返回 DeepSeek-Reasonix
activity-bar-store.test.ts
根目录 / desktop / frontend / src / __tests__ / activity-bar-store.test.ts
1 // Run: tsx src/__tests__/activity-bar-store.test.ts
2 // Verifies the right-dock activity-bar store: open-entry reuse, add/close tab
3 // activation fallback, and localStorage persistence round-trip.
4
5 import { readFileSync } from "node:fs";
6 import { dirname, resolve } from "node:path";
7 import { fileURLToPath } from "node:url";
8 import { JSDOM } from "jsdom";
9
10 // The store reads localStorage at module load, so install a jsdom global
11 // before importing it (mirrors how browser-only tests bootstrap the DOM).
12 const dom = new JSDOM("", { url: "https://reasonix.local/" });
13 globalThis.window = dom.window as unknown as Window & typeof globalThis;
14 globalThis.document = dom.window.document;
15 globalThis.localStorage = dom.window.localStorage;
16
17 const testDir = dirname(fileURLToPath(import.meta.url));
18 const storeSource = readFileSync(resolve(testDir, "../store/activityBar.ts"), "utf8");
19 const { useActivityBarStore } = await import("../store/activityBar");
20
21 let passed = 0;
22 let failed = 0;
23
24 function check(name: string, condition: boolean, detail = "") {
25 if (condition) {
26 passed += 1;
27 console.log(` PASS ${name}`);
28 } else {
29 failed += 1;
30 console.error(` FAIL ${name}${detail ? ` — ${detail}` : ""}`);
31 }
32 }
33
34 function resetStore(): void {
35 localStorage.clear();
36 useActivityBarStore.setState({ tabs: [], activeTabId: null, addMenuOpen: false, recentlyClosed: [] });
37 }
38
39 // --- persistence contract: store reads/writes localStorage under one key ---
40 check(
41 "store persists under reasonix.dock.tabs",
42 /STORAGE_KEY\s*=\s*"reasonix\.dock\.tabs"/.test(storeSource),
43 "STORAGE_KEY literal not found",
44 );
45
46 // --- openEntry reuses an existing tab of the same type ---
47 resetStore();
48 useActivityBarStore.getState().openEntry("file", "Files");
49 const first = useActivityBarStore.getState().tabs[0].id;
50 check("openEntry opens the first tab", useActivityBarStore.getState().tabs.length === 1, "expected 1 tab");
51 check("openEntry activates the tab", useActivityBarStore.getState().activeTabId === first, "activeTabId mismatch");
52 useActivityBarStore.getState().openEntry("changed", "Changes");
53 check("openEntry appends a different type", useActivityBarStore.getState().tabs.length === 2, "expected 2 tabs");
54 useActivityBarStore.getState().openEntry("file", "Files");
55 check("openEntry reuses same-type tab", useActivityBarStore.getState().tabs.length === 2, "expected still 2 tabs");
56 check("openEntry switches back to the reused tab", useActivityBarStore.getState().activeTabId === first, "activeTabId mismatch");
57
58 // --- closeTab falls back to the left neighbor, then right, then null ---
59 resetStore();
60 useActivityBarStore.getState().openEntry("file", "Files");
61 useActivityBarStore.getState().openEntry("changed", "Changes");
62 useActivityBarStore.getState().openEntry("browser", "Browser");
63 const ids = useActivityBarStore.getState().tabs.map((tab) => tab.id);
64 check("opening a tab expands the container", useActivityBarStore.getState().activityBarOpen === true, "expected expanded");
65 // Closing a non-active tab leaves the active tab untouched.
66 useActivityBarStore.getState().activateTab(ids[2]);
67 useActivityBarStore.getState().closeTab(ids[1]);
68 check("closeTab removes the tab", useActivityBarStore.getState().tabs.length === 2, "expected 2 tabs left");
69 check("closing a non-active tab keeps the active tab", useActivityBarStore.getState().activeTabId === ids[2], "activeTabId mismatch");
70 // Closing the active tab falls back to its left neighbor.
71 useActivityBarStore.getState().closeTab(ids[2]);
72 check("closeTab activates left neighbor", useActivityBarStore.getState().activeTabId === ids[0], "activeTabId mismatch");
73 // Closing the last tab collapses back to the activity bar.
74 useActivityBarStore.getState().closeTab(ids[0]);
75 check("closing the last tab clears activeTabId", useActivityBarStore.getState().tabs.length === 0 && useActivityBarStore.getState().activeTabId === null, "expected empty dock");
76 check("closing the last tab collapses the container", useActivityBarStore.getState().activityBarOpen === false, "expected collapsed");
77
78 // --- persistence round-trip survives a store re-init ---
79 resetStore();
80 useActivityBarStore.getState().openEntry("file", "Files");
81 useActivityBarStore.getState().openEntry("browser", "Browser");
82 const persisted = JSON.parse(localStorage.getItem("reasonix.dock.tabs") ?? "{}");
83 check("persistence writes tabs", Array.isArray(persisted.tabs) && persisted.tabs.length === 2, "expected 2 persisted tabs");
84 check("persistence writes activeTabId", typeof persisted.activeTabId === "string", "activeTabId not persisted");
85
86 // --- activityBarOpen is independent of tabs (toggle collapse keeps tabs) ---
87 resetStore();
88 useActivityBarStore.getState().openEntry("file", "Files");
89 useActivityBarStore.getState().openEntry("browser", "Browser");
90 useActivityBarStore.getState().setActivityBarOpen(false);
91 check("collapse via setActivityBarOpen keeps tabs", useActivityBarStore.getState().tabs.length === 2, "expected 2 tabs kept");
92 check("collapse via setActivityBarOpen closes the container", useActivityBarStore.getState().activityBarOpen === false, "expected collapsed");
93 useActivityBarStore.getState().setActivityBarOpen(true);
94 check("re-expand restores the container", useActivityBarStore.getState().activityBarOpen === true, "expected expanded");
95
96 // --- addMenuOpen is session-local ---
97 resetStore();
98 useActivityBarStore.getState().setAddMenuOpen(true);
99 check("addMenuOpen setter works", useActivityBarStore.getState().addMenuOpen === true, "expected open");
100
101 // --- fresh tab ids never collide with restored/persisted ones ---
102 // New identities remain unique even when closed tabs only exist in view memory.
103 resetStore();
104 useActivityBarStore.getState().addTab("file", "a");
105 useActivityBarStore.getState().addTab("file", "b");
106 useActivityBarStore.getState().addTab("file", "c");
107 const freshIds = useActivityBarStore.getState().tabs.map((tab) => tab.id);
108 check(
109 "fresh tab ids are unique",
110 freshIds.length === new Set(freshIds).size && freshIds.every((id) => /^dock-tab-[a-f0-9-]+$/.test(id)),
111 `ids=${freshIds.join(",")}`,
112 );
113
114 // --- moveTab reorders tabs and persists the new order ---
115 resetStore();
116 useActivityBarStore.getState().addTab("file", "a");
117 useActivityBarStore.getState().addTab("changed", "b");
118 useActivityBarStore.getState().addTab("context", "c");
119 const orderBefore = useActivityBarStore.getState().tabs.map((tab) => tab.id);
120 // Move c to the left of a → [c, a, b]
121 useActivityBarStore.getState().moveTab(orderBefore[2], orderBefore[0], "left");
122 const afterLeft = useActivityBarStore.getState().tabs.map((tab) => tab.id);
123 check("moveTab left places the tab before the target", afterLeft[0] === orderBefore[2] && afterLeft[1] === orderBefore[0], `order=${afterLeft.join(",")}`);
124 check("moveTab persists", JSON.parse(localStorage.getItem("reasonix.dock.tabs") ?? "{}").tabs.map((t: { id: string }) => t.id).join(",") === afterLeft.join(","), "order not persisted");
125 // Move a to the right of c → [c, a, b] stays? a is at index 1, target c at 0, right → [c, a, b]? move a right of c inserts after c → [c, a, b] (already). Pick a different move: b right of c → [c, b, a]
126 useActivityBarStore.getState().moveTab(afterLeft[2], afterLeft[0], "right");
127 const afterRight = useActivityBarStore.getState().tabs.map((tab) => tab.id);
128 check("moveTab right places the tab after the target", afterRight[0] === orderBefore[2] && afterRight[1] === orderBefore[1], `order=${afterRight.join(",")}`);
129 check("moveTab keeps the active tab", useActivityBarStore.getState().activeTabId === orderBefore[2], "activeTabId changed");
130 // Same-id move is a no-op.
131 useActivityBarStore.getState().moveTab(afterRight[0], afterRight[0], "left");
132 check("moveTab same id is a no-op", useActivityBarStore.getState().tabs.map((tab) => tab.id).join(",") === afterRight.join(","), "order changed");
133
134 // --- recently closed tabs are recorded and can be reopened ---
135 resetStore();
136 useActivityBarStore.getState().addTab("file", "a");
137 useActivityBarStore.getState().addTab("changed", "b");
138 const closedId = useActivityBarStore.getState().tabs[1].id;
139 useActivityBarStore.getState().closeTab(closedId);
140 const closedList = useActivityBarStore.getState().recentlyClosed;
141 check("closeTab records the closed tab", closedList.length === 1 && closedList[0].tab.id === closedId && closedList[0].tab.label === "b",
142 `recentlyClosed=${JSON.stringify(closedList)}`);
143 check("closeTab stamps the close time", typeof closedList[0].closedAt === "number" && closedList[0].closedAt > 0, "no closedAt");
144 check("the closed tab is gone from the list", useActivityBarStore.getState().tabs.every((tab) => tab.id !== closedId), "still open");
145 useActivityBarStore.getState().reopenTab(closedId);
146 check("reopenTab restores the tab", useActivityBarStore.getState().tabs.some((tab) => tab.id === closedId), "not restored");
147 check("reopenTab activates it", useActivityBarStore.getState().activeTabId === closedId, "not active");
148 check("reopenTab drops it from the closed list", useActivityBarStore.getState().recentlyClosed.length === 0, "still listed");
149 check("reopenTab persists the restored tab", JSON.parse(localStorage.getItem("reasonix.dock.tabs") ?? "{}").tabs.some((t: { id: string }) => t.id === closedId), "not persisted");
150 // Closing several tabs keeps newest first and is bounded.
151 for (let index = 0; index < 12; index += 1) {
152 useActivityBarStore.getState().addTab("file", `f${index}`);
153 const id = useActivityBarStore.getState().tabs[useActivityBarStore.getState().tabs.length - 1].id;
154 useActivityBarStore.getState().closeTab(id);
155 }
156 check("recently closed is bounded to 10 newest first",
157 useActivityBarStore.getState().recentlyClosed.length === 10 && useActivityBarStore.getState().recentlyClosed[0].tab.label === "f11",
158 `len=${useActivityBarStore.getState().recentlyClosed.length}`);
159
160 const projectAClosed = useActivityBarStore.getState().recentlyClosed.map(record => record.tab.id);
161 useActivityBarStore.getState().setWorkspaceRoot("/project-b");
162 check("new project has no closed tabs", useActivityBarStore.getState().recentlyClosed.length === 0, "leaked A history");
163 useActivityBarStore.getState().reopenTab(projectAClosed[0]);
164 check("cannot reopen another project's tab", useActivityBarStore.getState().tabs.length === 0, "cross-project reopen");
165 useActivityBarStore.getState().addTab("file", "B files");
166 const projectBTab = useActivityBarStore.getState().activeTabId!;
167 useActivityBarStore.getState().closeTab(projectBTab);
168 useActivityBarStore.getState().setWorkspaceRoot("");
169 check("returning restores A closed history", JSON.stringify(useActivityBarStore.getState().recentlyClosed.map(record => record.tab.id)) === JSON.stringify(projectAClosed), "A history changed");
170 useActivityBarStore.getState().setWorkspaceRoot("/project-b");
171 useActivityBarStore.getState().reopenTab(projectBTab);
172 check("reopen preserves B identity", useActivityBarStore.getState().activeTabId === projectBTab, "B identity lost");
173
174 console.log(`
175 activity-bar-store: ${passed} passed, ${failed} failed`);
176 if (failed > 0) process.exit(1);
177
177 lines TYPESCRIPT