Files
Aislo/resources/tester/test_342_2_chain_once.py
T
eomsangdonandClaude Opus 5.5 5025313b29 perf(342-2): 노선 [확인] 체인 종횡단 생성 한 번 · 자동 체인 재계산 한 번 · 3D 길 모형 서버 짓기
- 노선 [확인] 체인: solve 안 첫 생성을 건너뛰고 관 정한 뒤 한 번만(건너뛴 생성의 옵션을 넘겨 입력 같음 · 배수유역 실패 때도 종횡단 세움)
- 자동 체인: 서버 재계산 · 3D 짓기를 [확정] 끝 한 번씩(재계산 → 짓기 차례)
- 3D 길 모형: B06 [저장] · [확정] 끝에 서버 Node 가 지음 · 브라우저 올림(PUT) 길 없앰 · 브라우저는 해시 맞으면 받기만 · 어긋나면 미리보기만
- 시험 test_342_2_chain_once · corridor router 시험 고쳐 씀

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

277 lines
11 KiB
Python

# -*- 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