diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial.py b/B05_Profile/B05_Profile_Engine_RouteInitial.py index ba7278c84..13a1e0fd5 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial.py @@ -3,8 +3,9 @@ 예상노선 추종 · 영선 노선 · 토공 최소 · 기울기 순한 · 안전 우선 · 계류 횡단 최소 · 공사비 어림 최소 · 비중. -계산 자리 = 서버 단독(③). 결과는 화면 캐시용 — 이 모듈은 파일을 쓰지 않는다. 적용은 화면이 -받은 노드로 기존 `POST /route/replan`([확인])을 부른다. +계산 자리 = 서버 단독(③). 이 모듈은 파일을 쓰지 않는다 — 결과는 라우터가 입력 지문 키로 +`B05_Profile/route_recommend/` 에 남긴다(`..._Store` · PLAN 327). 적용은 화면이 받은 노드로 +기존 `POST /route/replan`([확인])을 부른다. 갈래(`mode`): · follow — 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv`)과 같은 규칙 · 같은 선. @@ -182,6 +183,20 @@ def write_initial_choice( return choice +def resolve_inputs( + max_grade: float, + stream_offset_m: float | None, + grade_smooth_m: float | None, + target_grade: float | None, +) -> tuple[float, float, float]: + """(계류 이격, 기울기 잴 지반 폭, 영선 목표 기울기) — 비운 값은 기본값 · 저장 키와 같은 풀이.""" + stream_offset = STREAM_OFFSET_M if stream_offset_m is None else float(stream_offset_m) + grade_smooth = GRADE_SMOOTH_M if grade_smooth_m is None else float(grade_smooth_m) + # 영선 노선 목표 기울기(비율) — 없으면 상한 · 상한 넘게 넣어도 상한(제약 안에서만) + goal_grade = max_grade if target_grade is None else min(float(target_grade), max_grade) + return stream_offset, grade_smooth, goal_grade + + def resolve_weights(mode: str, weights: dict[str, float] | None) -> dict[str, float]: """갈래의 비중(합 100). weighted 가 아니면 갈래가 정한 값.""" if mode != "weighted": @@ -240,10 +255,9 @@ def generate_initial_route( if len(expected) < 2: raise ValueError("예상노선 정점이 2개 미만입니다.") used = resolve_weights(mode, weights) - stream_offset = STREAM_OFFSET_M if stream_offset_m is None else float(stream_offset_m) - grade_smooth = GRADE_SMOOTH_M if grade_smooth_m is None else float(grade_smooth_m) - # 영선 노선 목표 기울기(비율) — 없으면 상한 · 상한 넘게 넣어도 상한(제약 안에서만) - goal_grade = max_grade if target_grade is None else min(float(target_grade), max_grade) + stream_offset, grade_smooth, goal_grade = resolve_inputs( + max_grade, stream_offset_m, grade_smooth_m, target_grade + ) def measure( outline: Any, *, bound: float | None, follow: bool = False, weighted: bool = False diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py index 13ec86a8e..de8e9fc8f 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py @@ -1,8 +1,9 @@ """노선 추천 후보의 균형 종단(PLAN 63-4 · 63-2 안 A). 후보 노선 하나에 종단을 세워 절 · 성토를 **횡단 ㎥** 로 맞춘 계획선과 그 토량을 낸다. -편집 창 [노선 추천]의 점수 · 미리 보기에만 쓴다 — 파일 · 캐시 · initial_snapshot · 확정 종단을 -바꾸지 않는다. 확정 체인의 종단 규칙(1차 계획고 = 지반고 · 2026-09-02 사용자 확정)은 그대로다. +편집 창 [노선 추천]의 점수 · 미리 보기에만 쓴다 — 작업본 파일 · initial_snapshot · 확정 종단을 +바꾸지 않는다(추천 결과째 저장본에 남는 것은 라우터 몫 · PLAN 327). 확정 체인의 종단 규칙 +(1차 계획고 = 지반고 · 2026-09-02 사용자 확정)은 그대로다. 계산 자리 = 서버 단독(③) — 화면은 받은 측점 · 지반고 · 계획고를 그리기만 한다. diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py new file mode 100644 index 000000000..5f4cd944b --- /dev/null +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py @@ -0,0 +1,128 @@ +"""계획노선 추천 결과 영구 저장(PLAN 327 · 요구 224-3). + +같은 입력이면 같은 결과 — 셈한 추천을 입력 지문 키로 프로젝트 폴더에 남기고, 같은 입력이 오면 +셈하지 않고 꺼낸다. 바뀐 입력만 새로 셈해 더한다(덮어쓰기 없음 · 상한 없음). + +층 = 서버 계산 캐시(데이터 3층 밖 · 사용자 편집값 아님 — 자동저장 금지와 무관). 자리 +`B05_Profile/route_recommend/<키>.json` — initial_snapshot 대상(`B05_Profile/route`) 밖이라 +[초기화]에 안 휩쓸리고, 자료 교체(`purge_output_folders`) · 프로젝트 삭제 때 폴더째 지워진다. + +키 = 후보 묶음 지문(`_pool_key` — 지형 · 예상노선 · 계류 · 상한 · 최소 R · 이격 · 고름 · 목표 +기울기) + 갈래 · 비중 · 종단 반영 · 설계속도 · `ALGO_VERSION`. 관 지점은 뺀다 — 덮개 수는 꺼낼 때 +다시 센다. 추천 결과를 바꾸는 코드 고침은 `ALGO_VERSION` 을 올린다(옛 파일은 다른 키라 안 쓰임). +""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +from contextlib import asynccontextmanager +from datetime import datetime +from pathlib import Path +from typing import Any, AsyncIterator + +from B05_Profile.B05_Profile_Engine_RouteInitial import ( + DEFAULT_WEIGHTS, + Terrain, + _pool_key, + resolve_inputs, + resolve_weights, +) +from common_util.common_util_json import atomic_write_json + +ALGO_VERSION = 1 +STORE_DIR = "B05_Profile/route_recommend" +# 업로드 체인 끝에 미리 셈할 기본 다섯 알약(화면 차례) · 추적은 묶음이 선 뒤라 `similar` 가 채워짐 +DEFAULT_MODES = ("gradeline", "earthwork", "gentle", "weighted", "follow") + +# 묶음 지문마다 잠금 — 진행 중 같은 묶음(같은 키 포함)은 기다렸다 꺼냄(두 번 셈 막음) +_LOCKS: dict[str, asyncio.Lock] = {} + + +def store_dir(project_root: Path) -> Path: + return Path(project_root) / STORE_DIR + + +def request_fingerprint( + expected: list[tuple[float, float]], + terrain: Terrain, + streams: list[list[tuple[float, float]]], + *, + mode: str, + weights: dict[str, float] | None, + max_grade: float, + min_radius_m: float, + stream_offset_m: float | None, + grade_smooth_m: float | None, + target_grade: float | None, + with_profile: bool, + design_speed: int, +) -> tuple[str, str]: + """(저장 키, 묶음 지문) — `generate_initial_route` 와 같은 기본값 풀이로 같은 입력 = 같은 키.""" + pool = _pool_key( + expected, + terrain, + streams, + max_grade, + min_radius_m, + *resolve_inputs(max_grade, stream_offset_m, grade_smooth_m, target_grade), + ).hex() + used = resolve_weights(mode, weights) + tail = (mode, sorted(used.items()), bool(with_profile), int(design_speed), ALGO_VERSION) + key = hashlib.blake2b((pool + repr(tail)).encode(), digest_size=16).hexdigest() + return key, pool + + +def is_default( + mode: str, weights: dict[str, float] | None, criteria: dict[str, Any] | None, terrain: Any +) -> bool: + """기본값 결과 — 빈 제약 · 50/25/25 · 설계 지형. 표시일 뿐 지우는 데 쓰지 않음.""" + empty = not any(value is not None for value in (criteria or {}).values()) + plain = mode != "weighted" or resolve_weights(mode, weights) == DEFAULT_WEIGHTS + return empty and plain and terrain is None + + +def read_stored(project_root: Path, key: str) -> dict[str, Any] | None: + """저장본 한 벌 — 없거나 못 읽거나 판이 다르면 None.""" + try: + data = json.loads((store_dir(project_root) / f"{key}.json").read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + if not isinstance(data, dict) or data.get("version") != ALGO_VERSION: + return None + return data + + +def write_stored( + project_root: Path, + key: str, + pool: str, + request: dict[str, Any], + result: dict[str, Any], + default: bool, +) -> bool: + """새 키만 적는다 — 이미 있으면 그대로 두고 False(덮어쓰기 없음).""" + path = store_dir(project_root) / f"{key}.json" + if path.exists(): + return False + atomic_write_json( + path, + { + "version": ALGO_VERSION, + "key": key, + "pool": pool, + "default": default, + "created_at": datetime.now().isoformat(timespec="seconds"), + "request": request, + "result": result, + }, + ) + return True + + +@asynccontextmanager +async def pool_lock(pool: str) -> AsyncIterator[None]: + # ponytail: 잠금은 지우지 않음 — 입력 묶음 수만큼(작음) · 다중 프로세스면 파일 잠금으로 + async with _LOCKS.setdefault(pool, asyncio.Lock()): + yield diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 10e2f4559..e3ae05f53 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -26,7 +26,7 @@ GET /api/projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계) POST /api/projects/{id}/route/replan → 고친 계획노선으로 갈아 끼우고 재계산 POST /api/projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산 - POST /api/projects/{id}/route/initial → 초기 계획노선 갈래 하나를 만들어 돌려줌(파일 안 씀) + POST /api/projects/{id}/route/initial → 초기 계획노선 갈래 하나를 만들어 돌려줌(추천 저장본만 씀) """ import asyncio diff --git a/B05_Profile/B05_Profile_Router_RouteInitial.py b/B05_Profile/B05_Profile_Router_RouteInitial.py index 88e90e6b1..5bb6f494e 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial.py @@ -30,14 +30,35 @@ async def initial_route( ) -> dict[str, Any] | JSONResponse: """초기 계획노선 갈래 하나를 만들어 비교값과 함께 돌려준다(PLAN 20장). - **파일을 쓰지 않는다** — 화면 캐시용. 적용은 받은 `nodes` 로 `POST /route/replan`([확인]). + 작업본은 쓰지 않는다 — 추천 저장본(`..._Store` · PLAN 327)만. 적용은 받은 `nodes` 로 + `POST /route/replan`([확인]). + """ + return await compute_initial(project_id, request) + + +async def compute_initial( + project_id: UUID, request: replan.RouteInitialRequest +) -> dict[str, Any] | JSONResponse: + """같은 입력이면 저장본을 꺼내고 없으면 셈해 더한다(PLAN 327) · 덮개 수는 늘 지금 관으로. + + 응답 `stored` = 저장본에서 꺼냈나. 진행 중인 같은 묶음은 기다렸다 꺼낸다(두 번 셈 막음). """ from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct from B05_Profile.B05_Profile_Engine_RouteInitial import ( generate_initial_route, load_terrain_and_streams, ) - from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import project_pipes + from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import ( + cover_shortfall, + project_pipes, + ) + from B05_Profile.B05_Profile_Engine_RouteInitial_Store import ( + is_default, + pool_lock, + read_stored, + request_fingerprint, + write_stored, + ) from common_util.common_util_surface_confirmation import ( analysis_terrain_params, get_surface_confirmation_params, @@ -81,37 +102,62 @@ async def initial_route( async with pool.acquire() as connection: selection = await get_surface_confirmation_params(connection, str(project_id)) selection = analysis_terrain_params(selection, request.terrain) + inputs = { + "mode": request.mode, + "weights": weights, + "max_grade": max_grade, + "min_radius_m": radius_m, + "stream_offset_m": stream_offset_m, + "grade_smooth_m": grade_smooth_m, + "target_grade": None if target_pct is None else target_pct / 100.0, + "design_speed": design_speed or 20, + "with_profile": request.with_profile, + } started = time.perf_counter() try: terrain, streams = await asyncio.to_thread( load_terrain_and_streams, project_root, selection ) - pipes = ( - await asyncio.to_thread(project_pipes, project_root) if request.with_profile else None - ) - result = await asyncio.to_thread( - functools.partial( - generate_initial_route, - [(x, y) for x, y in expected], - terrain, - streams, - mode=request.mode, - weights=weights, - max_grade=max_grade, - min_radius_m=radius_m, - stream_offset_m=stream_offset_m, - grade_smooth_m=grade_smooth_m, - target_grade=None if target_pct is None else target_pct / 100.0, - design_speed=design_speed or 20, - pipes=pipes, - with_profile=request.with_profile, - ) + route = [(x, y) for x, y in expected] + key, pool_key = await asyncio.to_thread( + functools.partial(request_fingerprint, route, terrain, streams, **inputs) ) + async with pool_lock(pool_key): + stored = await asyncio.to_thread(read_stored, project_root, key) + if stored is not None: + result = stored["result"] + else: + # 관 지점은 키 밖 — 덮개 수 없이 셈해 남기고 아래에서 지금 관으로 센다. + result = await asyncio.to_thread( + functools.partial( + generate_initial_route, route, terrain, streams, pipes=None, **inputs + ) + ) + await asyncio.to_thread( + write_stored, + project_root, + key, + pool_key, + request.model_dump(), + result, + is_default( + request.mode, + weights, + criteria.model_dump() if criteria else None, + request.terrain, + ), + ) + profile = result["metrics"].get("profile") + if request.with_profile and profile and "error" not in profile: + pipes = await asyncio.to_thread(project_pipes, project_root) + if pipes: + profile.update(cover_shortfall(profile, result["planned"], pipes)) except (FileNotFoundError, ValueError) as error: return JSONResponse(status_code=409, content={"status": "error", "message": str(error)}) logger.info( - "초기 계획노선 %s: project_id=%s %.1fs 연장 %.0fm 이격 평균 %.1fm", + "초기 계획노선 %s(%s): project_id=%s %.1fs 연장 %.0fm 이격 평균 %.1fm", request.mode, + "저장본" if stored is not None else "셈", project_id, time.perf_counter() - started, result["metrics"]["length_m"], @@ -122,10 +168,28 @@ async def initial_route( "status": "success", "project_id": str(project_id), **result, + "stored": stored is not None, "current_initial": current, } +async def precompute_defaults(project_id: UUID) -> None: + """업로드 체인 끝 예약(PLAN 327-2) — 기본 다섯 알약을 미리 셈해 남김 · 있으면 꺼내기만.""" + from B05_Profile.B05_Profile_Engine_RouteInitial_Store import DEFAULT_MODES + + started = time.perf_counter() + for mode in DEFAULT_MODES: + # 화면 기본과 같은 몸통 — 빈 제약 · 50/25/25 · 설계 지형 · 추적만 종단 끔(`withProfileOf`) + request = replan.RouteInitialRequest(mode=mode, with_profile=mode != "follow") + outcome = await compute_initial(project_id, request) + if isinstance(outcome, JSONResponse): + logger.warning( + "추천 미리 셈 멈춤(%s %s): project_id=%s", mode, outcome.status_code, project_id + ) + return + logger.info("추천 미리 셈 끝: project_id=%s %.1fs", project_id, time.perf_counter() - started) + + def _set_base_pvi(path: Path, stations: list[float], plan: list[float]) -> int: """종단 정본의 1차 변화점(`base_pvi`) 계획고를 `stations` · `plan` 직선 보간으로 바꿔 쓴다.""" import json diff --git a/resources/tester/test_224_3_recommend_store.py b/resources/tester/test_224_3_recommend_store.py new file mode 100644 index 000000000..528f34605 --- /dev/null +++ b/resources/tester/test_224_3_recommend_store.py @@ -0,0 +1,231 @@ +"""계획노선 추천 결과 영구 저장(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