diff --git a/B05_Profile/B05_Profile_Engine_Grade_Carry.py b/B05_Profile/B05_Profile_Engine_Grade_Carry.py index 78bcdad6a..56c5d8784 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Carry.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Carry.py @@ -5,8 +5,7 @@ ▲▼)은 이어받음. - `changed_span` = 옛 · 새 노선 정점열의 같은 앞머리 · 같은 꼬리를 뺀 가운데 — - 「노선 그대로?」 · 범위 · `route_shape_changed` = 노선 모양 바뀜 판정(492 · TS - `routeShapeChanged` 짝). + 「노선 그대로?」 · 범위. - 같은 자리(`line_matcher`) = 새 선 점을 옛 선에 투영 · 옆 `SAME_LINE_TOL_M` 안 · 방향 30° 안 (측점 스냅이 뒤 교점을 밀어도 이어받음). - 자동 꼭짓점(`carry_base`) · 편집(`carry_edits`)은 **새 측점 위에** 다시 놓음 — 같은 자리면 @@ -81,17 +80,6 @@ def changed_span( } -def route_shape_changed( - old_line: list[tuple[float, float]], new_line: list[tuple[float, float]] -) -> bool: - """노선 모양이 바뀌었나(492 · 요구 284 · 285) — 저장 · 새 **계획 폴리라인** 정점 1 mm 견줌. - - 교점 · R · 곡선 켬끔은 선에 담김 · 측점 스냅 · 간격은 선을 안 바꿈(= 초기화 아님). - 짝 = TS `logic/B05_Profile_Grade_Carry.ts` `routeShapeChanged`(sub1 · 거울 시험 = 그 시험). - """ - return changed_span(old_line, new_line) is not None - - def _segments(xy: list[tuple[float, float]]) -> np.ndarray: """토막 표 [x, y, ux, uy, 길이, 시작 누가거리] — 길이 0 토막은 뺌(누가거리는 셈).""" rows: list[tuple[float, ...]] = [] diff --git a/B05_Profile/B05_Profile_Route_Save.py b/B05_Profile/B05_Profile_Route_Save.py index 264816a93..75d225105 100644 --- a/B05_Profile/B05_Profile_Route_Save.py +++ b/B05_Profile/B05_Profile_Route_Save.py @@ -11,17 +11,18 @@ API — `POST /api/projects/{id}/route/replan`(`B05_Profile_Router_Replan.replan `as_initial` — 추천 노선 [적용](초기값 다시 뜸) · 손으로 고친 노선 저장은 false(초기값 그대로). 응답 200 `{status: "success", project_id, route_id, total_length_m, vertex_count, current_initial, initial_refreshed, pipes: {kept, crossing, outside}, structures: {moved, far, outside}, - follow_failed, cross_dropped, route_reset: {reset, dropped: {structures, pipes, plus_stations}}, - drainage_stale: true, seconds}`. + follow_failed, cross_dropped, drainage_stale: true, seconds}` · 추천 [적용](`as_initial`)만 + `route_reset: {reset: true, dropped: {structures, pipes, plus_stations}}` 더함. 오류 400(노드 둘 미만 · 초안 칸 길이 어긋남) · 404(프로젝트 없음) · 409 `{status: "error", message}`(체인 실패 — 노선 · 관 · 구조물 · 종횡단 파일 · 새 노선 행을 옛것으로 되살린 뒤). 규칙: - - 노선 초기화(`initial` = 추천 [적용] · 모양 바뀐 노선 [저장] · 492 · 요구 284 · 285) = 사용자 - 구조물 · 추가 측점 · 관(옵션 포함) 버림 · 관 = 새 노선 세류선 교차 기본 관만 · 종단 1차 계획선 · - 횡단 기본 설계 · 초기값 다시 뜸(부르는 쪽). - - 그 밖(스냅 · 간격만) = 관 정본을 지우지 않음 — 좌표를 따라감 · 새 세류선 교차점 기본 관만 더함 + - 노선 초기화(`initial` = 추천 [적용] · 요구 289) = 사용자 구조물 · 추가 측점 · 관(옵션 포함) + 버림 · 관 = 새 노선 세류선 교차 기본 관만 · 종단 1차 계획선 · 횡단 기본 설계 · 초기값 다시 + 뜸(부르는 쪽). + - 손 고침 [저장](교점 · R · 스냅 · 간격 · 요구 289 · 290) = 보이는 화면 그대로 — 관 정본을 + 지우지 않음 · 좌표를 따라감 · 새 세류선 교차점 기본 관만 더함 (`B05_Profile_Route_Save_Parts`) · 배수유역 저장분은 노선 지문이 달라 「낡음」(B04 `_resolve`). - 계획고 = 바뀐 자리만 새 지반 첫 계획선 · 같은 자리(옆 1 m 안) 사용자 계획고 지킴 (`B05_Profile_Engine_Grade_Carry` · 옛 · 새 **계획 폴리라인** — 화면 planline 과 같은 선) · @@ -421,7 +422,7 @@ async def run_route_save_chain( `planned_lines` = (옛 · 새 계획 폴리라인) — 바뀐 구간을 화면 planline 단계와 같은 선으로 가름 (없으면 풀린 노선 geojson · 점 간격이 달라 앞머리가 일찍 갈림). - `initial` = 노선 초기화(추천 [적용] · 모양 바뀐 [저장] · 492) — 사용자 시설 버림 · 옛 계획고 · + `initial` = 노선 초기화(추천 [적용]) — 사용자 시설 버림 · 옛 계획고 · 편집 · L 안 옮김 · 새 규칙 초기 종단(476). """ import aiomysql @@ -578,15 +579,21 @@ async def run_route_save_chain( "structures": structures, "follow_failed": follow_failed, "cross_dropped": dropped, - # 노선 초기화(492 · sub1 약속) — 버린 사용자 시설 수 - "route_reset": { - "reset": initial, - "dropped": { - "structures": len(old_structures) if initial else 0, - "pipes": len(old_pipes) if initial else 0, - "plus_stations": len(old_plus) if initial else 0, - }, - }, "drainage_stale": True, "seconds": round(marks[-1][1] - marks[0][1], 2), + # 노선 초기화(추천 [적용]만 · sub1 확인 창 · 알림) — 버린 사용자 시설 수 + **( + { + "route_reset": { + "reset": True, + "dropped": { + "structures": len(old_structures), + "pipes": len(old_pipes), + "plus_stations": len(old_plus), + }, + } + } + if initial + else {} + ), } diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 32a46ca15..c33ce63a3 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -13,8 +13,8 @@ 노선 저장(346-3) — 여기서 노선을 갈아 끼운 뒤 `B05_Profile_Route_Save.run_route_save_chain` 이 풀기 → 관 · 구조물 좌표 따라감 → 종횡단 한 번 → 계획고 옮김 → 횡단 이월(측점 열쇠) → B06 확정까지 -돈다. 배수유역은 다시 안 셈(낡음) · 노선 모양이 바뀌었거나 추천 [적용](`as_initial`)이면 초기화 -(사용자 시설 · 손 입력 버림 · 초기값 다시 뜸 · 492) · 스냅 · 간격만 바꾼 저장은 시설 따라감. +돈다. 배수유역은 다시 안 셈(낡음) · 추천 [적용](`as_initial`)이면 초기화(사용자 시설 · 손 입력 +버림 · 초기값 다시 뜸) · 손 고침 [저장]은 보이는 화면 그대로 시설 따라감(요구 289 · 290). 요청 · 응답 · 오류 모양 = 그 모듈 머리. GET /api/projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계) @@ -35,7 +35,6 @@ from fastapi import APIRouter from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path -from B05_Profile.B05_Profile_Engine_Grade_Carry import route_shape_changed from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice, write_initial_choice from B05_Profile.B05_Profile_Engine_RouteInitial_Station import snapped_initial from B05_Profile.B05_Profile_Engine_Sections import load_station_snap, load_station_snap_step @@ -333,7 +332,7 @@ async def _recompute( marks = [("시작", time.perf_counter())] - # 관 정본 = 체인이 씀 — 초기화면 새 교차 관만 · 아니면 좌표로 새 노선을 따라감(346-3 · 492). + # 관 정본 = 체인이 씀 — 추천 [적용]이면 새 교차 관만 · 아니면 좌표로 새 노선을 따라감(346-3). pool = get_db_pool() async with pool.acquire() as connection: await connection.begin() # 고른 지형 = 설계 지형 — 뒤 단계가 모두 이 확정값을 읽음 @@ -527,8 +526,7 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: async def replan_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, Any] | JSONResponse: """노선 초안을 정본으로 저장한다(346-3 · 요청 · 응답 모양 = `B05_Profile_Route_Save` 머리). - 배수유역은 다시 안 셈(낡음 표시) · 초기값은 노선 초기화(추천 [적용] · 모양 바뀜 · 492) 때 - 다시 뜬다. + 배수유역은 다시 안 셈(낡음 표시) · 초기값은 추천 [적용](`as_initial`)일 때만 다시 뜬다. 추천 [적용] = 즉시 저장(요구 275 ②) · [저장]과 같은 프로젝트 잠금 · 떠 두고 실패면 되돌림(476 · 472-6 `common_util_save_guard`) · 응답 `waited` · ⚠ `/design/save` 잠금 안에서 as_initial 금지. """ @@ -625,11 +623,8 @@ async def _save_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, Any summary["violations"], radius_m, ) - new_line = await asyncio.to_thread(_vertices_of, working_path) - # 노선 모양이 바뀜(교점 · R · 곡선 켬끔) · 추천 [적용] = 초기화(492 · 요구 284 · 285) — 사용자 - # 시설 · 손 입력 버림 · 자동 다시 셈 · 초기값 다시 뜸 · 스냅 · 간격만 = 측점만 다시(따라감). - reset = as_initial or route_shape_changed(old_line, new_line) - # 균형 종단 새로 풂 = 추천 [적용](`as_initial`)만 — 손 노선 초기화 = 1차 계획선(476 길). + # 추천 [적용](`as_initial`) = 초기화(사용자 시설 · 손 입력 버림 · 균형 종단 새로 풂) — 손으로 + # 고친 노선은 보이는 화면 그대로 시설 따라감 · 계획고 옮김(344-2 · 요구 289 · 290). result = await _recompute( project_id, project_root, @@ -640,8 +635,8 @@ async def _save_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, Any else None ), request.terrain, - (old_line, new_line), - reset, + (old_line, await asyncio.to_thread(_vertices_of, working_path)), + as_initial, ) if "error" in result: # 노선을 되돌린다 — 실패했는데 새 노선만 남으면 화면·정본이 어긋난 채 굳는다. @@ -671,9 +666,9 @@ async def _save_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, Any if choice else await asyncio.to_thread(read_initial_choice, project_root) ) - # 초기값 = 자동설계 체인 직후 + 노선 초기화(추천 [적용] · 모양 바뀐 [저장] · 492) 때만 — - # 스냅 · 간격만 바꾼 저장은 [초기화]가 돌아갈 자리를 안 바꿈. - if reset: + # 초기값 = 자동설계 체인 직후 + 추천 노선 [적용] 때만(346-3 · 사용자 확정 10-16) — 손으로 고친 + # 노선 저장은 [초기화]가 돌아갈 자리를 안 바꿈. + if as_initial: from B05_Profile.B05_Profile_Router_RouteInitial import refresh_initial_snapshot await refresh_initial_snapshot(project_id, project_root, result.get("route_id")) @@ -682,7 +677,7 @@ async def _save_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, Any "project_id": str(project_id), **result, "current_initial": current, - "initial_refreshed": reset, + "initial_refreshed": as_initial, } diff --git a/B06_Section/B06_Section_Router_DesignSave.py b/B06_Section/B06_Section_Router_DesignSave.py index 031466b3f..e29b95a64 100644 --- a/B06_Section/B06_Section_Router_DesignSave.py +++ b/B06_Section/B06_Section_Router_DesignSave.py @@ -8,10 +8,7 @@ (오류 응답 · 측점 맞춤 실패 · 재계산 실패 · 하위 차례 실패) DB 행 · storage 를 시작 전으로 되돌리고 그 차례 이름 · 까닭을 돌려줌(`common_util_save_guard` · 브레인 A). 되돌림마저 실패하면 「복구 확인 필요」. 같은 프로젝트 저장이 겹치면 뒤 요청이 기다림(`waited`). -노선 모양 바뀐 저장(492 · 요구 284 · 285) = 맨 앞 노선 저장이 초기화(초기값 다시 뜸) · 뒤 차례는 -페이지 설정만(보낸 시설 · 계획고 · 넘김 칸 안 씀) · 응답 `steps.route.route_reset`. -스냅 · 간격만 바뀐 노선 저장(`route` · `carry` 칸 · 6-B) = 맨 앞 노선 저장 → 관 · 구조물 초안 -다시 얹기 → +노선 바뀐 저장(`route` · `carry` 칸 · 6-B) = 맨 앞 노선 저장 → 관 · 구조물 초안 다시 얹기 → 위 차례(새 노선) · 횡단 = 옛 노선 세션 칸을 새 측점에 넘긴 patch · 상단측 안 보냄(요구 274) · 늘 [저장](새 노선을 본 뒤 [확정]) · 되돌림 범위에 배수 폴더 · 지표면 확정(설계 지형 바꿈)까지 (`B06_Section_Router_DesignSave_Route`). @@ -135,12 +132,6 @@ async def _run_steps( before = await moving.saved_facilities(project_id) routed = await step("route", replan_route(project_id, request.route)) route_id = int(routed["route_id"]) - if (routed.get("route_reset") or {}).get("reset"): - # 노선 초기화(492) — 보낸 관 · 구조물 · 추가 측점 · 계획고 · 관 옵션 · 넘김 칸 안 씀 - # (정본 = 서버 초기화 · 추천 [적용]과 한 벌) · 페이지 설정만 - if request.settings is not None: - await step("settings", save_page_settings(project_id, request.settings)) - return out # 관 · 구조물 초안 = 서버가 따라 옮긴 정본 위에(Node · 옛 브라우저 `rebasePipeDraft` 짝) try: await moving.rebase_drafts(project_id, request, before) @@ -222,9 +213,9 @@ async def save_design( root = Path(resolve_stored_project_path(stored_path)) pipes = request.pipes is not None or bool(request.culvert_options) or request.confirm pipes = pipes or moved # 노선 저장 = 관 따라감 · 새 교차 관(배수 폴더) - scope = guard.STORAGE_SCOPE + (guard.PIPE_SCOPE if pipes else ()) - # 노선 저장 = 초기화면 초기값 사본도 다시 뜸(492) - files = guard.snapshot_storage(root, scope + (guard.INITIAL_SCOPE if moved else ())) + files = guard.snapshot_storage( + root, guard.STORAGE_SCOPE + (guard.PIPE_SCOPE if pipes else ()) + ) try: steps = await _run_steps(project_id, request, session) except StepFailed as failed: diff --git a/common_util/common_util_save_guard.py b/common_util/common_util_save_guard.py index dcd7c6d8f..a86fe2fe2 100644 --- a/common_util/common_util_save_guard.py +++ b/common_util/common_util_save_guard.py @@ -4,8 +4,8 @@ - DB 행 — `projects` 한 줄 · `project_workflow_stages` · `routes` 와 자식 넷(`route_points` · `route_statistics` · `longitudinal_sections` · `cross_sections`) - 노선 바뀐 저장(6-B)이면 지표면 모델 확정 상태(`surface_models.status` · 설계 지형 바꿈) - - storage — 저장 차례가 쓰는 폴더(`STORAGE_SCOPE` · 관 차례 · 노선 저장이면 배수 폴더 · 초기값 - 사본도) + - storage — 저장 차례가 쓰는 폴더(`STORAGE_SCOPE` · 관 차례 · 노선 저장이면 배수 폴더 · 추천 + [적용]이면 초기값 사본도) · 뒤에서 도는 미리 셈이 뜨는 사이 지운 파일은 건너뜀(`_derived`) 를 떠 두고, 하나라도 실패하면 둘 다 되돌린다. 되돌림 쓰기는 바뀐 것만(시놀로지 동기 — 안 바뀐 파일 · 표는 안 씀). 같은 프로젝트 저장이 겹치면 뒤 요청이 기다림(`project_lock`). """ @@ -35,7 +35,7 @@ STORAGE_SCOPE = ( ) #: 관 · 관 옵션 차례 · [확정](배수유역 다시 셈)이 쓰는 자리. PIPE_SCOPE = ("B04_PreProcess/drainage",) -#: 노선 초기화(추천 [적용] · 모양 바뀐 노선 [저장] · 492)가 다시 뜨는 초기값 사본. +#: 노선 초기화(추천 [적용])가 다시 뜨는 초기값 사본. INITIAL_SCOPE = ("initial_snapshot",) #: 노선 아래 표(노선 행이 지워지면 함께 지워짐 · CASCADE). ROUTE_CHILDREN = ("route_points", "route_statistics", "longitudinal_sections", "cross_sections") @@ -52,8 +52,12 @@ def project_lock(project_id: str) -> asyncio.Lock: # ── storage ──────────────────────────────────────────────────────────────── -def _stamp(path: Path) -> tuple[int, int]: - stat = path.stat() +def _stamp(path: Path) -> tuple[int, int] | None: + """(크기, 바뀐 때) · 그 사이 지워졌으면 None.""" + try: + stat = path.stat() + except FileNotFoundError: + return None return stat.st_size, stat.st_mtime_ns @@ -85,7 +89,7 @@ class StorageSnapshot: now = _files(self.root, self.scope) for relative, path in now.items(): if relative not in self.stamps: - path.unlink() + path.unlink(missing_ok=True) written += 1 elif _stamp(path) != self.stamps[relative]: shutil.copy2(self.copy_dir / relative, path) @@ -113,8 +117,13 @@ def snapshot_storage(root: Path, scope: tuple[str, ...]) -> StorageSnapshot: for relative, path in _files(snap.root, scope).items(): target = copy_dir / relative target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(path, target) - snap.stamps[relative] = _stamp(path) + try: # 뒤 미리 셈(추천 노선 · `_derived` 원자 쓰기 · 정리)이 그 사이 지움 = 없던 파일 + shutil.copy2(path, target) + except FileNotFoundError: + continue + stamp = _stamp(path) + if stamp is not None: + snap.stamps[relative] = stamp return snap diff --git a/resources/tester/test_283_route_change_reset.py b/resources/tester/test_283_route_change_reset.py index 07d2dbfe6..4d61925b6 100644 --- a/resources/tester/test_283_route_change_reset.py +++ b/resources/tester/test_283_route_change_reset.py @@ -1,14 +1,18 @@ # -*- coding: utf-8 -*- -"""492-1 · 요구 283 · 284 · 285 — 노선 모양이 바뀐 [저장] · 추천 [적용] = 초기화 + 초기값 다시 뜸. +"""493-1 · 요구 289 · 290 — 손 고침 [저장] = 보이는 화면 그대로 · 추천 [적용] = 초기화 + 초기값 +다시 뜸. - ① 교점 끌기 [저장] — 판정(저장 · 새 계획 폴리라인) = 초기화 · 초기값 다시 뜸 · 체인 = 구조물 0 · - 추가 측점 0 · 관 = 새 교차만(stream · 옵션 없음 · 옆 0 m) · 계획고 옮김 없음 · 횡단 손 칸 0 - ② 추천 [적용] — 같은 초기화(균형 종단 갈래도) - ③ 스냅 · 측점 간격만(선 그대로) — 초기화 아님 · 시설 따라감 · 초기값 그대로 - ④ 검증_sub8 자료(노선 522 → 628 · 관 p35100) — 관 = 새 교차 388 하나(옛 = 388 + 394 · 34.44 m 옆) - ⑤ 한 본문 노선 초기화 — 보낸 관 · 구조물 · 추가 측점 · 계획고 · 관 옵션 · 넘김 칸 안 씀 - ⑥ 한 본문 실패 — 초기값 사본(`initial_snapshot`)까지 되돌림 -옛 코드(492 전) = ① ② ④ ⑤ ⑥ 실패. + ① 교점 끌기 [저장](손 고침) — 초기화 아님 · 초기값 그대로 · 체인 = 구조물 · 추가 측점 · 사용자 관 + (옵션) 그대로 따라감 · 새 세류선 교차 관만 더함 · `route_reset` 칸 없음 + ② 추천 [적용] — 초기화(균형 종단 갈래도) · 사용자 구조물 · 추가 측점 · 관 버림 · `route_reset` + ③ 스냅 · 측점 간격만 — 초기화 아님 · 초기값 그대로 + ④ 검증_sub8 자료(노선 522 → 628 · 관 p35100) — 손 고침 = 관 p35100(옵션) 지킴 + 교차 388 더함 · + 추천 [적용] = 교차 388 하나 + ⑤ 한 본문 손 고침 — 노선 차례 뒤 보낸 시설 · 넘김 차례 그대로 씀 + ⑥ 추천 [적용] 실패 — 초기값 사본(`initial_snapshot`)까지 되돌림 + ⑦ 떠 두기 중 뒤 미리 셈이 파일을 지움(`_derived` 원자 쓰기 · 정리) — 건너뜀(옛 = 500 + FileNotFoundError) +492 코드 = ① ④(손 고침) ⑤ 실패 · 옛 떠 두기 = ⑦ 실패. 체인 · API 는 346-3 가짜 세계(`chain_world` · `api_world`)를 빌려 씀 — 파일은 임시 폴더만. @@ -139,20 +143,7 @@ def _assert_reset(world, result: dict, cross_seen: list) -> None: assert cross_seen == [[]] # 횡단 손 입력 안 넘김 -# ── ① 교점 끌기 [저장] ───────────────────────────────────────────────────────── - - -def test_판정_선이_바뀌면_참_같으면_거짓(): - from B05_Profile.B05_Profile_Engine_Grade_Carry import route_shape_changed - - line = [(0.0, 0.0), (100.0, 0.0), (200.0, 0.0)] - assert route_shape_changed(line, list(line)) is False - assert ( - route_shape_changed(line, [(0.0, 0.0), (100.0, 0.0005), (200.0, 0.0)]) is False - ) # 1 mm 안 - assert route_shape_changed(line, [(0.0, 0.0), (100.0, 20.0), (200.0, 0.0)]) is True - assert route_shape_changed(line, [(0.0, 0.0), (200.0, 0.0)]) is True # 교점 지움 - assert route_shape_changed(line, [*line, (300.0, 0.0)]) is True # 교점 더함 +# ── ① 교점 끌기 [저장](손 고침) ───────────────────────────────────────────── def _replan_world(monkeypatch, old_line, new_line) -> list: @@ -170,24 +161,32 @@ def _replan_world(monkeypatch, old_line, new_line) -> list: return calls -def test_교점_끌기_저장은_초기화_초기값_다시(api_world, monkeypatch): - calls = _replan_world(monkeypatch, [[0.0, 0.0], [4.0, 4.0]], [[0.0, 0.0], [5.0, 5.0]]) - result = asyncio.run(replan.replan_route(uuid4(), base._body())) - assert calls == [("recompute", True, False)] # 균형 종단은 추천 [적용]만 - assert result["initial_refreshed"] is True and len(api_world) == 1 +def test_교점_끌기_저장은_초기화_아님_초기값_그대로(api_world, monkeypatch): + calls = _replan_world(monkeypatch, [[0.0, 0.0], [4.0, 4.0]], [[0.0, 0.0], [25.0, 5.0]]) + result = asyncio.run(replan.replan_route(uuid4(), base._body(balanced_profile=True))) + assert calls == [("recompute", False, False)] # 균형 종단 · 초기화는 추천 [적용]만 + assert result["initial_refreshed"] is False and api_world == [] -def test_교점_끌기_체인은_사용자_시설_버림(chain_world, crossings, cross_seen): +def test_교점_끌기_체인은_시설_따라감(chain_world, crossings, cross_seen): _seed(chain_world) - result = base._run(chain_world, initial=True) - _assert_reset(chain_world, result, cross_seen) + result = base._run(chain_world) + assert "error" not in result, result + assert "route_reset" not in result + assert len(load_structures(str(chain_world.root))[1]) == 1 + assert len(load_plus_stations(chain_world.root)) == 1 + points = json.loads(chain_world.pipes_file.read_text("utf-8"))["points"] + assert len(points) == 2, points # 사용자 관 그대로 + 새 교차 관 + assert any(p.get("options") == {"pipe_kind": "흄관"} for p in points) assert result["pipes"]["crossing"] == 1 + assert "profile" in chain_world.order # 계획고 옮김 + assert cross_seen != [[]] # 횡단 손 입력 넘김 # ── ② 추천 [적용] ───────────────────────────────────────────────────────────── -def test_추천_적용도_같은_초기화(api_world, chain_world, crossings, cross_seen, monkeypatch): +def test_추천_적용은_초기화(api_world, chain_world, crossings, cross_seen, monkeypatch): import B05_Profile.B05_Profile_Router_RouteInitial as route_initial same = [[0.0, 0.0], [5.0, 5.0]] @@ -202,6 +201,13 @@ def test_추천_적용도_같은_초기화(api_world, chain_world, crossings, cr _assert_reset(chain_world, chained, cross_seen) +def test_추천_적용_체인은_사용자_시설_버림(chain_world, crossings, cross_seen): + _seed(chain_world) + result = base._run(chain_world, initial=True) + _assert_reset(chain_world, result, cross_seen) + assert result["pipes"]["crossing"] == 1 + + # ── ③ 스냅 · 간격만 ─────────────────────────────────────────────────────────── @@ -214,17 +220,6 @@ def test_스냅만_바꾼_저장은_초기화_아님(api_world, monkeypatch): assert result["initial_refreshed"] is False and api_world == [] -def test_스냅만_체인은_시설_따라감(chain_world, crossings, cross_seen): - _seed(chain_world) - result = base._run(chain_world) - assert result["route_reset"]["reset"] is False - assert result["route_reset"]["dropped"] == {"structures": 0, "pipes": 0, "plus_stations": 0} - assert len(load_structures(str(chain_world.root))[1]) == 1 - assert load_plus_stations(chain_world.root) - points = json.loads(chain_world.pipes_file.read_text("utf-8"))["points"] - assert any(p.get("options") == {"pipe_kind": "흄관"} for p in points) # 사용자 관 그대로 - - # ── ④ 검증_sub8 자료 ───────────────────────────────────────────────────────── @@ -238,23 +233,38 @@ def sub8(monkeypatch): return data -def test_검증_sub8_노선_522_628_관은_교차_하나(sub8, chain_world, cross_seen): - chain_world.pipes_file.write_text( - json.dumps({"route_signature": "old", "points": [sub8["old_pipe"]]}), "utf-8" +def _sub8_pipes(world, data) -> None: + world.pipes_file.write_text( + json.dumps({"route_signature": "old", "points": [data["old_pipe"]]}), "utf-8" ) + + +def test_검증_sub8_손_고침은_관_지킴_교차_더함(sub8, chain_world, cross_seen): + _sub8_pipes(chain_world, sub8) + result = base._run(chain_world) + assert "error" not in result, result + points = json.loads(chain_world.pipes_file.read_text("utf-8"))["points"] + assert len(points) == 2, points + kept = [p for p in points if p.get("id") == "p35100"] + assert kept and kept[0]["options"] == sub8["old_pipe"]["options"] # 화면 그대로 + assert any(round(p["chainage_m"]) == 388 for p in points) # 새 교차 + + +def test_검증_sub8_추천_적용은_교차_하나(sub8, chain_world, cross_seen): + _sub8_pipes(chain_world, sub8) result = base._run(chain_world, initial=True) assert "error" not in result, result points = json.loads(chain_world.pipes_file.read_text("utf-8"))["points"] - assert [round(p["chainage_m"]) for p in points] == [388] # 옛 = [388, 394] - assert _side(sub8["new_xy"], points[0]["x"], points[0]["y"]) < 0.01 # 옛 394 = 34.44 m + assert [round(p["chainage_m"]) for p in points] == [388] + assert _side(sub8["new_xy"], points[0]["x"], points[0]["y"]) < 0.01 assert not points[0].get("options") assert result["route_reset"]["dropped"]["pipes"] == 1 -# ── ⑤ · ⑥ 한 본문 ────────────────────────────────────────────────────────────── +# ── ⑤ 한 본문 손 고침 ───────────────────────────────────────────────────────── -def test_한_본문_초기화는_보낸_칸을_안_씀(monkeypatch): +def test_한_본문_손_고침은_보낸_칸을_씀(monkeypatch): import B04_PreProcess.B04_PreProcess_Router_Basins as basins import B05_Profile.B05_Profile_Router as b05_router import B05_Profile.B05_Profile_Router_Lifecycle as lifecycle @@ -273,14 +283,7 @@ def test_한_본문_초기화는_보낸_칸을_안_씀(monkeypatch): return fake - routed = { - "route_id": 9, - "route_reset": { - "reset": True, - "dropped": {"structures": 1, "pipes": 2, "plus_stations": 1}, - }, - } - monkeypatch.setattr(replan, "replan_route", record("route", routed)) + monkeypatch.setattr(replan, "replan_route", record("route", {"route_id": 9})) monkeypatch.setattr(moving, "saved_facilities", record("before")) monkeypatch.setattr(moving, "rebase_drafts", record("rebase")) monkeypatch.setattr(moving, "carry_cross", record("carry", {"patches": [], "outside": 0})) @@ -299,39 +302,79 @@ def test_한_본문_초기화는_보낸_칸을_안_씀(monkeypatch): structures=SimpleNamespace(), plus_stations=SimpleNamespace(), alignment=SimpleNamespace(model_copy=lambda update: SimpleNamespace(**update)), - culvert_options={"50.00": {"pipe_kind": "흄관"}}, + culvert_options=None, uphill=None, cross=None, route=SimpleNamespace(), carry=SimpleNamespace(), ) out = asyncio.run(design_save._run_steps(uuid4(), request, None)) - assert called == ["before", "route", "settings"], called - assert out["route"]["route_reset"]["reset"] is True + assert called == [ + "before", + "route", + "rebase", + "settings", + "pipes", + "structures", + "plus", + "alignment", + "carry", + "cross", + ], called + assert "route_reset" not in out["route"] -def test_한_본문_실패면_초기값_사본까지_되돌림(tmp_path, monkeypatch): +# ── ⑥ 추천 [적용] 실패 ───────────────────────────────────────────────────────── + + +def test_추천_적용_실패면_초기값_사본까지_되돌림(tmp_path, monkeypatch): import B06_Section.B06_Section_Router_DesignSave as design_save import common_util.common_util_save_guard as guard - from B06_Section.B06_Section_Router_DesignSave_Route import StepFailed + from fastapi.responses import JSONResponse copy = tmp_path / "initial_snapshot" / "B04_PreProcess__drainage" / "edits" copy.mkdir(parents=True) (copy / "pipe_points.json").write_text("old", encoding="utf-8") - async def steps(project_id, request, session): + async def fail(project_id, body): (copy / "pipe_points.json").write_text("new", encoding="utf-8") # 초기값 다시 뜸 - raise StepFailed("route", "뒤 차례 실패") + return JSONResponse(status_code=409, content={"status": "error", "message": "x"}) + monkeypatch.setattr(replan, "_project_paths", _async((tmp_path, "p"))) + monkeypatch.setattr(replan, "_save_route", fail) + monkeypatch.setattr(replan, "get_db_pool", lambda: base._Pool([])) monkeypatch.setattr(design_save, "get_db_pool", lambda: base._Pool([])) - monkeypatch.setattr(design_save, "get_project_storage_relative_path", _async("storage/x")) - monkeypatch.setattr(design_save, "resolve_stored_project_path", lambda stored: str(tmp_path)) monkeypatch.setattr(guard, "snapshot_rows", _async({})) monkeypatch.setattr(guard, "restore_rows", _async(None)) - monkeypatch.setattr(design_save, "_run_steps", steps) - request = design_save.DesignSaveRequest.model_construct( - route_id=7, confirm=False, pipes=None, culvert_options=None, route=SimpleNamespace() - ) - response = asyncio.run(design_save.save_design(uuid4(), request, None)) + response = asyncio.run(replan.replan_route(uuid4(), base._body(as_initial=True))) assert response.status_code == 409 assert (copy / "pipe_points.json").read_text(encoding="utf-8") == "old" + + +# ── ⑦ 떠 두기 중 파일 사라짐 ─────────────────────────────────────────────────── + + +def test_떠_두기_중_지워진_파일은_건너뜀(tmp_path, monkeypatch): + import common_util.common_util_save_guard as guard + + derived = tmp_path / "_derived" / "route_initial" + derived.mkdir(parents=True) + (derived / "a.json").write_text("a", encoding="utf-8") + ghost = derived / "b.json.tmp" # 목록 뒤 · 사본 전에 원자 쓰기가 옮김 · 정리가 지움 + real_files = guard._files + + def listed(root, scope): + return {**real_files(root, scope), "_derived/route_initial/b.json.tmp": ghost} + + monkeypatch.setattr(guard, "_files", listed) + snap = guard.snapshot_storage(tmp_path, ("_derived",)) + monkeypatch.setattr(guard, "_files", real_files) + try: + assert set(snap.stamps) == {"_derived/route_initial/a.json"} + (derived / "a.json").write_text("changed", encoding="utf-8") + (derived / "c.json").write_text("new", encoding="utf-8") + snap.restore() + assert sorted(p.name for p in derived.iterdir()) == ["a.json"] + assert (derived / "a.json").read_text(encoding="utf-8") == "a" + finally: + snap.discard() diff --git a/resources/tester/test_346_3_route_save.py b/resources/tester/test_346_3_route_save.py index c11c404a0..c7a050b63 100644 --- a/resources/tester/test_346_3_route_save.py +++ b/resources/tester/test_346_3_route_save.py @@ -2,8 +2,6 @@ """346-3 노선 저장 서버 길 — 차례 · 관 지킴 · 새 교차점 기본 관 · 보충 없음(369) · 이월 열쇠 · 초기값 규칙 · 배수유역 낡음 · 실패 때 옛 노선 되살림. -관 지킴 · 따라감 = 초기화 아닌 저장(스냅 · 간격만) 몫 — 모양 바뀐 저장 · 추천 [적용] = 초기화(492 · -`test_283_route_change_reset`). 순수 부품(`B05_Profile_Route_Save_Parts`)은 값으로 · 체인(`run_route_save_chain`)과 API (`replan_route`)는 DB · 엔진을 가짜로 바꿔 차례 · 되살림 · 초기값만 잰다. @@ -482,8 +480,7 @@ def _body(**kwargs): return save.RouteSaveRequest(vertices=[{"x": 0, "y": 0}, {"x": 5, "y": 5}], **kwargs) -def test_모양_그대로_노선_저장은_초기값_그대로(api_world): - # 492 — 저장 선 = 새 선(스냅 · 간격만) · 모양 바뀐 저장 = test_283_route_change_reset +def test_손으로_고친_노선_저장은_초기값_그대로(api_world): result = asyncio.run(replan.replan_route(uuid4(), _body())) assert result["initial_refreshed"] is False and api_world == [] @@ -493,13 +490,13 @@ def test_추천_적용은_초기값_다시(api_world): assert result["initial_refreshed"] is True and len(api_world) == 1 -def test_균형_종단은_추천_적용만_손_저장은_초기화(api_world, monkeypatch): +def test_균형_종단은_추천_적용만_손_저장은_계획고_옮김(api_world, monkeypatch): calls: list = [] async def recompute( project_id, root, stored, balanced=None, terrain=None, lines=None, initial=False ): # 476 — [적용] = initial 칸 더함 - calls.append((balanced, lines, initial)) + calls.append((balanced, lines)) return {"route_id": 9, "total_length_m": 10.0} monkeypatch.setattr(replan, "_recompute", recompute) @@ -507,8 +504,7 @@ def test_균형_종단은_추천_적용만_손_저장은_초기화(api_world, mo monkeypatch.setattr(replan, "_vertices_of", lambda path: [[0.0, 0.0], [5.0, 5.0]]) asyncio.run(replan.replan_route(uuid4(), _body(balanced_profile=True))) # 손으로 고친 노선 asyncio.run(replan.replan_route(uuid4(), _body(balanced_profile=True, as_initial=True))) - # 손으로 모양 바꾼 노선 = 초기화(492) · 균형 종단 없음(1차 계획선) - assert calls[0] == (None, ([[0.0, 0.0], [4.0, 4.0]], [[0.0, 0.0], [5.0, 5.0]]), True) + assert calls[0] == (None, ([[0.0, 0.0], [4.0, 4.0]], [[0.0, 0.0], [5.0, 5.0]])) assert calls[1][0] is not None # 추천 [적용] = 균형 종단 그대로 diff --git a/resources/tester/test_472_6_route_save.py b/resources/tester/test_472_6_route_save.py index 9f668099f..1260de817 100644 --- a/resources/tester/test_472_6_route_save.py +++ b/resources/tester/test_472_6_route_save.py @@ -6,10 +6,9 @@ ② 다시 얹기 — 관 · 구조물 초안(`rebasePipeDraft` · `rebaseStructureDraft`) == Node `facility.rebase`. ③ 옛 코드 실패 — 한 본문이 `route` · `carry` 칸을 받음 · 브라우저가 노선 저장 창구를 안 부름. - ④ 실제 창구(검증_sub8 DB · storage 는 임시 사본 · 492) — 노선 차례 뒤 실패(시험 갈래 · 넘김 실패) - = 노선 행 · 자식 표 · storage(초기값 사본 포함) 그대로 · 정상 한 본문(스냅만 바꾼 노선 = - 따라감) = 옛 차례(노선 저장 → 다시 얹기 → 횡단 저장)와 행 · 파일 같음 · 모양 바뀐 노선 = - 초기화(노선 차례만 · `route_reset`). + ④ 실제 창구(검증_sub8 DB · storage 는 임시 사본) — 노선 차례 뒤 실패(시험 갈래 · 넘김 실패) + = 노선 행 · 자식 표 · storage(초기값 사본 포함) 그대로 · 정상 한 본문(교점 끌기) = 옛 차례 + (노선 저장 → 다시 얹기 → 횡단 저장)와 행 · 파일 같음 · 시설 수 그대로 · 초기값 그대로(493). 검증 프로젝트가 없는 자리(다른 PC · DB 없음)는 ④ 건너뜀. ./venv/Scripts/python.exe -m pytest resources/tester/test_472_6_route_save.py -q @@ -233,8 +232,8 @@ def _state(root: Path) -> tuple[dict, dict]: def _plan_draft(shape: bool = True) -> dict: - """지금 계획노선 노드 · 넣은 편집값 그대로 + `shape` 면 가운데 노드 하나를 3 m 옆으로(노선 끌기 - = 초기화) · 아니면 측점 스냅만 뒤집음(모양 그대로 = 따라감 · 492).""" + """지금 계획노선 노드 · 넣은 편집값 그대로 + `shape` 면 가운데 노드 하나를 3 m 옆으로(노선 + 끌기) · 아니면 측점 스냅만 뒤집음(모양 그대로).""" from B05_Profile.B05_Profile_Router_Replan import read_route_plan from config.config_db import close_db_pool, init_db_pool @@ -320,7 +319,7 @@ def test_넘김_실패면_노선까지_그대로(monkeypatch) -> None: return run_calc(ids, inputs) monkeypatch.setattr(moving, "run_calc", broken) - status, body = _save(_body(_plan_draft(shape=False))) # 넘김 = 모양 그대로 노선만 + status, body = _save(_body(_plan_draft())) assert status == 409 and body["step"] == "cross" and body["restored"] is True, body assert "넘김 일부러 실패" in body["message"] assert _state(ROOT_DIR) == before @@ -353,7 +352,7 @@ def test_정상_한_본문은_옛_차례와_같음() -> None: from config.config_db import close_db_pool, get_db_pool, init_db_pool start = _state(ROOT_DIR) - draft = _plan_draft(shape=False) + draft = _plan_draft() old_ids = {int(row["id"]) for row in _rows()["routes"]} async def old_way(): @@ -417,11 +416,23 @@ def test_정상_한_본문은_옛_차례와_같음() -> None: @live -def test_모양_바뀐_노선은_초기화_노선_차례만() -> None: - """492 — 교점 3 m 끌기 = 서버 초기화 · 보낸 넘김 칸 안 씀 · 초기값 = 작업본 관 · 끝에 되돌림.""" +def test_모양_바뀐_노선은_시설_따라감_초기값_그대로() -> None: + """493 · 요구 289 · 290 — 교점 3 m 끌기 [저장] = 보이는 화면 그대로(구조물 · 관 · 추가 측점 수 + 같음 · 관 id 그대로) · 초기화 · 초기값 다시 뜸 없음 · 끝에 되돌림.""" + from B05_Profile.B05_Profile_Plus_Stations import load_plus_stations + from B05_Profile.B05_Profile_Structures_Repository import load_structures from config.config_db import close_db_pool, get_db_pool, init_db_pool start = _state(ROOT_DIR) + pipes = ROOT_DIR / "B04_PreProcess" / "drainage" / "edits" / "pipe_points.json" + + def facilities() -> tuple[int, set, int]: + points = json.loads(pipes.read_text("utf-8"))["points"] if pipes.is_file() else [] + return ( + len(load_structures(str(ROOT_DIR))[1]), + {p.get("id") for p in points}, + len(load_plus_stations(ROOT_DIR)), + ) async def hold(): await init_db_pool() @@ -435,16 +446,18 @@ def test_모양_바뀐_노선은_초기화_노선_차례만() -> None: files = guard.snapshot_storage( ROOT_DIR, guard.STORAGE_SCOPE + guard.PIPE_SCOPE + guard.INITIAL_SCOPE ) + structures, pipe_ids, plus = facilities() + initial = {k: v for k, v in start[1].items() if k.startswith("initial_snapshot/")} try: status, body = _save(_body(_plan_draft())) - assert status == 200 and set(body["steps"]) == {"route"}, body - reset = body["steps"]["route"]["route_reset"] - assert reset["reset"] is True and body["steps"]["route"]["initial_refreshed"] is True - pipes = ROOT_DIR / "B04_PreProcess" / "drainage" / "edits" / "pipe_points.json" - kept = json.loads(pipes.read_text("utf-8"))["points"] - assert all(not p.get("options") and p.get("source") == "stream" for p in kept) - copy = ROOT_DIR / "initial_snapshot" / "B04_PreProcess__drainage" / "edits" - assert (copy / "pipe_points.json").read_bytes() == pipes.read_bytes() # 초기값 = 작업본 + assert status == 200 and {"route", "carry", "cross"} <= set(body["steps"]), body + route = body["steps"]["route"] + assert "route_reset" not in route and route["initial_refreshed"] is False + after_structures, after_ids, after_plus = facilities() + assert (after_structures, after_plus) == (structures, plus) + assert pipe_ids <= after_ids # 사용자 · 옛 관 그대로 · 새 교차 관만 더함 + now = {k: v for k, v in _state(ROOT_DIR)[1].items() if k.startswith("initial_snapshot/")} + assert now == initial # 초기값 그대로 finally: async def undo(): diff --git a/resources/tester/test_476_1_apply_initial.py b/resources/tester/test_476_1_apply_initial.py index 304598838..755a259d3 100644 --- a/resources/tester/test_476_1_apply_initial.py +++ b/resources/tester/test_476_1_apply_initial.py @@ -76,8 +76,7 @@ def test_적용_같은_노선_끔_새_1차_계획선(chain_world, monkeypatch): assert not (alignment.get("edits") or {}).get("station_offsets") -def test_초기화_아닌_저장은_계획고_옮김_그대로(chain_world, monkeypatch): - # 492 — 체인 initial=False = 노선 모양 그대로(스냅 · 간격만) 저장 몫 +def test_손_노선_저장은_계획고_옮김_그대로(chain_world, monkeypatch): path, fresh = _real_profile_world(chain_world, monkeypatch) _run(chain_world) alignment = json.loads(path.read_text("utf-8"))["profile_alignment"] @@ -105,7 +104,7 @@ def test_적용은_체인에_initial(api_world, monkeypatch): def test_적용은_횡단_손_입력_버림(chain_world, monkeypatch): - # 요구 277 — 추천 [적용] · 모양 바뀐 [저장](492) = 초기화(횡단 손 입력 전부 버림) · 스냅 · 간격만은 넘김 + # 요구 277 — 추천 [적용] = 새 노선 기준 초기화(횡단 손 입력 전부 버림) · 손 노선 [저장]은 넘김 import B06_Section.B06_Section_Repository as b06_repo seen: list = []