/* PLAN 424 단위검증 헬퍼 — 횡단 카드 중심점 = ▲▼(종단 계획고)만 · 좌우는 평면에서만(요구 260). * 화면 TS(`B06_Section_UI_Cross_Design_Pick` · `_Cross_Center`)를 트랜스파일해 Node 로 돌림(가짜 DOM = * `helper_370_2_toe_stop.cjs` 꼴) · 결과 = 표준출력 JSON 한 벌. 입력 = argv[2] 표준단면 JSON. 짝 = `test_356_b06_card_center.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(); const STUBS = { "@ui/ui_template_toast": { showToast: () => undefined }, "@ui/ui_template_elements": { showToast: () => undefined }, }; class FakeNode { constructor(tag) { this.tagName = String(tag); this.children = []; this.attrs = {}; this.listeners = {}; this.parent = null; this.hidden = false; this.title = ""; 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(""); } get childElementCount() { return this.children.length; } get isConnected() { return true; } _detach(kid) { if (kid.parent) kid.parent.children = kid.parent.children.filter((k) => k !== kid); kid.parent = this; } append(...kids) { kids.forEach((k) => { if (typeof k === "string") return; this._detach(k); this.children.push(k); }); } prepend(...kids) { kids.reverse().forEach((k) => { this._detach(k); this.children.unshift(k); }); } appendChild(kid) { this.append(kid); return kid; } insertBefore(kid, ref) { this._detach(kid); const at = ref ? this.children.indexOf(ref) : -1; if (at < 0) this.children.push(kid); else this.children.splice(at, 0, kid); return kid; } remove() { if (this.parent) this.parent.children = this.parent.children.filter((k) => k !== this); } get descendants() { return this.children.flatMap((c) => [c, ...c.descendants]); } /** `.이름` 한 개만 — 방향키 듣개가 슬롯 단추를 찾는 꼴(◀▶ 단추가 있으면 누름). */ querySelector(sel) { return this.descendants.find((n) => n.classList.contains(sel.slice(1))) ?? null; } getClientRects() { return this.classList.contains("is-hidden") ? [] : [{}]; } 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); } hasAttribute(name) { return name in this.attrs; } addEventListener(type, fn) { (this.listeners[type] ??= []).push(fn); } click() { const event = { stopPropagation: () => {}, preventDefault: () => {} }; (this.listeners.click ?? []).forEach((fn) => fn(event)); } } const docKeys = new Set(); // 방향키 듣개(379-1 · 정확히 0 자리) globalThis.document = { createElement: (tag) => new FakeNode(tag), createElementNS: (_ns, tag) => new FakeNode(tag), addEventListener: (type, fn) => type === "keydown" && docKeys.add(fn), removeEventListener: (type, fn) => type === "keydown" && docKeys.delete(fn), }; globalThis.window = { setTimeout, clearTimeout, addEventListener: () => {} }; 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]; if (request.endsWith(".css")) return {}; 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 pick = loadTs(B06("B06_Section_UI_Cross_Design_Pick")); const centerMod = loadTs(B06("B06_Section_UI_Cross_Center")); const { appendCrossDesignOverlay } = loadTs(B06("B06_Section_UI_Cross_Design")); const { computeCrossDesign } = loadTs( path.join(ROOT, "common_util", "common_util_cross_design.ts"), ); const standard = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); // 평지보다 조금 높은 지반 = 양쪽 절토(교차점 손잡이 없음 · 중심점 원 하나) const samples = Array.from({ length: 49 }, (_, i) => ({ offset_m: -12 + i * 0.5, elevation_m: 100.3, valid: true, })); const design = { ...computeCrossDesign(samples, 100, { standard, groundType: "soil", sectionMode: "both_cut" }), shoulder_extend_left_m: 0, shoulder_extend_right_m: 0, }; const section = { station_id: "S1", chainage_m: 20, samples, design }; const x = (o) => 300 - o * 20; const y = (e) => 400 - (e - 100) * 20; const layer = new FakeNode("g"); appendCrossDesignOverlay(layer, design, x, y, samples, undefined); layer.children.forEach((el) => el.setAttribute(pick.DESIGN_LINE_ATTR, "")); const picks = []; const changes = []; const moves = pick.designLineMoves(section, (_c, change) => changes.push(change)); const handle = pick.attachDesignLinePick({ layer, design, x, toDisplayY: y, onPick: (i) => picks.push(i), ...moves, }); const centers = layer.children.filter( (k) => k.classList.contains("b06-chart__center-hit") && !k.classList.contains("b06-chart__toe-hit"), ); const center = centers[0]; center.click(); handle.setActive(picks[0]); const row = handle.cell.children[1]; const press = (key) => { let prevented = false; const event = { key, target: null, preventDefault: () => (prevented = true) }; [...docKeys].forEach((fn) => fn(event)); return prevented; }; const out = { moves: { center: moves.center }, count: centers.length, cx: center ? Number(center.getAttribute("cx")) : null, x0: x(0), active: center.classList.contains("is-active"), title: handle.cell.children[0].textContent, value: row.children.at(-1).textContent, // 보이는 버튼(숨은 버튼 = 슬롯 없음) slots: row.children .filter((k) => /cell-btn--/.test(k.className) && !k.hidden) .map((k) => k.className.match(/cell-btn--(\w+)/)[1]), panelOpen: !handle.cell.classList.contains("is-empty"), // 방향키 — ◀▶ = 먹어 막음(아무것도 안 옮김) · ▲▼ = 안 막음(종단도 듣개 = 종단 계획고) keys: { left: press("ArrowLeft"), right: press("ArrowRight"), up: press("ArrowUp"), down: press("ArrowDown"), }, dragListeners: (center.listeners.pointerdown ?? []).length, changes: changes.length, exports: Object.keys(centerMod).sort(), }; process.stdout.write(JSON.stringify(out));