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('0.18'); }); 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(); 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; 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(); 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; 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(); 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, }); 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); }); });