Files
Aislo/resources/tester/helper_130_b07_plan_manage.cjs
T
eomsangdonandClaude Opus 5.5 c2ed0fad54 feat(512-1 B07 유역도 등고선 간격): 유역도 등고선 5 m 기본 · 평면도 관리 모달에서 10 m 고름(1 m 없음 · B05 · B06 전용) — 요구 326
- 원인 확인: 유역도 23 MB = 1 m 아님 · 도엽 N3L 5 m 주곡선 그대로(등고선 층이 응답 대부분)
- 유역도 종류 파일 contour_interval_m(5 · 10) · 배경선 = 간격 배수 높이만 · 10 m = 23.17 → 11.91 MB
- 지문: 종류 목록 지문에서 간격 뺌 → 간격 바꾸면 유역도만 다시 받음 · 기본 종류 조각 차례 맞춤(파일로 내려도 지문 그대로)
- 시험: test_512_1_b07_basin_contour 새로 · test_b07_plan_kinds · helper_130 새 칸 · test_cad_host_tabs 508-1f 뒤 ☰ 자리 단언 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:56:06 +09:00

413 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 { makeLoader } = require("./helper_ts_stub_loader.cjs");
const ROOT = path.join(__dirname, "..", "..");
const B07 = path.join(ROOT, "B07_DesignDetail");
// ── 화면 그릇 흉내 ─────────────────────────────────────────────────────────────
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() {
const event = { stopPropagation() {}, preventDefault() {} };
(this.listeners.click || []).forEach((handler) => handler(event));
}
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_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,
contour_intervals: [5, 10], // 512-1 유역도 등고선 간격(서버 `_state` 와 같은 칸)
default_contour_interval: 5,
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);
},
};
const { loadTs } = makeLoader({
root: ROOT,
requireFor: (full) => (request) => {
if (STUBS[request]) return STUBS[request];
// 공용 카드(269) = 진짜 모듈(가짜 그릇 위에서 돎)
if (request === "@ui/ui_template_collapsible")
return loadTs(path.join(ROOT, "ui_template", "ui_template_collapsible.ts"));
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}`);
},
});
const settle = () => new Promise((done) => setTimeout(done, 0));
const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Panels.ts"));
const { openPlanManager } = loadTs(path.join(B07, "B07_DesignDetail_UI_PlanManage.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,
// 280-5 평면도 ⚙ = 평면도 관리 모달(화면 조립 `openDrawingSettings` 와 같은 길)
(kind) => kind === "plan" && openPlanManager(plan),
);
const gear = () => section.querySelectorAll(".ui-collapsible__settings")[0];
const section = panel.querySelector(".b07-plan");
out.left = {
manage: section.querySelectorAll(".ui-collapsible__settings").length,
manageButton: 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,
};
// 단추 → 모달
gear().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
gear().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);
});