Files
Aislo/resources/tester/test_b05_routeedit_frame_mirror.py
T
eomsangdonandClaude Opus 5.5 b2cbd852a0 feat(B05): 계획노선 편집 — 예상노선 거리 재기 · 측점 눈금 라벨 비켜 놓기 · 횡단 반폭 선
- 거리·기울기 재기가 예상노선(점선)에서도 됨 — 두 점은 한 노선 위 · 다른 노선을 짚으면 새로 시작
- 측점 눈금 라벨이 겹치면 빼지 않고 비켜 놓음(반대쪽 → 한 칸 더 멀리 · 가는 선으로 이음) — 공용 drawStationTicks
- 오른쪽에 횡단이 뜬 측점에 좌·우 설계 반폭 선 — 노드를 끄는 동안 브라우저 셈으로 바로 따라감
- 측점 틀 파이썬·TS 짝(_interpolate_xy·_tangent_at ↔ stationFrameAt) + 거울 시험

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014WeXB1zh5hbYLgoeGS5kQu
2026-09-29 09:41:39 +09:00

139 lines
5.1 KiB
Python

"""측점 평면 틀 거울 — 파이썬(`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 "")