返回 DeepSeek-Reasonix
reasoning-scroll-follow.test.tsx
根目录 / desktop / frontend / src / __tests__ / reasoning-scroll-follow.test.tsx
1 // Run: tsx src/__tests__/reasoning-scroll-follow.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import { readFileSync } from "node:fs";
5 import React, { act } from "react";
6 import { createRoot } from "react-dom/client";
7 import { useReasoningScrollFollow } from "../lib/useReasoningScrollFollow";
8
9 let passed = 0;
10 let failed = 0;
11
12 function eq(actual: unknown, expected: unknown, label: string) {
13 if (actual === expected) {
14 process.stdout.write(` PASS ${label}\n`);
15 passed += 1;
16 } else {
17 process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}\n`);
18 failed += 1;
19 }
20 }
21
22 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { pretendToBeVisual: true });
23 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
24 globalThis.window = dom.window as unknown as Window & typeof globalThis;
25 globalThis.document = dom.window.document;
26 globalThis.HTMLElement = dom.window.HTMLElement;
27 globalThis.Event = dom.window.Event;
28
29 function Harness({ content }: { content: string }) {
30 const [elementRef, onScroll] = useReasoningScrollFollow(content, true);
31 return <div ref={elementRef} data-nested-scroll onScroll={onScroll}>{content}</div>;
32 }
33
34 function RefLessHarness({ content }: { content: string }) {
35 useReasoningScrollFollow(content, true);
36 return null;
37 }
38
39 console.log("\nreasoning inner-scroll follow");
40
41 const styles = readFileSync(new URL("../styles.css", import.meta.url), "utf8");
42 eq(styles.includes("max-height: min(40vh, 480px);"), true, "expanded reasoning uses the responsive height cap");
43 eq(styles.includes("overflow-y: auto;"), true, "expanded reasoning keeps full content internally scrollable");
44 eq(styles.includes(".reasoning--loading { height: 58px; }"), true, "lazy reasoning reserves collapsed history geometry");
45 eq(styles.includes(".reasoning--loading[data-expanded] { height: min(40vh, 480px); }"), true, "lazy expanded reasoning reserves bounded geometry");
46
47 const rootElement = document.getElementById("root");
48 if (!rootElement) throw new Error("missing root");
49 const root = createRoot(rootElement);
50 await act(async () => root.render(<Harness key="a:turn:0" content="start" />));
51 const pane = rootElement.firstElementChild as HTMLElement;
52 let scrollHeight = 1_000;
53 let scrollTop = 0;
54 Object.defineProperty(pane, "clientHeight", { configurable: true, value: 200 });
55 Object.defineProperty(pane, "scrollHeight", { configurable: true, get: () => scrollHeight });
56 Object.defineProperty(pane, "scrollTop", {
57 configurable: true,
58 get: () => scrollTop,
59 set: (value: number) => { scrollTop = value; },
60 });
61
62 await act(async () => root.render(<Harness key="a:turn:0" content="start next token" />));
63 eq(scrollTop, 1_000, "new reasoning tokens follow the inner tail while armed");
64 eq(pane.hasAttribute("data-nested-scroll"), true, "reasoning pane participates in nested-scroll handoff");
65
66 scrollTop = 300;
67 pane.dispatchEvent(new dom.window.Event("scroll", { bubbles: true }));
68 scrollHeight = 1_200;
69 await act(async () => root.render(<Harness key="a:turn:0" content="start next token more" />));
70 eq(scrollTop, 300, "manual upward reading releases automatic inner follow");
71
72 scrollTop = 1_000;
73 pane.dispatchEvent(new dom.window.Event("scroll", { bubbles: true }));
74 scrollHeight = 1_300;
75 await act(async () => root.render(<Harness key="a:turn:0" content="start next token more final" />));
76 eq(scrollTop, 1_300, "returning within eight pixels of the bottom rearms follow");
77
78 scrollTop = 100;
79 pane.dispatchEvent(new dom.window.Event("scroll", { bubbles: true }));
80 await act(async () => root.render(<Harness key="a:turn:1" content="new segment" />));
81 const nextPane = rootElement.firstElementChild as HTMLElement;
82 let nextScrollTop = 0;
83 Object.defineProperty(nextPane, "clientHeight", { configurable: true, value: 200 });
84 Object.defineProperty(nextPane, "scrollHeight", { configurable: true, value: 1_400 });
85 Object.defineProperty(nextPane, "scrollTop", {
86 configurable: true,
87 get: () => nextScrollTop,
88 set: (value: number) => { nextScrollTop = value; },
89 });
90 await act(async () => root.render(<Harness key="a:turn:1" content="new segment token" />));
91 eq(nextScrollTop, 1_400, "a new assistant segment starts with inner follow armed");
92
93 await act(async () => root.render(<RefLessHarness content="unmounted before layout effect" />));
94 eq(rootElement.firstElementChild, null, "an active follow tolerates a reasoning body that is not mounted");
95
96 await act(async () => root.unmount());
97 dom.window.close();
98
99 console.log(`\n${passed} passed, ${failed} failed`);
100 if (failed > 0) process.exit(1);
101
101 lines Plain Text