From 4046178a5d867718db1dd00705c2f495a03f0e88 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 21 Oct 2026 00:08:12 +0900 Subject: [PATCH] =?UTF-8?q?fix(498-3=20=EC=A0=80=EC=9E=A5=20=EB=AA=A8?= =?UTF-8?q?=EC=96=91):=20=EB=85=B8=EC=84=A0=20=EA=B7=B8=EB=8C=80=EB=A1=9C?= =?UTF-8?q?=20[=EC=A0=80=EC=9E=A5]=20=3D=20=EC=9E=A0=EA=B8=88=20=EC=97=86?= =?UTF-8?q?=EC=9D=B4=20=EB=92=A4=EC=97=90=EC=84=9C=20=C2=B7=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=A4=91=20[=EC=A0=80=EC=9E=A5]=20=EB=A7=89?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=EC=A0=80=EC=9E=A5=20=EC=A4=91=20[>]=20=3D?= =?UTF-8?q?=20=EC=95=9E=20=EC=A0=80=EC=9E=A5=20=EB=81=9D=20=EB=92=A4=20?= =?UTF-8?q?=ED=99=95=EC=A0=95=20=C2=B7=20=EA=B2=B0=EA=B3=BC=20=ED=95=9C=20?= =?UTF-8?q?=EC=A4=84=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20=EA=B1=B7=EC=9D=8C=20?= =?UTF-8?q?=E2=80=94=20=EC=9A=94=EA=B5=AC=20298-3=20=C2=B7=20302=20D1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 잠금 = 확정 · 노선 모양 바뀐 [저장]만 · 그 밖 [저장]은 뒤에서 · 끝나면 「저장했습니다」 - 줄 세움 queueDesignSave — 저장 중 [저장] = 「이전 수정을 저장 중입니다…」 + 막음 · 저장 중 확정 = 잠그고 기다린 뒤 지금 화면 · 서버 저장 도중 안 끊음 - 저장 도중 고침 = 조각 정리 · 깨끗한 표 건너뜀(초안 남아 다음 저장에서 다시 나감) - 노선 바뀐 [저장] = 옛 페이지 두 보기 헛 다시 읽기 걷음 · 떠남 표 · 알림만 뒤 다시 세움 - B06 [>] = 고친 것 없음 + 단계 2 · 3 COMPLETE 면 저장 없이 이동 · 그 밖 496 확정 그대로 - 노선 저장 알림 = 따라감 실패 · 새 교차 관 한 줄만(routeSaveSummary · 노선 밖 되풀이 지움) - 시험 test_298_3_save_shape 새로 · 옛 요약 · 잠금 단언 고쳐 씀 Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_leave.ts | 5 + A00_Common/design/design_save.ts | 83 ++++++++-- A00_Common/design/design_shared.ts | 38 ++++- B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts | 16 +- resources/tester/helper_298_3_save_shape.cjs | 150 ++++++++++++++++++ resources/tester/helper_346_3_route_save.cjs | 2 +- resources/tester/helper_449_save_pipes.cjs | 2 +- resources/tester/helper_design_store_save.cjs | 1 + resources/tester/test_272_detail_memory.py | 2 +- resources/tester/test_298_3_save_shape.py | 116 ++++++++++++++ .../tester/test_346_3_route_save_screen.py | 9 +- resources/tester/test_384_6_save_one_line.py | 2 +- resources/tester/test_416_page_cache.py | 2 +- resources/tester/test_design_store_save.py | 6 +- 14 files changed, 384 insertions(+), 50 deletions(-) create mode 100644 resources/tester/helper_298_3_save_shape.cjs create mode 100644 resources/tester/test_298_3_save_shape.py diff --git a/A00_Common/design/design_leave.ts b/A00_Common/design/design_leave.ts index 3925c3fa5..55fdd5005 100644 --- a/A00_Common/design/design_leave.ts +++ b/A00_Common/design/design_leave.ts @@ -70,6 +70,11 @@ function digest(text: string): string { // ponytail: 깨끗한 표는 메모리만 — [저장] 뒤 새로고침하면 남은 횡단 초안 때문에 한 번 더 물음. const cleanMarks = new Map(); +/** 지금 고침 지문(짧게) — 저장 본문을 낸 때와 끝난 때를 견줌(498-3). */ +export function designSignature(projectId: string): string { + return digest(editSignature(projectId)); +} + /** [저장] · [확정]이 끝난 때 — 지금 초안을 깨끗한 것으로 적음. */ export function markDesignClean(projectId: string): void { if (projectId) cleanMarks.set(projectId, digest(editSignature(projectId))); diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts index 0317095d6..debd7b0f3 100644 --- a/A00_Common/design/design_save.ts +++ b/A00_Common/design/design_save.ts @@ -22,7 +22,7 @@ import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; import { pipesPart } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { structuresPart } from "../../B05_Profile/B05_Profile_Api_Structures"; -import { outsideNotice, plusStationsPart } from "../../B05_Profile/B05_Profile_Api_PlusStations"; +import { plusStationsPart } from "../../B05_Profile/B05_Profile_Api_PlusStations"; import { routePartsBody } from "../../B05_Profile/B05_Profile_Api_Fetch"; import type { RouteReplanResponse } from "../../B05_Profile/B05_Profile_Api_Replan"; import { @@ -45,9 +45,9 @@ import { rebuildAfterRouteSave, routeCarryPart, routeSaved, - routeSaveSummary, } from "../../B05_Profile/B05_Profile_UI_RouteEdit_Apply"; import type { RouteSaveBody } from "../../B05_Profile/B05_Profile_Api_Replan"; +import { designSignature, markDesignClean } from "./design_leave"; export interface DesignSaveContext { projectId: string; @@ -61,8 +61,9 @@ export interface DesignSaveContext { parts?: Partial>; /** 횡단 조각 — 보기마다 본문이 다르다(표준 횡단 · 카드 고침 · 유토). */ crossPart: (confirm: boolean) => Promise; - /** 모두 나간 뒤 — 캐시 비우기 · 화면 다시 읽기 · 알림 · 다음 단계. */ - done?: (confirm: boolean) => Promise | void; + /** 모두 나간 뒤 — 캐시 비우기 · 화면 다시 읽기 · 알림 · 다음 단계. `reload` 거짓 = 알림만(노선 바뀐 저장 = + * 곧 다시 세움 · 옛 노선 id 로 다시 읽기는 헛일 · 498-3). */ + done?: (confirm: boolean, reload?: boolean) => Promise | void; /** 멈춘 저장을 알린다. */ failed: (error: unknown, confirm: boolean) => void; /** 노선 모양 바뀐 채 확정 — 노선 · 고침은 저장됨 · 페이지 다시 세우기 앞에 물음(요구 300 B2). */ @@ -75,6 +76,45 @@ export type PartOverride = ( ) => Promise | SavePart | null; const saving = new Set(); +/** 프로젝트마다 지금 도는 저장 한 번(498-3 D1) — 확정인가 · 끝 약속. */ +const inflight = new Map }>(); + +/** 저장이 도는 중인가(498-3) — 뒤에서 도는 [저장] 포함. */ +export function designSaveBusy(projectId: string): boolean { + return inflight.has(projectId); +} + +/** + * 저장 한 번을 줄 세움(요구 302 D1) — 저장 중 [저장] = 막음 + 알림 · 저장 중 확정 = 잠그고 앞 저장 끝을 기다린 뒤 + * 지금 화면으로(서버 한 본문 저장은 도중에 안 끊음) · 확정 중 또 확정 = 막음. 막으면 false. + */ +export async function queueDesignSave( + projectId: string, + confirm: boolean, + job: () => Promise, +): Promise { + const before = inflight.get(projectId); + if (before && (!confirm || before.confirm)) { + showToast("이전 수정을 저장 중입니다. 잠시 후 다시 해 주세요.", "warning"); + return false; + } + if (before) showLoadingOverlay(); + const run = (async () => { + try { + await before?.done.catch(() => undefined); + return await job(); + } finally { + if (before) hideLoadingOverlay(); + } + })(); + const entry = { confirm, done: run }; + inflight.set(projectId, entry); + try { + return await run; + } finally { + if (inflight.get(projectId) === entry) inflight.delete(projectId); + } +} /** 초안을 내보내는 중인가(323-9) — 그동안 바뀜 신호로 낡은 상세 · 빈 초안을 그리지 않음(뒤처리가 한 번 다시). */ export function savingDesign(projectId: string | null): boolean { @@ -166,21 +206,22 @@ function oneBodyError(error: unknown): Error { return new Error(`${where}실패 — 아무것도 저장하지 않았습니다(모두 그대로). ${error.message}`); } -/** 노선 저장 결과 알림 — 노선 밖 관 · 구조물 · 따라감 실패 · 결과 한 줄(관 지킴 · 노선 밖 고침 …). */ -function noticeRouteSaved(saved: RouteReplanResponse, carryOutside: number): void { - const outside = (saved.pipes?.outside ?? 0) + (saved.structures?.outside ?? 0); - if (outside) showToast(outsideNotice(outside), "warning"); +/** 노선 저장 결과 알림 — 따라감 실패 · 화면에 없던 새 교차 관만(요구 298-3 · 결과 한 줄 · 노선 밖 되풀이 지움). */ +function noticeRouteSaved(saved: RouteReplanResponse): void { if (saved.follow_failed?.length) showToast( `${saved.follow_failed.join(" · ")} 따라감 실패 — 옛 누가거리 그대로입니다.`, "error", ); - showToast(routeSaveSummary(saved, carryOutside), "info"); + const crossing = saved.pipes?.crossing ?? 0; + if (crossing) showToast(`새 교차 관 ${crossing}개 — 새 세류선 교차점에 더했습니다.`, "info"); } /** * [저장] · [확정] — 세션에 쌓인 조작을 서버 한 창구에 한 번(472-6). 노선이 없으면 아무 일도 하지 않고 false · * 다 저장하면 true. 서버가 다 저장한 뒤에만 조각 정리(초안 비움 · 화면 맞춤) · 실패면 초안 그대로. + * 잠금(요구 302 D1) = 확정 · 노선 모양 바뀐 [저장]만 — 그 밖 [저장]은 뒤에서 · 저장 도중 고쳤으면 조각 정리 · + * 깨끗한 표를 건너뜀(초안이 그대로 남아 다음 [저장] · [>]에서 다시 나감 · 화면 다시 읽기는 함). */ export async function saveDesign( ctx: DesignSaveContext, @@ -189,10 +230,15 @@ export async function saveDesign( const confirm = options.confirm ?? false; const routeId = ctx.routeId(); if (routeId === null) return false; - showLoadingOverlay(); saving.add(ctx.projectId); + let locked = confirm; + if (locked) showLoadingOverlay(); try { const route: RouteSaveBody | null = await pendingRouteSave(ctx.projectId); + if (route && !locked) { + showLoadingOverlay(); // 노선 모양 바뀐 [저장] = 잠금 · 다시 세움(D1) + locked = true; + } await ctx.prepare?.(); const parts: Partial> = {}; // 노선이 바뀌면 횡단 = 옛 노선 세션 고침 원본(서버가 새 측점에 넘김) · 화면 몫 조각은 옛 노선 판이라 안 씀 @@ -206,6 +252,7 @@ export async function saveDesign( const own = route ? undefined : ctx.parts?.[key]; parts[key] = own ? await own(base, confirm) : base; } + const sent = designSignature(ctx.projectId); // 본문을 낸 때 고침 — 끝에 견줌(498-3) const result = await postDesignSave( ctx.projectId, routeId, @@ -214,22 +261,30 @@ export async function saveDesign( route ? { route, carry: carried?.carry } : {}, ); if (result.waited) showToast("앞 저장이 끝나기를 기다린 뒤 저장했습니다.", "info"); + // 잠금 없는 저장 도중 고침 — 조각 정리가 그 고침까지 비우므로 건너뜀(서버는 낸 본문을 저장함) + // ponytail: 고침 하나라도 있으면 조각 정리를 통째 건너뜀 · 다음 저장이 초안 전부를 다시 냄 + const touched = !locked && designSignature(ctx.projectId) !== sent; if (route) routeSaved(ctx.projectId); // 가게 노선 초안 · 조회 결과 캐시 · 편집기 「저장됨」 - for (const key of SAVE_PART_KEYS) await parts[key]?.after?.(result.steps[key]); + if (!touched) for (const key of SAVE_PART_KEYS) await parts[key]?.after?.(result.steps[key]); clearState("structure-type-cache", ctx.projectId); // 종류별 캐시는 저장 뒤 비움(54-32) saving.delete(ctx.projectId); // 다 나감 — 뒤처리는 새 상세로 그림 if (!route) { + if (!touched) markDesignClean(ctx.projectId); // 떠남 확인 — 지금 초안이 저장분(346-4) await ctx.done?.(confirm); return true; } const saved = (result.steps.route ?? {}) as unknown as RouteReplanResponse; - noticeRouteSaved(saved, Number(result.steps.carry?.outside ?? 0)); + noticeRouteSaved(saved); // 새 노선 — 확정은 새 횡단 본문이 있어야 함 → 물음(머묾 / 새 노선으로 확정 뒤 이동) · 페이지 다시 세움. if (confirm) { hideLoadingOverlay(); // 물음 동안 가림막을 걷음 await ctx.routeMoved?.(); showLoadingOverlay(); - } else await ctx.done?.(false); + } else { + // 옛 페이지 두 보기 다시 읽기는 헛일(곧 다시 세움 · 옛 노선 id) — 떠남 표 · 알림만(298-3) + markDesignClean(ctx.projectId); + await ctx.done?.(false, false); + } await rebuildAfterRouteSave(ctx.projectId); // 옛 노선 칸 · 초안 전 hold = 서버가 새 노선에 넘겼음 — 쓰레기만 비움(넘김 규칙 그대로 · 세션 넘침 388-3) if (saved.route_id !== routeId) clearRouteDrafts(ctx.projectId, routeId); @@ -240,6 +295,6 @@ export async function saveDesign( return false; } finally { saving.delete(ctx.projectId); - hideLoadingOverlay(); + if (locked) hideLoadingOverlay(); } } diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 3fd612edf..2e538b2f0 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -17,6 +17,7 @@ import { splitSidebarActions } from "@ui/ui_template_overlay"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { clearWorkflowStateCache, + fetchWorkflowState, goToWorkflowStage, WORKFLOW_STEP_ROUTES, } from "../b_workflow_nav"; @@ -31,9 +32,10 @@ import { drainageSide } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool" import { createDesignPanel, type DesignPanel } from "./design_panel"; import { createDesignProfile, type DesignProfile } from "./design_profile"; import { attachProfilePopout, attachSubPanelPopouts } from "./design_profile_popout"; -import { saveDesign, type PartOverride } from "./design_save"; +import { designSaveBusy, queueDesignSave, saveDesign, type PartOverride } from "./design_save"; import type { SavePart, SavePartKey } from "./design_save_api"; -import { markDesignClean } from "./design_leave"; +import { designDirty } from "./design_leave"; +import { pendingRouteSave } from "../../B05_Profile/B05_Profile_UI_RouteEdit_Apply"; import { resetDesign } from "./design_reset"; import type { DesignView } from "./design_dock"; import type { FacilityLane } from "./design_facility_lane"; @@ -388,10 +390,22 @@ export function designReady(shared: DesignPageShared): void { void saveSharedDesign(shared, true, target); } +/** 고친 것 없고 단계 2 · 3 이미 완료 — [>]가 저장 없이 바로 감(요구 302 D1 · 그 밖은 496 확정 그대로). */ +async function alreadyConfirmed(projectId: string): Promise { + if (designSaveBusy(projectId) || designDirty(projectId)) return false; + if (await pendingRouteSave(projectId)) return false; + const state = await fetchWorkflowState(projectId).catch(() => null); + const done = (no: number) => + state?.stages.some((s) => s.stage_no === no && s.state === "COMPLETE"); + return !!done(2) && !!done(3); +} + /** B06 [>] · 단계 판 B07~B09(496-1) — 확정 뒤 그 주소 · 실패 · 자료 서기 전 = 머묾. */ export function confirmAndGo(shared: DesignPageShared, route: RoutePath): void { if (!shared.ready) return void showToast(L("Design_Next_NotReady"), "info"); - void saveSharedDesign(shared, true, route); + void alreadyConfirmed(shared.projectId).then((skip) => + skip ? goToWorkflowStage(shared.projectId, route) : saveSharedDesign(shared, true, route), + ); } /** @@ -412,12 +426,20 @@ async function routeChanged(shared: DesignPageShared): Promise { /** * [저장] · 확정 한 번(확정 = `target` 으로 감 · 기본 B07) — 관(배수유역도) · 계획고는 한 벌 종단도가, 구조물 · 상단측은 종단 보기가, - * 저장 앞 재계산 · 암거 옵션 · 횡단 본문은 횡단 보기가 낸다. 차례는 `saveDesign` 한 곳. + * 저장 앞 재계산 · 암거 옵션 · 횡단 본문은 횡단 보기가 낸다. 차례는 `saveDesign` 한 곳 · 줄 세움은 `queueDesignSave`(498-3). */ export async function saveSharedDesign( shared: DesignPageShared, confirm: boolean, target: RoutePath = WORKFLOW_STEP_ROUTES[4], +): Promise { + await queueDesignSave(shared.projectId, confirm, () => saveOnce(shared, confirm, target)); +} + +async function saveOnce( + shared: DesignPageShared, + confirm: boolean, + target: RoutePath, ): Promise { if (await routeChanged(shared)) return; // [저장] · [확정] 본문 = 횡단 보기 한 벌(표준 횡단면 · 전 측점 면적 · 카드 고침) — 들어온 화면 무관 · 숨긴 채라도 @@ -457,8 +479,12 @@ export async function saveSharedDesign( ...crossSave?.parts, }, crossPart, - done: async (isConfirm) => { - markDesignClean(shared.projectId); // 떠남 확인 — 지금 초안이 저장분(346-4) + done: async (isConfirm, reload = true) => { + if (!reload) { + // 노선 바뀐 저장 — 곧 다시 세움 · 다시 읽지 않음(498-3) + showToast(L("B06_Profile_Save_Success"), "success"); + return; + } if (isConfirm) { // 서버가 정본을 다시 씀 — 다음 페이지(B07)가 브라우저 셈 · 옛 세션 사본 말고 정본을 받게(470 N1) invalidateSectionDetail(shared.projectId); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index b43a99946..cf4e1fdee 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -5,7 +5,7 @@ * · 페이지 [저장](`design_save`)이 `pendingRouteSave` 로 지금 노선 초안을 받아 한 본문(`POST /design/save` · * `route` 칸 = `RouteSaveBody`)에 싣는다 — 노선이 마지막 저장 그대로면 null(안 보냄 · 472-6 6-B). * · 서버가 노선 저장 → 관 · 구조물 다시 얹기 → 세션 횡단 고침 넘김(`routeCarryPart` 가 낸 옛 노선 칸 · 같은 TS)까지 - * 한 번에 · 다 저장한 뒤 `routeSaved` · 결과 한 줄 · 페이지는 새로고침 없이 다시 세운다(`rebuildAfterRouteSave`). + * 한 번에 · 다 저장한 뒤 `routeSaved` · 페이지는 새로고침 없이 다시 세운다(`rebuildAfterRouteSave`). * · 추천 [적용] = `saveRecommendedRoute` — `as_initial: true` 로 바로 저장(초기값 = 추천 노선 · 사용자 확정). * 편집기가 살아 있는 동안 여기 등록부에 「지금 초안」 · 「다시 세우기」를 건다(프로젝트 하나에 하나). * 실패하면 서버가 옛 노선으로 되살리고 화면 초안은 그대로 남는다(오류 한 줄은 부르는 쪽). @@ -142,20 +142,6 @@ export function routeSaved(projectId: string): void { sources.get(projectId)?.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}`, - ]; - const dropped = (saved.cross_dropped ?? 0) + outside; - if (dropped) parts.push(`노선 밖 고침 ${dropped} 건`); - if (saved.drainage_stale) parts.push("배수유역 낡음"); - return `노선 저장 — ${parts.join(" · ")}`; -} - /** * 노선 바뀐 [저장]의 횡단 조각(472-6 6-B) — 옛 노선 세션 고침 원본(측점 열쇠 칸 · 누가거리 칸 · 열쇠 목록 · 관 장부)을 * `carry` 로 · 횡단 본문 = 표준 횡단면 · 풍선 자리(옛 `saveCachedCrossPatches` 와 같은 칸). 새 측점 patch 는 서버가 diff --git a/resources/tester/helper_298_3_save_shape.cjs b/resources/tester/helper_298_3_save_shape.cjs new file mode 100644 index 000000000..3eeb98d67 --- /dev/null +++ b/resources/tester/helper_298_3_save_shape.cjs @@ -0,0 +1,150 @@ +/* 요구 298-3 · 302 D1 — [저장] 모양 단위검증 헬퍼. + * `A00_Common/design/design_save.ts` 를 그 자리에서 트랜스파일해 Node 로 돌린다 · 둘레 모듈은 가짜 · + * 부른 차례만 적는다(잠금 · 조각 정리 · 깨끗한 표 · 알림 · 줄 세움). + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const calls = []; +let signature = "s0"; +let routeBody = null; +let routeResponse = {}; +let onPost = () => undefined; + +const STUBS = { + "@ui/ui_template_elements": { + showToast: (text, kind) => calls.push(`toast:${kind}:${text}`), + showLoadingOverlay: () => calls.push("overlay:on"), + hideLoadingOverlay: () => calls.push("overlay:off"), + }, + "../../B05_Profile/B05_Profile_Api_Pipes_Draft": { + pipesPart: () => ({ body: { points: [] }, after: () => calls.push("pipes:after") }), + }, + "../../B05_Profile/B05_Profile_Api_Structures": { structuresPart: () => null }, + "../../B05_Profile/B05_Profile_Api_PlusStations": { plusStationsPart: () => null }, + "../../B05_Profile/B05_Profile_Api_Fetch": { routePartsBody: async () => null }, + "../../B05_Profile/B05_Profile_UI_Profile_Edit": { + markAlignmentSaved: () => undefined, + pendingAlignment: () => null, + }, + "../../B06_Section/B06_Section_Api_Culvert_Options": { culvertOptionsPart: () => null }, + "../../B05_Profile/B05_Profile_Api_PageSettings": { pageSettingsPart: () => null }, + "./design_save_api": { + SAVE_PART_KEYS: ["settings", "pipes", "cross"], + DesignSaveError: class extends Error {}, + postDesignSave: async (_p, _r, confirm, _parts, extra = {}) => { + calls.push(`post:${confirm}${extra.route ? ":route" : ""}`); + await onPost(); + return { waited: false, steps: extra.route ? { route: routeResponse } : {} }; + }, + }, + "../b_page_state": { + clearRouteDrafts: () => undefined, + clearState: () => undefined, + stateKey: () => "k", + }, + "../../B05_Profile/B05_Profile_UI_RouteEdit_Apply": { + pendingRouteSave: async () => routeBody, + routeCarryPart: () => ({ + carry: {}, + cross: { body: {}, after: () => calls.push("carry:after") }, + }), + routeSaved: () => calls.push("routeSaved"), + rebuildAfterRouteSave: async () => calls.push("rebuild"), + }, + "./design_leave": { + designSignature: () => signature, + markDesignClean: () => calls.push("clean"), + }, +}; + +function loadTs(full) { + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + throw new Error(`stub 없음: ${request}`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const save = loadTs(path.join(ROOT, "A00_Common", "design", "design_save.ts")); +const ctx = (extra = {}) => ({ + projectId: "p1", + routeId: () => 7, + crossPart: async () => ({ body: {}, after: () => calls.push("cross:after") }), + done: async (confirm, reload) => calls.push(`done:${confirm}:${reload}`), + failed: (error) => calls.push(`failed:${error.message}`), + ...extra, +}); +const deferred = () => { + let resolve; + const promise = new Promise((done) => (resolve = done)); + return { promise, resolve }; +}; +const take = () => calls.splice(0); + +async function run() { + const out = {}; + + // ① 노선 모양 안 바뀐 [저장] — 잠금 없음 · 조각 정리 · 깨끗한 표 · 다시 읽기 + out.plain = { ok: await save.saveDesign(ctx()), calls: take() }; + + // ② 노선 모양 바뀐 [저장] — 잠금 · 다시 세움 · 헛 다시 읽기 없음 · 알림 = 따라감 실패 · 새 교차 관만 + routeBody = { draft: { nodes: [] } }; + routeResponse = { + route_id: 9, + pipes: { kept: 3, crossing: 2, outside: 1 }, + structures: { moved: 1, far: 0, outside: 1 }, + cross_dropped: 4, + drainage_stale: true, + follow_failed: ["구조물"], + }; + out.route = { ok: await save.saveDesign(ctx()), calls: take() }; + routeResponse = { route_id: 9, pipes: { kept: 3, crossing: 0 } }; + out.routeQuiet = { ok: await save.saveDesign(ctx()), calls: take() }; + routeBody = null; + + // 저장 도중 고침 — 조각 정리 · 깨끗한 표 건너뜀(고침이 남음) · 화면 다시 읽기는 함 + onPost = () => (signature = "s1"); + out.touched = { ok: await save.saveDesign(ctx()), calls: take() }; + onPost = () => undefined; + + // ⑤ 저장 중 [저장] — 막음 + 알림 · ④ 저장 중 확정 — 잠그고 앞 저장 끝을 기다린 뒤 · 확정 중 확정 = 막음 + const first = deferred(); + const firstRun = save.queueDesignSave("p1", false, async () => { + calls.push("job1:start"); + await first.promise; + calls.push("job1:end"); + return "one"; + }); + out.busyWhile = save.designSaveBusy("p1"); + out.second = await save.queueDesignSave("p1", false, async () => calls.push("job2")); + const confirmRun = save.queueDesignSave("p1", true, async () => { + calls.push("job3"); + return "three"; + }); + out.thirdBlocked = await save.queueDesignSave("p1", true, async () => calls.push("job4")); + await new Promise((done) => setTimeout(done, 10)); + calls.push("release"); + first.resolve(); + out.firstResult = await firstRun; + out.confirmResult = await confirmRun; + out.queueCalls = take(); + out.busyAfter = save.designSaveBusy("p1"); + out.otherProject = await save.queueDesignSave("p2", false, async () => "p2"); + out.otherCalls = take(); + return out; +} + +run() + .then((out) => process.stdout.write(JSON.stringify(out))) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/resources/tester/helper_346_3_route_save.cjs b/resources/tester/helper_346_3_route_save.cjs index c62dae2f8..35d83f333 100644 --- a/resources/tester/helper_346_3_route_save.cjs +++ b/resources/tester/helper_346_3_route_save.cjs @@ -140,7 +140,7 @@ const apply = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Ap out.savedRouteId = saved.route_id; out.storeAfterSave = useDesignStore("p1", null).get("routeDraft"); out.keptAfterSave = kept; - out.summary = apply.routeSaveSummary(saved, 2); + out.summary = typeof apply.routeSaveSummary; // 498-3 — 결과 한 줄 걷음 // 노선 바뀐 [저장] 횡단 조각(6-B) — 옛 노선 세션 칸 원본 + 표준 횡단 · 풍선 · 저장 뒤 상단측 · 종류 바꿈 비움 calls.length = 0; diff --git a/resources/tester/helper_449_save_pipes.cjs b/resources/tester/helper_449_save_pipes.cjs index c90a261b1..7d2f4d16f 100644 --- a/resources/tester/helper_449_save_pipes.cjs +++ b/resources/tester/helper_449_save_pipes.cjs @@ -26,7 +26,7 @@ globalThis.window = { clearTimeout, }; -const STUBS = {}; +const STUBS = { "/design_leave": { designSignature: () => "", markDesignClean: () => undefined } }; const cache = new Map(); function loadTs(full) { if (cache.has(full)) return cache.get(full); diff --git a/resources/tester/helper_design_store_save.cjs b/resources/tester/helper_design_store_save.cjs index 2a3a780ef..4495215e8 100644 --- a/resources/tester/helper_design_store_save.cjs +++ b/resources/tester/helper_design_store_save.cjs @@ -118,6 +118,7 @@ const STUBS = { }, // 472-6 한 본문 창구 — 보낸 칸만 적음 · 구조물 실패 = 서버가 그 차례에서 되돌림 "./design_save_api": saveApiStub, + "./design_leave": { designSignature: () => "", markDesignClean: () => undefined }, }; const cache = new Map(); diff --git a/resources/tester/test_272_detail_memory.py b/resources/tester/test_272_detail_memory.py index e79e5e2e9..d84c64d4c 100644 --- a/resources/tester/test_272_detail_memory.py +++ b/resources/tester/test_272_detail_memory.py @@ -116,6 +116,6 @@ def test_화면_선만_바뀌면_낡음_다시_셈() -> None: def test_확정_성공도_상세_비움() -> None: # 470 N1 — [확정] 은 다음 페이지(B07)로 바로 감 · [저장]처럼 상세 메모리 · 옛 세션 사본을 비움 shared = (ROOT / "A00_Common/design/design_shared.ts").read_text(encoding="utf-8") - done = shared[shared.index(" done: async (isConfirm) => {") :] + done = shared[shared.index(" done: async (isConfirm, reload = true) => {") :] confirm = done[done.index("if (isConfirm) {") : done.index("goToWorkflowStage(")] assert "invalidateSectionDetail(shared.projectId);" in confirm diff --git a/resources/tester/test_298_3_save_shape.py b/resources/tester/test_298_3_save_shape.py new file mode 100644 index 000000000..9c606ffb4 --- /dev/null +++ b/resources/tester/test_298_3_save_shape.py @@ -0,0 +1,116 @@ +"""요구 298-3 · 302 D1 — B05 · B06 [저장] 모양 · 저장 알림 정리(PLAN 498-3). + +① 노선 모양 안 바뀐 [저장] = 잠금 없이 뒤에서 · 끝나면 알림(`design_shared` done) +② 노선 모양 바뀐 [저장] = 잠금 + 다시 세움 · 옛 페이지 헛 다시 읽기 없음(`done(false, false)`) +③ 고친 것 없고 단계 2 · 3 이미 완료 = [>] 저장 없이 바로 이동 · 그 밖 = 496 확정 그대로 +④ 저장 중 확정 = 잠그고 앞 저장 끝을 기다린 뒤 지금 화면으로 · ⑤ 저장 중 [저장] = 막음 + 알림 +알림 — 결과 한 줄(관 지킴 · 노선 밖 고침 · 배수 낡음 · 구조물 옮김) · 노선 밖 되풀이 지움 · +남김 = 새 교차 관 한 줄 · 따라감 실패. +""" + +import json +import pathlib +import subprocess + +import pytest + +HERE = pathlib.Path(__file__).resolve().parent +ROOT = HERE.parents[1] +BUSY = "toast:warning:이전 수정을 저장 중입니다. 잠시 후 다시 해 주세요." + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( + ["node", str(HERE / "helper_298_3_save_shape.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_1_노선_그대로_저장은_잠금_없음(out): + assert out["plain"]["ok"] is True + assert out["plain"]["calls"] == [ + "post:false", + "pipes:after", + "cross:after", + "clean", + "done:false:undefined", + ] + assert not any(c.startswith("overlay") for c in out["plain"]["calls"]) + + +def test_2_노선_모양_바뀐_저장은_잠금_다시_세움(out): + calls = out["route"]["calls"] + assert calls[0] == "overlay:on" and calls[-1] == "overlay:off" + assert calls.index("done:false:false") < calls.index( + "rebuild" + ) # 다시 읽기 없이 알림만 · 다시 세움 + assert calls.index("clean") < calls.index("done:false:false") + + +def test_2_노선_저장_알림은_따라감_실패_새_교차_관만(out): + toasts = [c for c in out["route"]["calls"] if c.startswith("toast:")] + assert toasts == [ + "toast:error:구조물 따라감 실패 — 옛 누가거리 그대로입니다.", + "toast:info:새 교차 관 2개 — 새 세류선 교차점에 더했습니다.", + ] + assert not [c for c in out["routeQuiet"]["calls"] if c.startswith("toast:")] + + +def test_저장_도중_고침은_정리_깨끗한_표_건너뜀(out): + assert out["touched"]["ok"] is True + assert out["touched"]["calls"] == ["post:false", "done:false:undefined"] + + +def test_5_저장_중_저장은_막음_알림(out): + assert out["busyWhile"] is True + assert out["second"] is False + assert "job2" not in out["queueCalls"] + assert out["queueCalls"].count(BUSY) == 2 # 저장 중 [저장] · 확정 중 확정 + + +def test_4_저장_중_확정은_앞_저장_끝을_기다림(out): + calls = out["queueCalls"] + assert out["firstResult"] == "one" and out["confirmResult"] == "three" + assert calls.index("overlay:on") < calls.index("release") < calls.index("job1:end") + assert calls.index("job1:end") < calls.index("job3") < calls.index("overlay:off") + assert out["thirdBlocked"] is False and "job4" not in calls + assert out["busyAfter"] is False + assert out["otherProject"] == "p2" and out["otherCalls"] == [] # 프로젝트마다 따로 + + +def test_3_고친_것_없고_단계_완료면_바로_이동(): + shared = _read("A00_Common/design/design_shared.ts") + fn = shared[shared.index("async function alreadyConfirmed(") :] + fn = fn[: fn.index("\n}\n")] + assert "designSaveBusy(projectId) || designDirty(projectId)" in fn + assert "await pendingRouteSave(projectId)" in fn + assert 's.state === "COMPLETE"' in fn and "done(2) && !!done(3)" in fn + go = shared[shared.index("export function confirmAndGo(") :] + go = go[: go.index("\n}\n")] + assert ( + "skip ? goToWorkflowStage(shared.projectId, route) : saveSharedDesign(shared, true, route)" + in go + ) + # 줄 세움 = [저장] · 확정 한 곳 + assert ( + "await queueDesignSave(shared.projectId, confirm, () => saveOnce(shared, confirm, target));" + in shared + ) + + +def test_결과_한_줄_걷음(): + apply = _read("B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts") + save = _read("A00_Common/design/design_save.ts") + assert "routeSaveSummary" not in apply and "routeSaveSummary" not in save + assert "outsideNotice" not in save # 노선 밖 = 편집 중 경고만(저장 뒤 되풀이 없음) + assert "noticeDroppedEdits(response)" in save # 계획고 편집 지움 알림은 그대로(498-2 보류) diff --git a/resources/tester/test_346_3_route_save_screen.py b/resources/tester/test_346_3_route_save_screen.py index fcf477f9b..3fd0731b8 100644 --- a/resources/tester/test_346_3_route_save_screen.py +++ b/resources/tester/test_346_3_route_save_screen.py @@ -68,10 +68,7 @@ def test_저장_뒤_가게_초안_비움_결과_한_줄(out): assert out["savedRouteId"] == 9 assert out["storeAfterSave"] is None assert out["keptAfterSave"] == 1 - assert out["summary"] == ( - "노선 저장 — 관 지킴 3 · 새 교차 관 1 · 구조물 옮김 1 · 멀어짐 0 · " - "노선 밖 고침 3 건 · 배수유역 낡음" - ) # 서버 cross_dropped 1 + 화면 넘김 밖 2 = 한 줄 + assert out["summary"] == "undefined" # 498-3 — 결과 한 줄(관 지킴 · 노선 밖 고침 …) 걷음 def test_초안_넘김_차례(out): @@ -130,11 +127,11 @@ def test_넘김_알림은_결과_한_줄에_합침(): keys = _read("B06_Section/B06_Section_Station_Keys.ts") assert "{ quiet = false }: { quiet?: boolean } = {}," in keys assert "if (outside.size && !quiet)" in keys - # 6-B — 서버 넘김이 버린 수(`outside` · 측점 열쇠 + 누가거리 칸)를 결과 한 줄에(`routeSaveSummary`) patch = _read("B06_Section/B06_Section_Route_Carry_Patch.ts") assert "outside: keyed.outside.size + chain.outside.size" in patch + # 498-3 — 결과 한 줄 걷음 · 남김 = 따라감 실패 · 새 교차 관 save = _read("A00_Common/design/design_save.ts") - assert "routeSaveSummary(saved, carryOutside)" in save + assert "routeSaveSummary" not in save and "새 교차 관 ${crossing}개" in save def test_노선_반영_글자_없음(): diff --git a/resources/tester/test_384_6_save_one_line.py b/resources/tester/test_384_6_save_one_line.py index 89e08aeb7..bf51dba45 100644 --- a/resources/tester/test_384_6_save_one_line.py +++ b/resources/tester/test_384_6_save_one_line.py @@ -21,7 +21,7 @@ def _read(rel: str) -> str: def _save_body() -> str: shared = _read("A00_Common/design/design_shared.ts") start = shared.index("export async function saveSharedDesign") - return shared[start : shared.index("\n}\n", start)] + return shared[start : shared.index("\n}\n", shared.index("async function saveOnce(", start))] def test_저장도_횡단_보기를_세움(): diff --git a/resources/tester/test_416_page_cache.py b/resources/tester/test_416_page_cache.py index 8d7a4378c..7bb50854d 100644 --- a/resources/tester/test_416_page_cache.py +++ b/resources/tester/test_416_page_cache.py @@ -89,7 +89,7 @@ def test_M1_비운_뒤_두_보기는_새_객체_하나(r): def test_M1_저장_끝_한_번_비우고_종단_다시_읽음(): shared = _read("A00_Common/design/design_shared.ts") - done = shared[shared.index("done: async (isConfirm)") :] + done = shared[shared.index("done: async (isConfirm, reload = true)") :] assert done.index("invalidateSectionDetail(shared.projectId)") < done.index("profileSave?.done") actions = _read("B05_Profile/B05_Profile_UI_Page_Actions.ts") tail = actions[actions.index("done: async") :] diff --git a/resources/tester/test_design_store_save.py b/resources/tester/test_design_store_save.py index 75e5606a2..bc87bb052 100644 --- a/resources/tester/test_design_store_save.py +++ b/resources/tester/test_design_store_save.py @@ -75,8 +75,8 @@ def test_저장_차례(out): assert out["saveOk"] is True # 472-6 — 노선 초안 없으면 조각을 모아 한 요청(서버 한 창구 · 전부 아니면 없음) · # 저장 뒤 조각 정리는 차례대로 · 본문 없는 조각(추가 측점 초안 없음)도 정리는 돎 + # 498-3 D1 — 노선 모양 안 바뀐 [저장] = 잠금 없이 뒤에서 assert out["saveCalls"] == [ - "overlay:on", "prepare", "post:7:false:pipes,structures,alignment,culvertOptions,uphill,cross", "pipes:after", @@ -86,7 +86,6 @@ def test_저장_차례(out): "culvert:after:aislo:draft:culvertopt:p1:7", "cross:after:false", "done:false", - "overlay:off", ] assert out["afterSave"] == {"dirty": False, "draft": None} @@ -125,8 +124,7 @@ def test_노선이_바뀌면_노선_먼저_뒤_차례는_새_노선(out): "plusStations:after", "culvert:after:aislo:draft:culvertopt:p1:7", "cross:carried:after", - "summary:9:2", - "toast:info", + "toast:info", # 498-3 — 결과 한 줄 걷음 · 새 교차 관 한 줄만 "done:false", "rebuild", "overlay:off",