返回 DeepSeek-Reasonix
nested-scroll-handoff.test.ts
根目录 / desktop / frontend / src / __tests__ / nested-scroll-handoff.test.ts
1 // Run: tsx src/__tests__/nested-scroll-handoff.test.ts
2
3 import { JSDOM } from "jsdom";
4 import {
5 attachNestedScrollHandoff,
6 canElementScrollVertically,
7 normalizeWheelDelta,
8 shouldHandoffVerticalWheel,
9 } from "../lib/nestedScrollHandoff";
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) ok(true, label);
26 else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
27 }
28
29 console.log("\nnested scroll handoff");
30
31 const dom = new JSDOM(`<!doctype html><html><body>
32 <div id="parent" style="height:200px;overflow-y:auto">
33 <div id="nested" data-nested-scroll style="height:100px;overflow-y:auto">
34 <div id="inner" style="height:400px">cell</div>
35 </div>
36 <div id="table-wrap" class="md-table-scroll" style="height:80px;overflow-x:auto;overflow-y:hidden">
37 <div id="table-cell">wide table cell</div>
38 </div>
39 </div>
40 </body></html>`, { pretendToBeVisual: true });
41
42 globalThis.window = dom.window as unknown as Window & typeof globalThis;
43 globalThis.document = dom.window.document;
44 globalThis.HTMLElement = dom.window.HTMLElement;
45 globalThis.Element = dom.window.Element;
46 globalThis.WheelEvent = dom.window.WheelEvent;
47
48 const parent = document.getElementById("parent") as HTMLElement;
49 const nested = document.getElementById("nested") as HTMLElement;
50 const inner = document.getElementById("inner") as HTMLElement;
51 const tableWrap = document.getElementById("table-wrap") as HTMLElement;
52 const tableCell = document.getElementById("table-cell") as HTMLElement;
53
54 Object.defineProperty(nested, "clientHeight", { configurable: true, value: 100 });
55 Object.defineProperty(nested, "scrollHeight", { configurable: true, value: 400 });
56 let nestedTop = 0;
57 Object.defineProperty(nested, "scrollTop", {
58 configurable: true,
59 get: () => nestedTop,
60 set: (v: number) => { nestedTop = v; },
61 });
62
63 Object.defineProperty(tableWrap, "clientHeight", { configurable: true, value: 80 });
64 Object.defineProperty(tableWrap, "scrollHeight", { configurable: true, value: 80 });
65 Object.defineProperty(tableWrap, "scrollTop", { configurable: true, value: 0 });
66
67 Object.defineProperty(parent, "clientHeight", { configurable: true, value: 200 });
68 Object.defineProperty(parent, "scrollHeight", { configurable: true, value: 2000 });
69 let parentTop = 100;
70 Object.defineProperty(parent, "scrollTop", {
71 configurable: true,
72 get: () => parentTop,
73 set: (v: number) => { parentTop = v; },
74 });
75
76 // Mock computed styles for overflow detection.
77 const styleMap = new Map<Element, { overflowX: string; overflowY: string }>([
78 [nested, { overflowX: "auto", overflowY: "auto" }],
79 [tableWrap, { overflowX: "auto", overflowY: "hidden" }],
80 [parent, { overflowX: "hidden", overflowY: "auto" }],
81 [inner, { overflowX: "visible", overflowY: "visible" }],
82 [tableCell, { overflowX: "visible", overflowY: "visible" }],
83 ]);
84 dom.window.getComputedStyle = ((el: Element) => {
85 const s = styleMap.get(el) ?? { overflowX: "visible", overflowY: "visible" };
86 return s as unknown as CSSStyleDeclaration;
87 }) as typeof getComputedStyle;
88 globalThis.getComputedStyle = dom.window.getComputedStyle;
89
90 nestedTop = 50;
91 ok(canElementScrollVertically(nested, -20), "nested can scroll up from mid-body");
92 ok(canElementScrollVertically(nested, 20), "nested can scroll down from mid-body");
93 nestedTop = 0;
94 ok(!canElementScrollVertically(nested, -20), "nested cannot scroll up at top");
95 nestedTop = 300;
96 ok(!canElementScrollVertically(nested, 20), "nested cannot scroll down at bottom");
97 ok(!canElementScrollVertically(tableWrap, 20), "horizontal-only wrapper cannot scroll vertically");
98
99 nestedTop = 0;
100 ok(
101 shouldHandoffVerticalWheel(inner, parent, -40),
102 "wheel-up at nested top should handoff to parent",
103 );
104 nestedTop = 50;
105 ok(
106 !shouldHandoffVerticalWheel(inner, parent, -40),
107 "wheel-up mid nested should not handoff",
108 );
109 ok(
110 shouldHandoffVerticalWheel(tableCell, parent, 40),
111 "wheel over horizontal-only table wrapper handoffs vertical to parent",
112 );
113
114 // Integration: attach listener and dispatch wheels.
115 nestedTop = 0;
116 parentTop = 100;
117 let intents = 0;
118 const handedOffDeltas: number[] = [];
119 let clock = 1_000;
120 const handoff = attachNestedScrollHandoff({
121 parent,
122 onParentScrollIntent: (deltaY) => {
123 intents += 1;
124 handedOffDeltas.push(deltaY);
125 },
126 writeParentOffset: (top) => {
127 parent.scrollTop = top;
128 return true;
129 },
130 latchHoldMs: 200,
131 now: () => clock,
132 });
133
134 const wheelAt = (target: Element, deltaY: number, deltaMode: number = dom.window.WheelEvent.DOM_DELTA_PIXEL) => {
135 const event = new dom.window.WheelEvent("wheel", {
136 deltaY,
137 deltaX: 0,
138 deltaMode,
139 bubbles: true,
140 cancelable: true,
141 });
142 target.dispatchEvent(event);
143 return event;
144 };
145
146 const edgeUp = wheelAt(inner, -40);
147 ok(edgeUp.defaultPrevented, "edge wheel is preventDefaulted");
148 eq(parentTop, 60, "edge wheel promotes deltaY onto parent scrollTop");
149 ok(intents >= 1, "edge handoff notifies parent scroll intent");
150 eq(handedOffDeltas[0], -40, "edge handoff reports normalized direction to the parent controller");
151
152 // After an edge handoff, latch keeps driving the parent even if nested could scroll.
153 clock += 10; // still inside latchHoldMs
154 const afterLatch = parentTop;
155 nestedTop = 50;
156 const latched = wheelAt(inner, -30);
157 ok(latched.defaultPrevented, "latched gesture continues on parent");
158 eq(parentTop, afterLatch - 30, "latched wheel keeps applying to parent");
159 eq(handedOffDeltas[1], -30, "latched handoff keeps reporting normalized direction");
160
161 // Once the latch expires, mid-body nested scroll is left to the browser.
162 clock += 500;
163 const midTopBefore = parentTop;
164 nestedTop = 50;
165 const mid = wheelAt(inner, -20);
166 ok(!mid.defaultPrevented, "mid nested wheel is left to the browser after latch expires");
167 eq(parentTop, midTopBefore, "mid nested wheel does not move parent after latch expires");
168
169 const lineDelta = normalizeWheelDelta({ deltaX: 0, deltaY: 2, deltaMode: dom.window.WheelEvent.DOM_DELTA_LINE }, parent);
170 eq(lineDelta.y, 32, "line-mode wheel deltas use a stable pixel fallback");
171 const pageDelta = normalizeWheelDelta({ deltaX: 0, deltaY: 1, deltaMode: dom.window.WheelEvent.DOM_DELTA_PAGE }, parent);
172 eq(pageDelta.y, 200, "page-mode wheel deltas use the transcript viewport height");
173
174 clock += 500;
175 nestedTop = 0;
176 parentTop = 500;
177 wheelAt(inner, -2, dom.window.WheelEvent.DOM_DELTA_LINE);
178 eq(parentTop, 468, "line-mode handoff applies normalized pixels instead of raw lines");
179
180 clock += 500;
181 parentTop = 500;
182 wheelAt(inner, -1, dom.window.WheelEvent.DOM_DELTA_PAGE);
183 eq(parentTop, 300, "page-mode handoff applies one viewport page");
184
185 handoff.detach();
186 dom.window.close();
187
188 console.log(`\n${passed} passed, ${failed} failed`);
189 if (failed > 0) process.exit(1);
190
190 lines TYPESCRIPT