返回 DeepSeek-Reasonix
scroll-manager.test.tsx
根目录 / desktop / frontend / src / __tests__ / scroll-manager.test.tsx
1 // Run: tsx src/__tests__/scroll-manager.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import React, { useEffect } from "react";
5 import { act } from "react";
6 import { createRoot } from "react-dom/client";
7 import { useScrollManager } from "../lib/useScrollManager";
8
9 type ScrollManagerApi = ReturnType<typeof useScrollManager>;
10
11 let passed = 0;
12 let failed = 0;
13
14 function ok(value: boolean, label: string) {
15 if (value) {
16 process.stdout.write(` PASS ${label}\n`);
17 passed += 1;
18 } else {
19 process.stdout.write(` FAIL ${label}\n`);
20 failed += 1;
21 }
22 }
23
24 function eq(actual: unknown, expected: unknown, label: string) {
25 if (actual === expected) {
26 ok(true, label);
27 } else {
28 ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
29 }
30 }
31
32 function Harness({ onReady }: { onReady: (api: ScrollManagerApi) => void }) {
33 const manager = useScrollManager();
34 useEffect(() => onReady(manager), [manager, onReady]);
35 return (
36 <div
37 ref={manager.scrollRef}
38 data-testid="transcript"
39 onScroll={manager.onScroll}
40 onWheelCapture={manager.onWheelIntent}
41 onKeyDownCapture={manager.onKeyScrollIntent}
42 />
43 );
44 }
45
46 console.log("\nscroll manager manual intent");
47
48 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
49 pretendToBeVisual: true,
50 });
51 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
52 globalThis.window = dom.window as unknown as Window & typeof globalThis;
53 globalThis.document = dom.window.document;
54 globalThis.Node = dom.window.Node;
55 globalThis.HTMLElement = dom.window.HTMLElement;
56 globalThis.Event = dom.window.Event;
57 globalThis.WheelEvent = dom.window.WheelEvent;
58 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
59 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
60
61 const rootEl = document.getElementById("root");
62 if (!rootEl) throw new Error("missing root");
63 const root = createRoot(rootEl);
64 let api: ScrollManagerApi | null = null;
65
66 await act(async () => {
67 root.render(<Harness onReady={(next) => { api = next; }} />);
68 });
69
70 if (!api) throw new Error("scroll manager did not mount");
71 const transcript = document.querySelector<HTMLElement>("[data-testid='transcript']");
72 if (!transcript) throw new Error("transcript did not render");
73
74 let scrollTop = 900;
75 Object.defineProperty(transcript, "clientHeight", { configurable: true, value: 100 });
76 Object.defineProperty(transcript, "scrollHeight", { configurable: true, value: 1000 });
77 Object.defineProperty(transcript, "scrollTop", {
78 configurable: true,
79 get: () => scrollTop,
80 set: (value) => { scrollTop = value; },
81 });
82
83 await act(async () => {
84 api?.onScroll();
85 });
86 eq(api.stick.current, true, "manager starts pinned when the transcript is at the bottom");
87
88 await act(async () => {
89 api?.onWheelIntent({ deltaX: 0, deltaY: 48 } as React.WheelEvent<HTMLElement>);
90 });
91 eq(api.stick.current, true, "wheel-down at the bottom keeps tail-follow enabled");
92
93 await act(async () => {
94 const released = api?.onWheelIntent({ deltaX: 0, deltaY: -48 } as React.WheelEvent<HTMLElement>);
95 eq(released, true, "wheel-up at the bottom releases auto-scroll immediately");
96 });
97 eq(api.stick.current, false, "manual wheel intent breaks the bottom pin before the native scroll event");
98
99 if (api.stick.current) {
100 transcript.scrollTop = transcript.scrollHeight;
101 }
102 eq(scrollTop, 900, "a queued streaming auto-scroll would not yank after manual wheel intent");
103
104 scrollTop = 900;
105 await act(async () => {
106 api!.stick.current = true;
107 api?.onWheelIntent({ deltaX: 40, deltaY: 4 } as React.WheelEvent<HTMLElement>);
108 });
109 eq(api.stick.current, true, "horizontal-dominant wheel gestures do not break vertical tail-follow");
110
111 Object.defineProperty(transcript, "scrollHeight", { configurable: true, value: 100 });
112 scrollTop = 0;
113 await act(async () => {
114 api!.stick.current = true;
115 const released = api?.onWheelIntent({ deltaX: 0, deltaY: -48 } as React.WheelEvent<HTMLElement>);
116 eq(released, false, "wheel intent is ignored when the transcript is not scrollable");
117 });
118 eq(api.stick.current, true, "short transcripts stay pinned after ignored wheel intent");
119
120 Object.defineProperty(transcript, "scrollHeight", { configurable: true, value: 1000 });
121 scrollTop = 900;
122 await act(async () => {
123 api!.stick.current = true;
124 const released = api?.onWheelIntent({ deltaX: 0, deltaY: -48, ctrlKey: true } as React.WheelEvent<HTMLElement>);
125 eq(released, false, "ctrl+wheel (trackpad pinch-zoom) is ignored, not treated as scroll intent");
126 });
127 eq(api.stick.current, true, "pinch-zoom gesture does not release tail-follow");
128
129 const editTextarea = document.createElement("textarea");
130 await act(async () => {
131 api!.stick.current = true;
132 const released = api?.onKeyScrollIntent({ key: "Home", target: editTextarea } as unknown as React.KeyboardEvent<HTMLElement>);
133 eq(released, false, "Home pressed while editing a message textarea is not treated as scroll intent");
134 });
135 eq(api.stick.current, true, "editing an earlier message does not release the streaming tail-follow");
136
137 const plainDiv = document.createElement("div");
138 await act(async () => {
139 api!.stick.current = true;
140 const released = api?.onKeyScrollIntent({ key: "Home", target: plainDiv } as unknown as React.KeyboardEvent<HTMLElement>);
141 eq(released, true, "Home pressed on a non-editable target still releases tail-follow");
142 });
143 eq(api.stick.current, false, "keyboard scroll intent from outside an editable field still breaks the bottom pin");
144
145 await act(async () => {
146 root.unmount();
147 });
148 dom.window.close();
149
150 console.log(`\n${passed} passed, ${failed} failed`);
151 if (failed > 0) process.exit(1);
152
152 lines Plain Text