Files
Aislo/resources/tester/test_591_2_plan_points.py
T
eomsangdonandClaude Opus 5.5 bf77b70be9 feat(D 예정노선): 저장 꼴에 지점 이름 · 선 이름 · 노드 기록 칸 · [저장] 몸 기준점 (PLAN 594-1 · 594-3 · 요구 463)
- 파일 points [{x, y, name?}] · lines[].name? · lines[].nodes?(꼭짓점 수와 같음 · kind free/length/slope/both · length_m · slope_pct) — 칸 없는 옛 파일 그대로 읽힘
- PUT 몸 point_names · site{lon, lat, address?} — 기준점이 바뀌면 같은 트랜잭션에서 projects.site_* 고침(주소 = 서버 VWorld · 못 찾으면 몸 주소)
- 이름 20 자 · 파일 이름 못 쓰는 글자 막음 — 서버 400(name_error) ↔ TS nameError 짝 · 노드 기록 수 틀림 400
- 선 이름 = 붙인 이름 · 없으면 차례 이름(lineNames ↔ line_names 짝 · 내보내기 조각 이름에 이어짐)
- TS 꼴 NodeKind · NodeRecord · PlanLine.name/nodes · BasePoint.name · ServerNetwork.point_names/site · searchPlaces 틀
- 시험 530_3 이름 거울 · 591_2 왕복 · 400 · 기준점

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:40:34 +09:00

338 lines
13 KiB
Python

