"""D 지형 API(PLAN 530-1 · 561-2 · 591-2 · 595) — 범위 = 지점(설계 내용 파일) 상자 + 여유 · 지점 · 선이 없으면 등록 기준점 둘레 ± 1 km · 받기 = 한 벌 · 위치 칸 가운데 = 둘레 덧 · 멀다 · 다른 띠 물음 · 새로(reset) · 칸 상한 413 · 서버 다시 켜짐 = 끊김. 옛 분석 API 시험(GET · PUT analysis · 꺾임점 고침)은 옛 추천 엔진과 함께 걷음(PLAN 562-2) · 노선 정본 시험 = `test_530_2_plan_analyze.py` · 지형 셈 시험 = `test_530_1_plan_terrain.py`. ./venv/Scripts/python.exe -m pytest resources/tester/test_530_2_plan_router.py -q """ import asyncio import json from contextlib import asynccontextmanager import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from D03_PlanAnalysis import D03_PlanAnalysis_Router as router_mod PID = "12953f5a-5ba1-42ad-a427-8ac857a58bc2" P0, P2 = (0.0, 0.0), (900.0, 400.0) URL = f"/api/projects/{PID}/plan/terrain" OX, OY = 200000.0, 500000.0 # 595 시험 = 중부 띠 안(경도 127°) Q0, Q2 = (OX + P0[0], OY + P0[1]), (OX + P2[0], OY + P2[1]) class _Pool: @asynccontextmanager async def acquire(self): yield None @pytest.fixture def client(tmp_path, monkeypatch): plan = { "epsg": 5186, "points": [{"x": P0[0], "y": P0[1]}, {"x": P2[0], "y": P2[1]}], "lines": [], } info = {"year_start": 2026, "site": None} async def load_plan(project_id): return tmp_path, plan async def project_info(project_id): return info monkeypatch.setattr(router_mod.store, "load_plan", load_plan) monkeypatch.setattr(router_mod.network, "project_info", project_info) monkeypatch.setattr(router_mod, "get_db_pool", lambda: _Pool()) app = FastAPI() app.include_router(router_mod.router) test = TestClient(app) test.plan = plan test.info = info test.root = tmp_path return test def test_extent_uses_whole_ends(client) -> None: got = client.get(f"{URL}/extent?margin_m=400").json() m = 400.0 assert got["margin_m"] == m and got["have"] is None and got["covered"] is False # 지점 모두(D02 정본) — 연도 구간 아님 assert got["box_m"] == [P0[0] - m, P0[1] - m, P2[0] + m, P2[1] + m] extra = client.get(f"{URL}/extent", params={"extra": json.dumps([[2000, 0]])}).json() assert extra["box_m"][2] == 2000 + extra["margin_m"] assert client.get(f"{URL}/extent", params={"extra": "[1]"}).status_code == 400 client.plan["points"] = [] # 지점 · 기준점 빔 assert client.get(f"{URL}/extent").status_code == 400 def test_extent_site_first(client) -> None: """591-2 — 지점 · 선 없음 = 기준점 둘레 ± 1 km(처음 여유 포함) · 좌표계 = 파일 머리 없으면 기준점 띠.""" from common_util.common_util_crs import lonlat_to_tm client.plan.update(epsg=None, points=[]) client.info["site"] = {"lon": 127.8, "lat": 36.3, "address": None} got = client.get(f"{URL}/extent").json() x, y = lonlat_to_tm(127.8, 36.3, 5186) r = router_mod.terrain.SITE_RADIUS_M assert got["epsg"] == 5186 assert got["box_m"] == pytest.approx([x - r, y - r, x + r, y + r]) # 지점이 있으면 기준점은 안 넣음(받은 범위가 이미 덮음 · 옛 프로젝트 그대로) client.plan.update(epsg=5186, points=[{"x": P0[0], "y": P0[1]}]) got = client.get(f"{URL}/extent?margin_m=400").json() assert got["box_m"] == [P0[0] - 400, P0[1] - 400, P0[0] + 400, P0[1] + 400] def test_post_runs_one_set(client, monkeypatch) -> None: calls: list = [] saved: list = [] works: list = [] def ensure(root, points, epsg, margin_m, report, **kw): calls.append((points, epsg, margin_m)) return {"sheets": {"downloaded": ["1"]}, "models": [{"model_file_path": "m"}]} async def save(project_id, models, epsg): saved.append((models, epsg)) def start(project_id, kind, work, root=None): works.append(work) return {"status": "accepted", "kind": kind} def post(body): res = client.post(URL, json=body) asyncio.run(works.pop()(lambda percent, message: None)) return res monkeypatch.setattr(router_mod.terrain, "ensure_plan_terrain", ensure) monkeypatch.setattr(router_mod, "_save_models", save) monkeypatch.setattr(router_mod, "_start_job", start) res = post({"margin_m": 500, "extra": [[5, 6]]}) assert res.json() == {"status": "accepted", "kind": "terrain"} assert calls == [([P0, P2, (5.0, 6.0)], 5186, 500.0)] assert saved == [([{"model_file_path": "m"}], 5186)] # 옛 화면 꼴(points 몸) — 여유 처음 값 · 전체 시점 · 종점 post({"points": []}) assert calls[-1] == ([P0, P2], 5186, None) # ── 595 위치 바꿈 · 상한 · 끊김 ── @pytest.fixture def posting(client, monkeypatch): """POST 받기 — 일은 바로 돌림 · ensure 부름(점 · 좌표계 · reset · center) 기록.""" calls: list = [] def ensure(root, points, epsg, margin_m, report, reset=False, center=None): calls.append({"points": points, "epsg": epsg, "reset": reset, "center": center}) return {"sheets": {"downloaded": []}, "models": []} async def save(project_id, models, epsg): return None works: list = [] def start(project_id, kind, work, root=None): works.append(work) return {"status": "accepted", "kind": kind} def post(body): res = client.post(URL, json=body) while works: # 일은 응답 뒤 돌림(앱 고리 밖) asyncio.run(works.pop()(lambda percent, message: None)) return res monkeypatch.setattr(router_mod.terrain, "ensure_plan_terrain", ensure) monkeypatch.setattr(router_mod, "_save_models", save) monkeypatch.setattr(router_mod, "_start_job", start) client.plan["points"] = [{"x": Q0[0], "y": Q0[1]}, {"x": Q2[0], "y": Q2[1]}] client.calls = calls client.run_post = post return client def _have(client, box, epsg=5186, center=None): meta = {"box": list(box), "margin_m": 350.0, "epsg": epsg} if center: meta["center"] = list(center) folder = router_mod.terrain.models_dir_of(client.root) folder.mkdir(parents=True, exist_ok=True) (folder / router_mod.terrain.TERRAIN_META).write_text(json.dumps(meta), "utf-8") def test_center_near_adds_site_ring(posting) -> None: """가까운 위치 바꿈 = 지점 + 새 가운데 둘레 · 받은 범위와 합침.""" from common_util.common_util_crs import lonlat_to_tm, tm_to_lonlat _have(posting, (OX - 500.0, OY - 500.0, OX + 1500.0, OY + 1000.0)) lon, lat = tm_to_lonlat(OX + 3000.0, OY, 5186) # 받은 범위에서 1.5 km res = posting.run_post({"center": {"lon": lon, "lat": lat}}).json() assert res["status"] == "accepted" call = posting.calls[-1] x, y = lonlat_to_tm(lon, lat, 5186) r = router_mod.terrain.SITE_RADIUS_M - router_mod.terrain.DEFAULT_MARGIN_M assert call["points"][:2] == [Q0, Q2] and call["points"][2] == pytest.approx((x - r, y - r)) assert call["reset"] is False and call["center"] == (lon, lat) # extent 에도 캐시 가운데 = 둘레가 상자에 들어감 got = posting.get(f"{URL}/extent", params={"lon": lon, "lat": lat}).json() assert got["box_m"][2] == pytest.approx(x + router_mod.terrain.SITE_RADIUS_M) def test_center_far_asks_then_reset(posting) -> None: """5 km 넘음 = 「far」(받지 않음) · reset = 옛 범위 버리고 새 ±1 km(지점 안 넣음).""" from common_util.common_util_crs import tm_to_lonlat _have(posting, (OX - 500.0, OY - 500.0, OX + 1500.0, OY + 1000.0)) lon, lat = tm_to_lonlat(OX + 8000.0, OY, 5186) # 6.5 km body = {"center": {"lon": lon, "lat": lat}} far = posting.run_post(body).json() assert far["status"] == "far" and far["distance_m"] == pytest.approx(6500, abs=2) assert "km" in far["message"] and posting.calls == [] res = posting.run_post({**body, "reset": True}).json() assert res["status"] == "accepted" call = posting.calls[-1] assert call["reset"] is True and len(call["points"]) == 2 # 둘레 두 귀만 assert call["points"][0][0] == pytest.approx(OX + 8000 - 650, abs=1) def test_center_other_band_asks_then_moves(posting) -> None: """다른 TM 띠 = 「band」 · 확인(reset) 뒤 그 띠 · 그 뒤엔 받은 지형 띠를 따름.""" posting.plan["lines"] = [{"id": "a"}] body = {"center": {"lon": 129.1017, "lat": 36.8128}} # 용화(동부 5187) ask = posting.run_post(body).json() assert ask == { "status": "band", "epsg": 5186, "center_epsg": 5187, "message": ask["message"], } assert posting.calls == [] # 확인 전 extent = 가운데 무시(옛 띠 그대로) assert posting.get(f"{URL}/extent", params={"lon": 129.1, "lat": 36.8}).json()["epsg"] == 5186 posting.run_post({**body, "reset": True}) call = posting.calls[-1] assert call["epsg"] == 5187 and call["reset"] is True and len(call["points"]) == 2 # 받은 뒤([저장] 전) — 가운데 없이 물어도 받은 지형 띠 · 가운데 둘레 (x0, y0), (x1, y1) = call["points"] box = [x0 - 400, y0 - 400, x1 + 400, y1 + 400] _have(posting, box, epsg=5187, center=(129.1017, 36.8128)) got = posting.get(f"{URL}/extent").json() assert got["epsg"] == 5187 and got["covered"] is True def test_sheet_limit_413(posting) -> None: """613-1 — 6.7 × 6.7 km(옛 칸 상한 넘음)는 받아짐 · 도엽 37 장 이상은 413 + 장 수 알림.""" posting.plan["points"].append({"x": OX + 6000.0, "y": OY + 6000.0}) assert posting.run_post({}).status_code == 200 assert len(posting.calls) == 1 posting.plan["points"].append({"x": OX + 22000.0, "y": OY + 24000.0}) res = posting.run_post({}) detail = res.json()["detail"] assert res.status_code == 413 and "한 번에 36장까지" in detail and "도엽 " in detail assert len(posting.calls) == 1 def test_progress_interrupted_after_restart(client, monkeypatch) -> None: """도는 기록이 남았는데 기억에 없음(서버 다시 켜짐) = 끊김 · 받은 적 없음 = percent 100.""" root = client.root async def tile_root(project_id): return root monkeypatch.setattr(router_mod, "_tile_root", tile_root) url = f"/api/projects/{PID}/plan/progress" router_mod._JOBS.clear() idle = client.get(url).json()["terrain"] assert idle == {**router_mod.IDLE} path = router_mod._job_path(root, "terrain") seen = [] async def work(report): report(65, "5/5 사업 지형면 만드는 중") seen.append(json.loads(path.read_text("utf-8"))) async def run(): router_mod._start_job(PID, "terrain", work, root) await asyncio.sleep(0.05) asyncio.run(run()) assert seen[0]["running"] is True and seen[0]["percent"] == 65 # 도는 동안 기록 assert json.loads(path.read_text("utf-8"))["running"] is False # 끝 기록 path.write_text(json.dumps(seen[0]), "utf-8") # 도는 중 꺼짐 router_mod._JOBS.clear() # 다시 켜짐 = 기억 빔 got = client.get(url).json()["terrain"] assert got["running"] is False and got["interrupted"] is True assert got["percent"] == 65 and got["message"] is None and got["error"] is None # 끝난 기록 = 끊김 아님 path.write_text(json.dumps({**seen[0], "running": False}), "utf-8") assert client.get(url).json()["terrain"] == {**router_mod.IDLE}