From d2bf68b59879769ff9d25ed546cbf9e9c500ab44 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 20 Oct 2026 07:20:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(492-2=20=EB=85=B8=EC=84=A0=20=EB=B0=94?= =?UTF-8?q?=EB=80=9C=20=ED=99=95=EC=9D=B8):=20=EB=AA=A8=EC=96=91=20?= =?UTF-8?q?=EB=B0=94=EB=80=90=20=EB=85=B8=EC=84=A0=20=EC=B4=88=EC=95=88=20?= =?UTF-8?q?=EB=9D=A0=20=C2=B7=20[=EC=A0=80=EC=9E=A5]=20=C2=B7=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20[=EC=A0=81=EC=9A=A9]=20=ED=99=95=EC=9D=B8=20?= =?UTF-8?q?=EC=B0=BD(=EC=A7=80=EC=9B=8C=EC=A7=88=20=EC=88=98)=20=C2=B7=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EB=92=A4=20=EC=B4=88=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 판정 = 저장 노선 대 초안이 그릴 선 정점 1 mm(`routeShapeChanged` · 파이썬 `route_shape_changed` 짝) · 스냅 · 간격 · 지형만 = 아님 - 띠 = 평면(노선 편집) 위 한 줄 · B06 평면 패널로 같이 옮겨짐 · 저장 노선 = 페이지 자료 줄 `pageRoutePlan`(조작마다 안 물음) - [저장] 확인 창 = 잠금 막 · 서버 요청 앞 · 구조물 · 관 · 추가 측점 · 종단 편집 수 · [취소] = 요청 0 · 추천 [적용] 같은 글 · 수 - 저장 뒤 = 응답 `steps.route.route_reset` 지운 수 한 줄 · 초기화면 세션 초안 통째 비움 → 서버 정본으로 다시 세움 · 옛 서버(칸 없음) = 알림 건너뜀 - 시험: 새 test_283_route_reset_confirm(띠 · 확인 창 수 · [취소] · 스냅만 · 거울) · 저장 헬퍼 셋 확인 창 가짜 · 476-2 확인 글 기대 고침 Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_page_data.ts | 9 + A00_Common/design/design_save.ts | 16 +- A00_Common/design/design_view_profile.ts | 4 + B05_Profile/B05_Profile_Api_Replan.ts | 3 + B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts | 6 +- .../B05_Profile_UI_RouteEdit_Initial.ts | 2 + .../B05_Profile_UI_RouteEdit_Recommend.ts | 7 +- B05_Profile/B05_Profile_UI_RouteReset.ts | 101 +++++++++ .../B05_Profile_UI_Style_RouteEdit.css | 21 ++ B05_Profile/logic/B05_Profile_Grade_Carry.ts | 7 + B05_Profile/logic/B05_Profile_Route_Reset.ts | 77 +++++++ .../tester/helper_283_route_reset_confirm.cjs | 108 ++++++++++ .../tester/helper_442_2_page_settings.cjs | 2 + resources/tester/helper_472_5r_route_save.cjs | 2 + resources/tester/helper_design_store_save.cjs | 6 +- .../tester/test_283_route_reset_confirm.py | 192 ++++++++++++++++++ .../test_476_2_recommend_initial_line.py | 9 +- 17 files changed, 558 insertions(+), 14 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_RouteReset.ts create mode 100644 B05_Profile/logic/B05_Profile_Route_Reset.ts create mode 100644 resources/tester/helper_283_route_reset_confirm.cjs create mode 100644 resources/tester/test_283_route_reset_confirm.py 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_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..fbbf2399d 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -41,6 +41,27 @@ 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%; + 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/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/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_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