From 0bf50a1837ee7d782fa9dc2087926ddf7181ad96 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 12 Oct 2026 20:44:18 +0900 Subject: [PATCH] =?UTF-8?q?feat(common):=20=EB=8B=A4=EB=A5=B8=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EC=97=B4=EB=A9=B4=20=EA=B7=B8=20?= =?UTF-8?q?=EB=B0=96=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EC=A7=80=EC=9B=80=20=C2=B7=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=95=88=20=ED=95=9C=20=EA=B3=A0=EC=B9=A8=EC=9D=80=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=EC=B0=BD=20(290-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - b_project_switch.enterProject — 공용 goToWorkflowStage 에서 부름 · 탭마다 들어온 표식이 다를 때만 - 다른 프로젝트 세션 칸(초안 · 결과 · 옛 키 · 키 마디에 번호가 든 페이지 칸) + 3D 보관함 지움 · 지금 프로젝트 · 계정 취향 그대로 - 다른 프로젝트에 저장 안 한 고침이 있으면 확인창(지우고 열기 / 남기기) · 남기기 = 그 초안만 둠 - showConfirmDialog 취소 단추 글 받는 칸(기본 「취소」) - 시험 test_290_1(진짜 TS · 가짜 저장소) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- A00_Common/b_project_switch.ts | 122 ++++++++++++++++++ A00_Common/b_workflow_nav.ts | 23 ++-- .../tester/helper_290_1_project_purge.cjs | 90 +++++++++++++ resources/tester/test_290_1_project_purge.py | 64 +++++++++ ui_template/ui_template_elements.ts | 12 +- 5 files changed, 301 insertions(+), 10 deletions(-) create mode 100644 A00_Common/b_project_switch.ts create mode 100644 resources/tester/helper_290_1_project_purge.cjs create mode 100644 resources/tester/test_290_1_project_purge.py diff --git a/A00_Common/b_project_switch.ts b/A00_Common/b_project_switch.ts new file mode 100644 index 000000000..3ffaef671 --- /dev/null +++ b/A00_Common/b_project_switch.ts @@ -0,0 +1,122 @@ +/* ============================================================================= + * A00_Common/b_project_switch.ts + * 다른 프로젝트에 들어갈 때 **그 밖 프로젝트의 캐시**를 지운다(계획서 290-1 · 요구 183-1). + * + * 지우는 것 — 이 탭 세션의 다른 프로젝트 칸(초안 · 결과 · 고름 · 옛 키 · 페이지별 칸)과 + * 3D 지형 보관함(IndexedDB). 남기는 것 — 지금 프로젝트 것 · 계정 취향(pref, localStorage). + * 다른 프로젝트에 저장 안 한 고침(초안)이 있으면 확인창 — 「남기기」면 그 초안만 둔다. + * 같은 프로젝트로 다시 들어오면(새로고침 포함) 아무것도 안 지운다(탭마다 들어온 표식). + * ========================================================================== */ + +import { showConfirmDialog } from "@ui/ui_template_elements"; +import { purgeOtherProjects } from "./b_asset_cache"; +import { STATE_REGISTRY, type StateEntry } from "./b_page_state"; + +/** 이 탭이 마지막으로 들어온 프로젝트. */ +const ENTERED_KEY = "aislo:entered-project"; + +const MARK_P = "\u0001"; +const MARK_R = "\u0002"; + +/** 프로젝트를 가르는 키 머리 — 등록표 키(`aislo:{통}:{이름}:`)와 옛 키 머리. 초안 여부를 함께. */ +function projectPrefixes(): Array<{ prefix: string; draft: boolean }> { + const out: Array<{ prefix: string; draft: boolean }> = []; + Object.entries(STATE_REGISTRY as Record).forEach(([name, entry]) => { + if (entry.bucket === "pref" || entry.scope === "global") return; + const draft = entry.bucket === "draft"; + const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : ""; + out.push({ prefix: `aislo:${entry.bucket}:${name}${suffix}:`, draft }); + const legacy = entry.legacy?.(MARK_P, MARK_R); + const at = legacy?.indexOf(MARK_P) ?? -1; + if (legacy && at > 0) out.push({ prefix: legacy.slice(0, at), draft }); + }); + return out; +} + +const filled = (value: string | null): boolean => + value !== null && value !== "" && value !== "{}" && value !== "[]"; + +export interface ProjectPurgePlan { + /** 저장 안 한 고침이 남은 다른 프로젝트. */ + draftProjects: string[]; + /** 지울 키 — `keep` 프로젝트의 초안은 뺀다. */ + doomed: (keep: ReadonlySet) => string[]; +} + +/** 세션 칸(키 → 값)에서 다른 프로젝트 몫을 가른다 — 저장소를 만지지 않는다. */ +export function planProjectPurge( + session: Record, + projectId: string, +): ProjectPurgePlan { + const prefixes = projectPrefixes(); + const owners = new Map(); + Object.keys(session).forEach((key) => { + const hit = prefixes.find((item) => key.startsWith(item.prefix)); + if (!hit) return; + const project = key.slice(hit.prefix.length).split(":")[0]; + if (project) owners.set(key, { project, draft: hit.draft }); + }); + const others = new Set( + [...owners.values()].map((owner) => owner.project).filter((id) => id !== projectId), + ); + const draftProjects = [ + ...new Set( + [...owners.entries()] + .filter(([key, owner]) => owner.draft && others.has(owner.project) && filled(session[key])) + .map(([, owner]) => owner.project), + ), + ]; + return { + draftProjects, + // 등록표 밖 칸도 키 마디에 다른 프로젝트 번호가 있으면 그 프로젝트 몫으로 본다. + doomed: (keep) => + Object.keys(session).filter((key) => { + const owner = owners.get(key); + if (owner) return others.has(owner.project) && !(owner.draft && keep.has(owner.project)); + return key.split(":").some((part) => others.has(part) && !keep.has(part)); + }), + }; +} + +function sessionEntries(): Record { + const out: Record = {}; + try { + for (let i = 0; i < window.sessionStorage.length; i += 1) { + const key = window.sessionStorage.key(i); + const value = key === null ? null : window.sessionStorage.getItem(key); + if (key !== null && value !== null) out[key] = value; + } + } catch { + /* 저장소가 막힌 브라우저 — 지울 것이 없다. */ + } + return out; +} + +/** + * 프로젝트에 들어감 — 들어온 표식이 다르면 그 밖 프로젝트 캐시를 지운다. 실패해도 이동은 막지 않는다. + * `nameOf` 는 확인창에 적을 프로젝트 이름. + */ +export async function enterProject( + projectId: string, + nameOf: (projectId: string) => Promise, +): Promise { + try { + if (window.sessionStorage.getItem(ENTERED_KEY) === projectId) return; + const plan = planProjectPurge(sessionEntries(), projectId); + const keep = new Set(); + for (const other of plan.draftProjects) { + const name = await nameOf(other).catch(() => other); + const drop = await showConfirmDialog( + `「${name}」에 저장 안 한 고침이 있음 — 지우고 열기 / 남기기`, + "지우고 열기", + "남기기", + ); + if (!drop) keep.add(other); + } + plan.doomed(keep).forEach((key) => window.sessionStorage.removeItem(key)); + window.sessionStorage.setItem(ENTERED_KEY, projectId); + await purgeOtherProjects(projectId); + } catch { + /* 저장소가 막힌 브라우저 — 캐시를 못 지워도 프로젝트는 연다. */ + } +} diff --git a/A00_Common/b_workflow_nav.ts b/A00_Common/b_workflow_nav.ts index 94f204bcf..87f23a048 100644 --- a/A00_Common/b_workflow_nav.ts +++ b/A00_Common/b_workflow_nav.ts @@ -10,6 +10,7 @@ import { fetchSurfaceStatus, } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { isProjectPreloaded, setPreloadTarget } from "./b_asset_cache"; +import { enterProject } from "./b_project_switch"; import { navigateTo } from "./router"; export interface WorkflowState { @@ -93,14 +94,20 @@ export async function dropGoneProject(): Promise { const PRELOAD_REQUIRED_ROUTES: readonly RoutePath[] = [ROUTES.B04_PREPROCESS, ROUTES.B05_PROFILE]; export function goToWorkflowStage(projectId: string, route: RoutePath): void { - localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId); - // 단계가 바뀌어 이동하는 자리다 — 캐시를 버려 다음 화면이 새 상태를 본다. - clearWorkflowStateCache(); - if (!PRELOAD_REQUIRED_ROUTES.includes(route)) { - navigateTo(route); - return; - } - void routeAfterPreloadCheck(projectId, route); + // 다른 프로젝트에 들어가면 그 밖 프로젝트 캐시를 먼저 지운다(290-1). + void enterProject( + projectId, + async (id) => (await fetchWorkflowState(id)).project_name || id, + ).then(() => { + localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId); + // 단계가 바뀌어 이동하는 자리다 — 캐시를 버려 다음 화면이 새 상태를 본다. + clearWorkflowStateCache(); + if (!PRELOAD_REQUIRED_ROUTES.includes(route)) { + navigateTo(route); + return; + } + void routeAfterPreloadCheck(projectId, route); + }); } /** diff --git a/resources/tester/helper_290_1_project_purge.cjs b/resources/tester/helper_290_1_project_purge.cjs new file mode 100644 index 000000000..ac4d147cb --- /dev/null +++ b/resources/tester/helper_290_1_project_purge.cjs @@ -0,0 +1,90 @@ +/* 290-1 다른 프로젝트 열면 그 밖 프로젝트 캐시 지움 — 진짜 TS(b_page_state · b_project_switch)를 + * 가짜 저장소 위에서 돌려 JSON 으로 냄(공용 storage · 브라우저 안 씀). */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const load = (rel, requireStub) => { + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, rel), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const exports = {}; + new Function("exports", "require", "window", js)(exports, requireStub, global.window); + return exports; +}; + +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), + }; +} +global.window = { sessionStorage: fakeStorage(), localStorage: fakeStorage() }; + +const pageState = load("A00_Common/b_page_state.ts", () => ({})); +const asked = []; +const purged = []; +let answer = true; +const sw = load("A00_Common/b_project_switch.ts", (id) => { + if (id.includes("b_page_state")) return pageState; + if (id.includes("b_asset_cache")) return { purgeOtherProjects: async (p) => purged.push(p) }; + return { + showConfirmDialog: async (msg, ok, cancel) => (asked.push([msg, ok, cancel]), answer), + }; +}); + +const A = "aaaa1111"; +const B = "bbbb2222"; +const C = "cccc3333"; +const SEED = { + [`aislo:draft:structures:${A}`]: '[{"id":1}]', + [`aislo:draft:crossw:${A}:12`]: '{"10":1}', + [`b06:culvertopt:${A}:12`]: '{"x":1}', // 옛 키(초안) + [`aislo:result:latest:${A}`]: '{"r":1}', + [`aislo:draft:structures:${B}`]: '[{"id":2}]', + [`aislo:result:section-detail:${B}:7`]: '{"d":1}', + [`aislo:result:latest:${C}`]: '{"r":3}', + [`aislo:draft:pipes:${C}`]: "{}", // 빈 초안 = 고침 없음 + [`b09:page-cache:${C}`]: "1", // 등록표 밖 페이지 칸 + "aislo.otp-pending": "x", // 프로젝트와 무관 +}; +const seed = (extra = {}) => { + global.window.sessionStorage = fakeStorage(); + Object.entries({ ...SEED, ...extra }).forEach(([k, v]) => + global.window.sessionStorage.setItem(k, v), + ); + asked.length = 0; + purged.length = 0; +}; +const keys = () => Object.keys(global.window.sessionStorage.dump()).sort(); +const nameOf = async (id) => `이름-${id}`; +global.window.localStorage.setItem(`aislo:pref:routeedit-view:${A}`, "{}"); + +(async () => { + const out = {}; + seed(); + answer = true; + await sw.enterProject(B, nameOf); + out.drop = { keys: keys(), asked: [...asked], purged: [...purged] }; + + seed(); + answer = false; + await sw.enterProject(B, nameOf); + out.keep = { keys: keys(), asked: asked.length }; + + seed({ "aislo:entered-project": B }); + await sw.enterProject(B, nameOf); + out.same = { keys: keys(), asked: asked.length, purged: purged.length }; + + out.pref = global.window.localStorage.getItem(`aislo:pref:routeedit-view:${A}`); + out.plan = sw.planProjectPurge(SEED, B).draftProjects; + process.stdout.write(JSON.stringify(out)); +})(); diff --git a/resources/tester/test_290_1_project_purge.py b/resources/tester/test_290_1_project_purge.py new file mode 100644 index 000000000..f401a5705 --- /dev/null +++ b/resources/tester/test_290_1_project_purge.py @@ -0,0 +1,64 @@ +"""290-1 다른 프로젝트 열면 그 밖 프로젝트 캐시 지움(요구 183-1). + +- 들어갈 때(공용 `goToWorkflowStage`) 다른 프로젝트 세션 칸(초안 · 결과 · 옛 키 · 페이지 칸) · 3D 보관함 지움 +- 다른 프로젝트에 저장 안 한 고침이 있으면 확인창(지우고 열기 / 남기기) · 남기기 = 그 초안만 둠 +- 같은 프로젝트로 다시 들어오면 아무것도 안 지움 · 지금 프로젝트 · 계정 취향(pref) 그대로 +`helper_290_1_project_purge.cjs` 가 진짜 TS 를 가짜 저장소 위에서 돌림. +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HERE = os.path.dirname(os.path.abspath(__file__)) +A, B, C = "aaaa1111", "bbbb2222", "cccc3333" +MINE = [f"aislo:draft:structures:{B}", f"aislo:result:section-detail:{B}:7"] +UNRELATED = ["aislo.otp-pending", "aislo:entered-project"] + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_290_1_project_purge.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["drop"]["keys"] == sorted(MINE + UNRELATED) + assert out["drop"]["purged"] == [B] # 3D 보관함도 + assert out["drop"]["asked"] == [ + [f"「이름-{A}」에 저장 안 한 고침이 있음 — 지우고 열기 / 남기기", "지우고 열기", "남기기"] + ] + + +def test_남기기_그_프로젝트_초안만_둠(out): + kept = [f"aislo:draft:crossw:{A}:12", f"aislo:draft:structures:{A}", f"b06:culvertopt:{A}:12"] + 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 + + +def test_빈_초안은_고침_아님_취향은_둠(out): + assert out["plan"] == [A] # C 의 빈 초안({})은 묻지 않음 + assert out["pref"] == "{}" + + +def test_공용_이동_한_곳에서_부름(): + nav = (ROOT / "A00_Common/b_workflow_nav.ts").read_text(encoding="utf-8") + assert "void enterProject(" in nav and "./b_project_switch" in nav + ui = (ROOT / "ui_template/ui_template_elements.ts").read_text(encoding="utf-8") + assert 'cancelLabel = "취소"' in ui and "label: cancelLabel" in ui diff --git a/ui_template/ui_template_elements.ts b/ui_template/ui_template_elements.ts index e7a1cc30a..6ed57ae36 100644 --- a/ui_template/ui_template_elements.ts +++ b/ui_template/ui_template_elements.ts @@ -314,7 +314,11 @@ export function showToast( * -------------------------------------------------------------------------- */ /** 확인/취소 모달 — 확인이면 true. 네이티브 confirm 대신 반드시 이것을 쓴다. */ -export function showConfirmDialog(message: string, confirmLabel = "확인"): Promise { +export function showConfirmDialog( + message: string, + confirmLabel = "확인", + cancelLabel = "취소", +): Promise { return new Promise((resolve) => { const overlay = el("div", { className: "ui-confirm" }); const panel = el("div", { className: "ui-confirm__panel" }); @@ -326,7 +330,11 @@ export function showConfirmDialog(message: string, confirmLabel = "확인"): Pro if (back?.isConnected && back !== document.body) back.focus({ preventScroll: true }); resolve(result); }; - const cancel = createButton({ label: "취소", variant: "ghost", onClick: () => done(false) }); + const cancel = createButton({ + label: cancelLabel, + variant: "ghost", + onClick: () => done(false), + }); actions.append( cancel, createButton({ label: confirmLabel, variant: "danger", onClick: () => done(true) }),