返回 DeepSeek-Reasonix
project-tree-runtime.test.ts
根目录 / desktop / frontend / src / __tests__ / project-tree-runtime.test.ts
1 // Run: tsx src/__tests__/project-tree-runtime.test.ts
2
3 import {
4 projectTreeFolderDisclosure,
5 defaultExpandedProjectTreeKeys,
6 activeSessionAncestorKeys,
7 projectTreeTopicOpenRequest,
8 projectTreeShouldSuppressOpenForRename,
9 projectTreeReadActivityKey,
10 projectTreeTopicHasUnreadActivity,
11 projectTreeTopicArchiveBlocked,
12 projectTreeShouldRenderTopicActions,
13 projectTreeTopicMetaLine,
14 arrangeClassicProjectTree,
15 splitPinnedProjectTree,
16 classicTopicWindow,
17 projectTreeTopicHoverCardModel,
18 projectTreeTopicMenuOffersPin,
19 projectTreeDedupedExactTime,
20 } from "../components/ProjectTree";
21 import type { ProjectNode } from "../lib/types";
22
23 let passed = 0;
24 let failed = 0;
25
26 function eq(a: unknown, b: unknown, label: string) {
27 if (JSON.stringify(a) === JSON.stringify(b)) {
28 process.stdout.write(` PASS ${label}\n`);
29 passed += 1;
30 } else {
31 process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
32 failed += 1;
33 }
34 }
35
36 console.log("\nproject tree runtime sessions");
37
38 const testT = (key: string, vars?: Record<string, string | number>) => {
39 if (key === "history.turnOne") return `${vars?.n ?? 1} turn`;
40 if (key === "history.turnOther") return `${vars?.n ?? 0} turns`;
41 return key;
42 };
43
44 const tree: ProjectNode[] = [
45 {
46 key: "global_folder",
47 kind: "global_folder",
48 label: "Global",
49 children: [
50 {
51 key: "global_topic_topic-a",
52 kind: "global_topic",
53 label: "Topic A",
54 topicId: "topic-a",
55 children: [
56 {
57 key: "global_session_a",
58 kind: "global_session",
59 label: "Session A",
60 topicId: "topic-a",
61 sessionPath: "/tmp/a.jsonl",
62 },
63 {
64 key: "global_session_b",
65 kind: "global_session",
66 label: "Session B",
67 topicId: "topic-a",
68 sessionPath: "/tmp/b.jsonl",
69 },
70 ],
71 },
72 {
73 key: "global_topic_topic-b",
74 kind: "global_topic",
75 label: "Topic B",
76 topicId: "topic-b",
77 },
78 ],
79 },
80 ];
81
82 eq(
83 defaultExpandedProjectTreeKeys(tree),
84 [],
85 "without an active tab, no folders default to expanded",
86 );
87
88 eq(
89 defaultExpandedProjectTreeKeys(tree, "global", "", "topic-a", "/tmp/b.jsonl"),
90 ["global_folder", "global_topic_topic-a"],
91 "active session path expands only ancestor folders",
92 );
93
94 eq(
95 activeSessionAncestorKeys(tree, "global", "", "topic-a", "/tmp/b.jsonl"),
96 ["global_folder", "global_topic_topic-a"],
97 "activeSessionAncestorKeys matches defaultExpandedProjectTreeKeys for active session",
98 );
99
100 eq(
101 activeSessionAncestorKeys(tree, "global", "", "topic-b"),
102 ["global_folder"],
103 "active topic without runtime session rows expands only parent folders",
104 );
105
106 // Waiting confirmation copy is stable for both compact and expanded sidebars.
107 eq(
108 testT("projectTree.status.waitingConfirmation"),
109 "projectTree.status.waitingConfirmation",
110 "waiting confirmation key stays available for side-bar pill and badge",
111 );
112
113 eq(
114 projectTreeTopicOpenRequest(tree[0].children?.[0].children?.[1] as ProjectNode),
115 { scope: "global", workspaceRoot: "", topicId: "topic-a", sessionPath: "/tmp/b.jsonl" },
116 "runtime session row opens the concrete session path",
117 );
118
119 eq(
120 projectTreeTopicOpenRequest({
121 key: "topic_project",
122 kind: "topic",
123 label: "Project topic",
124 root: "/repo",
125 topicId: "topic-project",
126 }),
127 { scope: "project", workspaceRoot: "/repo", topicId: "topic-project", sessionPath: undefined },
128 "regular project topic still opens by topic",
129 );
130
131 eq(
132 projectTreeTopicMetaLine({
133 key: "global_topic_missing_time",
134 kind: "global_topic",
135 label: "Old empty topic",
136 topicId: "missing-time",
137 }, testT),
138 "projectTree.previously",
139 "topic with no turns and no timestamps renders previous-time fallback meta",
140 );
141
142 eq(
143 projectTreeTopicMetaLine({
144 key: "global_topic_recent",
145 kind: "global_topic",
146 label: "Recent blank topic",
147 topicId: "recent",
148 createdAt: Date.now(),
149 }, testT),
150 "projectTree.justNow",
151 "topic with a real recent timestamp still renders just-now meta",
152 );
153
154 const completedTopic: ProjectNode = {
155 key: "topic_complete",
156 kind: "topic",
157 label: "Completed",
158 root: "/repo",
159 topicId: "topic-complete",
160 lastActivityAt: 2000,
161 };
162 const completedTopicKey = projectTreeReadActivityKey(completedTopic) ?? "";
163
164 eq(
165 projectTreeTopicHasUnreadActivity(completedTopic, { [completedTopicKey]: 1000 }, "project", "/repo", "other-topic"),
166 true,
167 "completed inactive topic with newer activity shows unread attention",
168 );
169
170 eq(
171 projectTreeTopicHasUnreadActivity(completedTopic, { [completedTopicKey]: 2000 }, "project", "/repo", "other-topic"),
172 false,
173 "completed topic stops showing unread attention once opened at its latest activity",
174 );
175
176 eq(
177 projectTreeTopicHasUnreadActivity(completedTopic, { [completedTopicKey]: 1000 }, "project", "/repo", "topic-complete"),
178 false,
179 "active topic does not show unread attention",
180 );
181
182 eq(
183 projectTreeTopicHasUnreadActivity({ ...completedTopic, status: "streaming", running: true }, { [completedTopicKey]: 1000 }, "project", "/repo", "other-topic"),
184 false,
185 "running topic keeps runtime status instead of completed-unread attention",
186 );
187
188 for (const status of ["thinking", "streaming", "waiting_confirmation", "background_job"] as const) {
189 eq(
190 projectTreeTopicArchiveBlocked({ ...completedTopic, status, running: true }),
191 true,
192 `${status} topic blocks archive`,
193 );
194 }
195
196 for (const status of ["paused", "error"] as const) {
197 eq(
198 projectTreeTopicArchiveBlocked({ ...completedTopic, status, running: true }),
199 false,
200 `${status} topic remains archivable despite the legacy running flag`,
201 );
202 }
203
204 eq(
205 projectTreeTopicArchiveBlocked({ ...completedTopic, running: true }),
206 true,
207 "legacy running topic without a detailed status blocks archive",
208 );
209
210 eq(
211 projectTreeTopicArchiveBlocked(completedTopic),
212 false,
213 "idle topic remains archivable",
214 );
215
216 eq(
217 projectTreeTopicArchiveBlocked({
218 ...completedTopic,
219 children: [
220 { ...completedTopic, key: "idle-child", kind: "session", sessionPath: "/tmp/idle.jsonl" },
221 { ...completedTopic, key: "busy-child", kind: "session", sessionPath: "/tmp/busy.jsonl", status: "background_job", running: true },
222 ],
223 }),
224 true,
225 "topic blocks archive when any runtime child has active work",
226 );
227
228 eq(
229 projectTreeShouldRenderTopicActions(false, "workbench", false),
230 true,
231 "read workbench topic renders hover actions",
232 );
233
234 eq(
235 projectTreeShouldRenderTopicActions(false, "classic", false),
236 true,
237 "read classic topic renders hover actions",
238 );
239
240 eq(
241 projectTreeShouldRenderTopicActions(false, "classic", true),
242 false,
243 "unread classic topic reserves the action column for unread attention",
244 );
245
246 eq(
247 projectTreeShouldRenderTopicActions(false, "creation", false),
248 false,
249 "creation topic keeps hover actions disabled",
250 );
251
252 eq(
253 projectTreeShouldRenderTopicActions(true, "classic", false),
254 false,
255 "runtime session rows do not render topic hover actions",
256 );
257
258 eq(
259 projectTreeShouldSuppressOpenForRename(
260 { rowKey: "topic-a", canRename: true },
261 { rowKey: "topic-a", canRename: true },
262 ),
263 true,
264 "second click on the same renameable topic suppresses open for inline rename",
265 );
266
267 eq(
268 projectTreeShouldSuppressOpenForRename(
269 { rowKey: "session-a", canRename: false },
270 { rowKey: "session-a", canRename: false },
271 ),
272 false,
273 "runtime session double-click still allows the session row to open",
274 );
275
276 eq(
277 projectTreeShouldSuppressOpenForRename(
278 { rowKey: "topic-a", canRename: true },
279 { rowKey: "topic-b", canRename: true },
280 ),
281 false,
282 "quickly clicking a different topic still opens the new target",
283 );
284
285 eq(
286 projectTreeFolderDisclosure(false, true),
287 {
288 canExpand: false,
289 isOpen: false,
290 ariaExpanded: undefined,
291 iconStackClassName: "project-tree__icon-stack",
292 },
293 "empty project folders are not exposed as expandable disclosure rows",
294 );
295
296 eq(
297 projectTreeFolderDisclosure(true, false),
298 {
299 canExpand: true,
300 isOpen: false,
301 ariaExpanded: false,
302 iconStackClassName: "project-tree__icon-stack project-tree__icon-stack--expandable",
303 },
304 "collapsed project folders keep disclosure semantics when children exist",
305 );
306
307 eq(
308 projectTreeFolderDisclosure(true, true),
309 {
310 canExpand: true,
311 isOpen: true,
312 ariaExpanded: true,
313 iconStackClassName: "project-tree__icon-stack project-tree__icon-stack--expandable",
314 },
315 "expanded project folders can show the open-folder state only when children exist",
316 );
317
318 console.log("\nclassic project tree sorting");
319
320 const classicTopic = (id: string, extra: Partial<ProjectNode> = {}): ProjectNode => ({
321 key: `topic_${id}`,
322 kind: "topic",
323 label: id,
324 root: "/repo/a",
325 topicId: id,
326 ...extra,
327 });
328
329 const classicTree: ProjectNode[] = [
330 {
331 key: "project_/repo/a",
332 kind: "project",
333 label: "a",
334 root: "/repo/a",
335 children: [
336 classicTopic("old", { lastActivityAt: 100 }),
337 classicTopic("newest", { lastActivityAt: 300 }),
338 classicTopic("blank", { createdAt: 200 }),
339 ],
340 },
341 {
342 key: "project_/repo/b",
343 kind: "project",
344 label: "b",
345 root: "/repo/b",
346 children: [classicTopic("only", { root: "/repo/b", lastActivityAt: 50 })],
347 },
348 ];
349
350 eq(
351 arrangeClassicProjectTree(classicTree, "updated").map((node) => (node.children ?? []).map((child) => child.topicId)),
352 [["newest", "blank", "old"], ["only"]],
353 "classic default sorts topics by last activity while keeping project order",
354 );
355
356 eq(
357 arrangeClassicProjectTree(
358 [
359 {
360 key: "project_/repo/a",
361 kind: "project",
362 label: "a",
363 root: "/repo/a",
364 children: [
365 classicTopic("created-first", { createdAt: 100, lastActivityAt: 900 }),
366 classicTopic("created-last", { createdAt: 500, lastActivityAt: 600 }),
367 ],
368 },
369 ],
370 "created",
371 ).map((node) => (node.children ?? []).map((child) => child.topicId)),
372 [["created-last", "created-first"]],
373 "classic created mode sorts topics by creation time",
374 );
375
376 eq(
377 arrangeClassicProjectTree(
378 [
379 {
380 key: "project_/repo/a",
381 kind: "project",
382 label: "a",
383 root: "/repo/a",
384 children: [
385 classicTopic("recent", { lastActivityAt: 900 }),
386 classicTopic("pinned-old", { lastActivityAt: 100, pinned: true }),
387 ],
388 },
389 ],
390 "updated",
391 ).map((node) => (node.children ?? []).map((child) => child.topicId)),
392 [["pinned-old", "recent"]],
393 "classic sorting keeps pinned topics above unpinned ones",
394 );
395
396 const classicPinnedSections = splitPinnedProjectTree(
397 [
398 {
399 key: "project_/repo/a",
400 kind: "project",
401 label: "a",
402 root: "/repo/a",
403 children: [
404 classicTopic("pinned-old", { lastActivityAt: 100, pinned: true }),
405 classicTopic("recent", { lastActivityAt: 900 }),
406 ],
407 },
408 {
409 key: "project_/repo/b",
410 kind: "project",
411 label: "b",
412 root: "/repo/b",
413 pinned: true,
414 children: [classicTopic("pinned-new", { root: "/repo/b", lastActivityAt: 500, pinned: true })],
415 },
416 ],
417 "updated",
418 false,
419 );
420
421 eq(
422 classicPinnedSections.pinned.map((node) => node.topicId),
423 ["pinned-new", "pinned-old"],
424 "classic pinned section collects topics across projects by activity",
425 );
426
427 eq(
428 classicPinnedSections.projects.map((node) => ({
429 root: node.root,
430 pinned: Boolean(node.pinned),
431 topics: (node.children ?? []).map((child) => child.topicId),
432 })),
433 [
434 { root: "/repo/a", pinned: false, topics: ["recent"] },
435 { root: "/repo/b", pinned: true, topics: [] },
436 ],
437 "classic pinned topics appear once while pinned projects stay in project order",
438 );
439
440 eq(
441 splitPinnedProjectTree(
442 [
443 {
444 key: "project_/repo/a",
445 kind: "project",
446 label: "a",
447 root: "/repo/a",
448 children: [classicTopic("unpinned-again", { lastActivityAt: 100 })],
449 },
450 ],
451 "updated",
452 false,
453 ),
454 {
455 pinned: [],
456 projects: [
457 {
458 key: "project_/repo/a",
459 kind: "project",
460 label: "a",
461 root: "/repo/a",
462 children: [classicTopic("unpinned-again", { lastActivityAt: 100 })],
463 },
464 ],
465 },
466 "unpinning returns a topic to its original project",
467 );
468
469 eq(
470 splitPinnedProjectTree(
471 [{ key: "project_/repo/a", kind: "project", label: "a", root: "/repo/a", pinned: true, children: [] }],
472 "updated",
473 ).pinned.map((node) => node.root),
474 ["/repo/a"],
475 "workbench pinned section still extracts pinned projects",
476 );
477
478 console.log("\nclassic topic window and hover card");
479
480 const windowTopics = Array.from({ length: 7 }, (_, i) => classicTopic(`t${i}`, { lastActivityAt: 1000 - i }));
481
482 eq(
483 (() => {
484 const { visible, hiddenCount } = classicTopicWindow(windowTopics, false);
485 return { ids: visible.map((node) => node.topicId), hiddenCount };
486 })(),
487 { ids: ["t0", "t1", "t2", "t3", "t4"], hiddenCount: 2 },
488 "classic window previews the first five topics and reports the hidden count",
489 );
490
491 eq(
492 (() => {
493 const { visible, hiddenCount } = classicTopicWindow(windowTopics, true);
494 return { count: visible.length, hiddenCount };
495 })(),
496 { count: 7, hiddenCount: 0 },
497 "classic window shows everything once the folder is toggled open",
498 );
499
500 eq(
501 classicTopicWindow(windowTopics.slice(0, 4), false),
502 { visible: windowTopics.slice(0, 4), hiddenCount: 0 },
503 "classic window leaves short folders untouched",
504 );
505
506 eq(
507 projectTreeTopicHoverCardModel(
508 { key: "topic_t", kind: "topic", label: "● Busy topic", root: "/repo", topicId: "t", turns: 3, status: "streaming" },
509 testT,
510 "my-project",
511 ),
512 {
513 title: "Busy topic",
514 statusLabel: "projectTree.status.streaming",
515 metaLine: "3 turns",
516 exactTime: "",
517 projectLabel: "my-project",
518 },
519 "hover card model strips the running marker and carries turns, status, and project",
520 );
521
522 const day = 24 * 60 * 60 * 1000;
523
524 eq(
525 (() => {
526 const card = projectTreeTopicHoverCardModel(
527 { key: "topic_old", kind: "topic", label: "Old topic", root: "/repo", topicId: "old", turns: 3, lastActivityAt: Date.now() - 30 * day },
528 testT,
529 "my-project",
530 );
531 return { exactTime: card.exactTime, metaHasTurns: card.metaLine.startsWith("3 turns · ") };
532 })(),
533 { exactTime: "", metaHasTurns: true },
534 "hover card keeps a single calendar-date copy for week-old sessions",
535 );
536
537 eq(
538 (() => {
539 const card = projectTreeTopicHoverCardModel(
540 { key: "topic_recent", kind: "topic", label: "Recent topic", root: "/repo", topicId: "recent", turns: 2, lastActivityAt: Date.now() - 2 * day },
541 testT,
542 "my-project",
543 );
544 return { hasExactTime: card.exactTime.length > 0, metaRepeatsDate: card.metaLine.includes(card.exactTime) };
545 })(),
546 { hasExactTime: true, metaRepeatsDate: false },
547 "hover card for recent sessions still pairs relative time with the exact date",
548 );
549
550 eq(
551 projectTreeDedupedExactTime("3 turns · 2026/7/7", "2026/7/7"),
552 "",
553 "row title and hover card drop the exact date the meta line already ends with",
554 );
555
556 eq(
557 projectTreeDedupedExactTime("3 turns · 2 days ago", "2026/7/12"),
558 "2026/7/12",
559 "recent sessions keep the exact date next to the relative meta line",
560 );
561
562 eq(
563 projectTreeTopicMenuOffersPin("classic"),
564 true,
565 "classic context menu offers the pin entry",
566 );
567
568 eq(
569 projectTreeTopicMenuOffersPin("workbench"),
570 true,
571 "workbench context menu keeps the pin entry",
572 );
573
574 eq(
575 projectTreeTopicMenuOffersPin("creation"),
576 false,
577 "creation context menu hides pin so the shared ordering stays untouched",
578 );
579
580 eq(
581 projectTreeFolderDisclosure(false, false, true),
582 {
583 canExpand: true,
584 isOpen: false,
585 ariaExpanded: false,
586 iconStackClassName: "project-tree__icon-stack project-tree__icon-stack--expandable",
587 },
588 "classic empty folders stay expandable so the placeholder row is reachable",
589 );
590
591 eq(
592 projectTreeFolderDisclosure(false, true, true),
593 {
594 canExpand: true,
595 isOpen: true,
596 ariaExpanded: true,
597 iconStackClassName: "project-tree__icon-stack project-tree__icon-stack--expandable",
598 },
599 "expanded classic empty folders report the open state for the placeholder",
600 );
601
602 console.log(`\n${passed} passed, ${failed} failed`);
603 if (failed > 0) process.exit(1);
604
604 lines TYPESCRIPT