"""관 편집이 **한 자리에만** 쌓이는지(2026-09-12 사용자: 담는 꼴도 한 벌이어야 한다). B05 는 관 목록 전체를 세션 스냅샷(`pipes`)으로 담고, B06 은 바뀐 것만 따로 담고 있었다. 저장 순서에 따라 한쪽이 다른 쪽을 덮어 **넣은 관이 사라지는** 일이 생겼다. 그래서 관 목록은 두 페이지가 같은 스냅샷을 쓰도록 합쳤다. 여기서 지키는 것은 둘이다. ① 저장기(`createCulvertOptionWriter`)에는 **관을 넣고 빼는 길이 없다** — 남아 있으면 관 목록을 담는 자리가 다시 둘이 된다. ② 옛 세션에 남아 있던 **이동 예약은 저장 때 흘려보낸다** — 합치기 전에 만들어 둔 예약이 조용히 사라지면 안 된다. 실제 화면 코드를 그대로 컴파일해 Node 로 돌린다(다단 하한 시험과 같은 방식). """ import json import subprocess import sys from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[2] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" MODULE = PROJECT_ROOT / "B06_Section" / "B06_Section_Api_Culvert_Options.ts" _RUNNER = """ const { writeFileSync } = require("node:fs"); const Module = require("node:module"); // 화면 코드가 번들러 별칭(`@config/…`)을 쓴다 — Node 에는 없으니 빈 껍데기로 돌려준다. const load = Module._load; const saved = []; Module._load = function (request, parent, isMain) { if (request.startsWith("@config/") || request.startsWith("@util/") || request.startsWith("@ui/")) return new Proxy({}, { get: () => () => undefined }); const mod = load.call(this, request, parent, isMain); // 관 정본 읽기·쓰기는 서버로 나가므로 가로챈다. if (request.includes("B04_PreProcess_Api_Fetch")) { return { ...mod, fetchDetailPipePoints: async () => ({ pipe_points: [ { chainage_m: 120, lonlat: [0, 0], source: "stream" }, { chainage_m: 200, lonlat: [0, 0], source: "stream" }, ], }), saveDetailPipePoints: async (_projectId, points) => saved.push(points), }; } return mod; }; const store = new Map(); global.window = { sessionStorage: { getItem: (key) => (store.has(key) ? store.get(key) : null), setItem: (key, value) => store.set(key, String(value)), removeItem: (key) => store.delete(key), }, }; const api = require(process.argv[3]); const writer = api.createCulvertOptionWriter( () => "p1", () => "opt", undefined, () => "move", ); // ① 관을 넣고 빼는 길이 저장기에 남아 있으면 안 된다. const methods = Object.keys(writer).sort(); // ② 합치기 전 세션에 남아 있던 이동 예약은 저장 때 흘려보낸다. store.set("move", JSON.stringify({ "120.00": 135 })); api .flushCulvertOptions("p1", "opt", "move") .then(() => { writeFileSync( process.argv[2], JSON.stringify({ methods, savedChainages: (saved[0] ?? []).map((point) => point.chainage_m), moveLeft: store.get("move") ?? null, }), ); }) .catch((error) => { writeFileSync(process.argv[2], JSON.stringify({ error: String(error) })); process.exitCode = 1; }); """ def _run(tmp_path: Path) -> dict: out = tmp_path / "out" subprocess.run( # noqa: S603 — 고정 실행 파일 [ "node", str(TSC), "--ignoreConfig", "--target", "es2022", "--module", "commonjs", "--skipLibCheck", "--outDir", str(out), str(MODULE), ], cwd=str(PROJECT_ROOT), check=False, capture_output=True, ) compiled = next(out.rglob("B06_Section_Api_Culvert_Options.js"), None) assert compiled is not None, "화면 코드가 JS 로 안 나옴 — tsc 실패" (out / "runner.cjs").write_text(_RUNNER, encoding="utf-8") result = tmp_path / "result.json" subprocess.run( # noqa: S603 — 고정 실행 파일 ["node", str(out / "runner.cjs"), str(result), str(compiled)], cwd=str(PROJECT_ROOT), check=True, capture_output=True, ) return json.loads(result.read_text(encoding="utf-8")) def test_관_목록을_담는_자리는_하나뿐이다(tmp_path: Path) -> None: result = _run(tmp_path) assert "error" not in result, result.get("error") # ① 저장기에는 구간값 예약과 내보내기만 있다 — 관을 넣고 빼는 길은 스냅샷 한 곳뿐이다. assert result["methods"] == ["flush", "queue"] # ② 옛 세션의 이동 예약은 저장 때 반영되고 비워진다. assert result["savedChainages"] == [135, 200] assert result["moveLeft"] is None