- 노선 [확인] 체인: 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
277 lines
11 KiB
Python
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
|