fix(498-3 저장 모양): 노선 그대로 [저장] = 잠금 없이 뒤에서 · 저장 중 [저장] 막음 · 저장 중 [>] = 앞 저장 끝 뒤 확정 · 결과 한 줄 토스트 걷음 — 요구 298-3 · 302 D1
- 잠금 = 확정 · 노선 모양 바뀐 [저장]만 · 그 밖 [저장]은 뒤에서 · 끝나면 「저장했습니다」 - 줄 세움 queueDesignSave — 저장 중 [저장] = 「이전 수정을 저장 중입니다…」 + 막음 · 저장 중 확정 = 잠그고 기다린 뒤 지금 화면 · 서버 저장 도중 안 끊음 - 저장 도중 고침 = 조각 정리 · 깨끗한 표 건너뜀(초안 남아 다음 저장에서 다시 나감) - 노선 바뀐 [저장] = 옛 페이지 두 보기 헛 다시 읽기 걷음 · 떠남 표 · 알림만 뒤 다시 세움 - B06 [>] = 고친 것 없음 + 단계 2 · 3 COMPLETE 면 저장 없이 이동 · 그 밖 496 확정 그대로 - 노선 저장 알림 = 따라감 실패 · 새 교차 관 한 줄만(routeSaveSummary · 노선 밖 되풀이 지움) - 시험 test_298_3_save_shape 새로 · 옛 요약 · 잠금 단언 고쳐 씀 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -70,6 +70,11 @@ function digest(text: string): string {
|
||||
// ponytail: 깨끗한 표는 메모리만 — [저장] 뒤 새로고침하면 남은 횡단 초안 때문에 한 번 더 물음.
|
||||
const cleanMarks = new Map<string, string>();
|
||||
|
||||
/** 지금 고침 지문(짧게) — 저장 본문을 낸 때와 끝난 때를 견줌(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)));
|
||||
|
||||
@@ -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<Record<SavePartKey, PartOverride>>;
|
||||
/** 횡단 조각 — 보기마다 본문이 다르다(표준 횡단 · 카드 고침 · 유토). */
|
||||
crossPart: (confirm: boolean) => Promise<SavePart | null>;
|
||||
/** 모두 나간 뒤 — 캐시 비우기 · 화면 다시 읽기 · 알림 · 다음 단계. */
|
||||
done?: (confirm: boolean) => Promise<void> | void;
|
||||
/** 모두 나간 뒤 — 캐시 비우기 · 화면 다시 읽기 · 알림 · 다음 단계. `reload` 거짓 = 알림만(노선 바뀐 저장 =
|
||||
* 곧 다시 세움 · 옛 노선 id 로 다시 읽기는 헛일 · 498-3). */
|
||||
done?: (confirm: boolean, reload?: boolean) => Promise<void> | void;
|
||||
/** 멈춘 저장을 알린다. */
|
||||
failed: (error: unknown, confirm: boolean) => void;
|
||||
/** 노선 모양 바뀐 채 확정 — 노선 · 고침은 저장됨 · 페이지 다시 세우기 앞에 물음(요구 300 B2). */
|
||||
@@ -75,6 +76,45 @@ export type PartOverride = (
|
||||
) => Promise<SavePart | null> | SavePart | null;
|
||||
|
||||
const saving = new Set<string>();
|
||||
/** 프로젝트마다 지금 도는 저장 한 번(498-3 D1) — 확정인가 · 끝 약속. */
|
||||
const inflight = new Map<string, { confirm: boolean; done: Promise<unknown> }>();
|
||||
|
||||
/** 저장이 도는 중인가(498-3) — 뒤에서 도는 [저장] 포함. */
|
||||
export function designSaveBusy(projectId: string): boolean {
|
||||
return inflight.has(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 한 번을 줄 세움(요구 302 D1) — 저장 중 [저장] = 막음 + 알림 · 저장 중 확정 = 잠그고 앞 저장 끝을 기다린 뒤
|
||||
* 지금 화면으로(서버 한 본문 저장은 도중에 안 끊음) · 확정 중 또 확정 = 막음. 막으면 false.
|
||||
*/
|
||||
export async function queueDesignSave<T>(
|
||||
projectId: string,
|
||||
confirm: boolean,
|
||||
job: () => Promise<T>,
|
||||
): Promise<T | false> {
|
||||
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<Record<SavePartKey, SavePart | null>> = {};
|
||||
// 노선이 바뀌면 횡단 = 옛 노선 세션 고침 원본(서버가 새 측점에 넘김) · 화면 몫 조각은 옛 노선 판이라 안 씀
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
|
||||
/**
|
||||
* [저장] · 확정 한 번(확정 = `target` 으로 감 · 기본 B07) — 관(배수유역도) · 계획고는 한 벌 종단도가, 구조물 · 상단측은 종단 보기가,
|
||||
* 저장 앞 재계산 · 암거 옵션 · 횡단 본문은 횡단 보기가 낸다. 차례는 `saveDesign` 한 곳.
|
||||
* 저장 앞 재계산 · 암거 옵션 · 횡단 본문은 횡단 보기가 낸다. 차례는 `saveDesign` 한 곳 · 줄 세움은 `queueDesignSave`(498-3).
|
||||
*/
|
||||
export async function saveSharedDesign(
|
||||
shared: DesignPageShared,
|
||||
confirm: boolean,
|
||||
target: RoutePath = WORKFLOW_STEP_ROUTES[4],
|
||||
): Promise<void> {
|
||||
await queueDesignSave(shared.projectId, confirm, () => saveOnce(shared, confirm, target));
|
||||
}
|
||||
|
||||
async function saveOnce(
|
||||
shared: DesignPageShared,
|
||||
confirm: boolean,
|
||||
target: RoutePath,
|
||||
): Promise<void> {
|
||||
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);
|
||||
|
||||
@@ -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 는 서버가
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -118,6 +118,7 @@ const STUBS = {
|
||||
},
|
||||
// 472-6 한 본문 창구 — 보낸 칸만 적음 · 구조물 실패 = 서버가 그 차례에서 되돌림
|
||||
"./design_save_api": saveApiStub,
|
||||
"./design_leave": { designSignature: () => "", markDesignClean: () => undefined },
|
||||
};
|
||||
|
||||
const cache = new Map();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 보류)
|
||||
@@ -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_노선_반영_글자_없음():
|
||||
|
||||
@@ -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_저장도_횡단_보기를_세움():
|
||||
|
||||
@@ -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") :]
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user