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