fix(763-3): B04 분석 · 조합 짓기 = 보기만 · 다른 서피스 [확정] = 업로드와 같은 전처리 체인 · 체인 실패에도 미리 셈

- B04 [분석](조합 짓기) — 단계 시작 · 확정 해제 · 실패 단계 기록 걷음 · 1 단계 값(las_selection 포함) 그대로 · 미리 셈 없음 · 진행 글 = 확정 있으면 「확정 그대로」
- 분석 결과 DB 고쳐 쓰기가 확정 모델을 COMPLETE 로 내리던 것 — CONFIRMED 그대로(`_upsert_surface_model`) · 안 쓰는 `clear_confirmed_surface_models` 지움
- B04 [확정] — 같은 서피스(필터 · 표현 · 평활) = 등고선 간격만 · 다른 서피스 = `run_surface_change_chain`(새 파일): 초기값 스냅샷 버림 · B05~B09 지움(같은 지형 재업로드 길 keep_preprocess) · 단계 2 이후 처음 → 자동 설계 체인
- 자동 · 재확정 체인 미리 셈 = finally(실패 · 건너뜀 길에도) · B05 LAS 나중 올리기 끝 미리 셈 예약
- 추천 [적용](as_initial) = 선만 · 설계 지형 그대로(요구 605-2 · 브레인 승인) · 지형 칩은 사용자 답 기다림(지금대로)
- 지킴 시험 test_763_3 — B05~B09 파이썬에서 확정 서피스 쓰는 부름은 지형 칩 · 라이다 적어 둠 둘만
- 시험 309_3 · 351_1 · 509_5 를 새 길에 맞춤(미리 셈 부르는 곳 둘 더 · finally · 추천 [적용] 지형 안 넘김)
- 재확정 체인(`run_redesign_chain`) 부르는 곳 0 — 지움은 따로(시험 342_2 셋 함께)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-03 13:45:30 +09:00
co-authored by Claude Opus 5.5
parent 6af3f990ff
commit cf09d53924
11 changed files with 389 additions and 79 deletions
+8 -10
View File
@@ -347,12 +347,6 @@ async def run_auto_design_chain(
logger.exception("초기값 스냅샷 실패: project_id=%s", project_id)
mark_design_failed(project_root, f"초기값 스냅샷 저장 실패: {exc}")
# 노선 추천 기본 다섯 알약 미리 셈(PLAN 327-2) — 예약만 · 체인이 [초기화] 요청 안에서도
# 불리므로 기다리지 않는다. 같은 입력이면 저장본이 있어 셈 없이 끝난다.
from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute
schedule_precompute(project_id)
return {
"route_id": route_id,
"length_m": float(solve_result.total_length_m or 0.0),
@@ -367,6 +361,10 @@ async def run_auto_design_chain(
# 성공·실패·중단 어느 쪽이든 문은 연다 — 마커가 남으면 영영 못 들어간다.
if project_root is not None:
clear_designing(project_root)
# 성공 · 실패 · 건너뜀 어느 쪽이든 추천 미리 셈 예약(763-3) — 추천은 지형 · 예정노선만 씀
from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute
schedule_precompute(project_id)
async def _ensure_initial_polyline(
@@ -670,10 +668,6 @@ async def run_redesign_chain(
return f"종횡단 확정 실패({sections_result.status_code})"
marks.append(("B06 확정(서버 재계산 포함)", time.perf_counter()))
_log_steps("재확정 체인", marks)
# 지형이 바뀌면 추천 키도 바뀜 — 첫 누름이 저장본이게 미리 셈(351-1)
from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute
schedule_precompute(project_id)
logger.info(
"재확정 체인 완료: project_id=%s route %s→%s (설계 %d건 이월)",
project_id,
@@ -688,3 +682,7 @@ async def run_redesign_chain(
finally:
if project_root is not None:
clear_designing(project_root)
# 성공 · 실패 · 건너뜀 어느 쪽이든 추천 미리 셈 예약(763-3) — 추천은 지형 · 예정노선만 씀
from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute
schedule_precompute(project_id)
@@ -0,0 +1,48 @@
"""서피스가 바뀐 뒤 전처리 체인 다시(763-3 · 요구 605) — 업로드와 같은 길 하나.
부르는 곳 = B04 다른 서피스 [확정]. B05~B09 초기화 = 같은 지형 재업로드 길
(`purge_project_outputs(keep_preprocess=True)` · 단계 2 이후 처음 · 초기값 스냅샷 버림) →
`run_auto_design_chain`(끝에 추천 미리 셈 · 실패 길에도). 전처리 결과(B04) · 확정은 그대로.
"""
import asyncio
import logging
from pathlib import Path
from uuid import UUID
logger = logging.getLogger(__name__)
async def run_surface_change_chain(project_id: UUID, surface_model_id: int | None) -> None:
"""B05~B09 를 지우고 자동 설계 체인을 새 확정 서피스로 다시 탐 — 실패는 로그만."""
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B03_FileInput.B03_FileInput_Service_Chain import run_auto_design_chain
from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute
from common_util.common_util_initial_snapshot import discard_initial_snapshot
from common_util.common_util_project_reset import purge_project_outputs
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_workflow_state import reset_stages_after_input_change
from config.config_db import get_db_pool
try:
async with get_db_pool().acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
if not stored_path:
return
root = Path(resolve_stored_project_path(stored_path))
await asyncio.to_thread(discard_initial_snapshot, root)
await connection.begin()
try:
await purge_project_outputs(connection, str(project_id), root, keep_preprocess=True)
async with connection.cursor() as cursor:
await reset_stages_after_input_change(cursor, str(project_id), 2)
await connection.commit()
except Exception:
await connection.rollback()
raise
except Exception: # noqa: BLE001 — 체인은 응답 뒤 · 추천은 새 서피스로 셈
logger.exception("서피스 바꿈 체인 — B05~B09 초기화 실패: project_id=%s", project_id)
schedule_precompute(project_id)
return
logger.info("서피스 바꿈 체인 — B05~B09 초기화 뒤 자동 설계: project_id=%s", project_id)
await run_auto_design_chain(project_id, surface_model_id=surface_model_id)
+3 -15
View File
@@ -287,19 +287,6 @@ async def list_surface_models(
return models
async def clear_confirmed_surface_models(connection: aiomysql.Connection, project_id: UUID) -> None:
"""재분석 시 기존 확정을 해제하여 새 분석 결과의 재확정을 요구한다."""
async with connection.cursor() as cursor:
await cursor.execute(
"""
UPDATE surface_models
SET status = 'COMPLETE'
WHERE project_id = %s AND status = 'CONFIRMED'
""",
(str(project_id),),
)
async def confirm_surface_model(
connection: aiomysql.Connection, project_id: UUID, model_id: int
) -> None:
@@ -384,7 +371,7 @@ async def _upsert_surface_model(
processed_cloud_id: int | None,
crs_epsg: int | None,
) -> int:
"""파일 지문이 같은 옛 행이면 id 를 살려 고쳐 씀 — 화면 주소 · 노선 연결이 그대로.
"""파일 지문이 같은 옛 행이면 id 를 살려 고쳐 씀 — 화면 주소 · 노선 연결 · 확정이 그대로(763-3).
지문이 다르거나 없으면 옛 행을 지우고 새로 넣는다(새 지표면 = 노선 연결도 끊김).
"""
@@ -397,7 +384,8 @@ async def _upsert_surface_model(
await cursor.execute(
"""
UPDATE surface_models
SET source_file_id = %s, processed_cloud_id = %s, status = 'COMPLETE',
SET source_file_id = %s, processed_cloud_id = %s,
status = IF(status = 'CONFIRMED', 'CONFIRMED', 'COMPLETE'),
crs_epsg = %s, resolution_m = %s, generation_params = %s,
completed_at = CURRENT_TIMESTAMP
WHERE id = %s
+48 -45
View File
@@ -24,7 +24,6 @@ from B04_PreProcess.B04_PreProcess_Engine_Extent import (
from B04_PreProcess.B04_PreProcess_Engine_Ground import available_filters
from B04_PreProcess.B04_PreProcess_Engine_PointPreview import ensure_point_preview, sample_points
from B04_PreProcess.B04_PreProcess_Repository import (
clear_confirmed_surface_models,
get_input_file,
list_project_point_cloud_inputs,
list_project_point_cloud_paths,
@@ -67,13 +66,11 @@ from common_util.common_util_auth import require_system_admin
from common_util.common_util_http_cache import cached_file_response
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import (
confirmed_surface_params,
get_surface_confirmation_params,
surface_confirmation_defaults,
)
from common_util.common_util_workflow_state import (
fail_stage,
start_stage,
update_stage_progress,
terrain_changes,
update_contour_interval_param,
)
from config.config_db import get_db_pool
@@ -89,7 +86,10 @@ router.include_router(status_router)
async def analyze_surface(
project_id: UUID, request: SurfaceAnalyzeRequest
) -> SurfaceAnalyzeResponse | JSONResponse:
"""LAS 구조화·지면 필터·지표면 모델 생성을 실행하고 DB에 기록한다."""
"""LAS 구조화·지면 필터·지표면 모델 생성을 실행하고 DB에 기록한다 — 보기만(763-3 · 요구 605).
확정 · 단계 상태 · 1 단계 값(`las_selection` 포함)을 안 바꿈 · 미리 셈도 없음. 서피스를 바꾸는
길 = 다른 모델 [확정](전처리 체인 다시)."""
try:
source_filters = request.resolved_filters()
methods = request.resolved_methods()
@@ -97,19 +97,9 @@ async def analyze_surface(
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
pool = get_db_pool()
project_root: Path | None = None
try:
params = {
"input_file_id": request.input_file_id,
"source_filters": source_filters,
"methods": methods,
"force": request.force,
}
async with pool.acquire() as connection:
async with connection.cursor() as cursor:
await start_stage(cursor, str(project_id), 1, params)
await clear_confirmed_surface_models(connection, project_id)
await connection.commit()
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
input_file = await get_input_file(connection, project_id, request.input_file_id)
@@ -151,19 +141,23 @@ async def analyze_surface(
analysis_result=result,
source_filters=source_filters,
)
async with connection.cursor() as cursor:
await update_stage_progress(cursor, str(project_id), 1, 100)
await connection.commit()
confirmed = await confirmed_surface_params(connection, str(project_id))
except Exception:
await connection.rollback()
raise
write_surface_progress(
project_root,
100,
"awaiting_confirmation",
"WF1 분석이 완료되었습니다. 사용할 모델을 확정하세요.",
)
if confirmed is not None:
write_surface_progress(
project_root, 100, "completed", "조합을 지었습니다 — 확정 그대로."
)
else:
write_surface_progress(
project_root,
100,
"awaiting_confirmation",
"WF1 분석이 완료되었습니다. 사용할 모델을 확정하세요.",
)
return SurfaceAnalyzeResponse(
project_id=str(project_id),
@@ -173,16 +167,14 @@ async def analyze_surface(
)
except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except (OSError, ValueError) as exc:
async with pool.acquire() as connection, connection.cursor() as cursor:
await fail_stage(cursor, str(project_id), 1, str(exc))
await connection.commit()
except (OSError, ValueError) as exc: # 보기만 — 단계 상태 그대로 · 진행 파일만 끝(763-3)
if project_root is not None:
write_surface_progress(project_root, 100, "failed", str(exc))
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except Exception as exc:
logger.exception("B04 지표면 분석 실패: project_id=%s", project_id)
async with pool.acquire() as connection, connection.cursor() as cursor:
await fail_stage(cursor, str(project_id), 1, str(exc))
await connection.commit()
if project_root is not None:
write_surface_progress(project_root, 100, "failed", str(exc))
return JSONResponse(
status_code=500,
content={"status": "error", "message": "지표면 분석 처리 중 오류가 발생했습니다."},
@@ -214,7 +206,10 @@ async def confirm_surface(
request: SurfaceConfirmRequest,
_session: dict[str, Any] = Depends(require_system_admin),
) -> SurfaceConfirmResponse | JSONResponse:
"""사용자가 선택한 지표면 모델을 확정하고 WF1을 완료한다."""
"""사용자가 선택한 지표면 모델을 확정하고 WF1을 완료한다.
같은 서피스(필터 · 표현 · 평활) 다시 [확정] = 등고선 간격만 · 체인 없음. 다른 서피스 = 업로드와
같은 전처리 체인(B05~B09 초기화 → 자동 설계 · 추천 미리 셈 · 763-3 · 요구 605)."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
@@ -247,24 +242,32 @@ async def confirm_surface(
),
}
)
await confirm_surface_selection(
connection,
project_id,
request.model_id,
selection,
)
current = await confirmed_surface_params(connection, str(project_id))
same = current is not None and not terrain_changes(current, selection)
if same: # 같은 서피스 — 아무것도 안 다시 셈(등고선 간격만 · 763-3)
await update_contour_interval_param(
connection, str(project_id), float(selection["contour_interval_m"])
)
else:
await confirm_surface_selection(
connection,
project_id,
request.model_id,
selection,
)
await connection.commit()
except Exception:
await connection.rollback()
raise
# 재확정 체인(2026-08-04 사용자 확정) — 새 지표면 기준으로 B05·B06을 백그라운드
# 재계산·저장한다. 사용자 저장 입력(제어점·경사 옵션·측점별 설계)은 유지·이월된다.
if same:
return SurfaceConfirmResponse(project_id=str(project_id), model_id=request.model_id)
# 서피스가 바뀜 — 업로드와 같은 전처리 체인 하나(B05~B09 초기화 · 763-3 · 요구 605).
# 응답을 막지 않도록 백그라운드로 돌리고, 체인은 실패를 스스로 격리한다.
from B03_FileInput.B03_FileInput_Service_Chain import run_redesign_chain
from B03_FileInput.B03_FileInput_Service_SurfaceChange import run_surface_change_chain
redesign_task = asyncio.create_task(
run_redesign_chain(project_id, request.model_id, dict(selection)),
name=f"redesign-chain-{project_id}",
run_surface_change_chain(project_id, request.model_id),
name=f"surface-change-chain-{project_id}",
)
redesign_task.add_done_callback(
lambda task: task.exception() # 체인 내부에서 이미 로깅 — 미회수 예외 경고만 방지
+3
View File
@@ -221,6 +221,9 @@ async def upload_route_lidar(project_id: UUID, files: list[UploadFile] = File(..
await connection.begin()
await stash_las_selection(connection, str(project_id), got["meta"]["selection"])
await connection.commit()
from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute
schedule_precompute(project_id) # 라이다 지형이 바뀜 — 추천 다시 셈(763-3)
report(100, f"라이다 지형 끝 — 점 {got['meta']['point_count']:,}개")
return lidar_upload.start_job(_JOBS, _TASKS, project_id, KIND, work, root / JOB, "B05")
+1 -1
View File
@@ -578,7 +578,7 @@ async def _save_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, Any
if request.balanced_profile and as_initial
else None
),
request.terrain if as_initial else None, # 손 [저장] = 지형 그대로 · 바꿈 = 지형 칩(626-1)
None, # 설계 지형 그대로 — 추천 [적용]도 선만(763-3 · 요구 605-2) · 바꿈 = 지형 칩(626-1)
(old_line, await asyncio.to_thread(_vertices_of, working_path)),
as_initial,
)
+6 -3
View File
@@ -527,11 +527,14 @@ async def _surface_run_settled(cursor: aiomysql.DictCursor, project_id: str) ->
return stage in _SETTLED_SURFACE_STAGES
async def reset_stages_after_input_change(cursor: aiomysql.DictCursor, project_id: str) -> None:
async def reset_stages_after_input_change(
cursor: aiomysql.DictCursor, project_id: str, first_stage: int = 1
) -> None:
"""입력 자료가 갈렸을 때 1단계(전처리) 이후를 처음 상태로 되돌린다.
새 자료로 다시 계산해 덮어쓰므로, 옛 자료 기준의 진행 표시가 남아 있으면 사용자가 이미
끝난 단계로 착각한다. 파일 입력(0단계)은 방금 끝났으니 건드리지 않는다.
`first_stage` = 2 — 서피스 바꿈 체인(전처리 결과 · 확정은 그대로 · 763-3).
"""
await cursor.execute(
"""
@@ -542,7 +545,7 @@ async def reset_stages_after_input_change(cursor: aiomysql.DictCursor, project_i
message = NULL,
started_at = NULL,
completed_at = NULL
WHERE project_id = %s AND stage_no >= 1
WHERE project_id = %s AND stage_no >= %s
""",
(project_id,),
(project_id, first_stage),
)
@@ -66,6 +66,8 @@ def test_미리_셈_부르는_곳은_체인만() -> None:
"B03_FileInput_Service_Chain.py", # 업로드 체인 · B04 재확정 체인
"B01_Dashboard_Router.py", # 사용 범위 바꿈 = 예상노선 다시 씀
"B05_Profile_Router_RouteInitial.py", # 정의
"B03_FileInput_Service_SurfaceChange.py", # 서피스 바꿈 체인 초기화 실패 길(763-3)
"B05_Profile_Router_Lidar.py", # LAS 나중 올리기 끝(763-3)
}
@@ -92,10 +92,9 @@ def test_두_체인_끝에서_미리_셈_예약() -> None:
source = Path("B03_FileInput/B03_FileInput_Service_Chain.py").read_text(encoding="utf-8")
redesign = source[source.index("async def run_redesign_chain(") :]
upload = source[: source.index("async def run_redesign_chain(")]
assert "schedule_precompute(project_id)" in upload
# 재확정 체인 성공 끝(완료 로그 앞)
done = redesign.index("재확정 체인 완료")
assert "schedule_precompute(project_id)" in redesign[:done]
# 두 체인 모두 finally — 실패 · 건너뜀 길에도 예약(763-3 · 실행 시험 = test_763_3)
for body in (upload, redesign):
assert "schedule_precompute(project_id)" in body.split(" finally:", 1)[1]
b01 = Path("B01_Dashboard/B01_Dashboard_Router.py").read_text(encoding="utf-8")
assert "refresh_expected_route(" in b01
@@ -116,7 +116,7 @@ def test_고를_지형은_DB_모델_있을_때만(tmp_path, monkeypatch) -> None
def test_지형_바꿈은_추천_적용만_초기값_다시_세움() -> None:
replan = (ROOT / "B05_Profile/B05_Profile_Router_Replan.py").read_text(encoding="utf-8")
save = replan.split("async def _save_route(", 1)[1]
assert "request.terrain if as_initial else None," in save # 손 [저장]은 지형 안 바꿈
assert "request.terrain" not in save # 추천 [적용]도 지형 안 바꿈(763-3 · 요구 605-2)
assert save.index("await _recompute(") < save.index("refresh_initial_snapshot(project_id")
recompute = replan.split("async def _recompute(", 1)[1].split("\nasync def ")[0]
assert "switch_design_terrain(connection, project_id, terrain)" in recompute
@@ -0,0 +1,266 @@
"""763-3 (요구 605) B04 분석 · 조합 짓기 = 보기만 · 다른 서피스 [확정] = 업로드와 같은 전처리 체인 ·
같은 서피스 다시 [확정] = 다시 셈 없음 · 체인 실패에도 추천 미리 셈 · B05~B09 는 서피스를 안 바꿈.
./venv/Scripts/python.exe -m pytest resources/tester/test_763_3_surface_change.py -q
"""
import asyncio
import re
from contextlib import asynccontextmanager
from pathlib import Path
from uuid import uuid4
import pytest
from B03_FileInput import B03_FileInput_Service_SurfaceChange as change
from B04_PreProcess import B04_PreProcess_Router as b04
from B04_PreProcess.B04_PreProcess_Schema import SurfaceAnalyzeRequest, SurfaceConfirmRequest
ROOT = Path(__file__).resolve().parents[2]
CONFIRMED = {
"source_filter": "classification",
"method": "implicit",
"smooth": False,
"contour_interval_m": 1.0,
}
class _Cursor:
def __init__(self, log):
self.log = log
async def __aenter__(self):
return self
async def __aexit__(self, *_a):
return False
async def execute(self, sql, *_a):
self.log.append(" ".join(str(sql).split()))
async def fetchone(self):
return None
class _Connection:
def __init__(self, log):
self.log = log
def cursor(self, *_a):
return _Cursor(self.log)
async def begin(self):
self.log.append("BEGIN")
async def commit(self):
self.log.append("COMMIT")
async def rollback(self):
self.log.append("ROLLBACK")
class _Pool:
def __init__(self, log):
self.log = log
@asynccontextmanager
async def acquire(self):
yield _Connection(self.log)
def _async(value):
async def inner(*_a, **_k):
return value
return inner
@pytest.fixture
def b04_world(tmp_path, monkeypatch):
log: list[str] = []
calls: dict[str, list] = {"progress": [], "chain": [], "confirm": [], "contour": []}
las = tmp_path / "raw.las"
las.write_bytes(b"")
monkeypatch.setattr(b04, "get_db_pool", lambda: _Pool(log))
monkeypatch.setattr(b04, "get_project_storage_relative_path", _async("x"))
monkeypatch.setattr(b04, "resolve_stored_project_path", lambda _p: str(tmp_path))
monkeypatch.setattr(b04, "get_input_file", _async({"raw_file_path": "raw.las"}))
monkeypatch.setattr(b04, "list_project_point_cloud_paths", _async([las]))
monkeypatch.setattr(
b04,
"run_surface_analysis",
lambda *_a, **_k: {"ground_summary": {}, "manifest": {"status": "ok"}},
)
monkeypatch.setattr(b04, "save_surface_analysis_to_db", _async([1, 2]))
monkeypatch.setattr(b04, "confirmed_surface_params", _async(dict(CONFIRMED)))
monkeypatch.setattr(
b04, "write_surface_progress", lambda _r, p, stage, _m: calls["progress"].append(stage)
)
monkeypatch.setattr(
b04,
"list_surface_models",
_async(
[
{
"id": 7,
"model_type": "implicit",
"generation_params": {"source_filter": "classification"},
},
{"id": 8, "model_type": "implicit", "generation_params": {"source_filter": "csf"}},
]
),
)
async def confirm(_c, _pid, model_id, selection):
calls["confirm"].append((model_id, selection["source_filter"]))
async def contour(_c, _pid, value):
calls["contour"].append(value)
async def chain(pid, model_id):
calls["chain"].append(model_id)
monkeypatch.setattr(b04, "confirm_surface_selection", confirm)
monkeypatch.setattr(b04, "update_contour_interval_param", contour)
monkeypatch.setattr(change, "run_surface_change_chain", chain)
calls["log"] = log
return calls
def test_분석은_보기만_단계_확정_그대로(b04_world) -> None:
request = SurfaceAnalyzeRequest(input_file_id=3, source_filters=["csf"], methods=["implicit"])
answer = asyncio.run(b04.analyze_surface(uuid4(), request))
assert answer.surface_model_ids == [1, 2]
sql = " | ".join(b04_world["log"])
assert "project_workflow_stages" not in sql and "surface_models" not in sql
assert b04_world["progress"][-1] == "completed" # 확정 그대로 — 「확정하세요」 아님
source = (ROOT / "B04_PreProcess/B04_PreProcess_Router.py").read_text(encoding="utf-8")
body = source.split("async def analyze_surface(", 1)[1].split("\n@router.", 1)[0]
for name in ("start_stage", "fail_stage", "update_stage_progress", "schedule_precompute"):
assert name not in body, name
def test_조합_다시_적어도_확정_행_그대로() -> None:
source = (ROOT / "B04_PreProcess/B04_PreProcess_Repository.py").read_text(encoding="utf-8")
assert "status = IF(status = 'CONFIRMED', 'CONFIRMED', 'COMPLETE')" in source
assert "clear_confirmed_surface_models" not in source
def _confirm(model_id: int, contour: float | None = None):
request = SurfaceConfirmRequest(model_id=model_id, smooth=False, contour_interval_m=contour)
async def run():
answer = await b04.confirm_surface(uuid4(), request, {})
await asyncio.sleep(0) # 백그라운드 체인 한 번 돌림
return answer
return asyncio.run(run())
def test_같은_서피스_다시_확정은_다시_셈_없음(b04_world) -> None:
_confirm(7, 2.0)
assert b04_world["confirm"] == [] and b04_world["chain"] == []
assert b04_world["contour"] == [2.0]
def test_다른_서피스_확정은_전처리_체인(b04_world) -> None:
_confirm(8)
assert b04_world["confirm"] == [(8, "csf")]
assert b04_world["chain"] == [8]
def test_서피스_바꿈_체인_차례(tmp_path, monkeypatch) -> None:
import B03_FileInput.B03_FileInput_Repository as repo
import B03_FileInput.B03_FileInput_Service_Chain as chain
import B05_Profile.B05_Profile_Router_RouteInitial as router
import common_util.common_util_initial_snapshot as snapshot
import common_util.common_util_project_reset as reset
import common_util.common_util_storage as storage
import common_util.common_util_workflow_state as state
import config.config_db as db
log: list = []
async def purge(_c, _pid, _root, keep_preprocess=False):
log.append(("purge", keep_preprocess))
async def stages(_cursor, _pid, first_stage=1):
log.append(("stages", first_stage))
async def auto(_pid, surface_model_id=None):
log.append(("auto", surface_model_id))
monkeypatch.setattr(db, "get_db_pool", lambda: _Pool([]))
monkeypatch.setattr(repo, "get_project_storage_relative_path", _async("x"))
monkeypatch.setattr(storage, "resolve_stored_project_path", lambda _p: str(tmp_path))
monkeypatch.setattr(snapshot, "discard_initial_snapshot", lambda _r: log.append("snapshot"))
monkeypatch.setattr(reset, "purge_project_outputs", purge)
monkeypatch.setattr(state, "reset_stages_after_input_change", stages)
monkeypatch.setattr(chain, "run_auto_design_chain", auto)
monkeypatch.setattr(router, "schedule_precompute", lambda pid: log.append("precompute"))
asyncio.run(change.run_surface_change_chain(uuid4(), 8))
assert log == ["snapshot", ("purge", True), ("stages", 2), ("auto", 8)]
async def broken(*_a, **_k):
raise RuntimeError("db")
log.clear()
monkeypatch.setattr(reset, "purge_project_outputs", broken)
asyncio.run(change.run_surface_change_chain(uuid4(), 8))
assert log == ["snapshot", "precompute"] # 초기화 실패 = 체인 없음 · 추천만 새 서피스로
@pytest.mark.parametrize("name", ["run_auto_design_chain", "run_redesign_chain"])
def test_체인_실패_길에도_미리_셈(monkeypatch, name) -> None:
import B03_FileInput.B03_FileInput_Service_Chain as chain
import B05_Profile.B05_Profile_Router_RouteInitial as router
import config.config_db as db
class _Broken:
def acquire(self):
raise RuntimeError("db down")
scheduled: list = []
monkeypatch.setattr(db, "get_db_pool", lambda: _Broken())
monkeypatch.setattr(router, "schedule_precompute", scheduled.append)
project = uuid4()
args = (project,) if name == "run_auto_design_chain" else (project, 1, {})
asyncio.run(getattr(chain, name)(*args))
assert scheduled == [project]
def test_LAS_나중_올리기_끝에_미리_셈() -> None:
source = (ROOT / "B05_Profile/B05_Profile_Router_Lidar.py").read_text(encoding="utf-8")
work = source.split("async def work(", 1)[1].split("return lidar_upload.start_job", 1)[0]
assert "schedule_precompute(project_id)" in work
#: B05~B09 에서 확정 서피스를 바꿀 수 있는 부름 — 남은 곳은 까닭 있는 둘만(요구 605-2)
_SURFACE_WRITES = re.compile(
r"\b(confirm_surface_selection|confirm_surface_model|merge_surface_confirmation_params|"
r"switch_design_terrain|stash_las_selection|run_surface_change_chain)\("
)
_ALLOWED = {
# 지형 칩(626-1) — 사용자 답 기다림(763 ①) · 추천 [적용]은 지형을 안 넘김(아래 시험)
"B05_Profile/B05_Profile_Router_Replan.py",
# 라이다 확정값만 적어 둠 · 설계 지형 그대로(637-2)
"B05_Profile/B05_Profile_Router_Lidar.py",
}
def test_B05_B09_확정은_서피스를_안_바꿈() -> None:
found = set()
for folder in (
"B05_Profile",
"B06_Section",
"B07_DesignDetail",
"B08_Quantity",
"B09_Estimation",
):
for path in (ROOT / folder).glob("*.py"):
if _SURFACE_WRITES.search(path.read_text(encoding="utf-8")):
found.add(path.relative_to(ROOT).as_posix())
assert found <= _ALLOWED, found - _ALLOWED
replan = (ROOT / "B05_Profile/B05_Profile_Router_Replan.py").read_text(encoding="utf-8")
save = replan.split("async def _save_route(", 1)[1].split("\nasync def ", 1)[0]
assert "request.terrain" not in save # 추천 [적용] = 선만 · 설계 지형 그대로(605-2)