返回 DeepSeek-Reasonix
chat-turn-outline-jump.test.tsx
根目录 / desktop / frontend / src / __tests__ / chat-turn-outline-jump.test.tsx
1 import assert from "node:assert/strict";
2 import { act } from "react";
3 import { createTranscriptHarness } from "./transcript-dom-harness";
4 import type { TranscriptOutlineStore } from "../lib/transcriptOutlineStore";
5 import type { TranscriptOutlineEntry, TranscriptOutlinePage } from "../lib/transcriptProtocol";
6 import type { Item } from "../lib/useController";
7
8 const TAB = "outline-jump-tab";
9 const SNAPSHOT = "snapshot-1";
10 const TOTAL = 6;
11
12 function entry(turn: number): TranscriptOutlineEntry {
13 return {
14 id: `m:u${turn}`, messageId: `u${turn}`, turn, order: turn * 2 - 2,
15 prompt: `outline prompt ${turn}`, answer: `outline answer ${turn}`,
16 };
17 }
18
19 function outlinePage(): TranscriptOutlinePage {
20 return {
21 protocolVersion: 1, snapshotId: SNAPSHOT, stale: false,
22 entries: Array.from({ length: TOTAL }, (_, index) => entry(index + 1)),
23 nextOffset: TOTAL, done: true, total: TOTAL,
24 };
25 }
26
27 /** The loaded body window: the newest turns, oldest first, as the transcript
28 * loads them — an earlier page prepends above this.
29 *
30 * The user items carry the runtime shape of a question written in this app
31 * session: the reducer keeps the optimistic `u<seq>` id after the authoritative
32 * message settles and only attaches `messageId`, so matching the rail by the
33 * shape of the anchor key would miss them. */
34 function turnsFrom(start: number): Item[] {
35 const items: Item[] = [];
36 for (let index = start; index <= TOTAL; index += 1) {
37 items.push({ kind: "user", id: `u${index}`, messageId: `u${index}`, text: `loaded prompt ${index}`, historyTurn: index });
38 items.push({ kind: "assistant", id: `a${index}`, text: `loaded answer ${index}`, reasoning: "", streaming: false });
39 }
40 return items;
41 }
42
43 const harness = await createTranscriptHarness({ deterministic: true });
44 let store: TranscriptOutlineStore | undefined;
45 try {
46 await harness.loadModule("/src/components/ChatToolBody.tsx");
47 // The component resolves the store through the harness's module graph, so the
48 // test must take the same singleton instance rather than its own import.
49 const outlineModule = await harness.loadModule<{
50 getTranscriptOutlineStore: () => TranscriptOutlineStore;
51 }>("/src/lib/transcriptOutlineStore.ts");
52 store = outlineModule.getTranscriptOutlineStore();
53 store.register(TAB, async () => outlinePage());
54 await store.sync(TAB, SNAPSHOT);
55 assert.equal(store.getView(TAB).mode, "ready", "the outline is bound to the snapshot");
56
57 // Only the newest two turns are loaded. The rail must still show the whole
58 // conversation, which is the reported defect.
59 let windowStart = TOTAL - 1;
60 let pages = 0;
61 const pageCommits: Array<(loaded: boolean) => void> = [];
62 const render = () => harness.render(turnsFrom(windowStart), {
63 tabId: TAB, totalTurns: TOTAL, hasOlderHistory: windowStart > 1, historyStartTurn: windowStart - 1,
64 onLoadOlderHistory: async () => {
65 pages += 1;
66 if (windowStart <= 1) return false;
67 windowStart = Math.max(1, windowStart - 2);
68 // The parent commit is driven outside the click's act() scope. Rendering
69 // recursively from this callback creates overlapping act() calls and can
70 // leave later props uncommitted, which previously disguised hasOlder.
71 return new Promise<boolean>(resolve => { pageCommits.push(resolve); });
72 },
73 });
74 await render();
75 await harness.settle();
76
77 const marks = () => Array.from(harness.container.querySelectorAll<HTMLElement>("[data-nav-turn]"));
78 // The rail is a lazily imported chunk, so let it commit before asserting.
79 await harness.waitFor(() => marks().length === TOTAL, "the rail to list the complete outline");
80 assert.equal(marks().length, TOTAL, "the rail lists every turn, not only the loaded ones");
81 assert.deepEqual(
82 marks().map(mark => mark.dataset.navTurn),
83 Array.from({ length: TOTAL }, (_, index) => `m:u${index + 1}`),
84 "rail order follows the complete conversation",
85 );
86 const unloaded = marks().filter(mark => mark.dataset.navUnloaded === "true");
87 assert.deepEqual(unloaded.map(mark => mark.dataset.navTurn), ["m:u1", "m:u2", "m:u3", "m:u4"],
88 "turns without a mounted body are marked unloaded");
89 assert.equal(harness.container.querySelector('[data-chat-anchor-key="u1"]'), null, "the oldest turn is not loaded yet");
90
91 // A question written in this app session keeps its optimistic `u<seq>` anchor
92 // key, which does not match its outline record id. It must still be one mark
93 // — matched by identity — and not a duplicate "unloaded" entry beside a
94 // loaded one.
95 const keys = new Set(marks().map(mark => mark.dataset.navTurn));
96 assert.equal(keys.size, TOTAL, "every turn appears exactly once despite mismatched anchor keys");
97 assert.equal(marks().find(mark => mark.dataset.navTurn === "m:u6")?.dataset.navUnloaded, undefined,
98 "a loaded turn with an optimistic anchor key is not marked unloaded");
99
100 // Absolute turn numbering must survive loading an earlier page.
101 const labelsBefore = marks().map(mark => mark.getAttribute("aria-label"));
102 assert.match(labelsBefore[0]!, /1/, "the first mark is turn 1");
103
104 // Clicking an unloaded turn pages history in until its node is mounted.
105 const targetMark = marks().find(mark => mark.dataset.navTurn === "m:u1")!;
106 act(() => { targetMark.click(); });
107 for (let page = 0; page < 2; page += 1) {
108 await harness.waitFor(() => pageCommits.length > 0, `history page ${page + 1} to be requested`);
109 await render();
110 const commit = pageCommits.shift()!;
111 await act(async () => { commit(true); await Promise.resolve(); });
112 }
113 await harness.waitFor(
114 () => harness.container.querySelector('[data-chat-anchor-key="u1"]') !== null,
115 "the oldest turn's node to mount",
116 );
117 await harness.settle();
118 assert.ok(pages >= 2, "the jump paged older history more than once");
119 assert.equal(
120 marks().find(mark => mark.dataset.navTurn === "m:u1")?.dataset.navUnloaded,
121 undefined,
122 "the target is no longer marked unloaded once mounted",
123 );
124 assert.deepEqual(
125 marks().map(mark => mark.dataset.navTurn),
126 Array.from({ length: TOTAL }, (_, index) => `m:u${index + 1}`),
127 "the rail keeps its identity and order after the jump",
128 );
129 assert.deepEqual(marks().map(mark => mark.getAttribute("aria-label")), labelsBefore,
130 "loading an earlier page never renumbers the rail");
131
132 // The rail's busy state clears once the target is reached.
133 await harness.waitFor(
134 () => harness.container.querySelectorAll('[aria-busy="true"]').length === 0,
135 "the busy state to clear",
136 );
137
138 // A jump that fails must offer its own retry. The outline stays perfectly
139 // readable here, so an entry gated on the outline's own failure never appears
140 // — which is exactly the case that used to render no button at all.
141 const phantom: TranscriptOutlineEntry = {
142 id: "m:u9", messageId: "u9", turn: TOTAL + 1, order: 99, prompt: "phantom prompt", answer: "",
143 };
144 let outlineSnapshot = "snapshot-2";
145 let outlineRefreshes = 0;
146 store.register(TAB, async (_tabId, request) => ({
147 ...outlinePage(), snapshotId: request.snapshotId,
148 entries: [...outlinePage().entries, phantom], total: TOTAL + 1, nextOffset: TOTAL + 1,
149 }), async () => {
150 outlineRefreshes += 1;
151 outlineSnapshot = `snapshot-retry-${outlineRefreshes}`;
152 await store!.load(TAB, outlineSnapshot);
153 });
154 await act(async () => { await store!.load(TAB, outlineSnapshot); });
155 let jumpLoads = 0;
156 await harness.render(turnsFrom(TOTAL - 1), {
157 tabId: TAB, geometrySessionKey: "outline-jump-phantom", totalTurns: TOTAL + 1,
158 hasOlderHistory: true, historyStartTurn: TOTAL - 2,
159 onLoadOlderHistory: async () => {
160 pages += 1;
161 jumpLoads += 1;
162 return jumpLoads === 1 ? "stale" : "empty";
163 },
164 });
165 await harness.waitFor(() => marks().length === TOTAL + 1, "the rail to list the phantom turn");
166 assert.ok(harness.container.querySelector(".chat-older"), "the remounted session committed hasOlderHistory=true");
167 const phantomMark = marks().find(mark => mark.dataset.navTurn === "m:u9")!;
168 await act(async () => { phantomMark.click(); });
169 await harness.settle();
170 await harness.waitFor(
171 () => harness.container.querySelector('[data-nav-retry="jump"]') !== null,
172 "the failed jump to offer its own retry",
173 );
174 const retryButton = harness.container.querySelector<HTMLButtonElement>('[data-nav-retry="jump"]')!;
175 assert.ok(retryButton.getAttribute("title"), "the retry says why the jump failed");
176 assert.equal(store.getView(TAB).mode, "ready", "the outline itself never failed");
177 assert.equal(
178 harness.container.querySelector('[data-nav-retry="outline"]'),
179 null,
180 "no outline-retry entry is offered when only the jump failed",
181 );
182 const pagesBeforeRetry = pages;
183 await act(async () => { retryButton.click(); });
184 await harness.waitFor(() => outlineRefreshes === 1, "the retry to refresh the snapshot", 400);
185 await harness.waitFor(() => pages > pagesBeforeRetry, "the retry to re-run the jump", 400);
186
187 console.log("chat turn outline jump: complete rail, unloaded marks, absolute numbering, mount-confirmed jump and failed-jump retry passed");
188 } finally {
189 await harness.unmount();
190 store?.release(TAB);
191 await harness.close();
192 }
193
193 lines Plain Text