"""측점 평면 틀 거울 — 파이썬(`B05_Profile_Engine_Sections_Core`) ↔ TS(`_RouteEdit_Frame.ts`). 짝: `_interpolate_xy`·`_tangent_at`(측점 중심 · 좌측 축) ↔ `stationFrameAt`. 계획노선 편집 창이 노선을 끄는 동안 그리는 횡단 반폭 선이 [확인] 뒤 서버가 낸 측점 틀과 같은 자리 · 같은 방향이어야 함. """ from __future__ import annotations import json import math import re import subprocess import sys from pathlib import Path import numpy as np import pytest ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from B05_Profile import B05_Profile_Engine_Sections_Core as core # noqa: E402 from config.config_system import SECTION_LONG_SAMPLE_INTERVAL_M # noqa: E402 TS_FILE = ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Frame.ts" TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" _TOLERANCE = 1e-9 def _arc(cx: float, cy: float, radius: float, start: float, end: float, count: int) -> list: return [ [cx + radius * math.cos(angle), cy + radius * math.sin(angle)] for angle in np.linspace(start, end, count) ] # 직선 · 꺾임 · 원호 · 겹친 정점 · 되꺾임 · 시종점 · 노선 밖 누가거리 CASES = [ {"line": [[0, 0], [100, 0]], "chainages": [0, 20, 40, 100]}, {"line": [[0, 0], [40, 0], [40, 0], [40, 30], [10, 70]], "chainages": [0, 20, 40, 60, 99.9]}, {"line": _arc(500, 500, 30, 0.2, 2.9, 41), "chainages": [0, 13.7, 20, 40, 60, 80]}, {"line": [[10, 10], [60, 12], [15, 14], [70, 40]], "chainages": [20, 50.5, 51, 120, 999]}, {"line": [[0, 0], [0.4, 0.3], [1.0, 0.1]], "chainages": [0, 0.5, 1.2]}, ] _RUNNER = """ import { readFileSync, writeFileSync } from "node:fs"; import { stationFrameAt, SECTION_LONG_SAMPLE_INTERVAL_M, } from "./B05_Profile_UI_RouteEdit_Frame.js"; const cases = JSON.parse(readFileSync(process.argv[2], "utf8")); const out = { interval: SECTION_LONG_SAMPLE_INTERVAL_M, frames: cases.map((item) => item.chainages.map((c) => stationFrameAt(item.line, c))), }; writeFileSync(process.argv[3], JSON.stringify(out)); """ def _python_frame(line: list, chainage: float) -> dict: points, route_chainage = core._clean_polyline(np.asarray(line, dtype=np.float64)) total = float(route_chainage[-1]) target = min(float(chainage), total) origin = core._interpolate_xy(points, route_chainage, np.array([chainage]))[0] tangent = core._tangent_at( points, route_chainage, target, max(SECTION_LONG_SAMPLE_INTERVAL_M, 0.5) ) return {"origin": [float(origin[0]), float(origin[1])], "left": [-tangent[1], tangent[0]]} def _ts_frames(tmp_path: Path) -> dict: out = tmp_path / "js" subprocess.run( # noqa: S603 — 고정 실행 파일 [ "node", str(TSC), str(TS_FILE), "--outDir", str(out), "--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", ], cwd=str(ROOT), check=True, capture_output=True, ) for emitted in out.glob("*.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") payload = tmp_path / "input.json" result = tmp_path / "output.json" payload.write_text(json.dumps(CASES), encoding="utf-8") subprocess.run( # noqa: S603 ["node", str(out / "runner.mjs"), str(payload), str(result)], cwd=str(ROOT), check=True, capture_output=True, ) return json.loads(result.read_text(encoding="utf-8")) @pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") def test_측점_틀이_두_쪽에서_같다(tmp_path: Path) -> None: actual = _ts_frames(tmp_path) assert actual["interval"] == SECTION_LONG_SAMPLE_INTERVAL_M problems: list[str] = [] checked = 0 for index, case in enumerate(CASES): for chainage, frame in zip(case["chainages"], actual["frames"][index], strict=True): # 노선 밖 누가거리는 파이썬이 끝값으로 붙인다 — TS 도 같아야 함. expected = _python_frame(case["line"], chainage) assert frame is not None, f"사례 {index} · {chainage}m: TS 가 틀을 못 냄" for key in ("origin", "left"): for axis in (0, 1): if abs(frame[key][axis] - expected[key][axis]) > _TOLERANCE: problems.append( f"사례 {index} · {chainage}m · {key}[{axis}]: " f"{frame[key][axis]!r} vs {expected[key][axis]!r}" ) checked += 1 assert checked >= 20 assert not problems, "짝이 갈림:\n" + "\n".join(problems[:40]) def test_짝_머리글이_서로를_가리킨다() -> None: assert "B05_Profile_Engine_Sections_Core.py" in TS_FILE.read_text(encoding="utf-8") assert "B05_Profile_UI_RouteEdit_Frame.ts" in (core.__doc__ or "")