From 47366d37407ea110f11bec0433b80bcbf6b58f55 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 12:14:22 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(=EC=A2=85=EB=8B=A8=EB=B3=B4=EA=B8=B0):?= =?UTF-8?q?=20346-3=20[=EB=85=B8=EC=84=A0=20=EB=B0=98=EC=98=81]=20?= =?UTF-8?q?=EC=97=86=EC=95=B0=20=E2=80=94=20[=EC=A0=80=EC=9E=A5]=EC=9D=B4?= =?UTF-8?q?=20=EB=85=B8=EC=84=A0=EB=B6=80=ED=84=B0(POST=20/route/replan=20?= =?UTF-8?q?=C2=B7=20draft=20=3D=20=EA=B3=84=EC=95=BD=20RouteDraft=20=C2=B7?= =?UTF-8?q?=20=EC=95=88=20=EB=B0=94=EB=80=8C=EB=A9=B4=20=EC=95=88=20?= =?UTF-8?q?=EB=B3=B4=EB=83=84)=20=C2=B7=20=EB=92=A4=20=EC=B0=A8=EB=A1=80?= =?UTF-8?q?=EB=8A=94=20=EC=83=88=20=EB=85=B8=EC=84=A0=20=C2=B7=20=ED=9A=A1?= =?UTF-8?q?=EB=8B=A8=20=EC=B4=88=EC=95=88=20=EC=B8=A1=EC=A0=90=20=EC=97=B4?= =?UTF-8?q?=EC=87=A0=EB=A1=9C=20=EB=84=98=EA=B9=80=20=C2=B7=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=ED=95=9C=20=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - design_save 차례 = 노선 → 관 → 구조물 → 계획고(옛 노선 칸 → 새 노선) → 암거 옵션 → 상단측 → 횡단(carryStationDrafts 뒤 저장) · 잠금 막 = [저장] 하나 · 실패 = 멈춤 · 초안 그대로 · 오류 한 줄 - [확정] 중 노선이 바뀌면 노선만 저장하고 다시 세움(새 노선으로 다시 [확정]) - _RouteEdit_Apply = 노선 저장 이음(편집기 등록부 · pendingRouteSave · saveRouteDraft · carryAfterRouteSave · routeSaveSummary · rebuildAfterRouteSave) · 단추 듣기 없음 - 결과 한 줄 = 관 지킴 · 새 교차 관 · 오목 보충 · 구조물 옮김 · 멀어짐 · 노선 밖 고침(서버 cross_dropped + 화면) · 배수유역 낡음 - RE_Chrome [노선 반영] 지움 · [반영 전으로] → [저장 전으로] · replanRoute 본문 = RouteSaveBody - 시험: test_design_store_save 차례 넷 더함 · 꼴 시험 셋 고쳐 씀 · 323_9 대역 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- A00_Common/design/design_save.ts | 75 ++++-- B05_Profile/B05_Profile_Api_Replan.ts | 38 ++- B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts | 250 ++++++++++++------ .../B05_Profile_UI_RouteEdit_Chrome.ts | 9 +- resources/tester/helper_323_9_save_redraw.cjs | 4 + resources/tester/helper_design_store_save.cjs | 66 +++++ resources/tester/test_133_2_design_terrain.py | 3 +- .../tester/test_b05_routeedit_chrome_modal.py | 6 +- resources/tester/test_design_page_single.py | 4 +- resources/tester/test_design_store_save.py | 50 +++- 10 files changed, 388 insertions(+), 117 deletions(-) diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts index e57219467..0bf05bfba 100644 --- a/A00_Common/design/design_save.ts +++ b/A00_Common/design/design_save.ts @@ -2,7 +2,13 @@ * A00_Common/design/design_save.ts * B05 · B06 **저장 한 줄기**(계획서 24-1) — [저장] · [확정]이 어느 보기에서 눌려도 같은 차례. * - * 관 → 구조물 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단 + * 노선 → 관 → 구조물 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단 + * + * 노선(346-3 · [노선 반영] 없앰) — 노선 초안이 마지막 저장과 다를 때만 맨 앞에서 `POST /route/replan` + * (draft = 계약 `RouteDraft`). 서버가 노선 → 관 · 구조물 따라감 → 종횡단 → 계획고 · 횡단 이월까지 돌고 + * 새 노선 번호를 준다. 그 뒤 차례는 **새 노선**으로: 관 · 구조물 초안(좌표 정본) · 계획고 초안(옛 노선 칸 → + * 새 노선) · 횡단 초안은 측점 열쇠로 넘겨(`carryStationDrafts`) 저장 · 결과 한 줄 · 페이지 다시 세움. + * 노선 저장이 실패하면 서버가 되살리고 여기서 멈춤(다른 초안도 화면 · 세션에 그대로 · 오류 한 줄). * * 차례는 여기 한 곳에만 둔다. 보기마다 다른 것은 **화면이 쥔 값으로 한 번 더 맞추는 일** * (B05 배수유역도 목록 · 구조물 목록 새로 받기 · 종단 패널 다시 그리기)과 횡단 저장 본문뿐이라, @@ -26,9 +32,17 @@ import { } from "../../B05_Profile/B05_Profile_UI_Profile_Edit"; import { flushCulvertOptions } from "../../B06_Section/B06_Section_Api_Culvert_Options"; import { clearState, stateKey } from "../b_page_state"; +import { + carryAfterRouteSave, + pendingRouteSave, + rebuildAfterRouteSave, + routeSaveSummary, + saveRouteDraft, +} from "../../B05_Profile/B05_Profile_UI_RouteEdit_Apply"; -/** 저장 차례 — 이 순서 그대로 돈다. */ +/** 저장 차례 — 이 순서 그대로 돈다(`route` 는 노선 초안이 있을 때만). */ export const DESIGN_SAVE_ORDER = [ + "route", "pipes", "structures", "alignment", @@ -48,7 +62,7 @@ export interface DesignSaveContext { * 차례마다 **기본 창구 대신** 도는 일. 없으면 기본 창구(세션 초안 → 정본)가 돈다. * `pipes` 만 예외 — 기본(세션 초안)이 먼저 나가고 그 뒤에 돈다(화면 목록으로 한 번 더 맞춤). */ - steps?: Partial, () => Promise>>; + steps?: Partial, () => Promise>>; /** 횡단 — 보기마다 본문이 다르다. `confirm` 이면 단계를 닫는 길로. */ cross: (confirm: boolean) => Promise; /** 모두 나간 뒤 — 캐시 비우기 · 화면 다시 읽기 · 알림 · 다음 단계. */ @@ -87,12 +101,17 @@ export function noticeDroppedEdits(saved: { dropped_edit_chainages?: number[] }) ); } -/** 계획고 초안(가게 하나)을 종단 정본에 쓴다. 없으면 아무 일도 하지 않는다. */ -async function flushAlignmentDraft(projectId: string, routeId: number): Promise { - const draft = pendingAlignment(routeId); +/** 계획고 초안(가게 하나)을 종단 정본에 쓴다. 없으면 아무 일도 하지 않는다. 노선이 바뀌었으면 + * 옛 노선 칸(`from`)의 초안을 새 노선(`routeId`)에 — 측점이 없는 자리는 서버가 버리고 알림(213-2). */ +async function flushAlignmentDraft( + projectId: string, + routeId: number, + from: number = routeId, +): Promise { + const draft = pendingAlignment(from); if (!draft) return; noticeDroppedEdits(await saveProfileAlignment(projectId, routeId, draft)); - markAlignmentSaved(routeId); + markAlignmentSaved(from); } /** 실패해도 다음 차례로 넘어가는 창구와 그 이름(보기가 준 일에도 같이 씌운다). */ @@ -104,9 +123,10 @@ const TOLERANT: Partial> = { /** 차례마다 기본 창구. 암거 옵션 · 상단측은 조용히 넘어간다(다음 [저장]에서 다시 나감). */ function defaultStep( - step: Exclude, + step: Exclude, projectId: string, routeId: number, + fromRouteId: number = routeId, ): () => Promise { switch (step) { case "pipes": @@ -114,10 +134,10 @@ function defaultStep( case "structures": return () => flushPendingStructures(projectId); case "alignment": - return () => flushAlignmentDraft(projectId, routeId); + return () => flushAlignmentDraft(projectId, routeId, fromRouteId); case "culvertOptions": return () => - flushCulvertOptions(projectId, stateKey("culvertopt", projectId, routeId)).catch( + flushCulvertOptions(projectId, stateKey("culvertopt", projectId, fromRouteId)).catch( () => undefined, ); case "uphill": @@ -134,19 +154,34 @@ export async function saveDesign( options: { confirm?: boolean } = {}, ): Promise { const confirm = options.confirm ?? false; - const routeId = ctx.routeId(); - if (routeId === null) return false; + const fromRouteId = ctx.routeId(); + if (fromRouteId === null) return false; + let routeId = fromRouteId; + /** 노선을 새로 저장했으면 그 결과 — 뒤 차례는 새 노선 · 화면 몫(옛 노선 판) 일은 건너뜀. */ + let routeSaved: Awaited> | null = null; showLoadingOverlay(); saving.add(ctx.projectId); try { await ctx.prepare?.(); for (const step of DESIGN_SAVE_ORDER) { - if (step === "cross") { - await ctx.cross(confirm); + if (step === "route") { + const body = await pendingRouteSave(ctx.projectId); // 안 바뀌었으면 null — 안 보냄 + if (!body) continue; + routeSaved = await saveRouteDraft(ctx.projectId, body); // 실패 = throw(서버가 되살림) + if (routeSaved.route_id != null) routeId = routeSaved.route_id; continue; } - const own = ctx.steps?.[step]; - const base = defaultStep(step, ctx.projectId, routeId); + if (step === "cross") { + if (!routeSaved) await ctx.cross(confirm); + else { + const outside = await carryAfterRouteSave(ctx.projectId, fromRouteId, routeId); + showToast(routeSaveSummary(routeSaved, outside), "info"); + } + continue; + } + // 노선이 바뀌면 화면 몫 일(옛 노선 판 목록 · 계획고 판)은 건너뛰고 기본 창구만 새 노선으로. + const own = routeSaved ? undefined : ctx.steps?.[step]; + const base = defaultStep(step, ctx.projectId, routeId, fromRouteId); const run = step === "pipes" ? async () => { @@ -159,6 +194,14 @@ export async function saveDesign( } clearState("structure-type-cache", ctx.projectId); // 종류별 캐시는 저장 뒤 비움(54-32) saving.delete(ctx.projectId); // 다 나감 — 뒤처리는 새 상세로 그림 + if (routeSaved) { + // 새 노선 — [확정]은 새 노선을 본 뒤 다시(단계 닫기는 새 횡단 본문이 있어야 함) · 페이지 다시 세움. + if (confirm) + showToast("노선을 저장했습니다 — 새 노선을 확인한 뒤 다시 [확정]하세요.", "info"); + else await ctx.done?.(false); + await rebuildAfterRouteSave(ctx.projectId); + return true; + } await ctx.done?.(confirm); return true; } catch (error) { diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index 90e775521..ca1c1f9f7 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -3,7 +3,7 @@ * 계획노선 두 벌(예상노선·계획노선) 읽기와 노선 갈아 끼우기 요청. * * GET /projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계 m) - * POST /projects/{id}/route/replan → 고친 계획노선으로 갈아 끼우고 재계산 + * POST /projects/{id}/route/replan → 노선 초안 저장(346-3 · draft = 계약 RouteDraft · as_initial) * POST /projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산 * POST /projects/{id}/route/profile-preview → 지금 노드의 균형 종단(편집 창 종단 띠) * GET /projects/{id}/route/terrain-sources → 고를 수 있는 분석 지형(라이다 · 수치지형도) @@ -19,6 +19,7 @@ import { API_BASE_URL } from "@config/config_frontend"; import { readState, writeState } from "../A00_Common/b_page_state"; import { heightAt } from "../common_util/common_util_surface_sampler"; import { loadDesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid"; +import type { RouteDraft } from "../A00_Common/design/design_pipeline_types"; /** 편집 창 분석 지형 — las = 확정 지표면(라이다) · sheet = 수치지형도 서피스. */ export type AnalysisTerrain = "las" | "sheet"; @@ -115,7 +116,23 @@ export interface RouteReplanResponse { project_id: string; route_id: number | null; total_length_m: number | null; - vertex_count: number; + vertex_count?: number; + /** 노선 저장 결과(346-3 · 서버 `B05_Profile_Route_Save` 머리) — 관 지킴 · 새 교차 · 오목 보충. */ + pipes?: { kept: number; crossing: number; concave: number }; + structures?: { moved: number; far: number }; + /** 새 노선에 못 넘긴 횡단 고침(측점이 사라짐) 수. */ + cross_dropped?: number; + /** 배수유역 저장분이 새 노선과 안 맞음(다시 안 셈). */ + drainage_stale?: boolean; + initial_refreshed?: boolean; +} + +/** 노선 저장 본문 — `draft` 는 계약 `RouteDraft` 그대로 · `as_initial` = 추천 [적용](초기값 다시 뜸). */ +export interface RouteSaveBody { + draft: RouteDraft; + initial?: unknown; + balanced_profile?: boolean; + as_initial?: boolean; } export async function requestJson( @@ -159,23 +176,18 @@ export interface RouteReplanVertex { radius_m?: number | null; } -/** 고친 계획노선으로 갈아 끼우고 배수유역부터 다시 계산한다. +/** 노선 초안을 정본으로 저장한다(346-3) — 서버가 노선 → 관 · 구조물 따라감 → 종횡단 → 계획고 옮김 → + * 횡단 이월 → B06 확정까지 돈다. 실패(409)면 서버가 옛 노선으로 되살리고 오류 글을 낸다. * - * 편집 세 가지가 모두 이 한 목록으로 나간다(2026-09-07 사용자 지시) — - * **직선 삭제·추가**는 점을 빼거나 더하는 것, **곡선 삭제·추가**는 `curve` 를 끄고 켜는 것, - * **반지름 변경**은 `radius_m` 을 주는 것. */ + * 편집 세 가지가 모두 초안 한 벌로 나간다(2026-09-07 사용자 지시) — **직선 삭제·추가**는 노드를 빼거나 + * 더하는 것, **곡선 삭제·추가**는 `curveOn` 을 끄고 켜는 것, **반지름 변경**은 `curveRadius`. */ export async function replanRoute( projectId: string, - vertices: Array<[number, number]> | RouteReplanVertex[], - /** 함께 실어 보낼 값 — 지금은 마지막에 적용한 초기노선 갈래(`initial`, 계획서 20). */ - extra: Record = {}, + body: RouteSaveBody, ): Promise { - const payload = (vertices as Array<[number, number] | RouteReplanVertex>).map((vertex) => - Array.isArray(vertex) ? { x: vertex[0], y: vertex[1] } : vertex, - ); return requestJson( `/projects/${projectId}/route/replan`, - { method: "POST", body: JSON.stringify({ ...extra, vertices: payload }) }, + { method: "POST", body: JSON.stringify(body) }, REPLAN_TIMEOUT_MS, ); } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index 6096b7019..38a350737 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -1,105 +1,201 @@ /* ============================================================================= * B05_Profile_UI_RouteEdit_Apply.ts - * 계획노선 편집(평면)의 **[노선 반영]**(옛 [확인] · 346-3 까지 남김) — 무거운 재계산과 대기 표시. + * 노선 편집(평면)의 **노선 저장 이음**(346-3) — [노선 반영] 단추는 없앰. * - * 다시 계산은 **노선이 바뀐 뒤 [노선 반영]에서만** 돈다(계획서 20 ④). 노선이 마지막 반영 그대로면 - * 계산하지 않는다. [초기화](옛 「예상노선으로」)는 편집값만 되돌린다(`_Initial`). 끝나면 부른 쪽이 - * 페이지를 새로고침 없이 다시 세운다(`onApplied`). - * - * 누르면 서버가 배수유역부터 종·횡단·유토곡선까지 전 단계를 다시 돈다(약 90초). 중간 취소는 - * 만들지 않기로 했으므로(계획서 0-2, 2026-09-09) **얼마나 지났는지**를 초로 보여 사람이 - * 멈춘 것인지 도는 것인지 알 수 있게 한다. + * · 페이지 [저장](`design_save`)이 맨 앞에서 `pendingRouteSave` 로 지금 노선 초안을 받아 `saveRouteDraft` + * (`POST /route/replan` · draft = 계약 `RouteDraft`)로 보낸다 — 노선이 마지막 저장 그대로면 null(안 보냄). + * · 저장 뒤 `carryAfterRouteSave` 가 세션 횡단 초안을 측점 열쇠로 새 노선에 넘기고(`carryStationDrafts`) + * 넘긴 고침을 저장 · 결과 한 줄 · 페이지는 새로고침 없이 다시 세운다(`rebuildAfterRouteSave`). + * · 추천 [적용] = `saveRecommendedRoute` — `as_initial: true` 로 바로 저장(초기값 = 추천 노선 · 사용자 확정). + * 편집기가 살아 있는 동안 여기 등록부에 「지금 초안」 · 「다시 세우기」를 건다(프로젝트 하나에 하나). + * 실패하면 서버가 옛 노선으로 되살리고 화면 초안은 그대로 남는다(오류 한 줄은 부르는 쪽). * ========================================================================== */ import { clearDrafts, clearResults } from "../A00_Common/b_page_state"; -import { showToast } from "@ui/ui_template_elements"; -import { replanRoute } from "./B05_Profile_Api_Replan"; +import type { RouteDraft } from "../A00_Common/design/design_pipeline_types"; +import { useDesignStore } from "../A00_Common/design/design_store"; +import { currentRoute, remountRoute } from "../A00_Common/router"; +import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; +import { + invalidateSectionDetail, + loadSectionDetail, + saveCachedCrossPatches, +} from "../B06_Section/B06_Section_Section_Store"; +import { carryStationDrafts, useStationKeys } from "../B06_Section/B06_Section_Station_Keys"; +import { clearRouteLatestCache } from "./B05_Profile_Api_Fetch"; +import { + replanRoute, + type RouteReplanResponse, + type RouteSaveBody, +} from "./B05_Profile_Api_Replan"; type Vertex = [number, number]; +type RouteNodes = { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array }; export interface RouteApplyParams { - overlay: HTMLElement; - /** 화면 전체를 덮는 대기 막. 안에 `` 한 개가 글을 받는다. */ - busy: HTMLElement; + /** 옛 [노선 반영] 자리(단추 · 대기 막) — 346-3 부터 안 씀(대기는 페이지 잠금 막 하나). */ + overlay?: HTMLElement; + busy?: HTMLElement; projectId: string; - /** 지금 편집값 — 누른 순간에 읽는다. */ - nodes: () => { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array }; - /** 마지막 반영 뒤 노선이 바뀌었나 — 안 바뀌었으면 다시 계산하지 않는다. */ + /** 지금 편집값 — 저장하는 순간에 읽는다. */ + nodes: () => RouteNodes; + /** 마지막 저장 뒤 노선이 바뀌었나 — 안 바뀌었으면 보내지 않는다. */ changed: () => boolean; /** 설계 지형으로 보낼 분석 지형(PLAN 133-2) — 고르기가 안 보이면 null(지금 설계 지형 그대로). */ terrain: () => string | null; /** [측점 스냅] 켬끔(요구 224-2) — 곡선표에 적혀 측점 셈이 읽음. */ stationSnap: () => boolean; - /** 초기노선 갈래 — [확인]에 지금 초기값을 싣고, 결과 캐시를 비운 뒤 다시 적는다. */ + /** 초기노선 갈래 — 저장에 지금 추천을 싣고, 결과 캐시를 비운 뒤 다시 적는다. */ initial: { current: () => unknown; keep: () => void; }; /** 종단 띠 — 응답을 기다려 띠에 보인 계획선(균형 종단 · 지반고)을 그대로 적용한다(PLAN 75-1). */ profileShown: () => Promise; - /** 성공하면 화면을 다시 세운다(대기 막은 다시 세울 때까지 남음). */ + /** 저장이 끝나면 페이지를 새로고침 없이 다시 세운다. */ onApplied: () => void | Promise; } -/** [노선 반영]을 붙인다. 리스너는 편집기와 수명이 같다. */ +interface RouteSource { + pending: () => Promise; + settings: () => { stationSnap: boolean; terrain: string | null }; + saved: () => void; + rebuild: () => void | Promise; +} + +const sources = new Map(); + +/** 노드 · 곡선 편집값 → 계약 `RouteDraft`(붙든 값은 서버가 안 씀 · 칸 길이만 맞춤). */ +export function routeDraftOf( + nodes: RouteNodes, + settings: { stationSnap: boolean; terrain: string | null }, +): RouteDraft { + return { + rev: 0, + nodes: nodes.planned.map(([x, y]): Vertex => [x, y]), + curveOn: nodes.planned.map((_, index) => nodes.curveOn[index] !== false), + curveRadius: nodes.planned.map((_, index) => nodes.curveRadius[index] ?? null), + curveLock: nodes.planned.map(() => null), + curveArc: nodes.planned.map(() => null), + stationSnap: settings.stationSnap, + terrain: settings.terrain, + }; +} + +/** 편집기를 등록부에 건다 — 같은 프로젝트에 새 편집기가 서면 그쪽으로 바뀐다. */ export function bindRouteApply(params: RouteApplyParams): void { - const { overlay, busy, projectId } = params; - - async function runHeavy(label: string, task: () => Promise): Promise { - busy.hidden = false; - // ⚠ 「몇 분」은 옛 값이었다 — 0-11 로 **약 90초**가 됐다(2026-09-09 실측 네 번: - // 87.3 · 90.0 · 93.9 · 95.4초). - const message = busy.querySelector("span")!; - const started = Date.now(); - const tick = (): void => { - const seconds = Math.round((Date.now() - started) / 1000); - message.textContent = `${label} — 배수유역부터 다시 계산 중입니다. 1분 반쯤 걸립니다 (${seconds}초 지남).`; - }; - tick(); - const timer = window.setInterval(tick, 1000); - try { - await task(); - // 노선이 바뀌면 세션 초안·조회 캐시는 옛 노선 것이라 남기지 않는다(PLAN 0-7 확정 5). - clearDrafts(projectId); - clearResults(projectId); - params.initial.keep(); // 초기값은 새 노선의 출발점 — 옛 노선 결과와 함께 버리지 않는다. - showToast("노선을 다시 계산했습니다.", "success"); - await params.onApplied(); - } catch (error) { - busy.hidden = true; - showToast(error instanceof Error ? error.message : "노선 재계산에 실패했습니다.", "error"); - } finally { - window.clearInterval(timer); // 성공·실패·닫힘 어느 쪽이든 멈춘다 - } - } - - overlay.querySelector('[data-act="apply"]')!.addEventListener("click", () => { - const { planned, curveOn, curveRadius } = params.nodes(); - if (planned.length < 2) { - showToast("노선은 노드가 2개 이상이어야 합니다.", "error"); - return; - } - if (!params.changed()) { - showToast("노선이 그대로라 다시 계산하지 않았습니다.", "info"); - return; - } - void runHeavy("계획노선 반영", async () => - replanRoute( - projectId, - planned.map(([x, y], index) => ({ - x, - y, - curve: curveOn[index] !== false, - radius_m: curveRadius[index] ?? null, - })), - { - initial: params.initial.current(), - // 띠 응답 전에 눌렀으면 기다린다 — 서버는 같은 노선 · 같은 셈으로 띠 계획선을 다시 낸다. - balanced_profile: await params.profileShown(), - terrain: params.terrain(), - station_snap: params.stationSnap(), - }, - ), - ); + const settings = () => ({ stationSnap: params.stationSnap(), terrain: params.terrain() }); + sources.set(params.projectId, { + pending: async () => { + const nodes = params.nodes(); + if (nodes.planned.length < 2 || !params.changed()) return null; + return { + draft: routeDraftOf(nodes, settings()), + initial: params.initial.current(), + // 띠 응답 전이면 기다린다 — 서버는 같은 노선 · 같은 셈으로 띠 계획선을 다시 낸다. + balanced_profile: await params.profileShown(), + }; + }, + settings, + saved: () => params.initial.keep(), + rebuild: params.onApplied, }); } + +/** 지금 보낼 노선 초안 — 편집기가 있으면 그것(안 바뀌었으면 null) · 없으면 가게 `routeDraft`. */ +export async function pendingRouteSave(projectId: string): Promise { + const source = sources.get(projectId); + if (source) return source.pending(); + const draft = useDesignStore(projectId, null).get("routeDraft"); + return draft && draft.nodes.length >= 2 ? { draft } : null; +} + +/** 노선 초안 저장 — 실패하면 throw(서버가 되살림 · 화면 초안 그대로). 새 노선 번호 · 결과를 돌려줌. */ +export async function saveRouteDraft( + projectId: string, + body: RouteSaveBody, +): Promise { + const saved = await replanRoute(projectId, body); + useDesignStore(projectId, null).set("routeDraft", null); + // 노선이 바뀌면 조회 결과 캐시는 옛 노선 것 — 추천은 새 노선의 출발점이라 다시 적는다. + clearResults(projectId); + sources.get(projectId)?.saved(); + return saved; +} + +/** 저장 결과 한 줄 — 관 지킴 · 새 교차 관 · 구조물 옮김 · 멀어짐 · 노선 밖 고침 · 배수유역 낡음. */ +export function routeSaveSummary(saved: RouteReplanResponse, outside = 0): string { + const parts = [ + `관 지킴 ${saved.pipes?.kept ?? 0}`, + `새 교차 관 ${saved.pipes?.crossing ?? 0}`, + `구조물 옮김 ${saved.structures?.moved ?? 0}`, + `멀어짐 ${saved.structures?.far ?? 0}`, + ]; + if (saved.pipes?.concave) parts.splice(2, 0, `오목 보충 관 ${saved.pipes.concave}`); + const dropped = (saved.cross_dropped ?? 0) + outside; + if (dropped) parts.push(`노선 밖 고침 ${dropped} 건`); + if (saved.drainage_stale) parts.push("배수유역 낡음"); + return `노선 저장 — ${parts.join(" · ")}`; +} + +/** + * 노선 저장 뒤 세션 횡단 초안(옛 노선 측점 열쇠) → 새 노선으로 넘기고 그 고침을 저장 — 초안을 통째로 + * 지우지 않는다. 사라진 측점 고침은 버림(수를 돌려줌 · 서버 `cross_dropped` 와 한 줄로). + */ +export async function carryAfterRouteSave( + projectId: string, + fromRouteId: number, + toRouteId: number, +): Promise { + invalidateSectionDetail(projectId, toRouteId); + const detail = await loadSectionDetail(projectId, toRouteId); + useStationKeys(projectId, toRouteId, detail); + const outside = carryStationDrafts(projectId, fromRouteId, toRouteId); + await saveCachedCrossPatches(projectId, toRouteId); + return outside; +} + +/** 저장 뒤 페이지를 새로고침 없이 다시 세운다 — 편집기가 걸어 둔 길(옛 노선 캐시 비움 포함) · 없으면 라우터. */ +export async function rebuildAfterRouteSave(projectId: string): Promise { + const source = sources.get(projectId); + if (source) return source.rebuild(); + clearRouteLatestCache(projectId); + invalidateSectionDetail(projectId); + remountRoute(currentRoute()); +} + +/** + * 추천 [적용] — 받은 노드로 서버 노선을 바로 저장하고(`as_initial` · 초기값 = 추천 노선) 페이지를 다시 + * 세운다. 잠금 막 = 「노선 재탐색」 자리 하나. 새 초기값에서 다시 시작하므로 초안은 비운다([초기화]와 같음). + * 실패하면 false — 서버가 옛 노선으로 되살렸고 화면 노선은 그대로. + */ +export async function saveRecommendedRoute( + projectId: string, + nodes: RouteNodes, + initial: unknown, + balancedProfile: boolean, +): Promise { + const settings = sources.get(projectId)?.settings() ?? { stationSnap: false, terrain: null }; + showLoadingOverlay(); + try { + await saveRouteDraft(projectId, { + draft: routeDraftOf(nodes, settings), + initial, + balanced_profile: balancedProfile, + as_initial: true, + }); + clearDrafts(projectId); + sources.get(projectId)?.saved(); + showToast("추천 노선을 저장했습니다 — 새 초기값입니다.", "success"); + await rebuildAfterRouteSave(projectId); + return true; + } catch (error) { + showToast( + `추천 노선 저장에 실패했습니다.${error instanceof Error ? ` ${error.message}` : ""}`, + "error", + ); + return false; + } finally { + hideLoadingOverlay(); + } +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index 08f3e7d91..30daac5bf 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -13,7 +13,8 @@ import { bindFootprintToggle } from "./B05_Profile_UI_RouteEdit_Footprint"; * 아래. 메인 창은 왼쪽으로 밀고 오른쪽 세로 칸에 횡단 두 판이 앉는다. * * 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)에 붙는다. 덮개 · 전체 화면 · - * 닫기 단추는 걷었다 · [취소] → [반영 전으로] · [확인] → [노선 반영] · 오른쪽 횡단 칸은 접는 칸. + * 닫기 단추는 걷었다 · [취소] → [저장 전으로] · [확인] · [노선 반영]은 없앰(346-3 — 노선은 페이지 [저장]이 + * 맨 앞에서 보냄) · 오른쪽 횡단 칸은 접는 칸. * 머리 `tools` 자리에 부른 쪽 단추([3D])가 앉는다. [노선 추천] · 현재 추천 · [초기화]는 좌측 * 「노선 추천」 칸으로 옮겼다(345-4 · `_Panel_Recommend`). * ========================================================================== */ @@ -72,7 +73,7 @@ export function createRouteEditChrome( title="노선 위 두 점을 눌러 거리·기울기를 잽니다 (Shift+클릭도 같음)">거리 재기 + title="곡선(호) 안 측점 하나를 곡선 중점(MC)으로 옮깁니다 — [저장] 때 저장">측점 스냅 @@ -81,9 +82,7 @@ export function createRouteEditChrome( - + title="마지막 [저장] 뒤 고친 노선을 버리고 그때 노선으로 되돌립니다 — 되돌리기로 다시 살릴 수 있습니다">저장 전으로
diff --git a/resources/tester/helper_323_9_save_redraw.cjs b/resources/tester/helper_323_9_save_redraw.cjs index 66759a9c3..858520282 100644 --- a/resources/tester/helper_323_9_save_redraw.cjs +++ b/resources/tester/helper_323_9_save_redraw.cjs @@ -33,6 +33,10 @@ const STUBS = { showLoadingOverlay: () => calls.push("overlay:on"), hideLoadingOverlay: () => calls.push("overlay:off"), }, + // 노선 저장(346-3) — 이 시험은 노선이 그대로인 [저장]만 봄 + [path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Apply.ts")]: { + pendingRouteSave: async () => null, + }, [path.join(B06, "B06_Section_Api_Fetch.ts")]: { fetchSectionDetail: async () => (calls.push(`fetch:${server}`), detail()), saveSections: async () => calls.push("cross:save"), diff --git a/resources/tester/helper_design_store_save.cjs b/resources/tester/helper_design_store_save.cjs index 7bb4aec29..69c680c36 100644 --- a/resources/tester/helper_design_store_save.cjs +++ b/resources/tester/helper_design_store_save.cjs @@ -30,7 +30,22 @@ const record = (name, fail) => async () => { if (fail) throw new Error(`${name} 실패`); }; let failStructures = false; +// 노선 저장(346-3) — 초안 · 실패를 시험이 정함 +let routeBody = null; +let failRoute = false; +const routeStub = { + pendingRouteSave: async () => routeBody, + saveRouteDraft: async () => { + calls.push("route"); + if (failRoute) throw new Error("노선 저장 실패"); + return { route_id: 9, pipes: { kept: 3, crossing: 1, concave: 0 }, cross_dropped: 1 }; + }, + carryAfterRouteSave: async (_p, from, to) => (calls.push(`carry:${from}->${to}`), 2), + routeSaveSummary: (saved, outside) => (calls.push(`summary:${saved.route_id}:${outside}`), ""), + rebuildAfterRouteSave: async () => calls.push("rebuild"), +}; const STUBS = { + "../../B05_Profile/B05_Profile_UI_RouteEdit_Apply": routeStub, "@ui/ui_template_elements": { showToast: (text, kind) => calls.push(`toast:${kind}`), showLoadingOverlay: () => calls.push("overlay:on"), @@ -161,6 +176,57 @@ async function run() { }); out.noRouteCalls = [...calls]; + // ⑥ 노선이 바뀜(346-3) — 노선 먼저 · 뒤 차례는 새 노선(9) · 화면 몫 건너뜀 · 횡단 초안 넘김 · 다시 세움 + b05.replace({ station_offsets: { "30.000": 0.2 }, curve_radii: {} }); // 옛 노선(7) 계획고 초안 + routeBody = { + draft: { + rev: 1, + nodes: [ + [0, 0], + [10, 0], + ], + }, + }; + calls.length = 0; + out.routeOk = await saveDesign({ + projectId: "p1", + routeId: () => 7, + prepare: record("prepare"), + steps: { pipes: record("pipes:screen"), alignment: record("alignment:screen") }, + cross: async (confirm) => calls.push(`cross:${confirm}`), + done: async (confirm) => calls.push(`done:${confirm}`), + failed: () => calls.push("failed"), + }); + out.routeCalls = [...calls]; + // ⑦ 노선 저장 실패 — 거기서 멈춤 · 다른 초안 그대로 + b05.replace({ station_offsets: { "50.000": 0.3 }, curve_radii: {} }); + failRoute = true; + calls.length = 0; + out.routeFailOk = await saveDesign({ + projectId: "p1", + routeId: () => 7, + cross: async (confirm) => calls.push(`cross:${confirm}`), + done: async (confirm) => calls.push(`done:${confirm}`), + failed: () => calls.push("failed"), + }); + out.routeFailCalls = [...calls]; + out.routeFailDraft = edit.pendingAlignment(7)?.station_offsets ?? null; + failRoute = false; + // ⑧ [확정] 중 노선이 바뀜 — 노선 저장 뒤 다시 세움 · 확정 단계는 안 닫음(새 노선으로 다시) + calls.length = 0; + await saveDesign( + { + projectId: "p1", + routeId: () => 7, + cross: async (confirm) => calls.push(`cross:${confirm}`), + done: async (confirm) => calls.push(`done:${confirm}`), + failed: () => calls.push("failed"), + }, + { confirm: true }, + ); + out.routeConfirmCalls = [...calls]; + routeBody = null; + // ⑤ [초기화] — 초안 · 한 벌 모두 버림 b05.replace({ station_offsets: { "80.000": 1 }, curve_radii: {} }); edit.clearAlignmentDrafts(); diff --git a/resources/tester/test_133_2_design_terrain.py b/resources/tester/test_133_2_design_terrain.py index 92fddd064..552358ff8 100644 --- a/resources/tester/test_133_2_design_terrain.py +++ b/resources/tester/test_133_2_design_terrain.py @@ -291,6 +291,7 @@ def test_화면_확인이_고른_지형을_실음(): page = ROOT / "B05_Profile" apply = (page / "B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8") main = (page / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8") - assert "terrain: params.terrain()," in apply + # 346-3 — 노선 초안(draft.terrain)에 실림 · [저장]이 보냄 + assert "terrain: params.terrain() })" in apply and "terrain: settings.terrain," in apply assert "pickedTerrain() !== designTerrain" in main # 지형만 바꿔도 [확인]이 다시 셈 assert "analysisTerrain(projectId, designTerrain)" in main # 고른 값 없으면 설계 지형 diff --git a/resources/tester/test_b05_routeedit_chrome_modal.py b/resources/tester/test_b05_routeedit_chrome_modal.py index 42c2dd941..d07655715 100644 --- a/resources/tester/test_b05_routeedit_chrome_modal.py +++ b/resources/tester/test_b05_routeedit_chrome_modal.py @@ -33,10 +33,12 @@ def test_겉틀은_본문_칸(): assert 'overlay.className = "b05-routeedit";' in chrome assert "b05-routeedit__close" not in chrome and 'data-slot="close"' not in chrome assert '"tools"' in chrome and "replaceWith(...tools)" in chrome - assert ">반영 전으로" in chrome and ">노선 반영" in chrome + # 346-3 — [노선 반영] 없앰(노선은 페이지 [저장]이 맨 앞에서 보냄) · [취소] = [저장 전으로] + assert ">저장 전으로" in chrome and 'data-act="apply"' not in chrome + assert "노선 반영" not in chrome assert ">취소" not in chrome and ">확인" not in chrome # 시험 · 배선이 집는 자리는 남김 - for hook in ('data-act="apply"', 'data-act="cancel"', "b05-routeedit__measure-close"): + for hook in ('data-act="cancel"', "b05-routeedit__measure-close"): assert hook in chrome assert 'data-act="side-toggle"' in chrome and "is-collapsed" in chrome diff --git a/resources/tester/test_design_page_single.py b/resources/tester/test_design_page_single.py index cad80ba99..e44538814 100644 --- a/resources/tester/test_design_page_single.py +++ b/resources/tester/test_design_page_single.py @@ -114,9 +114,9 @@ def test_초기화_계획노선_편집은_다시_세움(): assert step in rebuild assert rebuild.index("invalidateSectionDetail") < rebuild.index("remountRoute(") assert "mountRouteEditor(mainContent, host, activeProjectId, rebuildAfterReplan," in view + # 346-3 — [저장]이 노선을 보낸 뒤 결과 캐시를 비우고 편집기가 건 다시 세우기(rebuildAfterReplan) apply = _read("B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts") - assert apply.index("clearDrafts(projectId);") < apply.index("await params.onApplied();") - assert "clearResults(projectId);" in apply + assert "clearResults(projectId);" in apply and "rebuild: params.onApplied," in apply def test_종단_보기_확정은_횡단_보기를_숨긴_채_세움(): diff --git a/resources/tester/test_design_store_save.py b/resources/tester/test_design_store_save.py index 16e4b9947..b868f21e4 100644 --- a/resources/tester/test_design_store_save.py +++ b/resources/tester/test_design_store_save.py @@ -48,7 +48,15 @@ def test_계획고_가게_한_벌(out): def test_저장_차례(out): - assert out["order"] == ["pipes", "structures", "alignment", "culvertOptions", "uphill", "cross"] + assert out["order"] == [ + "route", + "pipes", + "structures", + "alignment", + "culvertOptions", + "uphill", + "cross", + ] # 346-3 노선 먼저 assert out["saveOk"] is True assert out["saveCalls"] == [ "overlay:on", @@ -78,3 +86,43 @@ def test_구조물_실패는_알리고_넘어감_확정_노선없음(out): def test_초기화는_초안과_한_벌을_버림(out): assert out["cleared"] == {"draft": None, "pending": None} + + +def test_노선이_바뀌면_노선_먼저_뒤_차례는_새_노선(out): + """346-3 — 노선 초안이 있으면 맨 앞 · 관 · 구조물 기본 창구 · 계획고 초안(옛 노선 7 칸)을 새 노선 9 로 · + 화면 몫 일(옛 노선 판)은 건너뜀 · 횡단 초안 넘김(측점 열쇠) · 결과 한 줄 · 다시 세움.""" + assert out["routeOk"] is True + assert out["routeCalls"] == [ + "overlay:on", + "prepare", + "route", + "pipes", + "structures", + 'alignment:9:{"30.000":0.2}', + "culvert:aislo:draft:culvertopt:p1:7", + "uphill", + "carry:7->9", + "summary:9:2", + "toast:info", + "done:false", + "rebuild", + "overlay:off", + ] + assert "pipes:screen" not in out["routeCalls"] and "alignment:screen" not in out["routeCalls"] + assert "cross:false" not in out["routeCalls"] # 옛 노선 횡단 본문 대신 넘김 + + +def test_노선이_그대로면_안_보냄(out): + assert "route" not in out["saveCalls"] + + +def test_노선_저장_실패는_멈추고_초안_남음(out): + assert out["routeFailOk"] is False + assert out["routeFailCalls"] == ["overlay:on", "route", "failed", "overlay:off"] + assert out["routeFailDraft"] == {"50.000": 0.3} # 계획고 초안 그대로 + + +def test_확정_중_노선이_바뀌면_다시_세우고_확정은_다시(out): + calls = out["routeConfirmCalls"] + assert calls[1] == "route" and calls[-2:] == ["rebuild", "overlay:off"] + assert "done:true" not in calls and "cross:true" not in calls From 6086f309825ffaff46e42933614cc0a1371a8e52 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 12:14:22 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(=EB=85=B8=EC=84=A0=EC=B6=94=EC=B2=9C):?= =?UTF-8?q?=20346-3=20=EC=B6=94=EC=B2=9C=20[=EC=A0=81=EC=9A=A9]=20=3D=20as?= =?UTF-8?q?=5Finitial=20=EB=A1=9C=20=EC=84=9C=EB=B2=84=20=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=EC=A0=80=EC=9E=A5=20=E2=80=94=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=EA=B0=92=20=3D=20=EC=B6=94=EC=B2=9C=20=EB=85=B8=EC=84=A0=20?= =?UTF-8?q?=C2=B7=20=EC=9E=A0=EA=B8=88=20=EB=A7=89=20=C2=B7=20=EC=B4=88?= =?UTF-8?q?=EC=95=88=20=EB=B9=84=EC=9B=80=20=C2=B7=20=EC=83=88=EB=A1=9C?= =?UTF-8?q?=EA=B3=A0=EC=B9=A8=20=EC=97=86=EC=9D=B4=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EC=84=B8=EC=9B=80=20=C2=B7=20=EC=8B=A4=ED=8C=A8=20=3D=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=ED=95=9C=20=EC=A4=84=20=C2=B7=20=ED=8F=89?= =?UTF-8?q?=EB=A9=B4=EC=97=90=20=EC=8B=A4=EB=A6=B0=20=EC=B1=84=20=EB=82=A8?= =?UTF-8?q?=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _RouteEdit_Initial onApply → 평면 되돌리기 한 칸 그대로 + saveRecommendedRoute - 시험: test_346_3_route_save 7(안 바뀜 안 보냄 · 계약 꼴 · 저장 뒤 가게 비움 · 결과 한 줄 · 넘김 차례 · as_initial · 실패) · 관련 242 통과 · typecheck 통과 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- .../B05_Profile_UI_RouteEdit_Initial.ts | 25 +- resources/tester/helper_346_3_route_save.cjs | 171 +++++ resources/tester/test_346_3_route_save.py | 611 +++--------------- 3 files changed, 269 insertions(+), 538 deletions(-) create mode 100644 resources/tester/helper_346_3_route_save.cjs diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts index 86d135a55..c6101bc9f 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts @@ -1,14 +1,15 @@ /* ============================================================================= * B05_Profile_UI_RouteEdit_Initial.ts * 계획노선 편집의 **노선 추천 이음**(계획서 20 · 23-2) — 좌측 「노선 추천」 칸(345-4) [노선 추천] 단추 → - * 모달(`_Recommend`) · [초기화] · 「현재 추천: ○○」 · [노선 반영]에 실을 지금 추천. 칸 요소는 + * 모달(`_Recommend`) · [초기화] · 「현재 추천: ○○」 · [저장]에 실을 지금 추천. 칸 요소는 * 밖(`_Panel_Recommend`)에서 받는다. * * 모달의 알약이 서버에 갈래를 묻고(`/route/initial`) 미리 보기만 한다. [적용]을 눌러야 받은 노드를 * **편집값에 싣고**(되돌리기 한 걸음) 캐시(`routeedit-initial`)에 적는다. [초기화]는 서버를 다시 * 셈하지 않고 **전처리 때 만든 초기값 노선**(예상노선 추적)을 놓는다 — 그 노드는 `GET /route/plan` 의 - * `initial_route` 한 곳에서 읽는다. 종단 · 배수유역 · 횡단 다시 계산은 여기서 안 돈다 — - * 노선이 바뀐 뒤 [확인]에서만(`_Apply`). 비교값 · 기준값은 서버가 준 것을 보이기만 한다. + * `initial_route` 한 곳에서 읽는다. 손으로 고친 노선의 다시 계산은 페이지 [저장]에서(`_Apply` · 346-3) — + * 추천 [적용]만 그 자리에서 서버 노선을 `as_initial` 로 저장한다(초기값 = 추천 노선 · 사용자 확정 · 잠금 막 · + * 끝나면 페이지 다시 세움). 비교값 · 기준값은 서버가 준 것을 보이기만 한다. * * 공통 제약 네 칸(종단기울기 % · 최소 반지름 m · 계류 이격 m · 지반 고름 m, 계획서 20 확정) * — 비우면 서버 기본값을 쓴다. 채운 칸만 요청에 실어 보내고, 그 「채운 칸」만 캐시 · @@ -41,6 +42,7 @@ import { type InitialChoice, } from "./B05_Profile_UI_RouteEdit_Initial_Model"; import { openRecommendModal } from "./B05_Profile_UI_RouteEdit_Recommend"; +import { saveRecommendedRoute } from "./B05_Profile_UI_RouteEdit_Apply"; export interface InitialPanelParams { /** 단추 · 글 요소 — 좌측 「노선 추천」 칸 것(345-4). */ @@ -61,7 +63,7 @@ export interface InitialPanelParams { export interface InitialPanel { /** 노선을 읽었다 — 창을 연 편집값 · 「고친 노선인가」 · 서버가 적어 둔 현재 초기값(`current_initial`). */ ready: (opening: RouteEditSnapshot, edited: boolean, server?: unknown) => void; - /** [확인]에 실어 보낼 지금 추천. */ + /** [저장]에 실어 보낼 지금 추천. */ current: () => { mode: RouteInitialLogic; weights: RouteInitialWeights | null; @@ -160,7 +162,18 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { render(); params.apply( copy(picked.snapshot), - `노선 추천 「${choiceLabel(picked.logic, picked.weights)}」 적용 — 다시 계산은 [확인]에서.`, + `노선 추천 「${choiceLabel(picked.logic, picked.weights)}」 적용 — 서버에 저장합니다.`, + ); + // 초기값 = 추천 노선(346-3) — 바로 저장 · 실패하면 평면에 실린 채 남아 [저장]이 다시 보냄. + void saveRecommendedRoute( + projectId, + picked.snapshot, + { + mode: picked.logic, + weights: picked.weights, + criteria: picked.criteriaOverrides, + }, + withProfileOf(picked.logic), ); }, }); @@ -193,7 +206,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { writeInitialChoice(projectId, choice); params.apply( copy(snapshot), - "초기값(예상노선 추적)으로 되돌렸습니다 — 다시 계산은 [확인]에서.", + "초기값(예상노선 추적)으로 되돌렸습니다 — 다시 계산은 [저장]에서.", ); } finally { busy = false; diff --git a/resources/tester/helper_346_3_route_save.cjs b/resources/tester/helper_346_3_route_save.cjs new file mode 100644 index 000000000..9cd166030 --- /dev/null +++ b/resources/tester/helper_346_3_route_save.cjs @@ -0,0 +1,171 @@ +/* 346-3 노선 저장 이음 헬퍼 — `B05_Profile_UI_RouteEdit_Apply.ts` 를 트랜스파일해 서버 · 화면 창구는 가짜로, + * 가게 · 캐시(`design_store` · `b_page_state`)는 진짜로 돌림. 보낸 본문 · 부른 차례를 적어 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 memoryStorage() { + const map = new Map(); + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => map.set(key, String(value)), + removeItem: (key) => map.delete(key), + key: (index) => [...map.keys()][index] ?? null, + get length() { + return map.size; + }, + }; +} +const session = memoryStorage(); +globalThis.window = { sessionStorage: session, localStorage: memoryStorage() }; +globalThis.sessionStorage = session; + +const calls = []; +const bodies = []; +let failReplan = false; +const STUBS = { + "@ui/ui_template_elements": { + showToast: (text, kind) => calls.push(`toast:${kind}:${text}`), + showLoadingOverlay: () => calls.push("overlay:on"), + hideLoadingOverlay: () => calls.push("overlay:off"), + }, + "../A00_Common/router": { + currentRoute: () => "b05-profile", + remountRoute: () => calls.push("remount"), + }, + "../B06_Section/B06_Section_Section_Store": { + invalidateSectionDetail: (_p, routeId) => calls.push(`invalidate:${routeId ?? "all"}`), + loadSectionDetail: async (_p, routeId) => (calls.push(`load:${routeId}`), { tag: routeId }), + saveCachedCrossPatches: async (_p, routeId) => (calls.push(`crossSave:${routeId}`), 1), + }, + "../B06_Section/B06_Section_Station_Keys": { + useStationKeys: (_p, routeId, detail) => calls.push(`keys:${routeId}:${detail.tag}`), + carryStationDrafts: (_p, from, to) => (calls.push(`carry:${from}->${to}`), 2), + }, + "./B05_Profile_Api_Fetch": { clearRouteLatestCache: () => calls.push("latest:clear") }, + "./B05_Profile_Api_Replan": { + replanRoute: async (_p, body) => { + bodies.push(JSON.parse(JSON.stringify(body))); + calls.push("replan"); + if (failReplan) throw new Error("체인 실패"); + return { + route_id: 9, + pipes: { kept: 3, crossing: 1, concave: 2 }, + structures: { moved: 1, far: 0 }, + cross_dropped: 1, + drainage_stale: true, + }; + }, + }, +}; + +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const { useDesignStore } = loadTs(path.join(ROOT, "A00_Common", "design", "design_store.ts")); +const { readState, writeState } = loadTs(path.join(ROOT, "A00_Common", "b_page_state.ts")); +const apply = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Apply.ts")); + +(async () => { + const out = {}; + const draft = { + rev: 3, + nodes: [ + [0, 0], + [5, 5], + [10, 0], + ], + curveOn: [true, true, true], + }; + // 편집기 없음 — 가게 routeDraft 를 보냄 · 없으면 null + out.noneAtAll = await apply.pendingRouteSave("p1"); + useDesignStore("p1", null).set("routeDraft", draft); + out.storeOnly = await apply.pendingRouteSave("p1"); + + // 편집기 등록 — 안 바뀌었으면 null(가게에 초안이 있어도 편집기가 정함) · 바뀌면 계약 꼴 + let changed = false; + let kept = 0; + let rebuilt = 0; + apply.bindRouteApply({ + projectId: "p1", + nodes: () => ({ + planned: [ + [0, 0], + [5, 5], + [10, 0], + ], + curveOn: [true, false], + curveRadius: [null, 25], + }), + changed: () => changed, + terrain: () => "sheet", + stationSnap: () => true, + initial: { current: () => ({ mode: "follow" }), keep: () => (kept += 1) }, + profileShown: async () => true, + onApplied: () => (rebuilt += 1), + }); + out.unchanged = await apply.pendingRouteSave("p1"); + changed = true; + const body = await apply.pendingRouteSave("p1"); + out.body = body; + + // 저장 — 가게 초안 비움 · 결과 캐시 비움 · 추천 다시 적음 + writeState("design-stations", [{ k: 1 }], "p1"); + const saved = await apply.saveRouteDraft("p1", body); + out.savedRouteId = saved.route_id; + out.storeAfterSave = useDesignStore("p1", null).get("routeDraft"); + out.keptAfterSave = kept; + out.summary = apply.routeSaveSummary(saved, 2); + + // 저장 뒤 횡단 초안 넘김 차례 — 새 상세 → 측점 열쇠 → 넘김 → 넘긴 고침 저장 + calls.length = 0; + out.outside = await apply.carryAfterRouteSave("p1", 7, 9); + out.carryCalls = [...calls]; + + // 추천 [적용] — as_initial · 잠금 막 · 초안 비움 · 다시 세움 + useDesignStore("p1", null).set("routeDraft", draft); + writeState("pipes", [{ chainage_m: 1 }], "p1"); + calls.length = 0; + const snapshot = { + planned: [ + [1, 1], + [9, 9], + ], + curveOn: [true, true], + curveRadius: [null, null], + }; + out.recommendOk = await apply.saveRecommendedRoute("p1", snapshot, { mode: "earthwork" }, true); + out.recommendBody = bodies[bodies.length - 1]; + out.recommendCalls = calls.filter((c) => !c.startsWith("toast:")); + out.recommendDraftsLeft = { + route: useDesignStore("p1", null).get("routeDraft"), + pipes: readState("pipes", "p1"), + }; + out.rebuilt = rebuilt; + + // 추천 저장 실패 — false · 오류 한 줄 · 다시 안 세움 + failReplan = true; + calls.length = 0; + out.recommendFail = await apply.saveRecommendedRoute("p1", snapshot, null, false); + out.recommendFailCalls = [...calls]; + out.rebuiltAfterFail = rebuilt; + console.log(JSON.stringify(out)); +})(); diff --git a/resources/tester/test_346_3_route_save.py b/resources/tester/test_346_3_route_save.py index 3a0618cb5..50aa79fef 100644 --- a/resources/tester/test_346_3_route_save.py +++ b/resources/tester/test_346_3_route_save.py @@ -1,561 +1,108 @@ -# -*- coding: utf-8 -*- -"""346-3 노선 저장 서버 길 — 차례 · 관 지킴 · 새 교차점 기본 관 · 오목 보충 · 이월 열쇠 · -초기값 규칙 · -배수유역 낡음 · 실패 때 옛 노선 되살림. +"""346-3 화면 — [노선 반영] 없앰 · [저장]이 노선부터 · 추천 [적용] = as_initial 저장 · 저장 뒤 초안 넘김. -순수 부품(`B05_Profile_Route_Save_Parts`)은 값으로 · 체인(`run_route_save_chain`)과 API -(`replan_route`)는 DB · 엔진을 가짜로 바꿔 차례 · 되살림 · 초기값만 잰다. +노선 이음(`B05_Profile_UI_RouteEdit_Apply`)은 Node 헬퍼 `helper_346_3_route_save.cjs` 가 서버 · 화면 창구만 +가짜로 두고 돌림 · 저장 차례(노선 먼저 · 안 바뀌면 안 보냄 · 실패 때 초안 남음)는 `test_design_store_save.py`. """ from __future__ import annotations -import asyncio import json -import sys +import subprocess from pathlib import Path -from types import SimpleNamespace -from uuid import uuid4 -import numpy as np import pytest -ROOT = Path(__file__).resolve().parents[2] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from fastapi.responses import JSONResponse # noqa: E402 - -import B03_FileInput.B03_FileInput_Service_Chain as chain # noqa: E402 -import B04_PreProcess.B04_PreProcess_Router_Watershed as watershed # noqa: E402 -import B05_Profile.B05_Profile_Route_Save as save # noqa: E402 -import B05_Profile.B05_Profile_Router as b05_router # noqa: E402 -import B05_Profile.B05_Profile_Router_Replan as replan # noqa: E402 -import B05_Profile.B05_Profile_Router_RouteInitial as route_initial # noqa: E402 -import B05_Profile.B05_Profile_Structures_Router as structures_router # noqa: E402 -import B06_Section.B06_Section_Repository as b06_repo # noqa: E402 -import B06_Section.B06_Section_Router as b06_router # noqa: E402 -import B06_Section.B06_Section_Router_Confirm as b06_confirm # noqa: E402 -import common_util.common_util_drainage_pipes as drainage_pipes # noqa: E402 -import common_util.common_util_initial_snapshot as snapshot # noqa: E402 -import common_util.common_util_workflow_state as workflow # noqa: E402 -from B05_Profile.B05_Profile_Route_Save_Parts import ( # noqa: E402 - add_crossing_pipes, - carry_cross_inputs, - concave_chainages, - fill_concave_pipes, - follow_pipes, - station_keys, - vertices_of, -) -from common_util.common_util_drainage_pipes import ( # noqa: E402 - PIPE_SOURCE_SPACING, - PIPE_SOURCE_STREAM, - PipePoint, - analysis_route_signature, - route_signature, -) - -OLD = [(0.0, 0.0), (100.0, 0.0), (200.0, 0.0), (300.0, 0.0), (400.0, 0.0)] -# 새 노선 = 100 ~ 300 m 를 북으로 휘게(앞 · 뒤는 옛 노선 위) -NEW = [(0.0, 0.0), (100.0, 0.0), (200.0, 60.0), (300.0, 0.0), (400.0, 0.0)] +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] -# ── 관 따라감 · 교차점 · 오목 보충 ───────────────────────────────────────────── +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") -def test_관은_지우지_않고_좌표를_따라감(): - old = [ - PipePoint(chainage_m=50.0, x=50.0, y=0.0, options={"pipe_kind": "흄관"}), - PipePoint(chainage_m=350.0, x=350.0, y=0.0, start_m=345.0, end_m=355.0), - ] - pairs = follow_pipes(old, vertices_of(NEW)) - assert [pipe_id for pipe_id, _ in pairs] == [0, 1] - assert pairs[0][1].options == {"pipe_kind": "흄관"} # 사용자 관 속성 그대로 - shift = pairs[1][1].chainage_m - 350.0 # 새 노선이 길어진 만큼 뒤 관이 밀림 - assert shift > 30 and pairs[1][1].start_m == pytest.approx(345.0 + shift) - assert old[1].chainage_m == 350.0 # 옛 목록은 안 고침 - - -def test_새_교차점에만_기본_관(): - pairs = follow_pipes([PipePoint(chainage_m=50.0, x=50.0, y=0.0)], vertices_of(NEW)) - crossings = [ - SimpleNamespace(chainage_m=20.0, x=20.0, y=0.0), # 옛 노선 위 — 사용자가 지운 관 안 되살림 - SimpleNamespace(chainage_m=150.4, x=150.0, y=30.0), # 새 자리 · 관 없음 → 더함 - SimpleNamespace(chainage_m=52.0, x=52.0, y=0.0), # 옛 자리 - ] - added = add_crossing_pipes(pairs, crossings, OLD, 456.0) - assert added == 1 - new = [pipe for pipe_id, pipe in pairs if pipe_id < 0] - assert new[0].chainage_m == 150.0 and new[0].source == PIPE_SOURCE_STREAM - # 같은 자리 다시 = 이미 관이 있음 - assert add_crossing_pipes(pairs, crossings, OLD, 456.0) == 0 - - -def test_오목_지점_보충은_새_자리의_오목만(): - vertices = vertices_of(NEW) - total = vertices[-1].chainage_m - chainage = np.arange(0.0, total, 2.0) - ground = 100.0 + np.abs(chainage - 220.0) * 0.05 # 220 m 에 골(새 자리) - ground[chainage < 60] = ( - 100.0 + np.abs(chainage[chainage < 60] - 30.0) * 0.05 - ) # 30 m 골(옛 자리) - concave = concave_chainages(chainage, ground) - assert any(abs(at - 220.0) < 3 for at, _ in concave) - pairs: list = [] - added = fill_concave_pipes(pairs, concave, vertices, OLD) - places = [pipe.chainage_m for _, pipe in pairs] - assert added >= 1 and all(pipe.source == PIPE_SOURCE_SPACING for _, pipe in pairs) - assert any(abs(at - 220.0) <= 3 for at in places) - assert not any(at < 60 for at in places) # 옛 노선 위 오목엔 안 둠 - - -def test_간격이_좁으면_보충_없음(): - vertices = vertices_of(NEW) - pairs = [(0, PipePoint(chainage_m=at)) for at in (0.0, 90.0, 180.0, 270.0, 360.0, 450.0)] - concave = [(220.0, 99.0)] - assert fill_concave_pipes(pairs, concave, vertices, OLD) == 0 - - -# ── 측점 열쇠 · 횡단 이월 ────────────────────────────────────────────────────── - - -def _structure(sid, type_id, anchor, start=None, end=None): - return SimpleNamespace( - structure_id=sid, - type_id=type_id, - chainage_m=anchor, - start_m=start, - end_m=end, - options={}, +@pytest.fixture(scope="module") +def out() -> dict: + run = subprocess.run( + ["node", str(HERE / "helper_346_3_route_save.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + check=True, ) + return json.loads(run.stdout) -def test_측점_열쇠_규칙(): - stations = [{"chainage_m": s} for s in (0.0, 20.0, 40.0, 50.0, 60.0, 77.0, 80.0, 95.5)] - pipes = [(3, PipePoint(chainage_m=50.0))] - structures = [_structure("w1", "retaining_wall", 77.0, 60.0, 80.0)] - keys = station_keys(stations, pipes, structures, 20.0) - assert keys == { - "end:BP": 0.0, - "grid:1": 20.0, - "grid:2": 40.0, - "pipe:3": 50.0, - "structure:w1:start": 60.0, - "structure:w1": 77.0, - "structure:w1:end": 80.0, - "end:EP": 95.5, +def test_노선_안_바뀜_안_보냄(out): + assert out["noneAtAll"] is None + assert out["unchanged"] is None # 편집기가 정함 — 가게에 초안이 남아 있어도 + assert out["storeOnly"]["draft"]["rev"] == 3 # 편집기 없으면 가게 routeDraft + + +def test_초안은_계약_RouteDraft_꼴(out): + draft = out["body"]["draft"] + assert set(draft) == { + "rev", + "nodes", + "curveOn", + "curveRadius", + "curveLock", + "curveArc", + "stationSnap", + "terrain", } - - -def test_이월은_열쇠로_누가거리가_밀려도(): - old_keys = { - "end:BP": 0.0, - "grid:1": 20.0, - "pipe:0": 50.0, - "structure:w1": 77.0, - "grid:5": 100.0, - } - new_keys = {"end:BP": 0.0, "grid:1": 20.0, "pipe:0": 57.0, "structure:w1": 84.0} - designs = { - 0.0: {"ground_type": "soil"}, - 20.0: {"ground_type": "rock", "berm": {"on": True}, "cut_area_m2": 9.0}, - 50.0: {"ditch_type": "u", "inlet_structure": "basin", "basin_adjust": {"d": 1}}, - 77.0: {"rock_boundary_offset_m": 0.8, "revet_adjust": {"h": 2}}, - 100.0: {"ground_type": "soil"}, - } - inputs, adjusts, dropped = carry_cross_inputs( - old_keys, - new_keys, - designs, - ("ground_type", "ditch_type", "berm", "rock_boundary_offset_m"), - ("inlet_structure", "basin_adjust", "revet_adjust"), + n = len(draft["nodes"]) + assert all( + len(draft[name]) == n for name in ("curveOn", "curveRadius", "curveLock", "curveArc") ) - assert inputs == { - 0.0: {"ground_type": "soil"}, - 20.0: {"ground_type": "rock", "berm": {"on": True}}, # 결과 칸(면적)은 안 넘김 - 57.0: {"ditch_type": "u"}, - 84.0: {"rock_boundary_offset_m": 0.8}, - } - # 구조물 조정은 관 · 구조물 열쇠만 따라감 - assert adjusts == { - 57.0: {"inlet_structure": "basin", "basin_adjust": {"d": 1}}, - 84.0: {"revet_adjust": {"h": 2}}, - } - assert dropped == 1 # grid:5 — 새 노선에 없음(노선 밖 고침) + assert draft["curveOn"] == [True, False, True] and draft["curveRadius"] == [None, 25, None] + assert draft["stationSnap"] is True and draft["terrain"] == "sheet" + assert out["body"]["initial"] == {"mode": "follow"} and out["body"]["balanced_profile"] is True + assert "as_initial" not in out["body"] # 손으로 고친 노선 = 초기값 그대로 -# ── 노선 초안 요청 ──────────────────────────────────────────────────────────── +def test_저장_뒤_가게_초안_비움_결과_한_줄(out): + assert out["savedRouteId"] == 9 + assert out["storeAfterSave"] is None + assert out["keptAfterSave"] == 1 + assert out["summary"] == ( + "노선 저장 — 관 지킴 3 · 새 교차 관 1 · 오목 보충 관 2 · 구조물 옮김 1 · 멀어짐 0 · " + "노선 밖 고침 3 건 · 배수유역 낡음" + ) # 서버 cross_dropped 1 + 화면 넘김 밖 2 = 한 줄 -def test_초안_모양을_옛_칸으로(): - body = save.RouteSaveRequest( - draft={ - "rev": 3, - "nodes": [[0, 0], [10, 5], [20, 0]], - "curveOn": [True, False, True], - "curveRadius": [None, 30.0, None], - "curveLock": [None, "radius", None], - "curveArc": [None, None, None], - "stationSnap": True, - "terrain": "sheet", - } - ) - request = body.resolved() - assert [(v.x, v.y, v.curve, v.radius_m) for v in request.vertices] == [ - (0, 0, True, None), - (10, 5, False, 30.0), - (20, 0, True, None), - ] - assert request.station_snap is True and request.terrain == "sheet" - with pytest.raises(ValueError): - save.RouteSaveRequest(draft={"nodes": [[0, 0], [1, 1]], "curveOn": [True]}).resolved() +def test_초안_넘김_차례(out): + assert out["carryCalls"] == ["invalidate:9", "load:9", "keys:9:9", "carry:7->9", "crossSave:9"] + assert out["outside"] == 2 -# ── 체인 차례 · 되살림 · 배수유역 낡음 ───────────────────────────────────────── +def test_추천_적용은_as_initial_저장_뒤_다시_세움(out): + assert out["recommendOk"] is True + body = out["recommendBody"] + assert body["as_initial"] is True and body["initial"] == {"mode": "earthwork"} + assert body["draft"]["nodes"] == [[1, 1], [9, 9]] + assert out["recommendCalls"] == ["overlay:on", "replan", "overlay:off"] # 잠금 막 하나 + assert out["recommendDraftsLeft"] == {"route": None, "pipes": None} # 새 초기값에서 다시 + assert out["rebuilt"] == 1 -class _Cursor: - def __init__(self, log): - self.log = log - - async def __aenter__(self): - return self - - async def __aexit__(self, *exc): - return False - - async def execute(self, sql, params=None): - self.log.append(" ".join(sql.split())) +def test_추천_저장_실패는_한_줄_다시_안_세움(out): + assert out["recommendFail"] is False + errors = [c for c in out["recommendFailCalls"] if c.startswith("toast:error:")] + assert len(errors) == 1 and "체인 실패" in errors[0] + assert out["rebuiltAfterFail"] == 1 -class _Connection: - def __init__(self, log): - self.log = log - - def cursor(self, *args): - return _Cursor(self.log) - - async def begin(self): - return None - - async def commit(self): - return None - - async def rollback(self): - return None - - -class _Pool: - def __init__(self, log): - self.log = log - - def acquire(self): - connection = _Connection(self.log) - - class _Acquire: - async def __aenter__(self): - return connection - - async def __aexit__(self, *exc): - return False - - return _Acquire() - - -def _async(value, order=None, name=None): - async def fake(*args, **kwargs): - if order is not None: - order.append(name) - return value - - return fake - - -@pytest.fixture -def chain_world(monkeypatch, tmp_path): - order: list[str] = [] - sql: list[str] = [] - root = tmp_path - route_file = root / "B05_Profile" / "route" / "route_main.geojson" - route_file.parent.mkdir(parents=True) - route_file.write_text( - json.dumps({"geometry": {"coordinates": [[*p, 0] for p in OLD]}}), "utf-8" - ) - long_dir = root / "B06_Section" / "longitudinal" - long_dir.mkdir(parents=True) - (long_dir / "longitudinal.json").write_text(json.dumps({"stations": [], "old": True}), "utf-8") - drain = root / "drain" - pipes_file = drain / "edits" / "pipe_points.json" - pipes_file.parent.mkdir(parents=True) - pipes_file.write_text( - json.dumps( - {"route_signature": "old", "points": [{"chainage_m": 50.0, "x": 50.0, "y": 0.0}]} - ), - "utf-8", - ) - (drain / "00_watershed_response.json").write_text( - json.dumps({"route_signature": "old"}), "utf-8" - ) - state = {"stages": [{"stage_no": 2, "params": {"points": {}, "options": {}}}]} - - def solve(project_id, request, *, generate_sections=True): - order.append("solve") - assert generate_sections is False - route_file.write_text( - json.dumps({"geometry": {"coordinates": [[*p, 0] for p in NEW]}}), "utf-8" - ) - return _async( - SimpleNamespace( - route_id=8, - total_length_m=456.0, - route_data_path="B05_Profile/route/route_main.geojson", - ) - )() - - async def regenerate(*args, **kwargs): - order.append("sections") - (long_dir / "longitudinal.json").write_text( - json.dumps({"stations": [], "new": True}), "utf-8" - ) - return SimpleNamespace() - - async def watershed_must_not_run(*args, **kwargs): - raise AssertionError("배수유역은 체인에서 뗌") - - async def prepare(project_id): - order.append("pipes") - return {"vertices": vertices_of(NEW), "streams": []} - - monkeypatch.setattr(save, "get_db_pool", lambda: _Pool(sql)) - monkeypatch.setattr(workflow, "get_workflow_state", _async(state)) - monkeypatch.setattr(workflow, "start_stage", _async(None, order, "restore_params")) - monkeypatch.setattr( - b06_repo, - "get_longitudinal_section", - _async( - {"data": {}, "longitudinal_file_path": "B06_Section/longitudinal/longitudinal.json"} - ), - ) - monkeypatch.setattr(b06_repo, "get_cross_section_designs", _async([])) - monkeypatch.setattr(b06_repo, "get_latest_section_options", _async({})) - monkeypatch.setattr( - structures_router, - "follow_structures_to_route", - lambda *a: order.append("structures") or {"moved": 0, "far": 0}, - ) - monkeypatch.setattr( - chain, - "redesign_solve_request", - lambda *a: b05_router.RouteSolveRequest( - filter_key="csf", - method="dtm", - smooth=False, - algorithm="as_planned", - bp={"x": 0, "y": 0}, - ep={"x": 400, "y": 0}, - ), - ) - monkeypatch.setattr(b05_router, "solve_route_record", solve) - monkeypatch.setattr(b05_router, "confirm_latest_route", _async({})) - monkeypatch.setattr(watershed, "get_primary_region", watershed_must_not_run) - monkeypatch.setattr(watershed, "_prepare", prepare) - monkeypatch.setattr(drainage_pipes, "clear_pipe_points", watershed_must_not_run) - monkeypatch.setattr(drainage_pipes, "drainage_dir", lambda stored: drain) - monkeypatch.setattr(save, "_ground_along", lambda *a: (np.zeros(0), np.zeros(0))) - monkeypatch.setattr(b06_router, "regenerate_route_sections", regenerate) - monkeypatch.setattr(save, "_carry_profile", _async(None, order, "profile")) - monkeypatch.setattr(save, "_carry_cross", _async(2, order, "cross")) - monkeypatch.setattr( - b06_confirm, "confirm_sections", _async(SimpleNamespace(), order, "confirm") - ) - monkeypatch.setattr(snapshot, "mark_designing", lambda root: None) - monkeypatch.setattr(snapshot, "clear_designing", lambda root: None) - return SimpleNamespace( - order=order, - sql=sql, - root=root, - route_file=route_file, - pipes_file=pipes_file, - long_dir=long_dir, - drain=drain, - ) - - -def _run(world, **kwargs): - old_route = {"id": 7, "route_data_path": "B05_Profile/route/route_main.geojson"} - return asyncio.run( - save.run_route_save_chain( - uuid4(), - world.root, - "p", - 1, - {}, - old_route, - [{"x": x, "y": y} for x, y in NEW], - {"old": 1}, - **kwargs, - ) - ) - - -def test_차례와_배수유역_뗌(chain_world): - result = _run(chain_world) - assert "error" not in result, result - assert chain_world.order == [ - "structures", - "solve", - "pipes", - "sections", - "profile", - "cross", - "confirm", - ] - assert result["cross_dropped"] == 2 and result["drainage_stale"] is True - assert result["pipes"] == {"kept": 1, "crossing": 0, "concave": 0} - # 관 정본 = 새 노선 지문 · 배수유역 분석 지문(옛) 과 달라 B04 가 「낡음」 - saved = json.loads(chain_world.pipes_file.read_text("utf-8")) - assert saved["route_signature"] == route_signature(vertices_of(NEW)) - assert analysis_route_signature("p") != saved["route_signature"] - assert len(saved["points"]) == 1 # 사용자 관 그대로(지우지 않음) - - -def test_실패하면_옛_노선을_되살림(chain_world, monkeypatch): - monkeypatch.setattr( - b06_confirm, "confirm_sections", _async(JSONResponse(status_code=500, content={})) - ) - before_pipes = chain_world.pipes_file.read_text("utf-8") - result = _run(chain_world) - assert result["error"].startswith("종횡단 확정 실패") - assert json.loads(chain_world.route_file.read_text("utf-8"))["geometry"]["coordinates"] == [ - [*p, 0] for p in OLD - ] - assert chain_world.pipes_file.read_text("utf-8") == before_pipes - assert json.loads((chain_world.long_dir / "longitudinal.json").read_text("utf-8")) == { - "stations": [], - "old": True, - } - assert any( - sql.startswith("DELETE FROM routes WHERE project_id = %s AND id > %s") - for sql in chain_world.sql - ) - assert chain_world.order[-1] == "restore_params" - assert not list(chain_world.root.glob("route_save_*")) # 손에 쥔 사본 치움 - - -def test_추천_종단_반영이면_균형_종단(chain_world, monkeypatch): - monkeypatch.setattr( - route_initial, "apply_profile_target", _async(None, chain_world.order, "balanced") - ) - _run(chain_world, profile_target=([0.0, 456.0], [100.0, 110.0])) - assert "balanced" in chain_world.order and "profile" not in chain_world.order - - -# ── 초기값 규칙 ─────────────────────────────────────────────────────────────── - - -@pytest.fixture -def api_world(monkeypatch, tmp_path): - calls: list = [] - monkeypatch.setattr(replan, "_project_paths", _async((tmp_path, "p"))) - monkeypatch.setattr(replan, "_ensure_expected_route", lambda root: None) - monkeypatch.setattr(replan, "_plan_criteria", _async((20.0, 0.0, 0.0))) - monkeypatch.setattr(replan, "_ensure_planned_initial", lambda *a: None) - monkeypatch.setattr( - replan, - "_write_planned_polyline", - lambda *a, **k: {"vertices": 3, "curves": 0, "violations": 0}, - ) - monkeypatch.setattr(replan, "_recompute", _async({"route_id": 9, "total_length_m": 10.0})) - monkeypatch.setattr(replan, "read_initial_choice", lambda root: None) - monkeypatch.setattr( - route_initial, "refresh_initial_snapshot", lambda *a: calls.append(a) or _async(None)() - ) - return calls - - -def _body(**kwargs): - return save.RouteSaveRequest(vertices=[{"x": 0, "y": 0}, {"x": 5, "y": 5}], **kwargs) - - -def test_손으로_고친_노선_저장은_초기값_그대로(api_world): - result = asyncio.run(replan.replan_route(uuid4(), _body())) - assert result["initial_refreshed"] is False and api_world == [] - - -def test_추천_적용은_초기값_다시(api_world): - result = asyncio.run(replan.replan_route(uuid4(), _body(as_initial=True))) - assert result["initial_refreshed"] is True and len(api_world) == 1 - - -def test_초안_칸이_어긋나면_400(api_world): - body = save.RouteSaveRequest(draft={"nodes": [[0, 0], [1, 1]], "curveOn": [True]}) - response = asyncio.run(replan.replan_route(uuid4(), body)) - assert response.status_code == 400 - - -def _longitudinal(total: float) -> dict: - from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options - from B05_Profile.B05_Profile_Engine_Grade_Profile import design_ground_following_profile - - samples = [ - {"chainage_m": float(s), "elevation_m": 100.0 + 0.05 * s + 2.0 * np.sin(s / 17.0)} - for s in range(int(total) + 1) - ] - stations = [{"chainage_m": float(s)} for s in range(0, int(total) + 1, 20)] - longitudinal = {"samples": samples, "stations": stations} - alignment, profile = design_ground_following_profile( - longitudinal, resolve_grade_options("trunk") - ) - return {**longitudinal, "profile_alignment": alignment, "design_profiles": [profile]} - - -def test_계획고_옮김은_앞_그대로_뒤_밂(tmp_path, monkeypatch): - new_xy = [list(p) for p in NEW] - total = float(vertices_of(NEW)[-1].chainage_m) - old = _longitudinal(400.0) - old["profile_alignment"]["edits"] = { - "station_offsets": {"40.000": 0.7, "200.000": -0.4, "380.000": 0.3}, - "curve_radii": {}, - } - path = tmp_path / "B06_Section" / "longitudinal" / "longitudinal.json" - path.parent.mkdir(parents=True) - path.write_text(json.dumps(_longitudinal(total)), "utf-8") - import B05_Profile.B05_Profile_Repository as b05_repo - - monkeypatch.setattr(save, "get_db_pool", lambda: _Pool([])) - monkeypatch.setattr(b05_repo, "update_longitudinal_grade_summary", _async(None)) - asyncio.run( - save._carry_profile( - tmp_path, - "B06_Section/longitudinal/longitudinal.json", - 8, - old["profile_alignment"], - [list(p) for p in OLD], - new_xy, - ) - ) - edits = json.loads(path.read_text("utf-8"))["profile_alignment"]["edits"]["station_offsets"] - delta = total - 400.0 - assert edits["40.000"] == 0.7 # 바뀐 구간 앞 — 그대로 - assert "200.000" not in edits # 바뀐 구간 안 — 새 지반 - assert edits[f"{380.0 + delta:.3f}"] == 0.3 # 뒤 — 길이 차만큼 밂 - - -def test_저장_확정이_옛_횡단_파일을_지움(tmp_path): - from B05_Profile.B05_Profile_Engine_Sections import cross_filename - - long_path = tmp_path / "B06_Section" / "longitudinal" / "longitudinal.json" - cross_dir = tmp_path / "B06_Section" / "cross_sections" - long_path.parent.mkdir(parents=True) - cross_dir.mkdir(parents=True) - long_path.write_text(json.dumps({"stations": [{"chainage_m": 0.0}, {"chainage_m": 20.0}]})) - for at in (0.0, 20.0, 37.0): - (cross_dir / cross_filename(at)).write_text("{}") - b06_confirm._prune_cross_files(tmp_path, "B06_Section/longitudinal/longitudinal.json") - assert sorted(p.name for p in cross_dir.glob("cross_*.json")) == sorted( - cross_filename(at) for at in (0.0, 20.0) - ) - source = (ROOT / "B06_Section" / "B06_Section_Router_Confirm.py").read_text("utf-8") - for name in ("async def save_sections(", "async def confirm_sections("): - body = source[source.index(name) :] - assert body.index("_stand_patch_stations(") < body.index("_prune_cross_files,") +def test_노선_반영_단추_없앰_배선(): + chrome = _read("B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts") + assert 'data-act="apply"' not in chrome and "노선 반영" not in chrome + apply = _read("B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts") + assert "addEventListener" not in apply # 단추 듣기 없음 — [저장]이 부름 + save = _read("A00_Common/design/design_save.ts") + assert save.index('"route",') < save.index('"pipes",') + assert "await pendingRouteSave(ctx.projectId)" in save + assert "carryAfterRouteSave(ctx.projectId, fromRouteId, routeId)" in save + initial = _read("B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts") + at = initial.index("onApply: (picked) => {") + assert "saveRecommendedRoute(" in initial[at : at + 900] + replan = _read("B05_Profile/B05_Profile_Api_Replan.ts") + assert "body: JSON.stringify(body)" in replan and "as_initial?: boolean;" in replan From e8f32cbabbc4413c3d4532488253f3af6a9b4ebf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 12:15:15 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(=EC=8B=9C=ED=97=98):=20346-3=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EA=B8=B8=20=EC=8B=9C=ED=97=98(test=5F346?= =?UTF-8?q?=5F3=5Froute=5Fsave=20=C2=B7=20sub1)=20=EB=90=98=EC=82=B4?= =?UTF-8?q?=EB=A6=BC=20=E2=80=94=20=EA=B0=99=EC=9D=80=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=8D=AE=EC=97=88=EB=8D=98=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=8B=9C=ED=97=98=EC=9D=80=20test=5F346=5F3=5Frout?= =?UTF-8?q?e=5Fsave=5Fscreen=20=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 길 시험 = 덮기 전 판과 같음(차이 0) · 둘 함께 22 통과 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- resources/tester/test_346_3_route_save.py | 611 +++++++++++++++--- .../tester/test_346_3_route_save_screen.py | 109 ++++ 2 files changed, 641 insertions(+), 79 deletions(-) create mode 100644 resources/tester/test_346_3_route_save_screen.py diff --git a/resources/tester/test_346_3_route_save.py b/resources/tester/test_346_3_route_save.py index 50aa79fef..3a0618cb5 100644 --- a/resources/tester/test_346_3_route_save.py +++ b/resources/tester/test_346_3_route_save.py @@ -1,108 +1,561 @@ -"""346-3 화면 — [노선 반영] 없앰 · [저장]이 노선부터 · 추천 [적용] = as_initial 저장 · 저장 뒤 초안 넘김. +# -*- coding: utf-8 -*- +"""346-3 노선 저장 서버 길 — 차례 · 관 지킴 · 새 교차점 기본 관 · 오목 보충 · 이월 열쇠 · +초기값 규칙 · +배수유역 낡음 · 실패 때 옛 노선 되살림. -노선 이음(`B05_Profile_UI_RouteEdit_Apply`)은 Node 헬퍼 `helper_346_3_route_save.cjs` 가 서버 · 화면 창구만 -가짜로 두고 돌림 · 저장 차례(노선 먼저 · 안 바뀌면 안 보냄 · 실패 때 초안 남음)는 `test_design_store_save.py`. +순수 부품(`B05_Profile_Route_Save_Parts`)은 값으로 · 체인(`run_route_save_chain`)과 API +(`replan_route`)는 DB · 엔진을 가짜로 바꿔 차례 · 되살림 · 초기값만 잰다. """ from __future__ import annotations +import asyncio import json -import subprocess +import sys from pathlib import Path +from types import SimpleNamespace +from uuid import uuid4 +import numpy as np import pytest -HERE = Path(__file__).resolve().parent -ROOT = HERE.parents[1] +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from fastapi.responses import JSONResponse # noqa: E402 + +import B03_FileInput.B03_FileInput_Service_Chain as chain # noqa: E402 +import B04_PreProcess.B04_PreProcess_Router_Watershed as watershed # noqa: E402 +import B05_Profile.B05_Profile_Route_Save as save # noqa: E402 +import B05_Profile.B05_Profile_Router as b05_router # noqa: E402 +import B05_Profile.B05_Profile_Router_Replan as replan # noqa: E402 +import B05_Profile.B05_Profile_Router_RouteInitial as route_initial # noqa: E402 +import B05_Profile.B05_Profile_Structures_Router as structures_router # noqa: E402 +import B06_Section.B06_Section_Repository as b06_repo # noqa: E402 +import B06_Section.B06_Section_Router as b06_router # noqa: E402 +import B06_Section.B06_Section_Router_Confirm as b06_confirm # noqa: E402 +import common_util.common_util_drainage_pipes as drainage_pipes # noqa: E402 +import common_util.common_util_initial_snapshot as snapshot # noqa: E402 +import common_util.common_util_workflow_state as workflow # noqa: E402 +from B05_Profile.B05_Profile_Route_Save_Parts import ( # noqa: E402 + add_crossing_pipes, + carry_cross_inputs, + concave_chainages, + fill_concave_pipes, + follow_pipes, + station_keys, + vertices_of, +) +from common_util.common_util_drainage_pipes import ( # noqa: E402 + PIPE_SOURCE_SPACING, + PIPE_SOURCE_STREAM, + PipePoint, + analysis_route_signature, + route_signature, +) + +OLD = [(0.0, 0.0), (100.0, 0.0), (200.0, 0.0), (300.0, 0.0), (400.0, 0.0)] +# 새 노선 = 100 ~ 300 m 를 북으로 휘게(앞 · 뒤는 옛 노선 위) +NEW = [(0.0, 0.0), (100.0, 0.0), (200.0, 60.0), (300.0, 0.0), (400.0, 0.0)] -def _read(rel: str) -> str: - return (ROOT / rel).read_text(encoding="utf-8") +# ── 관 따라감 · 교차점 · 오목 보충 ───────────────────────────────────────────── -@pytest.fixture(scope="module") -def out() -> dict: - run = subprocess.run( - ["node", str(HERE / "helper_346_3_route_save.cjs")], - capture_output=True, - text=True, - encoding="utf-8", - check=True, +def test_관은_지우지_않고_좌표를_따라감(): + old = [ + PipePoint(chainage_m=50.0, x=50.0, y=0.0, options={"pipe_kind": "흄관"}), + PipePoint(chainage_m=350.0, x=350.0, y=0.0, start_m=345.0, end_m=355.0), + ] + pairs = follow_pipes(old, vertices_of(NEW)) + assert [pipe_id for pipe_id, _ in pairs] == [0, 1] + assert pairs[0][1].options == {"pipe_kind": "흄관"} # 사용자 관 속성 그대로 + shift = pairs[1][1].chainage_m - 350.0 # 새 노선이 길어진 만큼 뒤 관이 밀림 + assert shift > 30 and pairs[1][1].start_m == pytest.approx(345.0 + shift) + assert old[1].chainage_m == 350.0 # 옛 목록은 안 고침 + + +def test_새_교차점에만_기본_관(): + pairs = follow_pipes([PipePoint(chainage_m=50.0, x=50.0, y=0.0)], vertices_of(NEW)) + crossings = [ + SimpleNamespace(chainage_m=20.0, x=20.0, y=0.0), # 옛 노선 위 — 사용자가 지운 관 안 되살림 + SimpleNamespace(chainage_m=150.4, x=150.0, y=30.0), # 새 자리 · 관 없음 → 더함 + SimpleNamespace(chainage_m=52.0, x=52.0, y=0.0), # 옛 자리 + ] + added = add_crossing_pipes(pairs, crossings, OLD, 456.0) + assert added == 1 + new = [pipe for pipe_id, pipe in pairs if pipe_id < 0] + assert new[0].chainage_m == 150.0 and new[0].source == PIPE_SOURCE_STREAM + # 같은 자리 다시 = 이미 관이 있음 + assert add_crossing_pipes(pairs, crossings, OLD, 456.0) == 0 + + +def test_오목_지점_보충은_새_자리의_오목만(): + vertices = vertices_of(NEW) + total = vertices[-1].chainage_m + chainage = np.arange(0.0, total, 2.0) + ground = 100.0 + np.abs(chainage - 220.0) * 0.05 # 220 m 에 골(새 자리) + ground[chainage < 60] = ( + 100.0 + np.abs(chainage[chainage < 60] - 30.0) * 0.05 + ) # 30 m 골(옛 자리) + concave = concave_chainages(chainage, ground) + assert any(abs(at - 220.0) < 3 for at, _ in concave) + pairs: list = [] + added = fill_concave_pipes(pairs, concave, vertices, OLD) + places = [pipe.chainage_m for _, pipe in pairs] + assert added >= 1 and all(pipe.source == PIPE_SOURCE_SPACING for _, pipe in pairs) + assert any(abs(at - 220.0) <= 3 for at in places) + assert not any(at < 60 for at in places) # 옛 노선 위 오목엔 안 둠 + + +def test_간격이_좁으면_보충_없음(): + vertices = vertices_of(NEW) + pairs = [(0, PipePoint(chainage_m=at)) for at in (0.0, 90.0, 180.0, 270.0, 360.0, 450.0)] + concave = [(220.0, 99.0)] + assert fill_concave_pipes(pairs, concave, vertices, OLD) == 0 + + +# ── 측점 열쇠 · 횡단 이월 ────────────────────────────────────────────────────── + + +def _structure(sid, type_id, anchor, start=None, end=None): + return SimpleNamespace( + structure_id=sid, + type_id=type_id, + chainage_m=anchor, + start_m=start, + end_m=end, + options={}, ) - return json.loads(run.stdout) -def test_노선_안_바뀜_안_보냄(out): - assert out["noneAtAll"] is None - assert out["unchanged"] is None # 편집기가 정함 — 가게에 초안이 남아 있어도 - assert out["storeOnly"]["draft"]["rev"] == 3 # 편집기 없으면 가게 routeDraft - - -def test_초안은_계약_RouteDraft_꼴(out): - draft = out["body"]["draft"] - assert set(draft) == { - "rev", - "nodes", - "curveOn", - "curveRadius", - "curveLock", - "curveArc", - "stationSnap", - "terrain", +def test_측점_열쇠_규칙(): + stations = [{"chainage_m": s} for s in (0.0, 20.0, 40.0, 50.0, 60.0, 77.0, 80.0, 95.5)] + pipes = [(3, PipePoint(chainage_m=50.0))] + structures = [_structure("w1", "retaining_wall", 77.0, 60.0, 80.0)] + keys = station_keys(stations, pipes, structures, 20.0) + assert keys == { + "end:BP": 0.0, + "grid:1": 20.0, + "grid:2": 40.0, + "pipe:3": 50.0, + "structure:w1:start": 60.0, + "structure:w1": 77.0, + "structure:w1:end": 80.0, + "end:EP": 95.5, } - n = len(draft["nodes"]) - assert all( - len(draft[name]) == n for name in ("curveOn", "curveRadius", "curveLock", "curveArc") + + +def test_이월은_열쇠로_누가거리가_밀려도(): + old_keys = { + "end:BP": 0.0, + "grid:1": 20.0, + "pipe:0": 50.0, + "structure:w1": 77.0, + "grid:5": 100.0, + } + new_keys = {"end:BP": 0.0, "grid:1": 20.0, "pipe:0": 57.0, "structure:w1": 84.0} + designs = { + 0.0: {"ground_type": "soil"}, + 20.0: {"ground_type": "rock", "berm": {"on": True}, "cut_area_m2": 9.0}, + 50.0: {"ditch_type": "u", "inlet_structure": "basin", "basin_adjust": {"d": 1}}, + 77.0: {"rock_boundary_offset_m": 0.8, "revet_adjust": {"h": 2}}, + 100.0: {"ground_type": "soil"}, + } + inputs, adjusts, dropped = carry_cross_inputs( + old_keys, + new_keys, + designs, + ("ground_type", "ditch_type", "berm", "rock_boundary_offset_m"), + ("inlet_structure", "basin_adjust", "revet_adjust"), ) - assert draft["curveOn"] == [True, False, True] and draft["curveRadius"] == [None, 25, None] - assert draft["stationSnap"] is True and draft["terrain"] == "sheet" - assert out["body"]["initial"] == {"mode": "follow"} and out["body"]["balanced_profile"] is True - assert "as_initial" not in out["body"] # 손으로 고친 노선 = 초기값 그대로 + assert inputs == { + 0.0: {"ground_type": "soil"}, + 20.0: {"ground_type": "rock", "berm": {"on": True}}, # 결과 칸(면적)은 안 넘김 + 57.0: {"ditch_type": "u"}, + 84.0: {"rock_boundary_offset_m": 0.8}, + } + # 구조물 조정은 관 · 구조물 열쇠만 따라감 + assert adjusts == { + 57.0: {"inlet_structure": "basin", "basin_adjust": {"d": 1}}, + 84.0: {"revet_adjust": {"h": 2}}, + } + assert dropped == 1 # grid:5 — 새 노선에 없음(노선 밖 고침) -def test_저장_뒤_가게_초안_비움_결과_한_줄(out): - assert out["savedRouteId"] == 9 - assert out["storeAfterSave"] is None - assert out["keptAfterSave"] == 1 - assert out["summary"] == ( - "노선 저장 — 관 지킴 3 · 새 교차 관 1 · 오목 보충 관 2 · 구조물 옮김 1 · 멀어짐 0 · " - "노선 밖 고침 3 건 · 배수유역 낡음" - ) # 서버 cross_dropped 1 + 화면 넘김 밖 2 = 한 줄 +# ── 노선 초안 요청 ──────────────────────────────────────────────────────────── -def test_초안_넘김_차례(out): - assert out["carryCalls"] == ["invalidate:9", "load:9", "keys:9:9", "carry:7->9", "crossSave:9"] - assert out["outside"] == 2 +def test_초안_모양을_옛_칸으로(): + body = save.RouteSaveRequest( + draft={ + "rev": 3, + "nodes": [[0, 0], [10, 5], [20, 0]], + "curveOn": [True, False, True], + "curveRadius": [None, 30.0, None], + "curveLock": [None, "radius", None], + "curveArc": [None, None, None], + "stationSnap": True, + "terrain": "sheet", + } + ) + request = body.resolved() + assert [(v.x, v.y, v.curve, v.radius_m) for v in request.vertices] == [ + (0, 0, True, None), + (10, 5, False, 30.0), + (20, 0, True, None), + ] + assert request.station_snap is True and request.terrain == "sheet" + with pytest.raises(ValueError): + save.RouteSaveRequest(draft={"nodes": [[0, 0], [1, 1]], "curveOn": [True]}).resolved() -def test_추천_적용은_as_initial_저장_뒤_다시_세움(out): - assert out["recommendOk"] is True - body = out["recommendBody"] - assert body["as_initial"] is True and body["initial"] == {"mode": "earthwork"} - assert body["draft"]["nodes"] == [[1, 1], [9, 9]] - assert out["recommendCalls"] == ["overlay:on", "replan", "overlay:off"] # 잠금 막 하나 - assert out["recommendDraftsLeft"] == {"route": None, "pipes": None} # 새 초기값에서 다시 - assert out["rebuilt"] == 1 +# ── 체인 차례 · 되살림 · 배수유역 낡음 ───────────────────────────────────────── -def test_추천_저장_실패는_한_줄_다시_안_세움(out): - assert out["recommendFail"] is False - errors = [c for c in out["recommendFailCalls"] if c.startswith("toast:error:")] - assert len(errors) == 1 and "체인 실패" in errors[0] - assert out["rebuiltAfterFail"] == 1 +class _Cursor: + def __init__(self, log): + self.log = log + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def execute(self, sql, params=None): + self.log.append(" ".join(sql.split())) -def test_노선_반영_단추_없앰_배선(): - chrome = _read("B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts") - assert 'data-act="apply"' not in chrome and "노선 반영" not in chrome - apply = _read("B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts") - assert "addEventListener" not in apply # 단추 듣기 없음 — [저장]이 부름 - save = _read("A00_Common/design/design_save.ts") - assert save.index('"route",') < save.index('"pipes",') - assert "await pendingRouteSave(ctx.projectId)" in save - assert "carryAfterRouteSave(ctx.projectId, fromRouteId, routeId)" in save - initial = _read("B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts") - at = initial.index("onApply: (picked) => {") - assert "saveRecommendedRoute(" in initial[at : at + 900] - replan = _read("B05_Profile/B05_Profile_Api_Replan.ts") - assert "body: JSON.stringify(body)" in replan and "as_initial?: boolean;" in replan +class _Connection: + def __init__(self, log): + self.log = log + + def cursor(self, *args): + return _Cursor(self.log) + + async def begin(self): + return None + + async def commit(self): + return None + + async def rollback(self): + return None + + +class _Pool: + def __init__(self, log): + self.log = log + + def acquire(self): + connection = _Connection(self.log) + + class _Acquire: + async def __aenter__(self): + return connection + + async def __aexit__(self, *exc): + return False + + return _Acquire() + + +def _async(value, order=None, name=None): + async def fake(*args, **kwargs): + if order is not None: + order.append(name) + return value + + return fake + + +@pytest.fixture +def chain_world(monkeypatch, tmp_path): + order: list[str] = [] + sql: list[str] = [] + root = tmp_path + route_file = root / "B05_Profile" / "route" / "route_main.geojson" + route_file.parent.mkdir(parents=True) + route_file.write_text( + json.dumps({"geometry": {"coordinates": [[*p, 0] for p in OLD]}}), "utf-8" + ) + long_dir = root / "B06_Section" / "longitudinal" + long_dir.mkdir(parents=True) + (long_dir / "longitudinal.json").write_text(json.dumps({"stations": [], "old": True}), "utf-8") + drain = root / "drain" + pipes_file = drain / "edits" / "pipe_points.json" + pipes_file.parent.mkdir(parents=True) + pipes_file.write_text( + json.dumps( + {"route_signature": "old", "points": [{"chainage_m": 50.0, "x": 50.0, "y": 0.0}]} + ), + "utf-8", + ) + (drain / "00_watershed_response.json").write_text( + json.dumps({"route_signature": "old"}), "utf-8" + ) + state = {"stages": [{"stage_no": 2, "params": {"points": {}, "options": {}}}]} + + def solve(project_id, request, *, generate_sections=True): + order.append("solve") + assert generate_sections is False + route_file.write_text( + json.dumps({"geometry": {"coordinates": [[*p, 0] for p in NEW]}}), "utf-8" + ) + return _async( + SimpleNamespace( + route_id=8, + total_length_m=456.0, + route_data_path="B05_Profile/route/route_main.geojson", + ) + )() + + async def regenerate(*args, **kwargs): + order.append("sections") + (long_dir / "longitudinal.json").write_text( + json.dumps({"stations": [], "new": True}), "utf-8" + ) + return SimpleNamespace() + + async def watershed_must_not_run(*args, **kwargs): + raise AssertionError("배수유역은 체인에서 뗌") + + async def prepare(project_id): + order.append("pipes") + return {"vertices": vertices_of(NEW), "streams": []} + + monkeypatch.setattr(save, "get_db_pool", lambda: _Pool(sql)) + monkeypatch.setattr(workflow, "get_workflow_state", _async(state)) + monkeypatch.setattr(workflow, "start_stage", _async(None, order, "restore_params")) + monkeypatch.setattr( + b06_repo, + "get_longitudinal_section", + _async( + {"data": {}, "longitudinal_file_path": "B06_Section/longitudinal/longitudinal.json"} + ), + ) + monkeypatch.setattr(b06_repo, "get_cross_section_designs", _async([])) + monkeypatch.setattr(b06_repo, "get_latest_section_options", _async({})) + monkeypatch.setattr( + structures_router, + "follow_structures_to_route", + lambda *a: order.append("structures") or {"moved": 0, "far": 0}, + ) + monkeypatch.setattr( + chain, + "redesign_solve_request", + lambda *a: b05_router.RouteSolveRequest( + filter_key="csf", + method="dtm", + smooth=False, + algorithm="as_planned", + bp={"x": 0, "y": 0}, + ep={"x": 400, "y": 0}, + ), + ) + monkeypatch.setattr(b05_router, "solve_route_record", solve) + monkeypatch.setattr(b05_router, "confirm_latest_route", _async({})) + monkeypatch.setattr(watershed, "get_primary_region", watershed_must_not_run) + monkeypatch.setattr(watershed, "_prepare", prepare) + monkeypatch.setattr(drainage_pipes, "clear_pipe_points", watershed_must_not_run) + monkeypatch.setattr(drainage_pipes, "drainage_dir", lambda stored: drain) + monkeypatch.setattr(save, "_ground_along", lambda *a: (np.zeros(0), np.zeros(0))) + monkeypatch.setattr(b06_router, "regenerate_route_sections", regenerate) + monkeypatch.setattr(save, "_carry_profile", _async(None, order, "profile")) + monkeypatch.setattr(save, "_carry_cross", _async(2, order, "cross")) + monkeypatch.setattr( + b06_confirm, "confirm_sections", _async(SimpleNamespace(), order, "confirm") + ) + monkeypatch.setattr(snapshot, "mark_designing", lambda root: None) + monkeypatch.setattr(snapshot, "clear_designing", lambda root: None) + return SimpleNamespace( + order=order, + sql=sql, + root=root, + route_file=route_file, + pipes_file=pipes_file, + long_dir=long_dir, + drain=drain, + ) + + +def _run(world, **kwargs): + old_route = {"id": 7, "route_data_path": "B05_Profile/route/route_main.geojson"} + return asyncio.run( + save.run_route_save_chain( + uuid4(), + world.root, + "p", + 1, + {}, + old_route, + [{"x": x, "y": y} for x, y in NEW], + {"old": 1}, + **kwargs, + ) + ) + + +def test_차례와_배수유역_뗌(chain_world): + result = _run(chain_world) + assert "error" not in result, result + assert chain_world.order == [ + "structures", + "solve", + "pipes", + "sections", + "profile", + "cross", + "confirm", + ] + assert result["cross_dropped"] == 2 and result["drainage_stale"] is True + assert result["pipes"] == {"kept": 1, "crossing": 0, "concave": 0} + # 관 정본 = 새 노선 지문 · 배수유역 분석 지문(옛) 과 달라 B04 가 「낡음」 + saved = json.loads(chain_world.pipes_file.read_text("utf-8")) + assert saved["route_signature"] == route_signature(vertices_of(NEW)) + assert analysis_route_signature("p") != saved["route_signature"] + assert len(saved["points"]) == 1 # 사용자 관 그대로(지우지 않음) + + +def test_실패하면_옛_노선을_되살림(chain_world, monkeypatch): + monkeypatch.setattr( + b06_confirm, "confirm_sections", _async(JSONResponse(status_code=500, content={})) + ) + before_pipes = chain_world.pipes_file.read_text("utf-8") + result = _run(chain_world) + assert result["error"].startswith("종횡단 확정 실패") + assert json.loads(chain_world.route_file.read_text("utf-8"))["geometry"]["coordinates"] == [ + [*p, 0] for p in OLD + ] + assert chain_world.pipes_file.read_text("utf-8") == before_pipes + assert json.loads((chain_world.long_dir / "longitudinal.json").read_text("utf-8")) == { + "stations": [], + "old": True, + } + assert any( + sql.startswith("DELETE FROM routes WHERE project_id = %s AND id > %s") + for sql in chain_world.sql + ) + assert chain_world.order[-1] == "restore_params" + assert not list(chain_world.root.glob("route_save_*")) # 손에 쥔 사본 치움 + + +def test_추천_종단_반영이면_균형_종단(chain_world, monkeypatch): + monkeypatch.setattr( + route_initial, "apply_profile_target", _async(None, chain_world.order, "balanced") + ) + _run(chain_world, profile_target=([0.0, 456.0], [100.0, 110.0])) + assert "balanced" in chain_world.order and "profile" not in chain_world.order + + +# ── 초기값 규칙 ─────────────────────────────────────────────────────────────── + + +@pytest.fixture +def api_world(monkeypatch, tmp_path): + calls: list = [] + monkeypatch.setattr(replan, "_project_paths", _async((tmp_path, "p"))) + monkeypatch.setattr(replan, "_ensure_expected_route", lambda root: None) + monkeypatch.setattr(replan, "_plan_criteria", _async((20.0, 0.0, 0.0))) + monkeypatch.setattr(replan, "_ensure_planned_initial", lambda *a: None) + monkeypatch.setattr( + replan, + "_write_planned_polyline", + lambda *a, **k: {"vertices": 3, "curves": 0, "violations": 0}, + ) + monkeypatch.setattr(replan, "_recompute", _async({"route_id": 9, "total_length_m": 10.0})) + monkeypatch.setattr(replan, "read_initial_choice", lambda root: None) + monkeypatch.setattr( + route_initial, "refresh_initial_snapshot", lambda *a: calls.append(a) or _async(None)() + ) + return calls + + +def _body(**kwargs): + return save.RouteSaveRequest(vertices=[{"x": 0, "y": 0}, {"x": 5, "y": 5}], **kwargs) + + +def test_손으로_고친_노선_저장은_초기값_그대로(api_world): + result = asyncio.run(replan.replan_route(uuid4(), _body())) + assert result["initial_refreshed"] is False and api_world == [] + + +def test_추천_적용은_초기값_다시(api_world): + result = asyncio.run(replan.replan_route(uuid4(), _body(as_initial=True))) + assert result["initial_refreshed"] is True and len(api_world) == 1 + + +def test_초안_칸이_어긋나면_400(api_world): + body = save.RouteSaveRequest(draft={"nodes": [[0, 0], [1, 1]], "curveOn": [True]}) + response = asyncio.run(replan.replan_route(uuid4(), body)) + assert response.status_code == 400 + + +def _longitudinal(total: float) -> dict: + from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options + from B05_Profile.B05_Profile_Engine_Grade_Profile import design_ground_following_profile + + samples = [ + {"chainage_m": float(s), "elevation_m": 100.0 + 0.05 * s + 2.0 * np.sin(s / 17.0)} + for s in range(int(total) + 1) + ] + stations = [{"chainage_m": float(s)} for s in range(0, int(total) + 1, 20)] + longitudinal = {"samples": samples, "stations": stations} + alignment, profile = design_ground_following_profile( + longitudinal, resolve_grade_options("trunk") + ) + return {**longitudinal, "profile_alignment": alignment, "design_profiles": [profile]} + + +def test_계획고_옮김은_앞_그대로_뒤_밂(tmp_path, monkeypatch): + new_xy = [list(p) for p in NEW] + total = float(vertices_of(NEW)[-1].chainage_m) + old = _longitudinal(400.0) + old["profile_alignment"]["edits"] = { + "station_offsets": {"40.000": 0.7, "200.000": -0.4, "380.000": 0.3}, + "curve_radii": {}, + } + path = tmp_path / "B06_Section" / "longitudinal" / "longitudinal.json" + path.parent.mkdir(parents=True) + path.write_text(json.dumps(_longitudinal(total)), "utf-8") + import B05_Profile.B05_Profile_Repository as b05_repo + + monkeypatch.setattr(save, "get_db_pool", lambda: _Pool([])) + monkeypatch.setattr(b05_repo, "update_longitudinal_grade_summary", _async(None)) + asyncio.run( + save._carry_profile( + tmp_path, + "B06_Section/longitudinal/longitudinal.json", + 8, + old["profile_alignment"], + [list(p) for p in OLD], + new_xy, + ) + ) + edits = json.loads(path.read_text("utf-8"))["profile_alignment"]["edits"]["station_offsets"] + delta = total - 400.0 + assert edits["40.000"] == 0.7 # 바뀐 구간 앞 — 그대로 + assert "200.000" not in edits # 바뀐 구간 안 — 새 지반 + assert edits[f"{380.0 + delta:.3f}"] == 0.3 # 뒤 — 길이 차만큼 밂 + + +def test_저장_확정이_옛_횡단_파일을_지움(tmp_path): + from B05_Profile.B05_Profile_Engine_Sections import cross_filename + + long_path = tmp_path / "B06_Section" / "longitudinal" / "longitudinal.json" + cross_dir = tmp_path / "B06_Section" / "cross_sections" + long_path.parent.mkdir(parents=True) + cross_dir.mkdir(parents=True) + long_path.write_text(json.dumps({"stations": [{"chainage_m": 0.0}, {"chainage_m": 20.0}]})) + for at in (0.0, 20.0, 37.0): + (cross_dir / cross_filename(at)).write_text("{}") + b06_confirm._prune_cross_files(tmp_path, "B06_Section/longitudinal/longitudinal.json") + assert sorted(p.name for p in cross_dir.glob("cross_*.json")) == sorted( + cross_filename(at) for at in (0.0, 20.0) + ) + source = (ROOT / "B06_Section" / "B06_Section_Router_Confirm.py").read_text("utf-8") + for name in ("async def save_sections(", "async def confirm_sections("): + body = source[source.index(name) :] + assert body.index("_stand_patch_stations(") < body.index("_prune_cross_files,") diff --git a/resources/tester/test_346_3_route_save_screen.py b/resources/tester/test_346_3_route_save_screen.py new file mode 100644 index 000000000..154d25cb4 --- /dev/null +++ b/resources/tester/test_346_3_route_save_screen.py @@ -0,0 +1,109 @@ +"""346-3 화면 — [노선 반영] 없앰 · [저장]이 노선부터 · 추천 [적용] = as_initial 저장 · 저장 뒤 초안 넘김. + +노선 이음(`B05_Profile_UI_RouteEdit_Apply`)은 Node 헬퍼 `helper_346_3_route_save.cjs` 가 서버 · 화면 +창구만 가짜로 두고 돌림 · 서버 길 시험은 `test_346_3_route_save.py` · 저장 차례(노선 먼저 · 안 바뀌면 +안 보냄 · 실패 때 초안 남음)는 `test_design_store_save.py`. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def out() -> dict: + run = subprocess.run( + ["node", str(HERE / "helper_346_3_route_save.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(run.stdout) + + +def test_노선_안_바뀜_안_보냄(out): + assert out["noneAtAll"] is None + assert out["unchanged"] is None # 편집기가 정함 — 가게에 초안이 남아 있어도 + assert out["storeOnly"]["draft"]["rev"] == 3 # 편집기 없으면 가게 routeDraft + + +def test_초안은_계약_RouteDraft_꼴(out): + draft = out["body"]["draft"] + assert set(draft) == { + "rev", + "nodes", + "curveOn", + "curveRadius", + "curveLock", + "curveArc", + "stationSnap", + "terrain", + } + n = len(draft["nodes"]) + assert all( + len(draft[name]) == n for name in ("curveOn", "curveRadius", "curveLock", "curveArc") + ) + assert draft["curveOn"] == [True, False, True] and draft["curveRadius"] == [None, 25, None] + assert draft["stationSnap"] is True and draft["terrain"] == "sheet" + assert out["body"]["initial"] == {"mode": "follow"} and out["body"]["balanced_profile"] is True + assert "as_initial" not in out["body"] # 손으로 고친 노선 = 초기값 그대로 + + +def test_저장_뒤_가게_초안_비움_결과_한_줄(out): + assert out["savedRouteId"] == 9 + assert out["storeAfterSave"] is None + assert out["keptAfterSave"] == 1 + assert out["summary"] == ( + "노선 저장 — 관 지킴 3 · 새 교차 관 1 · 오목 보충 관 2 · 구조물 옮김 1 · 멀어짐 0 · " + "노선 밖 고침 3 건 · 배수유역 낡음" + ) # 서버 cross_dropped 1 + 화면 넘김 밖 2 = 한 줄 + + +def test_초안_넘김_차례(out): + assert out["carryCalls"] == ["invalidate:9", "load:9", "keys:9:9", "carry:7->9", "crossSave:9"] + assert out["outside"] == 2 + + +def test_추천_적용은_as_initial_저장_뒤_다시_세움(out): + assert out["recommendOk"] is True + body = out["recommendBody"] + assert body["as_initial"] is True and body["initial"] == {"mode": "earthwork"} + assert body["draft"]["nodes"] == [[1, 1], [9, 9]] + assert out["recommendCalls"] == ["overlay:on", "replan", "overlay:off"] # 잠금 막 하나 + assert out["recommendDraftsLeft"] == {"route": None, "pipes": None} # 새 초기값에서 다시 + assert out["rebuilt"] == 1 + + +def test_추천_저장_실패는_한_줄_다시_안_세움(out): + assert out["recommendFail"] is False + errors = [c for c in out["recommendFailCalls"] if c.startswith("toast:error:")] + assert len(errors) == 1 and "체인 실패" in errors[0] + assert out["rebuiltAfterFail"] == 1 + + +def test_노선_반영_단추_없앰_배선(): + chrome = _read("B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts") + assert 'data-act="apply"' not in chrome and "노선 반영" not in chrome + apply = _read("B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts") + assert "addEventListener" not in apply # 단추 듣기 없음 — [저장]이 부름 + save = _read("A00_Common/design/design_save.ts") + assert save.index('"route",') < save.index('"pipes",') + assert "await pendingRouteSave(ctx.projectId)" in save + assert "carryAfterRouteSave(ctx.projectId, fromRouteId, routeId)" in save + initial = _read("B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts") + at = initial.index("onApply: (picked) => {") + assert "saveRecommendedRoute(" in initial[at : at + 900] + replan = _read("B05_Profile/B05_Profile_Api_Replan.ts") + assert "body: JSON.stringify(body)" in replan and "as_initial?: boolean;" in replan