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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user