diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index b8665eb5f..0a89149af 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -214,14 +214,23 @@ export async function dropStaleStructureTypes(): Promise { } const noticed = new Set(); +const staleSaved = new Set(); // 정본이 옛 정의 판으로 적힌 프로젝트(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 { 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); } diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 669192248..0009778ff 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -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 { - if (readPending() === null) return; + if (readPending() === null && !definitionStale(deps.projectId)) return; // 옛 판 = 판 도장(323-9) // 저장 요청이 겹치면 판번호가 어긋나므로 앞의 저장이 끝난 뒤에 보낸다. // 보낼 것 = 세션 초안 — 횡단 보기에서 고친 조작도 이 초안에 있다(이 보기의 `ownStructures` 는 // 횡단 보기 고침을 모른다 · 안 그러면 횡단 보기에서 한 지정 · 해제가 [저장]에서 사라짐). diff --git a/resources/tester/helper_323_9_definition_stamp.cjs b/resources/tester/helper_323_9_definition_stamp.cjs new file mode 100644 index 000000000..2c6c36483 --- /dev/null +++ b/resources/tester/helper_323_9_definition_stamp.cjs @@ -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, + }), + ); +})(); diff --git a/resources/tester/test_323_9_definition_stamp.py b/resources/tester/test_323_9_definition_stamp.py new file mode 100644 index 000000000..fcb316a9e --- /dev/null +++ b/resources/tester/test_323_9_definition_stamp.py @@ -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]