返回 DeepSeek-Reasonix
project-tree-group-window.test.tsx
根目录 / desktop / frontend / src / __tests__ / project-tree-group-window.test.tsx
1 // Run: npx tsx src/__tests__/project-tree-group-window.test.tsx
2 import assert from "node:assert/strict";
3 import { JSDOM } from "jsdom";
4 import React, { act, useState } from "react";
5 import { createRoot } from "react-dom/client";
6 import type { Translator } from "../lib/i18n";
7 import type { ProjectNode, SessionGroup } from "../lib/types";
8 import type { ProjectTreeOrganizationController } from "../components/ProjectTreeOrganization";
9
10 const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { url: "http://localhost/" });
11 Object.assign(globalThis, {
12 window: dom.window,
13 document: dom.window.document,
14 HTMLElement: dom.window.HTMLElement,
15 Element: dom.window.Element,
16 requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
17 cancelAnimationFrame: (id: number) => clearTimeout(id),
18 IS_REACT_ACT_ENVIRONMENT: true,
19 });
20 Object.defineProperty(globalThis, "navigator", { value: dom.window.navigator, configurable: true });
21
22 const { ProjectTreeGroupRows } = await import("../components/ProjectTreeOrganization");
23
24 const folder: ProjectNode = { key: "project", kind: "project", label: "Project", root: "/project", children: [] };
25 const groups: SessionGroup[] = [
26 { id: "feature", title: "Feature", topicIds: Array.from({ length: 16 }, (_, index) => `feature-${index}`) },
27 { id: "bugs", title: "Bugs", topicIds: Array.from({ length: 16 }, (_, index) => `bugs-${index}`) },
28 ];
29 const topic = (id: string): ProjectNode => ({ key: `topic-${id}`, kind: "topic", label: id, topicId: id, children: [] });
30 const children = [
31 ...Array.from({ length: 16 }, (_, index) => topic(`plain-${index}`)),
32 ...groups.flatMap((group) => (group.topicIds ?? []).map(topic)),
33 ];
34
35 const organization: ProjectTreeOrganizationController = {
36 topicRow: () => ({ className: "", props: {} }),
37 topicMenuItems: () => [],
38 createGroup: () => {},
39 groupsFor: () => groups,
40 groupCollapsed: () => false,
41 toggleGroup: () => {},
42 renameGroup: () => {},
43 deleteGroup: () => {},
44 canDropTopicInto: () => false,
45 dropTopicInto: () => {},
46 };
47
48 const t = ((key: string, values?: Record<string, string>) => {
49 if (key === "projectTree.expandDisplay") return "Show more";
50 if (key === "projectTree.expandGroup") return `Show more in ${values?.name}`;
51 return key;
52 }) as Translator;
53
54 function Harness() {
55 const [limits, setLimits] = useState<Record<string, number>>({ "": 5, feature: 5, bugs: 5 });
56 return <ProjectTreeGroupRows
57 folder={folder}
58 children={children}
59 depth={1}
60 section="projects"
61 visible
62 organization={organization}
63 renderNode={(node) => <div data-topic={node.topicId} key={node.key} />}
64 t={t}
65 queryActive={false}
66 remote={false}
67 activeTopicId={undefined}
68 isActive={() => false}
69 listState={(groupID) => ({ itemKeys: (groupID ? groups.find((group) => group.id === groupID)?.topicIds : children.filter((node) => node.topicId?.startsWith("plain-")).map((node) => node.key))?.map((id) => id.startsWith("topic-") ? id : `topic-${id}`) ?? [], loading: false, initialized: true })}
70 listLimit={(groupID) => limits[groupID] ?? 5}
71 onEnsureList={() => {}}
72 onExpandList={(groupID) => setLimits((current) => ({ ...current, [groupID]: (current[groupID] ?? 5) + 5 }))}
73 onRetryList={() => {}}
74 onForgetList={() => {}}
75 />;
76 }
77
78 const container = document.getElementById("root")!;
79 const root = createRoot(container);
80 await act(async () => root.render(<Harness />));
81 const rows = (prefix: string) => container.querySelectorAll(`[data-topic^="${prefix}"]`).length;
82 assert.equal(rows("plain-"), 5);
83 assert.equal(rows("feature-"), 5);
84 assert.equal(rows("bugs-"), 5);
85
86 await act(async () => (container.querySelector('[aria-label="Show more in Feature"]') as HTMLButtonElement).click());
87 assert.equal(rows("feature-"), 10);
88 assert.equal(rows("bugs-"), 5, "expanding one group leaves the other group unchanged");
89 assert.equal(rows("plain-"), 5, "expanding a group leaves ungrouped rows unchanged");
90 assert.equal(container.querySelectorAll('[aria-label^="Show less in "]').length, 0, "session windows expose no competing collapse action");
91
92 await act(async () => (container.querySelector('[aria-label="Show more in Feature"]') as HTMLButtonElement).click());
93 assert.equal(rows("feature-"), 15);
94 await act(async () => (container.querySelector('[aria-label="Show more in Feature"]') as HTMLButtonElement).click());
95 assert.equal(rows("feature-"), 16);
96 assert.equal(container.querySelector('[aria-label="Show more in Feature"]'), null, "the final loaded row removes the one-way disclosure");
97
98 await act(async () => root.unmount());
99
100 let openedActiveGroup = "";
101 const collapsedOrganization: ProjectTreeOrganizationController = {
102 ...organization,
103 groupCollapsed: (_key, groupID) => groupID === "feature",
104 toggleGroup: (_key, groupID) => { openedActiveGroup = groupID; },
105 };
106 const secondRoot = createRoot(container);
107 await act(async () => secondRoot.render(<ProjectTreeGroupRows
108 folder={folder}
109 children={children}
110 depth={1}
111 section="projects"
112 visible
113 organization={collapsedOrganization}
114 renderNode={(node) => <div data-topic={node.topicId} key={node.key} />}
115 t={t}
116 queryActive={false}
117 remote={false}
118 activeTopicId="feature-10"
119 isActive={(node) => node.topicId === "feature-10"}
120 listState={(groupID) => ({ itemKeys: groupID === "feature" ? groups[0].topicIds?.map((id) => `topic-${id}`) : [], loading: false, initialized: true })}
121 listLimit={() => 5}
122 onEnsureList={() => {}}
123 onExpandList={() => {}}
124 onRetryList={() => {}}
125 onForgetList={() => {}}
126 />));
127 assert.equal(openedActiveGroup, "feature", "initial active navigation opens its collapsed group once");
128 await act(async () => secondRoot.unmount());
129
130 const ensuredGroups: string[] = [];
131 function LazyGroupHarness() {
132 const [featureCollapsed, setFeatureCollapsed] = useState(true);
133 const lazyOrganization: ProjectTreeOrganizationController = {
134 ...organization,
135 groupCollapsed: (_key, groupID) => featureCollapsed && groupID === "feature",
136 toggleGroup: (_key, groupID) => { if (groupID === "feature") setFeatureCollapsed((value) => !value); },
137 };
138 return <ProjectTreeGroupRows
139 folder={folder}
140 children={children}
141 depth={1}
142 section="projects"
143 visible
144 organization={lazyOrganization}
145 renderNode={(node) => <div data-topic={node.topicId} key={node.key} />}
146 t={t}
147 queryActive={false}
148 remote={false}
149 activeTopicId={undefined}
150 isActive={() => false}
151 listState={() => ({ loading: false, initialized: false })}
152 listLimit={() => 5}
153 onEnsureList={(groupID) => { ensuredGroups.push(groupID); }}
154 onExpandList={() => {}}
155 onRetryList={() => {}}
156 onForgetList={() => {}}
157 />;
158 }
159 const thirdRoot = createRoot(container);
160 await act(async () => thirdRoot.render(<LazyGroupHarness />));
161 assert.deepEqual(ensuredGroups, ["", "bugs"], "collapsed groups defer their first page until they are opened");
162 await act(async () => (container.querySelector(".project-tree__group-main") as HTMLElement).click());
163 assert.ok(ensuredGroups.includes("feature"), "opening a collapsed group requests its first page");
164 await act(async () => thirdRoot.unmount());
165 console.log(" PASS project tree group windows are independent");
166
166 lines Plain Text