"""계획노선 편집 폴리라인 — 파이썬 ↔ TS 거울 비교 (계획서 0-2-A). `common_util/common_util_route_polyline.py` 의 **편집 갈래**(`simplify=False` + `curve_flags`/`radii`)와 `B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts` 의 `buildEditedPolyline` 은 한 벌이다. 한쪽만 고치면 **끌 때 보이는 선**(브라우저)과 **[확인] 뒤 저장되는 선**(서버)이 갈린다. 실행: ./venv/Scripts/python.exe -m pytest resources/tester/test_route_polyline_browser_mirror.py -q """ import json import math import subprocess import sys from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) from common_util.common_util_route_polyline import build_planned_polyline # noqa: E402 TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" MIN_RADIUS_M = 12.0 # (이름, 점, 곡선켬끔, 못박은 R) CASES: list[tuple[str, list[tuple[float, float]], list[bool], list[float | None]]] = [ ( "직각 한 번 · R 자동", [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], [True, True, True], [None, None, None], ), ( "지그재그 · 전부 곡선", [(0.0, 0.0), (80.0, 0.0), (120.0, 60.0), (200.0, 60.0), (240.0, 0.0)], [True] * 5, [None] * 5, ), ( "가운데 곡선 지움", [(0.0, 0.0), (80.0, 0.0), (120.0, 60.0), (200.0, 60.0), (240.0, 0.0)], [True, True, False, True, True], [None] * 5, ), ( "R 못박음 30m", [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], [True, True, True], [None, 30.0, None], ), ( "R 이 접선 자리보다 커서 눌림", [(0.0, 0.0), (40.0, 0.0), (40.0, 40.0)], [True, True, True], [None, 500.0, None], ), ( "완만한 꺾임(내각 큼)", [(0.0, 0.0), (100.0, 0.0), (200.0, 12.0)], [True, True, True], [None, None, None], ), ( "배향곡선처럼 되꺾임", [(0.0, 0.0), (60.0, 0.0), (58.0, 30.0), (120.0, 30.0)], [True] * 4, [None] * 4, ), ( "겹친 점(0.3m) 섞임 — 서버와 같이 버려야 함", [(0.0, 0.0), (100.0, 0.0), (100.2, 0.2), (100.0, 100.0)], [True] * 4, [None] * 4, ), ] DRIVER = """ import { buildEditedPolyline } from "./B05_Profile_UI_RouteEdit_Curve.js"; import fs from "node:fs"; const cases = JSON.parse(fs.readFileSync(process.argv[2], "utf-8")); const out = cases.map((c) => buildEditedPolyline(c.points, c.curveOn, c.curveRadius, c.minRadiusM), ); process.stdout.write(JSON.stringify(out)); """ @pytest.fixture(scope="module") def ts_result(tmp_path_factory: pytest.TempPathFactory) -> list[dict]: build_dir = tmp_path_factory.mktemp("ts_routeedit_build") subprocess.run( # noqa: S603 — 고정 실행 파일 [ "node", str(TSC), str(ROOT / "B05_Profile" / "logic" / "B05_Profile_UI_RouteEdit_Curve.ts"), "--outDir", str(build_dir), "--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", ], cwd=str(ROOT), check=True, capture_output=True, ) payload = [ { "points": [list(point) for point in points], "curveOn": flags, "curveRadius": radii, "minRadiusM": MIN_RADIUS_M, } for _name, points, flags, radii in CASES ] cases_path = build_dir / "cases.json" cases_path.write_text(json.dumps(payload), encoding="utf-8") (build_dir / "driver.mjs").write_text(DRIVER, encoding="utf-8") run = subprocess.run( # noqa: S603 ["node", str(build_dir / "driver.mjs"), str(cases_path)], capture_output=True, text=True, cwd=str(build_dir), ) assert run.returncode == 0, f"node 실행 실패:\n{run.stdout}{run.stderr}" return json.loads(run.stdout) def _point_to_polyline_m(point: tuple[float, float], line: list[tuple[float, float]]) -> float: """점에서 폴리라인까지 가장 가까운 거리(m).""" best = float("inf") for start, end in zip(line, line[1:]): dx, dy = end[0] - start[0], end[1] - start[1] span = dx * dx + dy * dy t = ( 0.0 if span <= 0 else max( 0.0, min(1.0, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / span) ) ) best = min(best, math.hypot(start[0] + t * dx - point[0], start[1] + t * dy - point[1])) return best ARC_STEP_DEG = 5.0 def _sampling_tolerance_m(radii: list[float]) -> float: """원호 표본이 한 칸 어긋났을 때 생길 수 있는 최대 벌어짐(m) — 현(弦)의 중앙종거. 원호 위 점을 **몇 개** 찍을지는 `int(도수/5)` 라 90° 처럼 딱 떨어지는 자리에서 개수가 하나 갈릴 수 있다 — `math.sin` 이 파이썬(C libm)과 노드(V8)에서 마지막 자리 하나가 달라 중심이 1e-14 m 어긋나고 그것이 정수 나눗셈 경계를 넘긴다(2026-09-07 실측: 89.99999999999992 vs 90.0). **같은 원 위의 같은 호**라 선의 모양은 같지만, 한쪽 점이 다른 쪽 현의 가운데에 놓이면 R(1−cos(스텝/2)) 만큼 떨어져 보인다. 표본 개수가 갈리면 상대 쪽 현이 한 칸 넓어지므로 **현 하나분**(R(1−cos(5°)))을 문턱으로 잡는다. 공식이 어긋나면 점이 **미터 단위**로 움직이므로 이 문턱으로도 충분히 걸린다. """ if not radii: return 1e-9 return max(radii) * (1 - math.cos(math.radians(ARC_STEP_DEG))) + 1e-9 @pytest.mark.parametrize("index", range(len(CASES))) def test_python_and_ts_draw_the_same_line(ts_result, index: int) -> None: name, points, flags, radii = CASES[index] expected = build_planned_polyline( list(points), min_radius_m=MIN_RADIUS_M, simplify=False, curve_flags=list(flags), radii=list(radii), ) actual = ts_result[index] got = [(x, y) for x, y in actual["vertices"]] assert abs(len(got) - len(expected.vertices)) <= 1, f"[{name}] 정점 개수가 크게 다름" # 끝점은 정확히 같아야 한다 — 노선의 시작·끝은 표본 간격과 무관하다. for axis in (0, 1): assert abs(got[0][axis] - expected.vertices[0][axis]) < 1e-9, f"[{name}] 시작점이 다름" assert abs(got[-1][axis] - expected.vertices[-1][axis]) < 1e-9, f"[{name}] 끝점이 다름" if len(got) == len(expected.vertices): # 표본 개수가 같으면 **자리까지 똑같아야** 한다 — 여기서 봐주면 시험이 헐거워진다. for (px, py), (tx, ty) in zip(expected.vertices, got): assert abs(px - tx) < 1e-9, f"[{name}] x 가 다름" assert abs(py - ty) < 1e-9, f"[{name}] y 가 다름" else: tolerance = _sampling_tolerance_m([curve.radius_m for curve in expected.curves]) for vertex in got: assert _point_to_polyline_m(vertex, expected.vertices) < tolerance, ( f"[{name}] TS 정점이 서버 선에서 벗어남" ) for vertex in expected.vertices: assert _point_to_polyline_m(vertex, got) < tolerance, ( f"[{name}] 서버 정점이 TS 선에서 벗어남" ) assert len(actual["curves"]) == len(expected.curves), f"[{name}] 곡선 개수가 다름" for want, got in zip(expected.curves, actual["curves"]): assert abs(want.radius_m - got["radius_m"]) < 1e-9, f"[{name}] 반지름이 다름" assert abs(want.tangent_m - got["tangent_m"]) < 1e-9, f"[{name}] 접선 길이가 다름" assert abs(want.inner_angle_deg - got["inner_angle_deg"]) < 1e-9, f"[{name}] 내각이 다름" for axis in (0, 1): assert abs(want.apex[axis] - got["apex"][axis]) < 1e-9, f"[{name}] 교각점이 다름" assert abs(want.start[axis] - got["start"][axis]) < 1e-9, f"[{name}] 곡선 시작이 다름" assert abs(want.end[axis] - got["end"][axis]) < 1e-9, f"[{name}] 곡선 끝이 다름" assert want.violations == got["violations"], f"[{name}] 위반 문구가 다름" def test_ts_reports_curve_seats_in_the_caller_index(ts_result) -> None: """겹친 점을 버려도 TS 가 돌려주는 자리 번호는 **넘긴 목록 기준**이어야 한다. 화면이 잡고 있는 배열은 사용자가 끄는 노드 목록 그대로다. 서버처럼 걸러낸 뒤 번호를 돌려주면 손잡이가 엉뚱한 노드에 붙는다. """ index = next(i for i, case in enumerate(CASES) if "겹친 점" in case[0]) _name, points, _flags, _radii = CASES[index] curves = ts_result[index]["curves"] assert len(curves) == 1, "겹친 점을 버리면 꺾임점은 하나만 남는다" seat = curves[0]["node_first"] # 버려진 것은 index 2 (100.2, 0.2) 이므로 곡선이 앉는 자리는 원래 목록의 1번이다. assert seat == 1 assert points[seat] == (100.0, 0.0) assert len(ts_result[index]["nodes"]) == len(points), "노드 요약은 넘긴 목록과 자리가 같다" def test_turning_a_curve_off_leaves_a_sharp_corner(ts_result) -> None: """곡선을 지운 자리는 원호 없이 꺾여야 한다 — 화면에서 눈으로 확인하는 것과 같은 값.""" on_index = next(i for i, case in enumerate(CASES) if case[0] == "지그재그 · 전부 곡선") off_index = next(i for i, case in enumerate(CASES) if case[0] == "가운데 곡선 지움") assert len(ts_result[on_index]["curves"]) == 3 assert len(ts_result[off_index]["curves"]) == 2 # 지운 자리(120, 60)가 폴리라인에 **꼭짓점 그대로** 남는다. assert [120.0, 60.0] in ts_result[off_index]["vertices"] assert [120.0, 60.0] not in ts_result[on_index]["vertices"]