Files
Aislo/resources/tester/helper_130_b07_plan_manage.cjs
T
eomsangdonandClaude Opus 5.5 6df477a522 feat(B07): 평면도 관리 모달 종류 드롭다운 상세 · 장 한 블록 · 끌어 놓기 종류 순서(140-2)
- 맨 위 [종류 ▼] 상세 폼 · × 대신 폼 안 [지우기]
- 평면도 장 = 종류마다 한 줄 [≡] 이름 [1][2] · 장 단추 누르면 미리보기 + 드롭다운 따라감
- 줄 끌어 놓기 = 종류 순서 · 놓는 즉시 PUT plan-kinds-order(reorder_kinds) · 번호 바뀐 장 확정 풂

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
2026-10-09 14:51:11 +09:00

414 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 130-1 · 140-2 [평면도 관리] 모달 헬퍼 — 좌측 「평면도」 묶음 · 모달(종류 추가 · 업데이트 · 지우기 · 장 미리보기 ·
* 드롭다운 따라가기 · 줄 끌어 순서)을
* Node 로 돌려 표준출력 JSON 한 벌로. TS 는 그 자리에서 트랜스파일 · 화면 그릇(DOM)은 쓰는 만큼만 흉내 ·
* 서버 길(`_Api_Fetch`)은 메모리 가짜 · 공용 모달 · CAD · 드롭다운 · 체크박스는 기록용 가짜.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const B07 = path.join(ROOT, "B07_DesignDetail");
const cache = new Map();
// ── 화면 그릇 흉내 ─────────────────────────────────────────────────────────────
class FakeNode {
constructor(tag) {
this.tagName = tag.toUpperCase();
this.children = [];
this.dataset = {};
this.listeners = {};
this._text = "";
this._classes = new Set();
this.value = "";
this.hidden = false;
const self = this;
this.classList = {
add: (...names) => names.forEach((n) => self._classes.add(n)),
contains: (name) => self._classes.has(name),
};
}
set className(value) {
this._classes = new Set(String(value).split(/\s+/).filter(Boolean));
}
get className() {
return [...this._classes].join(" ");
}
set textContent(value) {
this._text = String(value);
this.children = [];
}
get textContent() {
return this._text + this.children.map((c) => c.textContent).join("");
}
append(...kids) {
kids.forEach((k) => this.children.push(k));
}
replaceChildren(...kids) {
this.children = [...kids];
}
addEventListener(type, handler) {
(this.listeners[type] ||= []).push(handler);
}
click() {
(this.listeners.click || []).forEach((handler) => handler({}));
}
fire(type, event = {}) {
(this.listeners[type] || []).forEach((handler) => handler({ preventDefault() {}, ...event }));
}
getBoundingClientRect() {
return { top: 0, height: 20, left: 0, width: 100 };
}
/** `.a` · `.a .b`(자손) 만. */
querySelectorAll(selector) {
const parts = selector
.trim()
.split(/\s+/)
.map((p) => p.slice(1));
const found = [];
const walk = (node, depth) =>
node.children.forEach((kid) => {
let next = depth;
if (kid.classList && kid.classList.contains(parts[depth])) {
if (depth === parts.length - 1) found.push(kid);
else next = depth + 1;
}
walk(kid, next);
});
walk(this, 0);
return found;
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] ?? null;
}
}
const all = (node) => [node, ...node.children.flatMap(all)];
const byText = (node, tag, text) =>
all(node).filter((n) => n.tagName === tag && n.textContent === text);
globalThis.document = { createElement: (tag) => new FakeNode(tag) };
const confirms = [];
globalThis.window = { confirm: (message) => (confirms.push(message), true) };
// ── 공용 부품 가짜 ─────────────────────────────────────────────────────────────
const toasts = [];
const modals = [];
const cads = [];
const selects = [];
const STUBS = {
"@ui/ui_template_elements": {
showToast: (message, kind) => toasts.push([kind, message]),
createSelectField: (opts) => {
const root = new FakeNode("select");
const select = { value: opts.options[0]?.value ?? "", disabled: false };
const field = {
root,
select,
label: opts.ariaLabel,
change: (value) => ((select.value = value), opts.onChange && opts.onChange()),
setOptions: (options, value) => (select.value = value ?? options[0]?.value ?? ""),
};
selects.push(field);
return field;
},
},
"@ui/ui_template_checkbox": {
createCheckbox: (label, opts = {}) => {
const root = new FakeNode("label");
root.textContent = label;
return { root, input: { value: opts.value ?? "", checked: !!opts.checked } };
},
},
"@ui/ui_template_modal": {
openModal: (opts) => {
const body = new FakeNode("div");
const modal = { opts, body, open: true };
modals.push(modal);
opts.mount(body, () => (modal.open = false));
return { close: () => (modal.open = false) };
},
},
"@ui/cad_host/cad_host": {
createCadHost: (opts) => {
const cad = { opts, element: new FakeNode("div"), loads: [], destroyed: false };
cad.beginLoading = () => {};
cad.fail = (detail) => toasts.push(["error", detail]);
cad.load = (drawing, meta, frameEdit, fields, extra) =>
cad.loads.push({ drawing, meta, extra });
cad.destroy = () => (cad.destroyed = true);
cads.push(cad);
return cad;
},
},
"@ui/ui_template_collapsible": { attachCollapsible: () => {} },
"@ui/ui_template_locale": {
currentLanguageIndex: 0,
ui_locales: new Proxy({}, { get: (_, key) => [String(key)] }),
},
"../B05_Profile/B05_Profile_UI_Structures_Fields": {
stationFields: () => ({ wrap: new FakeNode("div"), write: () => {}, read: () => 0 }),
},
};
// ── 서버 길 가짜 — 종류 파일 · 장 목록이 메모리에 ───────────────────────────────
const calls = [];
const state = {
pieces: [
{ id: "terrain", name: "지형" },
{ id: "route", name: "노선" },
],
scales: [1000, 1200],
default_scale: 1200,
watershed_scale: 6000,
kinds: [
{ id: "k_terrain", name: "지형", pieces: ["terrain"], layout: "plan", scale: 1200 },
{ id: "k_route", name: "노선배치도", pieces: ["route"], layout: "plan", scale: 1200 },
],
marks: [],
station_interval_m: 20,
};
let confirmed = new Set(["plan_k_route"]);
/** 종류 하나 = 장 하나 · 「지형」만 두 장. */
const sheets = () =>
state.kinds.flatMap((kind) =>
(kind.id === "k_terrain" ? [1, 2] : [1]).map((n) => {
const id = n === 1 ? `plan_${kind.id}` : `plan_${kind.id}_${n}`;
return {
id,
kind: "plan",
label: kind.name,
chainage_m: null,
confirmed: confirmed.has(id),
plan_kind: kind.id,
plan_kind_name: kind.name,
};
}),
);
const copy = () => JSON.parse(JSON.stringify(state));
const API = {
fetchPlanKinds: async () => copy(),
fetchDesignDrawingList: async () => ({ drawings: [{ id: "cover", kind: "cover" }, ...sheets()] }),
fetchDesignDrawing: async (_, id) => (
calls.push(["fetchDrawing", id]),
{ id, confirmed: confirmed.has(id), drawing: { entities: [{ id }], layers: [] } }
),
addPlanKind: async (_, name, pieces, layout, scale) => {
calls.push(["add", name, pieces, layout, scale]);
state.kinds.push({ id: "k_new", name, pieces, layout, scale });
return copy();
},
updatePlanKind: async (_, id, name, pieces, scale) => {
calls.push(["update", id, name, pieces, scale]);
Object.assign(
state.kinds.find((k) => k.id === id),
{ name, pieces, scale },
);
return copy();
},
deletePlanKind: async (_, id) => {
calls.push(["delete", id]);
state.kinds = state.kinds.filter((k) => k.id !== id);
return copy();
},
savePlanMarks: async () => copy(),
reorderPlanKinds: async (_, ids) => {
calls.push(["reorder", ids]);
state.kinds = ids.map((id) => state.kinds.find((k) => k.id === id));
confirmed.delete("plan_k_route"); // 번호 바뀐 장 확정 풂(서버 몫 흉내)
return copy();
},
invalidateDesignDrawing: async (_, id) => {
calls.push(["invalidate", id]);
confirmed.delete(id);
},
};
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (request === "./B07_DesignDetail_Api_Fetch") return API;
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
throw new Error(`stub 없음: ${request}`);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const settle = () => new Promise((done) => setTimeout(done, 0));
const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Panels.ts"));
(async () => {
const out = {};
const closes = [];
const selected = [];
const plan = { projectId: "p1", onClosed: (changed, openId) => closes.push([changed, openId]) };
const panel = buildDrawingSidePanel(
[{ id: "cover", kind: "cover", label: "표지", confirmed: false }, ...sheets()],
(drawing) => selected.push(drawing.id),
undefined,
false,
plan,
);
const section = panel.querySelector(".b07-plan");
out.left = {
manage: byText(section, "BUTTON", "평면도 관리").length,
sheetButtons: section.querySelectorAll(".b07-drawing-button").map((b) => b.dataset.drawingId),
formFields: all(section).filter((n) => ["SELECT", "INPUT", "LABEL"].includes(n.tagName)).length,
remove: byText(section, "BUTTON", "×").length,
texts: section.textContent,
};
// 단추 → 모달
byText(section, "BUTTON", "평면도 관리")[0].click();
await settle();
const modal = modals[0];
const cad = cads[0];
const rows = () => modal.body.querySelectorAll(".b07-plan-manage__sheets .b07-plan-manage__row");
const kindRows = () => rows().map((row) => row.dataset.planKind);
const sheetIds = () =>
modal.body.querySelectorAll(".b07-plan-manage__sheet").map((b) => b.dataset.drawingId);
const kindPick = () => selects.filter((s) => s.label === "종류").at(-1);
const action = () =>
all(modal.body).find(
(n) => n.tagName === "BUTTON" && ["추가", "업데이트"].includes(n.textContent),
);
out.open = {
title: modal.opts.title,
dialogClass: modal.opts.dialogClass,
kinds: kindRows(),
sheets: sheetIds(),
hasKindForm: !!kindPick(),
hasMarks: all(modal.body).some((n) => n.textContent === "대피소 · 사토장 자리"),
preview: cad.loads.at(-1).drawing.entities[0].id,
extra: cad.loads.at(-1).extra,
};
// 종류 추가 — 「새 종류」 · 이름 · 조각 하나
kindPick().change("");
all(modal.body).find((n) => n.tagName === "INPUT" && n.placeholder === "종류 이름").value =
"sub6임시";
action().click();
await settle();
await settle();
out.add = { kinds: kindRows(), sheets: sheetIds(), call: calls.find((c) => c[0] === "add") };
// 업데이트 — 새 종류를 골라 이름 · 축척 바꿈
kindPick().change("k_new");
out.updateLabel = action().textContent;
all(modal.body).find((n) => n.tagName === "INPUT" && n.placeholder === "종류 이름").value =
"sub6임시2";
selects.filter((s) => s.label === "축척").at(-1).select.value = "1000";
action().click();
await settle();
await settle();
out.update = {
call: calls.find((c) => c[0] === "update"),
rowText: rows().find((row) => row.dataset.planKind === "k_new").textContent,
};
// 지우기 — 드롭다운에서 그 종류를 고르고 폼 안 [지우기]
kindPick().change("k_new");
out.removeHiddenForNew = (() => {
kindPick().change("");
return byText(modal.body, "BUTTON", "지우기")[0].hidden;
})();
kindPick().change("k_new");
byText(modal.body, "BUTTON", "지우기")[0].click();
await settle();
await settle();
out.remove = {
kinds: kindRows(),
call: calls.find((c) => c[0] === "delete"),
confirms: confirms.length,
};
// 미리보기 장 바꾸기 — 「지형 2장」 → 「노선배치도」(확정 배지만 · [확정 풀기] 없음 140-1)
const pickSheet = async (id) => {
modal.body
.querySelectorAll(".b07-plan-manage__sheet")
.find((b) => b.dataset.drawingId === id)
.click();
await settle();
};
await pickSheet("plan_k_terrain_2");
const head = () => modal.body.querySelector(".b07-plan-manage__head");
out.sheet2 = {
preview: cad.loads.at(-1).drawing.entities[0].id,
head: head().textContent,
active: modal.body
.querySelectorAll(".b07-plan-manage__sheet")
.filter((b) => b.dataset.active === "true")
.map((b) => b.dataset.drawingId),
sheetTexts: rows()[0]
.querySelectorAll(".b07-plan-manage__sheet")
.map((b) => b.textContent),
dropdown: kindPick().select.value,
activeRow: rows()
.filter((r) => r.dataset.active === "true")
.map((r) => r.dataset.planKind),
};
await pickSheet("plan_k_route");
out.routeHead = head().textContent;
out.routeDropdown = kindPick().select.value;
// 드롭다운으로 종류 고름 → 그 종류 첫 장 미리보기 + 줄 강조
kindPick().change("k_terrain");
await settle();
out.dropdownPick = {
preview: cad.loads.at(-1).drawing.entities[0].id,
activeRow: rows()
.filter((r) => r.dataset.active === "true")
.map((r) => r.dataset.planKind),
};
// 줄 끌어 놓기 — 제자리면 부르지 않음 · 「노선배치도」를 「지형」 위 반에 놓으면 앞으로(놓는 즉시)
const dragTo = async (fromId, toId, clientY) => {
const from = rows().find((r) => r.dataset.planKind === fromId);
const to = rows().find((r) => r.dataset.planKind === toId);
from.fire("dragstart", {});
to.fire("dragover", { clientY });
to.fire("drop", {});
from.fire("dragend");
await settle();
await settle();
};
await dragTo("k_route", "k_terrain", 15); // 아래 반 = 뒤 = 제자리
out.dragSame = calls.filter((c) => c[0] === "reorder").length;
await dragTo("k_route", "k_terrain", 5);
out.drag = {
call: calls.find((c) => c[0] === "reorder"),
kinds: kindRows(),
sheets: sheetIds(),
};
// 닫기 — 고친 게 있으니 changed = true · 미리보기 듣기 멈춤
modal.opts.beforeClose();
out.close = { closes: [...closes], destroyed: cad.destroyed, open: modal.open };
// 다시 열어 고치지 않고 [CAD 에서 열기] → changed = false · 그 장 id
byText(section, "BUTTON", "평면도 관리")[0].click();
await settle();
await settle();
const modal2 = modals[1];
modal2.body
.querySelectorAll(".b07-plan-manage__sheet")
.find((b) => b.dataset.drawingId === "plan_k_route")
.click();
await settle();
byText(modal2.body.querySelector(".b07-plan-manage__head"), "BUTTON", "CAD 에서 열기")[0].click();
out.openInCad = { closes: closes.slice(1), destroyed: cads[1].destroyed, open: modal2.open };
out.toasts = toasts;
process.stdout.write(JSON.stringify(out));
})().catch((error) => {
process.stderr.write(String(error && error.stack ? error.stack : error));
process.exit(1);
});