Files
Aislo/resources/tester/test_224_2_station_snap.py
T

389 lines
15 KiB
Python

"""요구 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" / "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