- 파일 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>
338 lines
13 KiB
Python
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)
|