/* PLAN 322-0 전수 표 시험 헬퍼 — 42종 × 견본(구조물 · 관 지점 · 빈칸)마다 지금 화면 함수 결과를 표준출력 JSON 한 벌로. * 측점(`structureStationMarks` · `pipeStationMarks`) · 카드 이름 · 형상 열쇠(`Section_Store` 초안 얹기 → 미리 뜬 횡단 · * 벽 · 노면 · 맹암거) · 3D 몸체(`buildCorridorStructures`) · 알약 띠(`buildStructureLane`) · 고르기 목록(B05 · B06 `pickGroups`). * 입력 = argv[2] JSON(서버 꼴 types · 정의 · 견본 · 격자 · 맨 횡단) — 파이썬 짝이 만듦. 짝 = `test_322_0_golden.py`. * 진짜 TS 를 그 자리에서 트랜스파일해 Node 로 돌림(서버 부르는 곳 · 무거운 화면 모듈만 흉내). */ const fs = require("fs"); const path = require("path"); const ROOT = path.join(__dirname, "..", ".."); const { makeLoader } = require("./helper_ts_stub_loader.cjs"); const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); const clone = (v) => JSON.parse(JSON.stringify(v)); /* ── 브라우저 흉내 ─────────────────────────────────────────────────────── */ class FakeStorage { constructor() { this.map = new Map(); } get length() { return this.map.size; } key(i) { return [...this.map.keys()][i] ?? null; } getItem(k) { return this.map.has(k) ? this.map.get(k) : null; } setItem(k, v) { this.map.set(k, String(v)); } removeItem(k) { this.map.delete(k); } clear() { this.map.clear(); } } class FakeElement { constructor(tag) { this.tagName = tag; this.className = ""; this.style = {}; this.dataset = {}; this.children = []; this.textContent = ""; this.classList = { toggle: () => {}, add: () => {}, remove: () => {}, contains: () => false }; } append(...nodes) { this.children.push(...nodes); } prepend(...nodes) { this.children.unshift(...nodes); } appendChild(node) { this.children.push(node); return node; } addEventListener() {} setAttribute() {} } globalThis.window = { sessionStorage: new FakeStorage(), localStorage: new FakeStorage(), addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => true, requestAnimationFrame: (fn) => fn(), setTimeout, clearTimeout, location: { origin: "http://localhost", pathname: "/" }, }; globalThis.sessionStorage = window.sessionStorage; globalThis.localStorage = window.localStorage; globalThis.requestAnimationFrame = (fn) => fn(); const docListeners = new Map(); globalThis.document = { activeElement: null, hidden: false, visibilityState: "visible", addEventListener: (type, fn) => docListeners.set(type, fn), removeEventListener: (type, fn) => docListeners.get(type) === fn && docListeners.delete(type), createElement: (tag) => new FakeElement(tag), }; /** 서버 흉내 — 정의 판(`server.version`) · 종류 받은 횟수(`server.typeCalls`). */ const server = { version: input.definition.판, typeCalls: 0 }; globalThis.fetch = async (url) => { const at = String(url); if (at.includes("/structure-types")) server.typeCalls += 1; const body = at.includes("structure-definition/version") ? { 판: server.version } : at.includes("structure-definition") ? { ...clone(input.definition), 판: server.version } : at.includes("/route/structures") ? { status: "success", project_id: PROJECT, revision: 0, structures: [] } : { status: "success", schema_version: 1, types: clone(input.types) }; return { ok: true, status: 200, json: async () => body }; }; /* ── TS 싣기 — 흉내 파일만 갈음 ──────────────────────────────────────────── */ const file = (...parts) => path.resolve(ROOT, ...parts); const B05 = (name) => file("B05_Profile", `${name}.ts`); const B06 = (name) => file("B06_Section", `${name}.ts`); const PROJECT = "P322"; let pendingPipes = null; const STUBS = new Map([ [ B06("B06_Section_Api_Fetch"), { fetchSectionDetail: async () => currentDetail(), saveSections: async () => ({}), previewStructureSections: async (_p, chainages) => chainages.map((at) => ({ ...plainAt(at), kind: "irregular" })), }, ], [B06("B06_Section_Cross_Refresh"), { USER_TOUCHED_KEYS: [] }], [ B06("B06_Section_Api_Culvert_Options"), { kindChangedChainages: () => [], STRUCTURE_ADJUST_KEYS: [] }, ], [ B06("logic/B06_Section_Structure_Layouts"), { applyStructureAreaRows: () => {}, structureAreaRows: () => [] }, ], [ B05("B05_Profile_Api_Pipes_Draft"), { readPendingPipes: () => pendingPipes, writePendingPipes: () => {} }, ], [ file("B04_PreProcess", "B04_PreProcess_Api_Fetch.ts"), { fetchDetailPipePoints: async () => ({ pipe_points: [] }), noteSnapshotFailed: () => {} }, ], [file("A00_Common", "touch_bridge.ts"), { pickPx: () => 6 }], [file("ui_template", "ui_template_toast.ts"), { showToast: () => {} }], [file("ui_template", "ui_template_elements.ts"), { showToast: () => {} }], ]); const resolveFile = (base) => [base, `${base}.ts`, base.replace(/\.js$/, ".ts"), path.join(base, "index.ts")].find( (p) => fs.existsSync(p) && fs.statSync(p).isFile(), ) ?? `${base}.ts`; const { loadTs } = makeLoader({ root: ROOT, interop: true, fileStub: (full) => STUBS.get(full) ?? (full.endsWith(".css") ? {} : undefined), // .css = 화면 꼴(vite 몫) requireFor: (full) => (spec) => { if (spec === "three") return require(path.join(ROOT, "config", "node_modules", "three")); const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; for (const [prefix, dir] of Object.entries(alias)) if (spec.startsWith(prefix)) return loadTs(resolveFile(path.join(ROOT, dir, spec.slice(prefix.length)))); if (spec.startsWith(".")) return loadTs(resolveFile(path.join(path.dirname(full), spec))); return require(spec); }, }); /* ── 견본 횡단 ───────────────────────────────────────────────────────────── */ const plainAt = (at) => ({ ...clone(input.plain_section), chainage_m: at, kind: "regular" }); const currentDetail = () => ({ longitudinal: { stations: input.grid.map((at) => ({ chainage_m: at, kind: "regular" })) }, cross_sections: input.grid.map(plainAt), }); const frameAtOf = (sections) => (atM) => { const s = sections.reduce((b, x) => Math.abs(x.chainage_m - atM) < Math.abs(b.chainage_m - atM) ? x : b, ); const [lx, ly] = s.frame.left_xy; const along = atM - s.chainage_m; return { cx: s.center_x - ly * along, cy: s.center_y + lx * along, leftX: lx, leftY: ly, designZ: s.design?.design_elevation_m ?? null, }; }; /** 횡단 하나에 얹힌 구조물 그림 자료 — 열쇠 + 고르는 데 쓰는 값만(파이썬 짝 `_shape_of` 와 같은 꼴). */ const SHAPE_KEYS = [ "culvert", "ford", "box", "ford_pavement", "revetment", "pavement", "underdrain", ]; function shapeOf(section) { const out = {}; for (const key of SHAPE_KEYS) { const spec = section[key]; if (!spec) continue; if (key === "culvert") out[key] = { hidden_pipe: !!spec.hidden_pipe, body: !!spec.body }; else if (key === "revetment") out[key] = { type_id: spec.type_id ?? null, form: spec.form ?? null }; else if (key === "pavement") out[key] = spec.map((p) => p.type_id).sort(); else out[key] = true; } return out; } const at2 = (n) => Number(n).toFixed(2); const { structureStationMarks, pipeStationMarks } = loadTs( file("common_util", "common_util_structure_station.ts"), ); const { dropStaleStructureTypes, fetchStructureTypes, onStaleStructureTypes, pipesToStructureMarks, staleDefinitionNotice, writePendingStructures, } = loadTs(B05("B05_Profile_Api_Structures")); const { loadSectionDetail } = loadTs(B06("B06_Section_Section_Store")); const { forgetDraftStations } = loadTs(B06("B06_Section_Section_Drafts")); const { buildCorridorStructures } = loadTs(B05("logic/B05_Profile_Corridor_Structures")); const { computeRevetmentLayout } = loadTs(B06("logic/B06_Section_Cross_Revetment_Geom")); const { buildStructureLane } = loadTs(B05("B05_Profile_UI_Structures_Marks")); const { pickGroups } = loadTs(B05("B05_Profile_UI_Structures_Panel_Types")); const { structureDisplayName } = loadTs(B06("B06_Section_UI_Section_Common")); const { listPick } = loadTs(file("common_util", "common_util_structure_catalog.ts")); /** 415 M4 — 새 자리 = 계산 묶음 측점 갈아끼움 흉내(측점 계획 자리 중 상세에 없는 것 · 서버 안 물음). */ function swapIn(detail, types) { const plan = loadTs(file("common_util", "common_util_structure_station.ts")); const { registryDefaults } = loadTs(file("common_util", "common_util_culvert_sets.ts")); const structures = JSON.parse( window.sessionStorage.getItem(`aislo:draft:structures:${PROJECT}`) ?? "null", ); const planned = plan.planStations( pendingPipes ?? [], structures ?? [], types, registryDefaults(types), ); const near = (a, b) => Math.abs(a - b) < plan.SAME_STATION_M; for (const { chainage_m: at } of planned) if (detail.cross_sections.every((s) => !near(s.chainage_m, at))) detail.cross_sections.push({ ...plainAt(at), kind: "irregular" }); detail.cross_sections.sort((a, b) => a.chainage_m - b.chainage_m); } async function main() { const types = await fetchStructureTypes(true); const byId = new Map(types.map((type) => [type.type_id, type])); const out = { samples: {}, pick: {} }; // 고르기 목록 — 페이지가 넘기는 거르기 그대로(B05 = 켜진 것 · B06 = 전부) const pagePicks = { b05: listPick("b05"), b06: listPick("b06") }; // 페이지 거르기 = 표 목록(322-1) for (const [page, pick] of Object.entries(pagePicks)) { const seen = {}; for (const group of pickGroups(types, pick)) for (const member of group.types) seen[member.type.type_id] = group.hidden || member.hidden ? "hidden" : "shown"; for (const type of types) (out.pick[type.type_id] ??= {})[page] = seen[type.type_id] ?? "absent"; } let route = 0; for (const sample of input.samples) { route += 1; const type = byId.get(sample.type_id); const row = {}; // 측점 const marks = sample.pipe ? pipeStationMarks(sample.pipe) : structureStationMarks(sample.structure, type); row.station = marks.map((m) => `${m.role}@${at2(m.chainage_m)}`); // 초안 얹기(Section_Store) → 카드 이름 · 형상 열쇠 window.sessionStorage.clear(); forgetDraftStations(PROJECT); pendingPipes = sample.pipe ? [clone(sample.pipe)] : null; writePendingStructures(PROJECT, sample.structure ? [clone(sample.structure)] : null); swapIn(await loadSectionDetail(PROJECT, route, { force: true }), types); const detail = await loadSectionDetail(PROJECT, route); // 갈아끼움 뒤 다시 그림 = 캐시 다시 맞춤 const touched = detail.cross_sections.filter( (section) => section.structure || Object.keys(shapeOf(section)).length, ); row.cards = Object.fromEntries( touched.map((section) => [ at2(section.chainage_m), { title: section.structure ?? null, display: section.structure ? structureDisplayName(section.structure) : null, shape: shapeOf(section), }, ]), ); // 벽 물빼기(옹벽만 · 형상칸.weep) row.weep = touched .filter((section) => section.revetment) .some((section) => (computeRevetmentLayout(section, undefined)?.walls ?? []).some((wall) => wall.retaining), ); // 3D 몸체 const solids = touched.length ? buildCorridorStructures(touched, frameAtOf(touched)) : []; row.model3d = [...new Set(solids.map((s) => `${s.kind}:${s.key ?? ""}`))].sort(); // 알약 띠 const lane = buildStructureLane({ structures: sample.pipe ? pipesToStructureMarks([sample.pipe]) : [{ ...sample.structure, structure_id: "s1" }], types, x: (m) => m, chainageAt: (px) => px, maxChainageM: 400, widthPx: 400, axisWidthPx: 40, stationIntervalM: 20, selectedId: null, onSelect: () => {}, }); row.band = lane.children.filter((el) => el.className.startsWith("b05-structure__band")).length; out.samples[`${sample.type_id}/${sample.case}`] = row; } // 판 확인(322-0 ③) — 같은 판 = 캐시 그대로 · 바뀐 판 = 버리고 다시 받음 · 탭이 다시 보이면 알림 await fetchStructureTypes(true); server.typeCalls = 0; const same = await dropStaleStructureTypes(); await fetchStructureTypes(true); const sameCalls = server.typeCalls; let reloads = 0; const off = onStaleStructureTypes(() => (reloads += 1)); server.version += 1; docListeners.get("visibilitychange")?.(); await new Promise((resolve) => setTimeout(resolve, 0)); await fetchStructureTypes(true); const staleCalls = server.typeCalls - sameCalls; off(); // 판 알림(322-2) — 프로젝트 정본이 옛 판으로 적혔으면 한 번만 · 같은 판 · 판 없음은 안 알림 const old = server.version - 1; out.notice = [ staleDefinitionNotice(PROJECT, old), staleDefinitionNotice(PROJECT, old), staleDefinitionNotice(PROJECT, server.version), staleDefinitionNotice(PROJECT, null), ]; out.version = { same, sameCalls, reloads, staleCalls, removed: !docListeners.has("visibilitychange"), }; process.stdout.write(JSON.stringify(out)); } main().catch((error) => { process.stderr.write(String(error?.stack ?? error)); process.exit(1); });