/* PLAN 346-1 단위검증 헬퍼 — 계산 묶음을 페이지 · 노선 편집에 붙인 것. * 진짜 TS(`design_pipeline` · `design_pipeline_page` · `design_pipeline_stations` · `logic/*Pipeline*` · * `_RouteEdit_Draft` · sub1 · sub8 · sub6 짝)를 그 자리에서 트랜스파일해 Node 로 돌림. 서버 창구 * (`Api_Replan` · `Api_Structures` · `Api_TerrainGrid`)만 가짜. 입력 argv[2] JSON · argv[3] 격자 이진. * 결과 = 표준출력 JSON 한 벌. 짝 = `test_346_1_pipeline_page.py`. */ const fs = require("fs"); const path = require("path"); const { makeLoader } = require("./helper_ts_stub_loader.cjs"); const CATALOG = require("./helper_322_catalog_module.cjs"); const ROOT = path.join(__dirname, "..", ".."); const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); 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 }); // 계산 자리 「서버」 길(511-A) — 판 both 그대로 const CALC_STUBS = { design_calc_sites: { calcOnServerNow: () => false, calcProjectId: () => "" }, design_calc_server: { calcChecked: (_key, _id, fn, args) => fn(...args), calcOnServerOnly: async () => undefined, trialRequest: () => ({}), }, }; const STUBS = { ...CALC_STUBS }; const { load, loadTs } = makeLoader({ root: ROOT, requireFor: (full) => (request) => { if (CATALOG.isCatalogRequest(request)) return CATALOG; const key = Object.keys(STUBS).find((name) => request.endsWith(name)); if (key) return STUBS[key]; if (request.startsWith("@util/")) return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); return require(request); }, }); const sampler = load("common_util", "common_util_surface_sampler.ts"); const bytes = fs.readFileSync(process.argv[3]); const grid = sampler.parseDtmGrid( bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length), ); const local = { grid, design: "las" }; let remoteCalls = 0; STUBS["B05_Profile_Api_Replan"] = { fetchRoutePlan: async () => ({ planned: job.line, curves: [], min_radius_m: job.minRadius }), }; STUBS["B05_Profile_Api_Structures"] = { fetchStructures: async () => ({ structures: [] }), fetchStructureTypes: async () => job.types, }; STUBS["B05_Profile_Api_TerrainGrid"] = { designTerrainGridNow: () => local, loadDesignTerrainGrid: async () => local, groundStageDeps: () => ({ grid: () => local, remote: async () => (remoteCalls++, []) }), }; // 346-2 붙임 뒤 창구(관 목록 · 표준단면 · 흙 배분) — 이 시험은 노선 · 종단만 봄 STUBS["B04_PreProcess_Api_Fetch"] = { fetchDetailPipePoints: async () => ({ pipe_points: [] }) }; STUBS["B06_Section_UI_Standard_Panel"] = { effectiveStandardCross: () => null, readRockBoundaryDefault: () => -0.5, }; STUBS["B05_Profile_Api_HaulPlan"] = { createHaulStage: () => async () => null }; const pipeMod = load("A00_Common", "design", "logic", "design_pipeline.ts"); const pageMod = load("A00_Common", "design", "design_pipeline_page.ts"); const stationsMod = load("A00_Common", "design", "logic", "design_pipeline_stations.ts"); const routeGround = load("B05_Profile", "logic", "B05_Profile_Pipeline_RouteGround.ts"); const grade = load("B05_Profile", "logic", "B05_Profile_Pipeline_Grade.ts"); const gradeGround = load("B05_Profile", "logic", "B05_Profile_Grade_Ground.ts"); const footprint = load("B05_Profile", "logic", "B05_Profile_Footprint_Stage.ts"); const alignment = load("B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"); const draftLink = load("B05_Profile", "B05_Profile_UI_RouteEdit_Draft.ts"); const page = load("A00_Common", "b_page_state.ts"); const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); const n = job.nodes.length; const draftOf = (nodes) => ({ rev: 1, nodes, curveOn: Array(n).fill(true), curveRadius: Array(n).fill(null), curveLock: Array(n).fill(null), curveArc: Array(n).fill(null), stationSnap: false, terrain: null, }); const movedNodes = job.nodes.map((node, i) => (i === n - 1 ? [node[0] + 6, node[1] - 4] : node)); const inputs = (route = null, edits = alignment.emptyEdits()) => ({ route, alignment: edits, cross: null, pipes: [], structures: [], }); /** 단계 한 벌(실제) — 저장 기준은 부르는 쪽. */ function realStages(savedBase, remote = null) { return { route: routeGround.createRouteStage({ minRadiusM: () => job.minRadius, // 저장 노선 곡선표 = 같은 노드의 편집 곡선(407 곡선 측점 · 빈 표면 옮긴 뒤에만 MC 측점이 생김) savedLine: () => ({ line: job.line, curves: routeGround.draftLine(draftOf(job.nodes), job.minRadius).curves, }), }), follow: grade.followStage, stations: stationsMod.createStationsStage({ types: () => job.types }), ground: routeGround.createGroundStage( remote ? { grid: () => null, remote } : { grid: () => local, remote: async () => [] }, ), planline: grade.createPlanlineStage({ savedBase: () => savedBase, savedLine: () => job.line }), profile: grade.profileStage, footprint: footprint.footprintStage, }; } async function run() { const out = { n }; // ⓪ 저장 기준 — 저장 노선 지반으로 첫 계획선(서버 정책 그대로) const seed = pipeMod.createDesignPipeline({ stages: { ...realStages(null), planline: undefined, profile: undefined, footprint: undefined }, }); await seed.start(inputs()); const ground0 = seed.output("ground"); const samples = ground0.longitudinal.chainageM.map((c, i) => ({ chainage_m: c, elevation_m: ground0.longitudinal.z[i], })); const profile0 = gradeGround.groundProfile(samples); const savedBase = { policy: job.policy, onlyExplicitCurves: false, basePvi: gradeGround.groundFollowingBasePvi({ samples, stations: ground0.stations.map((s) => s.chainageM), policy: job.policy, }), chainage: profile0.chainage, ground: profile0.ground, stations: ground0.stations.map((s) => ({ station_id: grade.stationIdOf(s.chainageM), chainage_m: s.chainageM, })), }; // ① 실제 단계 한 번 — 저장 노선 · 노선 한 점 옮김 · 계획고 한 점 { const pipe = pipeMod.createDesignPipeline({ stages: realStages(savedBase) }); const first = await pipe.start(inputs()); const savedPlanline = pipe.output("planline") === savedBase; const beforeLine = pipe.output("route").line; const beforeKeys = pipe.output("ground").stations.map((s) => [s.key, s.chainageM]); const draft = draftOf(movedNodes); const after = routeGround.draftLine(draft, job.minRadius).line; const range = grade.routeRange(beforeLine, after); const routeRun = await pipe.update("route", draft, range); const moved = { basePvi: pipe.output("planline").basePvi, stations: pipe.output("ground").stations.map((s) => [s.key, s.chainageM]), profileStations: pipe.output("profile").stations.length, footprint: pipe.output("footprint") !== null, }; const nodes = pipe.output("planline").basePvi.map((p) => p.chainage_m); const target = nodes[Math.floor(nodes.length / 2)]; const edits = { station_offsets: { [alignment.chainageKey(target)]: 0.8 }, curve_radii: {} }; const gRange = grade.gradeRange(alignment.emptyEdits(), edits, nodes); const gradeRun = await pipe.update("grade", edits, gRange); // 꼭짓점 높이(종단곡선은 그 아래로 둥글게 지나감) const pviAt = (profile) => profile.pvi.find((p) => Math.abs(p.chainage_m - target) < 1e-6).elevation_m; const raised = pviAt(pipe.output("profile")); const plain = pviAt(alignment.buildAlignment(pipe.output("planline"), alignment.emptyEdits())); out.real = { first: { outcome: first.outcome, totalMs: first.totalMs, stages: first.stages }, savedPlanline, range, routeRun: { outcome: routeRun.outcome, totalMs: routeRun.totalMs, stages: routeRun.stages, skipped: routeRun.skipped, }, savedBasePvi: savedBase.basePvi, moved, beforeKeys, gradeRange: gRange, target, gradeRun: { outcome: gradeRun.outcome, stages: Object.keys(gradeRun.stages), skipped: gradeRun.skipped, totalMs: gradeRun.totalMs, }, raised: raised - plain, log: globalThis.__aisloPipeline.runs.length, }; } // ② 늦은 응답 버림 — 격자 없음(서버 길) · 첫 지반 응답이 늦는 사이 노선을 또 옮김 { const waits = []; const remote = (points, _terrain, signal) => new Promise((resolve, reject) => { waits.push(() => resolve(points.map(([x, y]) => sampler.heightAt(grid, x, y)))); signal.addEventListener("abort", () => reject(new Error("aborted"))); }); const pipe = pipeMod.createDesignPipeline({ stages: realStages(savedBase, remote) }); const commits = []; pipe.subscribe((e) => e.kind === "stage" && commits.push(`${e.stage}@${e.generation}`)); const a = pipe.start(inputs(draftOf(movedNodes))); await tick(5); const b = pipe.update("route", draftOf(job.nodes), { fromM: 0, toM: Infinity }); await tick(5); waits.forEach((go) => go()); const ra = await a; await tick(5); waits.slice(1).forEach((go) => go()); const rb = await b; out.late = { a: ra.outcome, b: rb.outcome, commits, profileGen: commits.filter((c) => c.startsWith("profile")), }; } // ③ 편집기 다리 — 사진첩 감싸기 · 초안 되살림 { const told = []; const dirty = true; const fake = { commit: () => undefined, undo: () => ({ planned: [ [0, 0], [1, 1], ], curveOn: [true, true], curveRadius: [null, null], curveLock: [null, null], curveArc: [null, null], apexLock: [false, false], picked: -1, }), redo: () => null, canUndo: () => true, canRedo: () => false, isDirty: () => dirty, // 다리는 처음 ≠ 지금 노선일 때만 초안(445-2 `lineChanged`) — 가짜도 처음 · 지금을 다르게 둠 current: () => fake.undo(), first: () => ({ ...fake.undo(), planned: [ [0, 0], [9, 9], ], }), }; const bridge = { edited: (d) => told.push(d && d.nodes.length), saved: () => draftOf(job.nodes), }; const watched = draftLink.watch(fake, bridge, () => true); watched.commit({ ...fake.undo(), planned: [ [0, 0], [2, 2], [3, 3], ], }); watched.undo(); watched.redo(); const restored = []; draftLink.restore(bridge, (snap, message, record) => restored.push([snap.planned.length, record]), ); draftLink.restore({ saved: () => null }, () => restored.push("none")); out.draftLink = { told, restored, unwatched: draftLink.watch(fake, undefined, () => false) === fake, }; } // ④ 페이지 묶음 — 다리 → 노선 초안 캐시 칸 · 종단도 받기 · 떠남 · 다시 엶(초안 살아남음) · ▲▼ { const P = "proj-3461"; const adopted = []; let edits = alignment.emptyEdits(); const link = { pipelineSource: () => ({ base: savedBase, edits }), adoptPipeline: (base, nextEdits, draft) => adopted.push({ draft, saved: base === savedBase, stations: base.stations.length }), }; const bridge = pageMod.routeDraftBridge(P, link, { stationIntervalM: 20 }); await tick(10); const before = { saved: bridge.saved(), pipe: pageMod.pagePipeline(P) !== null }; const edit = { ...draftOf(movedNodes) }; delete edit.rev; delete edit.terrain; bridge.edited(edit); await tick(50); const key = `aislo:draft:route-draft:${P}`; const cached = JSON.parse(session.getItem(key)); bridge.edited(edit); // 같은 노선 — 판 번호 그대로 · 다시 안 셈 const sameRev = JSON.parse(session.getItem(key)).rev; await tick(50); const adoptedDraft = adopted.slice(); bridge.edited(null); // 열 때로(되돌리기 끝) await tick(50); const afterRevert = { session: session.getItem(key), last: adopted[adopted.length - 1] }; bridge.edited(edit); await tick(50); pageMod.disposePagePipeline(P); const disposed = pageMod.pagePipeline(P); adopted.length = 0; const again = pageMod.routeDraftBridge(P, link, { stationIntervalM: 20 }); const survived = again.saved(); await tick(80); // 받기 끝 → 저장 안 한 초안이 있으면 그 노선으로 셈 const reopened = adopted.slice(); edits = { station_offsets: { "100.000": 0.5 }, curve_radii: {} }; pageMod.pipelineGrade(P, edits); await tick(30); const gradeRuns = globalThis.__aisloPipeline.runs.slice(-1)[0]; page.clearDrafts(P); out.page = { before, cached: { rev: cached.rev, terrain: cached.terrain, nodes: cached.nodes.length }, sameRev, adoptedDraft, afterRevert, disposed, survived: survived && survived.nodes.length, reopened, grade: { sources: gradeRuns.sources, stages: Object.keys(gradeRuns.stages) }, clearedDraft: session.getItem(key), remoteCalls, }; pageMod.disposePagePipeline(P); } 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); });