From 7cb82c6112e95053cef3dc4bc45438166211d51e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 21:04:51 +0900 Subject: [PATCH] =?UTF-8?q?fix(=EA=B5=AC=EC=A1=B0=EB=AC=BC=ED=91=9C):=2032?= =?UTF-8?q?3-9=20=E2=91=A0=20B06=20[=EC=A0=80=EC=9E=A5]=20=EB=92=A4=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=3D=20=EC=84=9C=EB=B2=84=20=EC=83=88=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=E2=80=94=20=EC=A0=80=EC=9E=A5=20=EB=8F=84?= =?UTF-8?q?=EC=A4=91=20=EB=B0=94=EB=80=9C=20=EC=8B=A0=ED=98=B8=EB=A1=9C=20?= =?UTF-8?q?=EB=82=A1=EC=9D=80=20=EC=83=81=EC=84=B8=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=C2=B7=20=EB=B9=88=20=EC=B4=88=EC=95=88=EC=9D=84=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EC=A7=80=20=EC=95=8A=EC=9D=8C(savingDesign)=20=C2=B7?= =?UTF-8?q?=20=EB=92=A4=EC=B2=98=EB=A6=AC=EA=B0=80=20=EB=B9=84=EC=9A=B4=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=EB=A1=9C=20=EC=83=88=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=EB=A5=BC=20=EB=B0=9B=EC=95=84=20=ED=95=9C=20=EB=B2=88=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EA=B7=B8=EB=A6=BC(=EB=B3=B4=EC=9D=BC=20?= =?UTF-8?q?=EB=95=8C=EB=A7=8C)=20=C2=B7=20B05=20=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=C2=B7=20[=ED=99=95=EC=A0=95]=20=EA=B8=B8?= =?UTF-8?q?=20=EA=B7=B8=EB=8C=80=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - design_save: 저장 도중 표시(savingDesign) · 다 나간 뒤 · 실패 때 풂 - design_view_cross: refreshDetailForStructures 가 저장 도중이면 차례만 올리고 안 그림(최신만) · persistContext.redraw - B06 Persist done: invalidateSectionDetail 뒤 redraw - 시험: test_323_9_save_redraw(저장 전 옛 자리 · 저장 뒤 새 자리 · 도중 건너뜀) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- A00_Common/design/design_save.ts | 10 ++ A00_Common/design/design_view_cross.ts | 3 + B06_Section/B06_Section_UI_Page_Persist.ts | 6 +- resources/tester/helper_323_9_save_redraw.cjs | 129 ++++++++++++++++++ resources/tester/test_323_9_save_redraw.py | 60 ++++++++ 5 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 resources/tester/helper_323_9_save_redraw.cjs create mode 100644 resources/tester/test_323_9_save_redraw.py diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts index 827ffbfe9..e57219467 100644 --- a/A00_Common/design/design_save.ts +++ b/A00_Common/design/design_save.ts @@ -57,6 +57,13 @@ export interface DesignSaveContext { failed: (error: unknown, confirm: boolean) => void; } +const saving = new Set(); + +/** 초안을 내보내는 중인가(323-9) — 그동안 바뀜 신호로 낡은 상세 · 빈 초안을 그리지 않음(뒤처리가 한 번 다시). */ +export function savingDesign(projectId: string | null): boolean { + return projectId !== null && saving.has(projectId); +} + /** 실패해도 저장을 멈추지 않는 창구 — 알리고 넘어간다(초안은 세션에 남아 다시 나갈 수 있다). */ function reportOnly(label: string): (error: unknown) => void { return (error) => { @@ -130,6 +137,7 @@ export async function saveDesign( const routeId = ctx.routeId(); if (routeId === null) return false; showLoadingOverlay(); + saving.add(ctx.projectId); try { await ctx.prepare?.(); for (const step of DESIGN_SAVE_ORDER) { @@ -150,12 +158,14 @@ export async function saveDesign( await (label ? run().catch(reportOnly(label)) : run()); } clearState("structure-type-cache", ctx.projectId); // 종류별 캐시는 저장 뒤 비움(54-32) + saving.delete(ctx.projectId); // 다 나감 — 뒤처리는 새 상세로 그림 await ctx.done?.(confirm); return true; } catch (error) { ctx.failed(error, confirm); return false; } finally { + saving.delete(ctx.projectId); hideLoadingOverlay(); } } diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index dba4f7730..8b7cee4fd 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -38,6 +38,7 @@ import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pi import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection"; import { paintMapPick } from "./design_pick_profile"; import { watchDrafts } from "./design_structure_status"; +import { savingDesign } from "./design_save"; import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick"; import { bindCrossPick } from "../../B06_Section/B06_Section_UI_Page_Pick_Sync"; import { createSectionView } from "../../B06_Section/B06_Section_UI_Section_View"; @@ -218,6 +219,7 @@ export async function mountCrossView( async function refreshDetailForStructures(): Promise { if (!projectId || currentRouteId === null) return; const seq = ++structuresRefreshSeq; // 잇단 조작 — 최신 요청만 그림(54-4) + if (savingDesign(projectId)) return; // 저장 도중 = 낡은 캐시 · 빈 초안 — 저장 뒤 한 번(323-9) try { const fresh = await loadSectionDetail(projectId, currentRouteId); if (seq !== structuresRefreshSeq) return; @@ -535,6 +537,7 @@ export async function mountCrossView( // 저장 직전 전 측점 재계산 — 표준단면·계획선을 고친 뒤 안 만진 측점이 옛 면적으로 // 실려 나가던 자리(2026-09-09 실측). reconcileDesigns: () => reconcileStaleDesigns({ force: true }), + redraw: () => (host.visible() ? refreshDetailForStructures() : Promise.resolve()), patchSources: () => ({ rockOffsets: rockStore.offsets, cutSlopeRatios: cutSlopeStore.ratios, diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 152a6fb81..3e870bbbc 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -381,6 +381,8 @@ export interface SectionPersistContext { * 0.69 → 0.9 로 고친 뒤 저장했더니 정본 한 줄에 **새 측구 기하(0.9) + 옛 절토 면적 * (0.69 기준 3.33㎡)** 이 섞여 남았다. 만진 측점만 새 값으로 서던 자리다. */ reconcileDesigns: () => Promise; + /** 저장 뒤 새 상세로 카드 · 그림 한 번 다시(보일 때만 · 323-9). */ + redraw: () => Promise; } /** 확정과 임시 저장이 함께 보내는 편집분(암 경계선 오프셋 + 유토곡선 + balloon 위치). */ @@ -499,12 +501,14 @@ export function crossSaveParts(ctx: SectionPersistContext): DesignViewSave { ); clearState("kindreset", ctx.projectId); // 비울 자리를 보냈음(318-4) }, - done: () => { + done: async () => { // 저장으로 구조물 초안이 정본이 됐다 — 화면이 들고 있던 상세는 초안 기준으로 벽을 // 얹은 사본이라, 그대로 두면 새로 저장된 구조물의 벽이 다음 조회까지 안 붙는다 // (2026-09-06 실측: 8+0.0 흙막이가 저장 뒤 화면 사본에만 빠져 있었음). + // 저장 도중 바뀜 신호는 안 그렸음 — 비운 캐시로 서버 새 상세를 받아 한 번 다시(323-9) const routeId = ctx.routeId(); if (ctx.projectId && routeId !== null) invalidateSectionDetail(ctx.projectId, routeId); + await ctx.redraw(); }, }; } diff --git a/resources/tester/helper_323_9_save_redraw.cjs b/resources/tester/helper_323_9_save_redraw.cjs new file mode 100644 index 000000000..66759a9c3 --- /dev/null +++ b/resources/tester/helper_323_9_save_redraw.cjs @@ -0,0 +1,129 @@ +/* PLAN 323-9 ① — B06 [저장] 뒤 카드 = 서버 새 상세. 진짜 `design_save.ts`(저장 차례) · + * `B06_Section_UI_Page_Persist.ts`(횡단 뒤처리) · `B06_Section_Section_Store.ts`(상세 캐시)를 Node 로 돌림. + * 서버 창구만 가짜 — 상세는 저장 전(옛 자리) · 저장 뒤(새 자리) 두 벌. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) + +const ROOT = path.join(__dirname, "..", ".."); +const memory = () => { + const map = new Map(); + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => map.set(key, String(value)), + removeItem: (key) => map.delete(key), + key: (index) => [...map.keys()][index] ?? null, + get length() { + return map.size; + }, + }; +}; +globalThis.window = { sessionStorage: memory(), localStorage: memory() }; +globalThis.sessionStorage = globalThis.window.sessionStorage; + +const calls = []; +let server = "옛 자리 4+5"; +const detail = () => ({ cross_sections: [], longitudinal: { stations: [] }, tag: server }); +const B06 = path.join(ROOT, "B06_Section"); +const STUBS = { + "@ui/ui_template_elements": { + showToast: (_text, kind) => calls.push(`toast:${kind}`), + showLoadingOverlay: () => calls.push("overlay:on"), + hideLoadingOverlay: () => calls.push("overlay:off"), + }, + [path.join(B06, "B06_Section_Api_Fetch.ts")]: { + fetchSectionDetail: async () => (calls.push(`fetch:${server}`), detail()), + saveSections: async () => calls.push("cross:save"), + confirmSections: async () => calls.push("cross:confirm"), + }, +}; + +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (STUBS[full]) return STUBS[full]; + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (CATALOG.isCatalogRequest(request)) return CATALOG; + if (request.endsWith(".json")) return require(path.join(path.dirname(full), request)); + if (request.endsWith(".css")) return {}; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + if (request.startsWith("@")) return new Proxy({}, { get: () => () => undefined }); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const { saveDesign, savingDesign } = loadTs( + path.join(ROOT, "A00_Common", "design", "design_save.ts"), +); +const { crossSaveParts } = loadTs(path.join(B06, "B06_Section_UI_Page_Persist.ts")); +const { loadSectionDetail } = loadTs(path.join(B06, "B06_Section_Section_Store.ts")); + +(async () => { + const P = "p1"; + const R = 7; + // 횡단 보기 = 바뀜 신호마다 `refreshDetailForStructures` 와 같은 차례(최신만 · 저장 도중 안 그림) + let shown = null; + let seq = 0; + const refresh = async () => { + const mine = ++seq; + if (savingDesign(P)) return calls.push("refresh:skip"); + const got = await loadSectionDetail(P, R); + if (mine === seq) shown = got.tag; + }; + await refresh(); // 저장 전 화면(캐시 = 옛 서버) + const before = shown; + const ctx = { + projectId: P, + routeId: () => R, + detail: () => null, + context: () => null, + standardValues: () => undefined, + flushCulvertOptions: async () => undefined, + patchSources: () => + Object.fromEntries( + ["rockOffsets", "cutSlopeRatios", "stationWidths", "inletStructures", "basinAdjustments"] + .concat(["revetAdjusts", "extraCounts", "extraSpans", "fordAdjusts", "boxAdjusts"]) + .concat(["linkFlags"]) + .map((key) => [key, new Map()]), + ), + reconcileDesigns: async () => undefined, + redraw: refresh, + }; + const parts = crossSaveParts(ctx); + calls.length = 0; + const savingSeen = []; + const ok = await saveDesign({ + projectId: P, + routeId: () => R, + steps: { + // 관 · 구조물 초안이 정본으로 나가며 가게가 바뀜 → 바뀜 신호(저장 도중) + structures: async () => { + server = "새 자리 4+8 · 47+0"; + savingSeen.push(savingDesign(P)); + await refresh(); + }, + ...parts.steps, + }, + cross: (confirm) => parts.cross(confirm), + done: async () => { + savingSeen.push(savingDesign(P)); + await parts.done(false); + }, + failed: (error) => calls.push(`failed:${error}`), + }); + process.stdout.write( + JSON.stringify({ ok, before, after: shown, savingSeen, calls, savingIdle: savingDesign(P) }), + ); +})(); diff --git a/resources/tester/test_323_9_save_redraw.py b/resources/tester/test_323_9_save_redraw.py new file mode 100644 index 000000000..a9d92f71d --- /dev/null +++ b/resources/tester/test_323_9_save_redraw.py @@ -0,0 +1,60 @@ +"""PLAN 323-9 ① — B06 [저장] 뒤 카드가 저장 전 서버 상태로 돌아가던 흠. + +저장 도중 바뀜 신호(초안이 정본으로 나가며 비워짐)가 낡은 상세 캐시 · 빈 초안으로 다시 그렸고, 뒤처리는 +캐시만 비우고 다시 안 그렸음 → 저장 도중엔 안 그림 · 뒤처리가 서버 새 상세로 한 번 그림. +Node 헬퍼 = `helper_323_9_save_redraw.cjs`(진짜 저장 차례 · 횡단 뒤처리 · 상세 캐시). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).parent +ROOT = HERE.parent.parent + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def out() -> dict: + run = subprocess.run( + ["node", str(HERE / "helper_323_9_save_redraw.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=True, + ) + return json.loads(run.stdout) + + +def test_after_save_cards_show_server_new_detail(out: dict) -> None: + assert out["ok"] is True + assert out["before"] == "옛 자리 4+5" + assert out["after"] == "새 자리 4+8 · 47+0" + + +def test_no_redraw_from_stale_cache_while_saving(out: dict) -> None: + assert out["savingSeen"] == [True, False] and out["savingIdle"] is False + # 저장 도중 신호는 건너뜀 · 뒤처리가 새로 한 번 받음(오버레이 안) + assert out["calls"] == [ + "overlay:on", + "refresh:skip", + "cross:save", + "fetch:새 자리 4+8 · 47+0", + "overlay:off", + ] + + +def test_cross_view_wires_guard_and_redraw() -> None: + view = (ROOT / "A00_Common" / "design" / "design_view_cross.ts").read_text("utf-8") + body = view[view.index("async function refreshDetailForStructures") :] + body = body[: body.index("loadSectionDetail")] + assert "++structuresRefreshSeq" in body and "savingDesign(projectId)" in body + assert "redraw: () => (host.visible() ? refreshDetailForStructures()" in view