返回 DeepSeek-Reasonix
project-tree-loading.test.tsx
根目录 / desktop / frontend / src / __tests__ / project-tree-loading.test.tsx
1 // Run: node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/project-tree-loading.test.tsx
2 import assert from "node:assert/strict";
3 import { mock } from "node:test";
4 import { JSDOM } from "jsdom";
5 import React, { act } from "react";
6 import type { Root } from "react-dom/client";
7 import type { ReasonixDesktopHost } from "../lib/desktopHost";
8 import type { ProjectNode, ProjectTopicPage, ProjectTopicPageRequest, SessionGroup } from "../lib/types";
9
10 const dom = new JSDOM('<html><body><div id="root"></div></body></html>', { url: "http://localhost/", pretendToBeVisual: true });
11 Object.assign(globalThis, {
12 window: dom.window, document: dom.window.document, Element: dom.window.Element,
13 HTMLElement: dom.window.HTMLElement, Node: dom.window.Node, Event: dom.window.Event,
14 MouseEvent: dom.window.MouseEvent, localStorage: dom.window.localStorage,
15 requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
16 cancelAnimationFrame: (id: ReturnType<typeof setTimeout>) => clearTimeout(id),
17 IS_REACT_ACT_ENVIRONMENT: true,
18 });
19 Object.defineProperty(globalThis, "navigator", { value: dom.window.navigator, configurable: true });
20 window.matchMedia = (() => ({ matches: false, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia;
21
22 // Import the event system after installing the DOM, so real React input events
23 // exercise the component's search handler as they do in the renderer.
24 const { createRoot } = await import("react-dom/client");
25 const { ProjectTree } = await import("../components/ProjectTree");
26 const { LocaleProvider } = await import("../lib/i18n");
27 const { resetProjectTreeRuntimeWindowLimits } = await import("../lib/projectTreeWindow");
28
29 const roots = ["/review-a", "/review-b"];
30 const projects: ProjectNode[] = roots.map((root, i) => ({ key: `project-${i}`, kind: "project", label: i ? "B" : "A", root, children: [] }));
31 const topic = (id: string, root = roots[0]): ProjectNode => ({ key: id, topicId: id, kind: "topic", label: id, root, children: [] });
32 const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
33 let revision = 1;
34 let rows: Record<string, ProjectNode[]> = {};
35 let groups: SessionGroup[] = [];
36 let calls: ProjectTopicPageRequest[] = [];
37 let intercept: ((req: ProjectTopicPageRequest) => Promise<ProjectTopicPage> | undefined) | undefined;
38 const catalog = () => ({ state: "ready", revision, indexed: 2, total: 2, repairPending: 0 });
39
40 function page(req: ProjectTopicPageRequest): ProjectTopicPage {
41 const query = req.query?.toLowerCase() ?? "";
42 const selected = req.groupId ? [] : (rows[req.workspaceRoot ?? ""] ?? []).filter(row => row.label.toLowerCase().includes(query));
43 const start = Number(req.cursor || 0), limit = req.limit ?? 5;
44 const items = selected.slice(start, start + limit);
45 return { revision, items, complete: true, nextCursor: start + items.length < selected.length ? String(start + items.length) : undefined };
46 }
47 const bindings = {
48 GetProjectTreeSnapshot: async () => ({ revision, projects, catalog: catalog() }),
49 ListProjectTopics: async (req: ProjectTopicPageRequest) => { calls.push(req); return intercept?.(req) ?? page(req); },
50 GetSessionCatalogStatus: async () => catalog(),
51 GetSessionOrganization: async () => ({ groups, revision, order: [], manualOrderEnabled: false }),
52 GetProjectTreeRuntimeSnapshot: async () => ({ revision: 0, topics: [] }),
53 Platform: async () => "darwin",
54 RemoteConnectionStatuses: async () => [],
55 };
56 window.reasonixDesktop = {
57 kind: "electron", contract: { commands: Object.keys(bindings) }, platform: { os: "darwin" }, native: {},
58 invoke: (method: string, args: unknown[]) => (bindings as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>)[method](...args),
59 on: (name: string, cb: (...args: unknown[]) => void) => {
60 const set = listeners.get(name) ?? new Set(); set.add(cb); listeners.set(name, set);
61 return () => set.delete(cb);
62 },
63 } as unknown as ReasonixDesktopHost;
64
65 const container = document.getElementById("root")!;
66 let root: Root;
67 const flush = async () => { await act(async () => { await new Promise<void>(resolve => setImmediate(resolve)); }); };
68 const advance = async (ms = 200) => { await act(async () => mock.timers.tick(ms)); await flush(); };
69 const labels = () => [...container.querySelectorAll(".project-tree__topic-label")].map(el => el.textContent);
70 const count = (workspaceRoot = roots[0]) => calls.filter(req => req.workspaceRoot === workspaceRoot).length;
71 async function click(selector: string, scope: ParentNode = container) {
72 const target = scope.querySelector<HTMLElement>(selector);
73 assert.ok(target, `missing control: ${selector}`);
74 await act(async () => target.click()); await flush();
75 }
76 async function folder(label = "A") {
77 const target = [...container.querySelectorAll<HTMLElement>(".project-tree__folder--project .project-tree__folder-main")]
78 .find(el => el.textContent?.trim() === label);
79 assert.ok(target, `missing project ${label}`);
80 await act(async () => target.click()); await flush();
81 }
82 async function event(stale = false) {
83 revision++;
84 await act(async () => {
85 for (const callback of listeners.get("project-tree:changed-v2") ?? []) callback({ revision: stale ? 0 : revision, roots: [roots[0]], reason: "changed" });
86 });
87 await flush();
88 }
89 async function search(value: string) {
90 const input = container.querySelector<HTMLInputElement>(".project-tree__search input")!;
91 assert.ok(input, "search input exists");
92 await act(async () => {
93 Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!.call(input, value);
94 input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
95 });
96 await flush();
97 }
98 async function mount(withGroups = false) {
99 revision = 1; calls = []; intercept = undefined;
100 rows = Object.fromEntries(roots.map((path, i) => [path, Array.from({ length: 12 }, (_, n) => topic(`${i ? "B" : "A"}-${n}`, path))]));
101 groups = withGroups ? [{ id: "feature", title: "Feature", topicIds: [] }] : [];
102 resetProjectTreeRuntimeWindowLimits(); localStorage.clear();
103 root = createRoot(container);
104 await act(async () => root.render(<LocaleProvider><ProjectTree activeScope="project" activeWorkspaceRoot={roots[0]} onOpenTopic={() => {}} onAddProject={async () => {}} /></LocaleProvider>));
105 await flush(); await advance();
106 }
107 async function unmount() { await act(async () => root.unmount()); }
108 function deferred<T>() {
109 let resolve!: (value: T) => void;
110 const promise = new Promise<T>(done => { resolve = done; });
111 return { promise, resolve };
112 }
113
114 mock.timers.enable({ apis: ["setTimeout"] });
115 try {
116 await mount();
117 assert.equal(count(), 1, "cold first page loads once");
118 await folder("B");
119 assert.equal(count(roots[1]), 1);
120 await folder(); await folder(); await advance();
121 assert.equal(count(), 1, "unchanged project reopen reuses its cache");
122 assert.equal(count(roots[1]), 1, "sibling expand/collapse never reloads B");
123 await click('[aria-label="Collapse all"]');
124 await click('[aria-label="Restore previous groups"]'); await advance();
125 assert.equal(calls.length, 2, "unchanged global restore reuses both caches");
126
127 await folder();
128 rows[roots[0]] = [topic("A-new"), ...rows[roots[0]].filter(row => row.topicId !== "A-0")];
129 await event();
130 assert.equal(count(), 1, "collapsed invalidation does not eagerly fetch");
131 await folder(); await advance();
132 assert.equal(count(), 2, "reopening an invalidated project fetches once");
133 assert.ok(labels().includes("A-new"));
134 assert.ok(!labels().includes("A-0"), "externally archived row is removed");
135 assert.equal(count(roots[1]), 1, "A's invalidation leaves B's cache valid");
136
137 await click('[aria-label="Collapse all"]');
138 rows[roots[0]] = [topic("A-newer"), ...rows[roots[0]].filter(row => row.topicId !== "A-new")];
139 await event();
140 assert.equal(count(), 2);
141 await click('[aria-label="Restore previous groups"]'); await advance();
142 assert.equal(count(), 3, "global restore reloads only invalidated A");
143 assert.equal(count(roots[1]), 1);
144 assert.ok(labels().includes("A-newer")); assert.ok(!labels().includes("A-new"));
145 await folder();
146 rows[roots[0]] = [topic("A-reconciled"), ...rows[roots[0]]];
147 await event(true);
148 await folder(); await advance();
149 assert.ok(labels().includes("A-reconciled"), "out-of-order events still invalidate cached lists while reconciling shells");
150 assert.equal(count(), 4); assert.equal(count(roots[1]), 1);
151 await unmount();
152 console.log(" PASS project/global reopen caches, deferred invalidation and sibling isolation");
153
154 await mount(true);
155 assert.deepEqual(calls.map(req => req.groupId || "ungrouped").sort(), ["feature", "ungrouped"], "group and parent initialization share one request per list");
156 await advance();
157 assert.equal(calls.length, 2, "no delayed duplicate first page");
158 await folder(); await folder(); await advance();
159 assert.equal(calls.length, 2, "reopening a grouped project reuses both lists");
160 await unmount();
161 console.log(" PASS grouped cold start requests each list exactly once");
162
163 for (const oldFinishesFirst of [true, false]) {
164 await mount();
165 const oldRequest = deferred<ProjectTopicPage>(), freshRequest = deferred<ProjectTopicPage>();
166 let requestIndex = 0;
167 intercept = () => (++requestIndex === 1 ? oldRequest.promise : freshRequest.promise);
168 await event();
169 const oldPage = page(calls.at(-1)!);
170 rows[roots[0]] = [topic("A-fresh"), ...rows[roots[0]]];
171 await event();
172 const freshPage = page(calls.at(-1)!);
173 assert.equal(count(), 3, "invalidation starts a new generation despite an older pending request");
174 if (oldFinishesFirst) {
175 await act(async () => oldRequest.resolve(oldPage)); await flush();
176 assert.ok(container.querySelector(".project-tree__topic-window-status"), "old completion cannot clear the newer loading state");
177 await act(async () => freshRequest.resolve(freshPage));
178 } else {
179 await act(async () => freshRequest.resolve(freshPage)); await flush();
180 await act(async () => oldRequest.resolve(oldPage));
181 }
182 await flush();
183 assert.ok(labels().includes("A-fresh"), "late stale response cannot overwrite fresh data");
184 assert.equal(count(), 3);
185 await unmount();
186 }
187 console.log(" PASS old/new request completion order preserves the current generation");
188
189 await mount();
190 await folder();
191 await search("A-");
192 assert.equal(count(), 1, "typing is debounced");
193 // An event is allowed to populate a visible search before debounce expires.
194 rows[roots[0]] = [topic("A-search-new"), ...rows[roots[0]]];
195 await event();
196 assert.equal(count(), 2, "search visibility refreshes even a manually collapsed project");
197 await advance();
198 assert.equal(count(), 2, "debounced search reuses the event's initialized page");
199 assert.ok(labels().includes("A-search-new"));
200 const oldSearch = deferred<ProjectTopicPage>();
201 intercept = req => req.query === "pending" ? oldSearch.promise : undefined;
202 await search("pending"); await advance();
203 await search("");
204 await folder();
205 await act(async () => oldSearch.resolve({ revision, items: [topic("stale-search")], complete: true })); await flush();
206 assert.ok(labels().includes("A-search-new")); assert.ok(!labels().includes("stale-search"));
207 assert.equal(container.querySelectorAll(".project-tree__topic-window-status").length, 0, "abandoned query cannot strand the normal list in loading");
208 await unmount();
209 console.log(" PASS search visibility, debounce deduplication and abandoned requests");
210
211 await mount();
212 const initialSort = calls.at(-1)?.sortMode ?? "created";
213 const nextSort = initialSort === "created" ? "updated" : "created";
214 const oldSort = deferred<ProjectTopicPage>();
215 intercept = req => req.sortMode === initialSort ? oldSort.promise : undefined;
216 await event();
217 const oldSortPage = page(calls.at(-1)!);
218 await click('.project-tree__header-menu-wrap button[aria-haspopup="menu"]');
219 const nextSortLabel = nextSort === "created" ? "Created time" : "Updated time";
220 const nextSortButton = [...document.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')].find(button => button.textContent?.includes(nextSortLabel));
221 assert.ok(nextSortButton);
222 await act(async () => nextSortButton.click()); await flush();
223 assert.equal(calls.at(-1)?.sortMode, nextSort, "sorting starts a new request while the old order is pending");
224 assert.equal(calls.at(-1)?.cursor, "", "new sort discards the old order's pagination cursor");
225 assert.equal(count(), 3);
226 await act(async () => oldSort.resolve(oldSortPage)); await flush();
227 assert.equal(container.querySelectorAll(".project-tree__topic-window-status").length, 0);
228 assert.equal(count(), 3);
229 await unmount();
230 console.log(" PASS sort changes retire pending requests and their cursors");
231 } finally {
232 mock.timers.reset();
233 dom.window.close();
234 }
235
235 lines Plain Text