/* PLAN 370-3 단위검증 헬퍼 — B06 암 경계선 고름 · 방향키 · 세로 끌기 · 아래 띠 공용 칸. * `_Cross_Design_Pick.attachDesignLinePick` 을 그 자리에서 트랜스파일해 흉내 DOM 으로 돌림. 짝 = `test_370_3_rock_cell.py`. */ const fs = require("fs"); const path = require("path"); const { makeLoader } = require("./helper_ts_stub_loader.cjs"); const ROOT = path.join(__dirname, "..", ".."); class Node { constructor(tag) { this.tagName = tag; this.children = []; this.attrs = {}; this.listeners = {}; this.hidden = false; this.isConnected = true; this._text = ""; this._cls = new Set(); const self = this; this.classList = { add: (...n) => n.forEach((c) => self._cls.add(c)), toggle: (c, on) => ((on ?? !self._cls.has(c)) ? self._cls.add(c) : self._cls.delete(c)), contains: (c) => self._cls.has(c), }; } get descendants() { return this.children.flatMap((c) => [c, ...c.descendants]); } /** `.이름` 한 개만 — 패널 공용 부품이 슬롯 단추를 찾는 꼴. */ querySelector(sel) { return this.descendants.find((n) => n._cls.has(sel.slice(1))) ?? null; } getClientRects() { return this._cls.has("is-hidden") ? [] : [{}]; } set className(v) { this._cls = new Set(String(v).split(/\s+/).filter(Boolean)); } set textContent(v) { this._text = String(v); } get textContent() { return this._text + this.children.map((c) => c.textContent).join(""); } append(...kids) { this.children.push(...kids); } insertBefore(kid, ref) { this.children = this.children.filter((k) => k !== kid); const at = ref ? this.children.indexOf(ref) : -1; if (at < 0) this.children.push(kid); else this.children.splice(at, 0, kid); } setAttribute(n, v) { this.attrs[n] = String(v); if (n === "class") this.className = v; } getAttribute(n) { return this.attrs[n] ?? null; } hasAttribute(n) { return n in this.attrs; } addEventListener(t, fn) { (this.listeners[t] ??= []).push(fn); } click() { (this.listeners.click ?? []).forEach((fn) => fn({ stopPropagation: () => undefined })); } } const docListeners = {}; globalThis.document = { createElement: (t) => new Node(t), createElementNS: (_n, t) => new Node(t), addEventListener: (t, fn) => (docListeners[t] ??= []).push(fn), removeEventListener: (t, fn) => { docListeners[t] = (docListeners[t] ?? []).filter((f) => f !== fn); }, }; const key = (name) => [...(docListeners.keydown ?? [])].forEach((fn) => fn({ key: name, target: null, preventDefault: () => undefined }), ); const { loadTs } = makeLoader({ root: ROOT, requireFor: (full) => (r) => r.startsWith("@util/") ? loadTs(path.join(ROOT, "common_util", `${r.slice(6)}.ts`)) : loadTs(path.join(path.dirname(full), `${r}.ts`)), }); globalThis.Element = Node; const pick = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Design_Pick.ts")); const x = (o) => 100 - o * 12; // +offset = 화면 왼쪽 const y = (e) => 900 - (e - 100) * 12; const line = (pts, structure = false) => { const el = new Node("polyline"); el.setAttribute("class", "b06-chart__design-cross"); el.setAttribute("points", pts.map(([o, e]) => `${x(o)},${y(e)}`).join(" ")); if (!structure) el.setAttribute(pick.DESIGN_LINE_ATTR, ""); return el; }; /** 방향키 한 번 — 막혔나(preventDefault) 돌려줌. */ /** 방향키 한 번 — 막혔나(preventDefault) 돌려줌. */ const press = (name) => { let prevented = false; [...(docListeners.keydown ?? [])].forEach((fn) => fn({ key: name, target: null, preventDefault: () => (prevented = true) }), ); return prevented; }; // 끌기 — window 듣개(놓을 때 click 한 번 막음 포함) const winListeners = {}; globalThis.window = { addEventListener: (t, fn) => (winListeners[t] ??= []).push(fn), removeEventListener: (t, fn) => { winListeners[t] = (winListeners[t] ?? []).filter((f) => f !== fn); }, setTimeout: () => 0, clearTimeout: () => undefined, setInterval: () => 0, clearInterval: () => undefined, }; globalThis.setTimeout = () => 0; const fire = (type, event) => [...(winListeners[type] ?? [])].forEach((fn) => fn(event)); /** 암 측점 카드 — 좌 사면 · 노면 · 우 사면 + 암 경계선 점선 두 토막(결측으로 끊김). */ function card(rockOn = true) { docListeners.keydown = []; const design = { road_edges: { left: { offset_m: 2, elevation_m: 100 }, right: { offset_m: -2, elevation_m: 100 }, }, ditch: null, ditch_side: "left", ditch_enabled: false, section_mode: "both_fill", }; const layer = new Node("g"); layer.getScreenCTM = () => ({ a: 1, d: 1 }); layer.append( line([ [6, 97], [2, 100], ]), line([ [2, 100], [-2, 100], ]), line([ [-2, 100], [-6, 97], ]), ); const rockLine = (pts) => { const el = new Node("polyline"); el.setAttribute("class", "b06-chart__rock-boundary"); el.setAttribute("points", pts.map(([o, e]) => `${x(o)},${y(e)}`).join(" ")); el.style = { setProperty: (k, v) => (el.styleSet = v) }; return el; }; const rocks = [ rockLine([ [8, 96], [1, 98], ]), rockLine([ [-1, 98], [-8, 96], ]), ]; layer.append(...rocks); const offsets = []; const resets = []; const rock = { legend: "암 경계선", offsetM: -0.5, stepM: 0.1, upTitle: "위로", downTitle: "아래로", resetTitle: "기본", adjust: (d) => offsets.push(Math.round(d * 100) / 100), reset: () => resets.push(true), }; const values = { left: 0, right: 0 }; const picks = []; const handle = pick.attachDesignLinePick({ layer, design, x, toDisplayY: y, onPick: (i) => picks.push(i), shift: { valueOf: (s) => values[s], set: (s, v) => (values[s] = v) }, stationId: "R1", ...(rockOn && { rock }), }); const cell = handle.cell; const slot = (name) => cell.querySelector(`.b06-cross-card__cell-btn--${name}`); const state = () => ({ empty: cell.classList.contains("is-empty"), title: cell.children[0].textContent, value: cell.children[1].children.at(-1).textContent, slots: ["left", "up", "reset", "down", "right"].filter((n) => !slot(n).hidden), arrowSlots: ["left", "up", "reset", "down", "right"].filter((n) => slot(n).classList.contains(`b06-structure-panel__btn--${n}`), ), }); const hits = layer.children.filter((k) => k.classList.contains("b06-chart__design-hit")); const rockHit = hits.find((k) => k.classList.contains("b06-chart__rock-hit")); if (rockHit) rockHit.style = { setProperty: (k, v) => (rockHit.styleSet = v) }; return { handle, cell, slot, state, hits, rockHit, rocks, offsets, resets, values, picks }; } const out = {}; // ① 안 고름 — 공용 칸 = 빈 칸(479) · 숨은 단추 눌러도 암 안 움직임 · 방향키는 종단 계획고로 그대로 { const c = card(); c.handle.setActive(null); out.idle = { ...c.state(), upPassed: !press("ArrowUp") }; c.slot("up").click(); c.slot("down").click(); c.slot("reset").click(); out.idle.offsets = c.offsets; out.idle.resets = c.resets.length; } // ② 판정 선 — 점선 위 path 하나(끊긴 두 토막 · M 둘) · 보라 선 판정 클래스 · 키 rock { const c = card(); c.rockHit.click(); const index = c.picks[0]; out.hit = { found: !!c.rockHit, tag: c.rockHit?.tagName, moves: (c.rockHit?.getAttribute("d").match(/M/g) ?? []).length, below: c.hits.indexOf(c.rockHit) === 0, // 그리는 차례 = 보라 선 판정 아래(겹친 자리는 설계선) picked: c.picks, }; // ③ 고름 — 제목 · ▲▼↺ · ◀▶ 막음 · ▲▼ = 암만(종단으로 안 샘) c.handle.setActive(index); pick.setPickedLine("R1", index); out.picked = { ...c.state(), active: c.rockHit.classList.contains("is-active"), leftBlocked: press("ArrowLeft"), rightBlocked: press("ArrowRight"), upTaken: press("ArrowUp"), downTaken: press("ArrowDown"), sideValues: { ...c.values }, key: pick.pickedLineAt("R1") === index, }; out.picked.offsets = c.offsets; // ④ 세로 끌기 — 위로 0.34 m(화면 px = m × 12) → 0.3 m 한 번 · 끄는 동안 점선 · 판정 선만 옮겨 보임 let stopped = false; c.rockHit.listeners.pointerdown[0]({ button: 0, clientX: 0, clientY: 500, preventDefault: () => undefined, stopPropagation: () => (stopped = true), }); fire("pointermove", { clientX: 0, clientY: 500 - 0.34 * 12 }); const during = { rock: c.rocks[0].styleSet, hit: c.rockHit.styleSet, value: c.state().value }; const before = c.offsets.length; fire("pointerup", {}); out.drag = { stopped, during, sent: c.offsets.slice(before), clickSwallowed: (winListeners.click ?? []).length === 1, }; // 풀면 빈 칸(479) · 방향키 안 먹음 c.handle.setActive(null); out.released = { ...c.state(), upPassed: !press("ArrowUp") }; // 사면을 고르면 그 선 칸(제목 · ◀↺▶ · 값 한 줄) const slope = c.handle.pieces.findIndex((p) => Math.max(p.from.offset, p.to.offset) > 3); c.handle.setActive(slope); out.slope = c.state(); pick.setPickedLine("R1", null); } // ⑤ 안 고른 끌기는 안 감(카드 팬) · 토사(암 없음) = 빈 칸 · 판정 선 없음 { const c = card(); c.handle.setActive(null); let stopped = false; c.rockHit.listeners.pointerdown[0]({ button: 0, clientY: 0, preventDefault: () => undefined, stopPropagation: () => (stopped = true), }); out.idleDrag = { stopped }; const soil = card(false); soil.handle.setActive(null); out.soil = { ...soil.state(), rockHit: !!soil.rockHit }; } process.stdout.write(JSON.stringify(out));