Files
Aislo/resources/tester/test_343_2_persist_keys.py
T
eomsangdonandClaude Opus 5.5 1fcdf5aa4c perf(영구저장): 343-2 도엽 3D 면 열쇠 · 지표면 모델 id 유지 · 비용면 메모리 기억 · 추천 노선 열쇠 · 횡단 상세 ETag
- 도엽 3D 면 — 결과 옆 dtm_sheet_{방식}.key.json(등고선 이름:mtime:크기 · 노선 · CRS · 격자 · 여유 · 스무딩 · 화면 상자 · ALGO) 같으면 건너뜀
- 지표면 모델 — (방식 · 필터 · 파일) + 파일 지문 같으면 행 id 살려 고쳐 씀 · 이번 결과에 없는 옛 행만 지움
- 비용면 · 계류 메모리 기억(비용면 서명 · 계류 파일 지문 · 좌표계) · 추천 열쇠 = 서명(배열 통째 해시 없앰)
- 추천 저장본 종류(갈래 · 지형)당 최근 2벌 · 꺼내면 최근으로
- 횡단 상세 GET — 측점 파일 안 지움(valid_cross_names) · 본문 해시 ETag 304
- 시험 test_343_2_persist_keys 13건

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-16 11:10:13 +09:00

342 lines
14 KiB
Python

