Files
Aislo/resources/tester/test_347_2_drainage_live.py
T
eomsangdonandClaude Opus 5.5 0ba4653212 feat(520-2): B05 배수유역 저장 노선 [업데이트] 결과 남김 · 끊김 알림 · 도엽 메모리 보관
- B04 Router_Watershed — 분석 응답 · 단계 산출물 저장을 Output.write_analysis 로 뗌(primary-region 같은 길) · 도엽 좌표 저장본 메모리 보관(2 벌)
- B04 Router_Watershed_Live — 저장 노선 기준(초안 없음 · 지문 같음) 답이면 마지막 끊김 확인 뒤 분석을 남김 · design/save 잠금 중이면 안 남김 · 남길 때 같은 잠금 쥠 · POST /drainage/live/cancel(그 차례가 마지막일 때만 지움) · 셈 멈춤 로그
- B05 Drainage_Live — 요청 끊을 때 서버에 끊김 알림 · 저장 노선 [업데이트] 답이 저장분 낡음 걷음 · 되돌리기로 초안이 없어지면 저장분의 마지막 낡음으로 표시 바로
- 시험 test_349_drainage_live_flow 고침 — 되돌리기 직후 낡음 기대가 옛 동작(답 올 때까지 낡음) · 새 동작 = 저장분 마지막 낡음(여기선 꺼짐) + 끊김 알림 확인
- 시험 test_347_2 — 「작업본에 안 씀」 옛 설명 고침 · 남김 · 초안 안 남김 · 잠금 중 안 남김 · 끊김 알림 시험 더함 · test_347_1 메모리 보관 확인

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 08:18:00 +09:00

232 lines
9.0 KiB
Python

