/* 구조물 폼 「집계 항목」 단위검증 헬퍼(PLAN 21-4) — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. * 화면 그릇(DOM)은 시험에 필요한 만큼만 흉내낸다. 결과는 표준출력에 JSON 한 벌. * * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다. */ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); const ROOT = path.join(__dirname, "..", ".."); const cache = new Map(); // ── 화면 그릇 흉내 — 요소 · 클래스 · 이벤트 · 글자만 ───────────────────────── class FakeNode { constructor(tag) { this.tagName = tag; this.children = []; this.dataset = {}; this.style = {}; this.listeners = {}; this._text = ""; this._classes = new Set(); this.title = ""; this.hidden = false; 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, force) => { const on = force === undefined ? !self._classes.has(name) : !!force; on ? self._classes.add(name) : self._classes.delete(name); return on; }, 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( typeof k === "string" ? Object.assign(new FakeNode("#text"), { _text: k }) : k, ), ); } replaceChildren(...kids) { this.children = []; this.append(...kids); } addEventListener(type, handler) { (this.listeners[type] ||= []).push(handler); } click() { (this.listeners.click || []).forEach((h) => h({ target: this })); } scrollIntoView() {} // 시험에 필요한 두 가지 선택자만 — `[data-option-keys]` · `.클래스` querySelectorAll(selector) { const match = selector.startsWith("[") ? (n) => n.dataset && n.dataset.optionKeys !== undefined : (n) => n.classList.contains(selector.slice(1)); const found = []; const walk = (node) => node.children.forEach((kid) => { if (kid.classList && match(kid)) found.push(kid); walk(kid); }); walk(this); return found; } } globalThis.document = { createElement: (tag) => new FakeNode(tag) }; globalThis.Option = function Option(text, value) { const node = new FakeNode("option"); node._text = String(text); node.value = value; return node; }; globalThis.requestAnimationFrame = (fn) => fn(); const session = new Map(); globalThis.window = { sessionStorage: { getItem: (k) => session.get(k) ?? null, setItem: (k, v) => session.set(k, String(v)), removeItem: (k) => session.delete(k), }, }; globalThis.sessionStorage = globalThis.window.sessionStorage; const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api", API_TIMEOUT_MS: 1000 }, }; 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]; // 화면 공용 부품(@ui/…) — 이 시험이 안 쓰는 것이라 뭘 불러도 아무 일 안 하는 함수로 막아 둠 if (request.startsWith("@ui/")) return new Proxy({}, { get: () => () => {} }); if (request.startsWith(".") && request.endsWith(".json")) { return JSON.parse(fs.readFileSync(path.join(path.dirname(full), request), "utf8")); } return request.startsWith(".") ? loadTs(path.join(path.dirname(full), `${request}.ts`)) : require(request); }; new Function("exports", "module", "require", js)(box.exports, box, localRequire); cache.set(full, box.exports); return box.exports; } const B05 = (name) => loadTs(path.join(ROOT, "B05_Profile", `${name}.ts`)); const item = loadTs(path.join(ROOT, "common_util", "common_util_summary_item.ts")); const logic = B05("B05_Profile_UI_Structures_Summary"); const merge = B05("B05_Profile_UI_Drainage_Facility_Merge"); const { buildSummaryRow } = B05("B05_Profile_UI_Structures_Summary_Row"); const { renderStructureList } = B05("B05_Profile_UI_Structures_List"); const { renderOptionFields } = B05("B05_Profile_UI_Structures_Panel_Options"); const commitModule = B05("B05_Profile_UI_Structures_Panel_Commit"); const { createHiddenOptions } = B05("B05_Profile_UI_Drainage_Facility_Hidden"); const { labeled } = B05("B05_Profile_UI_Drainage_Facility_Fields"); const out = {}; const A = { layer: "personal", owner: "삼성/홍길동", key: "SS000007", name: "돌기슭막이 메쌓기 1.2m", }; const B = { layer: "system", owner: null, key: "SS000004", name: "옹벽 콘크리트" }; // ── 순수 규칙 ─────────────────────────────────────────────────────────── out.visible = [ logic.isOptionVisible({}), logic.isOptionVisible({ visible: true }), logic.isOptionVisible({ visible: false }), ]; const before = { type_id: "wall", options: { H: 2, stone_kind: "화강암", back_length_m: 0.6, memo_only: "x" }, }; out.mergeSame = logic.mergeStructureOptions(before, "wall", ["H", "stone_kind"], { H: 3 }); out.mergeCleared = logic.mergeStructureOptions(before, "wall", ["H", "stone_kind"], {}); out.mergeTypeChange = logic.mergeStructureOptions(before, "fence", ["H"], { H: 1 }); out.mergeNoPrev = logic.mergeStructureOptions(null, "wall", ["H"], { H: 9 }); out.mergePrevNoOptions = logic.mergeStructureOptions({ type_id: "wall" }, "wall", ["H"], { H: 9 }); out.isItem = [ item.isSummaryItem(A), item.isSummaryItem({ layer: "x", owner: null, key: "K", name: "n" }), item.isSummaryItem(null), item.isSummaryItem({ ...A, key: "" }), ]; out.readItem = [item.readSummaryItem(A)?.key ?? null, item.readSummaryItem("nope")]; out.same = [ item.sameSummaryItem(A, { ...A, name: "이름만 바뀜" }), item.sameSummaryItem(A, B), item.sameSummaryItem(null, null), item.sameSummaryItem(A, null), ]; out.label = [item.summaryLabel(null), item.summaryLabel(A)]; out.tip = [item.summaryTooltip(null), item.summaryTooltip(A)]; // 관 계열 merge — 집계 항목 이어받기 · 지우기 const pipePrev = { facility: "pipe", options: { pipe_kind: "흄관", extra: 1 }, summary_item: A }; out.pipeKeep = merge.mergeFacilityOptions(pipePrev, { facility: "pipe", options: { pipe_kind: "VR관" } }) .summary_item ?? null; out.pipeClear = merge.mergeFacilityOptions(pipePrev, { facility: "pipe", options: {}, summary_item: null, }).summary_item; out.pipeSet = merge.mergeFacilityOptions(pipePrev, { facility: "pipe", options: {}, summary_item: B, }).summary_item.key; // 집계 항목 줄 const row = buildSummaryRow(); const clicks = []; row.bind({ pick: () => clicks.push("pick"), clear: () => clicks.push("clear") }); const [labelNode, nameNode, pickBtn, clearBtn] = row.root.children; out.rowEmpty = { name: nameNode.textContent, assigned: nameNode.classList.contains("is-assigned"), clearDisabled: clearBtn.disabled, get: row.get(), label: labelNode.textContent, pick: pickBtn.textContent, clear: clearBtn.textContent, }; row.set(A); out.rowSet = { name: nameNode.textContent, assigned: nameNode.classList.contains("is-assigned"), clearDisabled: clearBtn.disabled, title: nameNode.title, key: row.get().key, nameShowsKey: nameNode.textContent.includes("SS000007"), }; pickBtn.click(); clearBtn.click(); out.clicks = clicks; row.set(null); out.rowCleared = { name: nameNode.textContent, clearDisabled: clearBtn.disabled }; // 목록 줄 — 지정 색 · 툴팁 const list = new FakeNode("ul"); const types = new Map([ ["wall", { name: "옹벽" }], ["fence", { name: "울타리" }], ["pipe", { name: "배수관" }], ]); renderStructureList({ list, structures: [ { structure_id: "s1", type_id: "wall", placement: "point", chainage_m: 100, options: {}, summary_item: A, }, { structure_id: "s2", type_id: "fence", placement: "point", chainage_m: 200, options: {} }, ], pipeFacilities: [ { chainage_m: 300, facility: "pipe", summary_item: B }, { chainage_m: 400, facility: "pipe" }, ], typeMap: types, intervalM: 20, editingId: null, selectedPipeChainage: null, onSelectStructure() {}, onSelectPipe() {}, }); out.list = list.children.map((li) => ({ assigned: li.classList.contains("is-assigned"), title: li.title, })); // 옵션 칸 그리기 — visible:false 숨김 · 상세(detail) 는 includeDetail 일 때만 · 정의는 그대로 const opt = (key, extra = {}) => ({ key, label: key, input: "number", choices: [], unit: null, default: null, ...extra, }); const wallType = { type_id: "wall", options: [ opt("height_m"), opt("length_m", { visible: false }), opt("stone_kind", { phase: "detail", input: "select", choices: ["a", "b"] }), opt("back_len_cm", { phase: "detail", visible: false }), ], }; const draw = (includeDetail) => { const optionInputs = []; const optionRow = new FakeNode("div"); renderOptionFields( { optionInputs, optionRow, includeDetail, currentType: () => wallType, facilityFormKind: () => null, liveCommit() {}, syncOptionLock() {}, syncRangeDisplay() {}, syncSplitInputs() {}, }, { height_m: 2 }, ); return { keys: optionInputs.map((e) => e.key), hidden: optionRow.hidden }; }; out.drawB05 = draw(false); // B05 — 상세 칸 없음 + visible:false 숨김 out.drawB06 = draw(true); // B06 — 상세 칸까지, 단 visible:false 는 여기서도 숨김 out.definitionKept = wallType.options.map((o) => o.key); // 정의는 그대로 // [수정]/[추가] 저장 — 폼 값이 정본 기록으로 나가는 자리(Commit.commit)를 fake 폼으로 돌림 const saveType = { type_id: "wall", placement: "point", managed_by: null, options: [] }; async function saveWith({ previous, summary, formOptions, requiredEmpty = false, summaryOnly = false, }) { const structures = previous ? [previous] : []; let summaryValue = summary; let changed = null; const ctx = { callbacks: { onChange: (list) => (changed = list) }, structures, groupSelect: {}, memoField: { value: "" }, anchorFields: { read: () => 10, station: { focus() {} } }, startFields: {}, endFields: {}, facilityOptions: {}, optionInputs: Object.entries(formOptions).map(([key, value]) => ({ key, required: requiredEmpty, input: { focus() {} }, read: () => value, isEmpty: () => requiredEmpty, })), pipeFacilities: () => [], interval: () => 20, typeMap: () => new Map(), currentType: () => saveType, facilityFormKind: () => null, hasComputedRange: () => false, readLengthM: () => null, readBeforeM: () => 0, cancelTempPipe() {}, renderList() {}, renderOptionFields() {}, syncButtons() {}, syncFacilityForm() {}, syncPlacementFields() {}, syncTypeOptions() {}, editingId: () => previous?.structure_id ?? null, setEditingId() {}, selectedPipeChainage: () => null, setSelectedPipeChainage() {}, positionConfirmed: () => true, setPositionConfirmed() {}, tempPipeChainage: () => null, setTempPipeChainage() {}, summary: () => summaryValue, setSummary: (value) => (summaryValue = value), }; if (summaryOnly) { const done = commitModule.commitSummaryOnly(ctx); return { done, record: changed ? changed[0] : null }; } await commitModule.commit(ctx, true); return changed ? changed[0] : null; } const prev = () => ({ structure_id: "s1", type_id: "wall", placement: "point", chainage_m: 10, options: { H: 2, hidden_cost_key: "keep", detail_only: 9 }, memo: "", placement_source: "manual", status: "draft", revision: 0, geometry: null, summary_item: A, }); // 시설 폼 끈 옵션 가리기(PLAN 21-10 흠 8) { const tagged = labeled("관종", new FakeNode("select"), "pipe_kind"); const multi = labeled("구조", new FakeNode("select"), ["inlet_type", "inlet_structure"]); const plain = labeled("메모", new FakeNode("select")); out.labelTag = [tagged.dataset.optionKeys, multi.dataset.optionKeys, plain.dataset.optionKeys]; const root = new FakeNode("div"); const rowA = new FakeNode("div"); rowA.className = "b05-structure__grid"; const kind = labeled("관종", new FakeNode("select"), "pipe_kind"); const dia = labeled("관경", new FakeNode("select"), "pipe_diameter_mm"); rowA.append(kind, dia); const rowB = new FakeNode("div"); rowB.className = "b05-structure__grid"; const inlet = labeled("구조", new FakeNode("select"), ["inlet_type", "inlet_structure"]); const free = labeled("메모", new FakeNode("select")); // 표식 없는 칸은 안 가림 rowB.append(inlet, free); root.append(rowA, rowB); const hiddenOf = () => ({ kind: kind.classList.contains("is-option-hidden"), dia: dia.classList.contains("is-option-hidden"), rowA: rowA.classList.contains("is-option-hidden"), inlet: inlet.classList.contains("is-option-hidden"), free: free.classList.contains("is-option-hidden"), rowB: rowB.classList.contains("is-option-hidden"), }); const hidden = createHiddenOptions(root); hidden.set("pipe", ["pipe_kind"]); out.hideOne = hiddenOf(); hidden.set("pipe", ["pipe_kind", "pipe_diameter_mm"]); out.hideRow = hiddenOf(); // 줄의 칸이 다 가려지면 줄째 hidden.set("pipe", ["inlet_structure"]); out.hideInletPartial = hiddenOf(); // 키가 여럿인 칸은 하나만 꺼지면 그대로 hidden.set("pipe", ["inlet_type"]); out.hideInlet = hiddenOf(); // 유입구 「구조」는 inlet_type 을 끄면 함께 가려짐(구조 키 포함) hidden.set("pipe", []); out.showAll = hiddenOf(); // [모두 보임] 뒤 되돌아옴 // 숨긴 칸은 폼이 받은 옛 값 그대로 — 칸에서 읽은 값(제안값 채우기 포함)이 아니라 hidden.remember({ pipe_kind: "흄관", pipe_diameter_mm: 1000, other: 1 }); hidden.set("pipe", ["pipe_kind", "inlet_type"]); const read = { pipe_kind: "VR관", pipe_diameter_mm: 800, inlet_type: "제안", other: 2 }; hidden.restore(read); out.restored = read; hidden.remember({}); // 옛 값이 없던 숨긴 칸은 값이 안 실림 const read2 = { pipe_kind: "VR관", pipe_diameter_mm: 800 }; hidden.restore(read2); out.restoredEmpty = read2; out.revetKeys = [...merge.hiddenFormKeys("revetment", ["form", "height_m", "side"])].sort(); out.pipeKeys = [...merge.hiddenFormKeys("pipe", ["inlet_type"])].sort(); out.otherKeys = [...merge.hiddenFormKeys("box_culvert", ["form"])].sort(); } async function main() { const fresh = await saveWith({ previous: null, summary: null, formOptions: { H: 3 } }); out.commitNew = { hasKey: "summary_item" in fresh, options: fresh.options }; const cleared = await saveWith({ previous: prev(), summary: null, formOptions: { H: 3 } }); out.commitCleared = { hasKey: "summary_item" in cleared, options: cleared.options }; const kept = await saveWith({ previous: prev(), summary: A, formOptions: { H: 3 } }); out.commitKept = { key: kept.summary_item?.key, options: kept.options }; const swapped = await saveWith({ previous: prev(), summary: B, formOptions: { H: 3 } }); out.commitSwapped = swapped.summary_item?.key; // 집계 항목만 바뀐 때(PLAN 21-10 흠 2) — 필수 옵션이 비어 폼 검증에 걸려도 기록에 바로 반영 const blocked = await saveWith({ previous: prev(), summary: B, formOptions: { H: 3 }, requiredEmpty: true, }); out.formBlocks = blocked === null; // 종전 흐름(commit)은 필수 옵션이 비면 아무것도 안 함 const only = await saveWith({ previous: prev(), summary: B, formOptions: { H: 3 }, requiredEmpty: true, summaryOnly: true, }); out.summaryOnlyAssign = { done: only.done, key: only.record?.summary_item?.key, options: only.record?.options, }; const onlyClear = await saveWith({ previous: prev(), summary: null, formOptions: { H: 3 }, requiredEmpty: true, summaryOnly: true, }); out.summaryOnlyClear = { done: onlyClear.done, hasKey: "summary_item" in onlyClear.record }; const onlyNew = await saveWith({ previous: null, summary: B, formOptions: { H: 3 }, summaryOnly: true, }); out.summaryOnlyNew = onlyNew.done; // 새 항목은 [추가] 몫 — 반영 안 함 process.stdout.write(JSON.stringify(out)); } main();