- 새 `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>
222 lines
8.7 KiB
Python
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
|