"""347-2 「지금 노선으로 셈」 서버 길 · 347-3 관 자리 후보.
- 늦은 차례: 계산 중 새 차례가 오면 단계 사이에서 그만둠(superseded) · 그만둔 셈은 안 남김.
- 저장 노선(초안 없음 · 지문 같음) 답만 분석을 남김(520-2) · 초안 노선 · design/save 잠금 중엔 안 남김.
- 끊김 알림 = 그 차례가 아직 마지막일 때만 지움(새 차례는 안 끊음).
- 메모리 격자로 세부 유역(파일 안 읽음) · 노선 초안 → 그린 노선.
- 관 자리 후보: 최대 간격(100 m)을 넘는 구간에만 · 기존 관 자리엔 안 냄 · 관 목록엔 안 넣음.
- [확정](recompute) = 낡았으면 배수유역부터 다시 셈 · [저장] = 안 셈.
"""
import asyncio
import math
from pathlib import Path
from types import SimpleNamespace
from uuid import uuid4
import numpy as np
import pytest
from shapely.geometry import LineString
from B04_PreProcess import B04_PreProcess_Engine_Watershed_Expand as expand
from B04_PreProcess import B04_PreProcess_Router_Basins as basins
from B04_PreProcess import B04_PreProcess_Router_Watershed_Live as live
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import GridSpec
from common_util.common_util_drainage_context import DrainageContext
from common_util.common_util_drainage_detail import (
RoadRouting,
build_detail,
pipes_from_chainages,
suggest_pipe_positions,
)
from common_util.common_util_route_geometry import RouteVertex
VERTICES = [RouteVertex(0.0, 0.0, 100.0, 0.0), RouteVertex(300.0, 0.0, 90.0, 300.0)]
def test_draft_vertices_follow_nodes():
draft = {"nodes": [[0, 0], [100, 0], [100, 100]], "curveOn": [False, False, False]}
vertices = live.draft_vertices(draft, 30.0)
assert vertices[0].x == 0 and vertices[-1].y == 100
assert math.isclose(vertices[-1].chainage_m, 200.0)
def test_candidates_only_in_long_gaps():
strength = np.zeros(301)
strength[150] = 50.0 # 흐름이 모이는 자리
pipes = pipes_from_chainages(VERTICES, [0.0, 300.0])
picked = suggest_pipe_positions(VERTICES, pipes, strength)
assert picked and all(0 < pipe.chainage_m < 300 for pipe in picked)
assert all(pipe.chainage_m == round(pipe.chainage_m) for pipe in picked) # 정수 m
dense = pipes_from_chainages(VERTICES, [0.0, 90.0, 180.0, 270.0, 300.0])
assert suggest_pipe_positions(VERTICES, dense, strength) == []
def _routing() -> RoadRouting:
spec = GridSpec(x_min=-5.0, y_max=5.0, cell_m=1.0, n_rows=10, n_cols=310)
road_index = np.arange(5 * 310 + 5, 5 * 310 + 305, dtype=np.int32)
road_slot = np.full(spec.size, -1, dtype=np.int32)
road_slot[road_index] = np.arange(road_index.size)
return RoadRouting(
spec=spec,
road_slot=road_slot,
path_length=np.zeros(spec.size, np.float32),
elevation=np.full(spec.size, 95.0, np.float32),
road_cell_index=road_index,
road_chainage=np.arange(road_index.size, dtype=np.float64),
strength=np.ones(road_index.size, np.int64),
)
def test_build_detail_uses_memory_routing(tmp_path):
missing = tmp_path / "없는 폴더" # 파일을 읽으면 None 이 됨
detail = build_detail(missing, VERTICES, [0.0, 300.0], _routing(), [])
assert detail is not None and detail.pipe_candidates
assert build_detail(missing, VERTICES, [0.0, 300.0]) is None
def _patch_live(monkeypatch, preview_fn):
async def prepare(project_id):
return {"contours": [], "streams": [], "contour_key": "k"}
async def load(project_id):
return DrainageContext(stored_path="x", project_root=Path("."), vertices=VERTICES), ""
monkeypatch.setattr(live, "_prepare", prepare)
monkeypatch.setattr(live, "load_drainage_context", load)
monkeypatch.setattr(live, "drainage_dir", lambda stored: Path("."))
monkeypatch.setattr(live, "preview_stages", preview_fn)
calls = SimpleNamespace(build=0)
def build(*args):
calls.build += 1
return None, []
monkeypatch.setattr(live, "_build", build)
return calls
def test_newer_generation_stops_between_stages(monkeypatch):
pid = uuid4()
def preview(vertices, contours, streams, store, should_stop):
live._LATEST_GENERATION[str(pid)] = 8 # 계산 중 새 조작 → 새 요청이 차례를 올림
if should_stop():
raise expand.AnalysisStopped
calls = _patch_live(monkeypatch, preview)
result = asyncio.run(live.post_drainage_live(pid, {"generation": 7, "points": []}))
assert result == live.SUPERSEDED and calls.build == 0
def test_newer_generation_after_preview_skips_split(monkeypatch):
pid = uuid4()
def preview(vertices, contours, streams, store, should_stop):
assert not should_stop()
live._LATEST_GENERATION[str(pid)] = 3
return SimpleNamespace(routing=object())
calls = _patch_live(monkeypatch, preview)
result = asyncio.run(live.post_drainage_live(pid, {"generation": 2}))
assert result == live.SUPERSEDED and calls.build == 0
def test_engine_stops_in_expansion_round():
contours = [
{
"geometry": {
"type": "LineString",
"coordinates": [
[150 + r * math.cos(t), -150 + r * math.sin(t)]
for t in np.linspace(0, 2 * math.pi, 121)
],
},
"properties": {"elevation": 300 - r},
}
for r in range(10, 200, 10)
]
spec = GridSpec(x_min=100.0, y_max=-100.0, cell_m=1.0, n_rows=100, n_cols=100)
domain = np.zeros((100, 100), bool)
domain[20:80, 20:80] = True
route = LineString([(110.0, -150.0), (190.0, -150.0)])
with pytest.raises(expand.AnalysisStopped):
expand.expand_by_red_boundary(
spec, domain, contours, route, [], None, should_stop=lambda: True
)
@pytest.mark.parametrize(
"recompute, stale, expected", [(True, True, 1), (True, False, 0), (False, True, 0)]
)
def test_confirm_recomputes_only_when_stale(monkeypatch, recompute, stale, expected):
import B04_PreProcess.B04_PreProcess_Router_Watershed as watershed
calls = []
async def is_stale(project_id):
return stale
async def region(project_id, refresh=False):
calls.append(refresh)
return {"status": "success"}
async def resolve(*args, **kwargs):
return basins.JSONResponse(status_code=404, content={})
monkeypatch.setattr(basins, "_analysis_stale", is_stale)
monkeypatch.setattr(watershed, "get_primary_region", region)
monkeypatch.setattr(basins, "_resolve", resolve)
asyncio.run(basins.put_pipe_points(uuid4(), {"points": []}, None, recompute))
assert len(calls) == expected and all(calls)
def _patch_keep(monkeypatch):
preview = SimpleNamespace(
routing=object(), region=SimpleNamespace(split=SimpleNamespace(upstream=[]))
)
_patch_live(monkeypatch, lambda *args: preview)
monkeypatch.setattr(live, "_build", lambda *args: (object(), []))
monkeypatch.setattr(live, "live_routing", lambda *args: None)
monkeypatch.setattr(live, "_payload", lambda *args: {"status": "success"})
writes = []
monkeypatch.setattr(live, "write_analysis", lambda *args: writes.append(args[-1]))
return writes
def test_saved_route_answer_is_kept(monkeypatch):
writes = _patch_keep(monkeypatch)
result = asyncio.run(live.post_drainage_live(uuid4(), {"generation": 1, "route": None}))
assert result["status"] == "success" and writes == [VERTICES]
def test_draft_route_answer_not_kept(monkeypatch):
from B05_Profile import B05_Profile_Router_Replan as replan
writes = _patch_keep(monkeypatch)
moved = [RouteVertex(0.0, 5.0, 100.0, 0.0), RouteVertex(300.0, 5.0, 90.0, 300.0)]
async def criteria(project_id):
return (30.0,)
monkeypatch.setattr(replan, "_plan_criteria", criteria)
monkeypatch.setattr(live, "draft_vertices", lambda draft, radius: moved)
monkeypatch.setattr(live, "_open_sampler", lambda *args: None)
monkeypatch.setattr(live, "resolve_route_profile", lambda drawn, sampler: (drawn, "x"))
draft = {"nodes": [[0, 5], [300, 5]]}
asyncio.run(live.post_drainage_live(uuid4(), {"generation": 1, "route": draft}))
assert writes == []
monkeypatch.setattr(live, "draft_vertices", lambda draft, radius: VERTICES) # 지문 같음
asyncio.run(live.post_drainage_live(uuid4(), {"generation": 1, "route": draft}))
assert writes == [VERTICES]
def test_not_kept_while_design_save_holds_lock(monkeypatch):
writes = _patch_keep(monkeypatch)
pid = uuid4()
async def run():
async with live.guard.project_lock(str(pid)):
return await live.post_drainage_live(pid, {"generation": 1})
assert asyncio.run(run())["status"] == "success" and writes == []
def test_cancel_clears_only_latest_generation():
pid = uuid4()
live._LATEST_GENERATION[str(pid)] = 5
assert asyncio.run(live.post_drainage_live_cancel(pid, {"generation": 4}))["stopped"] is False
assert live._LATEST_GENERATION[str(pid)] == 5
assert asyncio.run(live.post_drainage_live_cancel(pid, {"generation": 5}))["stopped"] is True
assert str(pid) not in live._LATEST_GENERATION