"""요구 224-2 · PLAN 326 — 계획노선 편집 [측점 스냅]. 짝: 파이썬 `B05_Profile_Engine_Sections_Core` (`_chainages` · `_design_curve_spans` · `snap_curve_stations`) ↔ TS `B05_Profile_UI_RouteEdit_Frame.ts` (`stationsAlong` · `curveSpans` · `snapCurveStations`). 켜면 호(BC~EC) 안 측점 중 곡선 중점 MC 에 가장 가까운 하나를 floor(MC+0.5) 로 옮김 · 측점 없는 호는 안 심음 · 같은 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 _snap(spans: list[tuple[float, float]], stations: list[float] | None = None) -> list[float]: return [float(v) for v in core.snap_curve_stations(stations or GRID, spans)] # ── 파이썬 규칙 ──────────────────────────────────────────────────────────────── def test_곡선_없음_그대로() -> None: assert _snap([]) == GRID def test_측점_없는_호는_안_심음() -> None: assert _snap([(621.0, 639.0)]) == GRID def test_호_안_하나를_정수_MC_로() -> None: out = _snap([(612.0, 634.0)]) # MC 623 assert 623.0 in out and 620.0 not in out and len(out) == len(GRID) def test_호_안_둘이면_MC_에_가까운_하나만() -> None: out = _snap([(616.1, 646.5)]) # MC 631.3 — 640(8.7) 이 620(11.3) 보다 가까움 assert 631.0 in out and 640.0 not in out and 620.0 in out def test_BC_위_측점은_호_밖() -> None: assert _snap([(620.0, 630.0)]) == GRID assert _snap([(610.0, 620.0)]) == GRID # EC 위도 밖 def test_반_올림() -> None: out = _snap([(611.0, 640.0)]) # MC 625.5 → 626 · 640 = EC 위라 밖 assert 626.0 in out and 620.0 not in out and 640.0 in out def test_같은_m_는_하나로() -> None: # 20.3 이 MC 20.35 → 20 으로 옮겨 이웃 20 과 같은 m — 하나만 남음 assert _snap([(20.1, 20.6)], [0.0, 20.0, 20.3, 40.0]) == [0.0, 20.0, 40.0] # ── 파이썬 ↔ 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, snapped: frame.snapCurveStations(grid, spans) }; }), }; 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], "snapped": [float(v) for v in core.snap_curve_stations(grid, spans)], } 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 moved = 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["snapped"] == pytest.approx(expected["snapped"], abs=1e-9), f"사례 {index} 스냅" moved += row["snapped"] != row["grid"] assert moved >= 3 # 옮김이 실제로 일어난 사례를 견줌 def test_짝_머리글이_서로를_가리킨다() -> None: text = TS_FILE.read_text(encoding="utf-8") assert "B05_Profile_Engine_Sections_Core.py" in text and "snap_curve_stations" in text assert "snapCurveStations" in (core.__doc__ or "") # ── 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: by_chainage = {s["chainage_m"]: s for s in _stations(True)} assert 640.0 not in by_chainage and 631.0 in by_chainage assert by_chainage[631.0]["kind"] == "regular" assert by_chainage[631.0]["plan_radius_m"] == 40.0 # 호 안 = 곡선표 반경 assert len(by_chainage) == len(_stations(False)) def test_631_에_관이면_grid() -> None: by_chainage = {s["chainage_m"]: s for s in _stations(True, ((631.0, "관"),))} assert by_chainage[631.0]["kind"] == "irregular" assert by_chainage[631.0]["grid"] is True # 640 에 놓인 관은 스냅 뒤 격자가 아니라 따로 선 측점 off_grid = {s["chainage_m"]: s for s in _stations(True, ((640.0, "관"),))} assert off_grid[640.0]["kind"] == "irregular" and "grid" not in off_grid[640.0] # ── 서버 저장 · 정본 (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()) # [저장] 때 구조물 측점 셈 — 631 은 스냅한 격자 자리 stations, _ = sections.sample_structure_sections( tmp_path, "route.geojson", "f", "m", False, extra_stations=((631.0, "관"),) ) assert [s["chainage_m"] for s in stations] == [631.0] and stations[0]["grid"] is True 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 def test_횡단_미리보기도_스냅(tmp_path: Path, monkeypatch) -> None: from B05_Profile import B05_Profile_Router_Terrain as terrain from common_util.common_util_route_polyline import build_planned_polyline monkeypatch.setattr(terrain, "build_surface_sampler", lambda *_args, **_kw: _Flat()) built = build_planned_polyline(_NODES, min_radius_m=40.0, simplify=False) points, chain = core._clean_polyline(np.asarray(built.vertices, dtype=np.float64)) spans = core._design_curve_spans(points, chain, [c.as_dict() for c in built.curves]) assert len(spans) == 1 target = math.floor((spans[0][0] + spans[0][1]) / 2 + 0.5) assert target % 20 # 격자 밖 자리여야 견줄 뜻이 있음 def ask(snap: bool) -> float: request = terrain.CrossPreviewRequest( vertices=[{"x": x, "y": y} for x, y in _NODES], chainage_m=target, min_radius_m=40.0, station_interval_m=20.0, station_snap=snap, ) params = {"source_filter": "f", "method": "m", "smooth": False} return terrain._cross_preview(tmp_path, params, request)["chainage_m"] assert ask(True) == target assert ask(False) % 20 == 0