From a8ad867468dc00c111467eeafba1dec213f4f6b7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:43:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05=C2=B7B06=20=EB=96=A0=EB=82=A8):=20346-?= =?UTF-8?q?4=20=EB=96=A0=EB=82=A8=20=ED=99=95=EC=9D=B8=20=E2=80=94=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=95=88=20=ED=95=9C=20=EC=A1=B0=EC=9E=91?= =?UTF-8?q?=EC=9D=B4=EB=A9=B4=20=ED=83=AD=20=EB=8B=AB=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EB=8B=A4=EB=A5=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=95=8C=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=EB=AC=BC=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A00_Common/design/design_leave.ts — 고침 칸(초안 통 − 고름 · 관 id 장부 · 종류 캐시 · 구조물 넘김 · 표준 횡단면 · b08 · b09) 지문 · 모든 노선 - 횡단 초안은 [저장] 뒤에도 남음 — 저장 · 확정 끝 깨끗한 표(markDesignClean)와 다를 때만 물음 · [초기화]는 초안을 다 지움 - B05 ↔ B06 이동은 안 묻고 다시 걺 · [버리고 넘어감] = 고침 칸만 지움 · 저장 실패 = 머묾 - design_page 한 줄(걺) · design_shared 한 줄(저장 끝 표) · 문구 B05_Design_Leave · b07-extent 묶음 다시 지음 - 시험 test_346_4_design_leave.py Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- A00_Common/design/design_leave.ts | 123 +++++++++++++ A00_Common/design/design_page.ts | 4 +- A00_Common/design/design_shared.ts | 2 + .../tester/helper_346_4_design_leave.cjs | 167 ++++++++++++++++++ resources/tester/test_346_4_design_leave.py | 57 ++++++ ui_template/ui_template_locale_b4.ts | 1 + 6 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 A00_Common/design/design_leave.ts create mode 100644 resources/tester/helper_346_4_design_leave.cjs create mode 100644 resources/tester/test_346_4_design_leave.py diff --git a/A00_Common/design/design_leave.ts b/A00_Common/design/design_leave.ts new file mode 100644 index 000000000..345ba4168 --- /dev/null +++ b/A00_Common/design/design_leave.ts @@ -0,0 +1,123 @@ +/* ============================================================================= + * A00_Common/design/design_leave.ts + * B05 · B06 떠남 확인(346-4) — 저장 안 한 조작(초안 통)이 있으면 탭 닫기 · 다른 페이지로 갈 때 + * 공용 물음(`ui_template_leave_dialog` · [취소] [버리고 넘어감] [저장 후 넘어감]). + * + * 고침 = 초안 통 칸 중 고침이 아닌 칸(고름 · 관 id 장부 · 종류 캐시 · 고른 구조물 넘김) · + * 다른 페이지 칸(b08 · b09) · 저장 뒤에도 남기는 표준 횡단면(std-cross)을 뺀 것. + * 횡단 초안은 [저장] 뒤에도 세션에 남음 — 그래서 「있음」이 아니라 **마지막 깨끗한 때와 다름**으로 봄: + * [저장] · [확정]이 끝나면 `markDesignClean` 이 그때 지문을 적음 · [초기화]는 초안을 다 지우고 새로 읽음. + * B05 ↔ B06 는 한 페이지 — 그 사이 이동은 묻지 않음. + * 프로젝트 바꿈은 이 페이지를 떠난 뒤라 이 물음이 먼저 · 들어갈 때 물음은 `b_project_switch`. + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import { leaveOrStay } from "@ui/ui_template_leave_dialog"; +import { t as L } from "@ui/ui_template_locale"; +import { + clearState, + namesInBucket, + readByKey, + routeStateKeys, + stateKey, + type StateName, +} from "../b_page_state"; +import { currentRoute, setLeaveGuard } from "../router"; + +/** 초안 통이지만 고침으로 안 보는 칸 — 묻지 않고 [버리고 넘어감]에도 안 지움. */ +const NOT_EDITS = new Set([ + "selection", + "pipe-ids", + "structure-pick", + "structure-type-cache", + "std-cross", +]); +const OTHER_PAGE = /^b0[89]-/; + +export function editDraftNames(): StateName[] { + return namesInBucket("draft").filter((name) => !NOT_EDITS.has(name) && !OTHER_PAGE.test(name)); +} + +const filled = (raw: string | null): raw is string => + raw !== null && raw !== "" && raw !== "{}" && raw !== "[]"; + +/** 고침 칸 지문 — 모든 노선 것 · 빈 칸 뺌 · 고침이 없으면 "". */ +export function editSignature(projectId: string): string { + const parts: string[] = []; + for (const name of editDraftNames()) { + const own = stateKey(name, projectId); + const keys = own ? [own] : routeStateKeys(name, projectId); + for (const key of keys.sort()) { + const raw = readByKey(key); + if (filled(raw)) parts.push(`${key}=${raw}`); + } + } + return parts.join("\n"); +} + +/** 긴 지문을 들고 있지 않게 — FNV-1a 32비트 + 길이. */ +function digest(text: string): string { + let hash = 0x811c9dc5; + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return `${text.length}:${hash >>> 0}`; +} + +// ponytail: 깨끗한 표는 메모리만 — [저장] 뒤 새로고침하면 남은 횡단 초안 때문에 한 번 더 물음. +const cleanMarks = new Map(); + +/** [저장] · [확정]이 끝난 때 — 지금 초안을 깨끗한 것으로 적음. */ +export function markDesignClean(projectId: string): void { + if (projectId) cleanMarks.set(projectId, digest(editSignature(projectId))); +} + +/** 저장 안 한 조작이 있나. */ +export function designDirty(projectId: string): boolean { + if (!projectId) return false; + const signature = editSignature(projectId); + if (!signature) return false; + return cleanMarks.get(projectId) !== digest(signature); +} + +/** [버리고 넘어감] — 고침 칸만 지움(모든 노선). */ +export function discardDesignEdits(projectId: string): void { + editDraftNames().forEach((name) => clearState(name, projectId)); +} + +const SAME_PAGE = new Set([ROUTES.B05_PROFILE, ROUTES.B06_SECTION]); + +/** + * 페이지를 세울 때 한 번 — `root` 가 화면에서 빠지면 스스로 풂. + * `save` = [저장] 한 줄기(`saveSharedDesign(shared, false)`). + */ +export function guardDesignLeave( + projectId: string, + root: HTMLElement, + save: () => Promise, +): void { + const unload = (event: BeforeUnloadEvent): void => { + if (!root.isConnected) return window.removeEventListener("beforeunload", unload); + if (!designDirty(projectId)) return; + event.preventDefault(); + event.returnValue = ""; + }; + window.addEventListener("beforeunload", unload); + const guard = async (): Promise => { + if (!root.isConnected) return true; + if (SAME_PAGE.has(currentRoute())) { + // 라우터가 「넘어감」 뒤 물음을 내림 — 같은 페이지 안 이동이면 한 틱 뒤 다시 걺. + window.setTimeout(() => root.isConnected && setLeaveGuard(guard), 0); + return true; + } + if (!designDirty(projectId)) return true; + const got = await leaveOrStay(L("B05_Design_Leave"), { + save, + stillDirty: () => designDirty(projectId), + discard: () => discardDesignEdits(projectId), + }); + return got !== "stay"; + }; + setLeaveGuard(guard); +} diff --git a/A00_Common/design/design_page.ts b/A00_Common/design/design_page.ts index 2b2a93ec6..2d436080b 100644 --- a/A00_Common/design/design_page.ts +++ b/A00_Common/design/design_page.ts @@ -28,7 +28,8 @@ import { } from "../b_workflow_nav"; import { currentRoute, navigateTo, type MountedPage } from "../router"; import type { DesignView } from "./design_dock"; -import type { DesignPageShared } from "./design_shared"; +import { saveSharedDesign, type DesignPageShared } from "./design_shared"; +import { guardDesignLeave } from "./design_leave"; import { usePipeIds } from "./design_pipe_ids"; import "./design_page.css"; @@ -138,6 +139,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage { revealLeft: () => layout?.setOptionsOpen(true), ensureView: (view) => (slots.get(view) ?? mountView(view, false)).ready, }; + guardDesignLeave(projectId, page, () => saveSharedDesign(shared, false)); // 떠남 확인(346-4) /** 한 벌이 보기를 따라간다 — 좌측 칸 · 단추 글자 · 종단도 판 · 진행 단계 · 제목. */ const applyView = (view: DesignView): void => { diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 3374528c1..7985fd101 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -26,6 +26,7 @@ import { createDesignPanel, type DesignPanel } from "./design_panel"; import { createDesignProfile, type DesignProfile } from "./design_profile"; import { attachProfilePopout, attachSubPanelPopouts } from "./design_profile_popout"; import { saveDesign, type DesignSaveStep } from "./design_save"; +import { markDesignClean } from "./design_leave"; import { resetDesign } from "./design_reset"; import type { DesignView } from "./design_dock"; @@ -313,6 +314,7 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea else await profileSave?.cross?.(isConfirm); }, done: async (isConfirm) => { + markDesignClean(shared.projectId); // 떠남 확인 — 지금 초안이 저장분(346-4) if (isConfirm) { showToast(L("B06_Profile_Confirm_Success"), "success"); goToWorkflowStage(shared.projectId, WORKFLOW_STEP_ROUTES[4]); diff --git a/resources/tester/helper_346_4_design_leave.cjs b/resources/tester/helper_346_4_design_leave.cjs new file mode 100644 index 000000000..ebc38c799 --- /dev/null +++ b/resources/tester/helper_346_4_design_leave.cjs @@ -0,0 +1,167 @@ +/* 346-4 떠남 확인 단위검증 헬퍼 — `A00_Common/design/design_leave.ts` 를 그 자리에서 트랜스파일 · + * `b_page_state` 는 진짜 · 라우터 · 물음 창 · 문구는 흉내. 결과 JSON 을 stdout 으로. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +function memoryStorage() { + 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), + clear: () => map.clear(), + key: (index) => [...map.keys()][index] ?? null, + get length() { + return map.size; + }, + }; +} +const session = memoryStorage(); +const win = new EventTarget(); +win.sessionStorage = session; +win.localStorage = memoryStorage(); +win.setTimeout = setTimeout; +globalThis.window = win; +globalThis.sessionStorage = session; + +let route = "/b05-profile"; +let guard = null; +let guardSets = 0; +let choice = "cancel"; +const asked = []; +const STUBS = { + "@config/config_frontend": { + ROUTES: { B05_PROFILE: "/b05-profile", B06_SECTION: "/b06-section" }, + }, + "@ui/ui_template_locale": { t: (key) => key }, + "@ui/ui_template_leave_dialog": { + leaveOrStay: async (message, hooks) => { + asked.push(message); + if (choice === "cancel") return "stay"; + if (choice === "discard") { + await hooks.discard(); + return "discarded"; + } + await hooks.save(); + return hooks.stillDirty() ? "stay" : "saved"; + }, + }, + "../router": { + currentRoute: () => route, + setLeaveGuard: (fn) => { + guard = fn; + guardSets += 1; + }, + }, +}; + +function loadTs(file) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (request in STUBS) return STUBS[request]; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(file), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const leave = loadTs(path.join(ROOT, "A00_Common", "design", "design_leave.ts")); +const P = "p1"; +const tick = () => new Promise((resolve) => setTimeout(resolve, 5)); +/** 브라우저 BeforeUnloadEvent 처럼 returnValue 를 쓸 수 있게. */ +class BeforeUnload extends Event { + returnValue = true; +} +const unloadBlocked = () => { + const event = new BeforeUnload("beforeunload", { cancelable: true }); + win.dispatchEvent(event); + return event.defaultPrevented; +}; + +(async () => { + const out = {}; + const root = { isConnected: true }; + let saveMarks = true; + let saves = 0; + leave.guardDesignLeave(P, root, async () => { + saves += 1; + if (saveMarks) leave.markDesignClean(P); + }); + + out.emptyDirty = leave.designDirty(P); + // 고침이 아닌 칸 · 다른 페이지 칸 · 표준 횡단면만 — 안 물음 + session.setItem(`aislo:draft:selection:${P}`, '{"chainageM":20}'); + session.setItem(`aislo:draft:std-cross:${P}`, '{"soil":{}}'); + session.setItem(`aislo:draft:pipe-ids:${P}`, '{"next":3}'); + session.setItem(`aislo:draft:b08-materials:${P}`, '{"units":1}'); + session.setItem(`aislo:draft:crossdesign:${P}:7`, "{}"); // 빈 칸 + out.nonEditDirty = leave.designDirty(P); + out.nonEditUnload = unloadBlocked(); + route = "/b08-quantity"; + out.nonEditLeave = await guard(); + out.askedNonEdit = asked.length; + + // 횡단 초안(노선 7) — 물음 + session.setItem(`aislo:draft:crossdesign:${P}:7`, '{"20.000":{"ground_type":"soil"}}'); + out.editDirty = leave.designDirty(P); + out.editUnload = unloadBlocked(); + + // 같은 페이지(B05 → B06) — 안 묻고 한 틱 뒤 다시 걺 + route = "/b06-section"; + const setsBefore = guardSets; + out.samePage = await guard(); + await tick(); + out.reArmed = guardSets === setsBefore + 1; + out.askedSamePage = asked.length; + + // 다른 페이지 — 취소 = 머묾 + route = "/b08-quantity"; + choice = "cancel"; + out.cancelLeave = await guard(); + + // 저장 실패(깨끗한 표 없음) = 머묾 · 저장 성공 = 넘어감 · 뒤로 안 물음 + choice = "save"; + saveMarks = false; + out.saveFailLeave = await guard(); + saveMarks = true; + out.saveLeave = await guard(); + out.saves = saves; + out.afterSaveDirty = leave.designDirty(P); + out.afterSaveUnload = unloadBlocked(); + const askedAfterSave = asked.length; + out.afterSaveLeave = await guard(); + out.askedAfterSave = asked.length - askedAfterSave; + + // 저장 뒤 다시 고침 — 다시 물음 · 버리고 넘어감 = 고침 칸만 지움 + session.setItem(`aislo:draft:alignment-draft:${P}:7`, '{"station_offsets":{"20.000":0.3}}'); + session.setItem(`aislo:draft:route-draft:${P}`, '{"rev":2}'); + out.reEditDirty = leave.designDirty(P); + choice = "discard"; + out.discardLeave = await guard(); + out.left = { + crossdesign: session.getItem(`aislo:draft:crossdesign:${P}:7`), + alignment: session.getItem(`aislo:draft:alignment-draft:${P}:7`), + routeDraft: session.getItem(`aislo:draft:route-draft:${P}`), + selection: session.getItem(`aislo:draft:selection:${P}`), + stdCross: session.getItem(`aislo:draft:std-cross:${P}`), + b08: session.getItem(`aislo:draft:b08-materials:${P}`), + }; + out.afterDiscardDirty = leave.designDirty(P); + + // 페이지가 화면에서 빠짐 — 안 물음 + session.setItem(`aislo:draft:crossdesign:${P}:7`, '{"40.000":{}}'); + root.isConnected = false; + const askedGone = asked.length; + out.goneLeave = await guard(); + out.goneUnload = unloadBlocked(); + out.askedGone = asked.length - askedGone; + process.stdout.write(JSON.stringify(out)); +})(); diff --git a/resources/tester/test_346_4_design_leave.py b/resources/tester/test_346_4_design_leave.py new file mode 100644 index 000000000..3a47c49a4 --- /dev/null +++ b/resources/tester/test_346_4_design_leave.py @@ -0,0 +1,57 @@ +"""346-4 떠남 확인 — B05 · B06 저장 안 한 조작이 있으면 탭 닫기 · 다른 페이지 때 물음. + +./venv/Scripts/python.exe -m pytest resources/tester/test_346_4_design_leave.py -q +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HELPER = Path(__file__).resolve().parent / "helper_346_4_design_leave.cjs" + + +@pytest.fixture(scope="module") +def out() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8" + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def test_고침_아닌_칸은_안_물음(out) -> None: + assert out["emptyDirty"] is False + assert out["nonEditDirty"] is False and out["nonEditUnload"] is False + assert out["nonEditLeave"] is True and out["askedNonEdit"] == 0 + + +def test_횡단_초안이면_물음(out) -> None: + assert out["editDirty"] is True and out["editUnload"] is True + assert out["cancelLeave"] is False + + +def test_B05_B06_사이는_안_묻고_다시_걺(out) -> None: + assert out["samePage"] is True and out["reArmed"] is True and out["askedSamePage"] == 0 + + +def test_저장_실패는_머묾_성공_뒤엔_안_물음(out) -> None: + assert out["saveFailLeave"] is False + assert out["saveLeave"] is True and out["saves"] == 2 + assert out["afterSaveDirty"] is False and out["afterSaveUnload"] is False + assert out["afterSaveLeave"] is True and out["askedAfterSave"] == 0 + + +def test_버리고_넘어감은_고침_칸만_지움(out) -> None: + assert out["reEditDirty"] is True and out["discardLeave"] is True + left = out["left"] + assert left["crossdesign"] is None and left["alignment"] is None and left["routeDraft"] is None + assert left["selection"] and left["stdCross"] and left["b08"] + assert out["afterDiscardDirty"] is False + + +def test_페이지가_빠지면_안_물음(out) -> None: + assert out["goneLeave"] is True and out["goneUnload"] is False and out["askedGone"] == 0 diff --git a/ui_template/ui_template_locale_b4.ts b/ui_template/ui_template_locale_b4.ts index 4c0e3ff0e..4488597ca 100644 --- a/ui_template/ui_template_locale_b4.ts +++ b/ui_template/ui_template_locale_b4.ts @@ -113,4 +113,5 @@ export const ui_locales_b4 = { "B05 · B06 에 저장 안 한 조작이 있습니다 — B05 · B06 [저장] 먼저 누른 뒤 연결하세요.", "Unsaved edits in B05 · B06 — save there first, then link.", ], + B05_Design_Leave: ["B05 · B06 에 저장 안 한 조작이 있음", "Unsaved edits in B05 · B06"], } as const;