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('static'); }); 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: '', })); 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('low'); const sceneState = ref('running'); const pageLifecycle = ref<'active'>('active'); const wrapper = mount(defineComponent({ setup: () => useAdaptiveQuality({ maximum, sceneState, pageLifecycle, now: () => 0 }), template: '', })); 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 }; }