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 { initialMaximum, resolveMotionPolicy } from '@/journey/motion-policy'; afterEach(() => { localStorage.clear(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); describe('motion policy', () => { it.each([ [{ width: 767, coarsePointer: false }, 'low'], [{ width: 768, coarsePointer: false }, 'medium'], [{ width: 1279, coarsePointer: false }, 'medium'], [{ width: 1280, coarsePointer: false }, 'high'], [{ width: 1440, coarsePointer: true }, 'low'], ] as const)('maps the viewport and primary pointer to the exact initial maximum', (input, expected) => { expect(initialMaximum(input)).toBe(expected); }); it('fails closed to Low for an invalid viewport width', () => { expect(initialMaximum({ width: Number.NaN, coarsePointer: false })).toBe('low'); }); it('gives saved Static precedence over every other motion input', () => { expect(resolveMotionPolicy({ systemReduce: true, savedPause: true, savedStatic: true, dataSaver: true, width: 1440, coarsePointer: false, savedMaximum: 'high', })).toEqual({ allowSceneRequest: false, allowAutomaticVideo: false, maximumTier: 'static', reason: 'saved-static', }); }); it('does not let a saved full-motion maximum override system reduction', () => { expect(resolveMotionPolicy({ systemReduce: true, savedPause: false, savedStatic: false, dataSaver: false, width: 1440, coarsePointer: false, savedMaximum: 'high', })).toEqual({ allowSceneRequest: false, allowAutomaticVideo: false, maximumTier: 'static', reason: 'system-reduce', }); }); it('supports the minimal precedence input used by scene-request callers', () => { expect(resolveMotionPolicy({ systemReduce: true, savedPause: false, savedStatic: false, })).toMatchObject({ allowSceneRequest: false, maximumTier: 'static', }); }); it('places visitor pause ahead of data saver', () => { expect(resolveMotionPolicy({ systemReduce: false, savedPause: true, savedStatic: false, dataSaver: true, width: 1440, coarsePointer: false, savedMaximum: 'high', })).toMatchObject({ allowSceneRequest: false, maximumTier: 'static', reason: 'visitor-pause', }); }); it('blocks ambient scene and automatic video requests under data saver', () => { expect(resolveMotionPolicy({ systemReduce: false, savedPause: false, savedStatic: false, dataSaver: true, width: 1440, coarsePointer: false, savedMaximum: 'high', })).toEqual({ allowSceneRequest: false, allowAutomaticVideo: false, maximumTier: 'static', reason: 'data-saver', }); }); it.each([ ['high', 'high'], ['medium', 'medium'], ['low', 'low'], ] as const)('treats saved %s as a cap on a wide fine-pointer device', (savedMaximum, maximumTier) => { expect(resolveMotionPolicy({ systemReduce: false, savedPause: false, savedStatic: false, dataSaver: false, width: 1440, coarsePointer: false, savedMaximum, })).toEqual({ allowSceneRequest: true, allowAutomaticVideo: true, maximumTier, reason: 'full', }); }); it('keeps a narrow-device Low cap below a saved High maximum', () => { expect(resolveMotionPolicy({ systemReduce: false, savedPause: false, savedStatic: false, dataSaver: false, width: 390, coarsePointer: true, savedMaximum: 'high', }).maximumTier).toBe('low'); }); }); describe('useMotionPreferences', () => { it('imports and renders fail-closed on the server without browser globals', async () => { vi.resetModules(); vi.stubGlobal('window', undefined); vi.stubGlobal('document', undefined); vi.stubGlobal('navigator', undefined); const { useMotionPreferences } = await import('@/composables/useMotionPreferences'); const app = createSSRApp(defineComponent({ setup() { const motion = useMotionPreferences(); return () => h('output', `${motion.policy.value.reason}:${motion.volume.value}`); }, })); await expect(renderToString(app)).resolves.toBe('saved-static:0'); }); it('loads only the three allowed preferences and reacts to system, network, and resize changes', async () => { const { MOTION_PREFERENCE_KEYS, useMotionPreferences } = await import('@/composables/useMotionPreferences'); localStorage.setItem(MOTION_PREFERENCE_KEYS.pause, 'false'); localStorage.setItem(MOTION_PREFERENCE_KEYS.maximumQuality, 'high'); localStorage.setItem(MOTION_PREFERENCE_KEYS.volume, '0.4'); let reduced = false; let coarse = false; const reducedQuery = createMediaQuery(() => reduced); const coarseQuery = createMediaQuery(() => coarse); const matchMedia = vi.fn((query: string) => query.includes('reduced') ? reducedQuery : coarseQuery); Object.defineProperty(window, 'matchMedia', { configurable: true, value: matchMedia }); Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 1440 }); const connection = createConnection(false); Object.defineProperty(navigator, 'connection', { configurable: true, value: connection }); const windowAdd = vi.spyOn(window, 'addEventListener'); const windowRemove = vi.spyOn(window, 'removeEventListener'); const wrapper = mount(defineComponent({ setup() { return useMotionPreferences(); }, template: '', })); expect(wrapper.vm.policy).toEqual({ allowSceneRequest: true, allowAutomaticVideo: true, maximumTier: 'high', reason: 'full', }); expect(wrapper.vm.volume).toBe(0.4); expect(matchMedia).toHaveBeenCalledWith('(prefers-reduced-motion: reduce)'); expect(matchMedia).toHaveBeenCalledWith('(pointer: coarse)'); expect(windowAdd).toHaveBeenCalledWith('resize', expect.any(Function), { passive: true }); reduced = true; reducedQuery.dispatchEvent(new Event('change')); await nextTick(); expect(wrapper.vm.policy).toMatchObject({ reason: 'system-reduce', maximumTier: 'static' }); reduced = false; reducedQuery.dispatchEvent(new Event('change')); connection.saveData = true; connection.dispatchEvent(new Event('change')); await nextTick(); expect(wrapper.vm.policy).toMatchObject({ reason: 'saved-static', allowSceneRequest: false }); connection.saveData = false; connection.dispatchEvent(new Event('change')); window.innerWidth = 390; coarse = true; coarseQuery.dispatchEvent(new Event('change')); window.dispatchEvent(new Event('resize')); await nextTick(); expect(wrapper.vm.policy).toMatchObject({ reason: 'saved-static', maximumTier: 'static' }); wrapper.vm.setMaximumQuality('high'); await nextTick(); expect(wrapper.vm.policy).toMatchObject({ reason: 'full', maximumTier: 'low' }); wrapper.unmount(); expect(windowRemove).toHaveBeenCalledWith('resize', expect.any(Function), { passive: true }); expect(reducedQuery.listenerCount).toBe(0); expect(coarseQuery.listenerCount).toBe(0); expect(connection.listenerCount).toBe(0); }); it('persists pause, maximum quality, and volume under only their exact keys', async () => { const { MOTION_PREFERENCE_KEYS, useMotionPreferences } = await import('@/composables/useMotionPreferences'); const mediaQuery = createMediaQuery(() => false); Object.defineProperty(window, 'matchMedia', { configurable: true, value: () => mediaQuery }); Object.defineProperty(navigator, 'connection', { configurable: true, value: undefined }); const setItem = vi.spyOn(window.localStorage, 'setItem'); const wrapper = mount(defineComponent({ setup: () => useMotionPreferences(), template: '', })); wrapper.vm.setPaused(true); wrapper.vm.setMaximumQuality('medium'); wrapper.vm.setVolume(0.65); await nextTick(); expect(setItem.mock.calls).toEqual([ [MOTION_PREFERENCE_KEYS.pause, 'true'], [MOTION_PREFERENCE_KEYS.maximumQuality, 'medium'], [MOTION_PREFERENCE_KEYS.volume, '0.65'], ]); expect(wrapper.vm.policy).toMatchObject({ reason: 'visitor-pause', maximumTier: 'static' }); expect(wrapper.vm.maximumQuality).toBe('medium'); expect(wrapper.vm.volume).toBe(0.65); wrapper.unmount(); }); it('treats corrupt or inaccessible storage as paused Static and silent without throwing', async () => { const { MOTION_PREFERENCE_KEYS, useMotionPreferences } = await import('@/composables/useMotionPreferences'); localStorage.setItem(MOTION_PREFERENCE_KEYS.pause, 'sometimes'); localStorage.setItem(MOTION_PREFERENCE_KEYS.maximumQuality, 'ultra'); localStorage.setItem(MOTION_PREFERENCE_KEYS.volume, 'loud'); const mediaQuery = createMediaQuery(() => false); Object.defineProperty(window, 'matchMedia', { configurable: true, value: () => mediaQuery }); Object.defineProperty(navigator, 'connection', { configurable: true, value: undefined }); const corrupt = mount(defineComponent({ setup: () => useMotionPreferences(), template: '', })); expect(corrupt.vm.policy).toMatchObject({ reason: 'saved-static', maximumTier: 'static' }); expect(corrupt.vm.paused).toBe(true); expect(corrupt.vm.volume).toBe(0); corrupt.unmount(); vi.spyOn(window.localStorage, 'getItem').mockImplementation(() => { throw new DOMException('denied'); }); expect(() => mount(defineComponent({ setup: () => useMotionPreferences(), template: '', }))).not.toThrow(); }); }); function createMediaQuery(readMatches: () => boolean): MediaQueryList & { listenerCount: number } { const target = new EventTarget(); const listeners = new Set(); return { media: '', onchange: null, get matches() { return readMatches(); }, addListener: vi.fn(), removeListener: vi.fn(), addEventListener(type: string, listener: EventListenerOrEventListenerObject) { listeners.add(listener); target.addEventListener(type, listener); }, removeEventListener(type: string, listener: EventListenerOrEventListenerObject) { listeners.delete(listener); target.removeEventListener(type, listener); }, dispatchEvent: (event: Event) => target.dispatchEvent(event), get listenerCount() { return listeners.size; }, } as MediaQueryList & { listenerCount: number }; } function createConnection(initialSaveData: boolean): EventTarget & { saveData: boolean; listenerCount: number } { const target = new EventTarget(); const listeners = new Set(); return { saveData: initialSaveData, addEventListener(type: string, listener: EventListenerOrEventListenerObject) { listeners.add(listener); target.addEventListener(type, listener); }, removeEventListener(type: string, listener: EventListenerOrEventObject) { listeners.delete(listener); target.removeEventListener(type, listener); }, dispatchEvent: (event: Event) => target.dispatchEvent(event), get listenerCount() { return listeners.size; }, } as EventTarget & { saveData: boolean; listenerCount: number }; } type EventListenerOrEventObject = EventListenerOrEventListenerObject;