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
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
"""계획노선 편집 폴리라인 — 파이썬 ↔ 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"]
|
||||
Reference in New Issue
Block a user