feat(B05·B06 떠남): 346-4 떠남 확인 — 저장 안 한 조작이면 탭 닫기 · 다른 페이지 때 공용 물음
- 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
This commit is contained in:
@@ -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<StateName>([
|
||||
"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<string, string>();
|
||||
|
||||
/** [저장] · [확정]이 끝난 때 — 지금 초안을 깨끗한 것으로 적음. */
|
||||
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<string>([ROUTES.B05_PROFILE, ROUTES.B06_SECTION]);
|
||||
|
||||
/**
|
||||
* 페이지를 세울 때 한 번 — `root` 가 화면에서 빠지면 스스로 풂.
|
||||
* `save` = [저장] 한 줄기(`saveSharedDesign(shared, false)`).
|
||||
*/
|
||||
export function guardDesignLeave(
|
||||
projectId: string,
|
||||
root: HTMLElement,
|
||||
save: () => Promise<unknown>,
|
||||
): 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<boolean> => {
|
||||
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);
|
||||
}
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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));
|
||||
})();
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user