/* PLAN 344-4 단위검증 헬퍼 — 계산 묶음(`A00_Common/design/logic/design_pipeline.ts`) · 가게 칸 * (`design_store.ts`) · 등록표(`b_page_state.ts`)를 그 자리에서 트랜스파일해 Node 로 돌림. * 단계 함수는 가짜 — 부른 수 · 받은 범위 · 차례 번호만 적음. 결과 = 표준출력 JSON 한 벌. * 짝 = `test_344_4_design_pipeline.py`. */ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); const ROOT = path.join(__dirname, "..", ".."); function memoryStorage() { const map = new Map(); return { getItem: (key) => (map.has(key) ? map.get(key) : null), setItem: (key, value) => map.set(key, String(value)), removeItem: (key) => map.delete(key), key: (index) => [...map.keys()][index] ?? null, get length() { return map.size; }, }; } const session = memoryStorage(); globalThis.window = { sessionStorage: session, localStorage: memoryStorage() }; globalThis.fetch = async () => ({ ok: false }); const cache = new Map(); 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) => 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 DESIGN = path.join(ROOT, "A00_Common", "design"); const pipeMod = loadTs(path.join(DESIGN, "logic/design_pipeline.ts")); const storeMod = loadTs(path.join(DESIGN, "design_store.ts")); const page = loadTs(path.join(ROOT, "A00_Common", "b_page_state.ts")); const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); const plain = (range) => range && { ...range, toM: Number.isFinite(range.toM) ? range.toM : "Infinity" }; /** 가짜 단계 — 부른 수 · 범위 · 번호를 적고 입력 지문을 돌려줌. hold[name] 이 있으면 그 약속을 기다림. */ function fakeStages(log, hold = {}) { const stages = {}; for (const name of [...pipeMod.BROWSER_STAGES, ...pipeMod.AFTER_STAGES]) { stages[name] = (ctx) => { log.push({ name, gen: ctx.generation, range: plain(ctx.range), aborted: () => ctx.signal.aborted, }); const value = name === "follow" ? null : { name, gen: ctx.generation, inputs: JSON.stringify(ctx.inputs) }; if (name === "ground") value.stations = [{ key: "0", chainageM: 0 }]; return hold[name] ? hold[name](ctx).then(() => value) : value; }; } return stages; } const calls = (log, name) => log.filter((entry) => entry.name === name).length; const BASE = { route: { rev: 1 }, alignment: { station_offsets: {} }, cross: {}, pipes: [], structures: [], }; async function run() { const out = {}; // ① 처음 · 같은 입력 다시 안 셈 · 범위 전달 { const log = []; const pipe = pipeMod.createDesignPipeline({ stages: fakeStages(log) }); const events = []; pipe.subscribe((event) => events.push(event.kind === "stage" ? event.stage : `gen${event.generation}`), ); const first = await pipe.start(BASE); await pipe.settled(); const allOnce = Object.fromEntries( [...pipeMod.BROWSER_STAGES, ...pipeMod.AFTER_STAGES].map((n) => [n, calls(log, n)]), ); const firstRange = log.find((e) => e.name === "route").range; const same = pipe.update("grade", { station_offsets: {} }); const genAfterSame = pipe.generation; log.length = 0; const cross = await pipe.update( "cross", { "0+040": { mode: "both_fill" } }, { fromM: 40, toM: 40, keys: ["0+040"] }, ); await pipe.settled(); const crossRan = log.map((e) => e.name); const crossRange = log.find((e) => e.name === "cross").range; log.length = 0; await pipe.update("grade", { station_offsets: { "100.000": 0.5 } }, { fromM: 80, toM: 120 }); await pipe.settled(); const gradeRan = log.map((e) => e.name); const gradeRange = log.find((e) => e.name === "profile").range; log.length = 0; await pipe.update("route", { rev: 2 }, { fromM: 120, toM: Infinity }); await pipe.settled(); const routeRan = log.map((e) => e.name); const routeRange = log.find((e) => e.name === "stations").range; out.basic = { first: { outcome: first.outcome, skipped: first.skipped, allOnce, firstRange }, same: { result: same, generation: genAfterSame }, crossRan, crossRange, crossSkipped: cross.skipped, gradeRan, gradeRange, routeRan, routeRange, events: events.slice(0, 3), }; } // ② 늦은 응답 버림 — 브라우저 단계(ground 늦음) · 뒤 단계(haul 늦음 · 끊김) { const log = []; let releaseGround = null; let holdGround = true; const hold = { ground: () => holdGround ? new Promise((resolve) => (releaseGround = resolve)) : Promise.resolve(), }; const pipe = pipeMod.createDesignPipeline({ stages: fakeStages(log, hold) }); const commits = []; pipe.subscribe((e) => e.kind === "stage" && commits.push(`${e.stage}@${e.generation}`)); const runA = pipe.start(BASE); await tick(5); const release1 = releaseGround; holdGround = false; const runB = pipe.update("route", { rev: 2 }, { fromM: 0, toM: Infinity }); const recB = await runB; release1(); const recA = await runA; await pipe.settled(); out.lateBrowser = { a: recA.outcome, b: recB.outcome, groundGen: pipe.output("ground").gen, commits, }; // 뒤 단계 — haul 1 이 늦게 옴 · 그 사이 새 조작 → 끊김 · 버림 let releaseHaul = []; const hold2 = { haul: () => new Promise((resolve) => releaseHaul.push(resolve)) }; const log2 = []; const pipe2 = pipeMod.createDesignPipeline({ stages: fakeStages(log2, hold2) }); const commits2 = []; pipe2.subscribe((e) => e.kind === "stage" && e.stage === "haul" && commits2.push(e.generation)); await pipe2.start(BASE); const haul1 = log2.find((e) => e.name === "haul"); await pipe2.update("cross", { x: 1 }, { fromM: 0, toM: 0 }); const haul1Aborted = haul1.aborted(); releaseHaul[0](); await tick(1); releaseHaul[1](); await pipe2.settled(); out.lateAfter = { haul1Aborted, commits: commits2, haulGen: pipe2.output("haul").gen, generation: pipe2.generation, }; } // ③ 범위 합침 — cross 가 끝나기 전 새 조작 → 다음 cross 는 두 범위를 함께 셈 { const log = []; let release = null; let holdCross = false; const hold = { cross: () => (holdCross ? new Promise((r) => (release = r)) : Promise.resolve()), }; const pipe = pipeMod.createDesignPipeline({ stages: fakeStages(log, hold) }); await pipe.start(BASE); await pipe.settled(); log.length = 0; holdCross = true; const first = pipe.update( "grade", { station_offsets: { "50.000": 1 } }, { fromM: 40, toM: 60 }, ); await tick(10); holdCross = false; const second = pipe.update( "grade", { station_offsets: { "210.000": 1 } }, { fromM: 200, toM: 220 }, ); release(); const rec1 = await first; await second; await pipe.settled(); const profiles = log.filter((e) => e.name === "profile").map((e) => e.range); const crosses = log.filter((e) => e.name === "cross").map((e) => e.range); out.merge = { first: rec1.outcome, profiles, crosses }; out.mergeFn = plain( pipeMod.mergeRange( { sources: ["grade"], fromM: 40, toM: 60, keys: [] }, { sources: ["cross"], fromM: 10, toM: 20, keys: ["a"] }, ), ); } // ④ 고리 없음 — 알림 · 단계 안 입력 쓰기 막음 · follow 만 한 번 · 노선 · 횡단은 못 고침 { const log = []; const stages = fakeStages(log); let stageWrite = "unset"; let pipe = null; stages.planline = (ctx) => { stageWrite = pipe.update("pipe", [{ id: "p-loop" }]); return { gen: ctx.generation }; }; stages.follow = (ctx) => { log.push({ name: "follow", gen: ctx.generation }); return { pipes: [{ id: "p1", followed: ctx.inputs.route.rev }], route: { rev: 99 }, cross: { bad: 1 }, }; }; pipe = pipeMod.createDesignPipeline({ stages }); let notifyWrite = "unset"; pipe.subscribe((e) => { if (e.kind === "stage" && e.stage === "profile") notifyWrite = pipe.update("grade", { station_offsets: { loop: 1 } }); }); const warn = console.warn; const warnings = []; console.warn = (...args) => warnings.push(String(args[0])); await pipe.start(BASE); await pipe.settled(); const afterStart = { generation: pipe.generation, follow: calls(log, "follow"), stations: calls(log, "stations"), }; const stationsInputs = JSON.parse(pipe.output("stations").inputs); log.length = 0; await pipe.update("grade", { station_offsets: { "10.000": 1 } }, { fromM: 0, toM: 20 }); await pipe.settled(); const gradeRan = log.map((e) => e.name); console.warn = warn; out.loop = { stageWrite, notifyWrite, blocked: pipe.blockedWrites, afterStart, stationsPipes: stationsInputs.pipes, stationsRoute: stationsInputs.route, stationsCross: stationsInputs.cross, gradeRan, warnings: warnings.length, }; } // ⑤ 단계 시간 기록 — 마지막 RUN_LOG_SIZE 번 · 단계별 ms { const pipe = pipeMod.createDesignPipeline({ stages: fakeStages([]) }); await pipe.start(BASE); for (let i = 0; i < 25; i += 1) await pipe.update("grade", { station_offsets: { i } }, { fromM: i, toM: i + 1 }); await pipe.settled(); const runs = globalThis.__aisloPipeline.runs; const last = runs[runs.length - 1]; out.timing = { size: runs.length, limit: pipeMod.RUN_LOG_SIZE, last: { outcome: last.outcome, sources: last.sources, stageNames: Object.keys(last.stages), totalIsNumber: typeof last.totalMs === "number", }, }; } // ⑥ 가게 — 결과 칸 메모리 우선 · 멈춘 뒤 한 번 · 지우면 메모리도 · 노선 초안 즉시 · clearDrafts { const P = "proj-344"; const store = storeMod.useDesignStore(P, null); const key = (n, b = "result") => `aislo:${b}:${n}:${P}`; const stations = [{ key: "0", chainageM: 0 }]; store.set("stations", stations); const beforeFlush = session.getItem(key("design-stations")); const memoryRead = store.get("stations") === stations; await tick(storeMod.PERSIST_DELAY_MS + 50); const afterDelay = JSON.parse(session.getItem(key("design-stations"))); store.set("profile", { schema_version: 1 }); storeMod.flushDesignStore(); const flushed = JSON.parse(session.getItem(key("design-profile"))); store.set("generation", 7); const generationSession = [...Array(session.length).keys()] .map((i) => session.key(i)) .filter((k) => k.includes("generation")); page.clearResults(P); const cleared = { stations: store.get("stations"), profile: store.get("profile") }; store.set("footprint", { roadLeft: [] }); page.clearResults(P); await tick(storeMod.PERSIST_DELAY_MS + 50); const footprintRevived = session.getItem(key("design-footprint")); store.set("routeDraft", { rev: 3, nodes: [ [0, 0], [10, 0], ], }); const draftNow = JSON.parse(session.getItem(key("route-draft", "draft"))); const hasDraft = page.hasDrafts(P); page.clearDrafts(P); const draftCleared = { session: session.getItem(key("route-draft", "draft")), store: store.get("routeDraft"), }; const pipe = pipeMod.createDesignPipeline({ stages: fakeStages([]) }); pipe.subscribe(pipeMod.publishToStore(store)); await pipe.start(BASE); await pipe.settled(); out.store = { beforeFlush, memoryRead, afterDelay, flushed, generation: store.get("generation"), generationSession, cleared, footprintRevived, draftNow, hasDraft, draftCleared, published: { generation: store.get("generation"), stations: store.get("stations"), profileGen: store.get("profile").gen, }, registry: { draft: page.STATE_REGISTRY["route-draft"], stations: page.STATE_REGISTRY["design-stations"], profile: page.STATE_REGISTRY["design-profile"], footprint: page.STATE_REGISTRY["design-footprint"], }, }; } return out; } run() .then((out) => { process.stdout.write(JSON.stringify(out)); process.exit(0); }) .catch((error) => { process.stderr.write(String(error && error.stack ? error.stack : error)); process.exit(1); });