import { describe, expect, test } from "vite-plus/test"; import { createPageScrollController, getTimelinePageScrollKey, getPageScrollDistancePx, getPageScrollMultiplier, getPageScrollVelocityPxPerMs, PAGE_SCROLL_ACCELERATION_MS, PAGE_SCROLL_ANIMATION_MS, PAGE_SCROLL_MAX_MULTIPLIER, } from "./pageScrollController"; class TestClock { private currentTime = 0; private nextHandle = 1; private animationFrames = new Map(); private timeouts = new Map void }>(); readonly env = { now: () => this.currentTime, requestAnimationFrame: (callback: FrameRequestCallback) => { const handle = this.nextHandle; this.nextHandle += 1; this.animationFrames.set(handle, callback); return handle; }, cancelAnimationFrame: (handle: number) => { this.animationFrames.delete(handle); }, setTimeout: (callback: () => void, delay: number) => { const handle = this.nextHandle; this.nextHandle += 1; this.timeouts.set(handle, { at: this.currentTime + delay, callback }); return handle; }, clearTimeout: (handle: number) => { this.timeouts.delete(handle); }, }; advanceBy(ms: number, frameMs = 16) { const target = this.currentTime + ms; while (this.currentTime < target) { this.currentTime = Math.min(target, this.currentTime + frameMs); this.flushTimeouts(); this.flushAnimationFrames(); } } private flushTimeouts() { let hasDueTimeouts = true; while (hasDueTimeouts) { hasDueTimeouts = false; for (const [handle, timeout] of this.timeouts) { if (timeout.at > this.currentTime) { continue; } this.timeouts.delete(handle); timeout.callback(); hasDueTimeouts = true; } } } private flushAnimationFrames() { if (this.animationFrames.size === 0) { return; } const frames = [...this.animationFrames.values()]; this.animationFrames.clear(); for (const callback of frames) { callback(this.currentTime); } } } describe("page scroll helpers", () => { const composerPageScrollEvent = ( overrides: Partial[0]> = {}, ) => ({ altKey: false, clientHeight: 200, ctrlKey: false, defaultPrevented: false, isComposing: false, key: "PageDown", keyCode: 34, metaKey: false, scrollHeight: 200, scrollTop: 0, shiftKey: false, ...overrides, }); test("leaves page keys to IME composition", () => { expect(getTimelinePageScrollKey(composerPageScrollEvent({ isComposing: true }))).toBeNull(); expect(getTimelinePageScrollKey(composerPageScrollEvent({ keyCode: 229 }))).toBeNull(); }); test("leaves page keys to an overflowing composer until it reaches the boundary", () => { expect( getTimelinePageScrollKey(composerPageScrollEvent({ scrollHeight: 600, scrollTop: 0 })), ).toBeNull(); expect( getTimelinePageScrollKey( composerPageScrollEvent({ key: "PageUp", keyCode: 33, scrollHeight: 600, scrollTop: 400, }), ), ).toBeNull(); expect( getTimelinePageScrollKey( composerPageScrollEvent({ key: "PageUp", keyCode: 33, scrollHeight: 600, scrollTop: 0, }), ), ).toBe("PageUp"); expect( getTimelinePageScrollKey(composerPageScrollEvent({ scrollHeight: 600, scrollTop: 400 })), ).toBe("PageDown"); }); test("hands off page keys within a fractional pixel of the composer boundary", () => { expect( getTimelinePageScrollKey( composerPageScrollEvent({ key: "PageUp", keyCode: 33, scrollHeight: 600, scrollTop: 0.5, }), ), ).toBe("PageUp"); expect( getTimelinePageScrollKey(composerPageScrollEvent({ scrollHeight: 600, scrollTop: 399.5 })), ).toBe("PageDown"); }); test("ramps multiplier over time and caps at the max velocity", () => { expect(getPageScrollMultiplier(0)).toBe(1); expect(getPageScrollMultiplier(PAGE_SCROLL_ACCELERATION_MS / 2)).toBeCloseTo(1.5); expect(getPageScrollMultiplier(PAGE_SCROLL_ACCELERATION_MS * 5)).toBe( PAGE_SCROLL_MAX_MULTIPLIER, ); }); test("derives the hold velocity from page size and acceleration", () => { expect( getPageScrollVelocityPxPerMs({ holdElapsedMs: 0, pageScrollDistancePx: 600, }), ).toBeCloseTo(4); expect( getPageScrollVelocityPxPerMs({ holdElapsedMs: PAGE_SCROLL_ACCELERATION_MS * 5, pageScrollDistancePx: 600, }), ).toBeCloseTo(8); }); }); describe("createPageScrollController", () => { test("keeps a single page scroll when the key is tapped", () => { const clock = new TestClock(); const container = { clientHeight: 600, scrollHeight: 1_800, scrollTop: 0, getBoundingClientRect: () => ({ height: 600 }), }; const controller = createPageScrollController({ getContainer: () => container, getScrollPaddingBottomPx: () => 24, env: clock.env, }); controller.handleKeyDown("PageDown"); controller.handleKeyUp("PageDown"); clock.advanceBy(PAGE_SCROLL_ANIMATION_MS); expect(container.scrollTop).toBeCloseTo( getPageScrollDistancePx({ containerHeightPx: 600, scrollPaddingBottomPx: 24, }), 5, ); }); test("continues scrolling on hold without repeated keydown events and stops on keyup", () => { const clock = new TestClock(); const container = { clientHeight: 600, scrollHeight: 4_000, scrollTop: 0, getBoundingClientRect: () => ({ height: 600 }), }; const controller = createPageScrollController({ getContainer: () => container, getScrollPaddingBottomPx: () => 24, env: clock.env, }); controller.handleKeyDown("PageDown"); clock.advanceBy(PAGE_SCROLL_ANIMATION_MS + 50); const afterHoldStarts = container.scrollTop; clock.advanceBy(200); expect(container.scrollTop).toBeGreaterThan(afterHoldStarts); const stoppedAt = container.scrollTop; controller.handleKeyUp("PageDown"); clock.advanceBy(250); expect(container.scrollTop).toBe(stoppedAt); }); test("notifies once when a page scroll starts", () => { const clock = new TestClock(); const started: string[] = []; const controller = createPageScrollController({ getContainer: () => ({ clientHeight: 600, scrollHeight: 1_800, scrollTop: 600, getBoundingClientRect: () => ({ height: 600 }), }), getScrollPaddingBottomPx: () => 24, onScrollStart: (key) => started.push(key), env: clock.env, }); controller.handleKeyDown("PageUp"); controller.handleKeyDown("PageUp"); expect(started).toEqual(["PageUp"]); }); test("does not start a page scroll at the timeline boundary", () => { const clock = new TestClock(); const started: string[] = []; const container = { clientHeight: 600, scrollHeight: 1_800, scrollTop: 0.5, getBoundingClientRect: () => ({ height: 600 }), }; const controller = createPageScrollController({ getContainer: () => container, getScrollPaddingBottomPx: () => 24, onScrollStart: (key) => started.push(key), env: clock.env, }); controller.handleKeyDown("PageUp"); clock.advanceBy(PAGE_SCROLL_ANIMATION_MS * 2); container.scrollTop = container.scrollHeight - container.clientHeight - 0.5; controller.handleKeyDown("PageDown"); clock.advanceBy(PAGE_SCROLL_ANIMATION_MS * 2); expect(started).toEqual([]); expect(container.scrollTop).toBe(1_199.5); }); });