From 33201b14ef754c387644faf8e3166d7097db45d3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 21:29:23 +0900 Subject: [PATCH] =?UTF-8?q?fix(449=20B05=20=EC=A0=80=EC=9E=A5):=20?= =?UTF-8?q?=EA=B4=80=20=EC=A0=95=EB=B3=B8=20PUT=20=EC=9D=80=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=B4=88=EC=95=88=20=ED=95=9C=20=EB=B2=88=20=C2=B7?= =?UTF-8?q?=20=EB=85=B8=EC=84=A0=20=EC=A0=80=EC=9E=A5=20=EB=92=A4=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=EB=A9=B4=20=EC=B4=88=EC=95=88=EC=9D=84=20?= =?UTF-8?q?=EC=83=88=20=EB=85=B8=EC=84=A0=20=EC=B9=B8=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=C2=B7=20=EC=B8=A1=EC=A0=90=20=EB=A7=9E=EC=B6=A4=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=95=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 433-7 배수판 메모리 다시 PUT 걷음(판 안 선 [저장] 빈 목록 PUT 막음) · [확정] 초안 없음 = 칸 없이 다시 셈 · 판은 저장분 다시 받기만 - 433-12 노선 저장 뒤 다른 차례 실패 = 0 칸 계획고 → 새 노선 칸 · 횡단 초안 넘김(저장 없이 · 두 번 안 넘김) · 새 노선으로 다시 세움 - 433-6 관 PUT 끝 측점 맞춤 실패 = 응답 stations_failed · 알림 - 시험 test_449_save_pipes Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_save.ts | 34 +++- A00_Common/design/design_shared.ts | 11 +- B04_PreProcess/B04_PreProcess_Api_Fetch.ts | 12 +- .../B04_PreProcess_Router_Basins.py | 10 +- B05_Profile/B05_Profile_Api_Pipes_Draft.ts | 9 +- B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 13 +- .../B05_Profile_UI_Drainage_Panel_Types.ts | 8 +- B05_Profile/B05_Profile_UI_Profile_Edit.ts | 7 + B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts | 3 + resources/tester/helper_449_save_pipes.cjs | 152 ++++++++++++++++++ resources/tester/test_449_save_pipes.py | 105 ++++++++++++ 11 files changed, 328 insertions(+), 36 deletions(-) create mode 100644 resources/tester/helper_449_save_pipes.cjs create mode 100644 resources/tester/test_449_save_pipes.py diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts index 2edfd617b..7fa077b5e 100644 --- a/A00_Common/design/design_save.ts +++ b/A00_Common/design/design_save.ts @@ -9,6 +9,7 @@ * 새 노선 번호를 준다. 그 뒤 차례는 **새 노선**으로: 관 · 구조물 초안(좌표 정본) · 계획고 초안(옛 노선 칸 → * 새 노선) · 횡단 초안은 측점 열쇠로 넘겨(`carryStationDrafts`) 저장 · 결과 한 줄 · 페이지 다시 세움. * 노선 저장이 실패하면 서버가 되살리고 여기서 멈춤(다른 초안도 화면 · 세션에 그대로 · 오류 한 줄). + * 노선 저장 뒤 다른 차례가 실패하면 초안을 새 노선 칸으로 옮기고 페이지를 새 노선으로(433-12). * 어느 차례든 실패하면 그 차례에서 멈추고 차례 이름째 알림(429 ⑨ · 뒤 차례는 안 보냄). * 노선을 저장했으면 횡단은 고침이 없어도 한 번 나감 — 뒤에 나간 계획고 초안을 서버가 횡단에 다시 셈(429 ④). * @@ -38,6 +39,7 @@ import { import { markAlignmentSaved, pendingAlignment, + writeAlignmentDraft, } 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"; @@ -70,7 +72,7 @@ export interface DesignSaveContext { prepare?: () => Promise; /** * 차례마다 **기본 창구 대신** 도는 일. 없으면 기본 창구(세션 초안 → 정본)가 돈다. - * `pipes` 만 예외 — 기본(세션 초안)이 먼저 나가고 그 뒤에 돈다(화면 목록으로 한 번 더 맞춤). + * `pipes` 만 예외 — 기본(세션 초안)이 먼저 나가고 그 뒤에 돈다(저장분으로 화면만 맞춤 · PUT 없음 · 433-7). */ steps?: Partial, () => Promise>>; /** 횡단 — 보기마다 본문이 다르다. `confirm` 이면 단계를 닫는 길로. */ @@ -140,16 +142,35 @@ async function flushAlignmentDraft( if (moved) markAlignmentSaved(DRAFT_ROUTE_ID); } +/** 노선은 저장했는데 뒤 차례가 실패 — 계획고 초안(노선 초안 칸 · 옛 노선 칸)을 새 노선 칸에 · 횡단 초안을 새 + * 측점 열쇠로(저장 없이 · 이미 넘겼으면 안 함) · 페이지를 새 노선으로 다시 세움(433-12). 다음 [저장]이 그대로 보냄. */ +async function keepDraftsOnNewRoute( + projectId: string, + fromRouteId: number, + routeId: number, + carried: boolean, +): Promise { + const draft = pendingAlignment(DRAFT_ROUTE_ID) ?? pendingAlignment(fromRouteId); + if (draft) { + writeAlignmentDraft(routeId, draft); + markAlignmentSaved(DRAFT_ROUTE_ID); + markAlignmentSaved(fromRouteId); + } + if (!carried) await carryAfterRouteSave(projectId, fromRouteId, routeId, { save: false }); + await rebuildAfterRouteSave(projectId); +} + /** 차례마다 기본 창구. */ function defaultStep( step: Exclude, projectId: string, routeId: number, fromRouteId: number = routeId, + confirm = false, ): () => Promise { switch (step) { case "pipes": - return () => flushPendingPipes(projectId); + return () => flushPendingPipes(projectId, confirm); case "structures": return () => flushPendingStructures(projectId); case "plusStations": // 계획고 앞 — 추가 측점 자리의 계획고 편집이 버려지지 않게(368-2) @@ -197,6 +218,8 @@ export async function saveDesign( let routeId = fromRouteId; /** 노선을 새로 저장했으면 그 결과 — 뒤 차례는 새 노선 · 화면 몫(옛 노선 판) 일은 건너뜀. */ let routeSaved: Awaited> | null = null; + /** 횡단 초안을 새 노선으로 넘기기 시작함 — 실패 길에서 두 번 넘기지 않게(누가거리 칸은 제자리에서 옮김). */ + let carried = false; showLoadingOverlay(); saving.add(ctx.projectId); try { @@ -215,6 +238,7 @@ export async function saveDesign( if (step === "cross") { if (!routeSaved) await ctx.cross(confirm); else { + carried = true; const outside = await carryAfterRouteSave(ctx.projectId, fromRouteId, routeId); showToast(routeSaveSummary(routeSaved, outside), "info"); } @@ -224,7 +248,7 @@ export async function saveDesign( if (step === "uphill" && routeSaved) continue; // 노선이 바뀌면 화면 몫 일(옛 노선 판 목록 · 계획고 판)은 건너뛰고 기본 창구만 새 노선으로. const own = routeSaved ? undefined : ctx.steps?.[step]; - const base = defaultStep(step, ctx.projectId, routeId, fromRouteId); + const base = defaultStep(step, ctx.projectId, routeId, fromRouteId, confirm); const run = step === "pipes" ? async () => { @@ -250,6 +274,10 @@ export async function saveDesign( await ctx.done?.(confirm); return true; } catch (error) { + if (routeSaved) + await keepDraftsOnNewRoute(ctx.projectId, fromRouteId, routeId, carried).catch( + () => undefined, // 못 옮김 — 초안은 옛 칸에 남음(알림은 아래 한 줄) + ); ctx.failed(error, confirm); return false; } finally { diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 66069900c..de9a99620 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -393,14 +393,9 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea routeId: () => routeIdOf(shared), prepare: crossSave?.prepare, steps: { - // 세션 초안이 나간 뒤 배수유역도 목록으로 한 번 더 맞춘다(시설 되붙이기 · 응답으로 화면 갱신). - ...(side - ? { - pipes: async () => { - await side.savePipes(confirm); - }, - } - : {}), + // 관 정본 PUT 은 세션 초안 한 번(기본 창구) — 배수유역도는 저장분을 다시 받아 화면만 맞춤(433-7 · 판 + // 메모리를 다시 보내면 B06 고침 직후 · 판이 안 선 [저장]에서 관을 잃음). + ...(side ? { pipes: async () => side.reload(confirm) } : {}), // 계획고 초안 — 한 벌 종단도가 저장하고 다시 그린다. ...(profile ? { alignment: () => profile.save() } : {}), ...profileSave?.steps, diff --git a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts index 89744f458..6106be9bc 100644 --- a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts +++ b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts @@ -588,6 +588,8 @@ export interface DetailBasinResponse { upstream_lonlat: Array>; /** [저장] 때 못 만든 구조물 조합 사본 폴더(142-2 · `*` = 통째 실패). */ snapshot_failed?: string[]; + /** 관 PUT 끝 측점 맞춤 실패(433-6 · 관 저장은 그대로). */ + stations_failed?: boolean; /** 지문(342-3) — 지금 노선 · 관 자리. */ route_signature?: string; pipe_signature?: string; @@ -645,15 +647,21 @@ export async function resetDetailPipePoints(projectId: string): Promise { // [확정](recompute) = 낡았으면 서버가 지금 노선으로 배수유역부터 다시 셈(347-2) — 오래 걸림. + // points null = 칸 없이 — 서버 저장분 그대로(433-7). const saved = await requestJson( `/projects/${projectId}/drainage/pipe-points${recompute ? "?recompute=true" : ""}`, - { method: "PUT", body: JSON.stringify({ points }) }, + { method: "PUT", body: JSON.stringify(points ? { points } : {}) }, recompute ? API_ANALYSIS_TIMEOUT_MS : API_TIMEOUT_MS, ); noteSnapshotFailed(saved.snapshot_failed); + if (saved.stations_failed) + showToast( + "관은 저장했지만 관 자리 측점을 못 맞췄습니다 — 횡단 「측점 없는 관」 알림에서 [측점 만들기]를 눌러 주세요.", + "error", + ); return saved; } diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index 034f0e7da..6360da2e2 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -571,11 +571,12 @@ async def delete_pipe_points(project_id: UUID) -> dict[str, Any] | JSONResponse: return _payload(project_id, context, detail, points, False, stale) -async def _stand_pipe_stations(project_id: UUID) -> None: +async def _stand_pipe_stations(project_id: UUID) -> bool: """관 PUT 끝 — 측점을 지금 관 자리로 다시 세움(`put_plus_stations` 처럼 · 부르는 길 모두 · 428). 노선 [저장] 뒤 관 차례가 화면 누가거리로 관을 덮으면 서버가 노선 저장 때 세운 측점과 어긋남 — - 여기서 맞추지 않으면 「측점이 없는 구조물」 · 옛 자리 카드가 남음. 실패해도 관 저장은 그대로. + 여기서 맞추지 않으면 「측점이 없는 구조물」 · 옛 자리 카드가 남음. 실패해도 관 저장은 그대로 · + 실패 = 거짓(응답 `stations_failed` 로 알림 · 433-6). """ from B06_Section.B06_Section_Router_Stations import ensure_structure_stations @@ -583,6 +584,8 @@ async def _stand_pipe_stations(project_id: UUID) -> None: await ensure_structure_stations(project_id) except Exception: logger.exception("관 저장: 측점 맞춤 실패(저장은 그대로): project_id=%s", project_id) + return False + return True @router.put("/{project_id}/drainage/pipe-points", response_model=None) @@ -627,8 +630,9 @@ async def put_pipe_points( failed = await asyncio.to_thread( safe_sync, resolve_stored_project_path(context.stored_path), importer(session) ) - await _stand_pipe_stations(project_id) + stood = await _stand_pipe_stations(project_id) result = _payload(project_id, context, detail, points, True, stale) result["saved_count"] = saved result["snapshot_failed"] = failed # 못 만든 구조물 조합 복사본(142-2) — [저장] 알림 + result["stations_failed"] = not stood # 측점 맞춤 실패(433-6) — [저장] 알림 return result diff --git a/B05_Profile/B05_Profile_Api_Pipes_Draft.ts b/B05_Profile/B05_Profile_Api_Pipes_Draft.ts index acc0eb4d2..f4836f25d 100644 --- a/B05_Profile/B05_Profile_Api_Pipes_Draft.ts +++ b/B05_Profile/B05_Profile_Api_Pipes_Draft.ts @@ -27,10 +27,11 @@ export function writePendingPipes(projectId: string, next: PendingPipes | null): useDesignStore(projectId, null).set("pipes", next); } -/** 초안이 있으면 정본에 쓰고 비운다. 없으면 아무 일도 하지 않는다. */ -export async function flushPendingPipes(projectId: string): Promise { +/** 초안이 있으면 정본에 쓰고 비운다 — 관 정본 PUT 은 이 한 번뿐(433-7 · 배수판 메모리는 안 보냄). 빈 목록 = + * 사용자가 다 지움. 초안이 없으면 [저장]은 아무 일도 안 함 · [확정]은 `points` 없이(서버 저장분으로 다시 셈). */ +export async function flushPendingPipes(projectId: string, confirm = false): Promise { const pending = readPendingPipes(projectId); - if (!pending) return; - await saveDetailPipePoints(projectId, pending); + if (!pending && !confirm) return; + await saveDetailPipePoints(projectId, pending, confirm); writePendingPipes(projectId, null); } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 295dc6546..de00f3749 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -5,7 +5,6 @@ import { computeDetailBasins, fetchDetailPipePoints, getVWorldMapUrl, - saveDetailPipePoints, type DetailBasin, type DetailBasinResponse, type PipeSource, @@ -566,7 +565,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra projectId = nextProjectId; void loadLayers(); }, - reload() { + reload(fresh = false) { + if (fresh) live.markFresh(); // [확정] 뒤 — 낡음 걷음(응답이 아직 낡았으면 다시 닮) if (projectId && meta) void run(loadSaved); }, pipeChainages: () => pipeEditor.chainages(), @@ -667,15 +667,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra syncPipeSelection(notify); scheduleDraw(); }, - async savePipes(confirm = false) { - if (!projectId) return 0; - // 시설 종류·구간·옵션까지 정본에 남긴다(계산기 왕복에서 사라지므로 되붙임). - const response = await saveDetailPipePoints(projectId, pipeRequest(), confirm); - if (confirm && !response.stale) live.markFresh(); - apply(response); - scheduleDraw(); - return response.pipe_count; - }, setRoute(points) { routePoints = points; routeLayer = meta && points.length > 1 ? prepareMetricPolyline(points, meta) : null; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts index f80f31a15..f42956a42 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts @@ -19,8 +19,9 @@ export interface DrainagePanel { root: HTMLElement; /** 프로젝트가 정해지면 배경지도·도엽 레이어를 불러온다. */ load: (projectId: string) => void; - /** 세션 초안(관 목록)을 다시 읽어 세운다 — 다른 보기(횡단)가 관 목록을 고친 뒤(계획서 24-3b). */ - reload: () => void; + /** 세션 초안(관 목록)을 다시 읽어 세운다 — 다른 보기(횡단)가 관 목록을 고친 뒤(계획서 24-3b) · [저장] 뒤 + * 저장분으로(초안 비움 뒤 · 433-7). `fresh` = [확정] 뒤 낡음 걷음. */ + reload: (fresh?: boolean) => void; /** 확정된 노선 평면 선형(사업지 좌표계 m)을 지도 위에 겹친다. */ setRoute: (points: ReadonlyArray) => void; /** 현재 관 매설 누가거리 목록(종단 테이블의 "배관" 구조물 라인과 맞추는 데 쓴다). */ @@ -42,9 +43,6 @@ export interface DrainagePanel { attributes: FacilityAttributes, ) => void; removePipe: (chainageM: number) => void; - /** 경로 확정 시 관 매설 지점을 영구저장한다(B04 "모델 확정"과 같은 저장소). */ - /** [확정](confirm) = 낡았으면 서버가 지금 노선으로 배수유역부터 다시 셈 · [저장] = 낡음 달고 저장. */ - savePipes: (confirm?: boolean) => Promise; /** 계산 묶음 drainage 단계(347-2) — 붙이기 = 묶음 쪽. */ pipelineStage: StageFn<"drainage">; /** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */ diff --git a/B05_Profile/B05_Profile_UI_Profile_Edit.ts b/B05_Profile/B05_Profile_UI_Profile_Edit.ts index 4bbb56270..a686aa18c 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Edit.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Edit.ts @@ -147,6 +147,13 @@ export function pendingAlignment(routeId: number): AlignmentEdits | null { return readAlignmentDraft(routeId); } +/** 초안을 다른 노선 칸에 둔다 — 노선 저장 뒤 다른 차례가 실패(433-12 · 다음 [저장]이 새 노선으로 보냄). */ +export function writeAlignmentDraft(routeId: number, edits: AlignmentEdits): void { + draftOf(routeId).set("alignmentDraft", edits); + const state = shared.get(routeId); + if (state) Object.assign(state, { current: edits, unsaved: true }); +} + /** 계획고가 정본에 나갔다 — 그 노선 상태를 저장됨으로 두고 초안을 지운다. */ export function markAlignmentSaved(routeId: number): void { const state = shared.get(routeId); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index 11ef38788..e08a53272 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -160,11 +160,13 @@ const savedPipes = (projectId: string): Promise => /** * 노선 저장 뒤 세션 횡단 초안(옛 노선 측점 열쇠 · 누가거리 열쇠 조절 · 417) → 새 노선으로 넘기고 그 고침을 저장 — * 초안을 통째로 지우지 않는다. 사라진 측점 고침은 버림(수를 돌려줌 · 서버 `cross_dropped` 와 한 줄로). + * `save` 거짓 = 넘기기만(뒤 차례 실패 · 433-12 — 다음 [저장]이 새 노선 초안을 보냄). */ export async function carryAfterRouteSave( projectId: string, fromRouteId: number, toRouteId: number, + { save = true }: { save?: boolean } = {}, ): Promise { invalidateSectionDetail(projectId, toRouteId); const detail = await loadSectionDetail(projectId, toRouteId); @@ -192,6 +194,7 @@ export async function carryAfterRouteSave( const uphillDropped = uphillBefore - uphillCount(); if (uphillDropped > 0) showToast(`상단측 ${uphillDropped}곳 — 새 노선에 같은 측점이 없어 뺐습니다.`, "info"); + if (!save) return outside; await flushUphillOverrides(projectId); // 옮긴 고침을 새 상세 설계 칸에 — 같은 [저장]의 횡단 패치가 실음(사면 쉬프트 · 조절 · 소단 깊이 · 417) withDraftAdjusts(detail, projectId, toRouteId); diff --git a/resources/tester/helper_449_save_pipes.cjs b/resources/tester/helper_449_save_pipes.cjs new file mode 100644 index 000000000..23380951b --- /dev/null +++ b/resources/tester/helper_449_save_pipes.cjs @@ -0,0 +1,152 @@ +/* PLAN 449 헬퍼 — [저장] 관 차례 · 노선 저장 뒤 실패(433-7 · 433-12). 실제 `design_save.ts` · 관 초안 창구 · 가게를 + * Node 로 · 서버 창구 · 계획고 초안 · 노선 편집 창구만 가짜. 짝 = `test_449_save_pipes.py`. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); + +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; + }, + }; +} +globalThis.window = { + sessionStorage: memoryStorage(), + localStorage: memoryStorage(), + setTimeout, + clearTimeout, +}; + +const STUBS = {}; +const cache = new Map(); +function loadTs(full) { + 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 (CATALOG.isCatalogRequest(request)) return CATALOG; + const key = Object.keys(STUBS).find((name) => request.endsWith(name)); + if (key) return STUBS[key]; + if (request.startsWith("@config/")) return { API_BASE_URL: "http://fake" }; + if (request.startsWith("@util/")) + return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); + 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 load = (...parts) => loadTs(path.join(ROOT, ...parts)); + +let calls = []; +let failAt = null; // 이 차례에서 throw +let routeBody = null; +const alignment = new Map(); // 노선 칸 → 계획고 초안 +STUBS["ui_template_elements"] = { + showLoadingOverlay: () => undefined, + hideLoadingOverlay: () => undefined, + showToast: () => undefined, +}; +const failing = (name) => { + if (failAt === name) throw new Error(`${name} 409`); +}; +STUBS["B04_PreProcess_Api_Fetch"] = { + fetchDetailPipePoints: async () => (calls.push(["GET"]), { pipe_points: [] }), + saveDetailPipePoints: async (_projectId, points, recompute) => { + calls.push(["PUT", points ?? null, !!recompute]); + return {}; + }, +}; +STUBS["B05_Profile_Api_Structures"] = { + flushPendingStructures: async () => failing("structures"), +}; +STUBS["B05_Profile_Api_PlusStations"] = { flushPlusStations: async () => undefined }; +STUBS["B05_Profile_Api_Fetch"] = { + flushUphillOverrides: async () => undefined, + saveProfileAlignment: async (_projectId, routeId, draft) => { + calls.push(["ALIGN", routeId, draft]); + return {}; + }, +}; +STUBS["B05_Profile_UI_Profile_Edit"] = { + pendingAlignment: (routeId) => alignment.get(routeId) ?? null, + markAlignmentSaved: (routeId) => alignment.delete(routeId), + writeAlignmentDraft: (routeId, edits) => alignment.set(routeId, edits), +}; +STUBS["B06_Section_Api_Culvert_Options"] = { flushCulvertOptions: async () => undefined }; +STUBS["B05_Profile_UI_RouteEdit_Apply"] = { + pendingRouteSave: async () => routeBody, + saveRouteDraft: async () => (calls.push(["ROUTE"]), { route_id: 8 }), + carryAfterRouteSave: async (_projectId, from, to, options) => { + calls.push(["CARRY", from, to, options?.save ?? true]); + failing("carry"); + return 0; + }, + routeSaveSummary: () => "", + rebuildAfterRouteSave: async () => void calls.push(["REBUILD"]), +}; +const save = load("A00_Common", "design", "design_save.ts"); +const drafts = load("B05_Profile", "B05_Profile_Api_Pipes_Draft.ts"); + +const P = "proj-449"; +async function once({ route = false, draft = null, confirm = false, fail = null, routeId = 7 }) { + calls = []; + failAt = fail; + routeBody = route ? { draft: {} } : null; + drafts.writePendingPipes(P, draft); + let failure = null; + const ok = await save.saveDesign( + { + projectId: P, + routeId: () => routeId, + // 화면 몫 = 배수판 다시 받기(PUT 없음) — 433-7 뒤 design_shared 와 같음 + steps: { pipes: async () => void calls.push(["RELOAD"]) }, + cross: async () => void calls.push(["CROSS"]), + failed: (error) => (failure = String(error && error.message ? error.message : error)), + }, + { confirm }, + ); + return { ok, failure, calls, left: drafts.readPendingPipes(P) }; +} + +async function run() { + window.localStorage.setItem("frd_current_project_id", P); + const fifteen = [{ chainage_m: 15, source: "user", id: "p1" }]; + const out = {}; + out.draft = await once({ draft: fifteen }); // B06 고침 직후 [저장] = 초안 값 + out.noDraft = await once({}); // 판 안 섬 · 초안 없음 = PUT 없음 + out.confirmNoDraft = await once({ confirm: true }); // [확정] = 칸 없이(서버 저장분) + out.emptyDraft = await once({ draft: [] }); // 사용자가 다 지움 = 빈 목록 + // 433-12 — 노선 저장 뒤 구조물 실패 → 0 칸 계획고가 새 노선 칸에 · 다음 [저장]이 보냄 + alignment.clear(); + alignment.set(0, { pvi: { 120: 1.5 } }); + out.routeFail = await once({ route: true, fail: "structures" }); + out.alignAfterFail = Object.fromEntries(alignment); + out.nextSave = await once({ routeId: 8 }); + // 노선 저장 뒤 횡단 넘김 자체가 실패 → 두 번 안 넘김 + alignment.clear(); + out.carryFail = await once({ route: true, fail: "carry" }); + return out; +} + +run() + .then((out) => { + process.stdout.write(JSON.stringify(out)); + process.exit(0); + }) + .catch((error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); + }); diff --git a/resources/tester/test_449_save_pipes.py b/resources/tester/test_449_save_pipes.py new file mode 100644 index 000000000..302f0a58a --- /dev/null +++ b/resources/tester/test_449_save_pipes.py @@ -0,0 +1,105 @@ +"""PLAN 449 — B05 [저장] 관 목록 · 노선 저장 뒤 실패(433-7 · 433-12 · 433-6). + +· 관 정본 PUT = 세션 관 초안 한 번(B06 고침 직후 [저장] = 초안 값) · 배수판 메모리는 안 보냄 +· 초안 없음 = [저장] PUT 없음(판이 안 서도 관 수 그대로) · [확정] = 칸 없이(서버 저장분) · 빈 초안 = 다 지움 +· 노선 저장 뒤 구조물 차례 실패 → 0 칸 계획고가 새 노선 칸에 · 횡단 초안 넘김(저장 없이) · 다시 세움 → + 다음 [저장]이 새 노선으로 계획고를 보냄 · 넘김 자체가 실패하면 두 번 안 넘김 +· 관 PUT 끝 측점 맞춤 실패 = 응답 `stations_failed` +짝: `helper_449_save_pipes.cjs`(실제 `design_save.ts` 를 Node 로 · 서버 창구만 가짜). +""" + +from __future__ import annotations + +import asyncio +import json +import subprocess +import sys +from pathlib import Path +from uuid import uuid4 + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +@pytest.fixture(scope="module") +def out() -> dict: + if not (ROOT / "config" / "node_modules" / "typescript").is_dir(): + pytest.skip("프론트엔드 의존성 미설치") + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_449_save_pipes.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _puts(case: dict) -> list: + return [call for call in case["calls"] if call[0] == "PUT"] + + +def test_관_PUT_은_초안_한_번(out): + case = out["draft"] + assert case["ok"] and _puts(case) == [ + ["PUT", [{"chainage_m": 15, "source": "user", "id": "p1"}], False] + ] + assert case["calls"].index(["RELOAD"]) > case["calls"].index( + _puts(case)[0] + ) # 화면은 뒤에 받기만 + assert case["left"] is None + + +def test_초안_없으면_PUT_없음(out): + assert out["noDraft"]["ok"] and _puts(out["noDraft"]) == [] + + +def test_확정은_칸_없이_다시_셈(out): + assert _puts(out["confirmNoDraft"]) == [["PUT", None, True]] + + +def test_빈_초안은_다_지움(out): + assert _puts(out["emptyDraft"]) == [["PUT", [], False]] + + +def test_노선_저장_뒤_실패_초안_새_노선(out): + case = out["routeFail"] + assert not case["ok"] and "구조물" in case["failure"] + assert case["calls"] == [["ROUTE"], ["CARRY", 7, 8, False], ["REBUILD"]] + assert out["alignAfterFail"] == {"8": {"pvi": {"120": 1.5}}} # 0 칸 비움 · 새 노선 칸 + align = [call for call in out["nextSave"]["calls"] if call[0] == "ALIGN"] + assert align == [["ALIGN", 8, {"pvi": {"120": 1.5}}]] + + +def test_넘김_실패면_두_번_안_넘김(out): + case = out["carryFail"] + assert not case["ok"] + assert [call for call in case["calls"] if call[0] == "CARRY"] == [["CARRY", 7, 8, True]] + assert case["calls"][-1] == ["REBUILD"] + + +def test_공용_저장이_판_메모리를_안_보냄(): + shared = (ROOT / "A00_Common/design/design_shared.ts").read_text(encoding="utf-8") + panel = (ROOT / "B05_Profile/B05_Profile_UI_Drainage_Panel.ts").read_text(encoding="utf-8") + assert "savePipes" not in shared and "saveDetailPipePoints" not in panel + + +def test_측점_맞춤_실패는_응답에(monkeypatch): + from B04_PreProcess import B04_PreProcess_Router_Basins as basins + from B06_Section import B06_Section_Router_Stations as stations + + async def boom(_project_id): + raise RuntimeError("샘플링 없음") + + async def fine(_project_id): + return 1 + + monkeypatch.setattr(stations, "ensure_structure_stations", boom) + assert asyncio.run(basins._stand_pipe_stations(uuid4())) is False + monkeypatch.setattr(stations, "ensure_structure_stations", fine) + assert asyncio.run(basins._stand_pipe_stations(uuid4())) is True