返回 DeepSeek-Reasonix
project-topic-lifecycle.test.tsx
根目录 / desktop / frontend / src / __tests__ / project-topic-lifecycle.test.tsx
1 import assert from "node:assert/strict";
2 import React, { act } from "react";
3 import { createRoot } from "react-dom/client";
4 import { JSDOM } from "jsdom";
5 import { useProjectTopicCommands } from "../app-runtime/useProjectTopicCommands";
6 import type { ProjectTopicPorts } from "../app-runtime/projectTopicOwner";
7 import { mockSessionTitleTarget } from "../lib/mockSessionTitle";
8 import type { RemoteSessionView } from "../lib/remoteTypes";
9 import type { ProjectNode } from "../lib/types";
10 import { enqueueNavigationRequest, type NavigationCoalescingRefs } from "../lib/openTopicCoalescing";
11
12 function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>(done => { resolve = done; }); return { promise, resolve }; }
13 const dom = new JSDOM("<div id='root'></div>");
14 Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true });
15 const root = createRoot(document.getElementById("root")!);
16 let commands!: ReturnType<typeof useProjectTopicCommands>;
17 const effects: string[] = [];
18 let listing = deferred<RemoteSessionView[]>();
19 const localRequests = new Map<string, ReturnType<typeof deferred<void>>>();
20 type NavigationInput = { kind: "isolated-worktree"; workspaceRoot: string };
21 const navigationRefs: NavigationCoalescingRefs<NavigationInput> = {
22 seqRef: { current: 0 }, runningRef: { current: false }, pendingRef: { current: null },
23 };
24 let navigationGate: ReturnType<typeof deferred<void>> | undefined;
25 const ports: ProjectTopicPorts = {
26 renameLocal: async (selector, title) => {
27 const id = selector.ref?.sessionId ?? selector.sessionPath ?? "";
28 effects.push(`rename:${id}:${title}`);
29 const gate = deferred<void>(); localRequests.set(id, gate); await gate.promise;
30 },
31 listRemote: async () => listing.promise,
32 renameRemote: async (_host, _workspace, name, title) => { effects.push(`remote:${name}:${title}`); },
33 markChanged: () => { effects.push("refresh-projects"); },
34 refreshTabs: async () => [],
35 syncActive: async () => { effects.push("sync-current"); },
36 };
37 const navigation = {
38 openBlank: async (scope: string, path: string) => { effects.push(`blank:${scope}:${path}`); },
39 enqueue: (input: NavigationInput) => enqueueNavigationRequest(navigationRefs, input, async request => {
40 effects.push(`worktree:${request.workspaceRoot}`);
41 if (navigationGate) await navigationGate.promise;
42 if (request.seq === navigationRefs.seqRef.current && navigationGate) effects.push(`visible:${request.workspaceRoot}`);
43 }),
44 switchFolder: async (path?: string) => { effects.push(`project:${path}`); },
45 };
46
47 const source = { hostId: "local", path: "/fixture/session.jsonl" };
48 const topicNode: ProjectNode = { key: "topic-A", kind: "topic", label: "A", topicId: "A", source };
49 const projectNode: ProjectNode = { key: "project", kind: "project", label: "Project folder", root: "/fixture", children: [topicNode] };
50 assert.equal(mockSessionTitleTarget([projectNode], { topicId: "A" }), topicNode,
51 "an unsaved titlebar session resolves by topic ID instead of its parent project");
52 assert.equal(mockSessionTitleTarget([projectNode], { source }), topicNode,
53 "source selectors resolve the same session row");
54 assert.equal(mockSessionTitleTarget([projectNode], {}), undefined,
55 "missing session identity never resolves to the project row");
56 assert.equal(mockSessionTitleTarget([projectNode], { source: { ...source, path: "/fixture/other.jsonl" } }), undefined,
57 "an unknown source never resolves to the project row");
58
59 function Probe({ tab, remote = false }: { tab: string; remote?: boolean }) {
60 commands = useProjectTopicCommands({ visible: { tabId: tab, sessionKey: tab },
61 topic: { id: tab, title: tab, target: remote
62 ? { kind: "remote", hostId: "fixture", workspace: "fixture", sessionPath: `${tab}.jsonl` }
63 : { kind: "local", topicId: tab, selector: { ref: { hostId: "local", sessionId: tab } } } },
64 ports, navigation, reportError: error => { throw error; },
65 });
66 return null;
67 }
68 async function paint(tab: string, remote = false) { await act(async () => root.render(<Probe tab={tab} remote={remote} />)); }
69 try {
70 await paint("A");
71 const first = commands;
72 await paint("B");
73 assert.equal(commands.onCreateTopic, first.onCreateTopic);
74 assert.equal(commands.onCreateIsolatedWorktree, first.onCreateIsolatedWorktree);
75 assert.equal(commands.onAddProject, first.onAddProject);
76 await commands.onCreateTopic("global", "/fixture/global-workspace");
77 await commands.onCreateIsolatedWorktree("worktree");
78 await commands.onAddProject("project");
79 assert.deepEqual(effects, ["blank:global:/fixture/global-workspace", "worktree:worktree", "project:project"],
80 "global creation retains the actual directory for workspace preferences until navigation serialization");
81
82 effects.length = 0;
83 navigationGate = deferred<void>();
84 const firstNavigation = commands.onCreateIsolatedWorktree("A");
85 const supersededNavigation = commands.onCreateIsolatedWorktree("B");
86 const lastNavigation = commands.onCreateIsolatedWorktree("C");
87 await supersededNavigation;
88 assert.deepEqual(effects, ["worktree:A"], "replaced requests do not execute while the first backend call is pending");
89 navigationGate.resolve();
90 await Promise.all([firstNavigation, lastNavigation]);
91 assert.deepEqual(effects, ["worktree:A", "worktree:C", "visible:C"], "real coalescing queue accepts the last worktree command and rejects old UI continuation");
92 assert.equal(navigationRefs.pendingRef.current, null);
93 assert.equal(navigationRefs.runningRef.current, false);
94 navigationGate = undefined;
95
96 effects.length = 0;
97 await act(async () => commands.startActiveTopicRename());
98 await act(async () => commands.setTopicTitleDraft("changed"));
99 await act(async () => commands.cancelActiveTopicRename());
100 await commands.commitActiveTopicRename();
101 assert.deepEqual(effects, [], "escape followed by blur never submits a rename");
102 await act(async () => commands.startActiveTopicRename());
103 await paint("A");
104 assert.equal(commands.topicbarEditing, false, "switching resources releases the former draft");
105
106 await paint("A", true);
107 await act(async () => commands.startActiveTopicRename());
108 await act(async () => commands.setTopicTitleDraft("source title"));
109 let pending!: Promise<void>;
110 await act(async () => { pending = commands.commitActiveTopicRename(); });
111 await paint("B", true); await paint("A", true);
112 listing.resolve([{ name: "A", path: "A.jsonl", title: "A", turns: 1 }, { name: "B", path: "B.jsonl", title: "B", turns: 1, current: true }]);
113 await act(async () => { await pending; });
114 assert.deepEqual(effects, ["remote:A:source title", "refresh-projects"], "remote current may change but rename retains A; ABA cannot resync the visible tab");
115
116 effects.length = 0;
117 await paint("A");
118 const renameA = commands.renameTopic("A", "one");
119 await paint("B");
120 const renameB = commands.renameTopic("B", "two");
121 localRequests.get("A")!.resolve(); await renameA;
122 localRequests.get("B")!.resolve(); await renameB;
123 assert.equal(effects.filter(effect => effect === "refresh-projects").length, 2, "unrelated topics retain independent operation lanes");
124
125 effects.length = 0;
126 listing = deferred<RemoteSessionView[]>();
127 await paint("A", true);
128 await act(async () => commands.startActiveTopicRename());
129 await act(async () => { pending = commands.commitActiveTopicRename(); });
130 await act(async () => root.unmount());
131 listing.resolve([{ name: "A", path: "A.jsonl", title: "A", turns: 1 }]); await pending;
132 first.onAddProject("stale");
133 assert.deepEqual(effects, [], "disposed feature cannot rename, refresh, or navigate");
134 console.log("project/topic commands: stable entry, targeted rename, independent lanes, ABA, cancel and disposal passed");
135 } finally { dom.window.close(); }
136
136 lines Plain Text