diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index 7f750fdea..37836a853 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -23,7 +23,11 @@ import { toDesignProfile, } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data"; import { showToast } from "@ui/ui_template_elements"; -import { saveProfileAlignment } from "../../B05_Profile/B05_Profile_Api_Fetch"; +import { + saveProfileAlignment, + type ProfileAlignmentSaveResponse, +} from "../../B05_Profile/B05_Profile_Api_Fetch"; +import type { SavePart } from "./design_save_api"; import { hasStaleDesigns } from "../../B06_Section/B06_Section_UI_Section_Common"; import { blocksMinCover, @@ -297,6 +301,28 @@ export function createDesignProfile(ctx: DesignProfileContext) { listedBase(storedBase, detail, planted ? irregularStations : null); /** 편집 후 다시 그린다 — 길게 누르기(초당 10회)도 한 프레임에 한 번만 그림. */ + /** 한 본문 [저장] 조각(472-6) — 편집 없음 · 노선 초안 미리보기면 null · 저장 뒤 서버 선형으로 다시 그림. */ + function alignmentSavePart(): SavePart | null { + if (!routeId || !store.dirty() || store === preview) return null; // 노선 초안 미리보기 편집은 안 보냄 + return { + body: { route_id: routeId, ...store.edits() }, + after: (response) => adoptSaved(response as unknown as ProfileAlignmentSaveResponse), + }; + } + + function adoptSaved(saved: ProfileAlignmentSaveResponse): void { + noticeDroppedEdits(saved); + const next = saved.profile_alignment as ProfileAlignment | undefined; + if (next?.base_pvi?.length) { + storedBase = toAlignmentBase(next); + base = listed(); + alignment = buildAlignment(base!, store.edits()); + if (detail) detail.longitudinal.profile_alignment = next; + } + store.markSaved(); + draw(); + } + function rebuild(): void { if (base) alignment = buildAlignment(base, store.edits()); pagePipeline.pipelineGrade(projectId, store.edits()); @@ -597,19 +623,17 @@ export function createDesignProfile(ctx: DesignProfileContext) { }, /** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */ async save(): Promise { - if (!routeId || !store.dirty() || store === preview) return; // 노선 초안 미리보기 편집은 안 보냄 - const saved = await saveProfileAlignment(projectId, routeId, store.edits()); - noticeDroppedEdits(saved); - const next = saved.profile_alignment as ProfileAlignment | undefined; - if (next?.base_pvi?.length) { - storedBase = toAlignmentBase(next); - base = listed(); - alignment = buildAlignment(base!, store.edits()); - if (detail) detail.longitudinal.profile_alignment = next; - } - store.markSaved(); - draw(); + const part = alignmentSavePart(); + if (!part) return; + await part.after?.( + (await saveProfileAlignment(projectId, routeId!, store.edits())) as unknown as Record< + string, + unknown + >, + ); }, + /** 한 본문 [저장] 조각(472-6) — 편집 없음 · 노선 초안 미리보기면 null · 저장 뒤 서버 선형으로 다시 그림. */ + savePart: () => alignmentSavePart(), clear() { detail = null; base = null; diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts index d03985c1f..5f2b2495e 100644 --- a/A00_Common/design/design_save.ts +++ b/A00_Common/design/design_save.ts @@ -25,6 +25,7 @@ import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; import { flushPendingPipes, + pipesPart, readPendingPipes, writePendingPipes, } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft"; @@ -34,17 +35,39 @@ import { fetchStructures, flushPendingStructures, readPendingStructures, + structuresPart, writePendingStructures, } from "../../B05_Profile/B05_Profile_Api_Structures"; -import { flushPlusStations, outsideNotice } from "../../B05_Profile/B05_Profile_Api_PlusStations"; -import { flushRouteParts, saveProfileAlignment } from "../../B05_Profile/B05_Profile_Api_Fetch"; +import { + flushPlusStations, + outsideNotice, + plusStationsPart, +} from "../../B05_Profile/B05_Profile_Api_PlusStations"; +import { + flushRouteParts, + routePartsBody, + saveProfileAlignment, +} from "../../B05_Profile/B05_Profile_Api_Fetch"; import { markAlignmentSaved, pendingAlignment, writeAlignmentDraft, } from "../../B05_Profile/B05_Profile_UI_Profile_Edit"; -import { flushCulvertOptions } from "../../B06_Section/B06_Section_Api_Culvert_Options"; -import { flushPageSettings } from "../../B05_Profile/B05_Profile_Api_PageSettings"; +import { + culvertOptionsPart, + flushCulvertOptions, +} from "../../B06_Section/B06_Section_Api_Culvert_Options"; +import { + flushPageSettings, + pageSettingsPart, +} from "../../B05_Profile/B05_Profile_Api_PageSettings"; +import { + DesignSaveError, + postDesignSave, + SAVE_PART_KEYS, + type SavePart, + type SavePartKey, +} from "./design_save_api"; import { clearState, stateKey } from "../b_page_state"; import { carryAfterRouteSave, @@ -75,18 +98,23 @@ export interface DesignSaveContext { /** 저장 앞 준비 — 면적을 모으기 전 전 측점 재계산(B06). */ prepare?: () => Promise; /** - * 차례마다 **기본 창구 대신** 도는 일. 없으면 기본 창구(세션 초안 → 정본)가 돈다. - * `pipes` 만 예외 — 기본(세션 초안)이 먼저 나가고 그 뒤에 돈다(저장분으로 화면만 맞춤 · PUT 없음 · 433-7). + * 한 본문 차례 조각을 보기가 고침(472-6) — 받는 값 = 기본 조각(세션 초안 → 본문 · 없으면 null) · + * 돌려주는 조각이 그 차례 본문 · 저장 뒤 정리(보기 화면 맞춤을 덧붙이거나 보기 목록으로 갈음). */ - steps?: Partial, () => Promise>>; - /** 횡단 — 보기마다 본문이 다르다. `confirm` 이면 단계를 닫는 길로. */ - cross: (confirm: boolean) => Promise; + parts?: Partial>; + /** 횡단 조각 — 보기마다 본문이 다르다(표준 횡단 · 카드 고침 · 유토). */ + crossPart: (confirm: boolean) => Promise; /** 모두 나간 뒤 — 캐시 비우기 · 화면 다시 읽기 · 알림 · 다음 단계. */ done?: (confirm: boolean) => Promise | void; /** 멈춘 저장을 알린다. */ failed: (error: unknown, confirm: boolean) => void; } +export type PartOverride = ( + base: SavePart | null, + confirm: boolean, +) => Promise | SavePart | null; + const saving = new Set(); /** 초안을 내보내는 중인가(323-9) — 그동안 바뀜 신호로 낡은 상세 · 빈 초안을 그리지 않음(뒤처리가 한 번 다시). */ @@ -241,6 +269,9 @@ export async function saveDesign( const confirm = options.confirm ?? false; const fromRouteId = ctx.routeId(); if (fromRouteId === null) return false; + // 노선 초안이 없으면 한 본문 한 요청(472-6 6-A · 서버가 전부 아니면 없음) · 있으면 옛 차례(6-B 전) + const routeBody = await pendingRouteSave(ctx.projectId); + if (!routeBody) return saveOneBody(ctx, confirm, fromRouteId); let routeId = fromRouteId; /** 노선을 새로 저장했으면 그 결과 — 뒤 차례는 새 노선 · 화면 몫(옛 노선 판) 일은 건너뜀. */ let routeSaved: Awaited> | null = null; @@ -253,8 +284,7 @@ export async function saveDesign( for (const step of DESIGN_SAVE_ORDER) { try { if (step === "route") { - const body = await pendingRouteSave(ctx.projectId); // 안 바뀌었으면 null — 안 보냄 - if (!body) continue; + const body = routeBody; const [before, beforeStructures] = await Promise.all([ savedPipesIfDraft(ctx.projectId), savedStructuresIfDraft(ctx.projectId), @@ -273,27 +303,15 @@ export async function saveDesign( continue; } 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"); - } + carried = true; + const outside = await carryAfterRouteSave(ctx.projectId, fromRouteId, routeId); + if (routeSaved) showToast(routeSaveSummary(routeSaved, outside), "info"); continue; } // 상단측은 옛 누가거리 열쇠 — 노선이 바뀌면 횡단 차례(`carryAfterRouteSave`)가 새 측점으로 옮긴 뒤 보냄(443-2) if (step === "uphill" && routeSaved) continue; // 노선이 바뀌면 화면 몫 일(옛 노선 판 목록 · 계획고 판)은 건너뛰고 기본 창구만 새 노선으로. - const own = routeSaved ? undefined : ctx.steps?.[step]; - const base = defaultStep(step, ctx.projectId, routeId, fromRouteId, confirm); - const run = - step === "pipes" - ? async () => { - await base(); - await own?.(); - } - : (own ?? base); - await run(); + await defaultStep(step, ctx.projectId, routeId, fromRouteId, confirm)(); } catch (error) { throw stepFailed(step, error); // 그 차례에서 멈춤 · 이름째 알림(429 ⑨) } @@ -322,3 +340,86 @@ export async function saveDesign( hideLoadingOverlay(); } } + +/** 한 본문 기본 조각 — 세션 초안 그대로(옛 기본 창구와 같은 본문). */ +async function defaultPart( + key: Exclude, + projectId: string, + routeId: number, + confirm: boolean, +): Promise { + switch (key) { + case "settings": + return pageSettingsPart(projectId); + case "pipes": + return pipesPart(projectId, confirm); + case "structures": + return structuresPart(projectId); + case "plusStations": + return plusStationsPart(projectId); + case "alignment": { + const draft = pendingAlignment(routeId); + if (!draft) return null; + return { + body: { route_id: routeId, ...draft }, + after: (response) => { + if (response) noticeDroppedEdits(response); + markAlignmentSaved(routeId); + }, + }; + } + case "culvertOptions": + return culvertOptionsPart(stateKey("culvertopt", projectId, routeId)); + case "uphill": + return { + body: await routePartsBody(projectId), + after: () => clearState("uphill", projectId), + }; + } +} + +/** 서버 실패 → 화면 글 — 차례 이름째 · 모두 그대로 · 되돌림마저 실패면 「복구 확인 필요」. */ +function oneBodyError(error: unknown): Error { + if (!(error instanceof DesignSaveError)) + return error instanceof Error ? error : new Error(String(error)); + if (!error.restored) return new Error(`저장 실패 · 복구 확인 필요 — ${error.message}`); + const where = error.step ? `${STEP_LABEL[error.step]} 차례에서 ` : ""; + return new Error(`${where}실패 — 아무것도 저장하지 않았습니다(모두 그대로). ${error.message}`); +} + +/** 노선 안 바뀐 [저장] · [확정] — 조각을 모아 서버 한 창구에 한 번(472-6 6-A). 서버가 다 저장한 뒤에만 조각 + * 정리(초안 비움 · 화면 맞춤) · 실패면 초안 그대로(다음 [저장]에서 다시). */ +async function saveOneBody( + ctx: DesignSaveContext, + confirm: boolean, + routeId: number, +): Promise { + showLoadingOverlay(); + saving.add(ctx.projectId); + try { + await ctx.prepare?.(); + const parts: Partial> = {}; + for (const key of SAVE_PART_KEYS) { + if (key === "cross") { + parts.cross = await ctx.crossPart(confirm); + continue; + } + const base = await defaultPart(key, ctx.projectId, routeId, confirm); + const own = ctx.parts?.[key]; + parts[key] = own ? await own(base, confirm) : base; + } + const result = await postDesignSave(ctx.projectId, routeId, confirm, parts); + if (result.waited) showToast("앞 저장이 끝나기를 기다린 뒤 저장했습니다.", "info"); + 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); // 다 나감 — 뒤처리는 새 상세로 그림 + await ctx.done?.(confirm); + return true; + } catch (error) { + ctx.failed(oneBodyError(error), confirm); + return false; + } finally { + saving.delete(ctx.projectId); + hideLoadingOverlay(); + } +} diff --git a/A00_Common/design/design_save_api.ts b/A00_Common/design/design_save_api.ts new file mode 100644 index 000000000..e999ac62b --- /dev/null +++ b/A00_Common/design/design_save_api.ts @@ -0,0 +1,100 @@ +/* ============================================================================= + * A00_Common/design/design_save_api.ts + * [저장] · [확정] 한 본문(PLAN 472-6 6-A) — 차례 조각 꼴 · 서버 한 창구 `POST /design/save`. + * + * 차례마다 조각 하나 = 본문(없으면 null · 서버가 그 차례를 건너뜀) + 저장 뒤 정리(초안 비움 · 화면 맞춤). + * 서버는 전부 저장하거나 아무것도 안 바꿈 — 실패면 차례 이름 · 까닭(`restored` 거짓 = 복구 확인 필요). + * ========================================================================== */ + +import { API_ANALYSIS_TIMEOUT_MS, API_BASE_URL } from "@config/config_frontend"; + +/** 서버 본문 칸 차례 그대로(`B06_Section_Router_DesignSave`). */ +export const SAVE_PART_KEYS = [ + "settings", + "pipes", + "structures", + "plusStations", + "alignment", + "culvertOptions", + "uphill", + "cross", +] as const; + +export type SavePartKey = (typeof SAVE_PART_KEYS)[number]; + +/** 차례 조각 — `after` 는 서버가 다 저장한 뒤에만(그 차례 응답을 받음). */ +export interface SavePart { + body: unknown | null; + after?: (response: Record | undefined) => Promise | void; +} + +const BODY_KEY: Record = { + settings: "settings", + pipes: "pipes", + structures: "structures", + plusStations: "plus_stations", + alignment: "alignment", + culvertOptions: "culvert_options", + uphill: "uphill", + cross: "cross", +}; + +export interface DesignSaveResult { + waited: boolean; + steps: Partial>>; +} + +/** 실패 — 차례 이름(`step`) · 되돌림 됨(`restored`). */ +export class DesignSaveError extends Error { + constructor( + message: string, + readonly step: SavePartKey | null, + readonly restored: boolean, + ) { + super(message); + } +} + +/** 조각 본문을 모아 한 번에 보냄. */ +export async function postDesignSave( + projectId: string, + routeId: number, + confirm: boolean, + parts: Partial>, +): Promise { + const body: Record = { route_id: routeId, confirm }; + for (const key of SAVE_PART_KEYS) { + const part = parts[key]; + if (part && part.body !== null && part.body !== undefined) body[BODY_KEY[key]] = part.body; + } + const controller = new AbortController(); + // [확정]은 낡은 배수유역을 다시 셈 — 옛 차례들의 시간 한도 합 만큼 + const timer = window.setTimeout(() => controller.abort(), API_ANALYSIS_TIMEOUT_MS * 3); + try { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/design/save`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: controller.signal, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) + throw new DesignSaveError( + payload?.message ?? `HTTP ${response.status}`, + (payload?.step as SavePartKey) ?? null, + payload?.restored !== false, + ); + return { waited: payload?.waited === true, steps: payload?.steps ?? {} }; + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") + throw new DesignSaveError( + "저장 응답을 시간 안에 못 받았습니다 — 새로고침해 저장됐는지 확인해 주세요.", + null, + false, + ); + throw error; + } finally { + window.clearTimeout(timer); + } +} diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 57581dac0..4a7c4fe4d 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -27,7 +27,8 @@ 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 DesignSaveStep } from "./design_save"; +import { saveDesign, type PartOverride } from "./design_save"; +import type { SavePart, SavePartKey } from "./design_save_api"; import { markDesignClean } from "./design_leave"; import { resetDesign } from "./design_reset"; import type { DesignView } from "./design_dock"; @@ -71,10 +72,10 @@ export interface DesignProfileBinding { export interface DesignViewSave { routeId: () => number | null; prepare?: () => Promise; - steps?: Partial< - Record, () => Promise> - >; - cross?: (confirm: boolean) => Promise; + /** 한 본문 차례 조각 고침(472-6) — 구조물 · 상단측(종단 보기) 등. */ + parts?: Partial>; + /** 횡단 조각 — 횡단 보기 몫(표준 횡단 · 카드 고침 · 유토). */ + crossPart?: (confirm: boolean) => Promise; /** 저장 뒤 이 보기만의 캐시 비우기 · 다시 읽기(알림 · 다음 단계는 여기서 한다). */ done?: (confirm: boolean) => Promise | void; } @@ -399,8 +400,8 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea await shared.ensureView("cross"); const profileSave = shared.bindings.profile?.save; const crossSave = shared.bindings.cross?.save; - const crossBody = crossSave?.cross; - if (!crossBody) { + const crossPart = crossSave?.crossPart; + if (!crossPart) { showToast(L(confirm ? "B06_Profile_Confirm_Failed" : "B06_Profile_Save_Failed"), "error"); return; } @@ -411,16 +412,26 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea projectId: shared.projectId, routeId: () => routeIdOf(shared), prepare: crossSave?.prepare, - steps: { - // 관 정본 PUT 은 세션 초안 한 번(기본 창구) — 배수유역도는 저장분을 다시 받아 화면만 맞춤(433-7 · 판 + parts: { + // 관 본문은 세션 초안(기본 조각) — 배수유역도는 저장 뒤 저장분을 다시 받아 화면만 맞춤(433-7 · 판 // 메모리를 다시 보내면 B06 고침 직후 · 판이 안 선 [저장]에서 관을 잃음). - ...(side ? { pipes: async () => side.reload(confirm) } : {}), - // 계획고 초안 — 한 벌 종단도가 저장하고 다시 그린다. - ...(profile ? { alignment: () => profile.save() } : {}), - ...profileSave?.steps, - ...crossSave?.steps, + ...(side + ? { + pipes: (base: SavePart | null) => ({ + body: base?.body ?? null, + after: async (response: Record | undefined) => { + await base?.after?.(response); + await side.reload(confirm); + }, + }), + } + : {}), + // 계획고 초안 — 한 벌 종단도가 본문을 내고 저장 뒤 서버 선형으로 다시 그린다. + ...(profile ? { alignment: () => profile.savePart() } : {}), + ...profileSave?.parts, + ...crossSave?.parts, }, - cross: crossBody, + crossPart, done: async (isConfirm) => { markDesignClean(shared.projectId); // 떠남 확인 — 지금 초안이 저장분(346-4) if (isConfirm) { diff --git a/B05_Profile/B05_Profile_Api_Fetch.ts b/B05_Profile/B05_Profile_Api_Fetch.ts index e87d9169d..b9ebc0adf 100644 --- a/B05_Profile/B05_Profile_Api_Fetch.ts +++ b/B05_Profile/B05_Profile_Api_Fetch.ts @@ -255,17 +255,22 @@ function uphillOverridesOf( * 횡단을 맞춤) · 비정규 목록 = 빈 목록(서버가 정본 관 · 구조물에서 셈) · 상단측 = 세션. 종단 보기를 열었든 * 안 열었든 같은 요청(기본 창구 · 종단 보기 몫 둘 다 이것). */ export async function flushRouteParts(projectId: string): Promise { + await saveRouteParts(projectId, await routePartsBody(projectId)); +} + +/** 상단측 차례 본문 — 노선 캐시 지표 조건 + 확정 지표면 id + 세션 상단측. */ +export async function routePartsBody(projectId: string): Promise { const latest = readRouteLatestCache(projectId) ?? (await fetchLatestRoute(projectId).catch(() => null)); const surface = await fetchConfirmedSurface(projectId).catch(() => null); - await saveRouteParts(projectId, { + return { filter_key: latest?.surface_params?.source_filter, method: latest?.surface_params?.method, smooth: latest?.surface_params?.smooth, surface_model_id: surface?.model_id ?? undefined, irregular_stations: [], uphill_overrides: uphillOverridesOf(projectId), - }); + }; } /** [저장] 차례의 확정(단계 전이 없음) — 하위 차례가 실패하면 던짐(443 · 저장 한 줄기가 그 차례에서 멈춤 · diff --git a/B05_Profile/B05_Profile_Api_PageSettings.ts b/B05_Profile/B05_Profile_Api_PageSettings.ts index 92ff97392..763f2682a 100644 --- a/B05_Profile/B05_Profile_Api_PageSettings.ts +++ b/B05_Profile/B05_Profile_Api_PageSettings.ts @@ -19,6 +19,7 @@ import { type PageSettingsBody, } from "./B05_Profile_Api_Fetch"; import type { RoutePanelValues } from "./B05_Profile_UI_Panel_Fields"; +import type { SavePart } from "../A00_Common/design/design_save_api"; export type PageSettingsDraft = PageSettingsBody; @@ -68,9 +69,20 @@ onPageHistory({ /** [저장] `settings` 차례 — 초안이 없으면 안 보냄 · 성공하면 최신 응답 캐시에 얹고 초안을 비움 * (실패 = 던짐 · 초안 그대로 다음 [저장]에서 다시). */ export async function flushPageSettings(projectId: string): Promise { + const part = pageSettingsPart(projectId); + if (!part) return; + await savePageSettings(projectId, part.body as PageSettingsDraft); + part.after?.(undefined); +} + +/** 한 본문 [저장] 조각(472-6) — 초안이 없으면 null · 저장 뒤 최신 응답 캐시에 얹고 초안 비움. */ +export function pageSettingsPart(projectId: string): SavePart | null { const draft = readPageSettings(projectId); - if (!draft) return; - await savePageSettings(projectId, draft); + if (!draft) return null; + return { body: draft, after: () => adoptPageSettings(projectId, draft) }; +} + +function adoptPageSettings(projectId: string, draft: PageSettingsDraft): void { const latest = readRouteLatestCache(projectId); if (latest?.route_params) { const { design_speed_kph, terrain_type, ...top } = draft; diff --git a/B05_Profile/B05_Profile_Api_Pipes_Draft.ts b/B05_Profile/B05_Profile_Api_Pipes_Draft.ts index f4836f25d..19ffdc744 100644 --- a/B05_Profile/B05_Profile_Api_Pipes_Draft.ts +++ b/B05_Profile/B05_Profile_Api_Pipes_Draft.ts @@ -15,6 +15,7 @@ import { useDesignStore } from "../A00_Common/design/design_store"; import { saveDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import type { SavePart } from "../A00_Common/design/design_save_api"; /** 세션에 담는 관 한 벌 — 정본 PUT 이 그대로 받는 꼴이다. */ export type PendingPipes = DetailPipeInput[]; @@ -35,3 +36,14 @@ export async function flushPendingPipes(projectId: string, confirm = false): Pro await saveDetailPipePoints(projectId, pending, confirm); writePendingPipes(projectId, null); } + +/** 한 본문 [저장] 조각(472-6) — 초안 = `{points}` · 초안 없는 [확정] = 빈 본문(서버 저장분으로 다시 셈) · + * 초안 없는 [저장] = null. 저장 뒤 초안 비움. */ +export function pipesPart(projectId: string, confirm: boolean): SavePart | null { + const pending = readPendingPipes(projectId); + if (!pending && !confirm) return null; + return { + body: pending ? { points: pending } : {}, + after: () => writePendingPipes(projectId, null), + }; +} diff --git a/B05_Profile/B05_Profile_Api_PlusStations.ts b/B05_Profile/B05_Profile_Api_PlusStations.ts index fd3789e0f..6d51a3048 100644 --- a/B05_Profile/B05_Profile_Api_PlusStations.ts +++ b/B05_Profile/B05_Profile_Api_PlusStations.ts @@ -10,6 +10,7 @@ import { API_BASE_URL, API_TIMEOUT_MS } from "../config/config_frontend"; import { draftProjectId, useDesignStore } from "../A00_Common/design/design_store"; import { onPageHistory } from "./B05_Profile_UI_Profile_History"; +import type { SavePart } from "../A00_Common/design/design_save_api"; const saved = new Map(); const loaded = new Map>(); @@ -126,6 +127,16 @@ export function followPlusDraft(projectId: string, next: FollowedDraft | null | /** [저장] 차례 — 사용자 초안이 있으면 정본에 씀 · 없으면(follow 만 쓴 초안 포함 · 459) 저장분만 다시 받음 * (노선 저장이 좌표로 옮겼을 수 있음). */ export async function flushPlusStations(projectId: string): Promise { + const part = plusStationsPart(projectId); + const list = part.body + ? await request(projectId, { method: "PUT", body: JSON.stringify(part.body) }) + : await request(projectId); + await part.after?.({ chainages: list }); +} + +/** 한 본문 [저장] 조각(472-6) — 사용자 초안이 있으면 `{chainages}` · 없으면(follow 만 쓴 초안 포함 · 459) null · + * 저장 뒤 저장분을 받은 목록(없으면 다시 받음)으로 · 초안 비움. */ +export function plusStationsPart(projectId: string): SavePart { const auto = followedOnly.get(projectId); followedOnly.delete(projectId); beforeRoute.delete(projectId); @@ -133,11 +144,15 @@ export async function flushPlusStations(projectId: string): Promise { if (kept && auto === JSON.stringify(kept)) useDesignStore(projectId, null).set("plusStations", null); const draft = readPlusDraft(projectId); - const list = draft - ? await request(projectId, { method: "PUT", body: JSON.stringify({ chainages: draft }) }) - : await request(projectId); - saved.set(projectId, list); - loaded.set(projectId, Promise.resolve()); - if (draft) useDesignStore(projectId, null).set("plusStations", null); - else notify(projectId); + return { + body: draft ? { chainages: draft } : null, + after: async (response) => { + const given = response?.chainages; + const list = Array.isArray(given) ? given.map(Number) : await request(projectId); + saved.set(projectId, list); + loaded.set(projectId, Promise.resolve()); + if (draft) useDesignStore(projectId, null).set("plusStations", null); + else notify(projectId); + }, + }; } diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index af5c22d92..b4355d8ef 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -24,6 +24,7 @@ import { type StructureDefinition, withDefinition, } from "./logic/B05_Profile_UI_Structures_Define"; +import type { SavePart } from "../A00_Common/design/design_save_api"; /** 배치형태 — 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적). */ export type StructurePlacement = "point" | "interval" | "site"; @@ -393,3 +394,18 @@ export async function flushPendingStructures(projectId: string): Promise { await saveStructures(projectId, stored.revision, pending ?? stored.structures); // 고침 없음 = 판 도장만 writePendingStructures(projectId, null); } + +/** 한 본문 [저장] 조각(472-6) — 초안(없고 정의 판이 낡았으면 저장분 = 판 도장) · 판번호는 지금 서버 값 · + * 초안도 낡음도 없으면 null · 저장 뒤 초안 비움. `list` = 보기가 쥔 목록(초안 없을 때 · 구조물 보기). */ +export async function structuresPart( + projectId: string, + list: StructureInstance[] | null = null, +): Promise { + const pending = readPendingStructures(projectId); + if (!pending && !list && !definitionStale(projectId)) return null; + const stored = await fetchStructures(projectId); + return { + body: { base_revision: stored.revision, structures: pending ?? list ?? stored.structures }, + after: () => writePendingStructures(projectId, null), + }; +} diff --git a/B05_Profile/B05_Profile_UI_Page_Actions.ts b/B05_Profile/B05_Profile_UI_Page_Actions.ts index fab601dbe..09b968450 100644 --- a/B05_Profile/B05_Profile_UI_Page_Actions.ts +++ b/B05_Profile/B05_Profile_UI_Page_Actions.ts @@ -7,7 +7,7 @@ * 조작은 [저장]·[확정]에서만 정본으로 나간다). [초기화]는 한 벌(`design_reset`)이 한다. * ========================================================================== */ -import { flushRouteParts, type RouteLatestResponse } from "./B05_Profile_Api_Fetch"; +import type { RouteLatestResponse } from "./B05_Profile_Api_Fetch"; import type { DesignViewSave } from "../A00_Common/design/design_shared"; import { pageRouteId } from "../A00_Common/design/design_page_route"; import type { createRoutePanel } from "./B05_Profile_UI_Panel"; @@ -41,16 +41,18 @@ export function profileSaveParts(ctx: PageActionContext): DesignViewSave { const projectId = ctx.projectId; return { routeId: () => (ctx.routeReady() ? pageRouteId(projectId) : null), // 노선 id 한 칸(386-2b) - steps: { - // 구조물 — 저장 뒤 서버 목록(새 식별자)으로 화면을 맞춘다. 실패는 초안을 남기고 던져 - // 저장 한 줄기가 이 차례에서 멈춘다(443). - structures: () => ctx.bridge().saveStructuresIfDirty(), - // 상단측 + 비정규 측점 횡단 맞춤 — 기본 창구와 같은 본문(463 · 종단 보기를 안 열어도 같은 요청). - // 하위 실패 = 던짐 · 성공 = 상단측 세션 비움(443) · 메모리 값도 비움(다시 안 보냄). - uphill: async () => { - await flushRouteParts(projectId); - ctx.uphillOverrides.clear(); - }, + parts: { + // 구조물 — 보낼 것 = 세션 초안 ?? 이 보기 목록 · 저장 뒤 서버 목록(새 식별자)으로 화면을 맞춘다(472-6). + structures: () => ctx.bridge().structuresSavePart(), + // 상단측 + 비정규 측점 횡단 맞춤 — 기본 조각 본문 그대로(463) · 저장 뒤 세션(기본) · 메모리 값도 비움. + uphill: (base) => + base && { + body: base.body, + after: async (response) => { + await base.after?.(response); + ctx.uphillOverrides.clear(); + }, + }, }, // 횡단 본문은 늘 횡단 보기 몫 — [저장]이 숨긴 채라도 세움(386-6 · 옛 캐시 패치 길 지움) // 공유 캐시는 한 벌(`design_shared`)이 저장 끝에 한 번 비움 — 여기서는 새 자료를 다시 읽어 종단 · diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 667ad1d0c..ac98743a2 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -21,6 +21,8 @@ import { readPendingStructures, writePendingStructures, StructureConflictError, + structuresPart, + type StructureSaveResponse, type StructureInstance, type StructureType, } from "./B05_Profile_Api_Structures"; @@ -30,6 +32,7 @@ import { listPick } from "../common_util/common_util_structure_catalog"; import { pipeChainageOf } from "../A00_Common/design/design_pipe_ids"; import { pageStructures, pageTypesChecked } from "../A00_Common/design/design_page_data"; import type { FacilityLane } from "../A00_Common/design/design_facility_lane"; +import type { SavePart } from "../A00_Common/design/design_save_api"; export interface StructuresBridgeDeps { projectId: string; @@ -117,6 +120,30 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { await run; } + /** 한 본문 [저장] 조각(472-6) — 보낼 것 = 세션 초안 ?? 이 보기 목록(`saveStructuresIfDirty` 와 같음) · + * 저장 뒤 판번호 · 서버 목록(새 식별자)으로 화면을 맞추고 뒤 단계 표시 실패를 알림. */ + async function structuresSavePart(): Promise { + if (deps.isRestoring() || (readPending() === null && !definitionStale(deps.projectId))) + return null; + const part = await structuresPart(deps.projectId, readPending() ?? ownStructures); + if (!part) return null; + return { + body: part.body, + after: async (response) => { + part.after?.(response); + const saved = response as unknown as StructureSaveResponse | undefined; + if (saved) structureRevision = saved.revision; + await refreshStructuresFromServer(); + if (saved?.needs_downstream_invalidation && !saved.invalidated_downstream) + showToast( + "구조물은 저장되었지만 이후 단계(횡단·수량) 재작업 표시에 실패했습니다. " + + "B06을 다시 실행해 주세요.", + "error", + ); + }, + }; + } + /** 서버 정본을 다시 받아 화면(사이드 목록·그래프 마크)을 그 상태로 맞춘다. */ async function refreshStructuresFromServer(): Promise { const stored = await fetchStructures(deps.projectId).catch(() => null); @@ -262,6 +289,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { deps.panel().structures.setStructures(ownStructures); }, saveStructuresIfDirty, + structuresSavePart, /** 서버 정본을 다시 받아 화면을 그 상태로 맞춘다. */ refreshFromServer: refreshStructuresFromServer, /** 진입·새로고침 — 타입 레지스트리와 구조물 정본을 받아 화면을 채운다. */ diff --git a/B06_Section/B06_Section_Api_Culvert_Options.ts b/B06_Section/B06_Section_Api_Culvert_Options.ts index ac80fd1dd..21aded88f 100644 --- a/B06_Section/B06_Section_Api_Culvert_Options.ts +++ b/B06_Section/B06_Section_Api_Culvert_Options.ts @@ -30,6 +30,7 @@ import { type StateName, } from "../A00_Common/b_page_state"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; +import type { SavePart } from "../A00_Common/design/design_save_api"; /** 측점 하나에 얹을 옵션 조각 — 키는 `pipe_points` 옵션 키 그대로. * 값은 수치가 대부분이나 날개벽 설치("있음"/"없음")처럼 문자열도 있다. */ @@ -92,6 +93,14 @@ export async function flushCulvertOptions( writePending(sessionKey, {}); } +/** 한 본문 [저장] 조각(472-6) — 세션 조각 {누가거리 0.01 글: 패치}(서버가 저장된 관 위에 얹음) · 없으면 null · + * 저장 뒤 세션 비움. */ +export function culvertOptionsPart(sessionKey: string | null): SavePart | null { + const pending = readPending(sessionKey); + if (!Object.keys(pending).length) return null; + return { body: pending, after: () => writePending(sessionKey, {}) }; +} + /** 구조물(관 · 박스 · 세월교) 조정값 열쇠 — 종류를 바꾸면 버리는 몫 · 서버 짝. */ export const STRUCTURE_ADJUST_KEYS = [ "inlet_structure", diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index d5e78e9b1..42f2182c0 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -121,10 +121,7 @@ export async function confirmSections( /** 프론트에서 계산한 유토곡선 결과. 확정 시점에만 영구 저장한다. */ massHaul?: Record, ): Promise { - const body: Record = {}; - if (standardCrossSection) body.standard_cross_section = standardCrossSection; - if (crossPatches?.length) body.cross_patches = crossPatches; - if (massHaul) body.mass_haul = massHaul; + const body = sectionConfirmBody(standardCrossSection, crossPatches, massHaul); return warnUnrecomputed( requestJson(`/projects/${projectId}/sections/${routeId}/confirm`, { method: "POST", @@ -139,6 +136,25 @@ async function warnUnrecomputed( request: Promise, ): Promise { const result = await request; + warnSectionSave(result); + return result; +} + +/** 횡단 저장 본문(저장 · 확정 · 한 본문 [저장] 조각 공통). */ +export function sectionConfirmBody( + standardCrossSection?: StandardCrossSection, + crossPatches?: CrossSectionPatch[], + massHaul?: Record, +): Record { + const body: Record = {}; + if (standardCrossSection) body.standard_cross_section = standardCrossSection; + if (crossPatches?.length) body.cross_patches = crossPatches; + if (massHaul) body.mass_haul = massHaul; + return body; +} + +/** 횡단 저장 응답의 경고 — 재계산 실패 · 서버가 버린 고침 · 측점 맞춤 실패(한 본문 [저장]도 부름). */ +export function warnSectionSave(result: Partial): void { const dropped = [ ...new Set([ ...(result.dropped_patch_chainages ?? []), @@ -163,7 +179,6 @@ async function warnUnrecomputed( if (result.recomputed === false) showToast(L("B06_Recompute_Failed"), "warning", 8000); if (lost.length) showToast(lost.join(" "), "warning", 8000); }); - return result; } /** @@ -177,10 +192,7 @@ export async function saveSections( crossPatches?: CrossSectionPatch[], massHaul?: Record, ): Promise { - const body: Record = {}; - if (standardCrossSection) body.standard_cross_section = standardCrossSection; - if (crossPatches?.length) body.cross_patches = crossPatches; - if (massHaul) body.mass_haul = massHaul; + const body = sectionConfirmBody(standardCrossSection, crossPatches, massHaul); return warnUnrecomputed( requestJson(`/projects/${projectId}/sections/${routeId}/save`, { method: "POST", diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 2c3607452..09ddd65ea 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -8,8 +8,8 @@ * ========================================================================== */ import { - confirmSections, - saveSections, + sectionConfirmBody, + warnSectionSave, type CrossSectionPatch, type SectionContextResponse, type SectionDetailResponse, @@ -411,23 +411,27 @@ export function crossSaveParts(ctx: SectionPersistContext): DesignViewSave { // 안 만진 측점이 옛 면적으로 실려 나간다(2026-09-09 실측). prepare: () => ctx.reconcileDesigns(), // 조정창 구간값은 조정창 저장기가 낸다 — 실패하면 알리고 저장을 멈춘다(예전 그대로). - steps: { culvertOptions: () => ctx.flushCulvertOptions() }, - cross: async (confirm) => { + // 관 옵션(조정창 구간값) = 기본 조각(세션 `culvertopt`) — 서버가 저장된 관 위에 얹음(472-6) + crossPart: async () => { const routeId = ctx.routeId(); - if (routeId === null || !ctx.projectId) return; + if (routeId === null || !ctx.projectId) return null; + const projectId = ctx.projectId; const edits = collectSectionEdits(ctx); // 바탕 = 캐시 설계의 사용자 칸(측점마다 통째 · 옮긴 측점은 지반 등 입력까지) + 세션 고침(445 H2 · H3 · M2) // 가게는 늦게 불러옴(가게 → 다시 셈 → 이 파일 순환 없음) const { cachedCrossPatches } = await import("./B06_Section_Section_Store"); - const patches = cachedCrossPatches(ctx.projectId, routeId, edits.crossPatches); - await (confirm ? confirmSections : saveSections)( - ctx.projectId, - routeId, - ctx.standardValues(), - patches.length ? patches : undefined, - edits.massHaul, - ); - clearState("kindreset", ctx.projectId); // 비울 자리를 보냈음(318-4) + const patches = cachedCrossPatches(projectId, routeId, edits.crossPatches); + return { + body: sectionConfirmBody( + ctx.standardValues(), + patches.length ? patches : undefined, + edits.massHaul, + ), + after: (response) => { + clearState("kindreset", projectId); // 비울 자리를 보냈음(318-4) + if (response) warnSectionSave(response); // 버린 고침 · 측점 맞춤(445 M5) + }, + }; }, done: async () => { // 저장으로 구조물 초안이 정본이 됐다 — 화면이 들고 있던 상세는 초안 기준으로 벽을 diff --git a/docs/wiki/concepts/design_page/operations_common.md b/docs/wiki/concepts/design_page/operations_common.md index 7517c1ae5..bb433b0c5 100644 --- a/docs/wiki/concepts/design_page/operations_common.md +++ b/docs/wiki/concepts/design_page/operations_common.md @@ -19,12 +19,15 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/ | 새로고침 | 세션 남음 · 메모리 전부 잃음 | 세션 상세로 먼저 섬 → 서버 정본과 비교(`checkSessionCanon`) → 다르면 갈아씀 · 노선 초안 있으면 hold · 관 · 구조물 · 추가 측점 초안 있으면 측점 냄 · 없으면 조용히 열고 가게 측점 = 상세 측점 | 상세 GET(정본 확인) | — | — | `B06_Section_Section_Store.ts:117-135` · `design_pipeline_page.ts:528-545` · `design_pipeline_page_publish.ts` opened · shown | | 떠남 [취소] | 없음 — 페이지 그대로 | — | — | — | — | `design_leave.ts:95-123` | | 떠남 [폐기] | 세션 고침 칸 지움(모든 노선 · `NOT_EDITS` · B08 · B09 칸 뺌) → 떠남 = dispose | dispose = 묶음 · 시설 줄 · 페이지 자료(`forgetPageData`) · 상세 메모리(`forgetSectionMemory`) 버림 · 다시 열면 새로고침 길 | — | — | — | `design_leave.ts:27-42 · 85-87` · `design_page.ts:286-295` · `B06_Section_Section_Store.ts:424-428` | -| [저장] | — | 앞 단계 `prepare` = 전 측점 강제 다시 셈(서버가 면적 버림) · 화면 잠금 | 아래 차례 표 · 서버 정본 다시 셈 | 끝 = `invalidateSectionDetail` · 두 보기가 새 상세 한 객체 받음 · 노선 바뀜 = 페이지 다시 세움 | — | `design_shared.ts:395-448` · `design_save.ts:237-324` | +| [저장] | — | 앞 단계 `prepare` = 전 측점 강제 다시 셈(서버가 면적 버림) · 화면 잠금 | 노선 안 바뀜 = 조각 한 본문 `POST /design/save`(서버가 아래 차례 · 전부 아니면 없음) · 노선 바뀜 = 아래 차례 표(옛 길) · 서버 정본 다시 셈 | 끝 = `invalidateSectionDetail` · 두 보기가 새 상세 한 객체 받음 · 노선 바뀜 = 페이지 다시 세움 | — | `design_shared.ts:395-448` · `design_save.ts:237-324` | | [확정] | — | [저장]과 같음 | [저장] + 빈 측점 기본 설계 · 단계 완료 · 측구 쪽을 B05 종단 파일로 되씀 | 끝 = `invalidateSectionDetail` → B07 로 넘어감 · 노선이 바뀐 [확정] = 노선만 저장 · 다시 [확정] 알림 | — | `design_shared.ts:426-432` · `design_save.ts:303-310` · `B06_Section_Router_Confirm.py:368-496` | | [초기화] | — | 상세 비움 · 새로고침(메모리 전부 버림) | `/route/reset` | DB 노선 행 지우고 초기값으로 · 파일 트리 복원 · 세션 초안 전부 지움(B08 · B09 뺌) | — | `design_reset.ts:30-62` · `B05_Profile_Router_Lifecycle.py:234-390` · `common_util_initial_snapshot.py:38-54 · 266-327` | ## [저장] 차례(`DESIGN_SAVE_ORDER` `design_save.ts:58-68`) +- 노선 안 바뀐 [저장] · [확정](472-6 6-A) — 브라우저가 차례마다 조각(본문 · 저장 뒤 정리)을 모아 한 요청 (`design_save.ts` saveOneBody · `design_save_api.ts`) · 서버 `B06_Section_Router_DesignSave.py` 가 같은 차례로 아래 창구 함수를 부름 · 하나라도 실패 = DB 행 · storage 되돌림(`common_util_save_guard.py`) · 차례 이름째 알림 · 같은 프로젝트 저장 겹침 = 뒤 요청 기다림. +- 노선 바뀐 [저장] = 아래 표 차례마다 요청(6-B 전). + | 차례 | 기본 창구 | 서버 | 노선 바뀐 [저장] | 위치(파일:줄) | |---|---|---|---|---| | route | `pendingRouteSave` → `saveRouteDraft` | `/route/replan` · 관 · 구조물 따라감 · 노선 밖 알림 | 새 노선 id · 관 · 구조물 초안 다시 얹음 | `design_save.ts:255-274` | diff --git a/docs/wiki/concepts/design_page/rules.md b/docs/wiki/concepts/design_page/rules.md index 70558d98d..b660b7ae4 100644 --- a/docs/wiki/concepts/design_page/rules.md +++ b/docs/wiki/concepts/design_page/rules.md @@ -49,7 +49,7 @@ source: ["CLAUDE.md §5", "docs/raw/조사/2026-10-19_465_요구272_값안변함 | 내용 | 자리(파일:줄) | 상태 | |---|---|---| | 저장 밖 정본 쓰기 셋 — 넓힘 `/regenerate` · [측점 만들기] · 추천 [적용] | `B06_Section_Router.py:378-388` · `B06_Section_Router_Stations.py:236-296` · `B05_Profile_UI_RouteEdit_Apply.ts:220-245` | 사용자 확정 · 확인창 · 472-6 협의 | -| [저장] 9 차례 · 차례마다 따로 커밋 — 실패 = 앞 차례만 저장 | `design_save.ts:253-299` | 472-6 | +| 노선 바뀐 [저장]만 9 차례 · 차례마다 따로 커밋 — 실패 = 앞 차례만 저장(노선 안 바뀐 저장 = 한 본문 · 전부 아니면 없음) | `design_save.ts:253-299` · `B06_Section_Router_DesignSave.py` | 472-6 6-B | | `pipe_points.json` 한 [저장]에 두 번 PUT(관 · 관 옵션) | `B06_Section_Api_Culvert_Options.ts:67-95` | 코드 확인 | | 노선 바뀐 [저장]의 관 옵션 차례 = 옛 노선 열쇠 | `design_save.ts:187-188` | 추정 | | ↶ ↷ 가 세션을 직접 되씀 — 관 · 구조물 · 추가 측점은 되쓴 뒤 가게 듣는 쪽을 부름(`announceDesignStore` · 472-5) · B06 조정 메모리 맵 · B05 상단측 맵은 아직 옛 값일 수 있음 | `B05_Profile_UI_Profile_History.ts` apply · `design_store.ts` announceDesignStore | 관 · 구조물 = `test_472_5_shop_owner` · 나머지 추정 | diff --git a/resources/tester/helper_271_save_same.cjs b/resources/tester/helper_271_save_same.cjs index 8bb6c7f65..83da6625a 100644 --- a/resources/tester/helper_271_save_same.cjs +++ b/resources/tester/helper_271_save_same.cjs @@ -31,6 +31,7 @@ globalThis.window = { const REAL = new Set( [ "A00_Common/design/design_save.ts", + "A00_Common/design/design_save_api.ts", "A00_Common/design/design_page_route.ts", "A00_Common/b_page_state.ts", "B05_Profile/B05_Profile_Api_Fetch.ts", @@ -86,6 +87,7 @@ globalThis.fetch = async (url, init) => { if (url.includes("/surface/confirmed")) reply = { model_id: 7 }; else if (url.includes("/route/latest")) reply = LATEST; else if (url.includes("/route/confirm")) reply = { failed_parts: [] }; + else if (url.includes("/design/save")) reply = { status: "success", steps: { uphill: {} } }; return { ok: true, status: 200, json: async () => reply }; }; @@ -96,14 +98,14 @@ function seed(withLatest) { state.writeState("uphill", { "40.000": "right" }, P); } -async function run(steps) { +async function run(parts) { lines = []; const failed = []; const ok = await save.saveDesign({ projectId: P, routeId: () => 3, - steps, - cross: async () => undefined, + parts, + crossPart: async () => null, failed: (error) => failed.push(String(error?.message ?? error)), }); return { ok, failed, lines, uphillAfter: state.readState("uphill", P) }; @@ -125,10 +127,10 @@ async function run(steps) { latest: () => LATEST, confirmedSurface: () => ({ model_id: 7 }), routeReady: () => true, - bridge: () => ({ saveStructuresIfDirty: async () => undefined }), + bridge: () => ({ structuresSavePart: async () => null }), uphillOverrides: memory, }); - out.open = await run({ uphill: parts.steps.uphill }); + out.open = await run({ uphill: parts.parts.uphill }); out.openMemoryAfter = memory.size; // ③ 새로고침해 B06 로 바로 — 노선 캐시 없음 = 서버에서 한 번 읽고 같은 본문 diff --git a/resources/tester/helper_323_9_save_redraw.cjs b/resources/tester/helper_323_9_save_redraw.cjs index 6ebc39496..c8bb2a808 100644 --- a/resources/tester/helper_323_9_save_redraw.cjs +++ b/resources/tester/helper_323_9_save_redraw.cjs @@ -41,6 +41,28 @@ const STUBS = { fetchSectionDetail: async () => (calls.push(`fetch:${server}`), detail()), saveSections: async () => calls.push("cross:save"), confirmSections: async () => calls.push("cross:confirm"), + sectionConfirmBody: () => ({}), + warnSectionSave: () => undefined, + }, +}; +// 472-6 한 본문 창구 — 서버가 차례대로 정본을 쓰는 동안 가게가 바뀜(바뀜 신호 = 저장 도중) → 횡단 저장 +let duringSave = async () => undefined; +STUBS[path.join(ROOT, "A00_Common", "design", "design_save_api.ts")] = { + SAVE_PART_KEYS: [ + "settings", + "pipes", + "structures", + "plusStations", + "alignment", + "culvertOptions", + "uphill", + "cross", + ], + DesignSaveError: class extends Error {}, + postDesignSave: async (_p, _r, confirm, parts) => { + await duringSave(); + if (parts.cross?.body) calls.push(confirm ? "cross:confirm" : "cross:save"); + return { waited: false, steps: {} }; }, }; @@ -74,7 +96,11 @@ STUBS["@util/common_util_station_key"] = loadTs( ); // 상단측 차례 본문(463 · 지표 조건 · 확정 지표면 GET · 확정 POST) — 이 시험은 횡단 뒤처리만 봄 const B05_FETCH = path.join(ROOT, "B05_Profile", "B05_Profile_Api_Fetch.ts"); -STUBS[B05_FETCH] = { ...loadTs(B05_FETCH), flushRouteParts: async () => undefined }; +STUBS[B05_FETCH] = { + ...loadTs(B05_FETCH), + flushRouteParts: async () => undefined, + routePartsBody: async () => ({}), +}; const { saveDesign, savingDesign } = loadTs( path.join(ROOT, "A00_Common", "design", "design_save.ts"), ); @@ -117,21 +143,22 @@ const { invalidateSectionDetail, loadSectionDetail } = loadTs( const parts = crossSaveParts(ctx); calls.length = 0; const savingSeen = []; + // 관 · 구조물 초안이 정본으로 나가며 가게가 바뀜 → 바뀜 신호(저장 도중) + duringSave = async () => { + server = "새 자리 4+8 · 47+0"; + savingSeen.push(savingDesign(P)); + await refresh(); + }; const ok = await saveDesign({ projectId: P, routeId: () => R, - steps: { - // 관 · 구조물 초안이 정본으로 나가며 가게가 바뀜 → 바뀜 신호(저장 도중) - structures: async () => { - server = "새 자리 4+8 · 47+0"; - savingSeen.push(savingDesign(P)); - await refresh(); - }, - // 추가 측점(368-2) — 초안 없음 · 이 시험은 안 봄(진짜는 서버 GET) - plusStations: async () => undefined, - ...parts.steps, + parts: { + // 구조물 · 추가 측점 — 초안 없음 · 이 시험은 안 봄(진짜는 서버 GET) + structures: () => null, + plusStations: () => null, + ...parts.parts, }, - cross: (confirm) => parts.cross(confirm), + crossPart: (confirm) => parts.crossPart(confirm), // 뒤처리 = 한 벌(`design_shared` saveSharedDesign done) 차례 그대로 — 공용 캐시를 한 번 비운 뒤 보기 몫(416 M1 · // 횡단 보기 done 은 비우지 않고 다시 그리기만) done: async () => { diff --git a/resources/tester/helper_449_save_pipes.cjs b/resources/tester/helper_449_save_pipes.cjs index b18541aa5..51d94156f 100644 --- a/resources/tester/helper_449_save_pipes.cjs +++ b/resources/tester/helper_449_save_pipes.cjs @@ -71,12 +71,17 @@ STUBS["B04_PreProcess_Api_Fetch"] = { }; STUBS["B05_Profile_Api_Structures"] = { flushPendingStructures: async () => failing("structures"), + structuresPart: async () => null, readPendingStructures: () => null, // 구조물 초안 없음 — 노선 저장 뒤 얹기(459) 안 돎 }; -STUBS["B05_Profile_Api_PlusStations"] = { flushPlusStations: async () => undefined }; +STUBS["B05_Profile_Api_PlusStations"] = { + flushPlusStations: async () => undefined, + plusStationsPart: () => ({ body: null }), +}; STUBS["B05_Profile_Api_Fetch"] = { flushUphillOverrides: async () => undefined, flushRouteParts: async () => undefined, // 상단측 차례(463) + routePartsBody: async () => ({}), saveProfileAlignment: async (_projectId, routeId, draft) => { calls.push(["ALIGN", routeId, draft]); return {}; @@ -87,7 +92,38 @@ STUBS["B05_Profile_UI_Profile_Edit"] = { markAlignmentSaved: (routeId) => alignment.delete(routeId), writeAlignmentDraft: (routeId, edits) => alignment.set(routeId, edits), }; -STUBS["B06_Section_Api_Culvert_Options"] = { flushCulvertOptions: async () => undefined }; +STUBS["B06_Section_Api_Culvert_Options"] = { + flushCulvertOptions: async () => undefined, + culvertOptionsPart: () => null, +}; +// 472-6 한 본문 창구 — 관 칸 · 계획고 칸만 적음(옛 PUT 줄과 같은 꼴) +STUBS["design_save_api"] = { + SAVE_PART_KEYS: [ + "settings", + "pipes", + "structures", + "plusStations", + "alignment", + "culvertOptions", + "uphill", + "cross", + ], + DesignSaveError: class extends Error {}, + postDesignSave: async (_projectId, routeId, confirm, parts) => { + const pipes = parts.pipes?.body; + calls.push([ + "SAVE", + pipes === undefined || pipes === null ? "none" : (pipes.points ?? null), + confirm, + ]); + const align = parts.alignment?.body; + if (align) { + const { route_id: _id, ...draft } = align; + calls.push(["ALIGN", routeId, draft]); + } + return { waited: false, steps: {} }; + }, +}; STUBS["B05_Profile_UI_RouteEdit_Apply"] = { pendingRouteSave: async () => routeBody, saveRouteDraft: async () => (calls.push(["ROUTE"]), { route_id: 8 }), @@ -114,8 +150,16 @@ async function once({ route = false, draft = null, confirm = false, fail = null, projectId: P, routeId: () => routeId, // 화면 몫 = 배수판 다시 받기(PUT 없음) — 433-7 뒤 design_shared 와 같음 - steps: { pipes: async () => void calls.push(["RELOAD"]) }, - cross: async () => void calls.push(["CROSS"]), + parts: { + pipes: (base) => ({ + body: base?.body ?? null, + after: async (response) => { + await base?.after?.(response); + calls.push(["RELOAD"]); + }, + }), + }, + crossPart: async () => ({ body: {} }), failed: (error) => (failure = String(error && error.message ? error.message : error)), }, { confirm }, diff --git a/resources/tester/helper_design_store_save.cjs b/resources/tester/helper_design_store_save.cjs index 2aca10efc..3298ac518 100644 --- a/resources/tester/helper_design_store_save.cjs +++ b/resources/tester/helper_design_store_save.cjs @@ -44,6 +44,33 @@ const routeStub = { routeSaveSummary: (saved, outside) => (calls.push(`summary:${saved.route_id}:${outside}`), ""), rebuildAfterRouteSave: async () => calls.push("rebuild"), }; +class DesignSaveError extends Error { + constructor(message, step, restored) { + super(message); + this.step = step; + this.restored = restored; + } +} +const SAVE_PART_KEYS = [ + "settings", + "pipes", + "structures", + "plusStations", + "alignment", + "culvertOptions", + "uphill", + "cross", +]; +const saveApiStub = { + SAVE_PART_KEYS, + DesignSaveError, + postDesignSave: async (_p, routeId, confirm, parts) => { + const sent = SAVE_PART_KEYS.filter((key) => parts[key] && parts[key].body != null); + calls.push(`post:${routeId}:${confirm}:${sent.join(",")}`); + if (failStructures) throw new DesignSaveError("structures 실패", "structures", true); + return { waited: false, steps: { alignment: { dropped_edit_chainages: [] } } }; + }, +}; const STUBS = { "../../B05_Profile/B05_Profile_UI_RouteEdit_Apply": routeStub, "@ui/ui_template_elements": { @@ -53,6 +80,7 @@ const STUBS = { }, "../../B05_Profile/B05_Profile_Api_Pipes_Draft": { flushPendingPipes: () => record("pipes")(), + pipesPart: () => ({ body: { points: [] }, after: () => calls.push("pipes:after") }), readPendingPipes: () => null, // 관 초안 없음 — 노선 저장 뒤 얹기(349-0u)는 안 돎 writePendingPipes: () => undefined, }, @@ -65,24 +93,39 @@ const STUBS = { }, "../../B05_Profile/B05_Profile_Api_Structures": { flushPendingStructures: () => record("structures", failStructures)(), + structuresPart: async () => ({ + body: { base_revision: 0, structures: [] }, + after: () => calls.push("structures:after"), + }), readPendingStructures: () => null, // 구조물 초안 없음 — 노선 저장 뒤 얹기(459)는 안 돎 writePendingStructures: () => undefined, fetchStructures: async () => ({ structures: [] }), }, "../../B05_Profile/B05_Profile_Api_PlusStations": { flushPlusStations: () => record("plusStations")(), + plusStationsPart: () => ({ body: null, after: () => calls.push("plusStations:after") }), }, "../../B05_Profile/B05_Profile_Api_Fetch": { flushUphillOverrides: () => record("uphill")(), flushRouteParts: () => record("uphill")(), // 상단측 차례 = 종단 보기 몫과 같은 본문(463) + routePartsBody: async () => ({ irregular_stations: [] }), saveProfileAlignment: async (_p, routeId, edits) => calls.push(`alignment:${routeId}:${JSON.stringify(edits.station_offsets)}`), }, "../../B06_Section/B06_Section_Api_Culvert_Options": { flushCulvertOptions: async (_p, key) => calls.push(`culvert:${key}`), + culvertOptionsPart: (key) => ({ + body: { "10.00": {} }, + after: () => calls.push(`culvert:after:${key}`), + }), }, // 448 페이지 설정 — 초안 없음(차례만 · 내보내기는 test_442_2_page_settings) - "../../B05_Profile/B05_Profile_Api_PageSettings": { flushPageSettings: async () => undefined }, + "../../B05_Profile/B05_Profile_Api_PageSettings": { + flushPageSettings: async () => undefined, + pageSettingsPart: () => null, + }, + // 472-6 한 본문 창구 — 보낸 칸만 적음 · 구조물 실패 = 서버가 그 차례에서 되돌림 + "./design_save_api": saveApiStub, }; const cache = new Map(); @@ -160,8 +203,19 @@ async function run() { projectId: "p1", routeId: () => 7, prepare: record("prepare"), - steps: { pipes: record("pipes:screen") }, - cross: async (confirm) => calls.push(`cross:${confirm}`), + parts: { + pipes: (base) => ({ + body: base.body, + after: async (response) => { + await base.after(response); + calls.push("pipes:screen"); + }, + }), + }, + crossPart: async (confirm) => ({ + body: { confirm }, + after: () => calls.push(`cross:after:${confirm}`), + }), done: async (confirm) => calls.push(`done:${confirm}`), failed: () => calls.push("failed"), }); @@ -177,8 +231,7 @@ async function run() { { projectId: "p1", routeId: () => 7, - steps: { uphill: record("uphill:screen") }, - cross: async (confirm) => calls.push(`cross:${confirm}`), + crossPart: async (confirm) => ({ body: { confirm } }), failed: (error) => (calls.push("failed"), (out.tolerantError = error.message)), }, { confirm: true }, diff --git a/resources/tester/test_176_3_no_uphill_lamp.py b/resources/tester/test_176_3_no_uphill_lamp.py index daae0a2e6..bbeb01c64 100644 --- a/resources/tester/test_176_3_no_uphill_lamp.py +++ b/resources/tester/test_176_3_no_uphill_lamp.py @@ -30,8 +30,11 @@ def test_save_path_and_merge_kept(): # 저장 길(세션 → 저장 몸통)과 B06 이 부르는 종단 병합 함수는 그대로 page = _read("A00_Common", "design", "design_view_profile.ts") assert "persistUphillOverrides" in page - # 상단측 본문 = 종단 보기 몫 · 기본 창구 한 함수(463) - assert "flushRouteParts(projectId)" in _read("B05_Profile", "B05_Profile_UI_Page_Actions.ts") + # 상단측 본문 = 기본 조각 한 벌(463 · 472-6 종단 보기 몫은 그 본문 그대로) + assert "body: base.body," in _read("B05_Profile", "B05_Profile_UI_Page_Actions.ts") + assert "body: await routePartsBody(projectId)," in _read( + "A00_Common", "design", "design_save.ts" + ) assert "uphill_overrides: uphillOverridesOf(projectId)," in _read( "B05_Profile", "B05_Profile_Api_Fetch.ts" ) diff --git a/resources/tester/test_271_save_same.py b/resources/tester/test_271_save_same.py index 592c07851..786a6779d 100644 --- a/resources/tester/test_271_save_same.py +++ b/resources/tester/test_271_save_same.py @@ -41,9 +41,10 @@ def got() -> dict: def _confirm_body(lines: list[str]) -> dict: - posts = [line for line in lines if line.startswith("POST ") and "/route/confirm" in line] + """상단측 차례 본문 — 472-6 부터 한 본문 창구(`/design/save`)의 `uphill` 칸.""" + posts = [line for line in lines if line.startswith("POST ") and "/design/save" in line] assert len(posts) == 1, lines - return json.loads(posts[0].split(" ", 2)[2]) + return json.loads(posts[0].split(" ", 2)[2])["uphill"] def test_종단_보기를_열든_안_열든_같은_요청(got): @@ -79,6 +80,8 @@ def test_서버는_그_본문이면_비정규_측점_횡단을_맞춤(got): def test_종단_보기_몫은_기본_창구_함수를_부름(): actions = (ROOT / "B05_Profile" / "B05_Profile_UI_Page_Actions.ts").read_text("utf-8") save = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text("utf-8") - assert "await flushRouteParts(projectId);" in actions - assert "saveRouteParts(" not in actions + # 472-6 — 종단 보기 몫은 기본 조각 본문 그대로(메모리만 더 비움) · 노선 바뀐 저장은 옛 창구 + assert "uphill: (base) =>" in actions and "body: base.body," in actions + assert "saveRouteParts(" not in actions and "flushRouteParts" not in actions + assert "body: await routePartsBody(projectId)," in save assert "return () => flushRouteParts(projectId);" in save diff --git a/resources/tester/test_318_cross_card.py b/resources/tester/test_318_cross_card.py index dbd4a722c..76b0db181 100644 --- a/resources/tester/test_318_cross_card.py +++ b/resources/tester/test_318_cross_card.py @@ -243,7 +243,7 @@ def test_wiring_sources() -> None: assert "forgetStationAdjusts(ctx.projectId, pipeItem.chainage_m, prev.type_id" in events[live:] persist = _src("B06_Section", "B06_Section_UI_Page_Persist.ts") assert "patchFor(at).structure_reset = true" in persist - assert 'clearState("kindreset", ctx.projectId)' in persist + assert 'clearState("kindreset", projectId)' in persist # 472-6 횡단 조각 저장 뒤 store = _src("B06_Section", "B06_Section_Section_Store.ts") assert "crossPatchesFromCache(detail, kindChangedChainages(projectId), savedAt)" in store view = _src("A00_Common", "design", "design_view_cross.ts") diff --git a/resources/tester/test_384_6_save_one_line.py b/resources/tester/test_384_6_save_one_line.py index f06f3140b..89e08aeb7 100644 --- a/resources/tester/test_384_6_save_one_line.py +++ b/resources/tester/test_384_6_save_one_line.py @@ -28,13 +28,14 @@ def test_저장도_횡단_보기를_세움(): body = _save_body() assert 'await shared.ensureView("cross");' in body assert 'if (confirm) await shared.ensureView("cross")' not in body - assert "cross: crossBody," in body + # 472-6 — 횡단 본문 = 횡단 보기 조각(`crossPart`) · 한 본문 창구에 실림 + assert " crossPart," in body assert "profileSave?.cross" not in body def test_못_세우면_실패_알림(): body = _save_body() - assert "if (!crossBody) {" in body + assert "if (!crossPart) {" in body assert '"B06_Profile_Save_Failed"' in body and '"B06_Profile_Confirm_Failed"' in body @@ -43,4 +44,4 @@ def test_종단_보기_몫엔_횡단_본문_없음(): start = actions.index("export function profileSaveParts(") parts = actions[start:] assert "saveCachedCrossPatches" not in actions - assert "\n cross: " not in parts + assert "\n cross: " not in parts and "crossPart" not in parts diff --git a/resources/tester/test_443_save_stops.py b/resources/tester/test_443_save_stops.py index 578d0b44e..8a786a240 100644 --- a/resources/tester/test_443_save_stops.py +++ b/resources/tester/test_443_save_stops.py @@ -72,8 +72,8 @@ def test_확정_하위_실패면_던짐(out): def test_종단_보기_상단측_차례도_같은_창구(): actions = (ROOT / "B05_Profile" / "B05_Profile_UI_Page_Actions.ts").read_text("utf-8") - # 기본 창구와 같은 함수(463) — 상단측은 세션에서 읽음(메모리 옛 값 안 보냄) - assert "await flushRouteParts(projectId);" in actions + # 기본 창구와 같은 본문(463 · 472-6 기본 조각) — 상단측은 세션에서 읽음(메모리 옛 값 안 보냄) + assert "body: base.body," in actions and "flushRouteParts" not in actions fetch = (ROOT / "B05_Profile" / "B05_Profile_Api_Fetch.ts").read_text("utf-8") assert "uphill_overrides: uphillOverridesOf(projectId)," in fetch assert "confirmRoute(" not in actions diff --git a/resources/tester/test_445_save_patch.py b/resources/tester/test_445_save_patch.py index c95e83dc8..993c20920 100644 --- a/resources/tester/test_445_save_patch.py +++ b/resources/tester/test_445_save_patch.py @@ -243,7 +243,9 @@ def test_H5_노선_저장_뒤_본문_M7_kindreset_옮김(): for needle in ("crossDesignChoices(", "readRockBoundarySession(", "readCutSlopeSession("): assert needle in overlay, needle persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") - assert "cachedCrossPatches(ctx.projectId, routeId, edits.crossPatches)" in persist + assert ( + "cachedCrossPatches(projectId, routeId, edits.crossPatches)" in persist + ) # 472-6 횡단 조각 keys = (ROOT / "B06_Section" / "B06_Section_Station_Keys.ts").read_text(encoding="utf-8") carry = keys[keys.index("export function carryChainageStates") :] assert 'writeState("kindreset", rekey("revetx", resets), projectId)' in carry diff --git a/resources/tester/test_449_save_pipes.py b/resources/tester/test_449_save_pipes.py index 302f0a58a..62f046175 100644 --- a/resources/tester/test_449_save_pipes.py +++ b/resources/tester/test_449_save_pipes.py @@ -40,31 +40,32 @@ def out() -> dict: return json.loads(proc.stdout) -def _puts(case: dict) -> list: - return [call for call in case["calls"] if call[0] == "PUT"] +def _saves(case: dict) -> list: + """472-6 — 노선 안 바뀐 저장 = 한 본문 창구 한 번 · ["SAVE", 관 칸(points · "none"), 확정].""" + return [call for call in case["calls"] if call[0] == "SAVE"] def test_관_PUT_은_초안_한_번(out): case = out["draft"] - assert case["ok"] and _puts(case) == [ - ["PUT", [{"chainage_m": 15, "source": "user", "id": "p1"}], False] + assert case["ok"] and _saves(case) == [ + ["SAVE", [{"chainage_m": 15, "source": "user", "id": "p1"}], False] ] assert case["calls"].index(["RELOAD"]) > case["calls"].index( - _puts(case)[0] + _saves(case)[0] ) # 화면은 뒤에 받기만 assert case["left"] is None def test_초안_없으면_PUT_없음(out): - assert out["noDraft"]["ok"] and _puts(out["noDraft"]) == [] + assert out["noDraft"]["ok"] and _saves(out["noDraft"]) == [["SAVE", "none", False]] def test_확정은_칸_없이_다시_셈(out): - assert _puts(out["confirmNoDraft"]) == [["PUT", None, True]] + assert _saves(out["confirmNoDraft"]) == [["SAVE", None, True]] def test_빈_초안은_다_지움(out): - assert _puts(out["emptyDraft"]) == [["PUT", [], False]] + assert _saves(out["emptyDraft"]) == [["SAVE", [], False]] def test_노선_저장_뒤_실패_초안_새_노선(out): diff --git a/resources/tester/test_b06_save_channels.py b/resources/tester/test_b06_save_channels.py index b6a7c67ac..67c0cf7ca 100644 --- a/resources/tester/test_b06_save_channels.py +++ b/resources/tester/test_b06_save_channels.py @@ -75,10 +75,14 @@ def test_저장이_창구를_다섯_다_거친다(): "flushPendingStructures(", ): assert token in save, f"{token} 가 저장 한 줄기에서 사라짐" - persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") - assert "ctx.flushCulvertOptions()" in persist, "B06 조정창 구간값이 저장에서 빠짐" - # 차례가 실패하면 그 차례에서 멈추고 이름째 알림(429 ⑨ · 뒤 차례가 옛 정본 위에 얹히지 않게). + # 472-6 — 노선 안 바뀐 저장은 한 본문 조각(조정창 구간값 = 세션 `culvertopt`) · 노선 바뀐 + # 저장은 위 창구 차례 그대로 + assert 'culvertOptionsPart(stateKey("culvertopt", projectId, routeId))' in save, ( + "B06 조정창 구간값이 한 본문 저장에서 빠짐" + ) + # 차례가 실패하면 그 차례에서 멈추고 이름째 알림(429 ⑨) · 한 본문은 서버가 차례 이름째 되돌림 assert "throw stepFailed(step, error)" in save, "실패한 차례에서 멈춤이 빠짐" + assert "STEP_LABEL[error.step]" in save, "한 본문 실패 차례 이름이 빠짐" order = save[save.index("DESIGN_SAVE_ORDER = [") :] order = order[: order.index("]")] names = ["pipes", "structures", "alignment", "culvertOptions", "uphill", "cross"] diff --git a/resources/tester/test_b06_save_recompute.py b/resources/tester/test_b06_save_recompute.py index 63518f427..ad3922185 100644 --- a/resources/tester/test_b06_save_recompute.py +++ b/resources/tester/test_b06_save_recompute.py @@ -32,12 +32,15 @@ def test_저장이_면적을_모으기_전에_다시_계산한다() -> None: text = PERSIST.read_text(encoding="utf-8") body = _body(text, "export function crossSaveParts") assert "prepare: () => ctx.reconcileDesigns()" in body, "횡단 보기 몫이 재계산을 안 넘긴다" - assert "collectSectionEdits(ctx)" in body[body.index("cross:") :], "면적 수집이 횡단 차례 밖" + assert "collectSectionEdits(ctx)" in body[body.index("crossPart:") :], ( + "면적 수집이 횡단 조각 밖" + ) shared = _body(SHARED.read_text(encoding="utf-8"), "export async function saveSharedDesign") assert "prepare: crossSave?.prepare" in shared, "한 벌이 재계산을 저장 한 줄기에 안 넘긴다" save = SAVE.read_text(encoding="utf-8") - run = _body(save, "export async function saveDesign", 4000) - assert run.index("ctx.prepare?.()") < run.index("ctx.cross(confirm)"), ( + # 472-6 — 노선 안 바뀐 저장 = 한 본문(`saveOneBody`) · 재계산 뒤에 횡단 조각을 모음 + run = _body(save, "async function saveOneBody", 4000) + assert run.index("ctx.prepare?.()") < run.index("ctx.crossPart(confirm)"), ( "저장 한 줄기가 재계산보다 면적 수집을 먼저 부른다 — 옛 면적이 실린다" ) diff --git a/resources/tester/test_design_store_save.py b/resources/tester/test_design_store_save.py index 25e0daace..1ed386508 100644 --- a/resources/tester/test_design_store_save.py +++ b/resources/tester/test_design_store_save.py @@ -60,17 +60,18 @@ def test_저장_차례(out): "cross", ] # 346-3 노선 먼저 assert out["saveOk"] is True + # 472-6 — 노선 초안 없으면 조각을 모아 한 요청(서버 한 창구 · 전부 아니면 없음) · + # 저장 뒤 조각 정리는 차례대로 · 본문 없는 조각(추가 측점 초안 없음)도 정리는 돎 assert out["saveCalls"] == [ "overlay:on", "prepare", - "pipes", + "post:7:false:pipes,structures,alignment,culvertOptions,uphill,cross", + "pipes:after", "pipes:screen", - "structures", - "plusStations", - 'alignment:7:{"20.000":0.1}', - "culvert:aislo:draft:culvertopt:p1:7", - "uphill", - "cross:false", + "structures:after", + "plusStations:after", + "culvert:after:aislo:draft:culvertopt:p1:7", + "cross:after:false", "done:false", "overlay:off", ] @@ -78,10 +79,15 @@ def test_저장_차례(out): def test_구조물_실패는_멈추고_알림_노선없음(out): - """429 ⑨ — 차례가 실패하면 뒤 차례(상단측 · 횡단)를 안 보냄 · 차례 이름째 알림.""" - assert out["tolerant"] == ["overlay:on", "pipes", "structures", "failed", "overlay:off"] + """429 ⑨ · 472-6 — 서버가 구조물 차례에서 실패 · 되돌림 → 정리 · done 없음 · 차례 이름째.""" + assert out["tolerant"] == [ + "overlay:on", + "post:7:true:pipes,structures,culvertOptions,uphill,cross", + "failed", + "overlay:off", + ] assert out["tolerantOk"] is False - assert out["tolerantError"].startswith("구조물 차례에서 멈췄습니다") + assert out["tolerantError"].startswith("구조물 차례에서 실패 — 아무것도 저장하지 않았습니다") assert "structures 실패" in out["tolerantError"] # 까닭 그대로 assert out["noRouteSave"] is False and out["noRouteCalls"] == []