Files
Aislo/resources/tester/helper_280_5_b07_settings.cjs
T

566 lines
20 KiB
JavaScript

/* 280-5 · 281 B07 ⚙ 설정 · 저장 후 이동 헬퍼 — 좌측 목록 ⚙ 자리 · 누름(접힘 · 도면 열기 안 감) ·
* ⚙ 모달(평면도 = 평면도 관리 · 횡단 = 배치 칸 + 안내선 · 구조물 = 축척 없음 줄 · [원본에서 다시 그리기]) ·
* 저장 묶음(`_UI_Save` — 고친 탭만 · 실패 = 거짓 · 원본 되돌림)을 Node 로 돌려 표준출력 JSON 한 벌로.
* TS 는 그 자리에서 트랜스파일 · 화면 그릇은 쓰는 만큼만 흉내 · 서버 길 · 모달 · 단추는 기록용 가짜.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1)
const ROOT = path.join(__dirname, "..", "..");
const B07 = path.join(ROOT, "B07_DesignDetail");
const UI = path.join(ROOT, "ui_template");
const cache = new Map();
class FakeNode {
constructor(tag) {
this.tagName = tag.toUpperCase();
this.children = [];
this.parent = null;
this.dataset = {};
this.attrs = {};
this.listeners = {};
this._text = "";
this._classes = new Set();
this.value = "";
this.disabled = false;
const self = this;
this.classList = {
add: (...names) => names.forEach((n) => self._classes.add(n)),
remove: (...names) => names.forEach((n) => self._classes.delete(n)),
toggle: (name) =>
self._classes.has(name) ? self._classes.delete(name) : self._classes.add(name),
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("");
}
setAttribute(key, value) {
this.attrs[key] = String(value);
}
append(...kids) {
kids.forEach((k) => {
if (k.parent) k.parent.children = k.parent.children.filter((c) => c !== k);
k.parent = this;
this.children.push(k);
});
}
prepend(...kids) {
kids.forEach((k) => (k.parent = this));
this.children.unshift(...kids);
}
replaceWith(node) {
const at = this.parent.children.indexOf(this);
node.parent = this.parent;
this.parent.children[at] = node;
}
addEventListener(type, handler) {
(this.listeners[type] ||= []).push(handler);
}
/** 누름 — 올라가기(bubble)까지 · stopPropagation 이면 멈춤 */
click() {
if (this.disabled) return;
let stopped = false;
const event = { target: this, stopPropagation: () => (stopped = true), preventDefault() {} };
for (let node = this; node && !stopped; node = node.parent)
(node.listeners.click || []).forEach((handler) => handler(event));
}
closest(selector) {
const name = selector.slice(1);
for (let node = this; node; node = node.parent) if (node.classList.contains(name)) return node;
return null;
}
}
const all = (node) => [node, ...node.children.flatMap(all)];
const byClass = (node, name) => all(node).filter((n) => n.classList.contains(name));
globalThis.document = {
createElement: (tag) => new FakeNode(tag),
querySelectorAll: () => [],
};
const toasts = [];
const modals = [];
const plans = [];
const calls = [];
let failConfirm = false;
const SCALES = {
scales: {
longitudinal: { x: 1000, y: 200 },
cross: { x: 100, y: 100 },
mass_haul: { x: null, y: 50000 },
development: { x: null, y: null },
},
defaults: {
longitudinal: { x: 1000, y: 200 },
cross: { x: 100, y: 100 },
mass_haul: { x: null, y: 50000 },
development: { x: null, y: null },
},
ranges: {
longitudinal: { x: [100, 10000], y: [10, 2000] },
cross: { x: [10, 1000], y: [10, 1000] },
mass_haul: { x: [100, 20000], y: [1000, 1000000] },
development: { x: [10, 10000], y: [10, 10000] },
},
saved: { longitudinal: 2, cross: 0, mass_haul: 0, development: 0 },
};
let answer = true;
const button = (opts) => {
const node = new FakeNode("button");
node.className = "ui-btn";
node.textContent = opts.label;
node.disabled = !!opts.disabled;
if (opts.onClick) node.addEventListener("click", opts.onClick);
return node;
};
const STUBS = {
"@ui/ui_template_elements": {
createButton: button,
createInputField: (opts) => {
const root = new FakeNode("label");
const input = new FakeNode("input");
input.value = opts.value ?? "";
input.setAttribute("type", opts.type);
const label = new FakeNode("span");
label.className = "ui-field__label";
label.textContent = opts.label;
root.append(label, input);
const setError = (msg) => (input.error = msg ?? null);
return { root, input, setError };
},
showToast: (message, kind) => toasts.push([kind, message]),
showLoadingOverlay: () => calls.push(["overlay+"]),
hideLoadingOverlay: () => calls.push(["overlay-"]),
showConfirmDialog: async (message) => (calls.push(["ask", message]), answer),
},
"@ui/ui_template_checkbox": {
createCheckbox: (text) => {
const root = new FakeNode("label");
root.className = "ui-checkbox";
const input = new FakeNode("input");
input.checked = false;
root.append(input);
return { root, input };
},
},
"@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), new FakeNode("div"));
return { close: () => (modal.open = false) };
},
},
"@ui/ui_template_locale": {
currentLanguageIndex: 0,
ui_locales: new Proxy({}, { get: (_, key) => [String(key)] }),
},
"./B07_DesignDetail_UI_PlanManage": {
openPlanManager: (controls) => {
const plan = { controls, open: true };
plans.push(plan);
return { close: () => (plan.open = false) };
},
},
"./B07_DesignDetail_Api_Fetch": {
confirmDesignDrawing: async (projectId, id, drawing, table) => {
calls.push(["confirm", id, drawing.tag, table]);
if (failConfirm) throw new Error("서버 오류");
return { all_confirmed: true };
},
invalidateDesignDrawing: async (projectId, id) => calls.push(["invalidate", id]),
// 485-2 축척 — 범위 · 기본은 서버(`_Router_Support_Scale.py`)와 같은 값
fetchDrawingScales: async () => JSON.parse(JSON.stringify(SCALES)),
saveDrawingScales: async (projectId, scales) => calls.push(["scales", scales]),
},
};
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 (CATALOG.isCatalogRequest(request)) return CATALOG;
if (STUBS[request]) return STUBS[request];
if (request === "@ui/ui_template_collapsible")
return loadTs(path.join(UI, "ui_template_collapsible.ts"));
if (request.startsWith(".")) {
const target = path.join(path.dirname(full), `${request}.ts`);
return fs.existsSync(target) ? loadTs(target) : {};
}
return {};
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Panels.ts"));
const { attachCollapsible } = loadTs(path.join(UI, "ui_template_collapsible.ts"));
const { openDrawingSettings } = loadTs(path.join(B07, "B07_DesignDetail_UI_Settings.ts"));
const Save = loadTs(path.join(B07, "B07_DesignDetail_UI_Save.ts"));
const item = (id, kind, extra = {}) => ({ id, kind, label: id, confirmed: false, ...extra });
const drawings = [
item("cover", "cover"),
item("plan_k", "plan", { plan_kind: "k", plan_kind_name: "지형" }),
item("longitudinal", "longitudinal"),
item("cross_standard", "cross_standard"),
item("cross_s1", "cross"),
item("cross_s2", "cross"),
item("mass_haul", "mass_haul"),
item("structure_k", "structure"),
item("dev_k", "development", { plan_kind: "dk", plan_kind_name: "옹벽" }),
];
(async () => {
const out = {};
// ── 좌측 ⚙ 자리 ───────────────────────────────────────────────────────────
const settings = [];
const selected = [];
const panel = buildDrawingSidePanel(
drawings,
(d) => selected.push(d.id),
undefined,
false,
(kind) => settings.push(kind),
);
const cards = byClass(panel, "b07-drawing-group");
out.cardGears = cards
.filter((c) => byClass(c, "ui-collapsible__settings").length)
.map((c) => c.dataset.kind);
out.cardGearInTitle = cards.every((c) =>
byClass(c, "ui-collapsible__settings").every((g) =>
g.parent.classList.contains("ui-collapsible__title"),
),
);
const buttonGears = byClass(panel, "b07-drawing-button__settings");
out.buttonGears = buttonGears.map((g) => g.parent.dataset.drawingId);
out.buttonGearRole = buttonGears.map((g) => [g.tagName, g.attrs.role, g.tabIndex]);
out.coverGear = byClass(panel, "b07-drawing-button")
.filter((b) => b.dataset.drawingId === "cover")
.flatMap((b) => byClass(b, "b07-drawing-button__settings")).length;
// 누름 — 카드 ⚙ = 접힘 그대로 · 단추 ⚙ = 도면 안 엶 · 제목 누름은 여전히 접음
const before = cards.map((c) => c.classList.contains("is-collapsed"));
cards.forEach((c) => byClass(c, "ui-collapsible__settings")[0]?.click());
out.collapsedSame = cards.every((c, i) => c.classList.contains("is-collapsed") === before[i]);
buttonGears.forEach((g) => g.click());
out.settingsCalls = [...settings];
out.selectedAfterGear = [...selected];
const crossCard = cards.find((c) => c.dataset.kind === "cross");
const crossTitle = byClass(crossCard, "ui-collapsible__title")[0];
const wasCollapsed = crossCard.classList.contains("is-collapsed");
crossTitle.click();
out.titleClickToggles = crossCard.classList.contains("is-collapsed") !== wasCollapsed;
byClass(panel, "b07-drawing-button")
.find((b) => b.dataset.drawingId === "mass_haul")
.click();
out.buttonClickOpens = selected.at(-1) === "mass_haul";
// Enter 도 도면 안 엶
const enterBefore = selected.length;
const gear = buttonGears[0];
let stopped = false;
gear.listeners.keydown.forEach((h) =>
h({ key: "Enter", preventDefault() {}, stopPropagation: () => (stopped = true) }),
);
out.enterStops = stopped && selected.length === enterBefore;
// ── ⚙ 모달 ──────────────────────────────────────────────────────────────
let current = drawings[4]; // cross_s1
let redraws = 0;
const saved = [];
const guideToggle = button({ label: "안내선 숨기기" });
const deps = {
drawings,
current: () => current,
canRedraw: () => true,
redraw: async () => (redraws++, true),
plan: { projectId: "p1", onClosed: () => {} },
cross: {
layout: async () => ({ default: [4, 4], sheets: { 1: [3, 2] }, table_scale: 1.2 }),
save: (next, sheet) => saved.push([next, sheet]),
guideToggle,
},
};
const texts = (node) =>
all(node)
.map((n) => n._text)
.filter(Boolean);
openDrawingSettings("cross", deps);
await new Promise((r) => setTimeout(r, 0));
const cross = modals.at(-1);
const layoutNode = byClass(cross.body, "b07-cross-layout")[0];
const crossInputs = all(layoutNode).filter((n) => n.tagName === "INPUT");
out.crossModal = {
title: cross.opts.title,
labels: byClass(layoutNode, "ui-field__label").map((n) => n.textContent),
values: crossInputs.map((n) => n.value),
scaleTitle: texts(byClass(cross.body, "b07-scale")[0])[0],
buttons: all(layoutNode)
.filter((n) => n.tagName === "BUTTON")
.map((n) => [n.textContent, n.disabled]),
guideLast: byClass(cross.body, "b07-cross-layout")[0].children.at(-1) === guideToggle,
head: cross.opts.actions.map((n) => [n.textContent, n.disabled]),
notes: texts(cross.body).filter((t) => t.startsWith("지금 도면")),
};
crossInputs[0].value = "2";
all(layoutNode)
.find((n) => n.textContent === "이 장")
.click();
out.crossSave = { saved, closed: !cross.open };
openDrawingSettings("structure", deps);
const structure = modals.at(-1);
out.structureModal = {
title: structure.opts.title,
notes: texts(structure.body),
head: structure.opts.actions.map((n) => [n.textContent, n.disabled, n.title]),
};
openDrawingSettings("longitudinal", deps);
out.longModal = { title: modals.at(-1).opts.title, bodyKids: modals.at(-1).body.children.length };
current = drawings[1]; // plan_k
openDrawingSettings("plan", deps);
const plan = plans.at(-1);
out.planModal = {
viaManager: plans.length === 1 && modals.length === 3,
head: plan.controls.actions.map((n) => [n.textContent, n.disabled]),
};
plan.controls.actions[0].click();
await new Promise((r) => setTimeout(r, 0));
out.planRedraw = { redraws, closed: !plan.open };
current = drawings[3]; // 표준 횡단면도도 횡단 묶음
openDrawingSettings("cross", deps);
out.standardInCross = !modals.at(-1).opts.actions[0].disabled;
// ── 축척 칸(485-2) ─────────────────────────────────────────────────────
const closedCalls = [];
const scaleDeps = { ...deps, plan: { projectId: "p1", onClosed: (...a) => closedCalls.push(a) } };
const tick = () => new Promise((r) => setTimeout(r, 0));
const scaleOf = async (kind) => {
openDrawingSettings(kind, scaleDeps);
await tick();
const modal = modals.at(-1);
const section = byClass(modal.body, "b07-scale")[0];
const inputs = all(section).filter(
(n) => n.tagName === "INPUT" && !n.parent.classList.contains("ui-checkbox"),
);
const autos = byClass(section, "ui-checkbox").map((n) => n.children[0]);
const btn = (label) =>
all(section).find((n) => n.tagName === "BUTTON" && n.textContent === label);
const type = (input, value) => {
input.value = value;
input.listeners.input.forEach((h) => h());
};
const toggle = (box) => {
box.checked = !box.checked;
box.listeners.change.forEach((h) => h());
};
const state = () => ({
values: inputs.map((n) => n.value),
errors: inputs.map((n) => n.error ?? null),
disabled: inputs.map((n) => !!n.disabled),
autos: autos.map((n) => n.checked),
save: !btn("축척 저장").disabled,
});
return { modal, section, inputs, autos, btn, type, toggle, state };
};
const scale = {};
current = drawings[2]; // longitudinal
let s = await scaleOf("longitudinal");
scale.longLabels = byClass(s.section, "ui-field__label").map((n) => n.textContent);
scale.longStart = s.state();
s.type(s.inputs[0], "50");
scale.longLow = s.state();
s.type(s.inputs[0], "500.5");
scale.longFrac = s.state();
s.type(s.inputs[0], "500");
scale.longOk = s.state();
calls.length = 0;
answer = false;
s.btn("축척 저장").click();
await tick();
scale.cancel = { calls: calls.map((c) => c[0]), open: s.modal.open, closed: closedCalls.length };
answer = true;
calls.length = 0;
s.btn("축척 저장").click();
await tick();
await tick();
scale.save = {
ask: calls.find((c) => c[0] === "ask")?.[1],
sent: calls.find((c) => c[0] === "scales")?.[1],
open: s.modal.open,
closed: [...closedCalls],
};
s = await scaleOf("longitudinal");
s.type(s.inputs[1], "300");
s.btn("기본값").click();
scale.reset = s.state();
calls.length = 0;
const toastAt = toasts.length;
s.btn("축척 저장").click();
await tick();
scale.same = { calls: calls.length, toasts: toasts.slice(toastAt).map((t) => t[1]) };
s = await scaleOf("mass_haul");
scale.massStart = s.state();
s.toggle(s.autos[0]);
scale.massManual = s.state();
s.type(s.inputs[0], "2000");
scale.massTyped = s.state();
calls.length = 0;
s.btn("축척 저장").click();
await tick();
await tick();
scale.massSent = {
ask: calls.filter((c) => c[0] === "ask").length,
sent: calls.find((c) => c[0] === "scales")?.[1],
};
s = await scaleOf("mass_haul");
s.toggle(s.autos[0]);
s.toggle(s.autos[0]);
scale.massBack = s.state();
s = await scaleOf("development");
scale.dev = s.state();
openDrawingSettings("structure", scaleDeps);
scale.structure = byClass(modals.at(-1).body, "b07-scale").length;
out.scale = scale;
const Check = loadTs(path.join(B07, "logic", "B07_DesignDetail_Scale_Check.ts"));
out.readAxis = [
["1,000", false],
[" 99 ", false],
["10001", false],
["12.5", false],
["", false],
["abc", false],
["", true],
].map(([raw, auto]) => Check.readScaleAxis(raw, auto, [100, 10000]));
out.toSend = [
Check.scaleToSend(200, 200),
Check.scaleToSend(500, 200),
Check.scaleToSend(null, null),
];
// ── 저장 묶음 ────────────────────────────────────────────────────────────
const tabsOf = (list, activeId) => ({
tabs: () => list,
active: () => list.find((t) => t.id === activeId),
markClean: (tab) => {
tab.dirty = false;
delete tab.drawing;
},
});
const storeOf = (over = {}) => {
let dirty = over.cadDirty ?? false;
const store = {
projectId: "p1",
drawings,
tabs: over.tabs,
current: () => over.current,
cadDirty: () => dirty,
setCadClean: () => (dirty = false),
requestCad: async () => ({ drawing: { tag: "live" }, quantityTable: { q: 1 } }),
isFrameEditing: () => !!over.frame,
forget: (id) => calls.push(["forget", id]),
reload: async (d) => calls.push(["reload", d.id]),
};
return store;
};
const reset = () => {
calls.length = 0;
drawings.forEach((d) => (d.confirmed = false));
};
// 고친 것 없음 = 요청 0 · 참
reset();
let ok = await Save.saveEditedDrawings(
storeOf({ current: drawings[4], tabs: tabsOf([{ id: "cross_s1" }], "cross_s1") }),
);
out.noEdits = { ok, calls: [...calls] };
// 지금 탭(CAD 편집) + 다른 고친 탭 = confirm 둘 · 안 고친 탭은 안 보냄
reset();
const list = [
{ id: "cross_s1", dirty: true },
{ id: "longitudinal", dirty: true, drawing: { tag: "stash" } },
{ id: "mass_haul" },
];
ok = await Save.saveEditedDrawings(
storeOf({ current: drawings[4], cadDirty: true, tabs: tabsOf(list, "cross_s1") }),
);
out.saveEdited = {
ok,
confirms: calls.filter((c) => c[0] === "confirm"),
clean: list.map((t) => !!t.dirty),
confirmed: drawings.filter((d) => d.confirmed).map((d) => d.id),
};
// 실패 = 거짓 · 알림
reset();
failConfirm = true;
const toastStart = toasts.length;
ok = await Save.saveEditedDrawings(
storeOf({
current: drawings[4],
cadDirty: true,
tabs: tabsOf([{ id: "cross_s1", dirty: true }], "cross_s1"),
}),
);
failConfirm = false;
out.saveFail = { ok, toasts: toasts.slice(toastStart).map((t) => t[0]) };
// 도각 편집 중 = 거짓 · 요청 0
reset();
ok = await Save.saveEditedDrawings(
storeOf({ current: drawings[4], cadDirty: true, frame: true, tabs: tabsOf([], "") }),
);
out.frameEditing = { ok, calls: calls.filter((c) => c[0] === "confirm").length };
// 원본에서 다시 그리기 — 저장본 = invalidate + 다시 실음 · 원본 그대로 = 꺼짐 · 물음 [취소] = 그대로
reset();
drawings[6].confirmed = true;
const mh = storeOf({ current: drawings[6], tabs: tabsOf([{ id: "mass_haul" }], "mass_haul") });
out.redrawable = Save.hasRedrawable(mh);
ok = await Save.redrawFromSource(mh);
out.redraw = {
ok,
calls: calls.filter((c) => c[0] !== "overlay+" && c[0] !== "overlay-").map((c) => c[0]),
confirmed: drawings[6].confirmed,
};
out.pristine = Save.hasRedrawable(mh);
answer = false;
reset();
drawings[6].confirmed = true;
ok = await Save.redrawFromSource(mh);
answer = true;
out.redrawCancel = { ok, invalidates: calls.filter((c) => c[0] === "invalidate").length };
process.stdout.write(JSON.stringify(out));
})().catch((error) => {
process.stderr.write(String(error && error.stack ? error.stack : error));
process.exit(1);
});