返回 DeepSeek-Reasonix
history-read-switching.test.tsx
根目录 / desktop / frontend / src / __tests__ / history-read-switching.test.tsx
1 import assert from "node:assert/strict";
2 import { mock } from "node:test";
3 import { JSDOM } from "jsdom";
4 import React, { act } from "react";
5 import { createRoot } from "react-dom/client";
6 import type { ReasonixDesktopHost } from "../lib/desktopHost";
7 import type { SessionMeta } from "../lib/types";
8
9 const dom = new JSDOM('<div id="root"></div>', { url: "http://localhost/" });
10 Object.assign(globalThis, { window: dom.window, document: dom.window.document, localStorage: dom.window.localStorage, IS_REACT_ACT_ENVIRONMENT: true });
11 type Request = { workspaceRoot: string; cursor: string };
12 const reads: { name: string; req: Request; resolve: (page: unknown) => void }[] = [];
13 const released: string[] = [];
14 window.reasonixDesktop = {
15 kind: "electron", contract: { commands: ["ListHistorySessions", "SearchHistoryContent", "ReleaseReadSnapshot"] }, native: { window: {} },
16 on: () => () => {},
17 invoke: (name: string, args: unknown[]) => {
18 if (name === "ReleaseReadSnapshot") { released.push(String(args[0])); return Promise.resolve(); }
19 return new Promise(resolve => reads.push({ name, req: args[0] as Request, resolve }));
20 },
21 } as unknown as ReasonixDesktopHost;
22 const { useHistoryCatalog } = await import("../lib/useHistoryCatalog");
23 let state!: ReturnType<typeof useHistoryCatalog>;
24 function Harness({ project, session, trash = false, sessionId }: { project: string; session: string; trash?: boolean; sessionId?: string }) {
25 state = useHistoryCatalog({ isTrash: trash, suppliedSessions: [{ path: session, sessionId, workspaceRoot: project, current: true } as SessionMeta], scope: "project", status: "current", timeFilter: "all", query: "marker" });
26 return <div>{state.searchHits.map(hit => hit.sessionPath).join(",")}</div>;
27 }
28 const root = createRoot(document.getElementById("root")!);
29 const render = async (project: string, session: string, trash = false, sessionId?: string) => { await act(async () => root.render(<Harness project={project} session={session} trash={trash} sessionId={sessionId} />)); };
30 const advance = async () => { await act(async () => mock.timers.tick(200)); };
31 async function finish(start: number, session: string, id: string) {
32 await act(async () => {
33 for (const read of reads.slice(start, start + 2)) {
34 const search = read.name === "SearchHistoryContent";
35 read.resolve({ items: Array.from({ length: 50 }, (_, i) => search ? { sessionPath: session, messageIndex: i, snippet: session } : { path: session, current: true }), nextCursor: `${id}-cursor`, snapshotId: `${id}-${search ? "hits" : "sessions"}`, revision: 1, status: { indexed: 1, total: 1 } });
36 }
37 });
38 }
39 mock.timers.enable({ apis: ["setTimeout", "Date"] });
40 try {
41 await render("/A", "/A/1"); await advance(); await finish(0, "/A/1", "a1");
42 assert.equal(state.sessions[0].path, "/A/1");
43 const oldLoadMore = state.loadMore;
44 await act(async () => state.loadMore());
45 assert.equal(reads.length, 4);
46 await render("/B", "/B/1");
47 assert.equal(state.searchHits.length, 0, "A must disappear before B's debounce completes");
48 assert.equal(state.nextCursor, "");
49 await act(async () => { state.loadMore(); oldLoadMore(); });
50 assert.equal(reads.length, 4, "neither old nor new callbacks may send A's cursor under B");
51 await advance();
52 assert.equal(reads[4].req.workspaceRoot, "/B");
53 assert.equal(reads[4].req.cursor, "");
54 await finish(4, "/B/1", "b1");
55 await finish(2, "/A/late", "a1");
56 assert.equal(state.sessions[0].path, "/B/1", "late A append cannot overwrite B");
57 assert.ok(!released.includes("b1-hits"), "late A cannot release B's handle");
58
59 await render("/B", "/B/2"); await advance();
60 assert.equal(reads.length, 8, "same-project session switch starts a new generation");
61 await render("/A", "/A/1"); await advance();
62 await finish(8, "/A/1", "a2");
63 await finish(6, "/B/2", "b2");
64 assert.equal(state.sessions[0].path, "/A/1", "A -> B -> A rejects older B completion");
65 assert.ok(released.includes("b2-hits"));
66 assert.ok(released.includes("a1-hits"));
67 assert.ok(released.includes("b1-hits"));
68 await render("/A", "/A/1", true);
69 await act(async () => state.loadMore()); await advance();
70 assert.equal(reads.length, 10, "trash does not continue a history read");
71 assert.equal(state.searchHits.length, 0);
72 await render("/A", "", false, "canonical-a"); await advance(); await finish(10, "canonical-a", "ca");
73 await render("/A", "", false, "canonical-b");
74 assert.equal(state.searchHits.length, 0, "canonical session identity does not depend on a legacy path");
75 await advance(); await finish(12, "canonical-b", "cb");
76 assert.equal(reads.length, 14);
77 assert.equal(state.sessions[0].path, "canonical-b");
78 await act(async () => root.unmount());
79 assert.ok(released.includes("a2-hits"));
80 } finally { mock.timers.reset(); }
81 console.log("PASS project/session A-B-A switching, debounce clicks, late responses, trash and snapshot ownership");
82
82 lines Plain Text