diff --git a/A00_Common/design/design_page_data.ts b/A00_Common/design/design_page_data.ts index 1426b964d..455f72db3 100644 --- a/A00_Common/design/design_page_data.ts +++ b/A00_Common/design/design_page_data.ts @@ -21,6 +21,7 @@ import { dropStaleStructureTypes, fetchStructures, } from "../../B05_Profile/B05_Profile_Api_Structures"; +import type { RoutePlanResponse } from "../../B05_Profile/B05_Profile_Api_Replan"; const memo = new Map>(); @@ -42,6 +43,14 @@ export const pageStructures = (projectId: string): ReturnType => once(`pipes:${projectId}`, () => fetchDetailPipePoints(projectId)); +/** 저장 노선(그린 선 · 기본 반경) — 노선 모양 바뀜 판정(492-2 띠 · [저장] 확인 창)이 조작마다 다시 안 물음 · + * 창구는 부를 때 불러옴(`pageSectionContext` 와 같음). */ +export const pageRoutePlan = (projectId: string): Promise => + once(`plan:${projectId}`, async () => { + const { fetchRoutePlan } = await import("../../B05_Profile/B05_Profile_Api_Replan"); + return fetchRoutePlan(projectId); + }); + /** 정의 판이 바뀌었으면 종류 캐시 버림 — 페이지마다 한 번(탭이 다시 보일 때는 `onStaleStructureTypes`). */ export const pageTypesChecked = (projectId: string): Promise => once(`types:${projectId}`, () => dropStaleStructureTypes()); diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts index eda24c8cf..7ebb6fb2c 100644 --- a/A00_Common/design/design_save.ts +++ b/A00_Common/design/design_save.ts @@ -13,6 +13,8 @@ * 이월) → 관 · 구조물 초안 다시 얹기 → 새 노선으로 위 차례 → 횡단 = 세션 고침을 측점 열쇠로 넘긴 patch(같은 TS · * Node). 이 때 조각은 기본 조각만(화면 몫 일 = 옛 노선 판이라 건너뜀) · 계획고 초안 = 노선 초안 칸 ?? 옛 노선 칸 · * 손 상단측은 안 보냄(요구 274) · [확정]도 [저장]으로(새 노선을 본 뒤 다시 [확정]) · 다 저장하면 페이지 다시 세움. + * 노선 모양이 바뀌면 = 초기화(492 · 요구 284 · 285) — 잠금 막 앞 확인 창(지워질 수 · [취소] = 요청 0) · 서버가 + * 시설 · 손 입력을 버리고 다시 셈(응답 `route_reset`) → 알림 한 줄 · 세션 초안을 통째 비우고 서버 정본으로 다시 세움. * * ⚠ 차례를 바꾸면 결과가 갈린다 — 관 목록은 스냅샷이라 구간값(암거 옵션)보다 먼저, 상단측은 * 횡단 patch 보다 먼저 나가야 사용자 수정이 맨 위에 얹힌다(2026-09-06 · 09-12) — 차례 = 서버 한 곳. @@ -39,7 +41,7 @@ import { type SavePartKey, type SaveStepKey, } from "./design_save_api"; -import { clearRouteDrafts, clearState, stateKey } from "../b_page_state"; +import { clearDrafts, clearRouteDrafts, clearState, stateKey } from "../b_page_state"; import { pendingRouteSave, rebuildAfterRouteSave, @@ -48,6 +50,8 @@ import { routeSaveSummary, } from "../../B05_Profile/B05_Profile_UI_RouteEdit_Apply"; import type { RouteSaveBody } from "../../B05_Profile/B05_Profile_Api_Replan"; +import { confirmRouteReset } from "../../B05_Profile/B05_Profile_UI_RouteReset"; +import { routeResetNotice } from "../../B05_Profile/logic/B05_Profile_Route_Reset"; export interface DesignSaveContext { projectId: string; @@ -164,8 +168,10 @@ function oneBodyError(error: unknown): Error { return new Error(`${where}실패 — 아무것도 저장하지 않았습니다(모두 그대로). ${error.message}`); } -/** 노선 저장 결과 알림 — 노선 밖 관 · 구조물 · 따라감 실패 · 결과 한 줄(관 지킴 · 노선 밖 고침 …). */ +/** 노선 저장 결과 알림 — 초기화면 지운 수 한 줄(492) · 아니면 노선 밖 관 · 구조물 · 따라감 실패 · 결과 한 줄. */ function noticeRouteSaved(saved: RouteReplanResponse, carryOutside: number): void { + const reset = routeResetNotice(saved.route_reset, saved.pipes?.crossing ?? 0); + if (reset) return showToast(reset, "info"); const outside = (saved.pipes?.outside ?? 0) + (saved.structures?.outside ?? 0); if (outside) showToast(outsideNotice(outside), "warning"); if (saved.follow_failed?.length) @@ -187,10 +193,12 @@ export async function saveDesign( const confirm = options.confirm ?? false; const routeId = ctx.routeId(); if (routeId === null) return false; + const route: RouteSaveBody | null = await pendingRouteSave(ctx.projectId); + // 노선 모양이 바뀐 [저장] = 초기화(492-2) — 잠금 막 앞에서 지워질 수를 물음 · [취소] = 요청 0 + if (route && !(await confirmRouteReset(ctx.projectId, route.draft))) return false; showLoadingOverlay(); saving.add(ctx.projectId); try { - const route: RouteSaveBody | null = await pendingRouteSave(ctx.projectId); await ctx.prepare?.(); const parts: Partial> = {}; // 노선이 바뀌면 횡단 = 옛 노선 세션 고침 원본(서버가 새 측점에 넘김) · 화면 몫 조각은 옛 노선 판이라 안 씀 @@ -222,6 +230,8 @@ export async function saveDesign( } const saved = (result.steps.route ?? {}) as unknown as RouteReplanResponse; noticeRouteSaved(saved, Number(result.steps.carry?.outside ?? 0)); + // 초기화 — 세션 초안(시설 · 계획고 · 횡단 캐시)을 비워 서버 정본으로 다시 세움(추천 [적용]과 같음) + if (saved.route_reset?.reset) clearDrafts(ctx.projectId); // 새 노선 — [확정]은 새 노선을 본 뒤 다시(단계 닫기는 새 횡단 본문이 있어야 함) · 페이지 다시 세움. if (confirm) showToast("노선을 저장했습니다 — 새 노선을 확인한 뒤 다시 [확정]하세요.", "info"); else await ctx.done?.(false); diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 57ce81425..834db4d34 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -20,6 +20,7 @@ import { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profil import { leaveForDashboard } from "../b_missing_data_guard"; import { currentRoute, remountRoute } from "../router"; import { mountRouteEditor } from "../../B05_Profile/B05_Profile_UI_RouteEdit"; +import { mountRouteResetBand } from "../../B05_Profile/B05_Profile_UI_RouteReset"; import { routeDraftBridge } from "./design_pipeline_page"; import { pageRouteId, pageSectionContext } from "./design_page_data"; import { createSelectionSync, graphTargetAt } from "../../B05_Profile/B05_Profile_UI_Selection"; @@ -548,6 +549,9 @@ export async function mountProfileView( // 계산 묶음(346-1) — 페이지가 세운 다리(365-1) · 노선을 고칠 때마다 종단이 따라옴(미리보기 · [저장]이 정본). draft: routeDraftBridge(activeProjectId, profilePanel), }); + // 모양 바뀐 노선 초안 띠(492-2) — 편집기 뼈대는 위 부름에서 바로 섬 · B06 평면 패널로 같이 옮겨짐 + const canvasWrap = mainContent.querySelector(".b05-routeedit__canvas-wrap"); + if (canvasWrap) host.onDispose(mountRouteResetBand(canvasWrap, activeProjectId)); // ③ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다. const routeId = pageRouteId(activeProjectId); // 노선 id 한 칸(386-2b) diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index df97ed54a..5c773c27a 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -19,6 +19,7 @@ import { heightAt } from "../common_util/common_util_surface_sampler"; import { loadDesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid"; import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_types"; import type { RouteNodeEdit } from "./logic/B05_Profile_UI_RouteEdit_Edits"; +import type { RouteReset } from "./logic/B05_Profile_Route_Reset"; /** 편집 창 분석 지형 — las = 확정 지표면(라이다) · sheet = 수치지형도 서피스. */ export type AnalysisTerrain = "las" | "sheet"; @@ -142,6 +143,8 @@ export interface RouteReplanResponse { /** 배수유역 저장분이 새 노선과 안 맞음(다시 안 셈). */ drainage_stale?: boolean; initial_refreshed?: boolean; + /** 노선 모양 바뀜 = 초기화(492) — 지운 사용자 구조물 · 관 · 추가 측점 수 · 옛 서버는 없음. */ + route_reset?: RouteReset; } /** 노선 저장 본문 — `draft` 는 계약 `RouteDraft` 그대로 · `as_initial` = 추천 [적용](초기값 다시 뜸). */ diff --git a/B05_Profile/B05_Profile_Engine_Grade_Carry.py b/B05_Profile/B05_Profile_Engine_Grade_Carry.py index 56c5d8784..78bcdad6a 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Carry.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Carry.py @@ -5,7 +5,8 @@ ▲▼)은 이어받음. - `changed_span` = 옛 · 새 노선 정점열의 같은 앞머리 · 같은 꼬리를 뺀 가운데 — - 「노선 그대로?」 · 범위. + 「노선 그대로?」 · 범위 · `route_shape_changed` = 노선 모양 바뀜 판정(492 · TS + `routeShapeChanged` 짝). - 같은 자리(`line_matcher`) = 새 선 점을 옛 선에 투영 · 옆 `SAME_LINE_TOL_M` 안 · 방향 30° 안 (측점 스냅이 뒤 교점을 밀어도 이어받음). - 자동 꼭짓점(`carry_base`) · 편집(`carry_edits`)은 **새 측점 위에** 다시 놓음 — 같은 자리면 @@ -80,6 +81,17 @@ 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_Engine_InitialProfile.py b/B05_Profile/B05_Profile_Engine_InitialProfile.py index ae629a60f..457b7f28b 100644 --- a/B05_Profile/B05_Profile_Engine_InitialProfile.py +++ b/B05_Profile/B05_Profile_Engine_InitialProfile.py @@ -3,8 +3,8 @@ [적용] 체인(`B05_Profile_Route_Save.run_route_save_chain`)과 같은 조각을 메모리에서 부름(파일 안 씀): 후보 노드 → 화면 `flattenServerPlan` 꼴(`draft_nodes`) → `build_planned_polyline`(편집 갈래 · csv - 4자리) → 관 · 구조물 · 추가 측점 따라감(`followed_pipes` · `followed_structures` · - `followed_plus_rows` · 새 세류선 교차 관 포함) → 측점 틀 · 1차 계획선 438(`build_sections`) → + 4자리) → 초기화 시설(492 · `reset_facilities` = 새 세류선 교차 관만 · 사용자 구조물 · 추가 측점 · + 관 버림) → 측점 틀 · 1차 계획선 438(`build_sections`) → 「종단 반영」 켬이면 1 m 균형 종단을 꼭짓점에 얹음(`seat_base_pvi` → `rebuild_alignment_profile`). [적용]은 이 값을 받아 적지 않음 — 서버가 체인에서 같은 조각으로 다시 셈(CLAUDE.md 5). @@ -88,19 +88,12 @@ def candidate_line( return line, [curve.as_dict() for curve in outline.curves] -def followed_facilities( - project_root: Path, - line: list[tuple[float, float]], - old_route_data_path: str | None, - old_xy: list[list[float]], - old_pipes: list, - streams: list, -) -> tuple[list, list, list[float] | None]: - """체인과 같은 따라감(파일 안 씀) → (관 · 구조물 · 추가 측점 누가거리 | 없음 None).""" - from B05_Profile.B05_Profile_Plus_Stations import followed_plus_rows, load_plus_stations +def reset_facilities( + line: list[tuple[float, float]], streams: list +) -> tuple[list, list, list[float]]: + """체인 초기화(492)와 같은 시설(파일 안 씀) → (관 = 새 노선 세류선 교차 기본 관만 · + 구조물 없음 · 추가 측점 없음).""" from B05_Profile.B05_Profile_Route_Save import followed_pipes - from B05_Profile.B05_Profile_Structures_Repository import load_structures - from B05_Profile.B05_Profile_Structures_Router import followed_structures from common_util.common_util_drainage_pipes import ( ensure_pipe_ids, fill_pipe_coordinates, @@ -108,21 +101,13 @@ def followed_facilities( ) points = [{"x": x, "y": y} for x, y in line] - items = load_structures(str(project_root))[1] - if items: - followed_structures(project_root, items, old_route_data_path, points) - rows = load_plus_stations(project_root) - plus = ( - sorted({float(r["chainage_m"]) for r in followed_plus_rows(rows, line)["rows"]}) - if rows - else None - ) - pairs, _crossing, vertices = followed_pipes(old_pipes, old_xy, points, streams) + pairs, _crossing, vertices = followed_pipes([], [], points, streams) pipes = [pipe for _, pipe in pairs] fill_pipe_coordinates(pipes, vertices) ensure_pipe_ids(pipes) # 체인은 관 정본을 쓰고 다시 읽음(지문 같음 = 저장분 그대로) — 같은 왕복 - return parse_pipe_points([pipe.as_dict() for pipe in pipes]), items, plus + # 추가 측점 = 빈 목록(None 이면 측점 틀이 저장 파일을 읽음) + return parse_pipe_points([pipe.as_dict() for pipe in pipes]), [], [] def initial_longitudinal( diff --git a/B05_Profile/B05_Profile_Route_Save.py b/B05_Profile/B05_Profile_Route_Save.py index 1c5757475..264816a93 100644 --- a/B05_Profile/B05_Profile_Route_Save.py +++ b/B05_Profile/B05_Profile_Route_Save.py @@ -11,20 +11,23 @@ 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, + follow_failed, cross_dropped, route_reset: {reset, dropped: {structures, pipes, plus_stations}}, drainage_stale: true, seconds}`. 오류 400(노드 둘 미만 · 초안 칸 길이 어긋남) · 404(프로젝트 없음) · 409 `{status: "error", message}`(체인 실패 — 노선 · 관 · 구조물 · 종횡단 파일 · 새 노선 행을 옛것으로 되살린 뒤). 규칙: - - 관 정본을 지우지 않음 — 좌표를 따라감 · 새 세류선 교차점 기본 관만 더함 + - 노선 초기화(`initial` = 추천 [적용] · 모양 바뀐 노선 [저장] · 492 · 요구 284 · 285) = 사용자 + 구조물 · 추가 측점 · 관(옵션 포함) 버림 · 관 = 새 노선 세류선 교차 기본 관만 · 종단 1차 계획선 · + 횡단 기본 설계 · 초기값 다시 뜸(부르는 쪽). + - 그 밖(스냅 · 간격만) = 관 정본을 지우지 않음 — 좌표를 따라감 · 새 세류선 교차점 기본 관만 더함 (`B05_Profile_Route_Save_Parts`) · 배수유역 저장분은 노선 지문이 달라 「낡음」(B04 `_resolve`). - 계획고 = 바뀐 자리만 새 지반 첫 계획선 · 같은 자리(옆 1 m 안) 사용자 계획고 지킴 (`B05_Profile_Engine_Grade_Carry` · 옛 · 새 **계획 폴리라인** — 화면 planline 과 같은 선) · 추천 [적용](`as_initial`) = 옮기지 않음 — 균형 종단(`apply_profile_target`) · 1차 계획선(438) · 횡단 손 입력도 안 넘김(요구 277 · 측점마다 기본 설계). - - 초기값은 여기서 안 뜸 — 부르는 쪽이 `as_initial` 일 때만(자동설계 체인 직후 + 추천 [적용]). + - 초기값은 여기서 안 뜸 — 부르는 쪽이 초기화일 때만(자동설계 체인 직후 + 노선 초기화). """ from __future__ import annotations @@ -199,6 +202,7 @@ async def _place_pipes( points: list[dict[str, float]], ) -> tuple[list, dict[str, int]]: """관 따라감 → 새 교차점 기본 관 → 정본 저장(새 노선 지문) · 간격 보충 없음(369). + 초기화(492)면 부르는 쪽이 옛 관 · 옛 선을 빈 목록으로 줌 = 새 노선 교차 관만. 따라갈 선 = 구조물 · 추가 측점과 같은 `points`(사용 범위 자름 · 459) — B04 `_prepare` 노선은 범위를 안 잘라 누가거리가 갈림 · 세류선만 거기서. 좌표 없는 옛 관은 옛 노선 @@ -370,6 +374,36 @@ async def _rollback(project_id: UUID, old_route_id: int, params: dict[str, Any]) logger.exception("노선 저장 되살림(DB) 실패: project_id=%s", project_id) +async def _follow_facilities( + project_id: UUID, + project_root: Path, + old_route: dict[str, Any], + points: list[dict[str, float]], + follow_failed: list[str], +) -> dict[str, int]: + """구조물 · 추가 측점 땅 위 자리 따라감(초기화 아닌 저장) → 구조물 요약 · 실패 = 옛 누가거리.""" + from B05_Profile.B05_Profile_Plus_Stations import follow_plus_stations + from B05_Profile.B05_Profile_Structures_Router import follow_structures_to_route + + # 구조물 = 옛 노선 파일이 아직 옛 것일 때 — 좌표 없는 옛 자료 채움 + try: + structures = await asyncio.to_thread( + follow_structures_to_route, project_root, old_route.get("route_data_path"), points + ) + except Exception: # noqa: BLE001 — 따라감 실패 = 옛 누가거리 그대로(비치명) + logger.exception("구조물 따라감 실패(옛 누가거리 유지): project_id=%s", project_id) + structures = {"moved": 0, "far": 0} + follow_failed.append("구조물") + try: # 추가 측점(368-2) + await asyncio.to_thread( + follow_plus_stations, project_root, [(p["x"], p["y"]) for p in points] + ) + except Exception: # noqa: BLE001 + logger.exception("추가 측점 따라감 실패(옛 누가거리 유지): project_id=%s", project_id) + follow_failed.append("추가 측점") + return structures + + async def run_route_save_chain( project_id: UUID, project_root: Path, @@ -387,16 +421,24 @@ async def run_route_save_chain( `planned_lines` = (옛 · 새 계획 폴리라인) — 바뀐 구간을 화면 planline 단계와 같은 선으로 가름 (없으면 풀린 노선 geojson · 점 간격이 달라 앞머리가 일찍 갈림). - `initial` = 추천 [적용](`as_initial`) — 옛 계획고 · 편집 · L 안 옮김 · 새 규칙 초기 종단(476). + `initial` = 노선 초기화(추천 [적용] · 모양 바뀐 [저장] · 492) — 사용자 시설 버림 · 옛 계획고 · + 편집 · L 안 옮김 · 새 규칙 초기 종단(476). """ import aiomysql from B03_FileInput.B03_FileInput_Service_Chain import _log_steps, redesign_solve_request from B05_Profile.B05_Profile_Engine_Sections import _STAGE_SUBDIR - from B05_Profile.B05_Profile_Plus_Stations import follow_plus_stations, plus_stations_path + from B05_Profile.B05_Profile_Plus_Stations import ( + load_plus_stations, + plus_stations_path, + save_plus_stations, + ) from B05_Profile.B05_Profile_Router import confirm_latest_route, solve_route_record - from B05_Profile.B05_Profile_Structures_Repository import load_structures, structures_file_path - from B05_Profile.B05_Profile_Structures_Router import follow_structures_to_route + from B05_Profile.B05_Profile_Structures_Repository import ( + load_structures, + save_structures, + structures_file_path, + ) from B06_Section.B06_Section_Repository import ( get_cross_section_designs, get_latest_section_options, @@ -425,7 +467,8 @@ async def run_route_save_chain( old_long = _read_json(project_root / relative_old) if relative_old else {} old_xy = _xy(project_root, old_route.get("route_data_path")) old_pipes = read_pipe_points_file(pipe_points_path(stored_path)) - _, old_structures = load_structures(str(project_root)) + structures_revision, old_structures = load_structures(str(project_root)) + old_plus = load_plus_stations(project_root) stage_dir = project_root / _STAGE_SUBDIR backup = _Backup( project_root, @@ -440,22 +483,16 @@ async def run_route_save_chain( mark_designing(project_root) try: follow_failed: list[str] = [] # 따라감 실패 = 옛 누가거리 그대로 · 화면이 알림(459) - # 구조물 = 땅 위 자리 따라감(옛 노선 파일이 아직 옛 것일 때 — 좌표 없는 옛 자료 채움) - try: - structures = await asyncio.to_thread( - follow_structures_to_route, project_root, old_route.get("route_data_path"), points + if initial: # 초기화(492) — 사용자 구조물 · 추가 측점 버림(실패면 `backup` 이 되살림) + if old_structures: + save_structures(str(project_root), [], base_revision=structures_revision) + if old_plus: + save_plus_stations(project_root, [], None) + structures = {"moved": 0, "far": 0, "outside": 0} + else: + structures = await _follow_facilities( + project_id, project_root, old_route, points, follow_failed ) - except Exception: # noqa: BLE001 — 따라감 실패 = 옛 누가거리 그대로(비치명) - logger.exception("구조물 따라감 실패(옛 누가거리 유지): project_id=%s", project_id) - structures = {"moved": 0, "far": 0} - follow_failed.append("구조물") - try: # 추가 측점도 땅 위 자리 따라감(368-2 · 실패 = 옛 누가거리 그대로) - await asyncio.to_thread( - follow_plus_stations, project_root, [(p["x"], p["y"]) for p in points] - ) - except Exception: # noqa: BLE001 - logger.exception("추가 측점 따라감 실패(옛 누가거리 유지): project_id=%s", project_id) - follow_failed.append("추가 측점") request = redesign_solve_request(params, selection, surface_model_id) solved: Any = await solve_route_record(project_id, request, generate_sections=False) if isinstance(solved, JSONResponse): @@ -467,7 +504,10 @@ async def run_route_save_chain( new_xy = _xy(project_root, solved.route_data_path) marks.append(("노선 풀기 · 구조물 따라감", time.perf_counter())) - pairs, pipes = await _place_pipes(project_id, stored_path, old_pipes, old_xy, points) + # 초기화 = 옛 관(옵션 · 사용자 관) 버림 · 새 노선 세류선 교차 관만(492) + pairs, pipes = await _place_pipes( + project_id, stored_path, [] if initial else old_pipes, [] if initial else old_xy, points + ) marks.append(("관 따라감 · 교차점", time.perf_counter())) seed = section_seed(request, old_section_options) @@ -538,6 +578,15 @@ 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), } diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 7142b8ca3..32a46ca15 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -13,7 +13,8 @@ 노선 저장(346-3) — 여기서 노선을 갈아 끼운 뒤 `B05_Profile_Route_Save.run_route_save_chain` 이 풀기 → 관 · 구조물 좌표 따라감 → 종횡단 한 번 → 계획고 옮김 → 횡단 이월(측점 열쇠) → B06 확정까지 -돈다. 배수유역은 다시 안 셈(낡음) · 관 정본은 안 지움 · 초기값은 추천 [적용](`as_initial`)일 때만. +돈다. 배수유역은 다시 안 셈(낡음) · 노선 모양이 바뀌었거나 추천 [적용](`as_initial`)이면 초기화 +(사용자 시설 · 손 입력 버림 · 초기값 다시 뜸 · 492) · 스냅 · 간격만 바꾼 저장은 시설 따라감. 요청 · 응답 · 오류 모양 = 그 모듈 머리. GET /api/projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계) @@ -34,6 +35,7 @@ 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 @@ -331,7 +333,7 @@ async def _recompute( marks = [("시작", time.perf_counter())] - # 관 정본은 지우지 않음(346-3) — 사용자 관 편집을 지키고 좌표로 새 노선을 따라감(체인 안). + # 관 정본 = 체인이 씀 — 초기화면 새 교차 관만 · 아니면 좌표로 새 노선을 따라감(346-3 · 492). pool = get_db_pool() async with pool.acquire() as connection: await connection.begin() # 고른 지형 = 설계 지형 — 뒤 단계가 모두 이 확정값을 읽음 @@ -525,7 +527,8 @@ 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` 머리). - 배수유역은 다시 안 셈(낡음 표시) · 초기값은 추천 [적용](`as_initial`)일 때만 다시 뜬다. + 배수유역은 다시 안 셈(낡음 표시) · 초기값은 노선 초기화(추천 [적용] · 모양 바뀜 · 492) 때 + 다시 뜬다. 추천 [적용] = 즉시 저장(요구 275 ②) · [저장]과 같은 프로젝트 잠금 · 떠 두고 실패면 되돌림(476 · 472-6 `common_util_save_guard`) · 응답 `waited` · ⚠ `/design/save` 잠금 안에서 as_initial 금지. """ @@ -544,7 +547,9 @@ async def replan_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, An async with pool.acquire() as connection: # 설계 지형 바꿈(지표면 확정)도 떠 둠 rows = await guard.snapshot_rows(connection, str(project_id), surface=True) files = await asyncio.to_thread( - guard.snapshot_storage, paths[0], guard.STORAGE_SCOPE + guard.PIPE_SCOPE + guard.snapshot_storage, + paths[0], + guard.STORAGE_SCOPE + guard.PIPE_SCOPE + guard.INITIAL_SCOPE, ) try: result = await _save_route(project_id, body) @@ -620,7 +625,11 @@ async def _save_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, Any summary["violations"], radius_m, ) - # 균형 종단 새로 풂 = 추천 [적용](`as_initial`)만 — 손으로 고친 노선은 계획고 옮김(344-2). + 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 길). result = await _recompute( project_id, project_root, @@ -631,8 +640,8 @@ async def _save_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, Any else None ), request.terrain, - (old_line, await asyncio.to_thread(_vertices_of, working_path)), - as_initial, + (old_line, new_line), + reset, ) if "error" in result: # 노선을 되돌린다 — 실패했는데 새 노선만 남으면 화면·정본이 어긋난 채 굳는다. @@ -662,9 +671,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) ) - # 초기값 = 자동설계 체인 직후 + 추천 노선 [적용] 때만(346-3 · 사용자 확정 10-16) — 손으로 고친 - # 노선 저장은 [초기화]가 돌아갈 자리를 안 바꿈. - if as_initial: + # 초기값 = 자동설계 체인 직후 + 노선 초기화(추천 [적용] · 모양 바뀐 [저장] · 492) 때만 — + # 스냅 · 간격만 바꾼 저장은 [초기화]가 돌아갈 자리를 안 바꿈. + if reset: from B05_Profile.B05_Profile_Router_RouteInitial import refresh_initial_snapshot await refresh_initial_snapshot(project_id, project_root, result.get("route_id")) @@ -673,7 +682,7 @@ async def _save_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, Any "project_id": str(project_id), **result, "current_initial": current, - "initial_refreshed": as_initial, + "initial_refreshed": reset, } diff --git a/B05_Profile/B05_Profile_Router_RouteInitial_Initial.py b/B05_Profile/B05_Profile_Router_RouteInitial_Initial.py index a18f11767..78407b6e5 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial_Initial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial_Initial.py @@ -41,11 +41,9 @@ async def initial_profile_of( from B05_Profile import B05_Profile_Engine_InitialProfile as ip from B05_Profile import B05_Profile_Router_Replan as replan from B05_Profile.B05_Profile_Engine_Sections import _MODELS_SUBDIR - from B05_Profile.B05_Profile_Plus_Stations import plus_stations_path from B05_Profile.B05_Profile_Repository import get_latest_route - from B05_Profile.B05_Profile_Route_Save import _xy, section_seed + from B05_Profile.B05_Profile_Route_Save import section_seed from B05_Profile.B05_Profile_Router_RouteInitial import balanced_profile_of, project_standard - from B05_Profile.B05_Profile_Structures_Repository import structures_file_path from B06_Section.B06_Section_Repository import ( get_latest_section_options, get_route_generation_source, @@ -54,7 +52,6 @@ async def initial_profile_of( _regeneration_grade_options, _regeneration_options, ) - from common_util.common_util_drainage_pipes import pipe_points_path, read_pipe_points_file from common_util.common_util_surface_sampler import build_surface_sampler from common_util.common_util_workflow_state import get_workflow_state @@ -101,14 +98,8 @@ async def initial_profile_of( "surface": surface, "models": ip.stat_marks(list(models.glob(f"*_{surface['source_filter']}*.npz"))), "streams": ip.stat_marks([_sheet_dir(stored_path) / STREAM_FILE]), - "files": ip.file_marks( - [ - pipe_points_path(stored_path), - Path(structures_file_path(str(project_root))), - plus_stations_path(project_root), - project_root / route_path, - ] - ), + # 초기화(492) = 저장 관 · 구조물 · 추가 측점을 안 씀 — 노선 파일만 + "files": ip.file_marks([project_root / route_path]), "options": asdict(options), "grade": grade_options.as_dict() if grade_options else None, "crs": crs, @@ -123,15 +114,7 @@ async def initial_profile_of( return stored prepared = await _prepare(project_id) streams = [] if isinstance(prepared, JSONResponse) else prepared["streams"] - facilities = await asyncio.to_thread( - ip.followed_facilities, - project_root, - line, - route_path or None, - _xy(project_root, route_path), - read_pipe_points_file(pipe_points_path(stored_path)), - streams, - ) + facilities = await asyncio.to_thread(ip.reset_facilities, line, streams) target = None if request.with_profile: # [적용] `balanced_target` 과 같은 셈(노선 정점 · 새 곡선표) points = [{"x": x, "y": y} for x, y in line] diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index 94fbb43b4..a3b287f19 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -36,6 +36,7 @@ import { type RouteSaveBody, } from "./B05_Profile_Api_Replan"; import type { SnapSetting } from "./B05_Profile_UI_RouteEdit_SnapStep"; +import { routeResetNotice } from "./logic/B05_Profile_Route_Reset"; type Vertex = [number, number]; type RouteSettings = SnapSetting & { terrain: string | null }; @@ -207,7 +208,7 @@ export async function saveRecommendedRoute( const settings = sources.get(projectId)?.settings() ?? { stationSnap: false, terrain: null }; showLoadingOverlay(); try { - await saveRouteDraft(projectId, { + const saved = await saveRouteDraft(projectId, { // 383-1 — 추천 선은 곡선 MC 를 일반측점에 맞춘 선 → [측점 스냅] 켬 · 측점 No.(390-5)로 저장 draft: routeDraftOf(nodes, { ...settings, stationSnap: true, stationSnapStep: null }), initial, @@ -216,7 +217,8 @@ export async function saveRecommendedRoute( }); clearDrafts(projectId); sources.get(projectId)?.saved(); - showToast("추천 노선을 저장했습니다 — 새 초기값입니다.", "success"); + const reset = routeResetNotice(saved.route_reset, saved.pipes?.crossing ?? 0); + showToast(`추천 노선을 저장했습니다 — 새 초기값입니다.${reset ? ` ${reset}` : ""}`, "success"); await rebuildAfterRouteSave(projectId); return true; } catch (error) { diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts index 5dae0ca4d..b1cf1376a 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts @@ -42,6 +42,7 @@ import { } from "./B05_Profile_UI_RouteEdit_Initial_Model"; import { openRecommendModal } from "./B05_Profile_UI_RouteEdit_Recommend"; import { saveRecommendedRoute } from "./B05_Profile_UI_RouteEdit_Apply"; +import { applyResetText } from "./B05_Profile_UI_RouteReset"; export interface InitialPanelParams { /** 단추 · 글 요소 — 좌측 「노선 추천」 칸 것(345-4). */ @@ -158,6 +159,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { current: params.planned().map(([x, y]) => [x, y]), request, progress: () => fetchPrecomputeProgress(projectId), + confirmText: () => applyResetText(projectId), onApply: (picked) => { choice = picked; writeInitialChoice(projectId, choice); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts index 1c8cf5d97..9c8b288ad 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts @@ -88,9 +88,6 @@ const PILL_TITLE: Record<(typeof PILLS)[number], string> = { }; /** [적용] 확인 글(476 · 요구 277) — 같은 노선이어도 수작업(종단 · 횡단 손 입력)을 모두 버린다. */ -const APPLY_CONFIRM = - "이 노선과 미리 본 초기 종단을 저장합니다 — 종단 · 횡단 손 입력이 모두 지워지고 이 노선 기준으로 새로 시작합니다. 적용할까요?"; - const WEIGHT_NAME: Record = { expected: "예상", earthwork: "토공", @@ -154,6 +151,8 @@ export interface RecommendParams { ) => Promise; /** [적용] — 미리 본 노선을 바로 저장한다(새 초기값). */ onApply: (choice: InitialChoice) => void; + /** [적용] 확인 창 글 — 초기화로 지워질 수(492-2 · [저장]과 같은 글). */ + confirmText: () => Promise; /** 서버 미리 셈 진행(364-4) — 셈 기다리는 동안 상태줄에 붙인다. */ progress?: () => Promise; } @@ -622,7 +621,7 @@ export function openRecommendModal(params: RecommendParams): void { applyButton.addEventListener("click", () => { const picked = preview; if (!picked) return; - void showConfirmDialog(APPLY_CONFIRM, "적용").then((ok) => { + void (async () => showConfirmDialog(await params.confirmText(), "적용"))().then((ok) => { if (!ok || closed || preview !== picked) return; params.onApply(picked); closed = true; diff --git a/B05_Profile/B05_Profile_UI_RouteReset.ts b/B05_Profile/B05_Profile_UI_RouteReset.ts new file mode 100644 index 000000000..eceb9dfd5 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteReset.ts @@ -0,0 +1,101 @@ +/* ============================================================================= + * B05_Profile_UI_RouteReset.ts + * 노선 모양 바뀐 [저장] = 초기화(요구 283 · 284 · 285 · 492-2) — 화면 몫. + * + * · 띠 — 평면(노선 편집) 위 한 줄 · 노선 초안이 저장 노선 모양을 바꾸면 보임(B06 평면 패널로 옮겨져도 같이 감). + * · 확인 창 — [저장](`design_save`) · 추천 [적용](476-2) 앞 · 지워질 수 = 지금 캐시의 사용자 구조물 · 관 · + * 추가 측점 · 종단 편집 · [취소] = 요청 0. + * · 저장 뒤 알림 — 서버 응답 `route_reset`(정본 판정) · 없으면(옛 서버) 건너뜀. + * 셈 · 글 = `logic/B05_Profile_Route_Reset` · 저장 노선 = 페이지 자료 줄(`pageRoutePlan` · 조작마다 안 물음). + * ========================================================================== */ + +import { showConfirmDialog } from "@ui/ui_template_elements"; +import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_types"; +import { useDesignStore } from "../A00_Common/design/design_store"; +import { + pagePipes, + pageRouteId, + pageRoutePlan, + pageStructures, +} from "../A00_Common/design/design_page_data"; +import { plusStationsOf } from "./B05_Profile_Api_PlusStations"; +import { pendingAlignment, routeAlignment } from "./B05_Profile_UI_Profile_Edit"; +import { + askRouteReset, + draftShapeChanged, + ROUTE_RESET_BAND, + routeResetConfirmText, + type RouteResetCounts, +} from "./logic/B05_Profile_Route_Reset"; + +/** 노선 초안 동안의 계획고 초안 칸 — `design_pipeline_page` 의 `DRAFT_ROUTE_ID` 와 같은 값. */ +const DRAFT_ROUTE_ID = 0; + +/** 초안이 저장 노선 모양을 바꾸나(저장 노선을 못 받으면 바뀜으로 봄). */ +export async function draftResets(projectId: string, draft: RouteDraft | null): Promise { + if (!draft) return false; + const plan = await pageRoutePlan(projectId).catch(() => null); + return draftShapeChanged(plan?.planned ?? null, draft, plan?.min_radius_m ?? 0); +} + +/** 지금 캐시(초안 ?? 저장분)에서 지워질 수. */ +export async function routeResetCounts(projectId: string): Promise { + const store = useDesignStore(projectId, null); + const [structures, pipes] = await Promise.all([ + store.get("structures") ?? + pageStructures(projectId).then( + (list) => list.structures, + () => [], + ), + store.get("pipes") ?? + pagePipes(projectId).then( + (list) => list.pipe_points, + () => [], + ), + ]); + const routeId = pageRouteId(projectId); + const edits = + pendingAlignment(DRAFT_ROUTE_ID) ?? (routeId === null ? null : routeAlignment(routeId)); + return { + structures: structures?.length ?? 0, + pipes: pipes?.length ?? 0, + plusStations: plusStationsOf(projectId).length, + profileEdits: + Object.keys(edits?.station_offsets ?? {}).length + + Object.keys(edits?.curve_radii ?? {}).length, + }; +} + +/** [저장] 앞 — 모양 바뀐 초안이면 확인 창 · [취소] = false(요청 0) · 안 바뀜 = 묻지 않고 true. */ +export async function confirmRouteReset(projectId: string, draft: RouteDraft): Promise { + return askRouteReset( + await draftResets(projectId, draft), + () => routeResetCounts(projectId), + (text) => showConfirmDialog(text, "저장"), + ); +} + +/** 추천 [적용] 확인 창 글 — [저장]과 같은 글 · 수. */ +export async function applyResetText(projectId: string): Promise { + return routeResetConfirmText(await routeResetCounts(projectId), "적용"); +} + +/** 평면 위 띠를 세움 — 노선 초안이 바뀔 때마다 맞춤 · 돌려준 함수 = 떠날 때 풂. */ +export function mountRouteResetBand(parent: HTMLElement, projectId: string): () => void { + const band = document.createElement("div"); + band.className = "b05-routeedit__reset-band"; + band.setAttribute("role", "status"); + band.textContent = ROUTE_RESET_BAND; + band.hidden = true; + parent.append(band); + const store = useDesignStore(projectId, null); + let turn = 0; + const sync = async (draft: RouteDraft | null): Promise => { + const mine = ++turn; // 늦게 온 옛 판정이 새 판정을 덮지 않게 + const changed = await draftResets(projectId, draft); + if (mine === turn) band.hidden = !changed; + }; + void sync(store.get("routeDraft")); + const stop = store.subscribe("routeDraft", (draft) => void sync(draft)); + return () => (stop(), band.remove()); +} diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index 4d4584636..e5ef1b44e 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -41,6 +41,28 @@ touch-action: none; } +/* 노선 모양 바뀐 초안 띠(492-2) — 도구줄 아래 가운데 · 클릭 통과 */ +.b05-routeedit__reset-band { + position: absolute; + z-index: 2; + top: calc(var(--spacing-16) + 34px + var(--spacing-8)); + left: 50%; + width: max-content; + max-width: calc(100% - 2 * var(--spacing-16)); + padding: var(--spacing-4) var(--spacing-12); + transform: translateX(-50%); + border: 1px solid var(--color-warning); + border-radius: var(--radius-8, 6px); + background: color-mix(in srgb, var(--color-warning) 16%, var(--color-surface-raised)); + color: var(--color-text-body); + font-size: var(--text-caption); + pointer-events: none; +} + +.b05-routeedit__reset-band[hidden] { + display: none; +} + /* 지도 위에 얹는 판들 — 아래 정보행을 없애고 여기로 옮겼다(2026-09-12 사용자 지시 ⑩·⑫·⑬). 글판은 **클릭을 통과시킨다** — 지도 조작을 가리면 안 된다(접기 단추만 받음 · 345-5 덧 3). */ .b05-routeedit__hint-body, diff --git a/B05_Profile/logic/B05_Profile_Grade_Carry.ts b/B05_Profile/logic/B05_Profile_Grade_Carry.ts index cb8f6a18b..178d32d3e 100644 --- a/B05_Profile/logic/B05_Profile_Grade_Carry.ts +++ b/B05_Profile/logic/B05_Profile_Grade_Carry.ts @@ -5,6 +5,8 @@ * 짝 = 파이썬 `B05_Profile_Engine_Grade_Carry.py`(서버 정본 · 같은 규칙) · 거울 시험 = * resources/tester/test_400_profile_carry.py · test_344_2_route_pairs.py. * - `changedSpan` = 옛 · 새 정점열의 같은 앞머리 · 꼬리를 뺀 가운데 — 「노선 그대로?」 · 묶음 범위만. + * - `routeShapeChanged` = 노선 모양 바뀜(492 — [저장] = 초기화) · 짝 = 파이썬 `route_shape_changed` · + * 거울 시험 = resources/tester/test_283_route_reset_confirm.py. * - 같은 자리(`lineMatcher`) = 새 선 점을 옛 선에 투영 · 옆 `SAME_LINE_TOL_M` 안 · 방향 30° 안 * (측점 스냅이 뒤 교점을 밀어도 이어받음). * - 자동 꼭짓점(`carryBase`) · 편집(`carryEdits`)은 **새 측점 위에** 다시 놓음 — 같은 자리면 옛 선 그 자리 @@ -85,6 +87,11 @@ export function changedSpan( }; } +/** 노선 모양이 바뀌었나(492) — 저장 선 대 새 선 정점 1 mm(교점 · R · 곡선 켬끔이 선에 담김 · 스냅 · 간격은 + * 선 안 바꿈) · 파이썬 `route_shape_changed` 짝. */ +export const routeShapeChanged = (oldXy: ReadonlyArray, newXy: ReadonlyArray): boolean => + changedSpan(oldXy, newXy) !== null; + /** `np.interp` — 오름차순 xs · 범위 밖은 양 끝값. */ function interp(xs: number[], ys: number[], value: number): number { if (value <= xs[0]) return ys[0]; diff --git a/B05_Profile/logic/B05_Profile_Route_Reset.ts b/B05_Profile/logic/B05_Profile_Route_Reset.ts new file mode 100644 index 000000000..766b9db78 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_Route_Reset.ts @@ -0,0 +1,77 @@ +/* ============================================================================= + * B05_Profile/logic/B05_Profile_Route_Reset.ts + * 노선 모양 바뀐 [저장] = 초기화(요구 283 · 284 · 285 · 492-2) — 브라우저 판정 · 띠 글 · 확인 창 글 · 저장 뒤 알림 글. + * + * 판정 = 저장 노선(그린 선) 대 초안이 그릴 선(`draftLine` · 서버가 쓰는 자릿수) — `routeShapeChanged` + * (짝 = 파이썬 `B05_Profile_Engine_Grade_Carry.route_shape_changed` · 정점 1 mm · 거울 시험 + * resources/tester/test_283_route_reset_confirm.py). 스냅 켬끔 · 간격만 = 선 그대로 = 초기화 아님. + * 정본은 서버 판정(응답 `route_reset`) — 여기는 저장 전에 무엇이 지워지는지 알리는 몫. + * ========================================================================== */ + +import type { RouteDraft } from "../../A00_Common/design/logic/design_pipeline_types"; +import { routeShapeChanged } from "./B05_Profile_Grade_Carry"; +import { draftLine } from "./B05_Profile_Pipeline_RouteGround"; + +type Vertex = [number, number]; + +/** 지금 캐시에서 지워질 수 — 사용자 구조물 · 관 · 추가 측점 · 종단 편집. */ +export interface RouteResetCounts { + structures: number; + pipes: number; + plusStations: number; + profileEdits: number; +} + +/** 서버 응답 `route_reset`(sub4 약속) — 초기화했나 · 지운 수. */ +export interface RouteReset { + reset: boolean; + dropped?: { structures?: number; pipes?: number; plus_stations?: number }; +} + +export const ROUTE_RESET_BAND = + "노선이 바뀌어 [저장] 하면 초기화 — 구조물 · 관 · 추가 측점 · 종단 · 횡단 손 입력이 지워지고 새 노선으로 다시 셈"; + +const xy = (line: ReadonlyArray) => line.map(([x, y]) => ({ x, y })); + +/** 초안이 저장 노선 모양을 바꾸나 — 초안 없음 = 아님 · 저장 선을 모름 = 바뀜으로 봄(묻는 쪽이 안전). */ +export function draftShapeChanged( + savedLine: ReadonlyArray | null, + draft: RouteDraft | null, + minRadiusM: number, +): boolean { + if (!draft?.nodes || draft.nodes.length < 2) return false; + if (!savedLine || savedLine.length < 2) return true; + return routeShapeChanged(xy(savedLine), xy(draftLine(draft, minRadiusM).line)); +} + +/** 확인 창 글 — [저장](노선 고침) · [적용](추천 노선) 같은 글 · 수. */ +export function routeResetConfirmText(counts: RouteResetCounts, action: "저장" | "적용"): string { + const head = action === "저장" ? "노선이 바뀌어 [저장]하면" : "추천 노선을 [적용]하면"; + return ( + `${head} 초기화합니다 — 구조물 ${counts.structures} · 관 ${counts.pipes} · ` + + `추가 측점 ${counts.plusStations} · 종단 편집 ${counts.profileEdits} · 횡단 손 입력이 지워지고 ` + + `새 노선으로 다시 셉니다(세류선 교차 관 · 측점 · 초기 종단 · 횡단). ${action}할까요?` + ); +} + +/** 모양 바뀐 초안이면 묻고 답(지워질 수는 물을 때만 셈) · 안 바뀌면 묻지 않고 true. */ +export async function askRouteReset( + changed: boolean, + counts: () => Promise, + ask: (text: string) => Promise, +): Promise { + return !changed || ask(routeResetConfirmText(await counts(), "저장")); +} + +/** 저장 뒤 알림 한 줄 — 초기화가 아니면 null. */ +export function routeResetNotice( + reset: RouteReset | null | undefined, + crossing = 0, +): string | null { + if (!reset?.reset) return null; + const dropped = reset.dropped ?? {}; + return ( + `노선이 바뀌어 초기화했습니다 — 지움: 구조물 ${dropped.structures ?? 0} · 관 ${dropped.pipes ?? 0} · ` + + `추가 측점 ${dropped.plus_stations ?? 0} · 새 교차 관 ${crossing}` + ); +} diff --git a/B06_Section/B06_Section_Router_DesignSave.py b/B06_Section/B06_Section_Router_DesignSave.py index e29b95a64..031466b3f 100644 --- a/B06_Section/B06_Section_Router_DesignSave.py +++ b/B06_Section/B06_Section_Router_DesignSave.py @@ -8,7 +8,10 @@ (오류 응답 · 측점 맞춤 실패 · 재계산 실패 · 하위 차례 실패) DB 행 · storage 를 시작 전으로 되돌리고 그 차례 이름 · 까닭을 돌려줌(`common_util_save_guard` · 브레인 A). 되돌림마저 실패하면 「복구 확인 필요」. 같은 프로젝트 저장이 겹치면 뒤 요청이 기다림(`waited`). -노선 바뀐 저장(`route` · `carry` 칸 · 6-B) = 맨 앞 노선 저장 → 관 · 구조물 초안 다시 얹기 → +노선 모양 바뀐 저장(492 · 요구 284 · 285) = 맨 앞 노선 저장이 초기화(초기값 다시 뜸) · 뒤 차례는 +페이지 설정만(보낸 시설 · 계획고 · 넘김 칸 안 씀) · 응답 `steps.route.route_reset`. +스냅 · 간격만 바뀐 노선 저장(`route` · `carry` 칸 · 6-B) = 맨 앞 노선 저장 → 관 · 구조물 초안 +다시 얹기 → 위 차례(새 노선) · 횡단 = 옛 노선 세션 칸을 새 측점에 넘긴 patch · 상단측 안 보냄(요구 274) · 늘 [저장](새 노선을 본 뒤 [확정]) · 되돌림 범위에 배수 폴더 · 지표면 확정(설계 지형 바꿈)까지 (`B06_Section_Router_DesignSave_Route`). @@ -132,6 +135,12 @@ 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) @@ -213,9 +222,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 # 노선 저장 = 관 따라감 · 새 교차 관(배수 폴더) - files = guard.snapshot_storage( - root, guard.STORAGE_SCOPE + (guard.PIPE_SCOPE if pipes else ()) - ) + scope = guard.STORAGE_SCOPE + (guard.PIPE_SCOPE if pipes else ()) + # 노선 저장 = 초기화면 초기값 사본도 다시 뜸(492) + files = guard.snapshot_storage(root, scope + (guard.INITIAL_SCOPE if moved 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 f40877474..dcd7c6d8f 100644 --- a/common_util/common_util_save_guard.py +++ b/common_util/common_util_save_guard.py @@ -4,7 +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` · 관 차례 · 노선 저장이면 배수 폴더 · 초기값 + 사본도) 를 떠 두고, 하나라도 실패하면 둘 다 되돌린다. 되돌림 쓰기는 바뀐 것만(시놀로지 동기 — 안 바뀐 파일 · 표는 안 씀). 같은 프로젝트 저장이 겹치면 뒤 요청이 기다림(`project_lock`). """ @@ -34,6 +35,8 @@ STORAGE_SCOPE = ( ) #: 관 · 관 옵션 차례 · [확정](배수유역 다시 셈)이 쓰는 자리. PIPE_SCOPE = ("B04_PreProcess/drainage",) +#: 노선 초기화(추천 [적용] · 모양 바뀐 노선 [저장] · 492)가 다시 뜨는 초기값 사본. +INITIAL_SCOPE = ("initial_snapshot",) #: 노선 아래 표(노선 행이 지워지면 함께 지워짐 · CASCADE). ROUTE_CHILDREN = ("route_points", "route_statistics", "longitudinal_sections", "cross_sections") diff --git a/resources/tester/fixtures/req283_sub8_route.json b/resources/tester/fixtures/req283_sub8_route.json new file mode 100644 index 000000000..78e852dc1 --- /dev/null +++ b/resources/tester/fixtures/req283_sub8_route.json @@ -0,0 +1 @@ +{"source": "검증_sub8 노선 522 → 628(06:23 추천 [적용]) · 관 p35100 · 628 세류선 교차(_base_pipes)", "old_xy": [[208403.2, 367920.751], [208406.493, 367842.927], [208406.584, 367841.877], [208406.766, 367840.839], [208407.039, 367839.82], [208407.4, 367838.83], [208426.785, 367792.171], [208427.231, 367791.218], [208427.759, 367790.308], [208428.364, 367789.448], [208429.042, 367788.644], [208429.788, 367787.902], [208430.597, 367787.229], [208431.46, 367786.628], [208432.374, 367786.106], [208433.329, 367785.666], [208434.32, 367785.312], [208435.337, 367785.045], [208436.374, 367784.868], [208437.423, 367784.783], [208438.475, 367784.791], [208470.275, 367786.403], [208471.869, 367786.591], [208509.162, 367793.551], [208510.405, 367793.853], [208511.61, 367794.285], [208527.502, 367800.964], [208528.918, 367801.457], [208530.384, 367801.77], [208545.311, 367803.993], [208546.441, 367804.106], [208547.577, 367804.113], [208548.709, 367804.012], [208549.826, 367803.804], [208550.919, 367803.492], [208551.977, 367803.078], [208562.658, 367798.301], [208563.629, 367797.813], [208564.552, 367797.239], [208565.419, 367796.583], [208566.224, 367795.852], [208566.959, 367795.051], [208567.618, 367794.187], [208568.197, 367793.267], [208568.69, 367792.298], [208569.093, 367791.288], [208580.182, 367759.4], [208580.695, 367758.155], [208581.345, 367756.974], [208596.551, 367732.686], [208597.148, 367731.82], [208597.818, 367731.009], [208598.557, 367730.26], [208599.359, 367729.578], [208600.217, 367728.97], [208601.125, 367728.439], [208602.077, 367727.989], [208603.064, 367727.625], [208604.079, 367727.349], [208605.114, 367727.162], [208606.162, 367727.067], [208607.214, 367727.064], [208608.263, 367727.153], [208609.299, 367727.334], [208610.316, 367727.605], [208611.305, 367727.964], [208612.259, 367728.408], [208613.17, 367728.934], [208614.032, 367729.537], [208636.983, 367747.167], [208637.799, 367747.853], [208638.55, 367748.609], [208663.722, 367776.282], [208664.672, 367777.47], [208665.464, 367778.769], [208683.251, 367812.571], [208683.531, 367813.075], [208695.416, 367833.247], [208696.103, 367834.286], [208696.894, 367835.248], [208704.226, 367843.275], [208704.978, 367844.029], [208705.794, 367844.713], [208706.668, 367845.322], [208707.592, 367845.851], [208708.559, 367846.296], [208709.562, 367846.654], [208723.457, 367850.923], [208724.678, 367851.229], [208725.925, 367851.405], [208727.183, 367851.45], [208728.439, 367851.363], [208781.48, 367844.874], [208782.666, 367844.788], [208783.854, 367844.82], [208785.033, 367844.97], [208786.192, 367845.235], [208787.319, 367845.614], [208788.403, 367846.102], [208789.434, 367846.696], [208818.053, 367865.124], [208819.073, 367865.859], [208820.01, 367866.697], [208820.855, 367867.628], [208840.689, 367891.945], [208841.387, 367892.728], [208842.151, 367893.448], [208842.975, 367894.097], [208843.852, 367894.673], [208844.776, 367895.169], [208845.74, 367895.583], [208846.737, 367895.911], [208847.758, 367896.151], [208848.797, 367896.301], [208849.845, 367896.359], [208850.893, 367896.326], [208851.935, 367896.201], [208852.962, 367895.986], [208899.132, 367884.174], [208900.214, 367883.95], [208901.311, 367883.826], [208902.416, 367883.804], [208903.517, 367883.883], [208904.607, 367884.064], [208905.676, 367884.343], [208906.714, 367884.72], [208907.714, 367885.191], [208908.665, 367885.751], [208909.562, 367886.397], [208935.018, 367906.582], [208936.107, 367907.351], [208937.274, 367907.994], [208938.506, 367908.504], [208966.859, 367918.437], [208967.875, 367918.744], [208968.915, 367918.959], [208969.97, 367919.081], [208971.031, 367919.11], [208972.091, 367919.045], [208973.141, 367918.887], [208974.173, 367918.636], [208975.178, 367918.295], [208976.15, 367917.867], [208977.08, 367917.354], [208977.96, 367916.761], [208978.786, 367916.093], [208979.548, 367915.354], [208980.243, 367914.551], [208980.863, 367913.689], [208995.35, 367891.582], [208995.92, 367890.616], [208996.398, 367889.601], [208996.778, 367888.545], [208997.059, 367887.459], [208997.237, 367886.351], [208997.31, 367885.231], [208997.279, 367884.11], [208997.143, 367882.996], [208996.904, 367881.9], [208996.563, 367880.831], [208985.709, 367851.575], [208985.373, 367850.526], [208985.136, 367849.45], [208984.997, 367848.357], [208984.96, 367847.257], [208985.024, 367846.157], [208985.188, 367845.068], [208991.853, 367811.432], [208992.163, 367810.208], [208992.601, 367809.023], [208993.16, 367807.891], [208993.835, 367806.824], [209025.411, 367762.282], [209026.031, 367761.313], [209026.555, 367760.289], [209026.978, 367759.219], [209027.297, 367758.114], [209027.509, 367756.984], [209038.08, 367680.426]], "new_xy": [[208403.2, 367920.751], [208392.389, 367898.256], [208390.061, 367892.708], [208388.308, 367886.953], [208387.147, 367881.049], [208386.591, 367875.058], [208386.644, 367869.042], [208390.562, 367803.475], [208390.965, 367799.641], [208391.714, 367795.86], [208392.8, 367792.161], [208394.217, 367788.576], [208395.951, 367785.134], [208397.99, 367781.862], [208400.315, 367778.788], [208402.909, 367775.936], [208405.75, 367773.33], [208408.814, 367770.992], [208415.645, 367766.253], [208419.158, 367764.072], [208422.874, 367762.259], [208426.755, 367760.833], [208430.761, 367759.809], [208434.85, 367759.198], [208438.98, 367759.005], [208474.814, 367759.158], [208478.733, 367759.357], [208482.616, 367759.917], [208486.43, 367760.835], [208490.143, 367762.102], [208493.723, 367763.707], [208497.139, 367765.637], [208506.202, 367771.326], [208507.896, 367772.286], [208509.67, 367773.089], [208511.509, 367773.729], [208513.399, 367774.2], [208515.323, 367774.498], [208517.266, 367774.622], [208519.212, 367774.571], [208521.146, 367774.343], [208523.052, 367773.943], [208524.913, 367773.371], [208526.715, 367772.635], [208528.444, 367771.738], [208530.085, 367770.69], [208531.624, 367769.497], [208533.05, 367768.171], [208534.35, 367766.721], [208535.514, 367765.16], [208536.532, 367763.501], [208561.618, 367718.145], [208563.502, 367715.071], [208565.655, 367712.178], [208568.057, 367709.489], [208570.691, 367707.026], [208573.534, 367704.808], [208576.565, 367702.854], [208579.758, 367701.179], [208583.088, 367699.797], [208586.529, 367698.718], [208590.052, 367697.951], [208593.63, 367697.503], [208597.234, 367697.377], [208600.834, 367697.574], [208604.403, 367698.092], [208607.91, 367698.928], [208611.329, 367700.074], [208614.632, 367701.521], [208622.075, 367705.189], [208632.336, 367710.871], [208642.007, 367717.506], [208651.001, 367725.034], [208659.234, 367733.386], [208666.633, 367742.486], [208673.13, 367752.251], [208678.664, 367762.592], [208697.859, 367802.985], [208698.748, 367804.661], [208699.78, 367806.254], [208700.947, 367807.75], [208702.241, 367809.138], [208703.652, 367810.407], [208705.168, 367811.548], [208706.779, 367812.553], [208708.471, 367813.412], [208710.231, 367814.12], [208712.047, 367814.671], [208713.905, 367815.061], [208715.789, 367815.286], [208717.686, 367815.346], [208719.581, 367815.24], [208721.459, 367814.969], [208734.12, 367812.567], [208737.762, 367812.043], [208741.436, 367811.846], [208745.114, 367811.976], [208748.764, 367812.433], [208752.36, 367813.214], [208755.872, 367814.312], [208759.272, 367815.718], [208774.731, 367822.933], [208792.38, 367832.429], [208808.835, 367843.871], [208823.883, 367857.108], [208850.213, 367883.076], [208851.496, 367884.233], [208852.877, 367885.27], [208854.345, 367886.181], [208855.889, 367886.957], [208857.495, 367887.591], [208859.152, 367888.081], [208860.846, 367888.42], [208862.563, 367888.607], [208864.29, 367888.639], [208866.014, 367888.518], [208867.719, 367888.243], [208869.393, 367887.817], [208871.023, 367887.244], [208872.595, 367886.527], [208874.096, 367885.673], [208880.549, 367881.612], [208882.799, 367880.331], [208885.155, 367879.256], [208887.597, 367878.395], [208890.106, 367877.755], [208892.662, 367877.34], [208895.244, 367877.155], [208897.833, 367877.2], [208900.408, 367877.476], [208902.948, 367877.979], [208905.433, 367878.707], [208907.843, 367879.652], [208910.16, 367880.809], [208912.365, 367882.167], [208914.44, 367883.716], [208916.369, 367885.444], [208946.121, 367914.58], [208947.605, 367915.91], [208949.201, 367917.103], [208950.897, 367918.15], [208952.679, 367919.043], [208954.533, 367919.774], [208956.444, 367920.338], [208958.398, 367920.731], [208960.379, 367920.949], [208962.371, 367920.99], [208964.359, 367920.855], [208966.328, 367920.544], [208968.261, 367920.06], [208970.144, 367919.407], [208971.961, 367918.589], [208973.699, 367917.614], [208975.344, 367916.488], [208976.882, 367915.221], [208978.302, 367913.823], [208979.593, 367912.304], [208998.735, 367887.643], [208999.765, 367886.178], [209000.65, 367884.623], [209001.385, 367882.991], [209001.961, 367881.297], [209002.375, 367879.555], [209002.621, 367877.782], [209002.699, 367875.994], [209002.607, 367874.207], [209002.345, 367872.436], [209001.918, 367870.698], [209001.327, 367869.008], [208993.435, 367849.459], [208991.686, 367844.469], [208990.408, 367839.338], [208989.612, 367834.11], [208989.306, 367828.832], [208989.491, 367823.547], [208990.167, 367818.303], [208991.327, 367813.144], [208992.961, 367808.116], [208995.056, 367803.261], [208997.593, 367798.621], [209000.55, 367794.238], [209021.105, 367766.629], [209023.411, 367763.209], [209025.389, 367759.59], [209027.023, 367755.803], [209028.297, 367751.88], [209029.201, 367747.856], [209029.727, 367743.765], [209029.87, 367739.644], [209029.629, 367735.526], [209027.598, 367716.242], [209027.497, 367714.601], [209027.54, 367712.956], [209027.726, 367711.322], [209028.055, 367709.711], [209028.523, 367708.134], [209038.08, 367680.426]], "old_pipe": {"chainage_m": 351.0, "source": "stream", "options": {"pipe_kind": "파형강관", "pipe_diameter_mm": "1000", "inlet_type": "집수정", "inlet_basin_length_m": 2, "inlet_basin_before_m": 1, "inlet_basin_after_m": 1, "inlet_revet_form": "돌쌓기(찰)", "inlet_revet_length_m": 10, "inlet_revet_before_m": 5, "inlet_revet_after_m": 5, "outlet_type": "기슭막이", "outlet_revet_form": "돌쌓기(찰)", "outlet_revet_length_m": 10, "outlet_revet_before_m": 5, "outlet_revet_after_m": 5, "inlet_structure": "L", "inlet_revet_height_m": 1.7, "outlet_revet_height_m": 1.5, "inlet_basin_height_m": 1.5}, "x": 208597.117, "y": 367731.865, "id": "p35100"}, "crossings": [{"chainage_m": 388.1267, "x": 208592.7498, "y": 367697.6132}]} \ No newline at end of file diff --git a/resources/tester/helper_283_route_reset_confirm.cjs b/resources/tester/helper_283_route_reset_confirm.cjs new file mode 100644 index 000000000..71dc181a3 --- /dev/null +++ b/resources/tester/helper_283_route_reset_confirm.cjs @@ -0,0 +1,108 @@ +/* 283 노선 모양 바뀐 [저장] = 초기화 — 진짜 TS(`logic/B05_Profile_Route_Reset` · `logic/B05_Profile_Grade_Carry` + * routeShapeChanged · `logic/B05_Profile_Pipeline_RouteGround` draftLine)를 Node 로 돌림. + * 입력 argv[2] JSON · 표준출력 JSON 한 벌. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +function loadTs(file, stubs = {}) { + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (stubs[request]) return stubs[request]; + throw new Error(`stub 없음: ${request} (${file})`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const straightJudge = loadTs("B05_Profile/logic/B05_Profile_Profile_Straight.ts"); +const curveLaw = loadTs("B05_Profile/logic/B05_Profile_Profile_CurveLaw.ts"); +const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts", { + "./B05_Profile_Profile_Straight": straightJudge, +}); +const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", { + "./B05_Profile_UI_Profile_Pivot": pivot, + "./B05_Profile_Profile_Straight": straightJudge, + "./B05_Profile_Profile_CurveLaw": curveLaw, +}); +const carry = loadTs("B05_Profile/logic/B05_Profile_Grade_Carry.ts", { + "./B05_Profile_UI_Profile_Alignment": alignment, +}); +const curve = loadTs("B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts"); +const edits = loadTs("B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits.ts", { + "./B05_Profile_UI_RouteEdit_Curve": curve, +}); +const routeGround = loadTs("B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts", { + "../../common_util/common_util_surface_sampler": { heightAt: () => null }, + "./B05_Profile_UI_RouteEdit_Curve": curve, + "./B05_Profile_UI_RouteEdit_Edits": edits, + "./B05_Profile_Sections_Ground": {}, +}); +const reset = loadTs("B05_Profile/logic/B05_Profile_Route_Reset.ts", { + "./B05_Profile_Grade_Carry": carry, + "./B05_Profile_Pipeline_RouteGround": routeGround, +}); + +const xy = (line) => line.map(([x, y]) => ({ x, y })); + +async function main() { + const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); + const minR = input.min_radius_m; + const base = input.base; + const saved = routeGround.draftLine(base, minR).line; + const lines = {}; + const changed = {}; + for (const [name, draft] of Object.entries(input.drafts)) { + lines[name] = routeGround.draftLine(draft, minR).line; + changed[name] = reset.draftShapeChanged(saved, draft, minR); + } + const pairs = input.pairs.map(([a, b]) => carry.routeShapeChanged(xy(a), xy(b))); + + // 확인 창 줄 — 안 바뀜 = 묻지 않음 · [취소] = false · [저장] = true + const counts = input.counts; + const gate = []; + for (const [isChanged, answer] of [ + [false, false], + [true, false], + [true, true], + ]) { + const asked = []; + let counted = 0; + const ok = await reset.askRouteReset( + isChanged, + async () => (counted++, counts), + async (text) => (asked.push(text), answer), + ); + gate.push({ changed: isChanged, answer, ok, asked, counted }); + } + + process.stdout.write( + JSON.stringify({ + saved, + lines, + changed, + nullDraft: reset.draftShapeChanged(saved, null, minR), + unknownSaved: reset.draftShapeChanged(null, base, minR), + pairs, + gate, + band: reset.ROUTE_RESET_BAND, + applyText: reset.routeResetConfirmText(counts, "적용"), + notice: reset.routeResetNotice(input.reset, 2), + noticeNone: [ + reset.routeResetNotice(undefined), + reset.routeResetNotice({ reset: false, dropped: { pipes: 3 } }), + ], + }), + ); +} + +main().catch((error) => { + process.stderr.write(String(error?.stack ?? error)); + process.exit(1); +}); diff --git a/resources/tester/helper_442_2_page_settings.cjs b/resources/tester/helper_442_2_page_settings.cjs index d816d89e1..e4b9bf8cd 100644 --- a/resources/tester/helper_442_2_page_settings.cjs +++ b/resources/tester/helper_442_2_page_settings.cjs @@ -64,6 +64,8 @@ async function run() { return { waited: false, steps: { route: { route_id: 9 } } }; }, }, + // 492-2 노선 모양 바뀜 확인 창 — 이 시험은 묻지 않음(test_283_route_reset_confirm) + "../../B05_Profile/B05_Profile_UI_RouteReset": { confirmRouteReset: async () => true }, "../../B05_Profile/B05_Profile_UI_RouteEdit_Apply": { pendingRouteSave: async () => ({ draft: 1 }), routeCarryPart: () => ({ carry: {}, cross: { body: {} } }), diff --git a/resources/tester/helper_472_5r_route_save.cjs b/resources/tester/helper_472_5r_route_save.cjs index bf15cbdb4..a77f38f36 100644 --- a/resources/tester/helper_472_5r_route_save.cjs +++ b/resources/tester/helper_472_5r_route_save.cjs @@ -37,6 +37,8 @@ async function once(newRouteId, fail) { return { waited: false, steps: { route: { route_id: newRouteId } } }; }, }, + // 492-2 노선 모양 바뀜 확인 창 — 이 시험은 묻지 않음(test_283_route_reset_confirm) + "../../B05_Profile/B05_Profile_UI_RouteReset": { confirmRouteReset: async () => true }, "../../B05_Profile/B05_Profile_UI_RouteEdit_Apply": { pendingRouteSave: async () => ({ draft: 1 }), routeCarryPart: () => ({ carry: {}, cross: { body: {} } }), diff --git a/resources/tester/helper_design_store_save.cjs b/resources/tester/helper_design_store_save.cjs index 3733bc8ff..31c8285cd 100644 --- a/resources/tester/helper_design_store_save.cjs +++ b/resources/tester/helper_design_store_save.cjs @@ -84,6 +84,8 @@ const saveApiStub = { }, }; const STUBS = { + // 492-2 노선 모양 바뀜 확인 창 — 이 시험은 묻지 않음(test_283_route_reset_confirm) + "../../B05_Profile/B05_Profile_UI_RouteReset": { confirmRouteReset: async () => true }, "../../B05_Profile/B05_Profile_UI_RouteEdit_Apply": routeStub, "@ui/ui_template_elements": { showToast: (text, kind) => calls.push(`toast:${kind}`), @@ -141,9 +143,7 @@ function loadTs(file) { const { useDesignStore } = loadTs(path.join(ROOT, "A00_Common", "design", "design_store.ts")); const edit = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Edit.ts")); -const { saveDesign } = loadTs( - path.join(ROOT, "A00_Common", "design", "design_save.ts"), -); +const { saveDesign } = loadTs(path.join(ROOT, "A00_Common", "design", "design_save.ts")); async function run() { const out = {}; diff --git a/resources/tester/helper_temp_storage.py b/resources/tester/helper_temp_storage.py new file mode 100644 index 000000000..8de2ce5ba --- /dev/null +++ b/resources/tester/helper_temp_storage.py @@ -0,0 +1,74 @@ +"""공유 storage 를 쓰는 실DB 시험 → 프로젝트 폴더를 임시 storage 사본으로(492 · 요구 283). + +storage 는 Synology 로 데스크탑까지 번지므로 시험이 쓰는 자리는 사본이어야 함. 프로젝트 폴더를 +`<임시>/storage/<같은 상대 경로>` 로 베끼고 `common_util_storage.STORAGE_BASE_DIR` 를 그 뿌리로 바꿈 +— DB 의 저장 경로(`storage/…`)가 그대로 사본을 가리킴. 큰 원본 입력(파일 입력 · 전처리 산출)은 +안 베끼고 정션(읽기만 · 저장 차례가 안 씀). +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile +from contextlib import contextmanager +from pathlib import Path + +import pytest + +#: 저장 차례가 안 쓰는 큰 입력 — 정션으로 이음 +_LINKED = ("B03_FileInput", "B04_PreProcess/processed") + + +def _junction(target: Path, source: Path) -> None: + subprocess.run( + ["cmd", "/c", "mklink", "/J", str(target), str(source)], + check=True, + capture_output=True, + ) + + +def _copy(source: Path, target: Path, relative: str = "") -> None: + target.mkdir(parents=True, exist_ok=True) + for entry in source.iterdir(): + name = f"{relative}/{entry.name}" if relative else entry.name + if name in _LINKED and entry.is_dir(): + _junction(target / entry.name, entry) + elif entry.is_dir(): + if any(link.startswith(f"{name}/") for link in _LINKED): + _copy(entry, target / entry.name, name) + else: + shutil.copytree(entry, target / entry.name) + else: + shutil.copy2(entry, target / entry.name) + + +def _unlink_junctions(root: Path) -> None: + for link in _LINKED: + path = root / link + if path.exists() or os.path.islink(path): + os.rmdir(path) # 정션만 걷음(원본 안 지움) + + +@contextmanager +def temp_project_storage(shared_root: Path): + """공유 프로젝트 폴더 → 사본 뿌리(yield) · 끝나면 STORAGE_BASE_DIR 되돌리고 사본 지움.""" + from common_util import common_util_storage as storage + + base = Path(os.path.realpath(storage.STORAGE_BASE_DIR)) + relative = Path(os.path.realpath(shared_root)).relative_to(base) + work = Path(tempfile.mkdtemp(prefix="aislo_storage_")) + copy_root = work / "storage" / relative + _copy(Path(shared_root), copy_root) + patch = pytest.MonkeyPatch() + patch.setattr(storage, "STORAGE_BASE_DIR", str(work / "storage")) + try: + # DB 경로가 사본을 가리키는지 — 아니면 공유 storage 를 쓰게 되므로 시작 전 멈춤 + resolved = storage.resolve_stored_project_path(f"storage/{relative.as_posix()}") + assert Path(resolved) == Path(os.path.realpath(copy_root)), resolved + yield copy_root + finally: + patch.undo() + _unlink_junctions(copy_root) + shutil.rmtree(work, ignore_errors=True) diff --git a/resources/tester/test_283_route_change_reset.py b/resources/tester/test_283_route_change_reset.py new file mode 100644 index 000000000..07d2dbfe6 --- /dev/null +++ b/resources/tester/test_283_route_change_reset.py @@ -0,0 +1,337 @@ +# -*- coding: utf-8 -*- +"""492-1 · 요구 283 · 284 · 285 — 노선 모양이 바뀐 [저장] · 추천 [적용] = 초기화 + 초기값 다시 뜸. + + ① 교점 끌기 [저장] — 판정(저장 · 새 계획 폴리라인) = 초기화 · 초기값 다시 뜸 · 체인 = 구조물 0 · + 추가 측점 0 · 관 = 새 교차만(stream · 옵션 없음 · 옆 0 m) · 계획고 옮김 없음 · 횡단 손 칸 0 + ② 추천 [적용] — 같은 초기화(균형 종단 갈래도) + ③ 스냅 · 측점 간격만(선 그대로) — 초기화 아님 · 시설 따라감 · 초기값 그대로 + ④ 검증_sub8 자료(노선 522 → 628 · 관 p35100) — 관 = 새 교차 388 하나(옛 = 388 + 394 · 34.44 m 옆) + ⑤ 한 본문 노선 초기화 — 보낸 관 · 구조물 · 추가 측점 · 계획고 · 관 옵션 · 넘김 칸 안 씀 + ⑥ 한 본문 실패 — 초기값 사본(`initial_snapshot`)까지 되돌림 +옛 코드(492 전) = ① ② ④ ⑤ ⑥ 실패. + +체인 · API 는 346-3 가짜 세계(`chain_world` · `api_world`)를 빌려 씀 — 파일은 임시 폴더만. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_283_route_change_reset.py -q +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path +from types import SimpleNamespace +from uuid import uuid4 + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +for path in (ROOT, HERE): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +import test_346_3_route_save as base # noqa: E402 +from test_346_3_route_save import _async, api_world, chain_world # noqa: E402, F401 + +import B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze as analyze # noqa: E402 +import B05_Profile.B05_Profile_Route_Save as save # noqa: E402 +import B05_Profile.B05_Profile_Router_Replan as replan # noqa: E402 +import B06_Section.B06_Section_Repository as b06_repo # noqa: E402 +from B05_Profile.B05_Profile_Plus_Stations import load_plus_stations, save_plus_stations # noqa: E402 +from B05_Profile.B05_Profile_Structures_Repository import load_structures # noqa: E402 +from common_util.common_util_json import atomic_write_json # noqa: E402 + +FIXTURE = HERE / "fixtures" / "req283_sub8_route.json" +WALL = { + "structure_id": "wall", + "type_id": "retaining_wall", + "placement": "interval", + "chainage_m": 250.0, + "start_m": 245.0, + "end_m": 255.0, + "options": {"wall_form": "콘크리트", "length_m": 10, "height_m": 2}, + "memo": "", + "placement_source": "manual", + "status": "draft", + "revision": 0, + "geometry": None, + "summary_item": None, + "group_items": None, + "x": 250.0, + "y": 0.0, +} +# 새 노선(NEW) 100 ~ 200 m 토막 위 세류선 교차 — 옛 노선 밖 +CROSSING = SimpleNamespace(chainage_m=158.31, x=150.0, y=30.0) + + +def _side(xy, x: float, y: float) -> float: + from shapely.geometry import LineString, Point + + return LineString(xy).distance(Point(x, y)) + + +def _seed(world, pipe_options: dict | None = None) -> None: + """사용자 시설 — 관(옵션) · 구조물 · 추가 측점 · 옛 횡단 손 칸.""" + world.pipes_file.write_text( + json.dumps( + { + "route_signature": "old", + "points": [ + { + "chainage_m": 50.0, + "x": 50.0, + "y": 0.0, + "source": "user", + "options": pipe_options or {"pipe_kind": "흄관"}, + } + ], + } + ), + "utf-8", + ) + atomic_write_json( + world.root / "B05_Profile" / "route" / "structures.json", + {"revision": 1, "structures": [WALL], "definition_version": None}, + ) + save_plus_stations(world.root, [350], base.OLD) + + +@pytest.fixture +def crossings(monkeypatch): + """세류선 교차 = 고정 목록(도엽 없이).""" + found = [CROSSING] + monkeypatch.setattr(analyze, "_base_pipes", lambda vertices, streams: list(found)) + return found + + +@pytest.fixture +def cross_seen(monkeypatch): + seen: list = [] + + async def carry(*args): + seen.append(args[5]) # 옛 횡단 설계 — 넘길 손 입력 + return 0 + + monkeypatch.setattr(save, "_carry_cross", carry) + monkeypatch.setattr( + b06_repo, + "get_cross_section_designs", + _async([{"chainage_m": 50.0, "design": {"inlet_basin_length_m": 3}}]), + ) + return seen + + +def _assert_reset(world, result: dict, cross_seen: list) -> None: + assert "error" not in result, result + assert result["route_reset"] == { + "reset": True, + "dropped": {"structures": 1, "pipes": 1, "plus_stations": 1}, + } + assert load_structures(str(world.root))[1] == [] + assert load_plus_stations(world.root) == [] + points = json.loads(world.pipes_file.read_text("utf-8"))["points"] + assert len(points) == 1, points # 옛 사용자 관 버림 · 새 교차 관만 + assert points[0]["source"] == "stream" and not points[0].get("options") + assert _side(base.NEW, points[0]["x"], points[0]["y"]) < 1e-6 + assert "profile" not in world.order # 계획고 옮김 없음 = 1차 계획선 + 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: + calls: list = [] + + async def recompute( + project_id, root, stored, balanced=None, terrain=None, lines=None, initial=False + ): + calls.append(("recompute", initial, balanced is not None)) + return {"route_id": 9, "total_length_m": 10.0} + + monkeypatch.setattr(replan, "_recompute", recompute) + monkeypatch.setattr(replan, "_planned_line", lambda root: old_line) + monkeypatch.setattr(replan, "_vertices_of", lambda path: new_line) + 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_교점_끌기_체인은_사용자_시설_버림(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 + + +# ── ② 추천 [적용] ───────────────────────────────────────────────────────────── + + +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]] + calls = _replan_world(monkeypatch, same, same) + result = asyncio.run( + replan.replan_route(uuid4(), base._body(as_initial=True, balanced_profile=True)) + ) + assert calls == [("recompute", True, True)] and result["initial_refreshed"] is True + _seed(chain_world) + monkeypatch.setattr(route_initial, "apply_profile_target", _async(None)) + chained = base._run(chain_world, initial=True, profile_target=([0.0, 456.0], [1.0, 2.0])) + _assert_reset(chain_world, chained, cross_seen) + + +# ── ③ 스냅 · 간격만 ─────────────────────────────────────────────────────────── + + +def test_스냅만_바꾼_저장은_초기화_아님(api_world, monkeypatch): + same = [[0.0, 0.0], [5.0, 5.0]] + calls = _replan_world(monkeypatch, same, [list(p) for p in same]) + body = base._body(station_snap=True, station_snap_step=10.0) + result = asyncio.run(replan.replan_route(uuid4(), body)) + assert calls == [("recompute", False, False)] + 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 자료 ───────────────────────────────────────────────────────── + + +@pytest.fixture +def sub8(monkeypatch): + data = json.loads(FIXTURE.read_text("utf-8")) + monkeypatch.setattr(base, "OLD", [tuple(p) for p in data["old_xy"]]) + monkeypatch.setattr(base, "NEW", [tuple(p) for p in data["new_xy"]]) + found = [SimpleNamespace(**c) for c in data["crossings"]] + monkeypatch.setattr(analyze, "_base_pipes", lambda vertices, streams: list(found)) + 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" + ) + 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 not points[0].get("options") + assert result["route_reset"]["dropped"]["pipes"] == 1 + + +# ── ⑤ · ⑥ 한 본문 ────────────────────────────────────────────────────────────── + + +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 + import B05_Profile.B05_Profile_Structures_Router as structures_router + import B06_Section.B06_Section_Router_Confirm as confirm + import B06_Section.B06_Section_Router_DesignSave as design_save + import B06_Section.B06_Section_Router_DesignSave_Route as moving + import B06_Section.B06_Section_Router_Stations as stations + + called: list[str] = [] + + def record(name, value=None): + async def fake(*args, **kwargs): + called.append(name) + return {} if value is None else value + + 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(moving, "saved_facilities", record("before")) + monkeypatch.setattr(moving, "rebase_drafts", record("rebase")) + monkeypatch.setattr(moving, "carry_cross", record("carry", {"patches": [], "outside": 0})) + monkeypatch.setattr(lifecycle, "save_page_settings", record("settings")) + monkeypatch.setattr(basins, "put_pipe_points", record("pipes")) + monkeypatch.setattr(basins, "get_pipe_points", record("culvert", {"pipe_points": []})) + monkeypatch.setattr(structures_router, "write_structures", record("structures")) + monkeypatch.setattr(stations, "put_plus_stations", record("plus")) + monkeypatch.setattr(b05_router, "save_profile_alignment", record("alignment")) + monkeypatch.setattr(confirm, "save_sections", record("cross")) + request = design_save.DesignSaveRequest.model_construct( + route_id=7, + confirm=False, + settings=SimpleNamespace(), + pipes={"points": [{"chainage_m": 50.0}]}, + structures=SimpleNamespace(), + plus_stations=SimpleNamespace(), + alignment=SimpleNamespace(model_copy=lambda update: SimpleNamespace(**update)), + culvert_options={"50.00": {"pipe_kind": "흄관"}}, + 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 + + +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 + + 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): + (copy / "pipe_points.json").write_text("new", encoding="utf-8") # 초기값 다시 뜸 + raise StepFailed("route", "뒤 차례 실패") + + 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)) + assert response.status_code == 409 + assert (copy / "pipe_points.json").read_text(encoding="utf-8") == "old" diff --git a/resources/tester/test_283_route_reset_confirm.py b/resources/tester/test_283_route_reset_confirm.py new file mode 100644 index 000000000..1cc5c1d51 --- /dev/null +++ b/resources/tester/test_283_route_reset_confirm.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- +"""283 · 492-2 노선 모양 바뀐 [저장] = 초기화 — 브라우저 판정 · 띠 · 확인 창 · 저장 뒤 알림. + +거울(파이썬 = 서버 정본 판정 · TS = 브라우저 띠 · 확인 창 · 같은 값): + `B05_Profile_Engine_Grade_Carry.route_shape_changed` ↔ `logic/B05_Profile_Grade_Carry.ts` routeShapeChanged. +판정 입력 = 저장 노선(그린 선) 대 초안이 그릴 선(`draftLine`) — 스냅 · 간격 · 지형만 바뀜 = 아님. +[취소] = 요청 0 — `design_save` 가 잠금 막 · 서버 요청보다 먼저 묻는지(소스 차례). +""" + +from __future__ import annotations + +import copy +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B05_Profile import B05_Profile_Engine_Grade_Carry as grade_carry # noqa: E402 + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + +NODES = [[0.0, 0.0], [200.0, 0.0], [400.0, 150.0], [600.0, 150.0], [800.0, 0.0]] + + +def _draft(nodes=NODES, **over) -> dict: + n = len(nodes) + draft = { + "rev": 0, + "nodes": copy.deepcopy(nodes), + "curveOn": [True] * n, + "curveRadius": [None] * n, + "curveLock": [None] * n, + "curveArc": [None] * n, + "stationSnap": False, + "stationSnapStep": None, + "terrain": None, + } + draft.update(over) + return draft + + +def _moved(index: int, dx: float, dy: float = 0.0) -> list: + nodes = copy.deepcopy(NODES) + nodes[index] = [nodes[index][0] + dx, nodes[index][1] + dy] + return nodes + + +BASE = _draft() +SAME = { # 선이 그대로 — 띠 · 창 없음 + "snap_on": _draft(stationSnap=True), + "snap_step": _draft(stationSnap=True, stationSnapStep=10), + "terrain": _draft(terrain="las"), + "rev": _draft(rev=7), + "tiny": _draft(_moved(2, 0.0004)), # 0.4 mm — 1 mm 안 +} +CHANGED = { # 모양 바뀜 — 띠 · 창 + "drag20": _draft(_moved(2, 20.0)), + "radius": _draft(curveRadius=[None, 80.0, None, None, None]), + "add": _draft(NODES[:1] + [[100.0, 30.0]] + NODES[1:]), + "remove": _draft(NODES[:3] + NODES[4:]), + "curve_off": _draft(curveOn=[True, False, True, True, True]), + "end_drag": _draft(_moved(4, 0.0, 5.0)), +} +COUNTS = {"structures": 1, "pipes": 3, "plusStations": 1, "profileEdits": 2} +RESET = {"reset": True, "dropped": {"structures": 1, "pipes": 3, "plus_stations": 1}} + + +@pytest.fixture(scope="module") +def ts(tmp_path_factory): + drafts = {**SAME, **CHANGED} + payload = { + "min_radius_m": 30.0, + "base": BASE, + "drafts": drafts, + "counts": COUNTS, + "reset": RESET, + "pairs": [], + } + source = tmp_path_factory.mktemp("s283") / "in.json" + source.write_text(json.dumps(payload), encoding="utf-8") + first = _run(source) + # 거울 짝 — 저장 선 대 각 초안 선 · 같은 선 · 2 mm 흔듦 + saved = first["saved"] + jitter = [[x + (0.002 if i == 3 else 0.0), y] for i, (x, y) in enumerate(saved)] + pairs = [[saved, line] for line in first["lines"].values()] + [[saved, saved], [saved, jitter]] + payload["pairs"] = pairs + source.write_text(json.dumps(payload), encoding="utf-8") + out = _run(source) + out["pair_inputs"] = pairs + return out + + +def _run(source: Path) -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_283_route_reset_confirm.cjs"), str(source)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=False, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +# ── 판정 ───────────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("name", list(SAME)) +def test_스냅_간격_지형만_띠_창_없음(ts, name): + assert ts["changed"][name] is False + + +@pytest.mark.parametrize("name", list(CHANGED)) +def test_모양_바뀐_초안_띠_창(ts, name): + assert ts["changed"][name] is True + + +def test_초안_없음_아님_저장선_모름_바뀜(ts): + assert ts["nullDraft"] is False + assert ts["unknownSaved"] is True + + +def test_거울_파이썬_판정_같은값(ts): + judge = getattr(grade_carry, "route_shape_changed", None) + if judge is None: + pytest.skip("서버 판정 route_shape_changed 아직 없음(492-1 sub4 합침 전)") + for (old, new), got in zip(ts["pair_inputs"], ts["pairs"]): + assert judge(old, new) is got + assert ts["pairs"][-2] is False # 같은 선 + assert ts["pairs"][-1] is True # 2 mm + + +# ── 확인 창 · [취소] ────────────────────────────────────────────────────────── + + +def test_확인창_안바뀜_묻지않음(ts): + gate = ts["gate"][0] + assert gate["ok"] is True and gate["asked"] == [] and gate["counted"] == 0 + + +def test_확인창_수_취소_false(ts): + cancel, accept = ts["gate"][1], ts["gate"][2] + assert cancel["ok"] is False and accept["ok"] is True + for gate in (cancel, accept): + assert len(gate["asked"]) == 1 and gate["counted"] == 1 + text = gate["asked"][0] + assert text.startswith("노선이 바뀌어 [저장]하면 초기화") + for part in ("구조물 1", "관 3", "추가 측점 1", "종단 편집 2", "횡단 손 입력"): + assert part in text + assert text.endswith("저장할까요?") + + +def test_추천_적용_같은글_같은수(ts): + text = ts["applyText"] + assert text.startswith("추천 노선을 [적용]하면 초기화") + tail = ts["gate"][2]["asked"][0].split("초기화", 1)[1].replace("저장할까요?", "") + assert text.split("초기화", 1)[1].replace("적용할까요?", "") == tail + + +def test_취소_요청0_잠금막_서버요청_앞(ts): + src = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text(encoding="utf-8") + body = src[src.index("export async function saveDesign(") :] + ask = body.index("confirmRouteReset(") + assert ask < body.index("showLoadingOverlay()") < body.index("postDesignSave(") + assert "return false" in body[ask : body.index("showLoadingOverlay()")] + + +def test_띠_글(ts): + assert ts["band"].startswith("노선이 바뀌어 [저장] 하면 초기화") + for part in ("구조물", "관", "추가 측점", "종단", "횡단 손 입력"): + assert part in ts["band"] + + +# ── 저장 뒤 알림 ───────────────────────────────────────────────────────────── + + +def test_저장뒤_알림_dropped(ts): + assert ts["notice"] == ( + "노선이 바뀌어 초기화했습니다 — 지움: 구조물 1 · 관 3 · 추가 측점 1 · 새 교차 관 2" + ) + assert ts["noticeNone"] == [None, None] diff --git a/resources/tester/test_346_3_route_save.py b/resources/tester/test_346_3_route_save.py index c7a050b63..c11c404a0 100644 --- a/resources/tester/test_346_3_route_save.py +++ b/resources/tester/test_346_3_route_save.py @@ -2,6 +2,8 @@ """346-3 노선 저장 서버 길 — 차례 · 관 지킴 · 새 교차점 기본 관 · 보충 없음(369) · 이월 열쇠 · 초기값 규칙 · 배수유역 낡음 · 실패 때 옛 노선 되살림. +관 지킴 · 따라감 = 초기화 아닌 저장(스냅 · 간격만) 몫 — 모양 바뀐 저장 · 추천 [적용] = 초기화(492 · +`test_283_route_change_reset`). 순수 부품(`B05_Profile_Route_Save_Parts`)은 값으로 · 체인(`run_route_save_chain`)과 API (`replan_route`)는 DB · 엔진을 가짜로 바꿔 차례 · 되살림 · 초기값만 잰다. @@ -480,7 +482,8 @@ def _body(**kwargs): return save.RouteSaveRequest(vertices=[{"x": 0, "y": 0}, {"x": 5, "y": 5}], **kwargs) -def test_손으로_고친_노선_저장은_초기값_그대로(api_world): +def test_모양_그대로_노선_저장은_초기값_그대로(api_world): + # 492 — 저장 선 = 새 선(스냅 · 간격만) · 모양 바뀐 저장 = test_283_route_change_reset result = asyncio.run(replan.replan_route(uuid4(), _body())) assert result["initial_refreshed"] is False and api_world == [] @@ -490,13 +493,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)) + calls.append((balanced, lines, initial)) return {"route_id": 9, "total_length_m": 10.0} monkeypatch.setattr(replan, "_recompute", recompute) @@ -504,7 +507,8 @@ def test_균형_종단은_추천_적용만_손_저장은_계획고_옮김(api_wo 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))) - assert calls[0] == (None, ([[0.0, 0.0], [4.0, 4.0]], [[0.0, 0.0], [5.0, 5.0]])) + # 손으로 모양 바꾼 노선 = 초기화(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[1][0] is not None # 추천 [적용] = 균형 종단 그대로 diff --git a/resources/tester/test_472_6_design_save.py b/resources/tester/test_472_6_design_save.py index d2d444d9f..63c3634d8 100644 --- a/resources/tester/test_472_6_design_save.py +++ b/resources/tester/test_472_6_design_save.py @@ -4,7 +4,8 @@ ② 실제 창구(검증_sub8 · DB · storage) — 중간 차례 실패 → DB 7 표 행 · storage 해시 시작 그대로 · 차례 이름 · 까닭 · restored. 같은 본문 정상 = 옛 차례(안쪽 창구 차례로 부름)와 행 · 파일 같음. 검증 프로젝트가 없는 자리(다른 PC · DB 없음)는 ② 건너뜀. -② 는 살아 있는 검증_sub8 사용 · 노선 바뀌어도 됨(최신 노선 id · 추가 측점 자리를 그때 읽음). +② 는 살아 있는 검증_sub8 DB · storage 는 임시 사본(492 · `helper_temp_storage`) · 노선 바뀌어도 됨 +(최신 노선 id · 추가 측점 자리를 그때 읽음). ./venv/Scripts/python.exe -m pytest resources/tester/test_472_6_design_save.py -q """ @@ -23,6 +24,7 @@ import pytest HERE = Path(__file__).resolve().parent ROOT = HERE.parents[1] sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(HERE)) from common_util import common_util_save_guard as guard # noqa: E402 @@ -77,8 +79,27 @@ def _db_ready() -> Path | None: return None -ROOT_DIR = _db_ready() -live = pytest.mark.skipif(ROOT_DIR is None, reason="검증_sub8 · DB 없음") +SHARED_ROOT = _db_ready() +#: 시험이 쓰는 뿌리 = 공유 storage 의 임시 사본(492 · `sandbox` 가 세움 · Synology 로 안 번지게) +ROOT_DIR: Path | None = None + + +@pytest.fixture(scope="module") +def sandbox(): + global ROOT_DIR + from helper_temp_storage import temp_project_storage + + with temp_project_storage(SHARED_ROOT) as root: + ROOT_DIR = root + yield root + ROOT_DIR = None + + +def live(test): + """실DB · 사본 storage 시험 — 검증 프로젝트 없으면 건너뜀.""" + return pytest.mark.skipif(SHARED_ROOT is None, reason="검증_sub8 · DB 없음")( + pytest.mark.usefixtures("sandbox")(test) + ) def _state(root: Path) -> tuple[dict, dict]: diff --git a/resources/tester/test_472_6_route_save.py b/resources/tester/test_472_6_route_save.py index 14e732180..9f668099f 100644 --- a/resources/tester/test_472_6_route_save.py +++ b/resources/tester/test_472_6_route_save.py @@ -6,9 +6,10 @@ ② 다시 얹기 — 관 · 구조물 초안(`rebasePipeDraft` · `rebaseStructureDraft`) == Node `facility.rebase`. ③ 옛 코드 실패 — 한 본문이 `route` · `carry` 칸을 받음 · 브라우저가 노선 저장 창구를 안 부름. - ④ 실제 창구(검증_sub8 · DB · storage) — 노선 차례 뒤 실패(시험 갈래 · 넘김 실패) = 노선 행 · - 자식 표 · storage 그대로 · 정상 한 본문 = 옛 차례(노선 저장 → 다시 얹기 → 횡단 저장)와 - 행 · 파일 같음. + ④ 실제 창구(검증_sub8 DB · storage 는 임시 사본 · 492) — 노선 차례 뒤 실패(시험 갈래 · 넘김 실패) + = 노선 행 · 자식 표 · storage(초기값 사본 포함) 그대로 · 정상 한 본문(스냅만 바꾼 노선 = + 따라감) = 옛 차례(노선 저장 → 다시 얹기 → 횡단 저장)와 행 · 파일 같음 · 모양 바뀐 노선 = + 초기화(노선 차례만 · `route_reset`). 검증 프로젝트가 없는 자리(다른 PC · DB 없음)는 ④ 건너뜀. ./venv/Scripts/python.exe -m pytest resources/tester/test_472_6_route_save.py -q @@ -30,6 +31,7 @@ import pytest HERE = Path(__file__).resolve().parent ROOT = HERE.parents[1] sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(HERE)) from common_util import common_util_save_guard as guard # noqa: E402 from common_util.common_util_calc_runner import run_calc # noqa: E402 @@ -175,8 +177,27 @@ def _db_ready() -> Path | None: return None -ROOT_DIR = _db_ready() -live = pytest.mark.skipif(ROOT_DIR is None, reason="검증_sub8 · DB 없음") +SHARED_ROOT = _db_ready() +#: 시험이 쓰는 뿌리 = 공유 storage 의 임시 사본(492 · `sandbox` 가 세움 · Synology 로 안 번지게) +ROOT_DIR: Path | None = None + + +@pytest.fixture(scope="module") +def sandbox(): + global ROOT_DIR + from helper_temp_storage import temp_project_storage + + with temp_project_storage(SHARED_ROOT) as root: + ROOT_DIR = root + yield root + ROOT_DIR = None + + +def live(test): + """실DB · 사본 storage 시험 — 검증 프로젝트 없으면 건너뜀.""" + return pytest.mark.skipif(SHARED_ROOT is None, reason="검증_sub8 · DB 없음")( + pytest.mark.usefixtures("sandbox")(test) + ) def _rows() -> dict: @@ -203,14 +224,17 @@ def _state(root: Path) -> tuple[dict, dict]: } files = { relative: hashlib.sha1(path.read_bytes()).hexdigest() - for relative, path in guard._files(root, guard.STORAGE_SCOPE + guard.PIPE_SCOPE).items() + for relative, path in guard._files( + root, guard.STORAGE_SCOPE + guard.PIPE_SCOPE + guard.INITIAL_SCOPE + ).items() if not relative.startswith("_derived/") } return clean, files -def _plan_draft() -> dict: - """지금 계획노선 노드 · 곡선 그대로 + 가운데 노드 하나를 3 m 옆으로(노선 끌기).""" +def _plan_draft(shape: bool = True) -> dict: + """지금 계획노선 노드 · 넣은 편집값 그대로 + `shape` 면 가운데 노드 하나를 3 m 옆으로(노선 끌기 + = 초기화) · 아니면 측점 스냅만 뒤집음(모양 그대로 = 따라감 · 492).""" from B05_Profile.B05_Profile_Router_Replan import read_route_plan from config.config_db import close_db_pool, init_db_pool @@ -222,16 +246,26 @@ def _plan_draft() -> dict: await close_db_pool() plan = asyncio.run(run()) - nodes = plan["nodes"] - at = len(nodes) // 2 - points = [[float(n["x"]), float(n["y"])] for n in nodes] - points[at][1] += 3.0 + from B05_Profile.B05_Profile_Engine_InitialProfile import draft_nodes + + edits = plan.get("node_edits") + if edits: # 넣은 값 그대로(화면 `flattenServerPlan` 이 읽는 것) + points = [[float(n["x"]), float(n["y"])] for n in plan["nodes"]] + flags, radii = [bool(e["curve"]) for e in edits], [e["radius_m"] for e in edits] + else: # 편집값 없는 노선 = 화면 펴기(그린 R) 짝 + xy, flags, radii = draft_nodes(plan["nodes"], plan["curves"]) + points = [[x, y] for x, y in xy] + if shape: + points[len(points) // 2][1] += 3.0 + snap = bool(plan.get("station_snap")) return { "rev": 1, "nodes": points, - "curveOn": [True] * len(nodes), - "curveRadius": [None] * len(nodes), - "stationSnap": bool(plan.get("station_snap")), + "curveOn": flags, + "curveRadius": radii, + "curveLock": [e.get("lock") for e in edits] if edits else [None] * len(points), + "curveArc": [e.get("arc_m") for e in edits] if edits else [None] * len(points), + "stationSnap": snap if shape else not snap, "stationSnapStep": plan.get("station_snap_step"), } @@ -286,7 +320,7 @@ def test_넘김_실패면_노선까지_그대로(monkeypatch) -> None: return run_calc(ids, inputs) monkeypatch.setattr(moving, "run_calc", broken) - status, body = _save(_body(_plan_draft())) + status, body = _save(_body(_plan_draft(shape=False))) # 넘김 = 모양 그대로 노선만 assert status == 409 and body["step"] == "cross" and body["restored"] is True, body assert "넘김 일부러 실패" in body["message"] assert _state(ROOT_DIR) == before @@ -319,7 +353,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() + draft = _plan_draft(shape=False) old_ids = {int(row["id"]) for row in _rows()["routes"]} async def old_way(): @@ -382,6 +416,51 @@ def test_정상_한_본문은_옛_차례와_같음() -> None: assert _state(ROOT_DIR) == start +@live +def test_모양_바뀐_노선은_초기화_노선_차례만() -> None: + """492 — 교점 3 m 끌기 = 서버 초기화 · 보낸 넘김 칸 안 씀 · 초기값 = 작업본 관 · 끝에 되돌림.""" + from config.config_db import close_db_pool, get_db_pool, init_db_pool + + start = _state(ROOT_DIR) + + async def hold(): + await init_db_pool() + try: + async with get_db_pool().acquire() as connection: + return await guard.snapshot_rows(connection, PROJECT, surface=True) + finally: + await close_db_pool() + + rows = asyncio.run(hold()) + files = guard.snapshot_storage( + ROOT_DIR, guard.STORAGE_SCOPE + guard.PIPE_SCOPE + guard.INITIAL_SCOPE + ) + 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() # 초기값 = 작업본 + finally: + + async def undo(): + await init_db_pool() + try: + files.restore() + async with get_db_pool().acquire() as connection: + await guard.restore_rows(connection, PROJECT, rows) + finally: + await close_db_pool() + + asyncio.run(undo()) + files.discard() + assert _state(ROOT_DIR) == start + + def test_저장한_노선_초안은_버림으로_되돌리지_않음() -> None: """화면 흠(6-B 화면 검증) — 저장 뒤 노선 초안 칸을 비우면 묶음이 「초안 버림」으로 보고 초안 전 관 · 구조물 초안을 세션에 되써 다음 [저장]이 옛 자리로 되돌렸음 → 저장 성공이면 초안 전 값을 버림(commit).""" diff --git a/resources/tester/test_476_1_apply_initial.py b/resources/tester/test_476_1_apply_initial.py index 755a259d3..304598838 100644 --- a/resources/tester/test_476_1_apply_initial.py +++ b/resources/tester/test_476_1_apply_initial.py @@ -76,7 +76,8 @@ def test_적용_같은_노선_끔_새_1차_계획선(chain_world, monkeypatch): assert not (alignment.get("edits") or {}).get("station_offsets") -def test_손_노선_저장은_계획고_옮김_그대로(chain_world, monkeypatch): +def test_초기화_아닌_저장은_계획고_옮김_그대로(chain_world, monkeypatch): + # 492 — 체인 initial=False = 노선 모양 그대로(스냅 · 간격만) 저장 몫 path, fresh = _real_profile_world(chain_world, monkeypatch) _run(chain_world) alignment = json.loads(path.read_text("utf-8"))["profile_alignment"] @@ -104,7 +105,7 @@ def test_적용은_체인에_initial(api_world, monkeypatch): def test_적용은_횡단_손_입력_버림(chain_world, monkeypatch): - # 요구 277 — 추천 [적용] = 새 노선 기준 초기화(횡단 손 입력 전부 버림) · 손 노선 [저장]은 넘김 + # 요구 277 — 추천 [적용] · 모양 바뀐 [저장](492) = 초기화(횡단 손 입력 전부 버림) · 스냅 · 간격만은 넘김 import B06_Section.B06_Section_Repository as b06_repo seen: list = [] diff --git a/resources/tester/test_476_1_recommend_initial.py b/resources/tester/test_476_1_recommend_initial.py index a8d923301..5baf7e070 100644 --- a/resources/tester/test_476_1_recommend_initial.py +++ b/resources/tester/test_476_1_recommend_initial.py @@ -2,7 +2,8 @@ """476-1 추천 후보 초기 종단 — 미리 셈 = [적용] 체인 종단 · 화면 펴기 짝 · 응답 칸 · 저장본 지문. 고정 자료 = `fixtures/golden_472`(사본) · 체인 쪽 = `run_route_save_chain` 파일 길 차례(노선 csv → -구조물 · 추가 측점 따라감 → 노선 geojson → 관 따라감 · 정본 → `run_section_generation` → +초기화 = 구조물 · 추가 측점 비움(492) → 노선 geojson → 관 = 새 교차만 · 정본 → +`run_section_generation` → 켬이면 `_set_base_pvi` + `_apply_alignment_edits`) · 미리 셈 쪽 = 메모리 조각(파일 안 씀). 화면 펴기 = TS `flattenServerPlan`(Node `helper_476_1_flatten.cjs`) ↔ 파이썬 `draft_nodes`. """ @@ -113,24 +114,21 @@ def _chain(root: Path, draft: dict, target) -> dict: from B05_Profile.B05_Profile_Engine import _route_geojson from B05_Profile.B05_Profile_Engine_AsPlanned import _vertex_z from B05_Profile.B05_Profile_Engine_Sections import run_section_generation - from B05_Profile.B05_Profile_Plus_Stations import follow_plus_stations + from B05_Profile.B05_Profile_Plus_Stations import save_plus_stations from B05_Profile.B05_Profile_Route_Save import followed_pipes from B05_Profile.B05_Profile_Router import _apply_alignment_edits from B05_Profile.B05_Profile_Router_Replan import _vertices_of, _write_planned_polyline from B05_Profile.B05_Profile_Router_RouteInitial import _set_base_pvi - from B05_Profile.B05_Profile_Structures_Router import follow_structures_to_route + from B05_Profile.B05_Profile_Structures_Repository import load_structures, save_structures from common_util.common_util_drainage_pipes import ( ensure_pipe_ids, fill_pipe_coordinates, pipe_points_path_in, - read_pipe_points_file, route_signature, ) from common_util.common_util_json import atomic_write_json from common_util.common_util_route_geometry import planned_route_working_path - old_xy = _old_xy(root) - old_pipes = read_pipe_points_file(pipe_points_path_in(root)) working = planned_route_working_path(root) count = len(draft["nodes"]) _write_planned_polyline( @@ -146,12 +144,14 @@ def _chain(root: Path, draft: dict, target) -> dict: arcs=[None] * count, ) points = [{"x": x, "y": y} for x, y in _vertices_of(working)] - follow_structures_to_route(root, P.ROUTE, points) - follow_plus_stations(root, [(p["x"], p["y"]) for p in points]) + revision, items = load_structures(str(root)) # [적용] = 초기화(492) — 사용자 시설 버림 + if items: + save_structures(str(root), [], base_revision=revision) + save_plus_stations(root, [], None) xy = [(p["x"], p["y"]) for p in points] z = _vertex_z(root, *P.SURFACE, xy) atomic_write_json(root / P.ROUTE, _route_geojson([[x, y, h] for (x, y), h in zip(xy, z)])) - pairs, _crossing, vertices = followed_pipes(old_pipes, old_xy, points, []) + pairs, _crossing, vertices = followed_pipes([], [], points, []) pipes = [pipe for _, pipe in pairs] fill_pipe_coordinates(pipes, vertices) ensure_pipe_ids(pipes) @@ -168,18 +168,10 @@ def _chain(root: Path, draft: dict, target) -> dict: def _preview(root: Path, candidate: dict, target) -> tuple[dict, list, list]: - from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file from common_util.common_util_surface_sampler import build_surface_sampler line, curves = ip.candidate_line(candidate["nodes"], candidate["curves"], RADIUS) - facilities = ip.followed_facilities( - root, - line, - P.ROUTE, - _old_xy(root), - read_pipe_points_file(pipe_points_path_in(root)), - [], - ) + facilities = ip.reset_facilities(line, []) sampler = build_surface_sampler(root / "B04_PreProcess" / "models", *P.SURFACE) longitudinal = ip.initial_longitudinal( root, @@ -385,7 +377,7 @@ def test_지문_같으면_꺼냄_다르면_다시(gather, monkeypatch): doc = json.loads(gather["pipes"].read_text("utf-8")) doc["points"][0]["x"] = float(doc["points"][0]["x"]) + 1.0 # 관 하나 옮김 gather["pipes"].write_text(json.dumps(doc), "utf-8") - assert "다시 셈" in gather["run"]()["error"] + assert gather["run"]() == first # 초기화(492) = 저장 관을 안 씀 → 지문 그대로 def test_코드_바뀌면_다시(gather, monkeypatch): diff --git a/resources/tester/test_476_2_recommend_initial_line.py b/resources/tester/test_476_2_recommend_initial_line.py index e858c9277..87ede29cf 100644 --- a/resources/tester/test_476_2_recommend_initial_line.py +++ b/resources/tester/test_476_2_recommend_initial_line.py @@ -57,8 +57,13 @@ def test_모달_배선과_확인_글() -> None: assert "profileSvg(balance, initialShape)" in rec assert 'line("profile").hidden = !balance && !initialShape;' in rec apply = rec[rec.index('applyButton.addEventListener("click"') :][:400] - assert 'showConfirmDialog(APPLY_CONFIRM, "적용")' in apply + assert ( + 'showConfirmDialog(await params.confirmText(), "적용")' in apply + ) # 492-2 — [저장]과 같은 글 · 수 assert apply.index("showConfirmDialog") < apply.index("params.onApply(picked);") - assert "종단 · 횡단 손 입력이 모두 지워지고 이 노선 기준으로 새로 시작" in rec # 요구 277 + reset = (B05 / "logic" / "B05_Profile_Route_Reset.ts").read_text(encoding="utf-8") + assert ( + "횡단 손 입력이 지워지고 " in reset and "새 노선으로 다시 셉니다" in reset + ) # 요구 277 · 284 css = (B05 / "B05_Profile_UI_Style_RouteRecommend.css").read_text(encoding="utf-8") assert ".b05-routerec__profile .is-initial" in css and "var(--map-route" in css