Files
Aislo/resources/tester/test_224_3_recommend_store.py
T
eomsangdonandClaude Opus 5.5 eee1da09fa feat(B05 추천 횡단 토공): CrossEarth 새 파일 · 표준 횡단면 = 저장 패널값 · 판 8 + 소스 지문 · [재계산] 서버 · with_profile 저장 (420)
- 새 B05_Profile_Engine_RouteInitial_CrossEarth.py — cross_earthwork(line, chainages, design_z, terrain, standard) · cross_earthwork_delta(before, after, changed_chainages, …) · attach_cross_earth(결과 표 절 · 성 = 횡단)
- 균형 종단 · 횡단 토공 표준 횡단면 = 프로젝트 저장 패널값(추천 · [적용] 둘 다) · generate_initial_route standard 인자
- 저장본 ALGO_VERSION 8 · 엔진 소스 지문 열쇠 · 표준 횡단면 열쇠 · 옛 판 · 옛 지문 미리 셈 때 지움
- 요청 recompute — 묶음 잠금 안에서 이 묶음 저장본 · _POOLS 버리고 새로 셈 · 저장본 request 엔 안 적힘
- initial_choice.json with_profile([적용] 「종단 반영」) · 미리 셈 다섯 갈래 종단 켬
- 시험 420_1 새로 · 224_3 · 364_4 · route_initial 고쳐 씀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-18 12:43:20 +09:00

251 lines
9.5 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",
"source",
"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
self.inputs: list[dict] = []
self.standard: dict | None = None
@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 {}
async def project_standard(_pid):
return calls.standard
class _Pool:
@asynccontextmanager
async def acquire(self):
yield None
def generate(expected, terrain, streams, *, pipes, **inputs):
assert pipes is None # 관은 키 밖 — 덮개 없이 셈해 남김
calls.count += 1
calls.inputs.append(inputs)
return {
"planned": [[x, y] for x, y in expected],
"metrics": {
"length_m": 38.0,
"offset_avg_m": 0.0,
"profile": {
"cut_m3": 10.0,
"fill_m3": 4.0,
"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(router, "project_standard", project_standard)
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 를 채워 보냄 · 다섯 갈래 종단 켬 · 420) — 셈 없이 저장본
assert all(item["request"]["with_profile"] for item in saved)
hit = _ask(mode="weighted", weights={"expected": 50, "earthwork": 25, "grade": 25})
follow = _ask(mode="follow")
assert hit["stored"] and follow["stored"] and wired.count == 5