Files
Portfolio/tests/unit/scroll-drive-controller.test.ts

487 lines
18 KiB
TypeScript

import { mount } from '@vue/test-utils';
import { createSSRApp, defineComponent, h, nextTick, shallowRef } 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('rises smoothly 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).toBeGreaterThan(0.07);
expect(snapshot.acceleration).toBeLessThan(0.12);
expect(snapshot.driveSpeed).toBeGreaterThan(0.23);
expect(snapshot.driveSpeed).toBeLessThan(0.29);
});
it('releases gradually 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).toBeGreaterThan(0.34);
expect(snapshot.acceleration).toBeLessThan(0.42);
expect(snapshot.driveSpeed).toBeGreaterThan(0.45);
expect(snapshot.driveSpeed).toBeLessThan(0.54);
});
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).toBeGreaterThan(0.7);
expect(snapshot.acceleration).toBeLessThan(0.8);
expect(snapshot.driveSpeed).toBeGreaterThan(0.75);
expect(snapshot.driveSpeed).toBeLessThan(0.85);
});
it('keeps a decaying coast instead of hard-resetting after a delayed stationary frame', () => {
const controller = createScrollDriveController();
controller.sample({ scrollY: 0, now: 0, maxScrollY: 1000 });
controller.sample({ scrollY: 500, now: 100, maxScrollY: 1000 });
const coast = controller.sample({ scrollY: 500, now: 351, maxScrollY: 1000 });
expect(coast).toMatchObject({
scrollY: 500,
routeProgress: 0.5,
direction: 0,
});
expect(coast.acceleration).toBeGreaterThan(0.25);
expect(coast.acceleration).toBeLessThan(0.43);
expect(coast.driveSpeed).toBeGreaterThan(0.18);
expect(controller.sample({ scrollY: 600, now: 451, maxScrollY: 1000 }).direction).toBe(1);
});
it('preserves signed movement while dropping acceleration on the first delayed sample', () => {
const controller = createScrollDriveController();
controller.sample({ scrollY: 0, now: 0, maxScrollY: 1000 });
expect(controller.sample({ scrollY: 500, now: 351, maxScrollY: 1000 })).toMatchObject({
scrollY: 500,
routeProgress: 0.5,
direction: 1,
acceleration: 0,
driveSpeed: 0.18,
});
});
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).toBeGreaterThan(0.4);
expect(snapshot.acceleration).toBeLessThan(0.46);
expect(snapshot.driveSpeed).toBeGreaterThan(0.5);
expect(snapshot.driveSpeed).toBeLessThan(0.58);
});
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.any(Number),
});
expect(controller.reset({ scrollY: -10, now: 300, maxScrollY: 1000 }).routeProgress).toBe(0);
expect(controller.reset({ scrollY: 1010, now: 400, maxScrollY: 1000 }).routeProgress).toBe(1);
});
it('normalizes equivalent scroll gestures to viewport-relative velocity', () => {
const compact = createScrollDriveController();
const tall = createScrollDriveController();
compact.sample({ scrollY: 0, now: 0, maxScrollY: 4_000, viewportHeight: 400 });
tall.sample({ scrollY: 0, now: 0, maxScrollY: 4_000, viewportHeight: 1_000 });
const compactGesture = compact.sample({
scrollY: 100,
now: 100,
maxScrollY: 4_000,
viewportHeight: 400,
});
const tallGesture = tall.sample({
scrollY: 250,
now: 100,
maxScrollY: 4_000,
viewportHeight: 1_000,
});
expect(compactGesture.acceleration).toBeCloseTo(
tallGesture.acceleration,
10,
);
});
it('keeps tiny wheel input proportional instead of popping to a streak threshold', () => {
const controller = createScrollDriveController();
controller.sample({ scrollY: 0, now: 0, maxScrollY: 4_000, viewportHeight: 800 });
const tiny = controller.sample({
scrollY: 4,
now: 16,
maxScrollY: 4_000,
viewportHeight: 800,
});
expect(tiny.acceleration).toBeGreaterThan(0);
expect(tiny.acceleration).toBeLessThan(0.02);
});
});
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.snapshot.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');
let binding!: ReturnType<typeof useScrollDrive>;
const wrapper = mount(defineComponent({
setup() {
binding = useScrollDrive();
return () => h('output', binding.snapshot.value.routeProgress.toString());
},
}));
const drive = () => binding.snapshot.value;
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.42624657926256726, 14),
driveSpeed: expect.closeTo(0.5295221949953051, 14),
});
expect(callbacks).toHaveLength(1);
const decayingAccelerations: number[] = [];
let frameNow = 216;
while (callbacks.size > 0 && frameNow < 10_000) {
expect(callbacks).toHaveLength(1);
const decayFrame = [...callbacks.entries()][0]!;
callbacks.delete(decayFrame[0]);
decayFrame[1](frameNow);
await nextTick();
decayingAccelerations.push(drive().acceleration);
frameNow += 16;
}
expect(decayingAccelerations.length).toBeGreaterThan(1);
expect(decayingAccelerations.every((value, index) => (
index === 0 || value <= decayingAccelerations[index - 1]!
))).toBe(true);
expect(decayingAccelerations.at(-1)).toBe(0);
expect(drive()).toMatchObject({
scrollY: 750,
routeProgress: 0.75,
direction: 0,
acceleration: 0,
driveSpeed: 0.18,
});
expect(callbacks).toHaveLength(0);
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);
});
it('keeps the shared frame bus alive only while a subscriber requests frames', async () => {
const { useScrollDrive } = await import('@/composables/useScrollDrive');
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'visible',
});
Object.defineProperties(document.documentElement, {
scrollTop: { configurable: true, writable: true, value: 0 },
scrollHeight: { configurable: true, value: 1000 },
clientHeight: { configurable: true, value: 500 },
});
Object.defineProperty(window, 'visualViewport', {
configurable: true,
value: undefined,
});
Object.defineProperty(document, 'fonts', {
configurable: true,
value: { ready: Promise.resolve() },
});
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);
let binding!: ReturnType<typeof useScrollDrive>;
const wrapper = mount(defineComponent({
setup() {
binding = useScrollDrive();
return () => h('output', binding.snapshot.value.driveSpeed.toString());
},
}));
let subscriberFrames = 0;
const unsubscribe = binding.subscribeFrame((_now, frameSnapshot) => {
expect(frameSnapshot).toEqual(binding.snapshot.value);
subscriberFrames += 1;
return subscriberFrames < 3;
});
binding.requestFrame();
for (const now of [116, 132, 148]) {
expect(callbacks).toHaveLength(1);
const requested = [...callbacks.entries()][0]!;
callbacks.delete(requested[0]);
requested[1](now);
await nextTick();
}
expect(subscriberFrames).toBe(3);
expect(callbacks).toHaveLength(0);
unsubscribe();
binding.requestFrame();
expect(callbacks).toHaveLength(1);
wrapper.unmount();
expect(callbacks).toHaveLength(0);
});
it('owns the only raw window scroll listener used by the complete journey binding', async () => {
const { useJourneySections } = await import('@/composables/useJourneySections');
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'visible',
});
Object.defineProperties(document.documentElement, {
scrollTop: { configurable: true, writable: true, value: 0 },
scrollHeight: { configurable: true, value: 2000 },
clientHeight: { configurable: true, value: 800 },
});
Object.defineProperty(window, 'visualViewport', {
configurable: true,
value: undefined,
});
Object.defineProperty(document, 'fonts', {
configurable: true,
value: { ready: Promise.resolve() },
});
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(0);
const add = vi.spyOn(window, 'addEventListener');
const ids = ['start', 'profile', 'contact'] as const;
const navigation = shallowRef({
epoch: 0,
source: 'passive-scroll' as const,
destinationId: null,
signDestinationId: null,
});
const motionAllowed = shallowRef(true);
const wrapper = mount(defineComponent({
setup() {
useJourneySections(ids, navigation, motionAllowed);
return () => h('main', ids.map((id) => h('section', { id }, [
h('h2', { id: `${id}-heading` }, id),
])));
},
}), { attachTo: document.body });
expect(add.mock.calls.filter(([type]) => type === 'scroll')).toHaveLength(1);
wrapper.unmount();
expect(callbacks).toHaveLength(0);
});
});