/* PLAN 118-1 단위검증 헬퍼 — B06 배관 횡단도 관 고름 · 강조 · 조정창 숨김 · 3D pipe 키. * 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌. * 입력 = `fixtures/b07_cross_overlay_sections.json` 배관 측점 85.052(관 Φ1.0). 짝 = `test_118_pipe_pick.py`. */ 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(); let STUBS = { "@ui/ui_template_toast": { showToast: () => undefined }, "@ui/ui_template_elements": { showToast: () => undefined }, }; // ── 화면 그릇 흉내 — 오버레이 · 조정창이 쓰는 만큼만 ───────────────────────── class FakeNode { constructor(tag) { this.tagName = String(tag).toUpperCase(); this.children = []; this.attrs = {}; this.dataset = {}; this.listeners = {}; this._text = ""; this._classes = new Set(); 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), }; this.style = { setProperty: () => {} }; } 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)); } appendChild(kid) { this.children.push(kid); return kid; } replaceChildren(...kids) { this.children = kids; } setAttribute(name, value) { this.attrs[name] = String(value); if (name === "class") this.className = value; } getAttribute(name) { return name === "class" ? this.className : (this.attrs[name] ?? null); } addEventListener(type, fn) { (this.listeners[type] ??= []).push(fn); } removeEventListener() {} click() { let stopped = false; const event = { stopPropagation: () => (stopped = true), preventDefault: () => {} }; (this.listeners.click ?? []).forEach((fn) => fn(event)); return stopped; } querySelectorAll(selector) { const found = []; const walk = (node) => node.children.forEach((kid) => { if (kid.classList && kid.classList.contains(selector.slice(1))) found.push(kid); walk(kid); }); walk(this); return found; } querySelector(selector) { return this.querySelectorAll(selector)[0] ?? null; } getClientRects() { return []; } } globalThis.document = { createElement: (tag) => new FakeNode(tag), createElementNS: (_ns, tag) => new FakeNode(tag), createComment: () => new FakeNode("#comment"), addEventListener: () => {}, removeEventListener: () => {}, }; globalThis.window = { addEventListener: () => {}, removeEventListener: () => {} }; globalThis.requestAnimationFrame = (fn) => fn(); function loadTs(file) { const full = path.resolve(file); if (cache.has(full)) return cache.get(full); if (full.endsWith(".json")) { const data = JSON.parse(fs.readFileSync(full, "utf8")); cache.set(full, data); return data; } const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, esModuleInterop: true, }, }).outputText; const box = { exports: {} }; cache.set(full, box.exports); const localRequire = (request) => { if (STUBS[request]) return STUBS[request]; const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; for (const [prefix, dir] of Object.entries(alias)) if (request.startsWith(prefix)) return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length)))); return loadTs(resolve(path.join(path.dirname(full), request))); }; new Function("exports", "module", "require", js)(box.exports, box, localRequire); cache.set(full, box.exports); return box.exports; } const resolve = (base) => [base, `${base}.ts`, path.join(base, "index.ts")].find( (p) => fs.existsSync(p) && fs.statSync(p).isFile(), ) ?? `${base}.ts`; const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`); const { computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts")); const { appendCulvertOverlay } = loadTs(B06("B06_Section_UI_Cross_Culvert")); const { wallStandsAt } = loadTs(B06("B06_Section_UI_Cross_Culvert_Wire")); const { applyStructurePick } = loadTs(B06("B06_Section_UI_Page_Structure_Pick")); const fixture = JSON.parse( fs.readFileSync(path.join(__dirname, "fixtures", "b07_cross_overlay_sections.json"), "utf8"), ); const sections = fixture.cross_sections; const pipeSection = sections.find((s) => s.culvert && !s.culvert.hidden_pipe); const layout = computeStoredLayouts(pipeSection, sections).culvert; const out = {}; // ① 오버레이 — 판정면 차례 · 관 누름 · 강조 function overlay(hidePipe) { const layer = new FakeNode("g"); const picked = []; const setter = appendCulvertOverlay( layer, layout, (o) => -o * 10, (e) => -e * 10, (key) => picked.push(key), hidePipe, ); const hits = layer.children.filter((n) => n.classList.contains("b06-chart__culvert-revet-hit")); const pipes = layer.children.filter((n) => n.classList.contains("b06-chart__culvert-pipe")); const walls = layer.children.filter((n) => n.classList.contains("b06-chart__culvert-revet")); return { layer, picked, setter, hits, pipes, walls }; } const drawn = overlay(false); const firstHit = drawn.hits[0]; const stopped = firstHit.click(); const active = () => ({ pipes: drawn.pipes.map((p) => p.classList.contains("is-active")), walls: drawn.walls.map((w) => w.classList.contains("is-active")), }); drawn.setter("pipe"); const onPipe = active(); drawn.setter("outlet"); const onOutlet = active(); drawn.setter(null); const onNone = active(); // 판정면 차례 — 관 판정면이 벽 판정면들보다 먼저(아래) 얹힘 const order = drawn.layer.children .filter((n) => n.classList.contains("b06-chart__culvert-revet-hit")) .map((n) => (n === firstHit ? "pipe" : "wall")); // 관 판정면 = 외경 겹 꼭짓점과 같음 const outer = drawn.pipes.find((p) => p.classList.contains("b06-chart__culvert-pipe--outer")); out.overlay = { pipeCount: drawn.pipes.length, wallCount: drawn.walls.length, hitOrder: order, firstPicked: drawn.picked, stopped, hitMatchesOuter: firstHit.attrs.points === outer.attrs.points, onPipe, onOutlet, onNone, }; // 관을 숨긴 카드(링크 · 독립 흙막이 · 세월교 · 박스) — 관 판정면 없음 const hidden = overlay(true); hidden.hits.forEach((h) => h.click()); out.hiddenPipe = { pipes: hidden.pipes.length, picked: hidden.picked }; // ② 연동 카드 — 관은 주인 측점 카드에만 const neighbour = { ...pipeSection, chainage_m: pipeSection.chainage_m + 2 }; out.stands = { self: wallStandsAt(pipeSection, pipeSection, "pipe"), neighbour: wallStandsAt(pipeSection, neighbour, "pipe"), outletNeighbour: wallStandsAt(pipeSection, neighbour, "outlet"), }; // ③ B05 3D 관 고름(pipe 키) → B06 벽 고름 길로 function pick(key, extra = {}) { const calls = []; const target = { ...pipeSection, ...extra }; applyStructurePick( { at: target.chainage_m, key }, { cross_sections: [target] }, { focusStation: (id) => calls.push(["focus", id]), refreshCard: (c) => calls.push(["refresh", c]), revetSelect: (c, k) => calls.push(["revet", k]), fordSelect: (c, k) => calls.push(["ford", k]), boxSelect: (c, k) => calls.push(["box", k]), }, ); return calls.filter(([kind]) => kind !== "focus" && kind !== "refresh"); } out.pick = { pipe: pick("pipe"), basin: pick("basin"), fordPipe: pick("pipe", { ford: {} }), }; // ④ 조정창 — show("pipe") = 숨김 · side "pipe" · 조정 함수 안 불림 const base = loadTs(B06("B06_Section_UI_Cross_Panel_Base")); STUBS = { ...STUBS, "./B06_Section_UI_Section_Common": { L: (key) => key }, "./B06_Section_UI_Cross_Panel_Base": { ...base, bindDragHandle: () => ({ attach: () => {}, detach: () => {} }), }, }; const { buildStructurePanel } = loadTs(B06("B06_Section_UI_Cross_Structure_Panel")); const touched = []; const spy = (name, value) => (key) => { touched.push([name, key]); return value; }; const panelDeps = { scrollKey: "t118", adjustFor: spy("adjustFor", { x: 0, d: null, h: null, m: null }), nudge: spy("nudge"), nudgeSlope: spy("nudgeSlope"), nudgeHeight: spy("nudgeHeight"), heightFor: spy("heightFor", 2), formFor: spy("formFor", "돌쌓기(메)"), setForm: spy("setForm"), reset: spy("reset"), close: () => {}, structureFor: () => "revet", basinAdjustFor: () => ({ innerWidthM: 1, innerHeightM: 1.2, lateralM: 0, slopeM: 0 }), updateBasin: () => {}, nudgeBasinLateral: () => {}, resetBasin: () => {}, canNudge: spy("canNudge", true), fineMove: spy("fineMove", false), dragAxes: spy("dragAxes", null), extraState: () => ({ canAdd: false, count: 0 }), isLinkedCard: () => false, basinExtraState: () => ({ canAdd: false, count: 0 }), hiddenPipe: () => false, bodyGuard: () => false, setExtraCount: () => {}, setBasinExtraCount: () => {}, equalizeExtras: () => {}, spanFor: spy("spanFor", null), setSpan: () => {}, linkState: () => null, setLinked: () => {}, followGrade: () => true, setFollowGrade: () => {}, }; const panel = buildStructurePanel(panelDeps); panel.show("outlet"); const outletState = { hidden: panel.root.classList.contains("is-hidden"), side: panel.root.dataset.side, }; touched.length = 0; panel.show("pipe"); out.panel = { outlet: outletState, pipe: { hidden: panel.root.classList.contains("is-hidden"), side: panel.root.dataset.side, touched: touched.map(([name]) => name), }, }; panel.show(null); out.panel.none = { hidden: panel.root.classList.contains("is-hidden"), side: panel.root.dataset.side, }; process.stdout.write(JSON.stringify(out));