- 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
244 lines
9.8 KiB
Python
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"]
|