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