Files
Aislo/resources/tester/test_route_polyline_browser_mirror.py
T
eomsangdonandClaude Opus 5.5 a2c512b30c chore(B05 정리): 342-4 B05_Profile/Old 27파일 지움 · 노선 거울 시험 resources/tester 로
- Old/ 부르는 곳 0(주석만) · tsconfig exclude 줄 정리 · typecheck 통과
- test_route_polyline_browser_mirror.py — tmp/tests 에서 옮김 · 빌드 자리 = pytest 임시 폴더 · 10 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-16 11:04:19 +09:00

244 lines
9.8 KiB
Python

"""계획노선 편집 폴리라인 — 파이썬 ↔ TS 거울 비교 (계획서 0-2-A).
`common_util/common_util_route_polyline.py` 의 **편집 갈래**(`simplify=False` +
`curve_flags`/`radii`)와 `B05_Profile/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" / "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"]