Files
Aislo/resources/tester/test_224_3_recommend_store.py
T
eomsangdonandClaude Opus 5.5 f127c0179e feat(노선추천): 327-1 추천 결과 영구 저장 — 입력 지문 키 · 같은 입력이면 저장본 · 바뀐 입력만 셈해 더함
- 새 B05_Profile_Engine_RouteInitial_Store(ALGO_VERSION · request_fingerprint · 읽기 · 쓰기 · 묶음 잠금) · 자리 B05_Profile/route_recommend/<키>.json · 덮어쓰기 없음 · 상한 없음
- 엔드포인트 몸통 compute_initial(찾기 → 셈 · 더함 · 덮개 수 꺼낼 때 다시 셈 · stored 표시) · precompute_defaults(기본 다섯 알약)
- resolve_inputs 로 기본값 풀이 한 곳 · 머리글 「파일 안 씀」 정정
- 시험 test_224_3_recommend_store

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-15 12:34:58 +09:00

232 lines
9.0 KiB
Python

"""계획노선 추천 결과 영구 저장(PLAN 327 · 요구 224-3) — `B05_Profile_Engine_RouteInitial_Store`.
./venv/Scripts/python.exe -m pytest resources/tester/test_224_3_recommend_store.py -q
같은 입력 = 같은 키 · 하나만 바뀌어도 다른 키 · 두 번째 요청 셈 0회 · 덮어쓰기 없음 ·
[초기화](스냅샷 복원) 뒤에도 남음 · 자료 교체 때 지워짐 · 덮개 수는 꺼낼 때 지금 관으로.
엔드포인트 몸통(`compute_initial`)은 DB · 지형 읽기만 갈아 끼우고 그대로 돌린다.
"""
import asyncio
import json
import shutil
from contextlib import asynccontextmanager
from pathlib import Path
from uuid import uuid4
import numpy as np
import pytest
from B05_Profile import B05_Profile_Router_Replan as replan
from B05_Profile import B05_Profile_Router_RouteInitial as router
from B05_Profile.B05_Profile_Engine_RouteInitial import STREAM_OFFSET_M, Terrain
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import (
DEFAULT_MODES,
request_fingerprint,
store_dir,
write_stored,
)
from common_util.common_util_initial_snapshot import (
_FILE_TREES,
restore_snapshot_files,
snapshot_dir,
)
from common_util.common_util_project_reset import purge_output_folders
def _terrain(lift: float = 0.0) -> Terrain:
x = np.arange(0.0, 40.0, 2.0)
y = np.arange(0.0, 20.0, 2.0)
xx, yy = np.meshgrid(x, y)
return Terrain(x, y, 0.1 * xx + lift, np.ones(xx.shape, dtype=bool))
EXPECTED = [(0.0, 10.0), (20.0, 10.0), (38.0, 10.0)]
STREAMS = [[(5.0, 0.0), (5.0, 18.0)]]
BASE = {
"mode": "weighted",
"weights": None,
"max_grade": 0.14,
"min_radius_m": 12.0,
"stream_offset_m": None,
"grade_smooth_m": None,
"target_grade": None,
"with_profile": True,
"design_speed": 20,
}
def _key(expected=EXPECTED, terrain=None, streams=STREAMS, **changes) -> str:
return request_fingerprint(expected, terrain or _terrain(), streams, **{**BASE, **changes})[0]
def test_same_input_same_key() -> None:
assert _key() == _key()
# 비운 값 = 기본값 · 비중 None = 50/25/25 — 화면이 채워 보내도 같은 키
assert _key() == _key(stream_offset_m=STREAM_OFFSET_M)
assert _key() == _key(weights={"expected": 50.0, "earthwork": 25.0, "grade": 25.0})
# 비중 갈래가 아니면 비중은 갈래가 정함 — 보낸 비중은 키에 안 듦
assert _key(mode="earthwork") == _key(mode="earthwork", weights={"expected": 1.0})
@pytest.mark.parametrize(
"change",
[
{"mode": "earthwork"},
{"weights": {"expected": 40.0, "earthwork": 30.0, "grade": 30.0}},
{"max_grade": 0.12},
{"min_radius_m": 15.0},
{"stream_offset_m": 50.0},
{"grade_smooth_m": 3.0},
{"target_grade": 0.08},
{"with_profile": False},
{"design_speed": 30},
{"expected": [(0.0, 10.0), (20.0, 11.0), (38.0, 10.0)]},
{"terrain": _terrain(lift=0.5)},
{"streams": []},
],
ids=lambda change: next(iter(change)),
)
def test_one_change_other_key(change: dict) -> None:
assert _key(**change) != _key()
def test_no_overwrite(tmp_path: Path) -> None:
assert write_stored(tmp_path, "k", "p", {}, {"n": 1}, True)
assert not write_stored(tmp_path, "k", "p", {}, {"n": 2}, False)
data = json.loads((store_dir(tmp_path) / "k.json").read_text(encoding="utf-8"))
assert data["result"] == {"n": 1} and data["default"] is True
assert set(data) == {"version", "key", "pool", "default", "created_at", "request", "result"}
def test_survives_reset_and_goes_with_data_swap(tmp_path: Path) -> None:
(tmp_path / "B05_Profile/route").mkdir(parents=True)
(tmp_path / "B05_Profile/route/initial_choice.json").write_text("{}", encoding="utf-8")
write_stored(tmp_path, "k", "p", {}, {}, True)
# 스냅샷 = 작업본 트리 사본(`save_initial_snapshot` 과 같은 자리)
for tree in _FILE_TREES:
source = tmp_path / tree
if source.is_dir():
shutil.copytree(source, snapshot_dir(tmp_path) / tree.replace("/", "__"))
restore_snapshot_files(tmp_path)
assert (store_dir(tmp_path) / "k.json").is_file()
purge_output_folders(tmp_path) # 자료 교체
assert not store_dir(tmp_path).exists()
class _Calls:
def __init__(self) -> None:
self.count = 0
@pytest.fixture
def wired(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> _Calls:
"""`compute_initial` 의 DB · 파일 읽기를 갈아 끼움 · 셈은 횟수만 세는 가짜."""
import B05_Profile.B05_Profile_Engine_Grade as grade
import B05_Profile.B05_Profile_Engine_RouteInitial as engine
import B05_Profile.B05_Profile_Engine_RouteInitial_Profile as profile
import common_util.common_util_surface_confirmation as surface
calls = _Calls()
async def project_paths(_pid):
return tmp_path, "x"
async def plan_criteria(_pid):
return 12.0, 12.0, 5.0
async def road_settings(_pid):
return "간선", 20, "일반"
async def surface_params(_connection, _pid):
return {}
class _Pool:
@asynccontextmanager
async def acquire(self):
yield None
def generate(expected, terrain, streams, *, pipes, **_inputs):
assert pipes is None # 관은 키 밖 — 덮개 없이 셈해 남김
calls.count += 1
return {
"planned": [[x, y] for x, y in expected],
"metrics": {
"length_m": 38.0,
"offset_avg_m": 0.0,
"profile": {
"stations_m": [0.0, 38.0],
"plan_m": [1.0, 1.0],
"ground_m": [0.0, 0.0],
},
},
}
monkeypatch.setattr(replan, "_project_paths", project_paths)
monkeypatch.setattr(replan, "_plan_criteria", plan_criteria)
monkeypatch.setattr(replan, "_road_settings", road_settings)
monkeypatch.setattr(replan, "_vertices_of", lambda _path: [list(p) for p in EXPECTED])
monkeypatch.setattr(router, "get_db_pool", lambda: _Pool())
monkeypatch.setattr(surface, "get_surface_confirmation_params", surface_params)
monkeypatch.setattr(surface, "analysis_terrain_params", lambda selection, _t: selection)
monkeypatch.setattr(grade, "legal_grade_limit_pct", lambda *_a: 14.0)
monkeypatch.setattr(engine, "load_terrain_and_streams", lambda *_a: (_terrain(), STREAMS))
monkeypatch.setattr(engine, "generate_initial_route", generate)
# 덮개 여유 2 m 관 하나가 노선 위 — 계획고 − 지반고 1 m 라 모자람 1
monkeypatch.setattr(profile, "project_pipes", lambda _root: [(19.0, 10.0, 2.0)])
calls.root = tmp_path
return calls
def _ask(**fields) -> dict:
request = replan.RouteInitialRequest(**fields)
return asyncio.run(router.compute_initial(uuid4(), request))
def test_second_request_computes_nothing(wired: _Calls) -> None:
first = _ask(mode="earthwork")
assert wired.count == 1 and first["stored"] is False
second = _ask(mode="earthwork")
assert wired.count == 1 and second["stored"] is True
assert second["planned"] == first["planned"]
# 덮개 수는 꺼낼 때마다 지금 관으로 · 저장본에는 없음
assert second["metrics"]["profile"]["cover_short"] == 1
files = list(store_dir(wired.root).glob("*.json"))
assert len(files) == 1
saved = json.loads(files[0].read_text(encoding="utf-8"))
assert "cover_short" not in saved["result"]["metrics"]["profile"]
assert saved["default"] is True and saved["request"]["mode"] == "earthwork"
# 제약 하나 바꾸면 파일 하나 더 · 옛 파일 그대로 · 기본값 표시 꺼짐
changed = _ask(mode="earthwork", criteria={"min_radius_m": 15.0})
assert changed["stored"] is False and wired.count == 2
assert len(list(store_dir(wired.root).glob("*.json"))) == 2
assert json.loads(files[0].read_text(encoding="utf-8")) == saved
def test_same_pool_in_flight_waits(wired: _Calls) -> None:
async def both() -> list[dict]:
request = replan.RouteInitialRequest(mode="gentle")
project = uuid4()
return await asyncio.gather(
router.compute_initial(project, request), router.compute_initial(project, request)
)
results = asyncio.run(both())
assert wired.count == 1
assert sorted(result["stored"] for result in results) == [False, True]
def test_precompute_defaults_then_hits(wired: _Calls) -> None:
asyncio.run(router.precompute_defaults(uuid4()))
assert wired.count == len(DEFAULT_MODES) == 5
saved = [
json.loads(path.read_text(encoding="utf-8"))
for path in store_dir(wired.root).glob("*.json")
]
assert sorted(item["request"]["mode"] for item in saved) == sorted(DEFAULT_MODES)
assert all(item["default"] for item in saved)
# 화면 첫 누름(비중은 화면이 50/25/25 를 채워 보냄 · 추적은 종단 끔) — 셈 없이 저장본
hit = _ask(mode="weighted", weights={"expected": 50, "earthwork": 25, "grade": 25})
follow = _ask(mode="follow", with_profile=False)
assert hit["stored"] and follow["stored"] and wired.count == 5