"""요구 224-2 · PLAN 326 · 483(요구 280-4) — 계획노선 편집 [측점 스냅]. 짝: 파이썬 `B05_Profile_Engine_Sections_Core` (`_chainages` · `_design_curve_spans` · `curve_stations`) ↔ TS `B05_Profile_UI_RouteEdit_Frame.ts` (`stationsAlong` · `curveSpans` · `curveStations`). 스냅은 일반측점을 옮기지 않음(280-4) — MC(floor(MC+0.5))에 측점이 없으면 곡선 측점 하나를 더함 · 0.005 m 안 측점 있으면 안 더함. """ 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 TS_FILE = ROOT / "B05_Profile" / "logic" / "B05_Profile_UI_RouteEdit_Frame.ts" TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" GRID = [float(value) for value in range(0, 1001, 20)] def _curve(spans: list[tuple[float, float]], stations: list[float] | None = None) -> list[float]: return core.curve_stations(stations or GRID, spans, 1e9) # ── 파이썬 규칙 ──────────────────────────────────────────────────────────────── def test_곡선_없음_곡선_측점_없음() -> None: assert _curve([]) == [] def test_MC_가_측점_밖이면_더함() -> None: assert _curve([(621.0, 639.0)]) == [630.0] assert _curve([(612.0, 634.0)]) == [623.0] def test_MC_가_일반측점_위면_안_더함() -> None: assert _curve([(610.0, 630.0)]) == [] # MC 620 def test_반_올림() -> None: assert _curve([(611.0, 640.0)]) == [626.0] # MC 625.5 → 626 def test_0005_m_안_측점이면_안_더함() -> None: assert _curve([(20.1, 20.6)], [0.0, 20.0, 20.354, 40.0]) == [] # MC 20.35 → 20 # ── 파이썬 ↔ TS 거울 ───────────────────────────────────────────────────────── 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) ] def _bend_route() -> tuple[list, list]: """직선 300 m → R 40 호 → 직선 — 곡선표 한 줄 + 노선 밖 곡선 · 방향 없는 곡선.""" arc = _arc(300.0, 40.0, 40.0, -math.pi / 2, 0.3, 60) line = [[0.0, 0.0], *arc, [arc[-1][0] - 200 * math.sin(0.3), arc[-1][1] + 200 * math.cos(0.3)]] curves = [ {"start": arc[0], "apex": [340.0, 0.0], "end": arc[-1], "radius_m": 40.0}, {"start": [0.0, 500.0], "apex": [10.0, 510.0], "end": [20.0, 500.0], "radius_m": 30.0}, {"start": [50.0, 0.0], "apex": [60.0, 0.0], "end": [70.0, 0.0], "radius_m": 30.0}, {"start": [80.0, 0.0], "apex": [90.0, 1.0], "end": [100.0, 0.0], "radius_m": None}, ] return line, curves CASES = [ {"line": [[0, 0], [1000, 0]], "interval": 20, "end": True, "curves": []}, { "line": [[0, 0], [1000.3, 0]], "interval": 20, "end": True, "curves": [{"start": [616.1, 0], "apex": [631.3, 3], "end": [646.5, 0], "radius_m": 40}], }, { "line": [[0, 0], [500, 0], [500, 0], [700, 10]], "interval": 20, "end": False, "curves": [ {"start": [611, 0], "apex": [625.5, -2], "end": [640, 0], "radius_m": 25}, {"start": [621, 0], "apex": [630, 2], "end": [639, 0], "radius_m": 25}, {"start": [100, 0], "apex": [110, 2], "end": [112, 0], "radius_m": 25}, ], }, {"line": _bend_route()[0], "interval": 20, "end": True, "curves": _bend_route()[1]}, {"line": _bend_route()[0], "interval": 7.5, "end": True, "curves": _bend_route()[1]}, ] _RUNNER = """ import { readFileSync, writeFileSync } from "node:fs"; import * as frame from "./B05_Profile_UI_RouteEdit_Frame.js"; const cases = JSON.parse(readFileSync(process.argv[2], "utf8")); const out = { tolerance: frame.DESIGN_CURVE_MATCH_TOLERANCE_M, edge: frame.CURVE_STATION_EDGE_M, rows: cases.map((item) => { const grid = frame.stationsAlong(item.line, item.interval, item.end); const spans = frame.curveSpans(item.line, item.curves); return { grid, spans, curve: frame.curveStations(grid, spans, 1e9) }; }), }; writeFileSync(process.argv[3], JSON.stringify(out)); """ def _python_row(case: dict) -> dict: points, route_chainage = core._clean_polyline(np.asarray(case["line"], dtype=np.float64)) grid = core._chainages(float(route_chainage[-1]), float(case["interval"]), case["end"]) spans = core._design_curve_spans(points, route_chainage, case["curves"]) return { "grid": [float(v) for v in grid], "spans": [[span[0], span[1]] for span in spans], "curve": core.curve_stations(grid, spans, 1e9), } def _ts_rows(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_rows(tmp_path) assert actual["tolerance"] == core.DESIGN_CURVE_MATCH_TOLERANCE_M assert actual["edge"] == core.CURVE_STATION_EDGE_M added = 0 for index, case in enumerate(CASES): expected = _python_row(case) row = actual["rows"][index] assert row["grid"] == pytest.approx(expected["grid"], abs=1e-9), f"사례 {index} 격자" assert len(row["spans"]) == len(expected["spans"]), f"사례 {index} 곡선 수" for got, want in zip(row["spans"], expected["spans"], strict=True): assert got == pytest.approx(want, abs=1e-9), f"사례 {index} 곡선" assert row["curve"] == pytest.approx(expected["curve"], abs=1e-9), f"사례 {index} 곡선 측점" added += bool(row["curve"]) assert added >= 3 # 곡선 측점이 실제로 생긴 사례를 견줌 def test_짝_머리글이_서로를_가리킨다() -> None: text = TS_FILE.read_text(encoding="utf-8") assert "B05_Profile_Engine_Sections_Core.py" in text and "curve_stations" in text assert "curveStations" in (core.__doc__ or "") assert "snapCurveStations" not in text and not hasattr(core, "snap_curve_stations") # ── generate_sections ──────────────────────────────────────────────────────── class _Flat: def sample_xy(self, xy): count = len(np.asarray(xy, dtype=np.float64)) return np.full(count, 100.0), np.ones(count, dtype=bool) _LINE = [[0.0, 0.0, 100.0], [1000.3, 0.0, 100.0]] _CURVES = [{"start": [616.1, 0.0], "apex": [631.3, 3.0], "end": [646.5, 0.0], "radius_m": 40.0}] def _stations(snap: bool | None, extras: tuple = ()) -> list[dict]: kwargs = {} if snap is None else {"station_snap": snap} result = core.generate_sections( _LINE, _Flat(), core.SectionGenerationOptions(extra_stations=extras), design_curves=_CURVES, **kwargs, ) return result["longitudinal"]["stations"] def test_끔은_지금과_같음() -> None: assert _stations(False) == _stations(None) assert 640.0 in [s["chainage_m"] for s in _stations(False)] def test_켬은_640_남고_631_곡선_측점() -> None: # 280-4 — 켬도 일반측점을 옮기지 않음 · 끔과 같은 목록(640 남고 MC 631 에 곡선 측점) assert _stations(True) == _stations(False) by_chainage = {s["chainage_m"]: s for s in _stations(True)} assert by_chainage[620.0]["kind"] == "regular" and by_chainage[640.0]["kind"] == "regular" assert by_chainage[631.0]["kind"] == "curve" assert by_chainage[631.0]["plan_radius_m"] == 40.0 # 호 안 = 곡선표 반경 def test_끔은_MC_에_곡선_측점() -> None: by_chainage = {s["chainage_m"]: s for s in _stations(False)} assert by_chainage[640.0]["kind"] == "regular" assert by_chainage[631.0]["kind"] == "curve" assert by_chainage[631.0]["label"].endswith(" (MC)") def test_640_에_관이면_grid() -> None: on_grid = {s["chainage_m"]: s for s in _stations(True, ((640.0, "관"),))} assert on_grid[640.0]["kind"] == "irregular" and on_grid[640.0]["grid"] is True # 631(MC)에 놓인 관은 따로 선 측점 · 곡선 측점은 안 더함(0.005 m 안 측점) at_mc = {s["chainage_m"]: s for s in _stations(True, ((631.0, "관"),))} assert at_mc[631.0]["kind"] == "irregular" and "grid" not in at_mc[631.0] assert 640.0 in at_mc # ── 서버 저장 · 정본 (326-2) ───────────────────────────────────────────────── _NODES = [(0.0, 0.0), (300.0, 0.0), (300.0, 300.0)] def _working(tmp_path: Path) -> Path: from common_util.common_util_route_geometry import planned_route_working_path path = planned_route_working_path(tmp_path) path.parent.mkdir(parents=True, exist_ok=True) return path def test_확인_때_곡선표에_적고_읽음(tmp_path: Path) -> None: from B05_Profile import B05_Profile_Router_Replan as replan from B05_Profile.B05_Profile_Engine_Sections import load_station_snap path = _working(tmp_path) assert load_station_snap(tmp_path) is False # 파일 없음 replan._write_planned_polyline(path, _NODES, 30.0, simplify=False) assert "station_snap" not in json.loads(replan._curves_path(path).read_text("utf-8")) assert load_station_snap(tmp_path) is False # 칸 없음 = 끔(옛 프로젝트) replan._write_planned_polyline(path, _NODES, 30.0, simplify=False, station_snap=True) assert load_station_snap(tmp_path) is True replan._write_planned_polyline(path, _NODES, 30.0, simplify=False, station_snap=False) assert load_station_snap(tmp_path) is False def _patch_replan(monkeypatch, tmp_path: Path): from B05_Profile import B05_Profile_Router_Replan as replan async def paths(_project_id): return tmp_path, "stored" async def criteria(_project_id): return 30.0, 0.0, 0.0 monkeypatch.setattr(replan, "_project_paths", paths) monkeypatch.setattr(replan, "_plan_criteria", criteria) return replan def test_실패하면_곡선표_노드도_되돌림(tmp_path: Path, monkeypatch) -> None: import asyncio from uuid import uuid4 replan = _patch_replan(monkeypatch, tmp_path) async def broken(*_args, **_kwargs): return {"error": "체인 끊김"} monkeypatch.setattr(replan, "_recompute", broken) path = _working(tmp_path) replan._write_planned_polyline(path, _NODES, 30.0, simplify=False) files = (path, replan._curves_path(path), replan._nodes_path(path)) before = {file: file.read_bytes() for file in files} request = replan.RouteReplanRequest( vertices=[{"x": 0, "y": 0}, {"x": 320, "y": 40}, {"x": 300, "y": 300}], station_snap=True, ) response = asyncio.run(replan.replan_route(uuid4(), request)) assert response.status_code == 409 assert {file: file.read_bytes() for file in files} == before def test_GET_계획노선이_스냅_값을_돌려줌(tmp_path: Path, monkeypatch) -> None: import asyncio from uuid import uuid4 replan = _patch_replan(monkeypatch, tmp_path) path = _working(tmp_path) replan._write_planned_polyline(path, _NODES, 30.0, simplify=False) assert asyncio.run(replan.read_route_plan(uuid4()))["station_snap"] is False replan._write_planned_polyline(path, _NODES, 30.0, simplify=False, station_snap=True) assert asyncio.run(replan.read_route_plan(uuid4()))["station_snap"] is True def test_측점_셈_둘_다_스냅을_읽음(tmp_path: Path, monkeypatch) -> None: from B05_Profile import B05_Profile_Engine_Sections as sections from B05_Profile import B05_Profile_Router_Replan as replan path = _working(tmp_path) replan._write_planned_polyline(path, _NODES, 30.0, simplify=False) replan._curves_path(path).write_text( json.dumps({"curves": _CURVES, "station_snap": True}), encoding="utf-8" ) monkeypatch.setattr(sections, "_load_route_polyline", lambda *_args: _LINE) monkeypatch.setattr(sections, "build_surface_sampler", lambda *_args, **_kw: _Flat()) # [저장] 때 구조물 측점 셈 — 스냅 켬도 격자 그대로(280-4) · 640 은 격자 · 631 은 따로 선 측점 stations, _ = sections.sample_structure_sections( tmp_path, "route.geojson", "f", "m", False, extra_stations=((631.0, "관"), (640.0, "관")) ) by_chainage = {s["chainage_m"]: s for s in stations} assert sorted(by_chainage) == [631.0, 640.0] assert by_chainage[640.0]["grid"] is True and "grid" not in by_chainage[631.0] seen: dict = {} def stop(*_args, **kwargs): seen.update(kwargs) raise RuntimeError("여기까지") monkeypatch.setattr(sections, "generate_sections", stop) monkeypatch.setattr(sections, "_load_pipe_points", lambda *_args: []) monkeypatch.setattr(sections, "resolve_extra_stations", lambda *_args: ()) with pytest.raises(RuntimeError): sections.run_section_generation(tmp_path, "route.geojson", "f", "m", False) assert seen["station_snap"] is True