"""D 예정노선 2단계 노선 정본(PLAN 562-2 · 590-1) — 손 그은 선 · 갈래 · 마크 · 조각 → 서버가 다시 셈 · 연도 묶음 · 옛 연도 노선을 선으로 · GET · PUT(산림청만 · 틀린 꼴 거절 · 업체 지정된 조각 지움 막음) · [저장] = 설계 내용 파일 + 연도 상태 줄만(옛 DB 설계 칸 안 씀) · 파일 쓰기 실패 = DB 되돌림. 옛 추천 엔진 시험(합성 지형 갈래 넷 · 저장본 · 나란히 셈)은 엔진과 함께 걷음. ./venv/Scripts/python.exe -m pytest resources/tester/test_530_2_plan_analyze.py -q """ import json import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from common_util.common_util_auth import require_company from D03_PlanAnalysis import D03_PlanAnalysis_Network as net from D03_PlanAnalysis import D03_PlanAnalysis_Store as store PID = "12953f5a-5ba1-42ad-a427-8ac857a58bc2" MAIN = [[0.0, 0.0], [30.0, 40.0], [30.0, 100.0], [90.0, 180.0]] # 50 + 60 + 100 = 210 m BRANCH = [[30.0, 100.0], [30.0, 150.0]] # 본선 점 2 에서 50 m def _network(**over) -> dict: body = { "epsg": 5186, "lines": [ {"id": "main", "vertices": MAIN, "branch": None}, {"id": "b1", "vertices": BRANCH, "branch": {"line": "main", "vertex": 2}}, ], "marks": [{"line": "main", "at_m": 75.0}], "pieces": [ {"id": "p1", "line": "main", "from_m": 0, "to_m": 75.0, "year": 2026}, {"id": "p2", "line": "main", "from_m": 75.004, "to_m": 210.0, "year": 2027}, {"id": "p3", "line": "b1", "from_m": 0, "to_m": 50.0, "year": 2026}, ], } body.update(over) return body def test_derive_pieces_lengths_stations() -> None: got = net.derive(_network(), 2026) assert [(x["id"], x["length_m"]) for x in got["lines"]] == [("main", 210), ("b1", 50)] assert got["lines"][0]["stations"][-2:] == [200, 210] and got["lines"][1]["stations"] == [ 0, 20, 40, 50, ] pieces = {p["id"]: p for p in got["pieces"]} assert [p["id"] for p in got["pieces"]] == ["p1", "p2", "p3"] # 선 차례 · 앞에서부터 # 경계 = 마크 그대로(보낸 75.004 아님) assert pieces["p2"]["from_m"] == 75.0 and pieces["p2"]["length_m"] == 135.0 assert pieces["p1"]["planned"] == [[0, 0], [30, 40], [30, 65]] assert pieces["p1"]["stations"] == [0, 20, 40, 60, 75] assert pieces["p2"]["stations"][:2] == [75, 80] assert pieces["p3"]["planned"] == BRANCH # 갈래 측점 = 갈래마다 No.0 @pytest.mark.parametrize( "over", [ {"marks": [{"line": "main", "at_m": 210.0}]}, # 마크가 끝 {"marks": [{"line": "zz", "at_m": 10.0}]}, {"marks": [{"line": "main", "at_m": 75.0}, {"line": "main", "at_m": 75.0005}]}, {"pieces": [{"id": "p1", "line": "main", "from_m": 0, "to_m": 80.0, "year": 2026}]}, {"pieces": [{"id": "p1", "line": "main", "from_m": 0, "to_m": 75.0, "year": 2025}]}, { "pieces": [ {"id": "p1", "line": "main", "from_m": 0, "to_m": 75.0, "year": 2026}, {"id": "p9", "line": "main", "from_m": 0, "to_m": 75.0, "year": 2027}, ] }, { "lines": [ {"id": "b1", "vertices": BRANCH, "branch": {"line": "main", "vertex": 2}}, {"id": "main", "vertices": MAIN, "branch": None}, ], "marks": [], "pieces": [], }, # 부모가 뒤 { "lines": [ {"id": "main", "vertices": MAIN, "branch": None}, {"id": "b1", "vertices": BRANCH, "branch": {"line": "main", "vertex": 1}}, ], }, # 갈래 첫 점 ≠ 부모 점 {"lines": [{"id": "main", "vertices": [[0, 0], [float("nan"), 1]], "branch": None}]}, ], ) def test_derive_rejects(over) -> None: with pytest.raises(net.NetworkError): net.derive(_network(**over), 2026) def test_year_rows_group_pieces() -> None: rows = net.year_rows(net.derive(_network(), 2026)["pieces"]) assert list(rows) == [2026, 2027] first = rows[2026]["route"] assert first["mode"] == "manual" and [p["id"] for p in first["pieces"]] == ["p1", "p3"] assert first["planned"] == [[0, 0], [30, 40], [30, 65]] and first["length_m"] == 125.0 assert rows[2026]["start"] == [0, 0] and rows[2026]["end"] == [30, 65] assert rows[2027]["route"]["start_chainage_m"] == 75.0 class _Cursor: def __init__(self, db): self.db = db self.last = "" async def __aenter__(self): return self async def __aexit__(self, *exc): return False async def execute(self, sql, args=None): self.last = sql self.db.log.append((sql, args)) async def fetchone(self): return self._rows()[0] if self._rows() else None async def fetchall(self): return self._rows() def _rows(self): for key, rows in self.db.answers.items(): if key in self.last: return rows return [] class _Connection: def __init__(self, db): self.db = db async def __aenter__(self): return self async def __aexit__(self, *exc): return False async def begin(self): pass async def commit(self): self.db.log.append(("COMMIT", None)) async def rollback(self): self.db.log.append(("ROLLBACK", None)) def cursor(self, *args): return _Cursor(self.db) class _Db: def __init__(self): self.log: list = [] self.answers: dict = {"FROM projects": [(2026, None, None, None)]} # 연도 · 기준점(591-2) def acquire(self): return _Connection(self) BASE = {"epsg": 5186, "points": [{"x": 0.0, "y": 0.0}, {"x": 90.0, "y": 180.0}]} @pytest.fixture def client(monkeypatch, tmp_path): """파일 = tmp_path 진짜 파일 · 없으면 `old` 설계(지점만) · DB = 가짜 기록.""" db = _Db() monkeypatch.setattr(net, "get_db_pool", lambda: db) old = {**store.empty_plan(5186), "points": BASE["points"]} async def load(project_id): plan = store.read_plan(tmp_path) return tmp_path, plan or dict(old) monkeypatch.setattr(store, "load_plan", load) app = FastAPI() app.include_router(net.router) session = {"role": "USER", "email": "a@forest.go.kr"} app.dependency_overrides[require_company] = lambda: session test = TestClient(app) test.db = db test.session = session test.old = old test.file = store.plan_path(tmp_path) return test URL = f"/api/projects/{PID}/plan/network" def test_get_reads_plan_or_empty(client) -> None: assert client.get(URL).json()["network"] is None client.old.update(_network()) got = client.get(URL).json() assert len(got["derived"]["pieces"]) == 3 and got["network"]["epsg"] == 5186 assert got["base"]["points"][1]["x"] == 90.0 and got["year_start"] == 2026 assert not client.file.exists() # 열기만 = 쓰지 않음 def test_put_writes_file_and_year_status_only(client) -> None: got = client.put(URL, json=_network()) assert got.status_code == 200 and got.json()["derived"]["pieces"][1]["from_m"] == 75.0 assert got.json()["year_start"] == 2026 saved = json.loads(client.file.read_text("utf-8")) assert saved["version"] == 2 and saved["epsg"] == 5186 assert saved["points"] == BASE["points"] # 몸에 지점 없음 = 그대로 assert [p["id"] for p in saved["pieces"]] == ["p1", "p2", "p3"] assert "length_m" not in json.dumps(saved) # 파생 저장 안 함 log = client.db.log assert log[-1][0] == "COMMIT" sqls = [s for s, _ in log] for column in ("plan_route_pieces", "plan_networks", "plan_base_json", "route_json"): assert not [s for s in sqls if column in s and not s.startswith("SELECT")], column years = [a for s, a in log if s.startswith("INSERT INTO plan_route_years")] assert [a[1] for a in years] == [2026, 2027] and all(len(a[2]) == 40 for a in years) assert ("UPDATE projects SET project_year_end = %s WHERE id = %s", (2027, PID)) in log stale = [a for s, a in log if "year NOT IN" in s] assert stale == [(PID, 2026, 2027)] # 없는 연도 줄 지움(빈 줄 남기지 않음) def test_put_reset_drops_year_rows(client) -> None: got = client.put(URL, json={"epsg": 5186, "lines": [], "marks": [], "pieces": []}) assert got.status_code == 200 and got.json()["derived"] == { "lines": [], "pieces": [], "sets": [], } sqls = [s for s, _ in client.db.log] assert "DELETE FROM plan_route_years WHERE project_id = %s" in sqls assert not any("project_year_end" in s for s in sqls) assert json.loads(client.file.read_text("utf-8"))["lines"] == [] def test_put_file_failure_rolls_back(client, monkeypatch) -> None: def broken(root, plan): raise OSError("디스크") monkeypatch.setattr(store, "save_plan", broken) with pytest.raises(OSError): client.put(URL, json=_network()) assert ("ROLLBACK", None) in client.db.log and ("COMMIT", None) not in client.db.log def test_put_rejects(client) -> None: bad = _network(marks=[{"line": "main", "at_m": 500.0}]) assert client.put(URL, json=bad).status_code == 400 assert ( client.put(URL, json=_network(lines=[{"id": "a b", "vertices": MAIN}])).status_code == 422 ) # 업체가 지정된 조각을 지우면 막음 · 파일 안 씀 client.db.answers["piece_id IS NOT NULL"] = [("p2",)] one = _network( lines=[{"id": "main", "vertices": MAIN}], marks=[], pieces=[{"id": "p1", "line": "main", "from_m": 0, "to_m": 210, "year": 2026}], ) assert client.put(URL, json=one).status_code == 409 assert not client.file.exists() and ("ROLLBACK", None) in client.db.log assert not [s for s, _ in client.db.log if s.startswith("INSERT")] client.db.answers["piece_id IS NOT NULL"] = [] assert client.put(URL, json=_network(epsg=5187)).status_code == 400 # 파일 좌표계와 다름 client.session["email"] = "a@gmail.com" assert client.put(URL, json=_network()).status_code == 403