"""D03 꺾임점 고침 거울(PLAN 543-1 · 543-3) — 화면 `logic/D03_PlanAnalysis_Edit.ts` ↔ 서버 `D03_PlanAnalysis_Edit.py` · 이은 누가 짝 `withEdits` ↔ `with_edits`. - 끌기 · 넣기 · 지우기 · 손잡이 뒤 처음 · 끝 마디 그대로 · 끝 마디 잠금 · R ≥ 법정 최소 - 고친 선 길이 = `lineLength` · 이은 누가가 고친 길이로 이어짐(파이썬 = TS) - 브라우저 선 = 서버 선(1 mm) — Node 길 · 파이썬 폴백 둘 다 ./venv/Scripts/python.exe -m pytest resources/tester/test_543_plan_edit.py -q """ from __future__ import annotations import json import re import subprocess import sys from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from common_util.common_util_route_polyline import build_planned_polyline # noqa: E402 from D03_PlanAnalysis import D03_PlanAnalysis_Analyze as analyze # noqa: E402 from D03_PlanAnalysis import D03_PlanAnalysis_Edit as edit_mod # noqa: E402 TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Edit.ts" TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" MIN_R = 12.0 NODES = [(0.0, 0.0), (120.0, 10.0), (200.0, 120.0), (330.0, 140.0), (420.0, 60.0)] _RUNNER = """ import { readFileSync, writeFileSync } from "node:fs"; import * as ed from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Edit.js"; import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js"; const job = JSON.parse(readFileSync(process.argv[2], "utf8")); const R = job.minR; const base = ed.editFromRoute(job.route, "gentle", "f1"); const step = (s) => "blocked" in s ? { blocked: s.blocked } : { edit: s.edit, line: s.line.vertices }; const moved = ed.moveNode(base, 2, [215, 150], R); const handle = ed.moveHandle(base, 1, "end", job.handleTo, R); const inserted = ed.insertNode(base, 0, [60, 5], R); const removed = ed.removeNode(base, 1, R); const out = { base, baseLine: ed.buildEdit(base, R).line.vertices, moved: step(moved), handle: step(handle), inserted: step(inserted), removed: step(removed), endLocked: step(ed.moveNode(base, 0, [5, 5], R)), endRemove: step(ed.removeNode(base, base.nodes.length - 1, R)), movedLength: "edit" in moved ? plan.lineLength(moved.line.vertices) : null, }; const edits = { 2026: { mode: "gentle", planned: out.moved.line } }; const results = plan.withEdits(job.results, edits); out.rows = plan.chainRows(results, {}, 1000); out.line = plan.joinedLine(results, out.rows); writeFileSync(process.argv[3], JSON.stringify(out)); """ def _route() -> dict: line = build_planned_polyline(list(NODES), min_radius_m=MIN_R, simplify=False) return { "planned": [list(v) for v in line.vertices], "nodes": [n.as_dict() for n in line.nodes], "curves": [c.as_dict() for c in line.curves], "metrics": {"length_m": 999.0}, } def _results(route: dict) -> list: end = list(NODES[-1]) return [ { "year": 2026, "start": list(NODES[0]), "end": end, "fingerprint": "f1", "criteria": {"min_radius_m": MIN_R, "max_grade_pct": 14.0}, "routes": {"gentle": route, "earthwork": {"error": "길 없음"}}, }, { "year": 2027, "start": end, "end": [600.0, 60.0], "routes": {"gentle": {"planned": [end, [600.0, 60.0]], "metrics": {"length_m": 180.0}}}, }, ] @pytest.fixture(scope="module") def ts(tmp_path_factory) -> dict: tmp = tmp_path_factory.mktemp("543") out = tmp / "js" subprocess.run( # noqa: S603 — 고정 실행 파일 [ "node", str(TSC), str(TS_FILE), str(ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Plan.ts"), "--outDir", str(out), "--rootDir", str(ROOT), "--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", ], cwd=str(ROOT), check=True, capture_output=True, ) for emitted in out.rglob("*.js"): emitted.write_text( re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), encoding="utf-8", ) (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") route = _route() job = {"minR": MIN_R, "route": route, "results": _results(route), "handleTo": None} # 손잡이 = 첫 곡선 끝 접선점을 나가는 직선 따라 교각점에서 1.8 배 자리로(접선 길이 ↑ = R ↑) curve = next(c for c in route["curves"] if c["node_first"] == 1) apex, end = curve["apex"], curve["end"] job["handleTo"] = [apex[0] + 1.8 * (end[0] - apex[0]), apex[1] + 1.8 * (end[1] - apex[1])] (tmp / "job.json").write_text(json.dumps(job), encoding="utf-8") subprocess.run( # noqa: S603 ["node", str(out / "runner.mjs"), str(tmp / "job.json"), str(tmp / "out.json")], cwd=str(ROOT), check=True, capture_output=True, ) return {**json.loads((tmp / "out.json").read_text(encoding="utf-8")), "route": route} def _close(a: list, b: list, tol: float = 1e-3) -> bool: return len(a) == len(b) and all( abs(p[0] - q[0]) <= tol and abs(p[1] - q[1]) <= tol for p, q in zip(a, b) ) def test_ends_kept_and_locked(ts) -> None: for name in ("moved", "handle", "inserted", "removed"): step = ts[name] assert "edit" in step, (name, step) assert step["edit"]["nodes"][0] == list(NODES[0]) assert step["edit"]["nodes"][-1] == list(NODES[-1]) assert _close([step["line"][0], step["line"][-1]], [list(NODES[0]), list(NODES[-1])]) assert "연도 경계" in ts["endLocked"]["blocked"] assert "연도 경계" in ts["endRemove"]["blocked"] count = len(ts["base"]["nodes"]) assert len(ts["inserted"]["edit"]["nodes"]) == count + 1 assert len(ts["removed"]["edit"]["nodes"]) == count - 1 # 손잡이 = 그 마디 R 이 바뀜 · 법정 최소 밑 아님 assert ts["handle"]["edit"]["radii"][1] >= MIN_R assert ts["handle"]["edit"]["radii"][1] > ts["base"]["radii"][1] * 1.5 def test_unedited_base_redraws_engine_line(ts) -> None: assert _close(ts["baseLine"], ts["route"]["planned"]) def test_length_and_chain_mirror(ts) -> None: line = ts["moved"]["line"] assert abs(ts["movedLength"] - analyze.line_length(line)) < 1e-6 results = analyze.with_edits(_results(ts["route"]), {2026: {"mode": "gentle", "planned": line}}) rows = analyze.chain(results, {}, 1000.0) assert ts["rows"] == rows assert rows[0]["length_m"] == round(analyze.line_length(line), 3) assert rows[1]["start_chainage_m"] == rows[0]["end_chainage_m"] assert ts["line"] == analyze.joined_line(results, rows) @pytest.mark.parametrize("node_path", [True, False]) def test_browser_line_equals_server_line(ts, monkeypatch, node_path) -> None: if not node_path: monkeypatch.setattr(edit_mod, "node_polyline", lambda *a, **k: None) for name in ("moved", "handle", "inserted", "removed"): step = ts[name] server = edit_mod.edited_line(step["edit"], MIN_R) assert _close([list(v) for v in server.vertices], step["line"]), name def test_check_edit_rejects() -> None: result = _results(_route())[0] good = { "base_mode": "gentle", "base_fingerprint": "f1", "nodes": [list(p) for p in NODES], "curve_flags": [True] * len(NODES), "radii": [None] * len(NODES), } edit_mod.check_edit(good, result, "gentle") for bad, mode in ( ({**good, "base_fingerprint": "old"}, "gentle"), (good, "earthwork"), ({**good, "nodes": [[1.0, 0.0], *good["nodes"][1:]]}, "gentle"), ({**good, "radii": [None, -3.0, None, None, None]}, "gentle"), ({**good, "curve_flags": [True]}, "gentle"), ): with pytest.raises(edit_mod.EditError): edit_mod.check_edit(bad, result, mode)