Files
Aislo/resources/tester/test_626_2_route_follow.py
T
eomsangdonandClaude Opus 5.5 fea37b4ce8 feat(B05): 예정노선 다시 추적 POST /route/follow · 고친 예정노선 expected_route_edit.csv (PLAN 626-2 · 요구 530 D3 · D4 · D8)
- 새 `B05_Profile_Router_RouteFollow.py` — 고친 예정노선(TM · 경위도) → 평면선(노드 · 곡선 · 편집표 · GET /route/plan 칸 이름) · 저장 0
- 같은 함수 — `Route_Polyline_Write.followed_table` 을 체인 초기 폴리라인(`_ensure_planned_initial`)과 함께 씀 · 셈 몫 `polyline_table` 을 쓰기에서 나눔
- [저장] — 노선 초안 `expected`(없음 그대로 · [] 지움 · 점 묶음 씀) → `B05_Profile/route/expected_route_edit.csv` · GET /route/plan 덧칸 `expected_edited`
- [초기화] — 고친 예정노선 지움 · `expected_route.csv` · 초기값 · 추천 저장본 · `/route/replan` · `as_initial` 그대로
- 시험 — 새 `test_626_2_route_follow`(follow = 체인 초기선 · 저장 0 · 경위도 · edit csv 씀 · 지움 · initial_snapshot 지문 · 초기화 지움)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:30:32 +09:00

222 lines
8.7 KiB
Python

"""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