feat(common): 다른 프로젝트 열면 그 밖 프로젝트 캐시 지움 · 저장 안 한 고침은 확인창 (290-1)
- b_project_switch.enterProject — 공용 goToWorkflowStage 에서 부름 · 탭마다 들어온 표식이 다를 때만 - 다른 프로젝트 세션 칸(초안 · 결과 · 옛 키 · 키 마디에 번호가 든 페이지 칸) + 3D 보관함 지움 · 지금 프로젝트 · 계정 취향 그대로 - 다른 프로젝트에 저장 안 한 고침이 있으면 확인창(지우고 열기 / 남기기) · 남기기 = 그 초안만 둠 - showConfirmDialog 취소 단추 글 받는 칸(기본 「취소」) - 시험 test_290_1(진짜 TS · 가짜 저장소) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
This commit is contained in:
@@ -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<string, StateEntry>).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>) => string[];
|
||||
}
|
||||
|
||||
/** 세션 칸(키 → 값)에서 다른 프로젝트 몫을 가른다 — 저장소를 만지지 않는다. */
|
||||
export function planProjectPurge(
|
||||
session: Record<string, string>,
|
||||
projectId: string,
|
||||
): ProjectPurgePlan {
|
||||
const prefixes = projectPrefixes();
|
||||
const owners = new Map<string, { project: string; draft: boolean }>();
|
||||
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<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
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<string>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (window.sessionStorage.getItem(ENTERED_KEY) === projectId) return;
|
||||
const plan = planProjectPurge(sessionEntries(), projectId);
|
||||
const keep = new Set<string>();
|
||||
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 {
|
||||
/* 저장소가 막힌 브라우저 — 캐시를 못 지워도 프로젝트는 연다. */
|
||||
}
|
||||
}
|
||||
@@ -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<boolean> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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));
|
||||
})();
|
||||
@@ -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
|
||||
@@ -314,7 +314,11 @@ export function showToast(
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
/** 확인/취소 모달 — 확인이면 true. 네이티브 confirm 대신 반드시 이것을 쓴다. */
|
||||
export function showConfirmDialog(message: string, confirmLabel = "확인"): Promise<boolean> {
|
||||
export function showConfirmDialog(
|
||||
message: string,
|
||||
confirmLabel = "확인",
|
||||
cancelLabel = "취소",
|
||||
): Promise<boolean> {
|
||||
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) }),
|
||||
|
||||
Reference in New Issue
Block a user