fix(구조물표): 323-9 ① B06 [저장] 뒤 카드 = 서버 새 상세 — 저장 도중 바뀜 신호로 낡은 상세 캐시 · 빈 초안을 그리지 않음(savingDesign) · 뒤처리가 비운 캐시로 새 상세를 받아 한 번 다시 그림(보일 때만) · B05 보기 저장 · [확정] 길 그대로

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
2026-10-14 21:04:51 +09:00
co-authored by Claude Opus 5.5
parent 81f7d98e6f
commit 7cb82c6112
5 changed files with 207 additions and 1 deletions
+10
View File
@@ -57,6 +57,13 @@ export interface DesignSaveContext {
failed: (error: unknown, confirm: boolean) => void;
}
const saving = new Set<string>();
/** 초안을 내보내는 중인가(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();
}
}
+3
View File
@@ -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<void> {
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,
+5 -1
View File
@@ -381,6 +381,8 @@ export interface SectionPersistContext {
* 0.69 → 0.9 로 고친 뒤 저장했더니 정본 한 줄에 **새 측구 기하(0.9) + 옛 절토 면적
* (0.69 기준 3.33㎡)** 이 섞여 남았다. 만진 측점만 새 값으로 서던 자리다. */
reconcileDesigns: () => Promise<void>;
/** 저장 뒤 새 상세로 카드 · 그림 한 번 다시(보일 때만 · 323-9). */
redraw: () => Promise<void>;
}
/** 확정과 임시 저장이 함께 보내는 편집분(암 경계선 오프셋 + 유토곡선 + 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();
},
};
}
@@ -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) }),
);
})();
@@ -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