"""591-2 D02 기준 지정 걷음 · 지점 = D03 노선분석(요구 452 · 453) — 옛 529-3 시험 갈음.
- 지점 = 설계 내용 파일 `points` · D03 [저장](PUT plan/network) 몸에 실림 ·
빈 값 = 저장된 지점 그대로 · [초기화](빈 선) 뒤에도 지점 남음 · 상한 40 · 산림청만.
- GET = 지점(TM + 경위도) · 등록 기준점 · 좌표계(파일 머리 · 없으면 기준점 경도 TM 띠).
- 옛 지점 칸(시점 · 종점 목록 · 한 점 꼴)은 열 때 옮겨 보임.
- 핀 끌기 = 그 자리 노선 노드 같이(TS `moveNodesAt` · 붙은 갈래 첫 점 · 다른 점은 그대로).
- D02 길 · 단계 · 폴더가 남지 않음.
- 594-1 지점 · 선 이름 · 노드 기록 왕복 · 옛 파일 읽힘 · 21 자 · 금지 글자 400 ·
594-3 [저장] 몸 기준점 → 등록 칸(바뀔 때만 · 주소 = 서버 다시 찾음).
./venv/Scripts/python.exe -m pytest resources/tester/test_591_2_plan_points.py -q
"""
from __future__ import annotations
import json
import math
import re
import subprocess
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import require_company
from common_util.common_util_crs import lonlat_to_tm, tm_epsg_for_lon, tm_to_lonlat
from D03_PlanAnalysis import D03_PlanAnalysis_Network as net
from D03_PlanAnalysis import D03_PlanAnalysis_Store as store
ROOT = Path(__file__).resolve().parents[2]
PID = "12953f5a-5ba1-42ad-a427-8ac857a58bc2"
URL = f"/api/projects/{PID}/plan/network"
SITE = {"lon": 127.11, "lat": 37.505, "address": "경기도 어딘가 산 1"}
A, B = lonlat_to_tm(127.10, 37.50, 5186), lonlat_to_tm(127.12, 37.51, 5186)
LINE = {"id": "main", "vertices": [list(A), list(B)], "branch": None}
# --- 좌표 변환(옛 529-3 에서 옮김) ---
@pytest.mark.parametrize("epsg, lon0", [(5185, 125.0), (5186, 127.0), (5187, 129.0)])
def test_origin_is_false_origin(epsg, lon0) -> None:
x, y = lonlat_to_tm(lon0, 38.0, epsg) # 원점 = (200 000, 600 000)
assert abs(x - 200000.0) < 0.001 and abs(y - 600000.0) < 0.001
@pytest.mark.parametrize("lon, lat", [(127.3851, 36.3504), (125.7, 34.1), (129.36, 36.02)])
def test_round_trip_within_1mm(lon, lat) -> None:
epsg = tm_epsg_for_lon(lon)
x, y = lonlat_to_tm(lon, lat, epsg)
back = lonlat_to_tm(*tm_to_lonlat(x, y, epsg), epsg)
assert abs(back[0] - x) < 0.001 and abs(back[1] - y) < 0.001
# --- 지점 = D03 [저장] ---
class _Conn:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def begin(self):
pass
async def commit(self):
pass
async def rollback(self):
pass
class _Pool:
def acquire(self):
return _Conn()
@pytest.fixture
def api(monkeypatch, tmp_path):
"""파일 = tmp_path 의 진짜 파일 · 없으면 빈 설계 · 연도 상태 줄은 안 씀."""
state = {
"root": tmp_path,
"site": SITE,
"session": {"role": "USER", "email": "kim@forest.go.kr", "company_id": 1},
}
async def load(project_id):
plan = store.read_plan(tmp_path)
return tmp_path, plan or store.empty_plan()
async def project_info(project_id):
return {"year_start": 2026, "site": state["site"]}
async def no_status(*args):
pass
monkeypatch.setattr(store, "load_plan", load)
monkeypatch.setattr(net, "project_info", project_info)
monkeypatch.setattr(net, "_store_status", no_status)
monkeypatch.setattr(net, "get_db_pool", lambda: _Pool())
app = FastAPI()
app.include_router(net.router)
app.dependency_overrides[require_company] = lambda: state["session"]
client = TestClient(app)
client.state = state
return client
def _saved(api) -> dict:
return json.loads(store.plan_path(api.state["root"]).read_text("utf-8"))
def test_new_project_site_and_epsg(api) -> None:
base = api.get(URL).json()["base"]
assert base == {"epsg": 5186, "points": [], "site": SITE} # 기준점 경도 TM 띠
api.state["site"] = None
assert api.get(URL).json()["base"]["epsg"] is None # 기준점 · 지점 없음 = 화면 안내
def test_save_points_with_lines(api) -> None:
body = {"epsg": 5186, "points": [list(A), list(B)], "lines": [LINE]}
got = api.put(URL, json=body)
assert got.status_code == 200
points = got.json()["base"]["points"]
assert [(p["x"], p["y"]) for p in points] == pytest.approx([A, B])
assert abs(points[0]["lon"] - 127.10) < 1e-8 and abs(points[1]["lat"] - 37.51) < 1e-8
saved = _saved(api)
assert saved["epsg"] == 5186 and set(saved["points"][0]) == {"x", "y"}
assert saved["lines"][0]["id"] == "main"
def test_points_kept_when_absent_and_after_reset(api) -> None:
api.put(URL, json={"epsg": 5186, "points": [list(A)], "lines": [LINE]})
api.put(URL, json={"epsg": 5186, "lines": [LINE]}) # 빈 값 = 그대로
assert len(_saved(api)["points"]) == 1
# [초기화] = 빈 선 · 지점 실어 보냄 → 선만 지워짐
reset = api.put(URL, json={"epsg": 5186, "points": [list(A)], "lines": []}).json()
assert reset["network"] is None and len(reset["base"]["points"]) == 1
assert _saved(api)["lines"] == [] and len(_saved(api)["points"]) == 1
api.put(URL, json={"epsg": 5186, "points": [], "lines": []})
assert _saved(api)["points"] == []
def test_points_limit_and_forest_only(api) -> None:
many = [list(A)] * 40
assert api.put(URL, json={"epsg": 5186, "points": many, "lines": []}).status_code == 200
many.append(list(A))
assert api.put(URL, json={"epsg": 5186, "points": many, "lines": []}).status_code == 422
api.state["session"] = {"role": "USER", "email": "lee@company.com", "company_id": 1}
assert api.put(URL, json={"epsg": 5186, "points": [], "lines": []}).status_code == 403
def test_epsg_stays_once_saved(api) -> None:
api.put(URL, json={"epsg": 5186, "points": [list(A)], "lines": []})
got = api.put(URL, json={"epsg": 5187, "points": [list(A)], "lines": []})
assert got.status_code == 400 # 파일 머리 좌표계 한 곳
# --- 594-1 지점 · 선 이름 · 노드 기록 ---
NODES = [None, {"kind": "both", "length_m": 20.0, "slope_pct": 8.5}]
def test_names_nodes_round_trip(api) -> None:
# 옛 파일(이름 · 기록 칸 없음) 그대로 읽힘
store.save_plan(
api.state["root"],
{**store.empty_plan(5186), "points": [{"x": A[0], "y": A[1]}], "lines": [LINE]},
)
old = api.get(URL).json()
assert "name" not in old["base"]["points"][0] and "name" not in old["network"]["lines"][0]
line = {**LINE, "name": " 용화 본선 ", "nodes": NODES}
branch = {
"id": "branch-1",
"vertices": [list(B), [B[0] + 50, B[1]]],
"branch": {"line": "main", "vertex": 1},
}
body = {
"epsg": 5186,
"points": [list(A), list(B)],
"point_names": ["시점", None],
"lines": [line, branch],
}
got = api.put(URL, json=body)
assert got.status_code == 200, got.text
view = api.get(URL).json()
assert [p.get("name") for p in view["base"]["points"]] == ["시점", None]
main, side = view["network"]["lines"]
assert main["name"] == "용화 본선" and main["nodes"] == NODES
assert "name" not in side and "nodes" not in side # 빈 칸 = 옛 꼴 그대로
saved = _saved(api)
assert saved["points"][1] == {"x": B[0], "y": B[1]}
# 이름만 다시 [저장] = 같은 값
again = api.put(URL, json={**body, "lines": view["network"]["lines"]}).json()
assert again["network"]["lines"] == view["network"]["lines"]
@pytest.mark.parametrize(
"patch",
[
{"lines": [{**LINE, "name": "가" * 21}]},
{"lines": [{**LINE, "name": "a/b"}]},
{"lines": [{**LINE, "name": 'a"b'}]},
{"point_names": ["시:점"]},
{"point_names": ["가" * 21]},
{"point_names": ["a", "b"]}, # 지점보다 많음
{"lines": [{**LINE, "nodes": [None]}]}, # 꼭짓점 둘 · 기록 하나
],
)
def test_bad_names_nodes_400(api, patch) -> None:
body = {"epsg": 5186, "points": [list(A)], "lines": [LINE], **patch}
assert api.put(URL, json=body).status_code == 400
assert not store.plan_path(api.state["root"]).exists() # 안 씀
def test_name_20_ok_and_bad_kind_422(api) -> None:
body = {"epsg": 5186, "points": [list(A)], "point_names": ["가" * 20], "lines": [LINE]}
assert api.put(URL, json=body).status_code == 200
bad = {**LINE, "nodes": [None, {"kind": "grade", "length_m": 1, "slope_pct": 1}]}
assert api.put(URL, json={"epsg": 5186, "lines": [bad]}).status_code == 422
# --- 594-3 [저장] 몸 기준점 → 등록 칸 ---
def test_site_saved_with_network(api, monkeypatch) -> None:
written = []
async def store_site(connection, project_id, site):
written.append(site)
async def place(lon, lat):
return {"address": f"주소 {lon:.3f}", "region": "어딘가"}
monkeypatch.setattr(net, "_store_site", store_site)
monkeypatch.setattr(net, "reverse_place", place)
same = {"lon": SITE["lon"], "lat": SITE["lat"]}
api.put(URL, json={"epsg": 5186, "lines": [LINE], "site": same})
api.put(URL, json={"epsg": 5186, "lines": [LINE]})
assert written == [] # 그대로 · 빈 값 = 안 씀
moved = {"lon": 127.2, "lat": 37.6, "address": "몸 주소"}
got = api.put(URL, json={"epsg": 5186, "lines": [LINE], "site": moved}).json()
assert written == [{"lon": 127.2, "lat": 37.6, "address": "주소 127.200"}] # 서버가 다시 찾음
assert got["base"]["site"] == written[0]
async def no_place(lon, lat):
return {"address": None, "region": None}
monkeypatch.setattr(net, "reverse_place", no_place)
api.put(URL, json={"epsg": 5186, "lines": [LINE], "site": {**moved, "lon": 127.3}})
assert written[-1]["address"] == "몸 주소" # 못 찾으면 몸 주소
bad = api.put(URL, json={"epsg": 5186, "lines": [LINE], "site": {"lon": 200, "lat": 37}})
assert bad.status_code == 422
# --- D02 걷음 ---
def test_d02_removed() -> None:
assert not (ROOT / "D02_PlanBase" / "D02_PlanBase_Router.py").exists()
assert "D02_PlanBase" not in (ROOT / "main.py").read_text(encoding="utf-8")
front = (ROOT / "config" / "config_frontend.ts").read_text(encoding="utf-8")
assert "D02_PLAN_BASE" not in front and "d02-plan-base" not in front
from common_util.common_util_project_kind import KIND_STAGE_DIRS
from common_util.common_util_workflow_state import STAGE_KEYS_BY_KIND
assert STAGE_KEYS_BY_KIND["PLAN"] == ["PLAN_ANALYSIS", "PLAN_CONFIRM"]
assert "D02_PlanBase" not in KIND_STAGE_DIRS["PLAN"]
for path in (
"D01_PlanRegister/D01_PlanRegister_UI_Page.ts",
"B01_Dashboard/B01_Dashboard_UI_View_Assigned.ts",
):
assert "ROUTES.D03_PLAN_ANALYSIS" in (ROOT / path).read_text(encoding="utf-8")
# --- 핀 끌기 = 노드 같이(TS) ---
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Plan.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
_RUNNER = """
import { writeFileSync } from "node:fs";
import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js";
const Y = 2026;
// 본선 (0,0)→(100,0)→(100,100) · 갈래 (100,0)→(150,0) · 다른 본선이 (100,0) 에서 시작
let n = plan.newLine(plan.emptyNetwork(), [0, 0]);
n = plan.appendVertex(plan.appendVertex(n, 0, [100, 0]), 0, [100, 100]);
n = plan.appendVertex(plan.addBranch(n, 0, 1), 1, [150, 0]);
n = plan.appendVertex(plan.newLine(n, [100, 0]), 2, [100, -50]);
n = plan.normalizePieces(n, Y);
const moved = plan.moveNodesAt(n, [100, 0], [110, 5]);
const none = plan.moveNodesAt(n, [55, 55], [0, 0]);
writeFileSync(process.argv[2], JSON.stringify({
lines: moved.lines.map((l) => l.vertices),
same: none === n,
pieces: plan.normalizePieces(moved, Y).pieces.map((p) => [p.line, p.to_m]),
}));
"""
def _run_ts(tmp_path: Path) -> dict:
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--rootDir", str(ROOT),
"--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler",
"--ignoreConfig"],
cwd=str(ROOT), check=True, capture_output=True,
) # fmt: skip
for emitted in out.rglob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
result = tmp_path / "out.json"
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(result)],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
def test_pin_drag_moves_nodes(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
main, branch, other = got["lines"]
assert main == [[0, 0], [110, 5], [100, 100]] # 지점 자리 꺾임점
assert branch[0] == [110, 5] and branch[1] == [150, 0] # 붙은 갈래 첫 점 같이
assert other == [[110, 5], [100, -50]] # 같은 자리 다른 선 머리도
assert got["same"] # 꺾임점 없는 자리 = 그대로
lengths = dict(got["pieces"])
# 조각 = 새 길이(1 mm 반올림)
assert lengths["main"] == pytest.approx(math.hypot(110, 5) + math.hypot(10, 95), abs=1e-3)