1import { describe, expect, test } from "vite-plus/test";
2
3import {
4 createPageScrollController,
5 getTimelinePageScrollKey,
6 getPageScrollDistancePx,
7 getPageScrollMultiplier,
8 getPageScrollVelocityPxPerMs,
9 PAGE_SCROLL_ACCELERATION_MS,
10 PAGE_SCROLL_ANIMATION_MS,
11 PAGE_SCROLL_MAX_MULTIPLIER,
12} from "./pageScrollController";
13
14class TestClock {
15 private currentTime = 0;
16 private nextHandle = 1;
17 private animationFrames = new Map<number, FrameRequestCallback>();
18 private timeouts = new Map<number, { at: number; callback: () => void }>();
19
20 readonly env = {
21 now: () => this.currentTime,
22 requestAnimationFrame: (callback: FrameRequestCallback) => {
23 const handle = this.nextHandle;
24 this.nextHandle += 1;
25 this.animationFrames.set(handle, callback);
26 return handle;
27 },
28 cancelAnimationFrame: (handle: number) => {
29 this.animationFrames.delete(handle);
30 },
31 setTimeout: (callback: () => void, delay: number) => {
32 const handle = this.nextHandle;
33 this.nextHandle += 1;
34 this.timeouts.set(handle, { at: this.currentTime + delay, callback });
35 return handle;
36 },
37 clearTimeout: (handle: number) => {
38 this.timeouts.delete(handle);
39 },
40 };
41
42 advanceBy(ms: number, frameMs = 16) {
43 const target = this.currentTime + ms;
44
45 while (this.currentTime < target) {
46 this.currentTime = Math.min(target, this.currentTime + frameMs);
47 this.flushTimeouts();
48 this.flushAnimationFrames();
49 }
50 }
51
52 private flushTimeouts() {
53 let hasDueTimeouts = true;
54 while (hasDueTimeouts) {
55 hasDueTimeouts = false;
56
57 for (const [handle, timeout] of this.timeouts) {
58 if (timeout.at > this.currentTime) {
59 continue;
60 }
61
62 this.timeouts.delete(handle);
63 timeout.callback();
64 hasDueTimeouts = true;
65 }
66 }
67 }
68
69 private flushAnimationFrames() {
70 if (this.animationFrames.size === 0) {
71 return;
72 }
73
74 const frames = [...this.animationFrames.values()];
75 this.animationFrames.clear();
76
77 for (const callback of frames) {
78 callback(this.currentTime);
79 }
80 }
81}
82
83describe("page scroll helpers", () => {
84 const composerPageScrollEvent = (
85 overrides: Partial<Parameters<typeof getTimelinePageScrollKey>[0]> = {},
86 ) => ({
87 altKey: false,
88 clientHeight: 200,
89 ctrlKey: false,
90 defaultPrevented: false,
91 isComposing: false,
92 key: "PageDown",
93 keyCode: 34,
94 metaKey: false,
95 scrollHeight: 200,
96 scrollTop: 0,
97 shiftKey: false,
98 ...overrides,
99 });
100
101 test("leaves page keys to IME composition", () => {
102 expect(getTimelinePageScrollKey(composerPageScrollEvent({ isComposing: true }))).toBeNull();
103 expect(getTimelinePageScrollKey(composerPageScrollEvent({ keyCode: 229 }))).toBeNull();
104 });
105
106 test("leaves page keys to an overflowing composer until it reaches the boundary", () => {
107 expect(
108 getTimelinePageScrollKey(composerPageScrollEvent({ scrollHeight: 600, scrollTop: 0 })),
109 ).toBeNull();
110 expect(
111 getTimelinePageScrollKey(
112 composerPageScrollEvent({
113 key: "PageUp",
114 keyCode: 33,
115 scrollHeight: 600,
116 scrollTop: 400,
117 }),
118 ),
119 ).toBeNull();
120
121 expect(
122 getTimelinePageScrollKey(
123 composerPageScrollEvent({
124 key: "PageUp",
125 keyCode: 33,
126 scrollHeight: 600,
127 scrollTop: 0,
128 }),
129 ),
130 ).toBe("PageUp");
131 expect(
132 getTimelinePageScrollKey(composerPageScrollEvent({ scrollHeight: 600, scrollTop: 400 })),
133 ).toBe("PageDown");
134 });
135
136 test("hands off page keys within a fractional pixel of the composer boundary", () => {
137 expect(
138 getTimelinePageScrollKey(
139 composerPageScrollEvent({
140 key: "PageUp",
141 keyCode: 33,
142 scrollHeight: 600,
143 scrollTop: 0.5,
144 }),
145 ),
146 ).toBe("PageUp");
147 expect(
148 getTimelinePageScrollKey(composerPageScrollEvent({ scrollHeight: 600, scrollTop: 399.5 })),
149 ).toBe("PageDown");
150 });
151
152 test("ramps multiplier over time and caps at the max velocity", () => {
153 expect(getPageScrollMultiplier(0)).toBe(1);
154 expect(getPageScrollMultiplier(PAGE_SCROLL_ACCELERATION_MS / 2)).toBeCloseTo(1.5);
155 expect(getPageScrollMultiplier(PAGE_SCROLL_ACCELERATION_MS * 5)).toBe(
156 PAGE_SCROLL_MAX_MULTIPLIER,
157 );
158 });
159
160 test("derives the hold velocity from page size and acceleration", () => {
161 expect(
162 getPageScrollVelocityPxPerMs({
163 holdElapsedMs: 0,
164 pageScrollDistancePx: 600,
165 }),
166 ).toBeCloseTo(4);
167 expect(
168 getPageScrollVelocityPxPerMs({
169 holdElapsedMs: PAGE_SCROLL_ACCELERATION_MS * 5,
170 pageScrollDistancePx: 600,
171 }),
172 ).toBeCloseTo(8);
173 });
174});
175
176describe("createPageScrollController", () => {
177 test("keeps a single page scroll when the key is tapped", () => {
178 const clock = new TestClock();
179 const container = {
180 clientHeight: 600,
181 scrollHeight: 1_800,
182 scrollTop: 0,
183 getBoundingClientRect: () => ({ height: 600 }),
184 };
185 const controller = createPageScrollController({
186 getContainer: () => container,
187 getScrollPaddingBottomPx: () => 24,
188 env: clock.env,
189 });
190
191 controller.handleKeyDown("PageDown");
192 controller.handleKeyUp("PageDown");
193 clock.advanceBy(PAGE_SCROLL_ANIMATION_MS);
194
195 expect(container.scrollTop).toBeCloseTo(
196 getPageScrollDistancePx({
197 containerHeightPx: 600,
198 scrollPaddingBottomPx: 24,
199 }),
200 5,
201 );
202 });
203
204 test("continues scrolling on hold without repeated keydown events and stops on keyup", () => {
205 const clock = new TestClock();
206 const container = {
207 clientHeight: 600,
208 scrollHeight: 4_000,
209 scrollTop: 0,
210 getBoundingClientRect: () => ({ height: 600 }),
211 };
212 const controller = createPageScrollController({
213 getContainer: () => container,
214 getScrollPaddingBottomPx: () => 24,
215 env: clock.env,
216 });
217 controller.handleKeyDown("PageDown");
218 clock.advanceBy(PAGE_SCROLL_ANIMATION_MS + 50);
219
220 const afterHoldStarts = container.scrollTop;
221 clock.advanceBy(200);
222
223 expect(container.scrollTop).toBeGreaterThan(afterHoldStarts);
224
225 const stoppedAt = container.scrollTop;
226 controller.handleKeyUp("PageDown");
227 clock.advanceBy(250);
228
229 expect(container.scrollTop).toBe(stoppedAt);
230 });
231
232 test("notifies once when a page scroll starts", () => {
233 const clock = new TestClock();
234 const started: string[] = [];
235 const controller = createPageScrollController({
236 getContainer: () => ({
237 clientHeight: 600,
238 scrollHeight: 1_800,
239 scrollTop: 600,
240 getBoundingClientRect: () => ({ height: 600 }),
241 }),
242 getScrollPaddingBottomPx: () => 24,
243 onScrollStart: (key) => started.push(key),
244 env: clock.env,
245 });
246
247 controller.handleKeyDown("PageUp");
248 controller.handleKeyDown("PageUp");
249
250 expect(started).toEqual(["PageUp"]);
251 });
252
253 test("does not start a page scroll at the timeline boundary", () => {
254 const clock = new TestClock();
255 const started: string[] = [];
256 const container = {
257 clientHeight: 600,
258 scrollHeight: 1_800,
259 scrollTop: 0.5,
260 getBoundingClientRect: () => ({ height: 600 }),
261 };
262 const controller = createPageScrollController({
263 getContainer: () => container,
264 getScrollPaddingBottomPx: () => 24,
265 onScrollStart: (key) => started.push(key),
266 env: clock.env,
267 });
268
269 controller.handleKeyDown("PageUp");
270 clock.advanceBy(PAGE_SCROLL_ANIMATION_MS * 2);
271
272 container.scrollTop = container.scrollHeight - container.clientHeight - 0.5;
273 controller.handleKeyDown("PageDown");
274 clock.advanceBy(PAGE_SCROLL_ANIMATION_MS * 2);
275
276 expect(started).toEqual([]);
277 expect(container.scrollTop).toBe(1_199.5);
278 });
279});