"""343-2 도엽 3D 면 열쇠 · 지표면 모델 id 유지 · 비용면 · 추천 노선 열쇠 · 횡단 상세 ETag.
같은 입력 두 번째 = 다시 안 셈 · 열쇠 바뀌면 다시 셈 · 지울 때.
"""
from __future__ import annotations
import asyncio
import json
import os
import time
from pathlib import Path
import numpy as np
import pytest
from pyproj import Transformer
import B04_PreProcess.B04_PreProcess_Engine_SheetSurface as sheet
from B04_PreProcess.B04_PreProcess_Engine_SheetMethods import SHEET_METHOD_BUILDERS
EPSG = 5186
X0, Y0 = 200000.0, 500000.0
ROUTE = np.array([[X0 - 50.0, Y0], [X0 + 50.0, Y0]], dtype=np.float64)
@pytest.fixture()
def sheet_project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict:
processed = tmp_path / "B04_PreProcess" / "processed"
models = tmp_path / "B04_PreProcess" / "models"
processed.mkdir(parents=True)
models.mkdir(parents=True)
to_wgs84 = Transformer.from_crs(f"EPSG:{EPSG}", "EPSG:4326", always_xy=True)
features = []
for half, elev in ((450.0, 100.0), (300.0, 110.0), (150.0, 120.0)):
ring = [
list(to_wgs84.transform(x, y))
for x, y in (
(X0 - half, Y0 - half),
(X0 + half, Y0 - half),
(X0 + half, Y0 + half),
(X0 - half, Y0 + half),
(X0 - half, Y0 - half),
)
]
features.append(
{
"type": "Feature",
"properties": {"등고수치": elev},
"geometry": {"type": "LineString", "coordinates": ring},
}
)
(processed / "도엽_등고선.geojson").write_text(
json.dumps({"type": "FeatureCollection", "features": features}, ensure_ascii=False),
encoding="utf-8",
)
calls: list[str] = []
real = SHEET_METHOD_BUILDERS["tin_sheet"]
def counting(*args, **kwargs): # type: ignore[no-untyped-def]
calls.append("tin_sheet")
return real(*args, **kwargs)
monkeypatch.setitem(SHEET_METHOD_BUILDERS, "tin_sheet", counting)
return {"root": tmp_path, "processed": processed, "models": models, "calls": calls}
def _build(project: dict, route: np.ndarray = ROUTE) -> list[dict]:
return sheet.build_sheet_surface_model(
project["root"], project["processed"], project["models"], route, EPSG, ["tin_sheet"]
)
def test_도엽_3D_면_같은_입력_두번째는_안_셈(sheet_project: dict) -> None:
first = _build(sheet_project)
assert sheet_project["calls"] == ["tin_sheet"]
assert (sheet_project["models"] / "dtm_sheet_tin_sheet.key.json").is_file()
started = time.perf_counter()
second = _build(sheet_project)
assert time.perf_counter() - started < 0.5
assert sheet_project["calls"] == ["tin_sheet"]
assert second == first
assert first[0]["generation_params"]["file_stamp"]
@pytest.mark.parametrize("change", ["route", "contour", "algo", "key_gone", "file_gone"])
def test_도엽_3D_면_열쇠가_바뀌면_다시_셈(
sheet_project: dict, monkeypatch: pytest.MonkeyPatch, change: str
) -> None:
_build(sheet_project)
route = ROUTE
if change == "route":
route = ROUTE + np.array([5.0, 0.0])
elif change == "contour":
path = sheet_project["processed"] / "도엽_등고선.geojson"
stat = path.stat()
os.utime(path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 5_000_000_000))
elif change == "algo":
monkeypatch.setattr(sheet, "SHEET_SURFACE_ALGO", sheet.SHEET_SURFACE_ALGO + 1)
elif change == "key_gone":
(sheet_project["models"] / "dtm_sheet_tin_sheet.key.json").unlink()
else:
(sheet_project["models"] / "dtm_sheet_tin_sheet_preview.glb").unlink()
_build(sheet_project, route)
assert sheet_project["calls"] == ["tin_sheet", "tin_sheet"]
# ── 지표면 모델 id 유지(R2) ─────────────────────────────────────────────────────
class _FakeCursor:
def __init__(self, db: "_FakeDb") -> None:
self.db = db
self.rowcount = 0
self.lastrowid = 0
self._rows: list[tuple] = []
async def __aenter__(self) -> "_FakeCursor":
return self
async def __aexit__(self, *_: object) -> None:
return None
async def execute(self, sql: str, params: tuple = ()) -> None:
text = " ".join(sql.split())
self.db.log.append(text)
if text.startswith("SELECT id, model_type"):
self._rows = [
(row["id"], row["model_type"], row["path"], json.dumps(row["params"]))
for row in self.db.models.values()
]
elif text.startswith("INSERT INTO surface_models"):
self.db.next_id += 1
self.lastrowid = self.db.next_id
self.db.models[self.lastrowid] = {
"id": self.lastrowid,
"model_type": params[1],
"path": params[7],
"params": json.loads(params[8]),
}
elif text.startswith("UPDATE surface_models"):
self.db.models[params[-1]]["params"] = json.loads(params[4])
elif text.startswith("DELETE FROM surface_models WHERE id IN"):
for model_id in params:
self.db.models.pop(model_id, None)
elif text.startswith("DELETE FROM surface_models WHERE id ="):
self.db.models.pop(params[0], None)
elif text.startswith("DELETE FROM surface_models WHERE project_id"):
for model_id in [k for k, v in self.db.models.items() if v["path"] == params[1]]:
self.db.models.pop(model_id)
elif text.startswith("INSERT INTO terrain_layers"):
self.db.next_id += 1
self.lastrowid = self.db.next_id
async def fetchall(self) -> list[tuple]:
return self._rows
async def fetchone(self) -> tuple | None:
return None
class _FakeDb:
def __init__(self) -> None:
self.log: list[str] = []
self.models: dict[int, dict] = {}
self.next_id = 100
def cursor(self, *_: object) -> _FakeCursor:
return _FakeCursor(self)
def _model(path: str, stamp: str) -> dict:
return {
"model_type": "dtm",
"model_file_path": path,
"resolution_m": 1.0,
"generation_params": {"source_filter": "csf", "file_stamp": stamp},
"layers": [
{
"layer_name": "dtm_csf_preview",
"geometry_type": "MESH",
"file_path": "B04_PreProcess/models/dtm_csf_preview.glb",
"file_format": "glb",
}
],
}
def _save(db: _FakeDb, models: list[dict]) -> list[int]:
from B04_PreProcess import B04_PreProcess_Repository as repo
async def fake_input(*_a): # type: ignore[no-untyped-def]
return {"crs_epsg": 5186}
async def fake_cloud(*_a, **_k): # type: ignore[no-untyped-def]
return 9
repo_input, repo_cloud = repo.get_input_file, repo.create_processed_point_cloud
repo.get_input_file, repo.create_processed_point_cloud = fake_input, fake_cloud
try:
return asyncio.run(
repo.save_surface_analysis_to_db(
db, # type: ignore[arg-type]
project_id="00000000-0000-0000-0000-000000000001", # type: ignore[arg-type]
input_file_id=1,
analysis_result={
"processed": {
"processed_file_path": "B04_PreProcess/processed/structured.npz",
"converted_file_path": None,
"point_count": 1,
"bounds": {},
"statistics": {},
},
"models": models,
},
source_filters=["csf"],
)
)
finally:
repo.get_input_file, repo.create_processed_point_cloud = repo_input, repo_cloud
def test_파일_지문이_같으면_모델_id_유지() -> None:
db = _FakeDb()
path = "B04_PreProcess/models/dtm_csf.npz"
first = _save(db, [_model(path, "1:2"), _model("B04_PreProcess/models/tin_csf.npz", "3:4")])
second = _save(db, [_model(path, "1:2")])
assert second == [first[0]]
# 이번 결과에 없는 옛 행은 지움 · 같은 파일 행은 하나
assert list(db.models) == [first[0]]
third = _save(db, [_model(path, "9:9")])
assert third != second and list(db.models) == third
def test_도엽_한_방식_요청도_id_유지() -> None:
from B04_PreProcess import B04_PreProcess_Repository as repo
db = _FakeDb()
path = "B04_PreProcess/models/dtm_sheet_tin_sheet.npz"
entry = {**_model(path, "5:6"), "generation_params": {"source_filter": "sheet_tin_sheet"}}
entry["generation_params"]["file_stamp"] = "5:6"
first = asyncio.run(repo.save_sheet_surface_models(db, "p", [entry])) # type: ignore[arg-type]
second = asyncio.run(repo.save_sheet_surface_models(db, "p", [entry])) # type: ignore[arg-type]
assert first == second and len(db.models) == 1
# ── 비용면 · 계류 메모리 기억(R17) · 추천 노선 열쇠(R18) ──────────────────────────
def test_비용면_계류_메모리_기억(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
import B04_PreProcess.B04_PreProcess_Router_Watershed as watershed
import B05_Profile.B05_Profile_Engine_RouteInitial as engine
import B05_Profile.B05_Profile_Engine_Solver as solver
import common_util.common_util_crs as crs
loads: list[int] = []
signature = {"value": "sig-1"}
grid = np.arange(4.0)
def fake_load(*_a): # type: ignore[no-untyped-def]
loads.append(1)
return grid, grid, np.zeros((4, 4)), np.ones((4, 4), bool), None, None, 1.0
monkeypatch.setattr(engine, "_TERRAINS", {})
monkeypatch.setattr(solver, "_load_or_build_cost_surface", fake_load)
monkeypatch.setattr(solver, "_cost_surface_signature", lambda *_a: signature["value"])
monkeypatch.setattr(watershed, "_load_features", lambda *_a: [])
monkeypatch.setattr(crs, "resolve_project_crs", lambda _root: "EPSG:5186")
selection = {"source_filter": "csf", "method": "dtm", "smooth": False}
terrain, _ = engine.load_terrain_and_streams(tmp_path, selection)
again, _ = engine.load_terrain_and_streams(tmp_path, selection)
assert loads == [1] and again is terrain and terrain.signature
signature["value"] = "sig-2"
changed, _ = engine.load_terrain_and_streams(tmp_path, selection)
assert loads == [1, 1] and changed.signature != terrain.signature
# 추천 열쇠 = 서명 — 배열 통째 해시 안 함 · 서명이 다르면 다른 열쇠
key_a = engine._pool_key([(0.0, 0.0), (3.0, 3.0)], terrain, [], 0.14)
key_b = engine._pool_key([(0.0, 0.0), (3.0, 3.0)], changed, [], 0.14)
assert key_a != key_b
assert key_a == engine._pool_key([(0.0, 0.0), (3.0, 3.0)], terrain, [[(9.0, 9.0)]], 0.14)
def test_추천_저장본_종류당_최근_두_벌(tmp_path: Path) -> None:
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import (
KEEP_PER_KIND,
read_stored,
store_dir,
write_stored,
)
assert KEEP_PER_KIND == 2
request = {"mode": "earthwork", "terrain": None}
write_stored(tmp_path, "a", "p", request, {"n": 1}, True)
time.sleep(0.02)
write_stored(tmp_path, "b", "p", request, {"n": 2}, False)
write_stored(tmp_path, "g", "p", {"mode": "gentle", "terrain": None}, {}, True)
time.sleep(0.02)
assert read_stored(tmp_path, "a") is not None # 꺼내면 최근으로
time.sleep(0.02)
write_stored(tmp_path, "c", "p", request, {"n": 3}, False)
names = sorted(path.stem for path in store_dir(tmp_path).glob("*.json"))
assert names == ["a", "c", "g"]
# ── 횡단 상세 — GET 은 안 지움 · ETag(R26) ───────────────────────────────────────
def test_횡단_상세_조회는_파일을_지우지_않음(tmp_path: Path) -> None:
from B05_Profile.B05_Profile_Engine_Sections import prune_stale_cross_files, valid_cross_names
for name in ("cross_00000m.json", "cross_00020m.json", "cross_00040m.json"):
(tmp_path / name).write_text("{}", encoding="utf-8")
stations = [{"chainage_m": 0.0}, {"chainage_m": 20.0}]
names = valid_cross_names(tmp_path, stations)
assert len(names) == 2 and (tmp_path / "cross_00040m.json").is_file()
assert prune_stale_cross_files(tmp_path, stations) == names
assert not (tmp_path / "cross_00040m.json").exists()
def test_횡단_상세_ETag_같으면_304() -> None:
from starlette.requests import Request
from B06_Section.B06_Section_Router import _etag_json_response
def request(etag: str | None) -> Request:
headers = [] if etag is None else [(b"if-none-match", etag.encode())]
return Request({"type": "http", "headers": headers})
body = b'{"cross_sections": []}'
first = _etag_json_response(request(None), body)
assert first.status_code == 200 and first.body == body
etag = first.headers["etag"]
assert _etag_json_response(request(etag), body).status_code == 304
assert _etag_json_response(request(etag), body + b" ").status_code == 200
def test_횡단_상세_GET_소스에_지우기_없음() -> None:
source = Path("B06_Section/B06_Section_Router.py").read_text(encoding="utf-8")
start = source.index("def _read_section_detail(")
end = source.index("\ndef ", start + 10)
assert "prune_stale_cross_files" not in source[start:end]
assert "unlink" not in source[start:end]