fix(구조물표): 323-9 ② 정의 판 알림 되풀이 — 정본이 옛 정의 판으로 적혔으면 구조물을 안 고쳐도 [저장]이 정본을 그대로 다시 써 지금 판 도장 · 다시 열면 알림 없음 · B05 · B06 같은 길

- B05_Profile_Api_Structures: definitionStale(열 때 판 알림이 표시 · 저장 성공이 풂) · flushPendingStructures 가 초안 없어도 옛 판이면 정본 그대로 내보냄
- B05 보기 saveStructuresIfDirty 같은 조건
- 시험: test_323_9_definition_stamp(옛 판 열기 알림 · 고침 없음 저장 = 판 도장 · 다시 열기 알림 없음 · 또 저장 안 나감)

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:07:25 +09:00
co-authored by Claude Opus 5.5
parent 7cb82c6112
commit 13608c6be5
4 changed files with 177 additions and 5 deletions
+14 -4
View File
@@ -214,14 +214,23 @@ export async function dropStaleStructureTypes(): Promise<boolean> {
}
const noticed = new Set<string>();
const staleSaved = new Set<string>(); // 정본이 옛 정의 판으로 적힌 프로젝트(323-9)
/** 정본이 옛 정의 판으로 적혔나 — 그러면 고침이 없어도 [저장]이 정본을 다시 써 지금 판 도장(323-9). */
export function definitionStale(projectId: string): boolean {
return staleSaved.has(projectId);
}
/** 프로젝트 정본이 옛 정의 판으로 적혔으면 알림 한 줄(322-2 · 탭 수명 동안 프로젝트 · 판마다 한 번). */
export function staleDefinitionNotice(
projectId: string,
saved: number | null | undefined,
): string | null {
if (saved === null || saved === undefined || typesVersion === null || saved === typesVersion)
if (saved === null || saved === undefined || typesVersion === null || saved === typesVersion) {
staleSaved.delete(projectId);
return null;
}
staleSaved.add(projectId);
const key = `${projectId}:${saved}:${typesVersion}`;
if (noticed.has(key)) return null;
noticed.add(key);
@@ -296,6 +305,7 @@ export async function saveStructures(
{ method: "PUT", body: JSON.stringify({ base_revision: baseRevision, structures }) },
);
noteSnapshotFailed(saved.snapshot_failed);
staleSaved.delete(projectId); // 서버가 지금 판으로 적음
return saved;
}
@@ -371,12 +381,12 @@ export function writePendingStructures(projectId: string, next: StructureInstanc
/**
* 미저장분을 정본에 쓴다. B05 화면 밖(B06 [저장]·[확정])에서 부르는 경로라 판번호는
* 서버에서 다시 받아 쓴다. 미저장분이 없으면 아무것도 하지 않는다.
* 서버에서 다시 받아 쓴다. 미저장분이 없으면 아무것도 하지 않는다 — 정본이 옛 정의 판이면 그대로 다시 씀(323-9).
*/
export async function flushPendingStructures(projectId: string): Promise<void> {
const pending = readPendingStructures(projectId);
if (!pending) return;
if (!pending && !definitionStale(projectId)) return;
const stored = await fetchStructures(projectId);
await saveStructures(projectId, stored.revision, pending);
await saveStructures(projectId, stored.revision, pending ?? stored.structures); // 고침 없음 = 판 도장만
writePendingStructures(projectId, null);
}
@@ -19,6 +19,7 @@ import {
dropStaleStructureTypes,
fetchStructureTypes,
staleDefinitionNotice,
definitionStale,
saveStructures,
readPendingStructures,
writePendingStructures,
@@ -204,7 +205,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
/** [저장]·[확정]에서만 부른다 — 미저장분이 있으면 정본에 한 번에 쓴다. */
async function saveStructuresIfDirty(): Promise<void> {
if (readPending() === null) return;
if (readPending() === null && !definitionStale(deps.projectId)) return; // 옛 판 = 판 도장(323-9)
// 저장 요청이 겹치면 판번호가 어긋나므로 앞의 저장이 끝난 뒤에 보낸다.
// 보낼 것 = 세션 초안 — 횡단 보기에서 고친 조작도 이 초안에 있다(이 보기의 `ownStructures` 는
// 횡단 보기 고침을 모른다 · 안 그러면 횡단 보기에서 한 지정 · 해제가 [저장]에서 사라짐).
@@ -0,0 +1,108 @@
/* PLAN 323-9 ② — 정의 판 알림 되풀이. 진짜 `B05_Profile_Api_Structures.ts`(판 알림 · 정본 내보내기)를
* Node 로 돌림. 서버는 가짜 한 벌 — 구조물 PUT 이 지금 정의 판을 도장(진짜 저장소와 같음).
*/
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(),
setTimeout: () => 0,
clearTimeout: () => undefined,
};
globalThis.sessionStorage = globalThis.window.sessionStorage;
// 가짜 서버 — 정의 판 21 · 프로젝트 정본은 판 20 으로 적힘
const NOW = 21;
const server = {
revision: 3,
structures: [{ structure_id: "s1", type_id: "retaining_wall", chainage_m: 40 }],
definition_version: 20,
};
const puts = [];
const reply = (body) => ({ ok: true, status: 200, json: async () => body });
globalThis.fetch = async (url, init = {}) => {
const at = url.replace(/^.*?(?=\/(m02|projects))/, "");
if (at === "/m02/structure-definition") return reply({ 판: NOW, types: [] });
if (at === "/projects/structure-types") return reply({ types: [] });
if (at.endsWith("/route/structures") && init.method === "PUT") {
const body = JSON.parse(init.body);
puts.push(body.structures.length);
Object.assign(server, {
revision: server.revision + 1,
structures: body.structures,
definition_version: NOW,
});
return reply({ revision: server.revision });
}
if (at.endsWith("/route/structures")) return reply({ ...server });
throw new Error(`모르는 요청 ${at}`);
};
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 (CATALOG.isCatalogRequest(request)) return CATALOG;
if (request === "@config/config_frontend") return { API_BASE_URL: "", API_TIMEOUT_MS: 1000 };
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 api = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_Api_Structures.ts"));
(async () => {
const P = "p1";
// 열기 — 종류(정의 판) · 정본(옛 판) → 알림 한 줄 · 옛 판 표시
await api.fetchStructureTypes(true);
const opened = await api.fetchStructures(P);
const notice = api.staleDefinitionNotice(P, opened.definition_version);
const staleOnOpen = api.definitionStale(P);
// 고침 없음 [저장](B06 기본 창구) — 정본을 그대로 다시 써 판 도장
await api.flushPendingStructures(P);
const afterSave = { version: server.definition_version, puts: [...puts] };
// 다시 열기 — 알림 없음 · 또 [저장] 해도 안 나감
const reopened = await api.fetchStructures(P);
const again = api.staleDefinitionNotice("p1-새탭", reopened.definition_version);
api.staleDefinitionNotice(P, reopened.definition_version);
await api.flushPendingStructures(P);
process.stdout.write(
JSON.stringify({
notice,
staleOnOpen,
afterSave,
again,
staleAfter: api.definitionStale(P),
putsAfterSecond: puts.length,
structures: server.structures,
}),
);
})();
@@ -0,0 +1,53 @@
"""PLAN 323-9 ② — 정의 판 알림 되풀이: 구조물을 안 고치고 [저장]하면 정본이 안 나가 옛 판 그대로였음.
정본이 옛 정의 판이면 [저장]이 고침 없어도 정본을 다시 써 지금 판 도장 → 다시 열면 알림 없음.
B06 기본 창구(`flushPendingStructures`) · B05 보기 창구(`saveStructuresIfDirty`) 같은 길.
Node 헬퍼 = `helper_323_9_definition_stamp.cjs`(가짜 서버 · 진짜 API 모듈).
"""
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_definition_stamp.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
check=True,
)
return json.loads(run.stdout)
def test_old_version_save_without_edits_stamps_current(out: dict) -> None:
assert out["notice"] == "구조물 정의가 판 21 으로 바뀜 — 저장하면 반영"
assert out["staleOnOpen"] is True
assert out["afterSave"] == {"version": 21, "puts": [1]}
assert out["structures"][0]["structure_id"] == "s1" # 정본 그대로
def test_reopen_has_no_notice_and_no_more_puts(out: dict) -> None:
assert out["again"] is None and out["staleAfter"] is False
assert out["putsAfterSecond"] == 1
def test_b05_view_save_uses_same_stamp() -> None:
page = (ROOT / "B05_Profile" / "B05_Profile_UI_Page_Structures.ts").read_text("utf-8")
body = page[page.index("async function saveStructuresIfDirty") :]
assert "readPending() === null && !definitionStale(deps.projectId)" in body[:400]
assert "persistStructures(readPending() ?? ownStructures)" in body[:900]