Files
Portfolio/tests/unit/adaptive-quality.test.ts

307 lines
11 KiB
TypeScript

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';
import type { QualityTier } from '@/journey/motion-policy';
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 });
});
it('never lets a visitor request bypass the device-resolved maximum', async () => {
const { useAdaptiveQuality } = await import('@/composables/useAdaptiveQuality');
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'visible',
});
const maximum = ref<QualityTier>('low');
const sceneState = ref('running');
const pageLifecycle = ref<'active'>('active');
const wrapper = mount(defineComponent({
setup: () => useAdaptiveQuality({ maximum, sceneState, pageLifecycle, now: () => 0 }),
template: '<output />',
}));
expect(wrapper.vm.setMaximum('high', 'manual')).toBeNull();
expect(wrapper.vm.snapshot).toMatchObject({ maximum: 'low', actual: 'low' });
wrapper.unmount();
});
});
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 };
}