feat: add motion policy and adaptive quality
This commit is contained in:
285
tests/unit/adaptive-quality.test.ts
Normal file
285
tests/unit/adaptive-quality.test.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createSSRApp, defineComponent, h, nextTick, ref } from 'vue';
|
||||
import { renderToString } from '@vue/server-renderer';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createAdaptiveQualityController,
|
||||
type AdaptiveQualityController,
|
||||
type QualityDecision,
|
||||
} from '@/journey/adaptive-quality';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('adaptive quality controller', () => {
|
||||
it('starts a fresh epoch with a two-visible-second warm-up', () => {
|
||||
const controller = createAdaptiveQualityController('high');
|
||||
controller.enterRunningEpoch(1000);
|
||||
|
||||
expect(controller.sampleFrame(2900, 2921)).toBeNull();
|
||||
expect(controller.snapshot()).toEqual({
|
||||
maximum: 'high',
|
||||
actual: 'high',
|
||||
epoch: 1,
|
||||
coolingDownUntil: null,
|
||||
eligibleSamples: 0,
|
||||
consecutiveBadLowWindows: 0,
|
||||
});
|
||||
|
||||
expect(controller.sampleFrame(3000, 3021)).toBeNull();
|
||||
expect(controller.snapshot().eligibleSamples).toBe(1);
|
||||
});
|
||||
|
||||
it('requires both five visible seconds and 120 eligible samples before closing a window', () => {
|
||||
const bySamples = createAdaptiveQualityController('high');
|
||||
bySamples.enterRunningEpoch(0);
|
||||
for (let index = 0; index < 120; index += 1) {
|
||||
expect(bySamples.sampleFrame(2000 + index, 2016 + index)).toBeNull();
|
||||
}
|
||||
expect(bySamples.snapshot().eligibleSamples).toBe(120);
|
||||
expect(bySamples.sampleFrame(6999, 7015)).toBeNull();
|
||||
expect(bySamples.snapshot().eligibleSamples).toBe(0);
|
||||
|
||||
const byTime = createAdaptiveQualityController('high');
|
||||
byTime.enterRunningEpoch(0);
|
||||
for (let index = 0; index < 119; index += 1) {
|
||||
const start = 2000 + (index * 5000 / 118);
|
||||
expect(byTime.sampleFrame(start, start + 16)).toBeNull();
|
||||
}
|
||||
expect(byTime.snapshot().eligibleSamples).toBe(119);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['high', 20, null],
|
||||
['high', 20.01, 'medium'],
|
||||
['medium', 25, null],
|
||||
['medium', 25.01, 'low'],
|
||||
] as const)('uses strict %s p95 threshold behavior at %s ms', (tier, p95, expectedNext) => {
|
||||
const controller = createAdaptiveQualityController(tier);
|
||||
controller.enterRunningEpoch(0);
|
||||
|
||||
const result = feedWindow(controller, 2000, p95);
|
||||
|
||||
if (expectedNext === null) {
|
||||
expect(result.decision).toBeNull();
|
||||
expect(controller.snapshot().actual).toBe(tier);
|
||||
} else {
|
||||
expect(result.decision).toEqual({
|
||||
previous: tier,
|
||||
next: expectedNext,
|
||||
reason: 'p95-window',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('uses nearest-rank p95 rather than interpolation', () => {
|
||||
const sixSlow = createAdaptiveQualityController('high');
|
||||
sixSlow.enterRunningEpoch(0);
|
||||
const six = feedWindow(sixSlow, 2000, 21, 6);
|
||||
expect(six.decision).toBeNull();
|
||||
|
||||
const sevenSlow = createAdaptiveQualityController('high');
|
||||
sevenSlow.enterRunningEpoch(0);
|
||||
const seven = feedWindow(sevenSlow, 2000, 21, 7);
|
||||
expect(seven.decision).toMatchObject({ previous: 'high', next: 'medium' });
|
||||
});
|
||||
|
||||
it('requires two consecutive bad Low windows and resets the counter after a good one', () => {
|
||||
const controller = createAdaptiveQualityController('low');
|
||||
controller.enterRunningEpoch(0);
|
||||
|
||||
const firstBad = feedWindow(controller, 2000, 40.01);
|
||||
expect(firstBad.decision).toBeNull();
|
||||
expect(controller.snapshot().consecutiveBadLowWindows).toBe(1);
|
||||
|
||||
const good = feedWindow(controller, firstBad.end, 40);
|
||||
expect(good.decision).toBeNull();
|
||||
expect(controller.snapshot().consecutiveBadLowWindows).toBe(0);
|
||||
|
||||
const nextBad = feedWindow(controller, good.end, 40.01);
|
||||
expect(nextBad.decision).toBeNull();
|
||||
expect(controller.snapshot().consecutiveBadLowWindows).toBe(1);
|
||||
|
||||
const secondBad = feedWindow(controller, nextBad.end, 40.01);
|
||||
expect(secondBad.decision).toEqual({
|
||||
previous: 'low',
|
||||
next: 'static',
|
||||
reason: 'two-bad-low-windows',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['high', 'medium'],
|
||||
['medium', 'low'],
|
||||
['low', 'static'],
|
||||
] as const)('downgrades %s immediately for a same-epoch interval over 250 ms', (tier, next) => {
|
||||
const controller = createAdaptiveQualityController(tier);
|
||||
controller.enterRunningEpoch(0);
|
||||
expect(controller.sampleFrame(2000, 2250)).toBeNull();
|
||||
|
||||
expect(controller.sampleFrame(2250, 2500.01)).toEqual({
|
||||
previous: tier,
|
||||
next,
|
||||
reason: 'catastrophic-frame',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects intervals spanning a pause/hidden epoch and resets its RAF baseline and window', () => {
|
||||
const controller = createAdaptiveQualityController('high');
|
||||
controller.enterRunningEpoch(0);
|
||||
controller.sampleFrame(2000, 2016);
|
||||
controller.leaveRunningEpoch();
|
||||
|
||||
expect(controller.snapshot()).toMatchObject({ epoch: null, eligibleSamples: 0 });
|
||||
expect(controller.sampleFrame(2016, 9000)).toBeNull();
|
||||
|
||||
controller.enterRunningEpoch(9000);
|
||||
expect(controller.sampleFrame(2016, 9016)).toBeNull();
|
||||
expect(controller.sampleFrame(10900, 10921)).toBeNull();
|
||||
expect(controller.snapshot().eligibleSamples).toBe(0);
|
||||
expect(controller.sampleFrame(11000, 11021)).toBeNull();
|
||||
expect(controller.snapshot().eligibleSamples).toBe(1);
|
||||
});
|
||||
|
||||
it('counts the downgrade cooldown only in visible running time and performs a new warm-up', () => {
|
||||
const controller = createAdaptiveQualityController('high');
|
||||
controller.enterRunningEpoch(0);
|
||||
const downgrade = feedWindow(controller, 2000, 20.01);
|
||||
expect(downgrade.decision).toMatchObject({ next: 'medium' });
|
||||
const firstCooldownUntil = controller.snapshot().coolingDownUntil;
|
||||
expect(firstCooldownUntil).toBe(downgrade.end + 30000);
|
||||
|
||||
controller.leaveRunningEpoch();
|
||||
controller.enterRunningEpoch(100000);
|
||||
expect(controller.snapshot().coolingDownUntil).toBe(130000);
|
||||
expect(controller.sampleFrame(129900, 129926)).toBeNull();
|
||||
expect(controller.snapshot().eligibleSamples).toBe(0);
|
||||
expect(controller.sampleFrame(131900, 131926)).toBeNull();
|
||||
expect(controller.snapshot().eligibleSamples).toBe(0);
|
||||
expect(controller.sampleFrame(132000, 132026)).toBeNull();
|
||||
expect(controller.snapshot().eligibleSamples).toBe(1);
|
||||
});
|
||||
|
||||
it('resets the epoch/window on resize, downgrades to a lower maximum, and never auto-upgrades', () => {
|
||||
const controller = createAdaptiveQualityController('high');
|
||||
controller.enterRunningEpoch(0);
|
||||
controller.sampleFrame(2000, 2016);
|
||||
|
||||
expect(controller.setMaximum('medium', 'resize')).toEqual({
|
||||
previous: 'high',
|
||||
next: 'medium',
|
||||
reason: 'maximum-change',
|
||||
});
|
||||
expect(controller.snapshot()).toMatchObject({
|
||||
maximum: 'medium',
|
||||
actual: 'medium',
|
||||
epoch: null,
|
||||
eligibleSamples: 0,
|
||||
consecutiveBadLowWindows: 0,
|
||||
});
|
||||
|
||||
expect(controller.setMaximum('high', 'resize')).toBeNull();
|
||||
expect(controller.snapshot()).toMatchObject({ maximum: 'high', actual: 'medium' });
|
||||
});
|
||||
|
||||
it('allows a deliberate manual maximum change to upgrade within that selected maximum', () => {
|
||||
const controller = createAdaptiveQualityController('medium');
|
||||
|
||||
expect(controller.setMaximum('high', 'manual')).toEqual({
|
||||
previous: 'medium',
|
||||
next: 'high',
|
||||
reason: 'maximum-change',
|
||||
});
|
||||
expect(controller.snapshot()).toMatchObject({ maximum: 'high', actual: 'high' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAdaptiveQuality', () => {
|
||||
it('imports and renders on the server without browser globals', async () => {
|
||||
vi.resetModules();
|
||||
vi.stubGlobal('window', undefined);
|
||||
vi.stubGlobal('document', undefined);
|
||||
const { useAdaptiveQuality } = await import('@/composables/useAdaptiveQuality');
|
||||
const maximum = ref<'static'>('static');
|
||||
const sceneState = ref('static');
|
||||
const pageLifecycle = ref<'active'>('active');
|
||||
const app = createSSRApp(defineComponent({
|
||||
setup() {
|
||||
const adaptive = useAdaptiveQuality({ maximum, sceneState, pageLifecycle });
|
||||
return () => h('output', adaptive.snapshot.value.actual);
|
||||
},
|
||||
}));
|
||||
|
||||
await expect(renderToString(app)).resolves.toBe('<output>static</output>');
|
||||
});
|
||||
|
||||
it('tracks only visible active running epochs and tears down its lifecycle listener', async () => {
|
||||
const { useAdaptiveQuality } = await import('@/composables/useAdaptiveQuality');
|
||||
let visibility: DocumentVisibilityState = 'visible';
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
get: () => visibility,
|
||||
});
|
||||
const maximum = ref<'high' | 'medium'>('high');
|
||||
const sceneState = ref('running');
|
||||
const pageLifecycle = ref<'active' | 'frozen'>('active');
|
||||
let now = 1000;
|
||||
const documentAdd = vi.spyOn(document, 'addEventListener');
|
||||
const documentRemove = vi.spyOn(document, 'removeEventListener');
|
||||
const wrapper = mount(defineComponent({
|
||||
setup: () => useAdaptiveQuality({ maximum, sceneState, pageLifecycle, now: () => now }),
|
||||
template: '<output />',
|
||||
}));
|
||||
|
||||
expect(wrapper.vm.snapshot.epoch).toBe(1);
|
||||
expect(documentAdd).toHaveBeenCalledWith('visibilitychange', expect.any(Function), { passive: true });
|
||||
|
||||
visibility = 'hidden';
|
||||
now = 2000;
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
expect(wrapper.vm.snapshot.epoch).toBeNull();
|
||||
|
||||
visibility = 'visible';
|
||||
now = 5000;
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
expect(wrapper.vm.snapshot.epoch).toBe(2);
|
||||
|
||||
pageLifecycle.value = 'frozen';
|
||||
await nextTick();
|
||||
expect(wrapper.vm.snapshot.epoch).toBeNull();
|
||||
|
||||
pageLifecycle.value = 'active';
|
||||
sceneState.value = 'running';
|
||||
now = 7000;
|
||||
await nextTick();
|
||||
expect(wrapper.vm.snapshot.epoch).toBe(3);
|
||||
|
||||
maximum.value = 'medium';
|
||||
now = 8000;
|
||||
await nextTick();
|
||||
expect(wrapper.vm.snapshot).toMatchObject({ maximum: 'medium', actual: 'medium', epoch: 4 });
|
||||
|
||||
wrapper.unmount();
|
||||
expect(wrapper.vm.snapshot.epoch).toBeNull();
|
||||
expect(documentRemove).toHaveBeenCalledWith('visibilitychange', expect.any(Function), { passive: true });
|
||||
});
|
||||
});
|
||||
|
||||
function feedWindow(
|
||||
controller: AdaptiveQualityController,
|
||||
windowStart: number,
|
||||
slowDuration: number,
|
||||
slowCount = 120,
|
||||
): { decision: QualityDecision | null; end: number } {
|
||||
let decision: QualityDecision | null = null;
|
||||
let end = windowStart;
|
||||
for (let index = 0; index < 120; index += 1) {
|
||||
const start = windowStart + (index * 5000 / 119);
|
||||
const duration = index >= 120 - slowCount ? slowDuration : 16;
|
||||
end = start + duration;
|
||||
decision = controller.sampleFrame(start, end);
|
||||
}
|
||||
return { decision, end };
|
||||
}
|
||||
322
tests/unit/motion-policy.test.ts
Normal file
322
tests/unit/motion-policy.test.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
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: 'data-saver', 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: '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;
|
||||
Reference in New Issue
Block a user