- D02 걷음 — 길 · 단계(PLAN 단계 둘 = 노선 분석 · 확정) · [>] · 대시보드 단계 점 · 서버 길 · 폴더 만들기 · 캐시 등록 · 글 · 가이드 폴더 - 등록 [등록] · 받은 예정노선 [보기] = D03 으로 곧바로 - D03 지점 = 긋기에 붙는 점 · 목록 칸(여섯 색 · 「지점 N」 · 🗑) · 핀 끌기 = 그 자리 노선 노드 같이 · [저장] 몸에 실음 · [초기화] = 선만 지우고 지점 남김 - 지도 우클릭 메뉴(부품 상자) — 여기서 긋기 시작(빈 땅 = 새 본선) · 갈래 시작 · 이어 긋기 · 지점 놓기 · 지점 지우기 · 긋기 끝 - 받기 — 처음 = 등록 기준점 둘레 ± 1 km(지점 · 선 없을 때) · 지점 · 선 · 지도 보기 가운데가 받은 범위 밖이면 조용히 이어 받음(단추 · 막는 창 걷음 · 실패만 [다시 받기]) - 좌표계 = 파일 머리 · 없으면 기준점 경도 TM 띠 - 시험: 529_3(D02 지점 API) 지움 → 591_2 로 갈음 · 280_2 · 528 · 530_2 · 547_1 · 548 · 553_4 · 556_2 · 560_4 · 401 · 54_2 = D02 단계 · 페이지 걷음에 맞춤 · 590_1 · 530_2_analyze = 사업연도 · 기준점 한 번에 읽음 · 591_1 D04 씨앗 시험 = 590-1 이 씨앗 줄 걷음에 맞춤 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
290 lines
11 KiB
Python
290 lines
11 KiB
Python
"""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
|
|
|
|
|
|
def test_legacy_rows_become_one_line() -> None:
|
|
rows = [
|
|
{"year": 2026, "epsg": 5186, "route_json": json.dumps({"planned": [[0, 0], [30, 40]]})},
|
|
{"year": 2027, "epsg": 5186, "route_json": None},
|
|
{"year": 2028, "epsg": 5186, "route_json": {"planned": [[30, 40], [30, 100]]}},
|
|
]
|
|
got = net.legacy_network(rows)
|
|
assert got["lines"][0]["vertices"] == [[0, 0], [30, 40], [30, 100]]
|
|
assert got["marks"] == [{"line": "main", "at_m": 50.0}]
|
|
assert [(p["year"], p["from_m"], p["to_m"]) for p in got["pieces"]] == [
|
|
(2026, 0, 50),
|
|
(2028, 50, 110),
|
|
]
|
|
assert net.derive(got)["pieces"][1]["planned"] == [[30, 40], [30, 100]]
|
|
assert net.legacy_network([{"year": 2026, "epsg": 5186, "route_json": None}]) is None
|
|
|
|
|
|
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 진짜 파일 · 없으면 옛 DB 칸(`old`)을 옮김 · DB = 가짜 기록."""
|
|
db = _Db()
|
|
monkeypatch.setattr(net, "get_db_pool", lambda: db)
|
|
old = {"base": json.dumps(BASE), "network": None, "rows": []}
|
|
|
|
async def load(project_id):
|
|
plan = store.read_plan(tmp_path)
|
|
return tmp_path, plan or store.migrate(old["base"], old["network"], old["rows"])
|
|
|
|
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_migrates_old_db_or_empty(client) -> None:
|
|
assert client.get(URL).json()["network"] is None
|
|
client.old["rows"] = [
|
|
{"year": 2026, "epsg": 5186, "route_json": '{"planned": [[0, 0], [30, 40]]}'}
|
|
]
|
|
got = client.get(URL).json()
|
|
assert got["legacy"] is False and got["derived"]["pieces"][0]["length_m"] == 50
|
|
client.old["network"] = json.dumps(_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"] == 1 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": []}
|
|
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
|