"""B06 종단에서 넣고 뺀 **계곡 통과 시설**이 세션에 제대로 쌓이는지(2026-09-12 사용자 지시). B05·B06 은 한 페이지이므로 관 추가·삭제도 양쪽에서 되어야 한다. 다만 정본에 바로 쓰지 않고 **세션에 예약**했다가 [저장]·[확정]에서 한 번에 내보낸다(CLAUDE.md 5장). 넣었다 바로 빼면 예약만 걷어 내야 하고, 뺐다 다시 넣어도 마찬가지다 — 그러지 않으면 저장 때 있지도 않은 관을 지우거나 같은 자리에 둘이 선다. 실제 화면 코드를 그대로 컴파일해 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; Module._load = function (request, parent, isMain) { if (request.startsWith("@config/") || request.startsWith("@util/") || request.startsWith("@ui/")) return new Proxy({}, { get: () => () => undefined }); return load.call(this, request, parent, isMain); }; // 세션 저장소 흉내 — 화면 코드는 window.sessionStorage 만 쓴다. 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 { createCulvertOptionWriter } = require(process.argv[3]); const EDIT_KEY = "edit"; const writer = createCulvertOptionWriter( () => "p1", () => "opt", undefined, () => "move", () => EDIT_KEY, ); const read = () => JSON.parse(store.get(EDIT_KEY) ?? '{"adds":[],"removes":[]}'); const steps = {}; writer.queueAdd(120, "pipe"); writer.queueAdd(140); steps.afterTwoAdds = read(); // 방금 넣은 자리를 다시 빼면 **추가 예약만** 사라진다(정본에는 아직 없다). writer.queueRemove(140); steps.afterUndoAdd = read(); // 정본에 있던 관을 빼면 삭제 예약으로 남는다. writer.queueRemove(200); steps.afterRemove = read(); // 뺀 자리를 다시 넣으면 삭제 예약만 걷어 낸다(새로 만들지 않는다). writer.queueAdd(200); steps.afterUndoRemove = read(); // 같은 자리를 두 번 넣어도 하나만 남는다. writer.queueAdd(120); steps.afterDuplicateAdd = read(); writeFileSync(process.argv[2], JSON.stringify(steps)); """ 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: steps = _run(tmp_path) # 넣은 둘이 그대로 쌓인다. assert [entry["chainage_m"] for entry in steps["afterTwoAdds"]["adds"]] == [120.0, 140.0] assert steps["afterTwoAdds"]["adds"][0]["facility"] == "pipe" assert steps["afterTwoAdds"]["removes"] == [] # 방금 넣은 자리를 빼면 추가 예약만 사라지고 삭제 예약은 안 생긴다. assert [entry["chainage_m"] for entry in steps["afterUndoAdd"]["adds"]] == [120.0] assert steps["afterUndoAdd"]["removes"] == [] # 정본에 있던 관은 삭제 예약으로 남는다. assert steps["afterRemove"]["removes"] == ["200.00"] # 뺀 자리를 다시 넣으면 삭제 예약만 걷힌다 — 새 관을 만들지 않는다. assert steps["afterUndoRemove"]["removes"] == [] assert [entry["chainage_m"] for entry in steps["afterUndoRemove"]["adds"]] == [120.0] # 같은 자리를 두 번 넣어도 하나만 남는다. assert [entry["chainage_m"] for entry in steps["afterDuplicateAdd"]["adds"]] == [120.0]