From 7760dcd777b6777fb30128a985ddd2b79a4972c8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 20:15:28 +0900 Subject: [PATCH] =?UTF-8?q?refactor(B05=C2=B7B06):=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EA=B0=80=EA=B2=8C=20=ED=95=9C=20=EA=B3=B3=20=C2=B7=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=ED=95=9C=20=EC=A4=84=EA=B8=B0(PLAN=2024-2a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A00_Common/design/design_store.ts — useDesignStore(projectId, routeId) get · set · subscribe (structures · pipes · alignmentDraft · sectionDetail · selection) · 밑은 b_page_state 등록 키 (selection 등록 · alignmentDraft 는 옛 키 감쌈 — 브레인 판정 B) - A00_Common/design/design_save.ts — saveDesign(ctx, {confirm}) 한 줄기: 관 → 구조물 → 계획고 → 암거 옵션 → 오르막 → 횡단 · B05 [임시저장] · B06 [저장] · [확정]이 같은 차례 - 계획고 초안 가게 노선마다 한 벌(B05 Profile_Edit 주인 · B06 Grade_Edit 는 useProfileEditStore) - 관 목록 주인 = 가게 pipes(Pipes_Draft 창구) - 시험: test_design_store_save 추가 · save_channels · save_recompute · save_roundtrip 새 차례로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL --- A00_Common/b_page_state.ts | 2 + A00_Common/design/design_save.ts | 145 ++++++++++++++ A00_Common/design/design_store.ts | 148 ++++++++++++++ B05_Profile/B05_Profile_Api_Pipes_Draft.ts | 9 +- B05_Profile/B05_Profile_UI_Page_Actions.ts | 118 ++++++------ B05_Profile/B05_Profile_UI_Profile_Edit.ts | 141 +++++++++----- B06_Section/B06_Section_UI_Page_Grade_Edit.ts | 8 +- B06_Section/B06_Section_UI_Page_Persist.ts | 153 ++++++--------- resources/tester/helper_design_store_save.cjs | 180 ++++++++++++++++++ .../tester/test_b05_b06_save_roundtrip.py | 14 +- resources/tester/test_b06_save_channels.py | 34 +++- resources/tester/test_b06_save_recompute.py | 17 +- resources/tester/test_design_store_save.py | 80 ++++++++ 13 files changed, 815 insertions(+), 234 deletions(-) create mode 100644 A00_Common/design/design_save.ts create mode 100644 A00_Common/design/design_store.ts create mode 100644 resources/tester/helper_design_store_save.cjs create mode 100644 resources/tester/test_design_store_save.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index b00cea81..3482913b 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -110,6 +110,8 @@ export const STATE_REGISTRY = { scope: "project", legacy: (p) => `aislo:structure-pick:${p}`, }, + /** 설계 가게(`design/design_store`)의 고른 측점 · 구조물 — `{chainageM, structureId}`. */ + selection: { bucket: "draft", scope: "project" }, /** 배수관·암거 조정창에서 예약한 옵션 값. */ culvertopt: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertopt:${p}:${r}` }, /** 배수관 이동(측점 옮김) 예약. */ diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts new file mode 100644 index 00000000..0dbbaa72 --- /dev/null +++ b/A00_Common/design/design_save.ts @@ -0,0 +1,145 @@ +/* ============================================================================= + * A00_Common/design/design_save.ts + * B05 · B06 **저장 한 줄기**(계획서 24-1) — [저장] · [확정]이 어느 보기에서 눌려도 같은 차례. + * + * 관 → 구조물 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단 + * + * 차례는 여기 한 곳에만 둔다. 보기마다 다른 것은 **화면이 쥔 값으로 한 번 더 맞추는 일** + * (B05 배수유역도 목록 · 구조물 목록 새로 받기 · 종단 패널 다시 그리기)과 횡단 저장 본문뿐이라, + * 그 몫만 `steps` · `cross` 로 받는다. 서버 라우트는 그대로다. + * + * ⚠ 차례를 바꾸면 결과가 갈린다 — 관 목록은 스냅샷이라 구간값(암거 옵션)보다 먼저, 상단측은 + * 횡단 patch 보다 먼저 나가야 사용자 수정이 맨 위에 얹힌다(2026-09-06 · 09-12). + * 영구저장은 여기서만 한다(CLAUDE.md 5장 — 자동저장 없음). + * ========================================================================== */ + +import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; +import { flushPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft"; +import { flushPendingStructures } from "../../B05_Profile/B05_Profile_Api_Structures"; +import { + flushUphillOverrides, + saveProfileAlignment, +} from "../../B05_Profile/B05_Profile_Api_Fetch"; +import { + markAlignmentSaved, + pendingAlignment, +} from "../../B05_Profile/B05_Profile_UI_Profile_Edit"; +import { flushCulvertOptions } from "../../B06_Section/B06_Section_Api_Culvert_Options"; +import { stateKey } from "../b_page_state"; + +/** 저장 차례 — 이 순서 그대로 돈다. */ +export const DESIGN_SAVE_ORDER = [ + "pipes", + "structures", + "alignment", + "culvertOptions", + "uphill", + "cross", +] as const; + +export type DesignSaveStep = (typeof DESIGN_SAVE_ORDER)[number]; + +export interface DesignSaveContext { + projectId: string; + routeId: () => number | null; + /** 저장 앞 준비 — 면적을 모으기 전 전 측점 재계산(B06). */ + prepare?: () => Promise; + /** + * 차례마다 **기본 창구 대신** 도는 일. 없으면 기본 창구(세션 초안 → 정본)가 돈다. + * `pipes` 만 예외 — 기본(세션 초안)이 먼저 나가고 그 뒤에 돈다(화면 목록으로 한 번 더 맞춤). + */ + steps?: Partial, () => Promise>>; + /** 횡단 — 보기마다 본문이 다르다. `confirm` 이면 단계를 닫는 길로. */ + cross: (confirm: boolean) => Promise; + /** 모두 나간 뒤 — 캐시 비우기 · 화면 다시 읽기 · 알림 · 다음 단계. */ + done?: (confirm: boolean) => Promise | void; + /** 멈춘 저장을 알린다. */ + failed: (error: unknown, confirm: boolean) => void; +} + +/** 실패해도 저장을 멈추지 않는 창구 — 알리고 넘어간다(초안은 세션에 남아 다시 나갈 수 있다). */ +function reportOnly(label: string): (error: unknown) => void { + return (error) => { + const detail = error instanceof Error ? ` ${error.message}` : ""; + showToast(`${label} 저장에 실패했습니다.${detail}`, "error"); + }; +} + +/** 계획고 초안(가게 하나)을 종단 정본에 쓴다. 없으면 아무 일도 하지 않는다. */ +async function flushAlignmentDraft(projectId: string, routeId: number): Promise { + const draft = pendingAlignment(routeId); + if (!draft) return; + await saveProfileAlignment(projectId, routeId, draft); + markAlignmentSaved(routeId); +} + +/** 실패해도 다음 차례로 넘어가는 창구와 그 이름(보기가 준 일에도 같이 씌운다). */ +const TOLERANT: Partial> = { + pipes: "배수관", + structures: "구조물", + alignment: "계획선", +}; + +/** 차례마다 기본 창구. 암거 옵션 · 상단측은 조용히 넘어간다(다음 [저장]에서 다시 나감). */ +function defaultStep( + step: Exclude, + projectId: string, + routeId: number, +): () => Promise { + switch (step) { + case "pipes": + return () => flushPendingPipes(projectId); + case "structures": + return () => flushPendingStructures(projectId); + case "alignment": + return () => flushAlignmentDraft(projectId, routeId); + case "culvertOptions": + return () => + flushCulvertOptions(projectId, stateKey("culvertopt", projectId, routeId)).catch( + () => undefined, + ); + case "uphill": + return () => flushUphillOverrides(projectId).catch(() => undefined); + } +} + +/** + * [저장] · [확정] — 세션에 쌓인 조작을 정해진 차례로 정본에 보낸다. + * 노선이 없으면 아무 일도 하지 않고 false. 끝까지 나가면 true. + */ +export async function saveDesign( + ctx: DesignSaveContext, + options: { confirm?: boolean } = {}, +): Promise { + const confirm = options.confirm ?? false; + const routeId = ctx.routeId(); + if (routeId === null) return false; + showLoadingOverlay(); + try { + await ctx.prepare?.(); + for (const step of DESIGN_SAVE_ORDER) { + if (step === "cross") { + await ctx.cross(confirm); + continue; + } + const own = ctx.steps?.[step]; + const base = defaultStep(step, ctx.projectId, routeId); + const run = + step === "pipes" + ? async () => { + await base(); + await own?.(); + } + : (own ?? base); + const label = TOLERANT[step]; + await (label ? run().catch(reportOnly(label)) : run()); + } + await ctx.done?.(confirm); + return true; + } catch (error) { + ctx.failed(error, confirm); + return false; + } finally { + hideLoadingOverlay(); + } +} diff --git a/A00_Common/design/design_store.ts b/A00_Common/design/design_store.ts new file mode 100644 index 00000000..4201d84e --- /dev/null +++ b/A00_Common/design/design_store.ts @@ -0,0 +1,148 @@ +/* ============================================================================= + * A00_Common/design/design_store.ts + * B05 · B06 설계 상태 **한 곳**(계획서 24-1) — 두 보기가 같은 값을 같은 창구로 읽고 쓴다. + * + * const store = useDesignStore(projectId, routeId); + * store.get("pipes") · store.set("pipes", next) · store.subscribe("pipes", fn) + * + * 밑은 지금 캐시 그대로다 — 값마다 `b_page_state` 등록 키 하나(새 키는 등록부에 먼저). + * 가게는 값을 따로 들고 있지 않는다: 읽을 때마다 캐시를 읽고, 쓸 때 캐시에 쓴 뒤 듣는 쪽을 + * 부른다. 그래서 가게를 거치지 않고 캐시를 고친 옛 자리가 남아 있어도 읽는 값은 갈리지 않는다 + * (다만 그 자리는 듣는 쪽을 못 부른다 — 쓰는 길은 가게로 옮길 것). + * + * ⚠ `alignmentDraft` 만 등록부 밖 옛 키(`b05-profile-alignment-draft:{노선}`)를 감싼다 — + * B05 되돌리기(`_Profile_History`)가 `b05-` 접두어 키만 사진 찍기 때문이다. 등록부 이관은 + * 24-2c 가 되돌리기와 함께 한다(브레인 판정 9-29). + * ========================================================================== */ + +import { readByKey, readState, writeByKey, writeState } from "../b_page_state"; +import type { StructureInstance } from "../../B05_Profile/B05_Profile_Api_Structures"; +import type { DetailPipeInput } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import type { AlignmentEdits } from "../../B05_Profile/B05_Profile_UI_Profile_Alignment"; +import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; + +/** 한 페이지의 두 보기(계획서 24-1). */ +export type DesignView = "profile" | "cross"; + +/** 고른 자리 — 측점 · 구조물. 둘 다 비면 고른 것이 없다. */ +export interface DesignSelection { + chainageM: number | null; + structureId: string | null; +} + +/** 가게가 드는 값 — 없으면 null(= 만진 적 없음 · 저장분을 쓸 것). */ +export interface DesignStoreValues { + /** 미저장 구조물 목록(정본 `structures.json`). */ + structures: StructureInstance[] | null; + /** 미저장 관 목록(정본 `pipe_points.json`) — 주인은 이 가게 하나다. */ + pipes: DetailPipeInput[] | null; + /** 미저장 계획고 편집(▲▼ · 곡선반경). 노선이 있어야 한다. */ + alignmentDraft: AlignmentEdits | null; + /** 종횡단 상세 — 세션 사본. 화면이 함께 쥐는 객체는 `B06_Section_Section_Store`. */ + sectionDetail: SectionDetailResponse | null; + /** 고른 측점 · 구조물. */ + selection: DesignSelection | null; +} + +export type DesignStoreKey = keyof DesignStoreValues; + +export interface DesignStore { + get(key: K): DesignStoreValues[K]; + set(key: K, value: DesignStoreValues[K]): void; + /** 가게로 쓸 때마다 부른다. 돌려받은 함수로 끊는다. */ + subscribe( + key: K, + fn: (value: DesignStoreValues[K]) => void, + ): () => void; +} + +/** 옛 계획고 초안 키 — 위 머리 주석 ⚠ 참조. */ +export const ALIGNMENT_DRAFT_KEY_PREFIX = "b05-profile-alignment-draft"; + +interface Slot { + /** 듣는 쪽을 묶는 이름 — 같은 캐시 칸이면 같은 이름. 칸을 못 정하면 null. */ + id: string | null; + read(): T | null; + write(value: T | null): void; +} + +function slotOf( + key: K, + projectId: string | null, + routeId: number | null, +): Slot { + type V = DesignStoreValues[K]; + if (key === "alignmentDraft") { + const raw = routeId === null ? null : `${ALIGNMENT_DRAFT_KEY_PREFIX}:${routeId}`; + return { + id: raw, + read: () => { + const text = raw ? readByKey(raw) : null; + if (!text) return null; + try { + const parsed = JSON.parse(text) as Partial; + return { + station_offsets: parsed.station_offsets ?? {}, + curve_radii: parsed.curve_radii ?? {}, + } as V; + } catch { + return null; + } + }, + write: (value) => { + if (raw) writeByKey(raw, value === null ? null : JSON.stringify(value)); + }, + }; + } + // 등록부 이름 — 가게 이름과 캐시 이름이 다른 것만 적는다. + const name = ( + { + structures: "structures", + pipes: "pipes", + sectionDetail: "section-detail", + selection: "selection", + } as const + )[key as Exclude]; + const routeScoped = name === "section-detail"; + if (!projectId || (routeScoped && routeId === null)) { + return { id: null, read: () => null, write: () => undefined }; + } + const scopeRoute = routeScoped ? routeId : undefined; + return { + id: `${name}:${projectId}${routeScoped ? `:${routeId}` : ""}`, + read: () => readState(name, projectId, scopeRoute), + write: (value) => writeState(name, value, projectId, scopeRoute), + }; +} + +const listeners = new Map void>>(); + +/** + * 그 프로젝트 · 노선의 가게. 부를 때마다 새로 만들어도 된다 — 값도 듣는 쪽도 가게 밖 + * (캐시 · 모듈 표)에 있어서 같은 칸을 가리키면 같은 가게다. + * + * 노선을 모르면 `routeId = null` — 노선 칸(`alignmentDraft` · `sectionDetail`)은 비어 보이고 + * 쓰기는 버려진다. 프로젝트를 모르면 프로젝트 칸이 그렇다. + */ +export function useDesignStore(projectId: string | null, routeId: number | null): DesignStore { + return { + get(key) { + return slotOf(key, projectId, routeId).read() as DesignStoreValues[typeof key]; + }, + set(key, value) { + const slot = slotOf(key, projectId, routeId); + slot.write(value); + if (slot.id) listeners.get(`${key}|${slot.id}`)?.forEach((fn) => fn(value)); + }, + subscribe(key, fn) { + const id = slotOf(key, projectId, routeId).id; + if (!id) return () => undefined; + const bucket = `${key}|${id}`; + const set = listeners.get(bucket) ?? new Set(); + listeners.set(bucket, set); + const listener = fn as (value: unknown) => void; + set.add(listener); + return () => set.delete(listener); + }, + }; +} diff --git a/B05_Profile/B05_Profile_Api_Pipes_Draft.ts b/B05_Profile/B05_Profile_Api_Pipes_Draft.ts index 03c7fe96..acc0eb4d 100644 --- a/B05_Profile/B05_Profile_Api_Pipes_Draft.ts +++ b/B05_Profile/B05_Profile_Api_Pipes_Draft.ts @@ -7,9 +7,12 @@ * 하나뿐이었다). 다른 조작값과 같은 규칙으로 세션에 쌓고 저장 앞단에서 함께 내보낸다. * * 자동저장은 하지 않는다(CLAUDE.md 5장). + * + * 관 목록의 주인은 설계 가게(`design_store` 의 `pipes`) 하나다(계획서 24-2a) — B05 배수유역도 · + * B06 구조물 목록 모두 이 창구로 읽고 쓴다. * ========================================================================== */ -import { readState, writeState } from "../A00_Common/b_page_state"; +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"; @@ -17,11 +20,11 @@ import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch export type PendingPipes = DetailPipeInput[]; export function readPendingPipes(projectId: string): PendingPipes | null { - return readState("pipes", projectId); + return useDesignStore(projectId, null).get("pipes"); } export function writePendingPipes(projectId: string, next: PendingPipes | null): void { - writeState("pipes", next, projectId); + useDesignStore(projectId, null).set("pipes", next); } /** 초안이 있으면 정본에 쓰고 비운다. 없으면 아무 일도 하지 않는다. */ diff --git a/B05_Profile/B05_Profile_UI_Page_Actions.ts b/B05_Profile/B05_Profile_UI_Page_Actions.ts index 8b822cad..a08bab99 100644 --- a/B05_Profile/B05_Profile_UI_Page_Actions.ts +++ b/B05_Profile/B05_Profile_UI_Page_Actions.ts @@ -23,9 +23,7 @@ import { solveRoute, type RouteLatestResponse, } from "./B05_Profile_Api_Fetch"; -import { flushCulvertOptions } from "../B06_Section/B06_Section_Api_Culvert_Options"; -import { flushPendingPipes } from "./B05_Profile_Api_Pipes_Draft"; -import { stateKey } from "../A00_Common/b_page_state"; +import { saveDesign } from "../A00_Common/design/design_save"; import { invalidateSectionDetail, saveCachedCrossPatches, @@ -127,74 +125,68 @@ export async function solveRouteAction(ctx: PageActionContext): Promise { } /** [임시저장] — 현재 편집을 영구저장소에 남기되 워크플로 단계·페이지는 그대로 둔다. - * 종·횡 통합 확정은 B06 [확정]이 담당한다(2026-08-08 워크플로우 재정의). */ + * 종·횡 통합 확정은 B06 [확정]이 담당한다(2026-08-08 워크플로우 재정의). + * 차례는 저장 한 줄기(`saveDesign`)가 정한다 — B06 [저장]과 같다(계획서 24-2a). */ export async function tempSaveAction(ctx: PageActionContext): Promise { if (!ctx.routeReady()) return; const latest = ctx.latest(); const projectId = ctx.projectId; - // 관 매설 지점을 B04와 같은 저장소에 남긴다 — 저장 실패가 임시저장을 막지는 않는다. - // 세션 초안(B06 저장도 같은 창구를 쓴다)을 먼저 내보내고, 화면 목록으로 한 번 더 맞춘다. - await flushPendingPipes(projectId).catch(() => undefined); - await ctx - .profilePanel() - .drainage.savePipes() - .catch(() => 0); - showLoadingOverlay(); - try { - // 종단 계획선 편집은 화면에서만 계산해 두었으므로 저장 시점에 영속화한다. - await ctx.profilePanel().save(); - // 구조물도 조작분이 세션에만 있다(2026-08-29 — CLAUDE.md 5장). 실패는 자체 토스트로 - // 알리고 세션에 남겨 두므로, 여기서 저장 전체를 멈추지 않는다. - await ctx.bridge().saveStructuresIfDirty(); - // 비정규 측점·상단측 변경분까지 데이터로는 확정 저장하되, 단계 완료 전이는 하지 않는다. - await confirmRoute( - projectId, - { - filter_key: latest?.surface_params.source_filter, - method: latest?.surface_params.method, - smooth: latest?.surface_params.smooth, - surface_model_id: ctx.confirmedSurface()?.model_id ?? undefined, - irregular_stations: ctx - .bridge() - .irregularStations() - .map((station) => ({ - chainage_m: station.chainage_m, - structure: station.structure, - })), - // 상단측(측구 방향) 사용자 변경분 — 종단 정본에 병합되어 B06이 그대로 소비한다. - uphill_overrides: [...ctx.uphillOverrides.entries()].map(([chainage, side]) => ({ - chainage_m: Number(chainage), - side, - })), + await saveDesign({ + projectId, + routeId: () => latest?.route?.id ?? null, + steps: { + // 세션 초안이 나간 뒤 화면 목록으로 한 번 더 맞춘다(시설 보관소 되붙이기 · 응답으로 화면 갱신). + pipes: async () => { + await ctx.profilePanel().drainage.savePipes(); }, - false, - ); - // B06 조정창에서 만진 배수관 구간값도 세션에만 있다 — 함께 내보낸다 - // (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). 실패해도 저장은 진행한다. - // 관 위치는 두 페이지가 같은 스냅샷(`pipes`)으로만 옮김 — 옛 이동 예약은 안 보냄. - if (latest?.route?.id != null) { - await flushCulvertOptions( - projectId, - stateKey("culvertopt", projectId, latest.route.id), - ).catch(() => undefined); - } + // 구조물 — 저장 뒤 서버 목록(새 식별자)으로 화면을 맞춘다. 실패는 자체 토스트로 알리고 + // 세션에 남겨 두므로 저장 전체를 멈추지 않는다(2026-08-29 — CLAUDE.md 5장). + structures: () => ctx.bridge().saveStructuresIfDirty(), + // 종단 계획선 편집은 화면에서만 계산해 두었으므로 저장 시점에 영속화한다(패널도 다시 그림). + alignment: () => ctx.profilePanel().save(), + // 상단측 + 비정규 측점 — 데이터로는 확정 저장하되, 단계 완료 전이는 하지 않는다. + uphill: async () => { + await confirmRoute( + projectId, + { + filter_key: latest?.surface_params.source_filter, + method: latest?.surface_params.method, + smooth: latest?.surface_params.smooth, + surface_model_id: ctx.confirmedSurface()?.model_id ?? undefined, + irregular_stations: ctx + .bridge() + .irregularStations() + .map((station) => ({ + chainage_m: station.chainage_m, + structure: station.structure, + })), + // 상단측(측구 방향) 사용자 변경분 — 종단 정본에 병합되어 B06이 그대로 소비한다. + uphill_overrides: [...ctx.uphillOverrides.entries()].map(([chainage, side]) => ({ + chainage_m: Number(chainage), + side, + })), + }, + false, + ); + }, + }, // B06에서 만져 **캐시에 얹힌** 횡단 수정분을 함께 남긴다 — 안 보내면 바로 아래 // 캐시 비우기에서 사라진다. 계획선 저장 **뒤에** 보내야 사용자 수정 1세트가 // 최종본으로 얹힌다(2026-08-24 사용자 지적). - if (latest?.route?.id != null) { - await saveCachedCrossPatches(projectId, latest.route.id); - } - // 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 B06이 옛 계획선을 못 보게 한다. - invalidateSectionDetail(projectId); - ctx.renderLatest(await ctx.loadLatest(true)); - // 임시저장 = 코리도 영구저장 시점(2026-08-23) — 실패해도 임시저장은 성공 처리. - if (latest?.route?.id) void saveCorridorIfDirty(projectId, latest.route.id); - showToast(L("B05_Route_TempSave_Success"), "success"); - } catch (error) { - showToast(error instanceof Error ? error.message : L("B05_Route_TempSave_Failed"), "error"); - } finally { - hideLoadingOverlay(); - } + cross: async () => { + if (latest?.route?.id != null) await saveCachedCrossPatches(projectId, latest.route.id); + }, + done: async () => { + // 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 B06이 옛 계획선을 못 보게 한다. + invalidateSectionDetail(projectId); + ctx.renderLatest(await ctx.loadLatest(true)); + // 임시저장 = 코리도 영구저장 시점(2026-08-23) — 실패해도 임시저장은 성공 처리. + if (latest?.route?.id) void saveCorridorIfDirty(projectId, latest.route.id); + showToast(L("B05_Route_TempSave_Success"), "success"); + }, + failed: (error) => + showToast(error instanceof Error ? error.message : L("B05_Route_TempSave_Failed"), "error"), + }); } /** [초기화] — 사용자 편집 전부 폐기, 계획노선 CSV 기본값으로 B05·B06 재계산 후 재진입. diff --git a/B05_Profile/B05_Profile_UI_Profile_Edit.ts b/B05_Profile/B05_Profile_UI_Profile_Edit.ts index 0eacc6fb..ccf10047 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Edit.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Edit.ts @@ -17,8 +17,8 @@ import type { AlignmentEdits, ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; import { chainageKey, emptyEdits, hasEdits } from "./B05_Profile_UI_Profile_Alignment"; +import { ALIGNMENT_DRAFT_KEY_PREFIX, useDesignStore } from "../A00_Common/design/design_store"; -const DRAFT_KEY_PREFIX = "b05-profile-alignment-draft"; /** 버튼 폭(21px)에 여유를 더한 값 — 이보다 가까우면 겹친 것으로 본다. */ const BUTTON_CLEARANCE_PX = 23; const BUTTON_HALF_PX = 10; @@ -79,6 +79,9 @@ export interface ProfileEditStore { edited(): boolean; } +/** 초안 읽기 · 쓰기는 설계 가게(`design_store` 의 `alignmentDraft`) 한 창구로만. */ +const draftOf = (routeId: number) => useDesignStore(null, routeId); + /** * 노선의 미저장 편집 초안을 읽는다(B06 공용). * @@ -87,7 +90,7 @@ export interface ProfileEditStore { * 쓰면 초안 편집분이 B06에서 되돌아간 것처럼 보인다. */ export function readAlignmentDraft(routeId: number | null): AlignmentEdits | null { - return routeId === null ? null : readDraft(`${DRAFT_KEY_PREFIX}:${routeId}`); + return routeId === null ? null : draftOf(routeId).get("alignmentDraft"); } /** @@ -96,13 +99,15 @@ export function readAlignmentDraft(routeId: number | null): AlignmentEdits | nul * 초기화는 서버 값을 초기 스냅샷으로 되돌리는데, 이 초안이 남아 있으면 화면이 그 위에 * 옛 편집 델타를 다시 얹어 계획선이 측점에서 원지반선과 만나지 않는다(2026-09-04 실측: * 초기화 직후에는 멀쩡하다가 새로고침 한 번에 최대 6.0m 어긋남). + * 노선 번호를 모르는 옛 초안까지 쓸어야 해 여기서만 키를 훑는다. */ export function clearAlignmentDrafts(): void { + shared.clear(); try { const keys: string[] = []; for (let index = 0; index < sessionStorage.length; index += 1) { const key = sessionStorage.key(index); - if (key?.startsWith(`${DRAFT_KEY_PREFIX}:`)) keys.push(key); + if (key?.startsWith(`${ALIGNMENT_DRAFT_KEY_PREFIX}:`)) keys.push(key); } keys.forEach((key) => sessionStorage.removeItem(key)); } catch { @@ -110,18 +115,47 @@ export function clearAlignmentDrafts(): void { } } -function readDraft(storageKey: string): AlignmentEdits | null { - try { - const raw = sessionStorage.getItem(storageKey); - if (!raw) return null; - const parsed = JSON.parse(raw) as Partial; - return { - station_offsets: parsed.station_offsets ?? {}, - curve_radii: parsed.curve_radii ?? {}, - }; - } catch { - return null; +/** + * 노선마다 **한 벌**인 편집 상태 — B05 종단 패널이 주인이고 B06 계획선 편집은 같은 벌을 + * 쓴다(계획서 24-2a). 예전에는 두 화면이 가게를 따로 만들어 같은 초안 키를 각자 고쳤다 — + * 한쪽이 메모리에 쥔 옛 값으로 다시 쓰면 다른 쪽 편집이 덮였다. + */ +interface EditState { + current: AlignmentEdits; + unsaved: boolean; + /** + * [초기화]의 기준점 — **마지막 [저장] 시점의 편집분**이다(2026-09-03 사용자 지시). + * 처음에는 서버 저장분이고, [저장]을 누를 때마다 그때 값으로 옮겨 간다. 초기 자동 + * 선형으로 되돌리는 것(`resetAll`)과는 다르다 — 저장한 작업까지 지우지 않는다. + */ + savedBaseline: AlignmentEdits; + /** 서버 저장분 — 측점 원복 · 전체 원복이 자동 곡선반경을 되살리는 기준. */ + saved: AlignmentEdits; +} + +const shared = new Map(); + +/** 서버 저장분과 남은 초안으로 상태를 세운다(초안이 있으면 그것이 이기고 미저장). */ +function freshState(routeId: number | null, saved: AlignmentEdits): EditState { + const draft = readAlignmentDraft(routeId); + return { current: draft ?? saved, unsaved: draft !== null, savedBaseline: saved, saved }; +} + +/** [저장]이 보낼 계획고 초안 — 미저장 편집이 없으면 null. */ +export function pendingAlignment(routeId: number): AlignmentEdits | null { + const state = shared.get(routeId); + if (state) return state.unsaved ? state.current : null; + return readAlignmentDraft(routeId); +} + +/** 계획고가 정본에 나갔다 — 그 노선 상태를 저장됨으로 두고 초안을 지운다. */ +export function markAlignmentSaved(routeId: number): void { + const state = shared.get(routeId); + if (state) { + state.unsaved = false; + state.savedBaseline = state.current; } + draftOf(routeId).set("alignmentDraft", null); } /** @@ -131,6 +165,9 @@ function readDraft(storageKey: string): AlignmentEdits | null { * 새로고침한 경우) 초안을 우선 채택하고 미저장 상태로 표시한다. routeId가 바뀌면 * 초안 키도 바뀌어 이전 노선의 편집이 새 노선에 잘못 얹히지 않는다. * + * 부를 때마다 그 노선의 한 벌을 **초안 · 저장분으로 다시 세운다**(되돌리기가 초안을 바꾼 뒤 + * 다시 읽는 길). 이미 선 한 벌에 붙기만 하려면 `useProfileEditStore`. + * * routeId가 아직 없으면 **초안을 두지 않는다**(2026-09-04). 노선 없는 키(`:none`)에 * 쌓인 초안은 어느 노선에나 되붙어 초기화로도 떨어지지 않았다. */ @@ -139,56 +176,62 @@ export function createProfileEditStore( saved: AlignmentEdits, onChange: () => void, ): ProfileEditStore { - const storageKey = routeId === null ? null : `${DRAFT_KEY_PREFIX}:${routeId}`; - const draft = storageKey ? readDraft(storageKey) : null; - let current = draft ?? saved; - let unsaved = draft !== null; - /** - * [초기화]의 기준점 — **마지막 [저장] 시점의 편집분**이다(2026-09-03 사용자 지시). - * 처음에는 서버 저장분이고, [저장]을 누를 때마다 그때 값으로 옮겨 간다. 초기 자동 - * 선형으로 되돌리는 것(`resetAll`)과는 다르다 — 저장한 작업까지 지우지 않는다. - */ - let savedBaseline = saved; + const fresh = freshState(routeId, saved); + // 이미 선 한 벌은 **그 자리에서** 다시 세운다 — 바꿔 끼우면 붙어 있던 쪽이 옛 벌을 쥔다. + const state = routeId === null ? fresh : Object.assign(shared.get(routeId) ?? fresh, fresh); + if (routeId !== null) shared.set(routeId, state); + return handleOf(routeId, state, onChange); +} + +/** 그 노선에 이미 선 한 벌에 붙는다 — 없으면 `createProfileEditStore` 와 같다(B06 계획선 편집). */ +export function useProfileEditStore( + routeId: number, + saved: AlignmentEdits, + onChange: () => void, +): ProfileEditStore { + const state = shared.get(routeId); + return state + ? handleOf(routeId, state, onChange) + : createProfileEditStore(routeId, saved, onChange); +} + +function handleOf( + routeId: number | null, + state: EditState, + onChange: () => void, +): ProfileEditStore { + const draft = routeId === null ? null : draftOf(routeId); function dropDraft(): void { - if (!storageKey) return; - try { - sessionStorage.removeItem(storageKey); - } catch { - // 세션 저장소를 못 쓰는 환경에서도 편집 자체는 계속 동작해야 한다. - } + draft?.set("alignmentDraft", null); } function commit(next: AlignmentEdits): void { - current = next; - unsaved = true; - try { - if (storageKey) sessionStorage.setItem(storageKey, JSON.stringify(current)); - } catch { - // 초안 보관 실패는 편집을 막지 않는다. - } + state.current = next; + state.unsaved = true; + draft?.set("alignmentDraft", state.current); onChange(); } return { - edits: () => current, + edits: () => state.current, replace: commit, markSaved() { - unsaved = false; - savedBaseline = current; + state.unsaved = false; + state.savedBaseline = state.current; dropDraft(); }, restoreSaved() { - current = savedBaseline; - unsaved = false; + state.current = state.savedBaseline; + state.unsaved = false; dropDraft(); onChange(); }, - canRestoreSaved: () => JSON.stringify(current) !== JSON.stringify(savedBaseline), + canRestoreSaved: () => JSON.stringify(state.current) !== JSON.stringify(state.savedBaseline), resetStation(chainageM, moveTo) { const key = chainageKey(chainageM); - const stationOffsets = { ...current.station_offsets }; - const curveRadii = { ...current.curve_radii }; + const stationOffsets = { ...state.current.station_offsets }; + const curveRadii = { ...state.current.curve_radii }; if (moveTo !== undefined) { const to = chainageKey(moveTo); if (stationOffsets[key] !== undefined) stationOffsets[to] = stationOffsets[key]; @@ -200,7 +243,7 @@ export function createProfileEditStore( delete stationOffsets[key]; // 그 측점의 곡선반경은 저장분(자동 계산이 역산해 둔 값)으로 되돌린다 — 지우면 // 프론트가 기본 R로 다시 그려 계획고가 서버 정본과 어긋난다(2026-08-23). - const savedRadius = saved.curve_radii?.[key]; + const savedRadius = state.saved.curve_radii?.[key]; if (savedRadius === undefined) delete curveRadii[key]; else curveRadii[key] = savedRadius; commit({ station_offsets: stationOffsets, curve_radii: curveRadii }); @@ -209,10 +252,10 @@ export function createProfileEditStore( // 지우는 것은 **사용자 편집 측점**이다. 자동 계획선이 역산해 저장한 곡선반경 // (`curve_radii`)까지 비우면 호가 달라져 화면 계획고가 정본과 달라진다 — // 세월교 지점에서 0.22m 낮게 나와 최소고 경고가 잘못 떴다(2026-08-23 진단). - commit({ ...emptyEdits(), curve_radii: { ...(saved.curve_radii ?? {}) } }); + commit({ ...emptyEdits(), curve_radii: { ...(state.saved.curve_radii ?? {}) } }); }, - dirty: () => unsaved, - edited: () => hasEdits(current), + dirty: () => state.unsaved, + edited: () => hasEdits(state.current), }; } diff --git a/B06_Section/B06_Section_UI_Page_Grade_Edit.ts b/B06_Section/B06_Section_UI_Page_Grade_Edit.ts index 8c5c87c5..ff12e945 100644 --- a/B06_Section/B06_Section_UI_Page_Grade_Edit.ts +++ b/B06_Section/B06_Section_UI_Page_Grade_Edit.ts @@ -5,7 +5,7 @@ * ========================================================================== */ import { - createProfileEditStore, + useProfileEditStore, type ProfileEditStore, } from "../B05_Profile/B05_Profile_UI_Profile_Edit"; import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data"; @@ -28,8 +28,8 @@ export interface GradeEditContext { } /** - * 계획선 편집(▲/▼) — B05 와 **같은 세션 초안**에 쌓는다(2026-09-12 사용자: B06 에만 - * 버튼이 없었다). 누르면 그 자리에서 계획선·전 측점 횡단을 다시 풀고(`reconcile`), + * 계획선 편집(▲/▼) — B05 종단 패널의 **같은 가게**(노선마다 한 벌)를 쓴다(계획서 24-2a · + * 2026-09-12 사용자: B06 에만 버튼이 없었다). 누르면 그 자리에서 계획선·전 측점 횡단을 다시 풀고(`reconcile`), * 영구저장은 [저장]·[확정]에서만 한다(CLAUDE.md 5장). */ export function createGradeEdit(ctx: GradeEditContext): GradeEditProvider { @@ -53,7 +53,7 @@ export function createGradeEdit(ctx: GradeEditContext): GradeEditProvider { if (!stored) return null; // 선형 저장분이 없는 옛 노선 — 편집할 기준선이 없다. if (!gradeStore || gradeRouteId !== currentRouteId) { gradeRouteId = currentRouteId; - gradeStore = createProfileEditStore(currentRouteId, stored.edits, () => undefined); + gradeStore = useProfileEditStore(currentRouteId, stored.edits, () => undefined); } const store = gradeStore; const base = toAlignmentBase(stored); diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index b2827081..aaf97f6e 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -7,7 +7,7 @@ * (CLAUDE.md 5장 데이터 3층: 영구저장은 [저장]·[확정]에서만). * ========================================================================== */ -import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; +import { showToast } from "@ui/ui_template_elements"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; import { confirmSections, @@ -17,14 +17,7 @@ import { type SectionDetailResponse, } from "./B06_Section_Api_Fetch"; import { invalidateSectionDetail } from "./B06_Section_Section_Store"; -import { flushPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; -import { saveProfileAlignment } from "../B05_Profile/B05_Profile_Api_Fetch"; -import { - clearAlignmentDrafts, - readAlignmentDraft, -} from "../B05_Profile/B05_Profile_UI_Profile_Edit"; -import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures"; -import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch"; +import { saveDesign, type DesignSaveContext } from "../A00_Common/design/design_save"; import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches"; import { readByKey, readState, writeByKey, writeState } from "../A00_Common/b_page_state"; import { @@ -423,102 +416,64 @@ export function collectSectionEdits(ctx: SectionPersistContext): { }; } -/** 계획선 편집 초안이 있으면 종단 정본에 쓰고 초안을 지운다. 없으면 아무 일도 하지 않는다. */ -async function flushAlignmentDraft(projectId: string, routeId: number | null): Promise { - if (routeId === null) return; - const draft = readAlignmentDraft(routeId); - if (!draft) return; - await saveProfileAlignment(projectId, routeId, draft); - clearAlignmentDrafts(); -} - -/** 세션에 쌓인 조정창·구조물 조작을 정본으로 내보낸다 — [저장]·[확정] 공통 앞단. */ -async function flushPendingEdits(ctx: SectionPersistContext, projectId: string): Promise { - // ⚠ 순서는 **B05 [임시저장]과 같아야 한다**(2026-09-12 사용자: 어느 페이지에서 저장해도 - // 결과가 같아야 한다). 관 목록은 B05 가 **전체 스냅샷**으로, B06 이 **바뀐 것만**(추가· - // 삭제·이동·구간값) 담으므로, 스냅샷을 먼저 얹고 그 위에 델타를 적용해야 한다. 반대로 - // 하면 스냅샷이 B06 편집을 통째로 덮는다. - await flushPendingPipes(projectId).catch((error) => { - const detail = error instanceof Error ? ` ${error.message}` : ""; - showToast(`배수관 저장에 실패했습니다.${detail}`, "error"); - }); - // 조정창 구간값·추가·삭제·이동은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다 - // (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). - await ctx.flushCulvertOptions(); - // B05 3D에서 바꾼 상단측(측구 방향)도 여기서 내보낸다 — 예전에는 B05 [임시저장]에만 - // 실려, B06에서 저장·확정하면 세션에만 남아 옛 방향이 정본에 그대로 있었다 - // (2026-09-06). 서버가 종단 정본과 저장된 횡단 설계를 함께 갱신하므로 아래 - // 횡단 patch 저장보다 **먼저** 나가야 사용자 수정이 위에 얹힌다. - await flushUphillOverrides(projectId).catch(() => undefined); - // B05에서 만지고 넘어온 구조물 조작분도 여기서 정본에 남긴다. 실패해도 횡단 - // 저장까지 막지는 않는다 — 미저장분은 세션에 남으므로 다시 시도할 수 있다 - // (2026-08-29 실측: 타입이 거절되자 sections/save가 아예 나가지 않았다). - // 계획선 편집(▲/▼)은 세션 초안에만 있다 — B06 에서 고쳤든 B05 에서 고쳤든 여기서 - // 종단 정본으로 내보낸다(2026-09-12). 종전에는 B06 이 초안을 **읽기만** 해서, B06 에서 - // 저장하면 계획선 편집이 다음 진입 때 사라졌다. - await flushAlignmentDraft(projectId, ctx.routeId()).catch((error) => { - const detail = error instanceof Error ? ` ${error.message}` : ""; - showToast(`계획선 저장에 실패했습니다.${detail}`, "error"); - }); - await flushPendingStructures(projectId).catch((error) => { - const detail = error instanceof Error ? ` ${error.message}` : ""; - showToast(`구조물 저장에 실패했습니다.${detail}`, "error"); - }); +/** + * B06 의 저장 한 줄기 창구(계획서 24-2a) — 차례는 `saveDesign` 이 정하고(B05 [임시저장]과 같음), + * 여기서는 B06 몫만 준다: 저장 앞 전 측점 재계산 · 조정창 구간값 · 횡단 본문 · 뒤처리. + */ +function sectionSaveContext(ctx: SectionPersistContext, projectId: string): DesignSaveContext { + return { + projectId, + routeId: ctx.routeId, + // ⚠ 면적을 모으기 **전에** 전 측점을 다시 계산한다 — 안 그러면 표준단면을 고친 뒤 + // 안 만진 측점이 옛 면적으로 실려 나간다(2026-09-09 실측). + prepare: () => ctx.reconcileDesigns(), + // 조정창 구간값은 조정창 저장기가 낸다 — 실패하면 알리고 저장을 멈춘다(예전 그대로). + steps: { culvertOptions: () => ctx.flushCulvertOptions() }, + cross: async (confirm) => { + const routeId = ctx.routeId(); + if (routeId === null) return; + const edits = collectSectionEdits(ctx); + await (confirm ? confirmSections : saveSections)( + projectId, + routeId, + ctx.standardValues(), + edits.crossPatches.length ? edits.crossPatches : undefined, + edits.massHaul, + ); + }, + done: (confirm) => { + if (confirm) { + showToast(L("B06_Profile_Confirm_Success"), "success"); + goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[4]); + return; + } + // 저장으로 구조물 초안이 정본이 됐다 — 화면이 들고 있던 상세는 초안 기준으로 벽을 + // 얹은 사본이라, 그대로 두면 새로 저장된 구조물의 벽이 다음 조회까지 안 붙는다 + // (2026-09-06 실측: 8+0.0 흙막이가 저장 뒤 화면 사본에만 빠져 있었음). + const routeId = ctx.routeId(); + if (routeId !== null) invalidateSectionDetail(projectId, routeId); + showToast(L("B06_Profile_Save_Success"), "success"); + }, + failed: (error, confirm) => { + if (confirm) { + const detail = error instanceof Error ? error.message : L("B06_Profile_Confirm_Failed"); + showToast(`${L("B06_Profile_Confirm_Failed")} ${detail}`, "error"); + return; + } + const detail = error instanceof Error ? ` ${error.message}` : ""; + showToast(`${L("B06_Profile_Save_Failed")}${detail}`, "error"); + }, + }; } /** 임시 저장 — 저장만 하고 페이지는 그대로 둔다. */ export async function saveCurrentSections(ctx: SectionPersistContext): Promise { - const routeId = ctx.routeId(); - if (!ctx.projectId || routeId === null) return; - showLoadingOverlay(); - try { - // ⚠ 면적을 모으기 **전에** 전 측점을 다시 계산한다 — 안 그러면 표준단면을 고친 뒤 - // 안 만진 측점이 옛 면적으로 실려 나간다(2026-09-09 실측). - await ctx.reconcileDesigns(); - await flushPendingEdits(ctx, ctx.projectId); - const edits = collectSectionEdits(ctx); - await saveSections( - ctx.projectId, - routeId, - ctx.standardValues(), - edits.crossPatches.length ? edits.crossPatches : undefined, - edits.massHaul, - ); - // 저장으로 구조물 초안이 정본이 됐다 — 화면이 들고 있던 상세는 초안 기준으로 벽을 - // 얹은 사본이라, 그대로 두면 새로 저장된 구조물의 벽이 다음 조회까지 안 붙는다 - // (2026-09-06 실측: 8+0.0 흙막이가 저장 뒤 화면 사본에만 빠져 있었음). - invalidateSectionDetail(ctx.projectId, routeId); - showToast(L("B06_Profile_Save_Success"), "success"); - } catch (error) { - const detail = error instanceof Error ? ` ${error.message}` : ""; - showToast(`${L("B06_Profile_Save_Failed")}${detail}`, "error"); - } finally { - hideLoadingOverlay(); - } + if (!ctx.projectId) return; + await saveDesign(sectionSaveContext(ctx, ctx.projectId)); } /** 확정 — 저장 뒤 다음 단계(상세설계)로 넘어간다. */ export async function confirmCurrentSections(ctx: SectionPersistContext): Promise { - const routeId = ctx.routeId(); - if (!ctx.projectId || routeId === null) return; - showLoadingOverlay(); - try { - await ctx.reconcileDesigns(); // 저장과 같은 이유 — 옛 면적이 정본으로 굳는 것을 막는다 - await flushPendingEdits(ctx, ctx.projectId); - const edits = collectSectionEdits(ctx); - await confirmSections( - ctx.projectId, - routeId, - ctx.standardValues(), - edits.crossPatches.length ? edits.crossPatches : undefined, - edits.massHaul, - ); - showToast(L("B06_Profile_Confirm_Success"), "success"); - goToWorkflowStage(ctx.projectId, WORKFLOW_STEP_ROUTES[4]); - } catch (error) { - const detail = error instanceof Error ? error.message : L("B06_Profile_Confirm_Failed"); - showToast(`${L("B06_Profile_Confirm_Failed")} ${detail}`, "error"); - } finally { - hideLoadingOverlay(); - } + if (!ctx.projectId) return; + await saveDesign(sectionSaveContext(ctx, ctx.projectId), { confirm: true }); } diff --git a/resources/tester/helper_design_store_save.cjs b/resources/tester/helper_design_store_save.cjs new file mode 100644 index 00000000..03b12449 --- /dev/null +++ b/resources/tester/helper_design_store_save.cjs @@ -0,0 +1,180 @@ +/* 설계 가게 · 저장 한 줄기(계획서 24-2a) 단위검증 헬퍼 — + * `A00_Common/design/design_store.ts` · `design_save.ts` · `B05_Profile_UI_Profile_Edit.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, "..", ".."); + +function memoryStorage() { + const map = new Map(); + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => map.set(key, String(value)), + removeItem: (key) => map.delete(key), + key: (index) => [...map.keys()][index] ?? null, + get length() { + return map.size; + }, + }; +} +const session = memoryStorage(); +globalThis.window = { sessionStorage: session, localStorage: memoryStorage() }; +globalThis.sessionStorage = session; + +const calls = []; +const record = (name, fail) => async () => { + calls.push(name); + if (fail) throw new Error(`${name} 실패`); +}; +let failStructures = false; +const STUBS = { + "@ui/ui_template_elements": { + showToast: (text, kind) => calls.push(`toast:${kind}`), + showLoadingOverlay: () => calls.push("overlay:on"), + hideLoadingOverlay: () => calls.push("overlay:off"), + }, + "../../B05_Profile/B05_Profile_Api_Pipes_Draft": { + flushPendingPipes: () => record("pipes")(), + }, + "../../B05_Profile/B05_Profile_Api_Structures": { + flushPendingStructures: () => record("structures", failStructures)(), + }, + "../../B05_Profile/B05_Profile_Api_Fetch": { + flushUphillOverrides: () => record("uphill")(), + 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}`), + }, +}; + +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const { useDesignStore } = loadTs(path.join(ROOT, "A00_Common", "design", "design_store.ts")); +const edit = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Edit.ts")); +const { saveDesign, DESIGN_SAVE_ORDER } = loadTs( + path.join(ROOT, "A00_Common", "design", "design_save.ts"), +); + +async function run() { + const out = {}; + + // ① 가게 — 등록 키 밑 · 같은 칸이면 두 가게가 같은 값 · 듣는 쪽 + const a = useDesignStore("p1", 7); + const b = useDesignStore("p1", 99); // 프로젝트 칸은 노선과 무관 + const heard = []; + const off = b.subscribe("pipes", (value) => heard.push(value.length)); + a.set("pipes", [{ chainage_m: 10, source: "user" }]); + out.pipesShared = b.get("pipes"); + out.pipesKey = [...Array(session.length).keys()].map((i) => session.key(i)); + off(); + a.set("pipes", [{ chainage_m: 10 }, { chainage_m: 20 }]); + out.heard = heard; + out.otherProject = useDesignStore("p2", 7).get("pipes"); + out.noRoute = useDesignStore("p1", null).get("alignmentDraft"); + a.set("selection", { chainageM: 12.5, structureId: "s-1" }); + out.selection = b.get("selection"); + a.set("pipes", null); + a.set("selection", null); + + // ② 계획고 가게 한 벌 — B05 패널 · B06 계획선 편집이 같은 벌 + const saved = { station_offsets: {}, curve_radii: { "40.000": 30 } }; + const b05 = edit.createProfileEditStore(7, saved, () => undefined); + const b06 = edit.useProfileEditStore(7, saved, () => undefined); + b06.replace({ station_offsets: { "20.000": 0.5 }, curve_radii: saved.curve_radii }); + out.b05SeesB06 = b05.edits().station_offsets; + out.b05Dirty = b05.dirty(); + // B05 가 자기 손에 쥔 값으로 이어 고쳐도 B06 편집이 안 사라짐(옛 두 가게 흠) + b05.replace({ + station_offsets: { ...b05.edits().station_offsets, "60.000": -0.2 }, + curve_radii: b05.edits().curve_radii, + }); + out.afterBoth = b06.edits().station_offsets; + out.draft = useDesignStore(null, 7).get("alignmentDraft").station_offsets; + out.pending = edit.pendingAlignment(7).station_offsets; + // 되돌리기가 초안을 바꾼 뒤 B05 가 다시 세움 — B06 쪽 손잡이도 같은 벌을 봄 + useDesignStore(null, 7).set("alignmentDraft", { + station_offsets: { "20.000": 0.1 }, + curve_radii: {}, + }); + edit.createProfileEditStore(7, saved, () => undefined); + out.rebuiltSeenByB06 = b06.edits().station_offsets; + + // ③ 저장 한 줄기 — 차례 · 기본 창구 · 보기 몫 · 계획고 저장됨 표시 + calls.length = 0; + const ok = await saveDesign({ + projectId: "p1", + routeId: () => 7, + prepare: record("prepare"), + steps: { pipes: record("pipes:screen") }, + cross: async (confirm) => calls.push(`cross:${confirm}`), + done: async (confirm) => calls.push(`done:${confirm}`), + failed: () => calls.push("failed"), + }); + out.saveOk = ok; + out.saveCalls = [...calls]; + out.afterSave = { dirty: b05.dirty(), draft: useDesignStore(null, 7).get("alignmentDraft") }; + out.order = DESIGN_SAVE_ORDER; + + // ④ 구조물이 실패해도 다음 차례로(알림) · 확정 · 노선 없으면 안 나감 + calls.length = 0; + failStructures = true; + await saveDesign( + { + projectId: "p1", + routeId: () => 7, + steps: { uphill: record("uphill:screen") }, + cross: async (confirm) => calls.push(`cross:${confirm}`), + failed: () => calls.push("failed"), + }, + { confirm: true }, + ); + failStructures = false; + out.tolerant = [...calls]; + calls.length = 0; + out.noRouteSave = await saveDesign({ + projectId: "p1", + routeId: () => null, + cross: async () => calls.push("cross"), + failed: () => calls.push("failed"), + }); + out.noRouteCalls = [...calls]; + + // ⑤ [초기화] — 초안 · 한 벌 모두 버림 + b05.replace({ station_offsets: { "80.000": 1 }, curve_radii: {} }); + edit.clearAlignmentDrafts(); + out.cleared = { + draft: useDesignStore(null, 7).get("alignmentDraft"), + pending: edit.pendingAlignment(7), + }; + return out; +} + +run().then( + (out) => console.log(JSON.stringify(out)), + (error) => { + console.error(error); + process.exit(1); + }, +); diff --git a/resources/tester/test_b05_b06_save_roundtrip.py b/resources/tester/test_b05_b06_save_roundtrip.py index dbbcd235..7bddfb6f 100644 --- a/resources/tester/test_b05_b06_save_roundtrip.py +++ b/resources/tester/test_b05_b06_save_roundtrip.py @@ -474,23 +474,25 @@ def _resave_unchanged(flow: Flow) -> None: flow.put_structures(flow.api.get(f"{flow.base}/route/structures").json()["structures"]) +# 두 페이지 모두 저장 한 줄기(`A00_Common/design/design_save.ts` `DESIGN_SAVE_ORDER`)를 탄다 — +# 관 → 구조물 → 계획고 → 암거 옵션(구간값) → 오르막(상단측 확정) → 횡단(계획서 24-2a). def _b05_save(flow: Flow) -> None: - """B05 [임시저장] 차례 — 관 → 계획선 → 구조물 → 확정(관 측점 · 상단측) → 구간값 → 횡단.""" + """B05 [임시저장] — 오르막 차례에 관 측점(비정규 측점)도 함께 확정.""" flow.put_pipes(flow.b05_pipe_payload()) - flow.put_alignment() flow.put_structures(flow.screen_structures(reread=True)) - flow.confirm(with_irregular=True) + flow.put_alignment() flow.flush_culvert() + flow.confirm(with_irregular=True) flow.save_sections() def _b06_save(flow: Flow) -> None: - """B06 [저장] 차례 — 관 → 구간값 → 상단측만 확정 → 계획선 → 구조물 → 횡단.""" + """B06 [저장] — 오르막 차례는 상단측만 확정.""" flow.put_pipes(flow.b06_pipe_payload()) + flow.put_structures(flow.screen_structures(reread=False)) + flow.put_alignment() flow.flush_culvert() flow.confirm(with_irregular=False) - flow.put_alignment() - flow.put_structures(flow.screen_structures(reread=False)) flow.save_sections() diff --git a/resources/tester/test_b06_save_channels.py b/resources/tester/test_b06_save_channels.py index 59a1a75a..1ba209c4 100644 --- a/resources/tester/test_b06_save_channels.py +++ b/resources/tester/test_b06_save_channels.py @@ -63,14 +63,36 @@ def test_창구_넷은_초안이_비면_요청을_안_낸다(): def test_저장이_창구를_다섯_다_거친다(): - """순서·개수가 바뀌면 위 성질의 뜻도 바뀐다 — 자리를 못박는다.""" - persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") + """순서·개수가 바뀌면 위 성질의 뜻도 바뀐다 — 자리를 못박는다. + + 차례는 저장 한 줄기(`A00_Common/design/design_save.ts`) 한 곳에 있다(계획서 24-2a) — + B05 [임시저장] · B06 [저장] · [확정]이 모두 그것을 탄다.""" + save = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text(encoding="utf-8") for token in ( - "flushCulvertOptions()", + "flushCulvertOptions(", "flushUphillOverrides(", "flushPendingPipes(", "flushPendingStructures(", ): - assert token in persist, f"{token} 가 저장 앞단에서 사라짐" - # 하나가 실패해도 나머지가 나가야 한다 — 관·구조물은 `catch` 로 감싸 둔다. - assert persist.count(".catch(") >= 3, "실패 격리(catch)가 줄었음" + 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 조정창 구간값이 저장에서 빠짐" + # 하나가 실패해도 나머지가 나가야 한다 — 관·구조물·계획선은 `catch` 로 감싸 둔다. + assert save.count(".catch(") >= 3, "실패 격리(catch)가 줄었음" + order = save[save.index("DESIGN_SAVE_ORDER = [") :] + order = order[: order.index("]")] + names = ["pipes", "structures", "alignment", "culvertOptions", "uphill", "cross"] + assert [order.index(f'"{n}"') for n in names] == sorted(order.index(f'"{n}"') for n in names), ( + "저장 차례가 바뀜 — 관 → 구조물 → 계획고 → 암거 옵션 → 오르막 → 횡단" + ) + + +def test_두_페이지가_저장_한_줄기를_탄다(): + """B05 [임시저장] · B06 [저장] · [확정]이 창구를 따로 부르지 않는다(계획서 24-2a).""" + actions = (ROOT / "B05_Profile" / "B05_Profile_UI_Page_Actions.ts").read_text(encoding="utf-8") + persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") + for name, text in (("B05", actions), ("B06", persist)): + assert "saveDesign(" in text, f"{name} 가 저장 한 줄기를 안 탐" + for token in ("flushPendingPipes(", "flushPendingStructures(", "flushUphillOverrides("): + assert token not in text, f"{name} 가 {token} 를 따로 부름 — 차례가 갈림" + assert "{ confirm: true }" in persist, "[확정]이 한 줄기를 안 탐" diff --git a/resources/tester/test_b06_save_recompute.py b/resources/tester/test_b06_save_recompute.py index 162bceeb..c4ce42d8 100644 --- a/resources/tester/test_b06_save_recompute.py +++ b/resources/tester/test_b06_save_recompute.py @@ -8,6 +8,8 @@ ⇒ `saveCurrentSections`·`confirmCurrentSections` 가 `collectSectionEdits` **앞에서** `ctx.reconcileDesigns()` 를 부른다. 순서가 뒤집히면 옛 면적이 그대로 실린다. + (계획서 24-2a — 둘 다 저장 한 줄기 `saveDesign` 을 탄다: B06 창구 `sectionSaveContext` 의 + `prepare` 가 재계산, `cross` 가 면적 수집이고, `saveDesign` 이 `prepare` 를 먼저 부른다.) """ from __future__ import annotations @@ -17,6 +19,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] PERSIST = ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts" PAGE = ROOT / "B06_Section" / "B06_Section_UI_Page.ts" +SAVE = ROOT / "A00_Common" / "design" / "design_save.ts" def _body(source: str, marker: str) -> str: @@ -30,11 +33,17 @@ def test_저장이_면적을_모으기_전에_다시_계산한다() -> None: "export async function saveCurrentSections", "export async function confirmCurrentSections", ): - body = _body(text, marker) - assert "ctx.reconcileDesigns()" in body, f"{marker} 가 재계산을 안 부른다" - assert body.index("ctx.reconcileDesigns()") < body.index("collectSectionEdits(ctx)"), ( - f"{marker}: 재계산이 면적 수집보다 뒤에 있다 — 옛 면적이 실린다" + assert "saveDesign(sectionSaveContext(" in _body(text, marker)[:400], ( + f"{marker} 가 B06 창구로 저장 한 줄기를 안 탄다" ) + body = _body(text, "function sectionSaveContext") + assert "prepare: () => ctx.reconcileDesigns()" in body, "B06 창구가 재계산을 안 넘긴다" + assert "collectSectionEdits(ctx)" in body[body.index("cross:") :], "면적 수집이 횡단 차례 밖" + save = SAVE.read_text(encoding="utf-8") + run = _body(save, "export async function saveDesign") + assert run.index("ctx.prepare?.()") < run.index("ctx.cross(confirm)"), ( + "저장 한 줄기가 재계산보다 면적 수집을 먼저 부른다 — 옛 면적이 실린다" + ) def test_페이지가_재계산_통로를_이어_준다() -> None: diff --git a/resources/tester/test_design_store_save.py b/resources/tester/test_design_store_save.py new file mode 100644 index 00000000..16e4b994 --- /dev/null +++ b/resources/tester/test_design_store_save.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +"""설계 가게 한 곳 · 저장 한 줄기(계획서 24-2a) 회귀검증. + +- 가게(`design_store`) — 밑은 등록 키 · 같은 칸이면 어느 가게든 같은 값 · 듣는 쪽. +- 계획고 가게 한 벌 — B05 종단 패널 · B06 계획선 편집이 같은 벌을 씀(옛 두 가게 덮어쓰기 흠). +- 저장 한 줄기(`saveDesign`) — 관 → 구조물 → 계획고 → 암거 옵션 → 오르막 → 횡단 · 관 · 구조물 · + 계획고는 실패해도 다음 차례로. +Node 헬퍼(`helper_design_store_save.cjs`)가 TS 를 트랜스파일해 돌린다. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_design_store_save.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_가게는_등록_키_밑_같은_칸은_같은_값(out): + assert out["pipesShared"] == [{"chainage_m": 10, "source": "user"}] + assert out["pipesKey"] == ["aislo:draft:pipes:p1"] + assert out["heard"] == [1], "끊은 뒤에는 안 불려야 함" + assert out["otherProject"] is None + assert out["noRoute"] is None + assert out["selection"] == {"chainageM": 12.5, "structureId": "s-1"} + + +def test_계획고_가게_한_벌(out): + assert out["b05SeesB06"] == {"20.000": 0.5} + assert out["b05Dirty"] is True + assert out["afterBoth"] == {"20.000": 0.5, "60.000": -0.2} + assert out["draft"] == out["afterBoth"] == out["pending"] + assert out["rebuiltSeenByB06"] == {"20.000": 0.1} + + +def test_저장_차례(out): + assert out["order"] == ["pipes", "structures", "alignment", "culvertOptions", "uphill", "cross"] + assert out["saveOk"] is True + assert out["saveCalls"] == [ + "overlay:on", + "prepare", + "pipes", + "pipes:screen", + "structures", + 'alignment:7:{"20.000":0.1}', + "culvert:aislo:draft:culvertopt:p1:7", + "uphill", + "cross:false", + "done:false", + "overlay:off", + ] + assert out["afterSave"] == {"dirty": False, "draft": None} + + +def test_구조물_실패는_알리고_넘어감_확정_노선없음(out): + calls = out["tolerant"] + assert "toast:error" in calls + assert calls.index("structures") < calls.index("toast:error") < calls.index("uphill:screen") + assert "uphill" not in calls, "보기 몫이 기본 창구를 갈음" + assert calls[-2:] == ["cross:true", "overlay:off"] + assert "failed" not in calls + assert out["noRouteSave"] is False and out["noRouteCalls"] == [] + + +def test_초기화는_초안과_한_벌을_버림(out): + assert out["cleared"] == {"draft": None, "pending": None}