From 2ba46341eeba02981fb145a41d91fc8cc7c586c2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 28 Oct 2026 18:06:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EA=B3=B5=EC=9A=A9):=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=EB=A7=88=EB=8B=A4=20=EB=94=B0=EB=A1=9C=20?= =?UTF-8?q?=EB=8F=99=EC=9E=91=20=EA=B3=B5=EC=9A=A9=20=E2=80=94=20=ED=83=AD?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=C2=B7=20=EB=96=A0?= =?UTF-8?q?=EB=82=A8=20=EB=B9=84=EC=9B=80=20=C2=B7=20=EB=96=A0=EB=82=A8=20?= =?UTF-8?q?=EB=AC=BC=EC=9D=8C=20=ED=95=9C=20=EB=B2=8C=20(PLAN=20572-1=20?= =?UTF-8?q?=C2=B7=20=EC=9A=94=EA=B5=AC=20400~403)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - b_project_switch: currentProjectId(탭 세션 먼저 · 새 탭 = 마지막 값) · setCurrentProject · forgetProject · leaveProject(그 프로젝트 세션 칸 · 준비 표식 · 3D 보관함 몫) · reloadIfOtherProject(다른 프로젝트 화면 = 새로 엶) · guardProjectLeave · askProjectLeave([취소 · 폐기 · 저장] 한 번) · linkRouter(라우터가 이어 줌) · 화면 모듈은 부를 때만 들임 - b_asset_cache purgeProject · b_page_state 등록표 D02 · E 일곱 칸(옛 키 한 번 옮김) · router isProjectRoute - 가드 helper: design_leave = 공용 가드 위 껍질(B05 고침 지문에 D · E 칸 안 섞임) · group_workflow { dirty, save, discard } · b_workflow_nav · b_page_scaffold · overlay · design_store · design_page 직접 읽기 → currentProjectId - 시험 test_401_project_isolation 새로(탭 둘 흉내 · 떠남 · 보관함 · 가드 · 직접 읽기 한 곳) · 290_1 시드에 D02 · E 옛 키 - 낡은 시험 고침: 298_3 · 346_4 · 384_s · 520_3 헬퍼 = 공용 가드 · 탭 프로젝트 흉내 더함 · 298_3 탭 닫기 듣개 자리 b_project_switch 로 · 302_1 라우터 판별 글 isProjectRoute 꼴로 · 343_3 헬퍼 가짜 IndexedDB 내보냄 Co-Authored-By: Claude Opus 5.5 --- A00_Common/b_asset_cache.ts | 21 ++ A00_Common/b_page_scaffold.ts | 4 +- A00_Common/b_page_state.ts | 8 + A00_Common/b_project_switch.ts | 169 +++++++++++++- A00_Common/b_workflow_nav.ts | 20 +- A00_Common/design/design_leave.ts | 41 +--- A00_Common/design/design_page.ts | 5 +- A00_Common/design/design_store.ts | 8 +- A00_Common/group_workflow.ts | 11 +- A00_Common/router.ts | 11 +- .../tester/helper_290_1_project_purge.cjs | 2 + .../tester/helper_298_3_page_dispose.cjs | 2 + resources/tester/helper_343_3_asset_cache.cjs | 5 + .../tester/helper_346_4_design_leave.cjs | 4 + resources/tester/helper_384_s_session.cjs | 7 +- .../tester/helper_401_project_isolation.cjs | 217 ++++++++++++++++++ resources/tester/helper_520_3_move_stage.cjs | 5 +- resources/tester/test_290_1_project_purge.py | 9 +- resources/tester/test_298_3_page_dispose.py | 8 +- resources/tester/test_302_1_print_frame.py | 4 +- .../tester/test_401_project_isolation.py | 112 +++++++++ ui_template/ui_template_locale_common.ts | 1 + ui_template/ui_template_overlay.ts | 4 +- 23 files changed, 604 insertions(+), 74 deletions(-) create mode 100644 resources/tester/helper_401_project_isolation.cjs create mode 100644 resources/tester/test_401_project_isolation.py diff --git a/A00_Common/b_asset_cache.ts b/A00_Common/b_asset_cache.ts index 748436186..dd4445a44 100644 --- a/A00_Common/b_asset_cache.ts +++ b/A00_Common/b_asset_cache.ts @@ -169,6 +169,27 @@ export async function purgeOtherProjects(projectId: string): Promise { }); } +/** 그 프로젝트 몫만 지운다(401 · 떠날 때) — 다른 탭 · 다른 프로젝트 줄은 둠 · 이 탭 준비 표식도 버림. */ +export async function purgeProject(projectId: string): Promise { + if (!projectId) return; + try { + const stamp = window.sessionStorage.getItem(PRELOADED_SIGNATURE_KEY); + if (stamp?.startsWith(`${projectId}|`)) + window.sessionStorage.removeItem(PRELOADED_SIGNATURE_KEY); + } catch { + /* 저장소가 막힌 브라우저 — 표식이 없다. */ + } + await deleteWhere((store) => { + const cursorRequest = store.index("projectId").openKeyCursor(IDBKeyRange.only(projectId)); + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + if (!cursor) return; + store.delete(cursor.primaryKey); + cursor.continue(); + }; + }); +} + /** 보관함에서 바이트만 꺼낸다 — 네트워크를 타지 않는다. 없으면 null. * 주소에 열쇠가 박히는 자료(3D 코리도)는 이걸로 **먼저 보고** 없을 때만 받는다. */ export async function readCachedBytes(projectId: string, url: string): Promise { diff --git a/A00_Common/b_page_scaffold.ts b/A00_Common/b_page_scaffold.ts index f78642155..08d0a6821 100644 --- a/A00_Common/b_page_scaffold.ts +++ b/A00_Common/b_page_scaffold.ts @@ -12,7 +12,6 @@ * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { createButton } from "@ui/ui_template_elements"; import { @@ -21,6 +20,7 @@ import { WORKFLOW_STEP_ROUTES, type WorkflowState, } from "./b_workflow_nav"; +import { currentProjectId } from "./b_project_switch"; /** locale 헬퍼 */ function L(key: keyof typeof ui_locales): string { @@ -107,7 +107,7 @@ export async function renderPendingWorkflow( opts: PendingWorkflowOptions, ): Promise { injectScaffoldStyles(); - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const projectId = currentProjectId(); let workflowState: WorkflowState | undefined; if (projectId) { try { diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index cd50cd0a0..e6cd74bd1 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -214,6 +214,14 @@ export const STATE_REGISTRY = { "route-draft-hold": { bucket: "draft", scope: "project" }, /** D04 연도별 지정 업체 목록(531-3) — [저장] 때 PUT plan/assignees. */ "d04-assignees": { bucket: "draft", scope: "project" }, + /* D02 · E 페이지 캐시(401) — 진입 · 떠남 비우기가 같은 표를 봄 · 옛 키는 한 번 옮김. */ + "d02-points": { bucket: "draft", scope: "project", legacy: (p) => `d02_points_${p}` }, + "e02-ground": { bucket: "draft", scope: "project", legacy: (p) => `e02_ground_cache:${p}` }, + "e03-prob": { bucket: "draft", scope: "project", legacy: (p) => `e03_prob_cache:${p}` }, + "e04-settings": { bucket: "draft", scope: "project", legacy: (p) => `e04_settings:${p}` }, + "e05-report": { bucket: "draft", scope: "project", legacy: (p) => `e05_report_cache:${p}` }, + "e06-rock": { bucket: "draft", scope: "project", legacy: (p) => `e06_rock_cache:${p}` }, + "e07-settings": { bucket: "draft", scope: "project", legacy: (p) => `e07_settings:${p}` }, /* ── ④ 계산 결과 ─────────────────────────────────────────────────────── */ /** 노선·종단 최신 응답. 페이지를 오갈 때 이 값으로 먼저 그린다. */ diff --git a/A00_Common/b_project_switch.ts b/A00_Common/b_project_switch.ts index 91f3fd032..9e1df05b5 100644 --- a/A00_Common/b_project_switch.ts +++ b/A00_Common/b_project_switch.ts @@ -1,21 +1,70 @@ /* ============================================================================= * A00_Common/b_project_switch.ts - * 다른 프로젝트에 들어갈 때 **그 밖 프로젝트의 캐시**를 지운다(계획서 290-1 · 요구 183-1). + * 프로젝트마다 따로 동작(401) — 탭마다 지금 프로젝트 · 떠남 물음 · 떠날 때 · 들 때 비움. * - * 지우는 것 — 이 탭 세션의 다른 프로젝트 칸(초안 · 결과 · 고름 · 옛 키 · 페이지별 칸)과 - * 3D 지형 보관함(IndexedDB). 남기는 것 — 지금 프로젝트 것 · 계정 취향(pref, localStorage). - * 다른 프로젝트에 저장 안 한 고침(초안)이 있으면 확인창 — 「남기기」면 그 초안만 둔다. - * 같은 프로젝트로 다시 들어오면(새로고침 포함) 아무것도 안 지운다(탭마다 들어온 표식). + * 지금 프로젝트 = 탭 세션 칸 먼저 · 없으면 마지막으로 연 프로젝트(localStorage · 새 탭이 이어받음). + * 들 때(`enterProject`) — 다른 프로젝트 세션 칸 · 3D 보관함을 지움(290-1) · 저장 안 한 고침이 있으면 확인창. + * 떠날 때(`leaveProject`) — 그 프로젝트 세션 칸 · 준비 표식 · 3D 보관함 몫 · 탭 프로젝트 표식 · 취향은 남김. + * 다른 프로젝트 화면을 그리려 하면 새로 엶(`reloadIfOtherProject` · 모듈 메모리 통째 비움). + * 떠남 물음(`guardProjectLeave`) — 화면에 붙은 가드를 모아 [취소 · 폐기 · 저장] 한 번. + * 화면 모듈은 부를 때만 들임 · 라우터는 스스로 이어 줌(`linkRouter`) — 설계 가게 · 인쇄 묶음이 이 파일을 부름. * ========================================================================== */ -import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; -import { showConfirmDialog } from "@ui/ui_template_elements"; -import { purgeOtherProjects } from "./b_asset_cache"; +import { CURRENT_PROJECT_ID_KEY } from "../config/config_frontend"; import { STATE_REGISTRY, type StateEntry } from "./b_page_state"; /** 이 탭이 마지막으로 들어온 프로젝트. */ const ENTERED_KEY = "aislo:entered-project"; +/** 이 탭의 지금 프로젝트(401) — 탭 세션 칸 먼저 · 없으면 마지막으로 연 프로젝트(새 탭) · 없으면 "". */ +export function currentProjectId(): string { + try { + return ( + window.sessionStorage.getItem(ENTERED_KEY) || + window.localStorage.getItem(CURRENT_PROJECT_ID_KEY) || + "" + ); + } catch { + return ""; + } +} + +/** 지금 프로젝트로 적음 — 이 탭 + 새 탭이 이어받을 마지막 값. 다른 프로젝트 캐시 비움은 `enterProject`. */ +export function setCurrentProject(projectId: string): void { + try { + window.sessionStorage.setItem(ENTERED_KEY, projectId); + window.localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId); + } catch { + /* 저장소가 막힌 브라우저 — 기억 못 함. */ + } +} + +/** 지워진 · 권한 없는 프로젝트를 잊음 — 이 탭 표식 · 마지막 값이 그 프로젝트일 때만. */ +export function forgetProject(projectId: string): void { + try { + if (window.sessionStorage.getItem(ENTERED_KEY) === projectId) + window.sessionStorage.removeItem(ENTERED_KEY); + if (window.localStorage.getItem(CURRENT_PROJECT_ID_KEY) === projectId) + window.localStorage.removeItem(CURRENT_PROJECT_ID_KEY); + } catch { + /* 저장소가 막힌 브라우저 — 잊을 것이 없다. */ + } +} + +/** 이 화면(모듈 메모리)이 담은 프로젝트 — 프로젝트 화면을 그릴 때마다 견줌. */ +let heldProject = ""; + +/** 다른 프로젝트 화면을 그리려 하면 새로 엶(401 · 모듈 메모리 통째 비움) · true = 새로 여는 중. */ +export function reloadIfOtherProject(): boolean { + const projectId = currentProjectId(); + if (heldProject && projectId && projectId !== heldProject) { + window.location.reload(); + return true; + } + if (projectId) heldProject = projectId; + return false; +} + const MARK_P = "\u0001"; const MARK_R = "\u0002"; @@ -107,6 +156,7 @@ export async function enterProject( const keep = new Set(); for (const other of plan.draftProjects) { const name = await nameOf(other).catch(() => other); + const { showConfirmDialog } = await import("@ui/ui_template_elements"); const drop = await showConfirmDialog( `「${name}」에 저장 안 한 고침이 있음 — 지우고 열기 / 남기기`, "지우고 열기", @@ -115,7 +165,8 @@ export async function enterProject( if (!drop) keep.add(other); } plan.doomed(keep).forEach((key) => window.sessionStorage.removeItem(key)); - window.sessionStorage.setItem(ENTERED_KEY, projectId); + setCurrentProject(projectId); + const { purgeOtherProjects } = await import("./b_asset_cache"); await purgeOtherProjects(projectId); } catch { /* 저장소가 막힌 브라우저 — 캐시를 못 지워도 프로젝트는 연다. */ @@ -136,6 +187,7 @@ export async function wipeAccountCache(): Promise { } catch { /* 저장소가 막힌 브라우저 — 지울 것이 없다. */ } + const { purgeOtherProjects } = await import("./b_asset_cache"); await purgeOtherProjects(""); // 빈 id 보다 큰 열쇠 = 모든 프로젝트 } @@ -153,3 +205,102 @@ export async function wipeIfOwnerChanged(user: string | number): Promise { + if (!projectId) return; + try { + // 프로젝트 칸 열쇠는 모두 프로젝트 번호(uuid)를 품음 — 등록표 · 옛 키 · 페이지 칸을 한 줄로. + Object.keys(sessionEntries()) + .filter((key) => key.includes(projectId)) + .forEach((key) => window.sessionStorage.removeItem(key)); + } catch { + /* 저장소가 막힌 브라우저 — 지울 것이 없다. */ + } + const { purgeProject } = await import("./b_asset_cache"); + await purgeProject(projectId); +} + +/** 페이지가 알리는 떠남 셋(401) — 고침 있나 · 저장(false · 던짐 = 머묾) · 폐기. */ +export interface ProjectLeaveGuard { + dirty: () => boolean; + save: () => Promise; + discard?: () => void | Promise; + /** 물음 글 — 없으면 공용 */ + message?: string; + /** 같은 페이지로 보는 주소(B05 ↔ B06) — 그리로 가면 안 묻고 다시 걺 */ + samePage?: readonly string[]; +} + +/** 라우터의 떠남 물음 자리 — 라우터가 들 때 이어 줌(라우터 → 이 파일 한 방향 · 페이지 표를 안 끌게). */ +interface RouterLink { + currentRoute: () => string; + setLeaveGuard: (guard: (() => Promise) | null) => void; +} +let routerLink: RouterLink | null = null; +export function linkRouter(link: RouterLink): void { + routerLink = link; +} + +const guards: Array<{ root: HTMLElement; guard: ProjectLeaveGuard }> = []; +let unloadHooked = false; + +/** 화면에 붙은 가드만 — 빠진 페이지 것은 버림. */ +function liveGuards(): ProjectLeaveGuard[] { + for (let index = guards.length - 1; index >= 0; index -= 1) { + if (!guards[index].root.isConnected) guards.splice(index, 1); + } + return guards.map((entry) => entry.guard); +} + +/** 저장 안 한 고침이 있나 — 화면에 붙은 가드 모두. */ +export const projectLeaveDirty = (): boolean => liveGuards().some((guard) => guard.dirty()); + +/** 떠나도 되나 — 고친 가드를 모아 [취소 · 폐기 · 저장] 한 번 · true = 넘어감. */ +export async function askProjectLeave(): Promise { + const live = liveGuards(); + if (!live.length) return true; + const route = routerLink?.currentRoute() ?? ""; + if (live.some((guard) => guard.samePage?.includes(route))) { + // 라우터가 「넘어감」 뒤 물음을 내림 — 같은 페이지 안 이동이면 한 틱 뒤 다시 걺. + window.setTimeout(() => liveGuards().length && routerLink?.setLeaveGuard(askProjectLeave), 0); + return true; + } + const dirty = live.filter((guard) => guard.dirty()); + if (!dirty.length) return true; + const [{ leaveOrStay }, { t }] = await Promise.all([ + import("@ui/ui_template_leave_dialog"), + import("@ui/ui_template_locale"), + ]); + const message = (dirty.length === 1 && dirty[0].message) || t("Common_Leave_Unsaved"); + const got = await leaveOrStay(message, { + save: async () => { + for (const guard of dirty) { + if (guard.dirty() && (await guard.save()) === false) throw new Error("저장 못 함"); + } + }, + stillDirty: () => dirty.some((guard) => guard.dirty()), + discard: async () => { + for (const guard of dirty) await guard.discard?.(); + }, + }); + return got !== "stay"; +} + +/** 페이지를 세울 때 한 번 — `root` 가 화면에서 빠지면 스스로 풂 · 탭 닫기도 물음. */ +export function guardProjectLeave(root: HTMLElement, guard: ProjectLeaveGuard): void { + liveGuards(); // 빠진 옛 페이지를 붙들지 않음 + guards.push({ root, guard }); + if (!unloadHooked) { + unloadHooked = true; + window.addEventListener("beforeunload", (event) => { + if (!projectLeaveDirty()) return; + event.preventDefault(); + event.returnValue = ""; + }); + } + routerLink?.setLeaveGuard(askProjectLeave); +} diff --git a/A00_Common/b_workflow_nav.ts b/A00_Common/b_workflow_nav.ts index 844447241..d430656c7 100644 --- a/A00_Common/b_workflow_nav.ts +++ b/A00_Common/b_workflow_nav.ts @@ -1,9 +1,4 @@ -import { - API_BASE_URL, - CURRENT_PROJECT_ID_KEY, - ROUTES, - type RoutePath, -} from "@config/config_frontend"; +import { API_BASE_URL, ROUTES, type RoutePath } from "@config/config_frontend"; import { hideLoadingOverlay, setLoadingOverlayProgress, @@ -16,7 +11,12 @@ import { fetchSurfaceStatus, } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { isProjectPreloaded, setPreloadTarget } from "./b_asset_cache"; -import { enterProject } from "./b_project_switch"; +import { + currentProjectId, + enterProject, + forgetProject, + setCurrentProject, +} from "./b_project_switch"; import { navigateTo } from "./router"; export interface WorkflowState { @@ -77,7 +77,7 @@ export async function fetchWorkflowState(projectId: string): Promise { - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const projectId = currentProjectId(); if (!projectId) return false; try { await fetchWorkflowState(projectId); @@ -85,7 +85,7 @@ export async function dropGoneProject(): Promise { } catch (error) { const status = (error as { status?: number }).status; if (status !== 403 && status !== 404) return false; - localStorage.removeItem(CURRENT_PROJECT_ID_KEY); + forgetProject(projectId); return true; } } @@ -105,7 +105,7 @@ export function goToWorkflowStage(projectId: string, route: RoutePath): Promise< projectId, async (id) => (await fetchWorkflowState(id)).project_name || id, ).then(() => { - localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId); + setCurrentProject(projectId); // 단계가 바뀌어 이동하는 자리다 — 캐시를 버려 다음 화면이 새 상태를 본다. clearWorkflowStateCache(); if (!PRELOAD_REQUIRED_ROUTES.includes(route)) { diff --git a/A00_Common/design/design_leave.ts b/A00_Common/design/design_leave.ts index 55fdd5005..5d260a924 100644 --- a/A00_Common/design/design_leave.ts +++ b/A00_Common/design/design_leave.ts @@ -4,7 +4,7 @@ * 공용 물음(`ui_template_leave_dialog` · [취소] [폐기] [저장]). * * 고침 = 초안 통 칸 중 고침이 아닌 칸(고름 · 관 id 장부 · 종류 캐시 · 고른 구조물 넘김 · 소단 사본 = 구조물 파생) · - * 다른 페이지 칸(b08 · b09) · 저장 뒤에도 남기는 표준 횡단면(std-cross)을 뺀 것. + * 다른 페이지 칸(b08 · b09 · D · E) · 저장 뒤에도 남기는 표준 횡단면(std-cross)을 뺀 것. * 횡단 초안은 [저장] 뒤에도 세션에 남음 — 그래서 「있음」이 아니라 **마지막 깨끗한 때와 다름**으로 봄: * [저장] · [확정]이 끝나면 `markDesignClean` 이 그때 지문을 적음 · [초기화]는 초안을 다 지우고 새로 읽음. * B05 ↔ B06 는 한 페이지 — 그 사이 이동은 묻지 않음. @@ -12,9 +12,8 @@ * ========================================================================== */ import { ROUTES } from "@config/config_frontend"; -import { leaveOrStay } from "@ui/ui_template_leave_dialog"; import { t as L } from "@ui/ui_template_locale"; -import { listenWhileAlive } from "@ui/ui_template_window_watch"; +import { guardProjectLeave } from "../b_project_switch"; import { clearState, namesInBucket, @@ -23,7 +22,6 @@ import { stateKey, type StateName, } from "../b_page_state"; -import { currentRoute, setLeaveGuard } from "../router"; /** 초안 통이지만 고침으로 안 보는 칸 — 묻지 않고 [폐기]에도 안 지움. */ const NOT_EDITS = new Set([ @@ -34,7 +32,7 @@ const NOT_EDITS = new Set([ "std-cross", "berm", // 소단 사본 — 열 때 시설 줄이 구조물에서 씀(272 · 고침은 `structures` 칸) ]); -const OTHER_PAGE = /^b0[89]-/; +const OTHER_PAGE = /^(b0[89]|d0\d|e0\d)-/; export function editDraftNames(): StateName[] { return namesInBucket("draft").filter((name) => !NOT_EDITS.has(name) && !OTHER_PAGE.test(name)); @@ -93,10 +91,8 @@ export function discardDesignEdits(projectId: string): void { editDraftNames().forEach((name) => clearState(name, projectId)); } -const SAME_PAGE = new Set([ROUTES.B05_PROFILE, ROUTES.B06_SECTION]); - /** - * 페이지를 세울 때 한 번 — `root` 가 화면에서 빠지면 스스로 풂. + * 페이지를 세울 때 한 번 — `root` 가 화면에서 빠지면 스스로 풂(공용 `guardProjectLeave` 위 얇은 껍질). * `save` = [저장] 한 줄기(`saveSharedDesign(shared, false)`). */ export function guardDesignLeave( @@ -104,26 +100,11 @@ export function guardDesignLeave( root: HTMLElement, save: () => Promise, ): void { - const unload = (event: BeforeUnloadEvent): void => { - if (!root.isConnected || !designDirty(projectId)) return; - event.preventDefault(); - event.returnValue = ""; - }; - listenWhileAlive(window, "beforeunload", root, unload); // 다시 세운 옛 페이지를 안 붙듦(298-3) - 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); + guardProjectLeave(root, { + dirty: () => designDirty(projectId), + save, + discard: () => discardDesignEdits(projectId), + message: L("B05_Design_Leave"), + samePage: [ROUTES.B05_PROFILE, ROUTES.B06_SECTION], + }); } diff --git a/A00_Common/design/design_page.ts b/A00_Common/design/design_page.ts index dfff7f67f..86349548f 100644 --- a/A00_Common/design/design_page.ts +++ b/A00_Common/design/design_page.ts @@ -15,7 +15,8 @@ * 숨으면 3D 그리기 루프 · 포인터 듣기를 떼고, 보이면 가게 값으로 다시 그린다. * ========================================================================== */ -import { CURRENT_PROJECT_ID_KEY, ROUTES, type RoutePath } from "@config/config_frontend"; +import { ROUTES, type RoutePath } from "@config/config_frontend"; +import { currentProjectId } from "../b_project_switch"; import { showToast } from "@ui/ui_template_elements"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createWorkflowLayout, type WorkflowLayoutHandle } from "@ui/ui_template_workflow_layout"; @@ -124,7 +125,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage { const page = document.createElement("div"); page.className = "design-page"; root.replaceChildren(page); - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; + const projectId = currentProjectId(); pinDraftProject(projectId); // 연 프로젝트 고정(388-3) — 다른 탭이 바꿔도 이 탭 초안 칸 그대로 void openCalcSites(projectId); // 계산 자리 판(504-2) — 열 때 한 번 · 열린 동안 바뀐 판은 다음 열기부터 usePipeIds(projectId || null); // 관 세션 id 장부 = 이 프로젝트(323-5 · 카드 · 알약 · 종단 · 3D 같은 id) diff --git a/A00_Common/design/design_store.ts b/A00_Common/design/design_store.ts index c1b6e0540..ac058ec3b 100644 --- a/A00_Common/design/design_store.ts +++ b/A00_Common/design/design_store.ts @@ -23,7 +23,7 @@ import { onStateRemoved, readState, stateKey, writeState } from "../b_page_state"; import type { StateName } from "../b_page_state"; -import { CURRENT_PROJECT_ID_KEY } from "../../config/config_frontend"; +import { currentProjectId } from "../b_project_switch"; import type { StructureInstance } from "../../B05_Profile/B05_Profile_Api_Structures"; import type { DetailPipeInput } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { PageSettingsBody } from "../../B05_Profile/B05_Profile_Api_Fetch"; @@ -91,11 +91,7 @@ export function pinDraftProject(projectId: string | null): void { export function draftProjectId(projectId: string | null = null): string { if (projectId) return projectId; if (openedProjectId) return openedProjectId; - try { - return window.localStorage.getItem(CURRENT_PROJECT_ID_KEY) || "-"; - } catch { - return "-"; - } + return currentProjectId() || "-"; } interface Slot { diff --git a/A00_Common/group_workflow.ts b/A00_Common/group_workflow.ts index 9bdda4a68..f6d86daf2 100644 --- a/A00_Common/group_workflow.ts +++ b/A00_Common/group_workflow.ts @@ -9,7 +9,6 @@ import { API_BASE_URL, - CURRENT_PROJECT_ID_KEY, PLAN_STEP_ROUTES, STAB_STEP_ROUTES, type RoutePath, @@ -17,6 +16,7 @@ import { import { el, hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; +import { guardProjectLeave, setCurrentProject, type ProjectLeaveGuard } from "./b_project_switch"; import { navigateTo, setLeaveGuard, type PageRenderer } from "./router"; interface GroupFlow { @@ -60,15 +60,18 @@ export function mountGroupStep( parts: { mainContent?: HTMLElement; leftPanel?: HTMLElement; - /** 떠나기 전 — 저장 등 · false = 머묾 */ + /** 옛 떠남(저장 등 · false = 머묾) — 페이지가 dirty · save 로 옮기면 지움 */ beforeLeave?: () => Promise; - } = {}, + } & Partial = {}, ): void { const flow = FLOWS.find((f) => f.routes.includes(route)); if (!flow) throw new Error(`단계 묶음 밖 주소: ${route}`); const active = flow.routes.indexOf(route); const leave = parts.beforeLeave; if (leave) setLeaveGuard(async () => !root.isConnected || (await leave())); + // 떠남 셋(401) — 단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로 모두 라우터 물음 한 자리([취소 · 폐기 · 저장]) + const { dirty, save, discard, message } = parts; + if (dirty && save) guardProjectLeave(root, { dirty, save, discard, message }); const layout = createWorkflowLayout({ title: t(flow.labels[active]), steps: flow.labels.map((key) => t(key)), @@ -114,7 +117,7 @@ export async function createGroupProject( const detail = typeof data.detail === "string" ? data.detail : `HTTP ${response.status}`; throw new Error(detail); } - localStorage.setItem(CURRENT_PROJECT_ID_KEY, data.project_id); + setCurrentProject(data.project_id); showToast(t("DE_Reg_Success"), "success"); navigateTo(firstStep); } catch (error) { diff --git a/A00_Common/router.ts b/A00_Common/router.ts index c5680ec2d..51a766a3e 100644 --- a/A00_Common/router.ts +++ b/A00_Common/router.ts @@ -8,6 +8,7 @@ * ========================================================================== */ import { ROUTES, DEFAULT_ROUTE, PROTECTED_ROUTES, type RoutePath } from "@config/config_frontend"; +import { linkRouter, reloadIfOtherProject } from "./b_project_switch"; /** * 여러 주소를 맡는 페이지 — 같은 페이지 안 주소 이동은 다시 만들지 않고 `show` 만 부른다 @@ -103,6 +104,8 @@ export const setLeaveGuard = (guard: (() => Promise) | null): void => { leaveGuard = guard; }; export const takeLeaveGuard = (): (() => Promise) | null => leaveGuard; +// 공용 떠남 물음(401)이 이 자리를 씀 — 함수 선언은 끌어올려져 아래 currentRoute 도 바로 씀 +linkRouter({ currentRoute, setLeaveGuard }); /** 현재 프로젝트(기억한 id)로 여는 화면 — 그 프로젝트가 서버에 없으면 안내를 띄운다. */ const PROJECT_ROUTES: readonly RoutePath[] = [ @@ -116,6 +119,10 @@ const PROJECT_ROUTES: readonly RoutePath[] = [ ROUTES.B11_LOADING, ]; +/** 프로젝트 화면(401) — B 단계 · 준비 화면 · 초안 길 `project: true`. */ +export const isProjectRoute = (route: string): boolean => + PROJECT_ROUTES.includes(route as RoutePath) || Boolean(draftRoute(route)?.project); + /** 지금 떠 있는 여러 주소 페이지 · 렌더 차례(늦게 끝난 옛 렌더를 버림). */ let mounted: MountedPage | null = null; let renderSeq = 0; @@ -222,7 +229,9 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise { } // 기억한 현재 프로젝트가 지워졌으면 페이지 대신 안내 — 모든 B 단계 화면이 여기 한 곳을 지난다. - if (PROJECT_ROUTES.includes(route) || draft?.project) { + if (isProjectRoute(route)) { + // 다른 프로젝트 화면이면 새로 엶 — 앞 프로젝트 모듈 메모리가 안 묻어감(401) + if (reloadIfOtherProject()) return; const { dropGoneProject } = await import("./b_workflow_nav"); if (await dropGoneProject()) { if (seq !== renderSeq) return; diff --git a/resources/tester/helper_290_1_project_purge.cjs b/resources/tester/helper_290_1_project_purge.cjs index ac4d147cb..df77196ae 100644 --- a/resources/tester/helper_290_1_project_purge.cjs +++ b/resources/tester/helper_290_1_project_purge.cjs @@ -55,6 +55,8 @@ const SEED = { [`aislo:draft:pipes:${C}`]: "{}", // 빈 초안 = 고침 없음 [`b09:page-cache:${C}`]: "1", // 등록표 밖 페이지 칸 "aislo.otp-pending": "x", // 프로젝트와 무관 + [`d02_points_${A}`]: '{"base":1}', // D02 옛 키(401 · 등록표 옛 키 머리로 잡음) + [`e03_prob_cache:${C}`]: "{}", // E 옛 키 · 빈 값 = 고침 없음 }; const seed = (extra = {}) => { global.window.sessionStorage = fakeStorage(); diff --git a/resources/tester/helper_298_3_page_dispose.cjs b/resources/tester/helper_298_3_page_dispose.cjs index 77f5449cd..067326ed5 100644 --- a/resources/tester/helper_298_3_page_dispose.cjs +++ b/resources/tester/helper_298_3_page_dispose.cjs @@ -58,6 +58,8 @@ const { loadTs } = makeLoader({ }); const drag = loadTs(path.join(ROOT, "ui_template", "ui_template_overlay_drag.ts")); +// 공용 떠남 가드(401) — 라우터 흉내를 이어 둠(같은 한 벌을 design_leave 가 부름) +loadTs(path.join(ROOT, "A00_Common", "b_project_switch.ts")).linkRouter(STUBS["../router"]); const leave = loadTs(path.join(ROOT, "A00_Common", "design", "design_leave.ts")); let settles = 0; diff --git a/resources/tester/helper_343_3_asset_cache.cjs b/resources/tester/helper_343_3_asset_cache.cjs index 73d7df13a..34afa58a0 100644 --- a/resources/tester/helper_343_3_asset_cache.cjs +++ b/resources/tester/helper_343_3_asset_cache.cjs @@ -20,6 +20,7 @@ global.IDBKeyRange = { upperBound: (v, open) => ({ test: (k) => (open ? k < v : k <= v) }), lowerBound: (v, open) => ({ test: (k) => (open ? k > v : k >= v) }), bound: (lo, hi) => ({ test: (k) => k >= lo && k <= hi }), + only: (v) => ({ test: (k) => k === v }), }; function makeIdb(options = {}) { @@ -163,7 +164,11 @@ const fresh = (options) => { }; const text = (buffer) => new TextDecoder().decode(buffer); +// 401 시험이 가짜 IndexedDB 만 빌려 씀 — 그때는 아래 차례를 안 돌림 +module.exports = { makeIdb }; + (async () => { + if (require.main !== module) return; const out = {}; // 1. 받고 저장 → 두 번째는 보관본(네트워크는 뒤 확인 한 번뿐) diff --git a/resources/tester/helper_346_4_design_leave.cjs b/resources/tester/helper_346_4_design_leave.cjs index 16a4eb96f..abdab8f9e 100644 --- a/resources/tester/helper_346_4_design_leave.cjs +++ b/resources/tester/helper_346_4_design_leave.cjs @@ -71,6 +71,10 @@ const { loadTs } = makeLoader({ }, }); +// 공용 떠남 가드(401) — 라우터 흉내를 이어 둔 한 벌을 design_leave 에 줌 +const projectSwitch = loadTs(path.join(ROOT, "A00_Common", "b_project_switch.ts")); +projectSwitch.linkRouter(STUBS["../router"]); +STUBS["../b_project_switch"] = projectSwitch; const leave = loadTs(path.join(ROOT, "A00_Common", "design", "design_leave.ts")); const P = "p1"; const tick = () => new Promise((resolve) => setTimeout(resolve, 5)); diff --git a/resources/tester/helper_384_s_session.cjs b/resources/tester/helper_384_s_session.cjs index e16d6c389..78909a5d1 100644 --- a/resources/tester/helper_384_s_session.cjs +++ b/resources/tester/helper_384_s_session.cjs @@ -57,9 +57,10 @@ const sw = load("A00_Common/b_project_switch.ts", (id) => { if (id.includes("config_frontend")) return config; return { showConfirmDialog: async () => true }; }); -const store = load("A00_Common/design/design_store.ts", (id) => - id.includes("b_page_state") ? pageState : config, -); +const store = load("A00_Common/design/design_store.ts", (id) => { + if (id.includes("b_project_switch")) return sw; // 지금 프로젝트 = 탭 칸 먼저(401) + return id.includes("b_page_state") ? pageState : config; +}); const P = "aaaa1111"; const seed = () => { diff --git a/resources/tester/helper_401_project_isolation.cjs b/resources/tester/helper_401_project_isolation.cjs new file mode 100644 index 000000000..38da3bbd3 --- /dev/null +++ b/resources/tester/helper_401_project_isolation.cjs @@ -0,0 +1,217 @@ +/* 401 프로젝트마다 따로 동작 — 진짜 TS(b_page_state · b_project_switch · b_asset_cache)를 가짜 저장소 · + * 가짜 IndexedDB 위에서 돌려 JSON 으로 냄. 탭 둘 = 세션 둘 · localStorage 하나 · 모듈 한 벌씩. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const { makeIdb } = require("./helper_343_3_asset_cache.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); + +function fakeStorage() { + const map = new Map(); + return { + get length() { + return map.size; + }, + key: (i) => [...map.keys()][i] ?? null, + getItem: (k) => (map.has(k) ? map.get(k) : null), + setItem: (k, v) => map.set(k, String(v)), + removeItem: (k) => map.delete(k), + dump: () => Object.fromEntries(map), + }; +} + +const shared = { localStorage: fakeStorage(), idb: makeIdb() }; +const router = { route: "/b07-design-detail", guard: null, sets: 0 }; +let choice = "save"; +const asked = []; + +/** 탭 하나 = 창 하나 · 모듈 한 벌(모듈 메모리 따로). */ +function openTab() { + const win = new EventTarget(); + win.sessionStorage = fakeStorage(); + win.localStorage = shared.localStorage; + win.indexedDB = shared.idb.indexedDB; + win.setTimeout = setTimeout; + win.reloads = 0; + win.location = { reload: () => (win.reloads += 1) }; + const cache = new Map(); + const load = (rel) => { + if (cache.has(rel)) return cache.get(rel); + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, rel), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const exports = {}; + cache.set(rel, exports); + new Function("exports", "require", "window", js)(exports, need, win); + return exports; + }; + const need = (id) => { + if (id.includes("config_frontend")) + return { CURRENT_PROJECT_ID_KEY: "frd_current_project_id", API_BASE_URL: "http://x/api" }; + if (id.endsWith("b_page_state")) return load("A00_Common/b_page_state.ts"); + if (id.endsWith("b_asset_cache")) return load("A00_Common/b_asset_cache.ts"); + if (id.endsWith("ui_template_locale")) return { t: (key) => key }; + if (id.endsWith("ui_template_leave_dialog")) + return { + leaveOrStay: async (message, hooks) => { + asked.push(message); + if (choice === "cancel") return "stay"; + if (choice === "discard") return (await hooks.discard(), "discarded"); + try { + await hooks.save(); + } catch { + return "stay"; + } + return hooks.stillDirty() ? "stay" : "saved"; + }, + }; + if (id.endsWith("ui_template_elements")) return { showConfirmDialog: async () => true }; + return {}; // B04 · B05 받기(보관함이 부르지 않음) + }; + const sw = load("A00_Common/b_project_switch.ts"); + sw.linkRouter({ + currentRoute: () => router.route, + setLeaveGuard: (fn) => ((router.guard = fn), (router.sets += 1)), + }); + return { win, sw }; +} + +const X = "11111111-aaaa-4aaa-8aaa-000000000001"; +const Y = "22222222-bbbb-4bbb-8bbb-000000000002"; +/** 브라우저 BeforeUnloadEvent 처럼 returnValue 를 쓸 수 있게. */ +class BeforeUnload extends Event { + returnValue = true; +} +const tick = () => new Promise((resolve) => setTimeout(resolve, 5)); +const keys = (win) => Object.keys(win.sessionStorage.dump()).sort(); + +(async () => { + const out = {}; + + // 1. 탭 둘 — B 가 Y 로 바꿔도 A 는 X · 새 탭은 마지막 값(Y) + shared.localStorage.setItem("frd_current_project_id", X); + const A = openTab(); + out.newTabStartsWithLast = A.sw.currentProjectId(); + await A.sw.enterProject(X, async (id) => id); + const B = openTab(); + await B.sw.enterProject(Y, async (id) => id); + out.tabs = { + a: A.sw.currentProjectId(), + b: B.sw.currentProjectId(), + fresh: openTab().sw.currentProjectId(), + last: shared.localStorage.getItem("frd_current_project_id"), + }; + + // 2. 다른 프로젝트 화면을 그리려 하면 새로 엶 — 같은 프로젝트는 그대로 + out.reload = { first: A.sw.reloadIfOtherProject(), same: A.sw.reloadIfOtherProject() }; + A.sw.setCurrentProject(Y); + out.reload.other = A.sw.reloadIfOtherProject(); + out.reload.count = A.win.reloads; + A.sw.setCurrentProject(X); + + // 3. 떠남 — 그 프로젝트 칸 · 준비 표식 · 보관함 몫만 · 밖 칸 · 취향 · CAD 복구본 그대로 + const seedX = [ + `aislo:draft:structures:${X}`, + `aislo:draft:crossw:${X}:12`, + `aislo:result:section-detail:${X}:12`, + `b06:culvertopt:${X}:12`, + `aislo:draft:d03-plan:${X}`, + `aislo:draft:d04-assignees:${X}`, + `aislo:draft:e05-report:${X}`, + `d02_points_${X}`, + `e02_ground_cache:${X}`, + `e03_prob_cache:${X}`, + `e04_settings:${X}`, + `e06_rock_cache:${X}`, + `e07_settings:${X}`, + ]; + const keep = [ + `aislo:draft:structures:${Y}`, + "a06:otp-pending", + "aislo.adminCompany", + "aislo.provenance.tint", + "m01_master_draft", + "m02-hopyo-check:3", + "aislo:entered-project", + ]; + seedX.forEach((k) => A.win.sessionStorage.setItem(k, '{"v":1}')); + keep.slice(0, -1).forEach((k) => A.win.sessionStorage.setItem(k, "1")); + A.win.sessionStorage.setItem("frd_preloaded_signature", `${X}|sig`); + const localKeep = [`aislo:pref:routeedit-view:${X}`, `OPEN_WEB_CAD__RECOVERY__b07:${X}:1`]; + localKeep.forEach((k) => shared.localStorage.setItem(k, "1")); + for (const [pid, url] of [ + [X, "u1"], + [X, "u2"], + [Y, "u1"], + ]) { + shared.idb.data.set(`${pid}|${url}`, { key: `${pid}|${url}`, projectId: pid, url }); + } + await A.sw.leaveProject(X); + out.leave = { + keys: keys(A.win), + expected: [...keep].sort(), + entered: A.win.sessionStorage.getItem("aislo:entered-project"), + local: localKeep.map((k) => shared.localStorage.getItem(k)), + idb: [...shared.idb.data.keys()].sort(), + }; + // 다른 탭(B) 세션은 안 건드림 + B.win.sessionStorage.setItem(`aislo:draft:structures:${X}`, "1"); + await A.sw.leaveProject(X); + out.leave.otherTabKept = B.win.sessionStorage.getItem(`aislo:draft:structures:${X}`); + + // 4. 떠남 물음 — 고친 가드만 모아 한 번 · 저장 실패 = 머묾 · 폐기 · 같은 페이지 · 빠진 페이지 + const root1 = { isConnected: true }; + const root2 = { isConnected: true }; + const state = { d1: false, d2: false, saves: [], discards: [], saveOk: true }; + A.sw.guardProjectLeave(root1, { + dirty: () => state.d1, + save: async () => (state.saves.push(1), state.saveOk && (state.d1 = false), state.saveOk), + discard: () => state.discards.push(1), + }); + A.sw.guardProjectLeave(root2, { + dirty: () => state.d2, + save: async () => (state.saves.push(2), (state.d2 = false), true), + message: "둘째 글", + }); + await tick(); + const ask = router.guard; + const unloadBlocked = () => { + const event = new BeforeUnload("beforeunload", { cancelable: true }); + A.win.dispatchEvent(event); + return event.defaultPrevented; + }; + out.guard = { armed: ask === A.sw.askProjectLeave, cleanLeave: await ask() }; + out.guard.cleanUnload = unloadBlocked(); + out.guard.asked0 = asked.length; + state.d2 = true; + out.guard.dirtyUnload = unloadBlocked(); + choice = "cancel"; + out.guard.cancel = await ask(); + choice = "save"; + out.guard.save = await ask(); + out.guard.messages = [...asked]; + state.d1 = true; + state.saveOk = false; + out.guard.saveFail = await ask(); + choice = "discard"; + out.guard.discard = await ask(); + out.guard.saves = state.saves; + out.guard.discards = state.discards.length; + router.route = "/b06-section"; + A.sw.guardProjectLeave( + { isConnected: true }, + { dirty: () => true, save: async () => true, samePage: ["/b05-profile", "/b06-section"] }, + ); + await tick(); + const sets = router.sets; + const askedBefore = asked.length; + out.guard.samePage = await ask(); + await tick(); + out.guard.reArmed = router.sets === sets + 1; + out.guard.samePageAsked = asked.length - askedBefore; + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); +}); diff --git a/resources/tester/helper_520_3_move_stage.cjs b/resources/tester/helper_520_3_move_stage.cjs index 491031661..91b9f8e0f 100644 --- a/resources/tester/helper_520_3_move_stage.cjs +++ b/resources/tester/helper_520_3_move_stage.cjs @@ -17,7 +17,10 @@ const stubs = { }, "../B04_PreProcess/B04_PreProcess_Api_Fetch": {}, "./b_asset_cache": {}, - "./b_project_switch": { enterProject: async () => calls.push("enter") }, + "./b_project_switch": { + enterProject: async () => calls.push("enter"), + setCurrentProject: () => undefined, // 탭 프로젝트(401) + }, "./router": { navigateTo: (route) => calls.push(`go:${route}`) }, }; const js = ts.transpileModule( diff --git a/resources/tester/test_290_1_project_purge.py b/resources/tester/test_290_1_project_purge.py index 5b0e27f4f..d23b0ecd9 100644 --- a/resources/tester/test_290_1_project_purge.py +++ b/resources/tester/test_290_1_project_purge.py @@ -42,14 +42,19 @@ def test_지우고_열기_다른_프로젝트_모두_지움(out): def test_남기기_그_프로젝트_초안만_둠(out): - kept = [f"aislo:draft:crossw:{A}:12", f"aislo:draft:structures:{A}", f"b06:culvertopt:{A}:12"] + kept = [ + f"aislo:draft:crossw:{A}:12", + f"aislo:draft:structures:{A}", + f"b06:culvertopt:{A}:12", + f"d02_points_{A}", # D02 옛 키도 초안(401) + ] assert out["keep"]["keys"] == sorted(MINE + UNRELATED + kept) assert out["keep"]["asked"] == 1 def test_같은_프로젝트_다시_들어오면_안_지움(out): assert out["same"]["asked"] == 0 and out["same"]["purged"] == 0 - assert len(out["same"]["keys"]) == 11 + assert len(out["same"]["keys"]) == 13 def test_빈_초안은_고침_아님_취향은_둠(out): diff --git a/resources/tester/test_298_3_page_dispose.py b/resources/tester/test_298_3_page_dispose.py index 927f1c65b..397d65697 100644 --- a/resources/tester/test_298_3_page_dispose.py +++ b/resources/tester/test_298_3_page_dispose.py @@ -46,7 +46,6 @@ def test_옛_페이지_풀림_지금_페이지는_들음(out) -> None: @pytest.mark.parametrize( ("rel", "types"), [ - ("A00_Common/design/design_leave.ts", "beforeunload"), ("ui_template/ui_template_overlay_drag.ts", "pointermove|pointerup|pointercancel|resize"), ( "ui_template/ui_template_side_panels.ts", @@ -76,3 +75,10 @@ def test_코리도_캐시는_지금_노선_한_칸() -> None: in code ) assert code.count("cache.set(") == 1 + + +def test_떠남_가드는_탭_닫기_듣개_한_번만() -> None: + # 401 — 떠남 가드가 공용으로 옮김 · 듣개는 모듈에 한 번 · 페이지 노드는 가드 목록에서 빠지면 버림 + code = _read("A00_Common/b_project_switch.ts") + assert "if (!unloadHooked) {" in code and code.count('addEventListener("beforeunload"') == 1 + assert "liveGuards(); // 빠진 옛 페이지를 붙들지 않음" in code diff --git a/resources/tester/test_302_1_print_frame.py b/resources/tester/test_302_1_print_frame.py index 9dd193563..a037cf21c 100644 --- a/resources/tester/test_302_1_print_frame.py +++ b/resources/tester/test_302_1_print_frame.py @@ -95,7 +95,9 @@ def test_초안_길은_선언으로_등록_정적_표엔_이름_없음(): in router ) assert "routeTable[route] ?? draft?.load" in router - assert "draft?.workflow" in router and "draft?.project" in router + assert ( + "draft?.workflow" in router and "draftRoute(route)?.project" in router + ) # 401 isProjectRoute assert "draftRoute(currentRoute())?.footerless" in _text(ROOT / "A00_Common/main.ts") route = _text(DRAFT / "B09_Draft_Print_Route.ts") assert 'path: "b09-print"' in route and "workflow: true" in route and "project: true" in route diff --git a/resources/tester/test_401_project_isolation.py b/resources/tester/test_401_project_isolation.py new file mode 100644 index 000000000..fb8a5f388 --- /dev/null +++ b/resources/tester/test_401_project_isolation.py @@ -0,0 +1,112 @@ +"""401 프로젝트마다 따로 동작(요구 400~403 · PLAN 572). + +- 지금 프로젝트 = 탭마다(탭 세션 칸 먼저 · 새 탭 = 마지막 값) · 다른 프로젝트 화면을 그리려 하면 새로 엶 +- 떠남 = 그 프로젝트 세션 칸 · 준비 표식 · 3D 보관함 몫만 지움 · 밖 칸 · 취향 · CAD 복구본 · 다른 탭은 그대로 +- 떠남 물음 = 화면에 붙은 가드 {dirty, save} 를 모아 [취소 · 폐기 · 저장] 한 번 +`helper_401_project_isolation.cjs` 가 진짜 TS 를 가짜 저장소 · 가짜 IndexedDB 위에서 돌림. +""" + +import json +import os +import re +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HERE = os.path.dirname(os.path.abspath(__file__)) +X = "11111111-aaaa-4aaa-8aaa-000000000001" +Y = "22222222-bbbb-4bbb-8bbb-000000000002" + +#: 아직 직접 읽는 B · D · E 페이지(572-2 · 572-3 창 몫) — 고치면 여기서 뺌. +PENDING = { + "B03_FileInput/B03_FileInput_UI_Page.ts", + "B03_FileInput/B03_FileInput_UI_Page_Flow.ts", + "B03_FileInput/B03_FileInput_UI_PlanImport.ts", + "B04_PreProcess/B04_PreProcess_UI_Page.ts", + "B07_DesignDetail/B07_DesignDetail_UI_Page.ts", + "B08_Quantity/B08_Quantity_UI_Page.ts", + "B09_Estimation/B09_Estimation_UI_Haul_Modal.ts", + "B09_Estimation/B09_Estimation_UI_Shell.ts", + "B09_Estimation/B09_Estimation_UI_Tabs_Base_Quote.ts", + "B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts", + "B09_Estimation/draft/B09_Draft_Print_Page.ts", + "B11_Status/B11_Status_UI_Loading.ts", + "D02_PlanBase/D02_PlanBase_UI_Page.ts", + "D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts", + "D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts", + "E02_StabGround/E02_StabGround_UI_Page.ts", + "E03_StabSection/E03_StabSection_UI_Page.ts", + "E04_Stab3D/E04_Stab3D_UI_Page.ts", + "E05_StabReport/E05_StabReport_UI_Page.ts", + "E06_StabRock/E06_StabRock_UI_Page.ts", + "E07_StabArea/E07_StabArea_UI_Page.ts", +} + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_401_project_isolation.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_탭마다_지금_프로젝트(out): + assert out["newTabStartsWithLast"] == X + assert out["tabs"] == {"a": X, "b": Y, "fresh": Y, "last": Y} + + +def test_다른_프로젝트_화면은_새로_엶(out): + assert out["reload"] == {"first": False, "same": False, "other": True, "count": 1} + + +def test_떠남은_그_프로젝트_몫만_지움(out): + leave = out["leave"] + assert leave["keys"] == leave["expected"] # B · D · E · 옛 키 · 준비 표식 0 · 밖 칸 그대로 + assert leave["entered"] == X # 탭 프로젝트 표식은 남김 + assert leave["local"] == ["1", "1"] # 취향 · CAD 복구본 + assert leave["idb"] == [f"{Y}|u1"] # 보관함 = X 줄만 지움 + assert leave["otherTabKept"] == "1" # 다른 탭 세션은 안 건드림 + + +def test_떠남_물음_한_번(out): + g = out["guard"] + assert g["armed"] and g["cleanLeave"] and not g["cleanUnload"] and g["asked0"] == 0 + assert g["dirtyUnload"] and g["cancel"] is False and g["save"] is True + assert g["messages"] == ["둘째 글", "둘째 글"] # 고친 가드 하나 = 그 글 + assert g["saveFail"] is False and g["discard"] is True + assert g["saves"] == [2, 1] and g["discards"] == 1 # 고친 가드만 저장 + assert g["samePage"] and g["reArmed"] and g["samePageAsked"] == 0 + + +def test_지금_프로젝트_직접_읽기는_한_곳(): + hits = set() + tracked = subprocess.run( + ["git", "ls-files", "*.ts"], cwd=ROOT, capture_output=True, text=True, check=True + ).stdout.split() # storage · node_modules 는 훑지 않음 + for rel in tracked: + text = (ROOT / rel).read_text(encoding="utf-8", errors="ignore") + if re.search(r"localStorage\.getItem\(\s*CURRENT_PROJECT_ID_KEY", text): + hits.add(rel) + assert hits - PENDING == {"A00_Common/b_project_switch.ts"} - PENDING + assert "A00_Common/b_project_switch.ts" in hits + + +def test_공용이_도우미를_씀(): + router = (ROOT / "A00_Common/router.ts").read_text(encoding="utf-8") + assert "export const isProjectRoute" in router and "reloadIfOtherProject()" in router + group = (ROOT / "A00_Common/group_workflow.ts").read_text(encoding="utf-8") + assert "guardProjectLeave(root, { dirty, save, discard, message })" in group + leave = (ROOT / "A00_Common/design/design_leave.ts").read_text(encoding="utf-8") + assert "guardProjectLeave(root, {" in leave + state = (ROOT / "A00_Common/b_page_state.ts").read_text(encoding="utf-8") + for name in ("d02-points", "e02-ground", "e03-prob", "e04-settings", "e05-report"): + assert f'"{name}": {{ bucket: "draft", scope: "project"' in state + assert '"e06-rock"' in state and '"e07-settings"' in state diff --git a/ui_template/ui_template_locale_common.ts b/ui_template/ui_template_locale_common.ts index de94262a0..7c9615f06 100644 --- a/ui_template/ui_template_locale_common.ts +++ b/ui_template/ui_template_locale_common.ts @@ -20,6 +20,7 @@ export const ui_locales_common = { * ------------------------------------------------------------------------ */ Common_Btn_Confirm: ["확인", "Confirm"], Common_Btn_Cancel: ["취소", "Cancel"], + Common_Leave_Unsaved: ["저장 안 한 고침이 있음", "Unsaved edits"], Common_Btn_Save: ["저장", "Save"], Common_Btn_Delete: ["삭제", "Delete"], Common_Btn_Edit: ["수정", "Edit"], diff --git a/ui_template/ui_template_overlay.ts b/ui_template/ui_template_overlay.ts index 26bddec98..5b43c7c57 100644 --- a/ui_template/ui_template_overlay.ts +++ b/ui_template/ui_template_overlay.ts @@ -13,13 +13,13 @@ import "./ui_template_overlay.css"; // 옛 목록 줄(`.ui-sidebar-row` · M01 · M02 왼쪽 목록) 겉 = 목록 줄 부품 import "./ui_template_list_row.css"; -import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { t } from "./ui_template_locale"; import { placeHelpButton } from "./ui_template_help"; import { makePanelDraggable } from "./ui_template_overlay_drag"; // 워크플로 상태는 공용 창구 하나로 받는다 — 화면마다 따로 부르면 진입에서 같은 답을 // 두 번 받는다(2026-09-06 실측). 그 창구가 짧은 시간 동안 캐시한다. import { fetchWorkflowState } from "../A00_Common/b_workflow_nav"; +import { currentProjectId } from "../A00_Common/b_project_switch"; import { readByKey, writeByKey } from "../A00_Common/b_page_state"; import { SIZE_BREAKS } from "../A00_Common/device_context"; @@ -162,7 +162,7 @@ export function splitSidebarActions(body: HTMLElement): void { function createProjectNameTag(): HTMLElement { const tag = document.createElement("span"); tag.className = "ui-workflow-overlay__project"; - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const projectId = currentProjectId(); if (!projectId) return tag; void fetchWorkflowState(projectId) .then((state) => {