# -*- coding: utf-8 -*- """342-2 노선 [확인] 체인 — 종횡단 생성 한 번 · 자동 체인 재계산 한 번 · 3D 는 [저장]·[확정] 끝. ① 노선 [확인] 체인(`run_redesign_chain`)이 종횡단을 **한 번만** 만들고, 그 한 번이 옛 흐름의 마지막 생성(4c `regenerate_sections`)과 **같은 입력**(옵션 · 반폭 · 계획선 기준)을 받는다. 옛 흐름은 solve 안 첫 생성이 남긴 옵션을 4c 가 읽었다 — 그 옵션을 체인이 넘겨 같게 둠. ② 배수유역이 실패해도 종횡단은 선다(첫 생성을 건너뛰었으므로 체인이 한 번 세움). ③ 자동 체인은 서버 재계산 · 3D 짓기를 따로 부르지 않음 — [확정](confirm_sections) 끝 한 번씩 · 재계산 → 짓기 차례 · [저장](save_sections)도 끝에 짓기. DB · 엔진은 가짜로 바꿔 체인의 차례와 생성 입력만 잰다. """ from __future__ import annotations import asyncio import re import sys from dataclasses import asdict from pathlib import Path from types import SimpleNamespace from uuid import uuid4 import pytest ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) import B03_FileInput.B03_FileInput_Repository as b03_repo # noqa: E402 import B03_FileInput.B03_FileInput_Service_Chain as chain # noqa: E402 import B04_PreProcess.B04_PreProcess_Router_Basins as basins # noqa: E402 import B04_PreProcess.B04_PreProcess_Router_Watershed as watershed # noqa: E402 import B05_Profile.B05_Profile_Repository as b05_repo # noqa: E402 import B05_Profile.B05_Profile_Router as b05_router # noqa: E402 import B06_Section.B06_Section_Repository as b06_repo # noqa: E402 import B06_Section.B06_Section_Repository_Bulk as b06_bulk # noqa: E402 import B06_Section.B06_Section_Router as b06_router # noqa: E402 import B06_Section.B06_Section_Router_Confirm as b06_confirm # noqa: E402 import B06_Section.B06_Section_Router_Design as b06_design # noqa: E402 import common_util.common_util_initial_snapshot as snapshot # noqa: E402 import common_util.common_util_storage as storage # noqa: E402 import common_util.common_util_workflow_state as workflow # noqa: E402 import config.config_db as config_db # noqa: E402 from B05_Profile.B05_Profile_Schema import RouteSolveRequest # noqa: E402 from fastapi.responses import JSONResponse # noqa: E402 # stage 2 params(요청 쪽) · 옛 노선 종단 옵션(DB 쪽)이 **엇갈리게** — 옛 흐름 순서를 못 지키면 값이 갈린다 PARAMS = { "filter_key": "csf", "method": "dtm", "smooth": False, "points": {"bp": {"x": 0.0, "y": 0.0}, "ep": {"x": 100.0, "y": 0.0}, "cp": []}, "options": {"grade_class": "trunk", "terrain_type": "normal"}, "cross_half_width_m": 20.0, "cross_sample_interval_m": 0.5, "station_interval_m": None, "long_sample_interval_m": None, } OLD_STORED = { "station_interval_m": 25.0, "cross_half_width_m": 30.0, "cross_sample_interval_m": 1.0, "long_sample_interval_m": 2.0, } class _Cursor: async def __aenter__(self): return self async def __aexit__(self, *exc): return False async def execute(self, *args, **kwargs): return None async def fetchone(self): return None class _Connection: def cursor(self, *args): return _Cursor() async def begin(self): return None async def commit(self): return None async def rollback(self): return None class _Acquire: async def __aenter__(self): return _Connection() async def __aexit__(self, *exc): return False class _Pool: def acquire(self): return _Acquire() def _async(value): async def fake(*args, **kwargs): return value return fake @pytest.fixture def world(monkeypatch, tmp_path): calls: dict = {"generate": [], "order": [], "latest_options": 0} async def solve(project_id, request, *, generate_sections=True): calls["order"].append("solve") calls["solve_generates"] = generate_sections return SimpleNamespace(route_id=6, total_length_m=100.0) async def primary_region(project_id, refresh=False): calls["order"].append("drainage") return calls.get("region", {}) async def latest_options(connection, project_id): calls["latest_options"] += 1 return OLD_STORED def generate(project_root, route_data_path, filter_key, method, smooth, **kwargs): calls["order"].append("generate") calls["generate"].append(kwargs) data = {"length_m": 100.0, "options": {}} return { "longitudinal": {"data": data, "file_path": "long.json"}, "cross_sections": [], "result": {"longitudinal": {}, "cross_sections": []}, } async def confirm(project_id, route_id, request=None, mark_stage_complete=True): calls["order"].append("confirm_sections") return SimpleNamespace(route_id=route_id) state = {"stages": [{"stage_no": 2, "params": PARAMS}]} monkeypatch.setattr(config_db, "get_db_pool", lambda: _Pool()) monkeypatch.setattr(b05_repo, "get_latest_route", _async({"id": 5})) monkeypatch.setattr(b03_repo, "get_project_storage_relative_path", _async("p")) monkeypatch.setattr(storage, "resolve_stored_project_path", lambda path: str(tmp_path)) monkeypatch.setattr(snapshot, "mark_designing", lambda root: None) monkeypatch.setattr(snapshot, "clear_designing", lambda root: None) monkeypatch.setattr(snapshot, "discard_initial_snapshot", lambda root: False) monkeypatch.setattr(workflow, "get_workflow_state", _async(state)) monkeypatch.setattr( b06_repo, "get_longitudinal_section", _async({"data": {}, "longitudinal_file_path": "x"}) ) monkeypatch.setattr(b06_repo, "get_cross_section_designs", _async([])) monkeypatch.setattr(b06_repo, "get_latest_section_options", latest_options) monkeypatch.setattr(b05_router, "solve_route_record", solve) monkeypatch.setattr(b05_router, "confirm_latest_route", _async({})) monkeypatch.setattr(watershed, "get_primary_region", primary_region) monkeypatch.setattr(basins, "put_pipe_points", _async({"saved_count": 2})) monkeypatch.setattr(b06_confirm, "_rowless_station_chainages", lambda *a: []) monkeypatch.setattr(b06_confirm, "confirm_sections", confirm) monkeypatch.setattr(b06_design, "compute_default_designs", lambda *a: []) monkeypatch.setattr(b06_bulk, "merge_cross_section_designs", _async(0)) # 재생성 본체(B06_Section_Router) — DB 는 가짜 · 생성 입력만 잰다 monkeypatch.setattr(b06_router, "get_db_pool", lambda: _Pool()) monkeypatch.setattr( b06_router, "get_route_generation_source", _async({"route_data_path": "route.geojson", "crs_epsg": 5186}), ) monkeypatch.setattr( b06_router, "get_surface_confirmation_params", _async({"source_filter": "csf", "method": "dtm", "smooth": False}), ) monkeypatch.setattr(b06_router, "get_project_storage_relative_path", _async("p")) monkeypatch.setattr(b06_router, "get_latest_section_options", latest_options) monkeypatch.setattr(b06_router, "get_workflow_state", _async(state)) monkeypatch.setattr(b06_router, "resolve_stored_project_path", lambda path: str(tmp_path)) monkeypatch.setattr(b06_router, "run_section_generation", generate) monkeypatch.setattr(b06_router, "delete_sections_for_route", _async(None)) monkeypatch.setattr(b06_router, "create_longitudinal_section", _async(None)) monkeypatch.setattr(b06_router, "insert_cross_sections", _async(None)) monkeypatch.setattr(b06_router, "attach_saved_shapes", lambda *a: None) return calls def _old_flow_options(): """옛 흐름 — solve 첫 생성 옵션(요청 → 옛 저장 → 기본)을 4c 가 DB 에서 읽어 다시 고른 값.""" request = RouteSolveRequest( filter_key="csf", method="dtm", smooth=False, algorithm="as_planned", bp=PARAMS["points"]["bp"], ep=PARAMS["points"]["ep"], cross_half_width_m=PARAMS["cross_half_width_m"], cross_sample_interval_m=PARAMS["cross_sample_interval_m"], ) first = b05_router._section_options(request, OLD_STORED) stored_by_first = {**asdict(first), "extra_stations": []} return b06_router._regeneration_options( stored_by_first, PARAMS, float(stored_by_first["cross_half_width_m"]) ) def test_종횡단은_한_번_관_뒤에(world): failure = asyncio.run(chain.run_redesign_chain(uuid4(), 1, {})) assert failure is None assert world["solve_generates"] is False assert len(world["generate"]) == 1 order = world["order"] assert order.index("solve") < order.index("drainage") < order.index("generate") assert order.index("generate") < order.index("confirm_sections") def test_한_번이_옛_마지막_생성과_같은_입력(world): asyncio.run(chain.run_redesign_chain(uuid4(), 1, {})) got = world["generate"][0] expected = _old_flow_options() assert got["options"] == expected # 옛 노선 저장 반폭(30)이 아니라 요청 반폭(20) — 첫 생성이 남기던 값 assert got["options"].cross_half_width_m == 20.0 assert got["options"].cross_sample_interval_m == 0.5 assert got["options"].station_interval_m == 25.0 assert got["grade_options"] == b06_router._regeneration_grade_options(PARAMS) assert "grade_overrides" not in got # 옛 4c 도 안 넘김 — 저장본 grade_overrides 빈 값 그대로 # 생성 쪽은 DB 최신 종단(=옛 노선)을 다시 읽지 않음 — 체인이 넘긴 옵션 assert world["latest_options"] == 1 # 체인이 옛 노선 옵션을 읽은 한 번뿐 def test_배수유역이_실패해도_종횡단은_선다(world): world["region"] = JSONResponse(status_code=500, content={}) failure = asyncio.run(chain.run_redesign_chain(uuid4(), 1, {})) assert failure is None assert len(world["generate"]) == 1 assert world["generate"][0]["options"] == _old_flow_options() def _body(source: str, name: str) -> str: start = source.index(f"async def {name}(") following = re.search(r"\n(async def|def|@router)", source[start + 10 :]) return source[start : start + 10 + following.start()] if following else source[start:] def test_자동_체인은_재계산_짓기를_따로_안_부름(): source = (ROOT / "B03_FileInput" / "B03_FileInput_Service_Chain.py").read_text("utf-8") auto = _body(source, "run_auto_design_chain") assert "recompute_server_side" not in auto assert "prebuild_corridor" not in auto assert "confirm_sections(" in auto def test_확정_저장_끝에서_재계산_뒤_짓기(): source = (ROOT / "B06_Section" / "B06_Section_Router_Confirm.py").read_text("utf-8") for name in ("confirm_sections", "save_sections"): body = _body(source, name) assert body.count("_recompute_stored_designs(") == 1, name assert body.count("_prebuild_corridor(") == 1, name assert body.index("_recompute_stored_designs(") < body.index("_prebuild_corridor("), name confirm = _body(source, "confirm_sections") # 측구 방향 역반영(종단 파일 고침)까지 끝난 뒤 짓기 assert confirm.index("_merge_uphill_overrides_into_longitudinal") < confirm.index( "_prebuild_corridor(" ) def test_브라우저는_3D_를_올리지_않음(): source = (ROOT / "B05_Profile" / "B05_Profile_UI_Corridor.ts").read_text("utf-8") assert '"PUT"' not in source assert "putStored" not in source