返回 DeepSeek-Reasonix
chat-natural-flow.test.tsx
根目录 / desktop / frontend / src / __tests__ / chat-natural-flow.test.tsx
1 import assert from "node:assert/strict";
2 import { act } from "react";
3 import { createTranscriptHarness } from "./transcript-dom-harness";
4 import { reconcileMountedOrder, revealEarlierMountedOrder } from "../lib/chatMountedOrder";
5 import type { Item } from "../lib/useController";
6
7 for (const count of [1, 5, 23, 24, 25, 48, 49]) {
8 const resident = Array.from({ length: 60 }, (_, index) => `resident-${index}`);
9 const prefix = Array.from({ length: count }, (_, index) => `history-${index}`);
10 const full = [...prefix, ...resident];
11 let mounted = reconcileMountedOrder(resident, full);
12 assert.equal(mounted, resident, `${count}-node prepend keeps the resident order object before the first frame`);
13 let frames = 0;
14 while (mounted.length < full.length) {
15 const previous = mounted;
16 mounted = revealEarlierMountedOrder(mounted, full);
17 frames += 1;
18 assert.deepEqual(mounted.slice(-resident.length), resident, `${count}-node prepend keeps the resident suffix after frame ${frames}`);
19 assert.ok(mounted.length > previous.length && mounted.length - previous.length <= 24, `${count}-node prepend reveals a bounded frame`);
20 }
21 assert.deepEqual(mounted, full, `${count}-node prepend converges to the source order`);
22 assert.equal(frames, Math.ceil(count / 24), `${count}-node prepend uses the expected frame count`);
23 }
24
25 const harness = await createTranscriptHarness({ deterministic: true });
26 const items: Item[] = [
27 { kind: "user", id: "u1", text: "hello", checkpointTurn: 1 },
28 { kind: "tool", id: "t1", name: "read_file", args: "{}", output: "result", readOnly: true, status: "done" },
29 { kind: "assistant", id: "a1", text: "answer", reasoning: "thought", streaming: false },
30 ];
31 try {
32 await harness.loadModule("/src/components/ChatToolBody.tsx");
33 await harness.render(items);
34 await harness.settle();
35 assert.ok(harness.container.querySelector(".chat-column"));
36 assert.ok(harness.container.querySelector('[id^="reasonix-chat-transcript-"] .chat-column'), "native upgrade evidence scopes history to the transcript");
37 assert.equal(harness.container.querySelectorAll(".transcript__window-item").length, 0);
38 assert.equal(harness.container.querySelectorAll(".chat-tool").length, 0, "completed process unmounts heavy rows");
39 const disclosure = harness.container.querySelector<HTMLButtonElement>(".chat-process");
40 await act(async () => disclosure!.click());
41 const tool = harness.container.querySelector<HTMLElement>(".chat-tool [data-disclosure-row]");
42 assert.ok(tool);
43 await act(async () => tool.click());
44 await harness.settle();
45 assert.ok(harness.container.querySelector(".dsh-ToolRow-ioCard"), "tool opens an inline preview");
46 await act(async () => harness.container.querySelector<HTMLButtonElement>(".dsh-ToolRow-inspectButton")!.click());
47 assert.ok(harness.container.querySelector('[role="dialog"]'));
48 for (let i = 0; i < 60; i++) {
49 await harness.render(items.map(item => item.kind === "assistant" ? { ...item, text: `answer ${i}`, streaming: true } : item), { running: true });
50 await act(async () => { harness.resizeNotifications.forEach(notify => notify()); harness.clock.advance(16); });
51 }
52 assert.ok(harness.container.querySelector(".chat-column"), "60 geometry changes do not trip React nested update fuse");
53 await harness.render(items, { geometrySessionKey: "other" });
54 assert.equal(harness.container.querySelector('[role="dialog"]'), null, "session replacement closes details");
55
56 const restored: Item[] = [];
57 for (let turn = 1; turn <= 12; turn += 1) {
58 restored.push(
59 { kind: "user", id: `u${turn}`, text: `prompt ${turn}`, checkpointTurn: turn },
60 { kind: "assistant", id: `a${turn}`, text: `answer ${turn}`, reasoning: "", streaming: false },
61 );
62 }
63 await harness.render(restored, { geometrySessionKey: "history-identity" });
64 await harness.settle();
65 const existing = new Map(Array.from(harness.container.querySelectorAll<HTMLElement>("[data-chat-anchor-key]"))
66 .map(node => [node.dataset.chatAnchorKey!, node]));
67 assert.equal(existing.size, 60, "fixture spans multiple legacy 24-node chunks");
68 const selectedParagraph = harness.container.querySelector<HTMLElement>('[data-chat-anchor-key="a6"] .md p')!;
69 const selectedText = selectedParagraph.firstChild!;
70 const range = harness.dom.window.document.createRange();
71 range.selectNodeContents(selectedText);
72 const selection = harness.dom.window.getSelection()!;
73 selection.removeAllRanges();
74 selection.addRange(range);
75 const selectedBefore = selection.toString();
76 await harness.render([
77 { kind: "user", id: "u0", text: "older prompt", checkpointTurn: 0 },
78 { kind: "assistant", id: "a0", text: "older answer", reasoning: "", streaming: false },
79 ...restored,
80 ], { geometrySessionKey: "history-identity" });
81 await harness.settle();
82 for (const [key, node] of existing) {
83 assert.equal(harness.container.querySelector(`[data-chat-anchor-key="${key}"]`), node, `history prepend preserves ${key} DOM identity`);
84 }
85 assert.equal(selection.toString(), selectedBefore, "history prepend preserves the native text selection");
86
87 const deepHistory: Item[] = [];
88 for (let turn = 1; turn <= 15; turn += 1) {
89 deepHistory.push(
90 { kind: "user", id: `old-u${turn}`, text: `old prompt ${turn}`, checkpointTurn: -turn },
91 { kind: "assistant", id: `old-a${turn}`, text: `old answer ${turn}`, reasoning: "", streaming: false },
92 );
93 }
94 await harness.render([
95 ...deepHistory,
96 { kind: "user", id: "u0", text: "older prompt", checkpointTurn: 0 },
97 { kind: "assistant", id: "a0", text: "older answer", reasoning: "", streaming: false },
98 ...restored,
99 ], { geometrySessionKey: "history-identity" });
100 assert.equal(harness.container.querySelector('[data-chat-anchor-key="old-u1"]'), null, "deep history is still mounting after two bounded frames");
101 assert.equal(harness.container.querySelector('[data-nav-turn="old-u1"]'), null, "navigation omits history without a mounted target");
102 await harness.settle();
103 assert.ok(harness.container.querySelector('[data-chat-anchor-key="old-u1"]'), "deep history eventually mounts");
104 assert.ok(harness.container.querySelector('[data-nav-turn="old-u1"]'), "navigation publishes the target after its DOM commit");
105 let newerLoads = 0;
106 await harness.render(restored, {
107 geometrySessionKey: "history-identity",
108 hasNewerHistory: true,
109 historyStartTurn: 4,
110 historyEndTurn: 12,
111 totalTurns: 20,
112 onLoadNewerHistory: async () => { newerLoads += 1; return "loaded"; },
113 });
114 const newer = harness.container.querySelector<HTMLButtonElement>(".chat-history-newer .btn")!;
115 await act(async () => newer.click());
116 assert.equal(newerLoads, 0, "a native transcript selection protects its resident page from reclaim");
117 assert.ok(harness.container.querySelector(".chat-history-selection"), "selection protection explains why paging paused");
118 await act(async () => {
119 selection.removeAllRanges();
120 harness.dom.window.document.dispatchEvent(new harness.dom.window.Event("selectionchange"));
121 });
122 await act(async () => newer.click());
123 assert.equal(newerLoads, 1, "newer paging resumes after the selection is cleared");
124 assert.match(harness.container.querySelector(".chat-history-window")?.textContent ?? "", /5.*12.*20/, "the bounded window reports its visible turn range");
125 console.log("chat natural flow: process disclosure, details, stable history identity, mounted navigation and session isolation passed");
126 } finally { await harness.unmount(); await harness.close(); }
127
127 lines Plain Text