Files
Aislo/resources/tester/test_543_plan_edit.py
T

211 lines
8.0 KiB
Python

"""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)