211 lines
8.0 KiB
Python
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)
|