feat: add deterministic scroll drive controller
This commit is contained in:
282
tests/unit/scroll-drive-controller.test.ts
Normal file
282
tests/unit/scroll-drive-controller.test.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createSSRApp, defineComponent, h, nextTick } from 'vue';
|
||||
import { renderToString } from '@vue/server-renderer';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createScrollDriveController } from '@/journey/scroll-drive-controller';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('scroll drive controller', () => {
|
||||
it('starts and resets at a deterministic cruise snapshot', () => {
|
||||
const controller = createScrollDriveController();
|
||||
|
||||
expect(controller.reset()).toEqual({
|
||||
scrollY: 0,
|
||||
routeProgress: 0,
|
||||
direction: 0,
|
||||
acceleration: 0,
|
||||
driveSpeed: 0.18,
|
||||
});
|
||||
expect(controller.reset({ scrollY: 250, now: 10, maxScrollY: 1000 })).toEqual({
|
||||
scrollY: 250,
|
||||
routeProgress: 0.25,
|
||||
direction: 0,
|
||||
acceleration: 0,
|
||||
driveSpeed: 0.18,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the literal rise formula for a fixed finite sample', () => {
|
||||
const controller = createScrollDriveController();
|
||||
controller.sample({ scrollY: 0, now: 0, maxScrollY: 1000 });
|
||||
|
||||
const snapshot = controller.sample({ scrollY: 50, now: 100, maxScrollY: 1000 });
|
||||
|
||||
expect(snapshot.scrollY).toBe(50);
|
||||
expect(snapshot.routeProgress).toBe(0.05);
|
||||
expect(snapshot.direction).toBe(1);
|
||||
expect(snapshot.acceleration).toBeCloseTo(0.13416140243841887, 14);
|
||||
expect(snapshot.driveSpeed).toBeCloseTo(0.2900123499995034, 14);
|
||||
});
|
||||
|
||||
it('uses the literal 420 ms decay formula when scrolling stops', () => {
|
||||
const controller = createScrollDriveController();
|
||||
controller.sample({ scrollY: 0, now: 0, maxScrollY: 1000 });
|
||||
controller.sample({ scrollY: 500, now: 100, maxScrollY: 1000 });
|
||||
|
||||
const snapshot = controller.sample({ scrollY: 500, now: 200, maxScrollY: 1000 });
|
||||
|
||||
expect(snapshot.direction).toBe(0);
|
||||
expect(snapshot.acceleration).toBeCloseTo(0.5286815391938753, 14);
|
||||
expect(snapshot.driveSpeed).toBeCloseTo(0.6135188621389778, 14);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['NaN scroll position', { scrollY: Number.NaN, now: 10, maxScrollY: 1000 }],
|
||||
['infinite scroll position', { scrollY: Number.POSITIVE_INFINITY, now: 10, maxScrollY: 1000 }],
|
||||
['NaN timestamp', { scrollY: 10, now: Number.NaN, maxScrollY: 1000 }],
|
||||
['infinite timestamp', { scrollY: 10, now: Number.POSITIVE_INFINITY, maxScrollY: 1000 }],
|
||||
['NaN range', { scrollY: 10, now: 10, maxScrollY: Number.NaN }],
|
||||
['infinite range', { scrollY: 10, now: 10, maxScrollY: Number.POSITIVE_INFINITY }],
|
||||
])('falls back to finite cruise for an invalid sample: %s', (_label, input) => {
|
||||
const controller = createScrollDriveController();
|
||||
|
||||
expect(controller.sample(input)).toEqual({
|
||||
scrollY: 0,
|
||||
routeProgress: 0,
|
||||
direction: 0,
|
||||
acceleration: 0,
|
||||
driveSpeed: 0.18,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['zero', 100],
|
||||
['negative', 99],
|
||||
])('emits cruise and does not integrate a %s dt', (_label, now) => {
|
||||
const controller = createScrollDriveController();
|
||||
controller.sample({ scrollY: 0, now: 100, maxScrollY: 1000 });
|
||||
|
||||
expect(controller.sample({ scrollY: 500, now, maxScrollY: 1000 })).toEqual({
|
||||
scrollY: 500,
|
||||
routeProgress: 0.5,
|
||||
direction: 0,
|
||||
acceleration: 0,
|
||||
driveSpeed: 0.18,
|
||||
});
|
||||
});
|
||||
|
||||
it('integrates an exact 250 ms interval', () => {
|
||||
const controller = createScrollDriveController();
|
||||
controller.sample({ scrollY: 0, now: 0, maxScrollY: 1000 });
|
||||
|
||||
const snapshot = controller.sample({ scrollY: 625, now: 250, maxScrollY: 1000 });
|
||||
|
||||
expect(snapshot.direction).toBe(1);
|
||||
expect(snapshot.acceleration).toBeCloseTo(0.9378234759778836, 14);
|
||||
expect(snapshot.driveSpeed).toBeCloseTo(0.9490152503018645, 14);
|
||||
});
|
||||
|
||||
it('resets transient velocity and establishes a baseline after a gap over 250 ms', () => {
|
||||
const controller = createScrollDriveController();
|
||||
controller.sample({ scrollY: 0, now: 0, maxScrollY: 1000 });
|
||||
controller.sample({ scrollY: 500, now: 100, maxScrollY: 1000 });
|
||||
|
||||
expect(controller.sample({ scrollY: 500, now: 351, maxScrollY: 1000 })).toEqual({
|
||||
scrollY: 500,
|
||||
routeProgress: 0.5,
|
||||
direction: 0,
|
||||
acceleration: 0,
|
||||
driveSpeed: 0.18,
|
||||
});
|
||||
expect(controller.sample({ scrollY: 600, now: 451, maxScrollY: 1000 }).direction).toBe(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['downward', 0, 1000, 1],
|
||||
['upward', 1000, 0, -1],
|
||||
] as const)('clamps a maximum %s delta while preserving signed direction', (_label, start, end, direction) => {
|
||||
const controller = createScrollDriveController();
|
||||
controller.sample({ scrollY: start, now: 0, maxScrollY: 1000 });
|
||||
|
||||
const snapshot = controller.sample({ scrollY: end, now: 100, maxScrollY: 1000 });
|
||||
|
||||
expect(snapshot.direction).toBe(direction);
|
||||
expect(snapshot.acceleration).toBeCloseTo(0.6708070121920944, 14);
|
||||
expect(snapshot.driveSpeed).toBeCloseTo(0.7300617499975173, 14);
|
||||
});
|
||||
|
||||
it.each([0, -1])('emits zero progress cruise for a non-positive range of %s', (maxScrollY) => {
|
||||
const controller = createScrollDriveController();
|
||||
|
||||
expect(controller.sample({ scrollY: 500, now: 100, maxScrollY })).toEqual({
|
||||
scrollY: 500,
|
||||
routeProgress: 0,
|
||||
direction: 0,
|
||||
acceleration: 0,
|
||||
driveSpeed: 0.18,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a denominator of at least one CSS pixel for a fractional scroll range', () => {
|
||||
const controller = createScrollDriveController();
|
||||
|
||||
expect(controller.reset({ scrollY: 0.25, now: 100, maxScrollY: 0.5 }).routeProgress).toBe(0.25);
|
||||
});
|
||||
|
||||
it('clamps route progress and recomputes it when the range changes during smoothing', () => {
|
||||
const controller = createScrollDriveController();
|
||||
controller.sample({ scrollY: 0, now: 0, maxScrollY: 1000 });
|
||||
controller.sample({ scrollY: 500, now: 100, maxScrollY: 1000 });
|
||||
|
||||
expect(controller.sample({ scrollY: 500, now: 200, maxScrollY: 2000 })).toMatchObject({
|
||||
routeProgress: 0.25,
|
||||
acceleration: expect.closeTo(0.5286815391938753, 14),
|
||||
});
|
||||
expect(controller.reset({ scrollY: -10, now: 300, maxScrollY: 1000 }).routeProgress).toBe(0);
|
||||
expect(controller.reset({ scrollY: 1010, now: 400, maxScrollY: 1000 }).routeProgress).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useScrollDrive', () => {
|
||||
it('imports and renders on the server without browser globals', async () => {
|
||||
vi.resetModules();
|
||||
vi.stubGlobal('window', undefined);
|
||||
vi.stubGlobal('document', undefined);
|
||||
const { useScrollDrive } = await import('@/composables/useScrollDrive');
|
||||
const app = createSSRApp(defineComponent({
|
||||
setup() {
|
||||
const drive = useScrollDrive();
|
||||
return () => h('output', drive.value.driveSpeed.toString());
|
||||
},
|
||||
}));
|
||||
|
||||
await expect(renderToString(app)).resolves.toBe('<output>0.18</output>');
|
||||
});
|
||||
|
||||
it('initializes deterministically, batches passive events, resets visibility gaps, and tears down', async () => {
|
||||
const { useScrollDrive } = await import('@/composables/useScrollDrive');
|
||||
let visibility: DocumentVisibilityState = 'visible';
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
get: () => visibility,
|
||||
});
|
||||
Object.defineProperties(document.documentElement, {
|
||||
scrollTop: { configurable: true, writable: true, value: 250 },
|
||||
scrollHeight: { configurable: true, value: 1500 },
|
||||
clientHeight: { configurable: true, value: 500 },
|
||||
});
|
||||
|
||||
const visualViewport = new EventTarget() as VisualViewport;
|
||||
Object.defineProperty(visualViewport, 'height', { configurable: true, value: 500 });
|
||||
Object.defineProperty(window, 'visualViewport', { configurable: true, value: visualViewport });
|
||||
|
||||
const callbacks = new Map<number, FrameRequestCallback>();
|
||||
let nextFrameId = 1;
|
||||
vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => {
|
||||
const id = nextFrameId++;
|
||||
callbacks.set(id, callback);
|
||||
return id;
|
||||
}));
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn((id: number) => callbacks.delete(id)));
|
||||
vi.spyOn(performance, 'now').mockReturnValue(100);
|
||||
const windowAdd = vi.spyOn(window, 'addEventListener');
|
||||
const documentAdd = vi.spyOn(document, 'addEventListener');
|
||||
const viewportAdd = vi.spyOn(visualViewport, 'addEventListener');
|
||||
|
||||
const wrapper = mount(defineComponent({
|
||||
setup() {
|
||||
return { drive: useScrollDrive() };
|
||||
},
|
||||
template: '<output>{{ drive.routeProgress }}</output>',
|
||||
}));
|
||||
const drive = () => wrapper.vm.drive;
|
||||
|
||||
expect(drive()).toMatchObject({
|
||||
scrollY: 250,
|
||||
routeProgress: 0.25,
|
||||
direction: 0,
|
||||
acceleration: 0,
|
||||
driveSpeed: 0.18,
|
||||
});
|
||||
expect(callbacks).toHaveLength(0);
|
||||
expect(windowAdd).toHaveBeenCalledWith('scroll', expect.any(Function), { passive: true });
|
||||
expect(windowAdd).toHaveBeenCalledWith('resize', expect.any(Function), { passive: true });
|
||||
expect(documentAdd).toHaveBeenCalledWith('visibilitychange', expect.any(Function), { passive: true });
|
||||
expect(viewportAdd).toHaveBeenCalledWith('resize', expect.any(Function), { passive: true });
|
||||
expect(viewportAdd).toHaveBeenCalledWith('scroll', expect.any(Function), { passive: true });
|
||||
|
||||
document.documentElement.scrollTop = 750;
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
visualViewport.dispatchEvent(new Event('resize'));
|
||||
expect(callbacks).toHaveLength(1);
|
||||
|
||||
const firstFrame = [...callbacks.entries()][0]!;
|
||||
callbacks.delete(firstFrame[0]);
|
||||
firstFrame[1](200);
|
||||
await nextTick();
|
||||
expect(drive()).toMatchObject({
|
||||
scrollY: 750,
|
||||
routeProgress: 0.75,
|
||||
direction: 1,
|
||||
acceleration: expect.closeTo(0.6708070121920944, 14),
|
||||
driveSpeed: expect.closeTo(0.7300617499975173, 14),
|
||||
});
|
||||
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
expect(callbacks).toHaveLength(1);
|
||||
visibility = 'hidden';
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
expect(callbacks).toHaveLength(0);
|
||||
expect(drive()).toMatchObject({ acceleration: 0, driveSpeed: 0.18 });
|
||||
|
||||
visibility = 'visible';
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
expect(callbacks).toHaveLength(1);
|
||||
const visibleFrame = [...callbacks.entries()][0]!;
|
||||
callbacks.delete(visibleFrame[0]);
|
||||
visibleFrame[1](1000);
|
||||
await nextTick();
|
||||
expect(drive()).toMatchObject({
|
||||
scrollY: 750,
|
||||
routeProgress: 0.75,
|
||||
direction: 0,
|
||||
acceleration: 0,
|
||||
driveSpeed: 0.18,
|
||||
});
|
||||
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
expect(callbacks).toHaveLength(1);
|
||||
wrapper.unmount();
|
||||
expect(callbacks).toHaveLength(0);
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
visualViewport.dispatchEvent(new Event('scroll'));
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
expect(callbacks).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user