From c87fa54f5b41845214491460d790a73ecf1ce6cb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 15:37:02 +0900 Subject: [PATCH] =?UTF-8?q?fix(B05=20=EC=B4=88=EA=B8=B0=ED=99=94):=20?= =?UTF-8?q?=EB=B9=88=ED=8B=88=20=EB=84=B7=20=E2=80=94=20=ED=8F=B4=EB=B0=B1?= =?UTF-8?q?=20=EB=8D=A7=20=EC=B8=A1=EC=A0=90=20=EC=A7=80=EC=9B=80=20=C2=B7?= =?UTF-8?q?=20=EB=B0=B0=EC=88=98=20=ED=8F=B4=EB=8D=94=20=EC=97=86=EB=8A=94?= =?UTF-8?q?=20=EC=8A=A4=EB=83=85=EC=83=B7=20=EA=B4=80=20=ED=8E=B8=EC=A7=91?= =?UTF-8?q?=20=EB=B9=84=EC=9B=80=20=C2=B7=20=EB=B3=B5=EC=9B=90=20=EA=B8=B8?= =?UTF-8?q?=20stage=202=20=C2=B7=203=20=EA=B2=80=ED=86=A0=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0=20+=20B07=20=EC=9D=B4=ED=9B=84=20STALE=20=C2=B7=20M02?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=82=AC=EB=B3=B8=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4=20(431)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- B05_Profile/B05_Profile_Router_Lifecycle.py | 21 ++- common_util/common_util_initial_snapshot.py | 13 +- resources/tester/test_431_reset_gaps.py | 143 ++++++++++++++++++++ 3 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 resources/tester/test_431_reset_gaps.py diff --git a/B05_Profile/B05_Profile_Router_Lifecycle.py b/B05_Profile/B05_Profile_Router_Lifecycle.py index 8450ff4e4..285cf5dd2 100644 --- a/B05_Profile/B05_Profile_Router_Lifecycle.py +++ b/B05_Profile/B05_Profile_Router_Lifecycle.py @@ -8,9 +8,10 @@ import asyncio import logging from pathlib import Path +from typing import Any from uuid import UUID -from fastapi import APIRouter +from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path @@ -23,8 +24,9 @@ from B05_Profile.B05_Profile_Router_Confirm import ( ) from B05_Profile.B05_Profile_Schema import RouteConfirmRequest, RouteConfirmResponse from B06_Section.B06_Section_Repository import get_longitudinal_section +from common_util.common_util_auth import get_optional_session from common_util.common_util_storage import resolve_stored_project_path -from common_util.common_util_workflow_state import complete_stage +from common_util.common_util_workflow_state import complete_stage, start_stage from config.config_db import get_db_pool logger = logging.getLogger(__name__) @@ -140,7 +142,10 @@ async def confirm_latest_route( @router.post("/{project_id}/route/reset") -async def reset_route_design(project_id: UUID) -> JSONResponse: +async def reset_route_design( + project_id: UUID, + session: dict[str, Any] | None = Depends(get_optional_session), # 사본 가져온이 +) -> JSONResponse: """B05·B06 설계를 초기값으로 되돌린다 ([초기화] 버튼). **초기값 스냅샷이 있으면 복원한다**(2026-08-29 사용자 확정, CLAUDE.md 5장). 자동설계 @@ -224,6 +229,10 @@ async def reset_route_design(project_id: UUID) -> JSONResponse: restored_route_id = await restore_initial_snapshot( connection, project_root, str(project_id) ) + # 재계산 길(체인)과 같은 단계 — stage 2 · 3 검토 대기 · B07 이후 STALE + async with connection.cursor() as cursor: + await start_stage(cursor, str(project_id), 2) + await start_stage(cursor, str(project_id), 3) await connection.commit() except Exception: await connection.rollback() @@ -258,6 +267,12 @@ async def reset_route_design(project_id: UUID) -> JSONResponse: }, ) + # M02 구조물 사본을 되돌린 구조물 · 관 지정에 맞춤(실패해도 초기화 유지) + if project_root: + from M02_MasterTemplete.M02_Item_Snapshot import importer, safe_sync + + await asyncio.to_thread(safe_sync, project_root, importer(session)) + # 옛 경로의 코리도 파일은 주인이 사라졌다 — 함께 지운다(2026-08-28 백로그). try: if project_root: diff --git a/common_util/common_util_initial_snapshot.py b/common_util/common_util_initial_snapshot.py index 256919d8d..d970396df 100644 --- a/common_util/common_util_initial_snapshot.py +++ b/common_util/common_util_initial_snapshot.py @@ -243,7 +243,11 @@ def wipe_edited_masters(project_root: Path) -> list[str]: """ removed: list[str] = [] root = Path(project_root) - for rel in ("B05_Profile/route/structures.json", "B04_PreProcess/drainage/edits"): + for rel in ( + "B05_Profile/route/structures.json", + "B05_Profile/route/plus_stations.json", + "B04_PreProcess/drainage/edits", + ): path = root / rel try: if path.is_dir(): @@ -269,7 +273,12 @@ def restore_snapshot_files(project_root: Path, route_id: int | None = None) -> N for tree in _FILE_TREES: stored = source / tree.replace("/", "__") if not stored.is_dir() and tree == "B04_PreProcess/drainage": - _copy_tree(source / _LEGACY_DRAINAGE_TREE, root / "B04_PreProcess/drainage/edits") + legacy = source / _LEGACY_DRAINAGE_TREE + edits = root / "B04_PreProcess/drainage/edits" + if legacy.is_dir(): + _copy_tree(legacy, edits) + else: # 배수 폴더 없는 스냅샷 — 관 편집은 비움(초기값에 없던 것) + shutil.rmtree(edits, ignore_errors=True) continue _copy_tree(stored, root / tree) diff --git a/resources/tester/test_431_reset_gaps.py b/resources/tester/test_431_reset_gaps.py new file mode 100644 index 000000000..1c979f7bd --- /dev/null +++ b/resources/tester/test_431_reset_gaps.py @@ -0,0 +1,143 @@ +"""431 [초기화] 빈틈(분석 424 3장). + +- 스냅샷 없는 폴백 = 덧 측점(`plus_stations.json`)도 지움 +- 배수 폴더 없는 스냅샷 = 관 편집 비움 +- 복원 길 = stage 2 · 3 검토 대기 · B07 · B08 STALE · M02 구조물 사본 맞춤(진짜 `safe_sync`) +""" + +import asyncio +import json +from contextlib import asynccontextmanager +from pathlib import Path +from uuid import uuid4 + +from common_util.common_util_initial_snapshot import ( + restore_snapshot_files, + snapshot_dir, + wipe_edited_masters, +) + +EDITS = "B04_PreProcess/drainage/edits" + + +def _touch(path: Path, text: str = "{}") -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def test_폴백은_덧_측점도_지움(tmp_path: Path): + _touch(tmp_path / "B05_Profile/route/plus_stations.json") + _touch(tmp_path / "B05_Profile/route/structures.json") + _touch(tmp_path / EDITS / "pipe_points.json") + removed = wipe_edited_masters(tmp_path) + assert "B05_Profile/route/plus_stations.json" in removed + assert not (tmp_path / "B05_Profile/route/plus_stations.json").exists() + assert not (tmp_path / EDITS).exists() + + +def _snapshot_without_drainage(root: Path) -> None: + _touch(snapshot_dir(root) / "B05_Profile__route" / "planned_route.csv", "x,y\n") + _touch(snapshot_dir(root) / "db.json", json.dumps({"routes": [{"id": 1}]})) + + +def test_배수_폴더_없는_스냅샷은_관_편집_비움(tmp_path: Path): + _snapshot_without_drainage(tmp_path) + _touch(tmp_path / EDITS / "pipe_points.json", '[{"chainage_m": 3}]') + _touch(tmp_path / "B04_PreProcess/drainage/basins.json") # 분석 산출물은 그대로 + restore_snapshot_files(tmp_path) + assert not (tmp_path / EDITS).exists() + assert (tmp_path / "B04_PreProcess/drainage/basins.json").is_file() + + +def test_옛_스냅샷은_edits_만_되돌림(tmp_path: Path): + _snapshot_without_drainage(tmp_path) + _touch(snapshot_dir(tmp_path) / "B04_PreProcess__drainage__edits/pipe_points.json", "[]") + _touch(tmp_path / EDITS / "pipe_points.json", '[{"chainage_m": 3}]') + restore_snapshot_files(tmp_path) + assert (tmp_path / EDITS / "pipe_points.json").read_text(encoding="utf-8") == "[]" + + +class _Cursor: + def __init__(self, log: list[tuple[str, tuple]]): + self._log = log + self.rowcount = 1 + + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + async def execute(self, sql, params=None): + self._log.append((" ".join(sql.split()), params)) + + +class _Connection: + def __init__(self, log): + self._log = log + + def cursor(self, *_a, **_k): + return _Cursor(self._log) + + async def begin(self): + pass + + 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 run(*_a, **_k): + return value + + return run + + +def test_복원_길은_단계_되돌리고_사본_맞춤(tmp_path: Path, monkeypatch): + from B05_Profile import B05_Profile_Router_Corridor as corridor + from B05_Profile import B05_Profile_Router_Lifecycle as lifecycle + from common_util import common_util_initial_snapshot as snapshot + + _snapshot_without_drainage(tmp_path) + _touch(tmp_path / EDITS / "pipe_points.json", "[]") + # 사용자가 넣은 구조물의 사본 — 초기값엔 그 구조물 없음 + _touch(tmp_path / "templates/structures/s9/manifest.json", '{"key": "SS9"}') + + log: list[tuple[str, tuple]] = [] + monkeypatch.setattr(lifecycle, "get_db_pool", lambda: _Pool(log)) + monkeypatch.setattr(lifecycle, "get_project_storage_relative_path", _async("p")) + monkeypatch.setattr(lifecycle, "resolve_stored_project_path", lambda _p: str(tmp_path)) + monkeypatch.setattr(lifecycle, "get_latest_route", _async({"id": 9})) + monkeypatch.setattr(snapshot, "restore_initial_snapshot", _async(9)) + monkeypatch.setattr(corridor, "prune_corridor_files", lambda *_a: 0) + + pid = uuid4() + response = asyncio.run(lifecycle.reset_route_design(pid, None)) + body = json.loads(response.body) + assert body["restored"] is True and body["route_id"] == 9 + + started = [ + p[3] + for sql, p in log + if sql.startswith("UPDATE project_workflow_stages SET state = 'IN_PROGRESS'") + ] + assert started == [2, 3] + stale = [p[1] for sql, p in log if "SET state = 'STALE'" in sql] + assert stale == [2, 3] # stage > 3 (B07 · B08 · B09) 완료분 STALE + assert log.index(("COMMIT", ())) > max( + i for i, (sql, _) in enumerate(log) if "project_workflow_stages" in sql + ) + assert not (tmp_path / EDITS).exists() + assert not (tmp_path / "templates/structures/s9").exists()