- 지적도를 지형 일에서 뺌(겹 GET 이 받음) · 지적 실패 조각만 다시(열쇠 failed_tiles) - 받기 몸 center · reset — 5 km 넘음 far · 다른 TM 띠 band 물음 · reset = 옛 범위 · 병합 도엽 · 위성 버림 - 받을 칸 500 만 넘음 413 · extent max_cells · lon/lat - 진행 기록 파일 — 서버 다시 켜짐 = interrupted(percent < 100 · message 없음) · 받은 적 없음 percent 100 - 진행 단계 글 n/5 · 받은 범위에 epsg · center 기록 - 임도망 자르기 = 기준점 ±5 km ∪ 받은 지형 · 지형 전에도 올림 · bounds · distance_m · 밖뿐 = 거리 글 - 시험 530_1 · 530_2 · 569_1 · 584_3 덧 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
274 lines
11 KiB
Python
274 lines
11 KiB
Python
"""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_cells_limit_413(posting) -> None:
|
||
posting.plan["points"].append(
|
||
{"x": OX + 6000.0, "y": OY + 6000.0}
|
||
) # 6.7 × 6.7 km = 1,100 만 칸
|
||
res = posting.run_post({})
|
||
assert res.status_code == 413 and "상한 500만" in res.json()["detail"]
|
||
assert posting.calls == []
|
||
|
||
|
||
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}
|