327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
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('<output>saved-static:0</output>');
|
|
});
|
|
|
|
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: '<output />',
|
|
}));
|
|
|
|
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: '<output />',
|
|
}));
|
|
|
|
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: '<output />',
|
|
}));
|
|
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: '<output />',
|
|
}))).not.toThrow();
|
|
});
|
|
});
|
|
|
|
function createMediaQuery(readMatches: () => boolean): MediaQueryList & { listenerCount: number } {
|
|
const target = new EventTarget();
|
|
const listeners = new Set<EventListenerOrEventListenerObject>();
|
|
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<EventListenerOrEventListenerObject>();
|
|
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;
|