返回 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 mergeProjectTopicPage,
6 projectTreeWithoutTopic,
7 projectTreeShellChildren,
8 projectTreeEventAffectsFolder,
9 projectTreeRevisionIsFresh,
10 projectTreeTopicPageIsFresh,
11 projectTreeShouldApplyShellSnapshot,
12 defaultExpandedProjectTreeKeys,
13 activeSessionAncestorKeys,
14 projectTreeTopicOpenRequest,
15 projectTreeShouldSuppressOpenForRename,
16 projectTreeMigrateReadActivity,
17 projectTreeReadActivityKey,
18 projectTreeSeedReadActivity,
19 projectTreeTopicHasUnreadActivity,
20 topicIsActive,
21 topicStatusLabel,
22 projectTreeTopicArchiveBlocked,
23 projectTreeShouldRenderTopicActions,
24 projectTreeTopicMetaLine,
25 arrangeWorkbenchTree,
26 splitPinnedProjectTree,
27 projectTreeTopicMenuOffersPin,
28 projectTreeDedupedExactTime,
29 projectTreeShellSignature,
30 } from "../components/ProjectTree";
31 import { projectTreeTrashingTopics } from "../lib/projectTreeArchive";
32 import { normalizeProjectTreeRuntimeSnapshot } from "../lib/projectTreeRuntime";
33 import { runProjectTreeSortRuntimeTests } from "./project-tree-sort-runtime.test";
34 import { runProjectTreePinnedShellRuntimeTests } from "./project-tree-pinned-shell-runtime.test";
35 import type { ProjectNode } from "../lib/types";
36 import { readFileSync } from "node:fs";
37 import { fileURLToPath } from "node:url";
38 import { dirname, join } from "node:path";
39
40 let passed = 0;
41 let failed = 0;
42
43 function eq(a: unknown, b: unknown, label: string) {
44 if (JSON.stringify(a) === JSON.stringify(b)) {
45 process.stdout.write(` PASS ${label}\n`);
46 passed += 1;
47 } else {
48 process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
49 failed += 1;
50 }
51 }
52
53 console.log("\nproject tree runtime sessions");
54
55 eq(
56 normalizeProjectTreeRuntimeSnapshot({ revision: 0, topics: null }),
57 { revision: 0, topics: [] },
58 "runtime bridge normalizes a legacy/null topic array",
59 );
60
61 const noTrashingTopics = new Set<string>();
62 const topicATrashing = projectTreeTrashingTopics(noTrashingTopics, "topic-a", true);
63 const twoTopicsTrashing = projectTreeTrashingTopics(topicATrashing, "topic-b", true);
64 eq([...topicATrashing], ["topic-a"], "archive pending state is keyed by topic");
65 eq([...twoTopicsTrashing], ["topic-a", "topic-b"], "another topic remains independently actionable");
66 eq([...projectTreeTrashingTopics(twoTopicsTrashing, "topic-a", false)], ["topic-b"], "settling one archive leaves the other pending");
67 eq(projectTreeTrashingTopics(topicATrashing, "topic-a", true) === topicATrashing, true, "same pending state preserves Set identity");
68
69 const testT = (key: string, vars?: Record<string, string | number>) => {
70 if (key === "history.turnOne") return `${vars?.n ?? 1} turn`;
71 if (key === "history.turnOther") return `${vars?.n ?? 0} turns`;
72 return key;
73 };
74
75 const tree: ProjectNode[] = [
76 {
77 key: "global_folder",
78 kind: "global_folder",
79 label: "Global",
80 children: [
81 {
82 key: "global_topic_topic-a",
83 kind: "global_topic",
84 label: "Topic A",
85 topicId: "topic-a",
86 children: [
87 {
88 key: "global_session_a",
89 kind: "global_session",
90 label: "Session A",
91 topicId: "topic-a",
92 sessionPath: "/tmp/a.jsonl",
93 },
94 {
95 key: "global_session_b",
96 kind: "global_session",
97 label: "Session B",
98 topicId: "topic-a",
99 sessionPath: "/tmp/b.jsonl",
100 },
101 ],
102 },
103 {
104 key: "global_topic_topic-b",
105 kind: "global_topic",
106 label: "Topic B",
107 topicId: "topic-b",
108 },
109 ],
110 },
111 ];
112
113 eq(
114 defaultExpandedProjectTreeKeys(tree),
115 [],
116 "without an active tab, no folders default to expanded",
117 );
118
119 eq(
120 defaultExpandedProjectTreeKeys(tree, "global", "", "topic-a", "/tmp/b.jsonl"),
121 ["global_folder", "global_topic_topic-a"],
122 "active session path expands only ancestor folders",
123 );
124
125 eq(
126 activeSessionAncestorKeys(tree, "global", "", "topic-a", "/tmp/b.jsonl"),
127 ["global_folder", "global_topic_topic-a"],
128 "activeSessionAncestorKeys matches defaultExpandedProjectTreeKeys for active session",
129 );
130
131 eq(
132 activeSessionAncestorKeys(tree, "global", "", "topic-b"),
133 ["global_folder"],
134 "active topic without runtime session rows expands only parent folders",
135 );
136
137 // Waiting confirmation copy is stable for both compact and expanded sidebars.
138 eq(
139 testT("projectTree.status.waitingConfirmation"),
140 "projectTree.status.waitingConfirmation",
141 "waiting confirmation key stays available for side-bar pill and badge",
142 );
143
144 eq(
145 projectTreeTopicOpenRequest(tree[0].children?.[0].children?.[1] as ProjectNode),
146 { scope: "global", workspaceRoot: "", topicId: "topic-a", sessionPath: "/tmp/b.jsonl" },
147 "runtime session row opens the concrete session path",
148 );
149
150 eq(
151 projectTreeTopicOpenRequest({
152 key: "topic_project",
153 kind: "topic",
154 label: "Project topic",
155 root: "/repo",
156 topicId: "topic-project",
157 }),
158 { scope: "project", workspaceRoot: "/repo", topicId: "topic-project", sessionPath: undefined },
159 "regular project topic still opens by topic",
160 );
161
162 eq(
163 projectTreeTopicMetaLine({
164 key: "global_topic_missing_time",
165 kind: "global_topic",
166 label: "Old empty topic",
167 topicId: "missing-time",
168 }, testT),
169 "projectTree.previously",
170 "topic with no turns and no timestamps renders previous-time fallback meta",
171 );
172
173 eq(
174 projectTreeTopicMetaLine({
175 key: "global_topic_indexing",
176 kind: "global_topic",
177 label: "Legacy topic",
178 topicId: "indexing",
179 turns: 0,
180 turnsState: "unknown",
181 }, testT),
182 "history.indexing",
183 "unknown legacy turn counts are never presented as zero turns",
184 );
185
186 eq(
187 projectTreeTopicMetaLine({
188 key: "global_topic_recent",
189 kind: "global_topic",
190 label: "Recent blank topic",
191 topicId: "recent",
192 createdAt: Date.now(),
193 }, testT),
194 "projectTree.justNow",
195 "topic with a real recent timestamp still renders just-now meta",
196 );
197
198 const completedTopic: ProjectNode = {
199 key: "topic_complete",
200 kind: "topic",
201 label: "Completed",
202 root: "/repo",
203 topicId: "topic-complete",
204 lastActivityAt: 2000,
205 session: { hostId: "local", sessionId: "session-complete" },
206 resultSequence: 20,
207 turnsState: "ready",
208 };
209 const completedTopicKey = projectTreeReadActivityKey(completedTopic) ?? "";
210 const legacyTopicKey = "project\u001f/repo\u001ftopic-complete";
211 const legacyCompletedSessionKey = "session\u001flocal\u001fcomplete";
212 const migratedReadActivity = projectTreeMigrateReadActivity({
213 [legacyCompletedSessionKey]: 0,
214 ["session\u001flocal\u001falready-read"]: 12,
215 [legacyTopicKey]: 2000,
216 }, 1);
217 eq(migratedReadActivity[legacyCompletedSessionKey], undefined, "read-state v2 removes a placeholder session zero baseline");
218 eq(migratedReadActivity["session\u001flocal\u001falready-read"], 12, "read-state v2 preserves durable session read progress");
219 eq(migratedReadActivity[legacyTopicKey], 2000, "read-state v2 preserves legacy topic read progress");
220 eq(
221 projectTreeMigrateReadActivity({ [completedTopicKey]: 0 }, 2)[completedTopicKey],
222 0,
223 "read-state v2 migration runs only once so new-session zero baselines remain durable",
224 );
225
226 const zeroResultTopic = { ...completedTopic, resultSequence: 0 };
227 const zeroResultKey = projectTreeReadActivityKey(zeroResultTopic) ?? "";
228 const seededBeforeCompletion = projectTreeSeedReadActivity([zeroResultTopic], {});
229 eq(seededBeforeCompletion[zeroResultKey], 0, "canonical sessions seed a zero result baseline before their first run completes");
230 eq(
231 projectTreeTopicHasUnreadActivity(completedTopic, seededBeforeCompletion, "project", "/repo", "other-topic"),
232 true,
233 "the first background result after a zero baseline shows unread attention",
234 );
235 const seededHistorical = projectTreeSeedReadActivity([completedTopic], {});
236 eq(
237 projectTreeTopicHasUnreadActivity(completedTopic, seededHistorical, "project", "/repo", "other-topic"),
238 false,
239 "historical results present on first observation seed as already read",
240 );
241 const pendingHistoricalTopic = { ...completedTopic, resultSequence: 0, turnsState: "pending" };
242 const pendingHistorical = projectTreeSeedReadActivity([pendingHistoricalTopic], {});
243 eq(
244 pendingHistorical[completedTopicKey],
245 undefined,
246 "pending catalog metadata does not persist a placeholder result baseline",
247 );
248 const rebuiltHistorical = projectTreeSeedReadActivity([completedTopic], pendingHistorical);
249 eq(
250 rebuiltHistorical[completedTopicKey],
251 20,
252 "the first ready catalog projection seeds the durable historical result as read",
253 );
254 eq(
255 projectTreeTopicHasUnreadActivity(completedTopic, rebuiltHistorical, "project", "/repo", "other-topic"),
256 false,
257 "pending-to-ready catalog migration does not show historical results as unread",
258 );
259
260 eq(
261 projectTreeTopicHasUnreadActivity(completedTopic, { [completedTopicKey]: 10 }, "project", "/repo", "other-topic"),
262 true,
263 "completed inactive topic with newer activity shows unread attention",
264 );
265
266 eq(
267 projectTreeTopicHasUnreadActivity(completedTopic, { [completedTopicKey]: 20 }, "project", "/repo", "other-topic"),
268 false,
269 "completed topic stops showing unread attention once opened at its latest activity",
270 );
271
272 eq(
273 projectTreeTopicHasUnreadActivity(completedTopic, { [completedTopicKey]: 10 }, "project", "/repo", "topic-complete", "session-id:session-complete"),
274 false,
275 "active topic does not show unread attention",
276 );
277
278 eq(
279 projectTreeTopicHasUnreadActivity({ ...completedTopic, status: "streaming", running: true }, { [completedTopicKey]: 10 }, "project", "/repo", "other-topic"),
280 false,
281 "running topic keeps runtime status instead of completed-unread attention",
282 );
283
284 const relocatedTopic = { ...completedTopic, sessionPath: "/s/b.jsonl" };
285 const relocatedKey = projectTreeReadActivityKey({ ...completedTopic, sessionPath: "/s/a.jsonl" }) ?? "";
286 eq(
287 projectTreeReadActivityKey(relocatedTopic),
288 relocatedKey,
289 "unread key stays on the logical topic when the representative path changes",
290 );
291 eq(
292 projectTreeTopicHasUnreadActivity(relocatedTopic, { [relocatedKey]: 20 }, "project", "/repo", "other-topic"),
293 false,
294 "marking a topic read survives a later representative-path refresh",
295 );
296 eq(
297 projectTreeTopicHasUnreadActivity(completedTopic, {}, "project", "/repo", "other-topic", undefined, 2000),
298 false,
299 "a canonical session is initially seeded instead of treating history as unread",
300 );
301 eq(
302 projectTreeTopicHasUnreadActivity({ ...completedTopic, lastActivityAt: 999999 }, { [completedTopicKey]: 20 }, "project", "/repo", "other-topic"),
303 false,
304 "metadata activity does not re-arm a read canonical result",
305 );
306 eq(
307 topicIsActive({ ...completedTopic, sessionPath: "/s/a.jsonl" }, "project", "/repo", "topic-complete", "/s/other.jsonl"),
308 false,
309 "another canonical session cannot become active through a shared topic",
310 );
311
312 for (const status of ["thinking", "streaming", "waiting_confirmation", "background_job"] as const) {
313 eq(
314 projectTreeTopicArchiveBlocked({ ...completedTopic, status, running: true }),
315 true,
316 `${status} topic blocks archive`,
317 );
318 }
319
320 for (const status of ["paused", "error", "awaiting_delivery"] as const) {
321 eq(
322 projectTreeTopicArchiveBlocked({ ...completedTopic, status, running: true }),
323 false,
324 `${status} topic remains archivable despite the legacy running flag`,
325 );
326 }
327
328 eq(
329 topicStatusLabel({ ...completedTopic, status: "awaiting_delivery" }, testT),
330 "projectTree.status.awaitingDelivery",
331 "delivery-check pause uses its own sidebar label, not paused",
332 );
333 eq(
334 topicStatusLabel({ ...completedTopic, status: "paused" }, testT),
335 "projectTree.status.paused",
336 "recovery pause keeps the paused sidebar label",
337 );
338
339 eq(
340 projectTreeTopicArchiveBlocked({ ...completedTopic, running: true }),
341 true,
342 "legacy running topic without a detailed status blocks archive",
343 );
344
345 eq(
346 projectTreeTopicArchiveBlocked(completedTopic),
347 false,
348 "idle topic remains archivable",
349 );
350
351 eq(
352 projectTreeTopicArchiveBlocked({
353 ...completedTopic,
354 children: [
355 { ...completedTopic, key: "idle-child", kind: "session", sessionPath: "/tmp/idle.jsonl" },
356 { ...completedTopic, key: "busy-child", kind: "session", sessionPath: "/tmp/busy.jsonl", status: "background_job", running: true },
357 ],
358 }),
359 true,
360 "topic blocks archive when any runtime child has active work",
361 );
362
363 eq(
364 projectTreeShouldRenderTopicActions(false, "workbench", false),
365 true,
366 "read workbench topic renders row actions",
367 );
368
369 eq(
370 projectTreeShouldRenderTopicActions(false, "workbench", true),
371 false,
372 "unread workbench topic reserves the action column for unread attention",
373 );
374
375 eq(
376 projectTreeShouldRenderTopicActions(false, "creation", false),
377 false,
378 "creation topic keeps row actions disabled",
379 );
380
381 eq(
382 projectTreeShouldRenderTopicActions(true, "workbench", false),
383 false,
384 "runtime session rows do not render topic actions",
385 );
386
387 eq(
388 projectTreeShouldSuppressOpenForRename(
389 { rowKey: "topic-a", canRename: true },
390 { rowKey: "topic-a", canRename: true },
391 ),
392 true,
393 "second click on the same renameable topic suppresses open for inline rename",
394 );
395
396 eq(
397 projectTreeShouldSuppressOpenForRename(
398 { rowKey: "session-a", canRename: false },
399 { rowKey: "session-a", canRename: false },
400 ),
401 false,
402 "runtime session double-click still allows the session row to open",
403 );
404
405 eq(
406 projectTreeShouldSuppressOpenForRename(
407 { rowKey: "topic-a", canRename: true },
408 { rowKey: "topic-b", canRename: true },
409 ),
410 false,
411 "quickly clicking a different topic still opens the new target",
412 );
413
414 eq(
415 projectTreeFolderDisclosure(false, true),
416 {
417 canExpand: false,
418 isOpen: false,
419 ariaExpanded: undefined,
420 iconStackClassName: "project-tree__icon-stack",
421 },
422 "empty project folders are not exposed as expandable disclosure rows",
423 );
424
425 eq(
426 projectTreeFolderDisclosure(true, false),
427 {
428 canExpand: true,
429 isOpen: false,
430 ariaExpanded: false,
431 iconStackClassName: "project-tree__icon-stack project-tree__icon-stack--expandable",
432 },
433 "collapsed project folders keep disclosure semantics when children exist",
434 );
435
436 eq(
437 projectTreeFolderDisclosure(true, true),
438 {
439 canExpand: true,
440 isOpen: true,
441 ariaExpanded: true,
442 iconStackClassName: "project-tree__icon-stack project-tree__icon-stack--expandable",
443 },
444 "expanded project folders can show the open-folder state only when children exist",
445 );
446
447 console.log("\nproject tree sorting");
448
449 const topicNode = (id: string, extra: Partial<ProjectNode> = {}): ProjectNode => ({
450 key: `topic_${id}`,
451 kind: "topic",
452 label: id,
453 root: "/repo/a",
454 topicId: id,
455 ...extra,
456 });
457
458 const sortTree: ProjectNode[] = [
459 {
460 key: "project_/repo/a",
461 kind: "project",
462 label: "a",
463 root: "/repo/a",
464 children: [
465 topicNode("old", { lastActivityAt: 100 }),
466 topicNode("newest", { lastActivityAt: 300 }),
467 topicNode("blank", { createdAt: 200 }),
468 ],
469 },
470 {
471 key: "project_/repo/b",
472 kind: "project",
473 label: "b",
474 root: "/repo/b",
475 children: [topicNode("only", { root: "/repo/b", lastActivityAt: 50 })],
476 },
477 ];
478
479 // Creation mode is the surviving non-compact arrangement: project order, with
480 // each folder's topics sorted by the stored sort mode.
481 eq(
482 arrangeWorkbenchTree(sortTree, "updated").map((node) => (node.children ?? []).map((child) => child.topicId)),
483 [["newest", "blank", "old"], ["only"]],
484 "project arrange sorts topics by last activity while keeping project order",
485 );
486
487 eq(
488 arrangeWorkbenchTree(
489 [
490 {
491 key: "project_/repo/a",
492 kind: "project",
493 label: "a",
494 root: "/repo/a",
495 children: [
496 topicNode("created-first", { createdAt: 100, lastActivityAt: 900 }),
497 topicNode("created-last", { createdAt: 500, lastActivityAt: 600 }),
498 ],
499 },
500 ],
501 "created",
502 ).map((node) => (node.children ?? []).map((child) => child.topicId)),
503 [["created-last", "created-first"]],
504 "created sort mode orders topics by creation time",
505 );
506
507 eq(
508 arrangeWorkbenchTree(
509 [
510 {
511 key: "project_/repo/a",
512 kind: "project",
513 label: "a",
514 root: "/repo/a",
515 children: [
516 topicNode("recent", { lastActivityAt: 900 }),
517 topicNode("pinned-old", { lastActivityAt: 100, pinned: true }),
518 ],
519 },
520 ],
521 "updated",
522 ).map((node) => (node.children ?? []).map((child) => child.topicId)),
523 [["pinned-old", "recent"]],
524 "topic sorting keeps pinned topics above unpinned ones",
525 );
526
527 const pinnedSections = splitPinnedProjectTree(
528 [
529 {
530 key: "project_/repo/a",
531 kind: "project",
532 label: "a",
533 root: "/repo/a",
534 children: [
535 topicNode("pinned-old", { lastActivityAt: 100, pinned: true }),
536 topicNode("recent", { lastActivityAt: 900 }),
537 ],
538 },
539 {
540 key: "project_/repo/b",
541 kind: "project",
542 label: "b",
543 root: "/repo/b",
544 pinned: true,
545 children: [topicNode("pinned-new", { root: "/repo/b", lastActivityAt: 500, pinned: true })],
546 },
547 ],
548 "updated",
549 false,
550 );
551
552 eq(
553 pinnedSections.pinned.map((node) => node.topicId),
554 ["pinned-new", "pinned-old"],
555 "creation pinned section collects topics across projects by activity",
556 );
557
558 eq(
559 pinnedSections.projects.map((node) => ({
560 root: node.root,
561 pinned: Boolean(node.pinned),
562 topics: (node.children ?? []).map((child) => child.topicId),
563 })),
564 [
565 { root: "/repo/a", pinned: false, topics: ["recent"] },
566 { root: "/repo/b", pinned: true, topics: [] },
567 ],
568 "creation pinned topics appear once while pinned projects stay inline in project order",
569 );
570
571 eq(
572 splitPinnedProjectTree(
573 [
574 {
575 key: "project_/repo/a",
576 kind: "project",
577 label: "a",
578 root: "/repo/a",
579 children: [topicNode("unpinned-again", { lastActivityAt: 100 })],
580 },
581 ],
582 "updated",
583 false,
584 ),
585 {
586 pinned: [],
587 projects: [
588 {
589 key: "project_/repo/a",
590 kind: "project",
591 label: "a",
592 root: "/repo/a",
593 children: [topicNode("unpinned-again", { lastActivityAt: 100 })],
594 },
595 ],
596 },
597 "unpinning returns a topic to its original project",
598 );
599
600 eq(
601 splitPinnedProjectTree(
602 [{ key: "project_/repo/a", kind: "project", label: "a", root: "/repo/a", pinned: true, children: [] }],
603 "updated",
604 ).pinned.map((node) => node.root),
605 ["/repo/a"],
606 "workbench pinned section still extracts pinned projects",
607 );
608
609 console.log("\nproject tree topic labels");
610
611 eq(
612 projectTreeDedupedExactTime("3 turns · 2026/7/7", "2026/7/7"),
613 "",
614 "row title drops the exact date the meta line already ends with",
615 );
616
617 eq(
618 projectTreeDedupedExactTime("3 turns · 2 days ago", "2026/7/12"),
619 "2026/7/12",
620 "recent sessions keep the exact date next to the relative meta line",
621 );
622
623 eq(
624 projectTreeTopicMenuOffersPin("workbench"),
625 true,
626 "workbench context menu keeps the pin entry",
627 );
628
629 eq(
630 projectTreeTopicMenuOffersPin("creation"),
631 false,
632 "creation context menu hides pin so the shared ordering stays untouched",
633 );
634
635 eq(
636 projectTreeFolderDisclosure(false, false, true),
637 {
638 canExpand: true,
639 isOpen: false,
640 ariaExpanded: false,
641 iconStackClassName: "project-tree__icon-stack project-tree__icon-stack--expandable",
642 },
643 "empty project shells stay expandable so their first page can load",
644 );
645
646 eq(
647 projectTreeFolderDisclosure(false, true, true),
648 {
649 canExpand: true,
650 isOpen: true,
651 ariaExpanded: true,
652 iconStackClassName: "project-tree__icon-stack project-tree__icon-stack--expandable",
653 },
654 "expanded empty project shells report the open state before their first page arrives",
655 );
656
657 eq(
658 [projectTreeRevisionIsFresh(12, 11), projectTreeRevisionIsFresh(12, 12), projectTreeRevisionIsFresh(12, 13)],
659 [false, true, true],
660 "project tree ignores stale snapshots and pages while accepting the current revision",
661 );
662
663 eq(
664 [
665 projectTreeTopicPageIsFresh({ "project-a": 11, "project-b": 9 }, "project-a", 10),
666 projectTreeTopicPageIsFresh({ "project-a": 11, "project-b": 9 }, "project-b", 10),
667 ],
668 [false, true],
669 "a newer revision in one project does not discard another project's slower page",
670 );
671
672 eq(
673 [
674 projectTreeShouldApplyShellSnapshot({ currentRevision: 1, incomingRevision: 0, treeEmpty: true }),
675 projectTreeShouldApplyShellSnapshot({ currentRevision: 1, incomingRevision: 0, treeEmpty: false }),
676 projectTreeShouldApplyShellSnapshot({ currentRevision: 1, incomingRevision: 2, treeEmpty: false }),
677 ],
678 [true, false, true],
679 "empty-tree shell snapshots apply even after a faster catalog revision event",
680 );
681
682 eq(
683 mergeProjectTopicPage(
684 [
685 { key: "topic-a", kind: "topic", label: "A", topicId: "a" },
686 { key: "topic-b", kind: "topic", label: "Old B", topicId: "b" },
687 ],
688 [
689 { key: "topic-b", kind: "topic", label: "New B", topicId: "b" },
690 { key: "topic-c", kind: "topic", label: "C", topicId: "c" },
691 ],
692 true,
693 ).map((node) => `${node.key}:${node.label}`),
694 ["topic-a:A", "topic-b:New B", "topic-c:C"],
695 "overlapping keyset pages replace duplicates without changing stable order",
696 );
697
698 eq(
699 projectTreeWithoutTopic(
700 [
701 {
702 key: "p",
703 kind: "project",
704 label: "P",
705 children: [
706 { key: "topic_archive", kind: "topic", label: "Archive me", topicId: "topic_archive" },
707 { key: "topic_keep", kind: "topic", label: "Keep me", topicId: "topic_keep" },
708 ],
709 },
710 ],
711 "topic_archive",
712 ).map((node) => (node.children ?? []).map((child) => child.topicId)),
713 [["topic_keep"]],
714 "archiving a topic removes it from loaded children before the catalog reloads",
715 );
716
717 eq(
718 projectTreeShellChildren(
719 [{ key: "topic_keep", kind: "topic", label: "Keep me", topicId: "topic_keep" }],
720 ),
721 [{ key: "topic_keep", kind: "topic", label: "Keep me", topicId: "topic_keep" }],
722 "a mutation refresh keeps sibling conversations visible until the replacement page arrives",
723 );
724
725 const projectTreeSource = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../components/ProjectTree.tsx"), "utf8");
726 const projectTreeArchiveSource = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../lib/projectTreeArchive.ts"), "utf8");
727 eq(projectTreeSource.includes("projectTreeWithoutTopic("), true, "TrashTopic removes the archived row before the shell refresh");
728 eq(
729 projectTreeArchiveSource.includes("reloadTopicKeys"),
730 true,
731 "archive refresh reloads only the affected topic folder after preserving the painted siblings",
732 );
733 runProjectTreePinnedShellRuntimeTests(eq, projectTreeSource);
734 await runProjectTreeSortRuntimeTests(eq, projectTreeSource);
735
736 eq(
737 [
738 projectTreeEventAffectsFolder({ key: "global", kind: "global_folder", label: "Global" }, [""]),
739 projectTreeEventAffectsFolder({ key: "p", kind: "project", label: "P", root: "/repo" }, ["/other"]),
740 projectTreeEventAffectsFolder({ key: "p", kind: "project", label: "P", root: "/repo" }, []),
741 ],
742 [true, false, true],
743 "revision events refresh only affected expanded roots, with an empty roots list as broadcast",
744 );
745
746 const shellWithTopics = (label: string): ProjectNode => ({
747 key: "p1",
748 kind: "project",
749 label,
750 root: "/repo",
751 children: [{ key: "t1", kind: "topic", label: "T1", topicId: "t1" }],
752 });
753 eq(
754 [
755 projectTreeShellSignature([{ key: "p1", kind: "project", label: "P" }]),
756 projectTreeShellSignature([shellWithTopics("P")]),
757 projectTreeShellSignature([{ key: "p1", kind: "project", label: "P" }, { key: "p2", kind: "project", label: "P2" }]),
758 ],
759 ["p1", "p1", "p1\u001fp2"],
760 "shell signature tracks project arrivals only, so topic page loads cannot re-arm the reload effect",
761 );
762
763 console.log(`\n${passed} passed, ${failed} failed`);
764 if (failed > 0) process.exit(1);
765
765 lines TYPESCRIPT