"""626-2 예정노선 다시 추적 `POST /route/follow` · 고친 예정노선 `expected_route_edit.csv`. 요구 530 D3 · D4 · D8. 못박는 것 - follow = 체인 초기 폴리라인(`_ensure_planned_initial` 파일)과 같은 입력이면 같은 선 · 노드 · 곡선 · 편집표. - follow 는 아무것도 안 씀(프로젝트 폴더 그대로) · 경위도 입력 = TM 입력과 같은 선. - [저장](노선 초안 `expected`) = `expected_route_edit.csv` 씀 · `expected_route.csv` · 초기 폴리라인 · `initial_snapshot/` 그대로 · 없음 = 그대로 · [] = 지움 · GET `/route/plan` `expected_edited`. - [초기화] = 고친 예정노선 지움(스냅샷에 섞여 있어도). - follow 길은 `/route/replan` · `as_initial` 을 안 부름. """ from __future__ import annotations import asyncio import csv import hashlib import json import math from contextlib import asynccontextmanager from pathlib import Path from uuid import uuid4 import pytest from B05_Profile import B05_Profile_Router_Replan as replan from B05_Profile import B05_Profile_Router_RouteFollow as follow from common_util.common_util_crs import tm_to_lonlat from common_util.common_util_initial_snapshot import snapshot_dir from common_util.common_util_route_geometry import ( expected_route_csv_path, expected_route_edit_path, planned_route_initial_path, write_route_csv, ) ROOT = Path(__file__).resolve().parents[2] RADIUS_M = 30.0 EPSG = 5186 # 예정노선 점 묶음 꼴(3 m 간격 · 굽은 산길) — 사업지 TM LINE = [(200000.0 + 3 * i, 450000.0 + 40 * math.sin(i / 20)) for i in range(160)] def _patch(monkeypatch, root: Path) -> None: async def paths(_pid): return root, "stored" async def criteria(_pid): return RADIUS_M, 0.0, 0.0 monkeypatch.setattr(replan, "_project_paths", paths) monkeypatch.setattr(replan, "_plan_criteria", criteria) monkeypatch.setattr(follow, "_project_paths", paths) monkeypatch.setattr(follow, "_plan_criteria", criteria) def _rows(path: Path) -> list[list[float]]: with path.open(encoding="utf-8") as file: return [[float(r["x"]), float(r["y"])] for r in csv.DictReader(file)] def _tree(root: Path) -> dict[str, str]: return { str(p.relative_to(root)): hashlib.sha1(p.read_bytes()).hexdigest() for p in sorted(root.rglob("*")) if p.is_file() } def test_follow_은_체인_초기선과_같은_선(tmp_path: Path) -> None: write_route_csv(expected_route_csv_path(tmp_path), [{"x": x, "y": y} for x, y in LINE]) assert replan._ensure_planned_initial(tmp_path, RADIUS_M) target = planned_route_initial_path(tmp_path) table = json.loads(replan._curves_path(target).read_text(encoding="utf-8")) # 같은 입력 = 체인이 읽는 예정노선 파일 점(csv 4자리) same = [tuple(p) for p in replan._vertices_of(expected_route_csv_path(tmp_path))] line = follow.follow_route_line(same, RADIUS_M) assert line["planned"] == _rows(target) assert [[round(n["x"], 4), round(n["y"], 4)] for n in line["nodes"]] == _rows( replan._nodes_path(target) ) assert line["curves"] == table["curves"] and line["curves"] assert line["node_edits"] == table.get("edits") assert line["station_snap"] is True and table["station_snap"] is True def test_follow_은_안_씀_경위도도_같은_선(tmp_path: Path, monkeypatch) -> None: _patch(monkeypatch, tmp_path) (tmp_path / "keep.txt").write_text("x", encoding="utf-8") before = _tree(tmp_path) body = follow.RouteFollowRequest(points=LINE) tm = asyncio.run(follow.follow_route(uuid4(), body)) assert tm["status"] == "success" and len(tm["nodes"]) >= 3 class _Pool: @asynccontextmanager async def acquire(self): yield None async def epsg(*_a): return EPSG monkeypatch.setattr(follow, "get_db_pool", lambda: _Pool()) monkeypatch.setattr(follow, "get_surface_crs_epsg", epsg) lonlat = [tm_to_lonlat(x, y, EPSG) for x, y in LINE] geo = asyncio.run( follow.follow_route(uuid4(), follow.RouteFollowRequest(points=lonlat, crs="lonlat")) ) assert len(geo["nodes"]) == len(tm["nodes"]) assert all(math.dist(a, b) < 0.01 for a, b in zip(geo["planned"], tm["planned"], strict=True)) assert _tree(tmp_path) == before # 저장 0 short = asyncio.run(follow.follow_route(uuid4(), follow.RouteFollowRequest(points=LINE[:1]))) assert short.status_code == 400 def _save(root: Path, expected) -> None: nodes = [LINE[0], LINE[80], LINE[-1]] draft = {"nodes": nodes, "curveOn": [True] * 3, "stationSnap": True, "expected": expected} body = replan.RouteSaveRequest(vertices=[], draft=draft) out = asyncio.run(replan.replan_route(uuid4(), body)) assert out["status"] == "success" def test_저장은_고친_예정노선만_씀(tmp_path: Path, monkeypatch) -> None: _patch(monkeypatch, tmp_path) async def recompute(*_a, **_k): return {"route_id": 1} monkeypatch.setattr(replan, "_recompute", recompute) write_route_csv(expected_route_csv_path(tmp_path), [{"x": x, "y": y} for x, y in LINE]) replan._ensure_planned_initial(tmp_path, RADIUS_M) (snapshot_dir(tmp_path) / "B05_Profile__route").mkdir(parents=True) (snapshot_dir(tmp_path) / "B05_Profile__route" / "planned_route.csv").write_text("x") keep = [expected_route_csv_path(tmp_path), planned_route_initial_path(tmp_path)] fixed = {p: p.read_bytes() for p in keep} shot = _tree(snapshot_dir(tmp_path)) edited = [list(p) for p in LINE[::10]] _save(tmp_path, edited) assert _rows(expected_route_edit_path(tmp_path)) == [ [round(x, 4), round(y, 4)] for x, y in edited ] assert asyncio.run(replan.read_route_plan(uuid4()))["expected_edited"] == _rows( expected_route_edit_path(tmp_path) ) _save(tmp_path, None) # 없음 = 그대로 assert expected_route_edit_path(tmp_path).is_file() _save(tmp_path, []) # 지움 assert not expected_route_edit_path(tmp_path).exists() assert asyncio.run(replan.read_route_plan(uuid4()))["expected_edited"] == [] assert {p: p.read_bytes() for p in keep} == fixed assert _tree(snapshot_dir(tmp_path)) == shot # 초기값 그대로 def test_초기화는_고친_예정노선_지움(tmp_path: Path, monkeypatch) -> None: from B05_Profile import B05_Profile_Router_Corridor as corridor from B05_Profile import B05_Profile_Router_Lifecycle as lifecycle from common_util import common_util_initial_snapshot as snapshot class _Cursor: rowcount = 1 async def __aenter__(self): return self async def __aexit__(self, *_): return False async def execute(self, *_a): pass class _Connection: def cursor(self, *_a, **_k): return _Cursor() async def begin(self): pass async def commit(self): pass async def rollback(self): pass class _Pool: @asynccontextmanager async def acquire(self): yield _Connection() def _async(value): async def run(*_a, **_k): return value return run # 추천 [적용]이 고친 예정노선째 스냅샷을 뜬 경우도 — 복원 뒤 지움 stored = snapshot_dir(tmp_path) / "B05_Profile__route" stored.mkdir(parents=True) (stored / "expected_route_edit.csv").write_text("sequence,x,y\n", encoding="utf-8") (snapshot_dir(tmp_path) / "db.json").write_text(json.dumps({"routes": [{"id": 1}]})) write_route_csv(expected_route_edit_path(tmp_path), [{"x": 0, "y": 0}, {"x": 1, "y": 1}]) monkeypatch.setattr(lifecycle, "get_db_pool", lambda: _Pool()) monkeypatch.setattr(lifecycle, "get_project_storage_relative_path", _async("p")) monkeypatch.setattr(lifecycle, "resolve_stored_project_path", lambda _p: str(tmp_path)) monkeypatch.setattr(lifecycle, "get_latest_route", _async({"id": 9})) monkeypatch.setattr(lifecycle, "start_stage", _async(None)) monkeypatch.setattr(snapshot, "restore_initial_snapshot", _async(9)) monkeypatch.setattr(corridor, "prune_corridor_files", lambda *_a: 0) response = asyncio.run(lifecycle.reset_route_design(uuid4(), None)) assert json.loads(response.body)["restored"] is True assert not expected_route_edit_path(tmp_path).exists() @pytest.mark.parametrize("name", ["B05_Profile_Router_RouteFollow.py"]) def test_follow_길은_replan_as_initial_안_부름(name: str) -> None: text = (ROOT / "B05_Profile" / name).read_text(encoding="utf-8") code = text.split('"""', 2)[2] # 머리글 뺀 몸통 assert "replan_route" not in code and "as_initial" not in code assert "expected_route_csv_path" not in code and "write_" not in code