From cc0887fee0a3d53580958d830e2a2dbcad7de6ca Mon Sep 17 00:00:00 2001 From: LucaP Date: Wed, 2 Sep 2026 04:39:28 +0300 Subject: [PATCH] feat: add motion policy and adaptive quality --- src/composables/useAdaptiveQuality.ts | 117 +++++++++ src/composables/useMotionPreferences.ts | 195 ++++++++++++++ src/journey/adaptive-quality.ts | 228 +++++++++++++++++ src/journey/motion-policy.ts | 79 ++++++ tests/unit/adaptive-quality.test.ts | 285 +++++++++++++++++++++ tests/unit/motion-policy.test.ts | 322 ++++++++++++++++++++++++ 6 files changed, 1226 insertions(+) create mode 100644 src/composables/useAdaptiveQuality.ts create mode 100644 src/composables/useMotionPreferences.ts create mode 100644 src/journey/adaptive-quality.ts create mode 100644 src/journey/motion-policy.ts create mode 100644 tests/unit/adaptive-quality.test.ts create mode 100644 tests/unit/motion-policy.test.ts diff --git a/src/composables/useAdaptiveQuality.ts b/src/composables/useAdaptiveQuality.ts new file mode 100644 index 0000000..d87a6a1 --- /dev/null +++ b/src/composables/useAdaptiveQuality.ts @@ -0,0 +1,117 @@ +import { + onMounted, + onUnmounted, + readonly, + shallowRef, + watch, + type Ref, + type WatchStopHandle, +} from 'vue'; +import { + createAdaptiveQualityController, + type AdaptiveQualitySnapshot, + type QualityDecision, +} from '@/journey/adaptive-quality'; +import type { QualityTier } from '@/journey/motion-policy'; + +const PASSIVE_LISTENER: AddEventListenerOptions = { passive: true }; + +export interface AdaptiveQualitySources { + maximum: Readonly>; + sceneState: Readonly>; + pageLifecycle: Readonly>; + now?: () => number; +} + +export interface AdaptiveQualityBinding { + snapshot: Readonly>; + sampleFrame(start: number, end: number): QualityDecision | null; + setMaximum(tier: QualityTier, reason: 'resize' | 'manual'): QualityDecision | null; +} + +export function useAdaptiveQuality(sources: AdaptiveQualitySources): AdaptiveQualityBinding { + const controller = createAdaptiveQualityController(sources.maximum.value); + const snapshot = shallowRef(controller.snapshot()); + const readNow = sources.now ?? (() => performance.now()); + let visible = false; + let mounted = false; + let stopWatch: WatchStopHandle = () => {}; + let teardown = (): void => {}; + + const publish = (): void => { + snapshot.value = controller.snapshot(); + }; + + const isEligible = (): boolean => ( + mounted + && visible + && sources.pageLifecycle.value === 'active' + && sources.sceneState.value === 'running' + ); + + const reconcileEpoch = (): void => { + if (isEligible()) { + if (controller.snapshot().epoch === null) { + controller.enterRunningEpoch(readNow()); + } + } else { + controller.leaveRunningEpoch(); + } + publish(); + }; + + const sampleFrame = (start: number, end: number): QualityDecision | null => { + const decision = controller.sampleFrame(start, end); + publish(); + return decision; + }; + + const setMaximum = ( + tier: QualityTier, + reason: 'resize' | 'manual', + ): QualityDecision | null => { + const decision = controller.setMaximum(tier, reason); + reconcileEpoch(); + return decision; + }; + + onMounted(() => { + mounted = true; + visible = document.visibilityState === 'visible'; + + const handleVisibility = (): void => { + visible = document.visibilityState === 'visible'; + reconcileEpoch(); + }; + document.addEventListener('visibilitychange', handleVisibility, PASSIVE_LISTENER); + + stopWatch = watch( + [sources.maximum, sources.sceneState, sources.pageLifecycle], + ([maximum], [previousMaximum]) => { + if (maximum !== previousMaximum) { + controller.setMaximum(maximum, 'resize'); + } + reconcileEpoch(); + }, + { flush: 'sync' }, + ); + reconcileEpoch(); + + teardown = (): void => { + mounted = false; + stopWatch(); + stopWatch = () => {}; + document.removeEventListener('visibilitychange', handleVisibility, PASSIVE_LISTENER); + controller.leaveRunningEpoch(); + publish(); + }; + }); + + onUnmounted(() => teardown()); + + return { + snapshot: readonly(snapshot), + sampleFrame, + setMaximum, + }; +} diff --git a/src/composables/useMotionPreferences.ts b/src/composables/useMotionPreferences.ts new file mode 100644 index 0000000..c367cf2 --- /dev/null +++ b/src/composables/useMotionPreferences.ts @@ -0,0 +1,195 @@ +import { onMounted, onUnmounted, readonly, shallowRef, type Ref } from 'vue'; +import { + resolveMotionPolicy, + type MotionPolicy, + type QualityTier, +} from '@/journey/motion-policy'; + +export const MOTION_PREFERENCE_KEYS = Object.freeze({ + pause: 'portfolio.motion.pause', + maximumQuality: 'portfolio.motion.maximum-quality', + volume: 'portfolio.audio.volume', +}); + +const PASSIVE_LISTENER: AddEventListenerOptions = { passive: true }; +const QUALITY_TIERS = new Set(['high', 'medium', 'low', 'static']); + +interface NetworkInformationLike extends EventTarget { + saveData?: boolean; +} + +export interface MotionPreferences { + policy: Readonly>; + paused: Readonly>; + maximumQuality: Readonly>; + volume: Readonly>; + systemReduce: Readonly>; + dataSaver: Readonly>; + setPaused(value: boolean): void; + setMaximumQuality(value: QualityTier): void; + setVolume(value: number): void; +} + +const failClosedPolicy = (): MotionPolicy => resolveMotionPolicy({ + systemReduce: false, + savedPause: true, + savedStatic: true, + dataSaver: false, + width: 0, + coarsePointer: true, + savedMaximum: 'static', +}); + +export function useMotionPreferences(): MotionPreferences { + const policy = shallowRef(failClosedPolicy()); + const paused = shallowRef(true); + const maximumQuality = shallowRef('static'); + const volume = shallowRef(0); + const systemReduce = shallowRef(false); + const dataSaver = shallowRef(false); + + let reducedQuery: MediaQueryList | undefined; + let coarseQuery: MediaQueryList | undefined; + let connection: NetworkInformationLike | undefined; + let teardown = (): void => {}; + + const viewportWidth = (): number => window.visualViewport?.width ?? window.innerWidth; + + const updatePolicy = (): void => { + const coarsePointer = coarseQuery?.matches ?? true; + policy.value = resolveMotionPolicy({ + systemReduce: systemReduce.value, + savedPause: paused.value, + savedStatic: maximumQuality.value === 'static', + dataSaver: dataSaver.value, + width: viewportWidth(), + coarsePointer, + savedMaximum: maximumQuality.value, + }); + }; + + const persist = (key: string, value: string): void => { + try { + if (typeof window !== 'undefined') { + window.localStorage.setItem(key, value); + } + } catch { + // Preference persistence is optional; the in-memory safe state still applies. + } + }; + + const setPaused = (value: boolean): void => { + paused.value = value; + persist(MOTION_PREFERENCE_KEYS.pause, String(value)); + if (typeof window !== 'undefined') { + updatePolicy(); + } + }; + + const setMaximumQuality = (value: QualityTier): void => { + maximumQuality.value = value; + persist(MOTION_PREFERENCE_KEYS.maximumQuality, value); + if (typeof window !== 'undefined') { + updatePolicy(); + } + }; + + const setVolume = (value: number): void => { + volume.value = Number.isFinite(value) && value >= 0 && value <= 1 ? value : 0; + persist(MOTION_PREFERENCE_KEYS.volume, String(volume.value)); + }; + + onMounted(() => { + const disposers: Array<() => void> = []; + try { + const stored = readStoredPreferences(window.localStorage); + paused.value = stored.paused; + maximumQuality.value = stored.maximumQuality; + volume.value = stored.volume; + + reducedQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + coarseQuery = window.matchMedia('(pointer: coarse)'); + connection = (window.navigator as Navigator & { connection?: NetworkInformationLike }).connection; + + const updateReduced = (): void => { + systemReduce.value = reducedQuery?.matches ?? true; + updatePolicy(); + }; + const updateCoarse = (): void => updatePolicy(); + const updateConnection = (): void => { + dataSaver.value = connection?.saveData === true; + updatePolicy(); + }; + const updateViewport = (): void => updatePolicy(); + + systemReduce.value = reducedQuery.matches; + dataSaver.value = connection?.saveData === true; + updatePolicy(); + + reducedQuery.addEventListener('change', updateReduced); + coarseQuery.addEventListener('change', updateCoarse); + connection?.addEventListener('change', updateConnection); + window.addEventListener('resize', updateViewport, PASSIVE_LISTENER); + disposers.push( + () => reducedQuery?.removeEventListener('change', updateReduced), + () => coarseQuery?.removeEventListener('change', updateCoarse), + () => connection?.removeEventListener('change', updateConnection), + () => window.removeEventListener('resize', updateViewport, PASSIVE_LISTENER), + ); + } catch { + paused.value = true; + maximumQuality.value = 'static'; + volume.value = 0; + systemReduce.value = true; + dataSaver.value = true; + policy.value = failClosedPolicy(); + } + + teardown = (): void => { + for (const dispose of disposers) { + dispose(); + } + disposers.length = 0; + }; + }); + + onUnmounted(() => teardown()); + + return { + policy: readonly(policy), + paused: readonly(paused), + maximumQuality: readonly(maximumQuality), + volume: readonly(volume), + systemReduce: readonly(systemReduce), + dataSaver: readonly(dataSaver), + setPaused, + setMaximumQuality, + setVolume, + }; +} + +function readStoredPreferences(storage: Storage): { + paused: boolean; + maximumQuality: QualityTier; + volume: number; +} { + const storedPause = storage.getItem(MOTION_PREFERENCE_KEYS.pause); + const storedMaximum = storage.getItem(MOTION_PREFERENCE_KEYS.maximumQuality); + const storedVolume = storage.getItem(MOTION_PREFERENCE_KEYS.volume); + + return { + paused: storedPause === null ? false : storedPause === 'false' ? false : true, + maximumQuality: storedMaximum === null + ? 'high' + : QUALITY_TIERS.has(storedMaximum as QualityTier) ? storedMaximum as QualityTier : 'static', + volume: parseVolume(storedVolume), + }; +} + +function parseVolume(value: string | null): number { + if (value === null) { + return 0.4; + } + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : 0; +} diff --git a/src/journey/adaptive-quality.ts b/src/journey/adaptive-quality.ts new file mode 100644 index 0000000..ed4cdd7 --- /dev/null +++ b/src/journey/adaptive-quality.ts @@ -0,0 +1,228 @@ +import type { QualityTier } from '@/journey/motion-policy'; + +export interface QualityDecision { + previous: QualityTier; + next: QualityTier; + reason: 'catastrophic-frame' | 'p95-window' | 'two-bad-low-windows' | 'maximum-change'; +} + +export interface AdaptiveQualitySnapshot { + maximum: QualityTier; + actual: QualityTier; + epoch: number | null; + coolingDownUntil: number | null; + eligibleSamples: number; + consecutiveBadLowWindows: number; +} + +export interface AdaptiveQualityController { + enterRunningEpoch(now: number): void; + leaveRunningEpoch(): void; + sampleFrame(start: number, end: number): QualityDecision | null; + setMaximum(tier: QualityTier, reason: 'resize' | 'manual'): QualityDecision | null; + snapshot(): AdaptiveQualitySnapshot; +} + +const WARM_UP_MS = 2000; +const WINDOW_MS = 5000; +const WINDOW_SAMPLES = 120; +const CATASTROPHIC_MS = 250; +const COOLDOWN_MS = 30000; + +const TIER_RANK: Readonly> = { + static: 0, + low: 1, + medium: 2, + high: 3, +}; + +export function createAdaptiveQualityController(initialMaximum: QualityTier): AdaptiveQualityController { + let maximum = initialMaximum; + let actual = initialMaximum; + let epoch: number | null = null; + let nextEpoch = 1; + let epochStartedAt = 0; + let lastEpochTime = 0; + let samplingReadyAt = 0; + let coolingDownUntil: number | null = null; + let pausedCooldownRemaining = 0; + let windowStartedAt: number | null = null; + let samples: number[] = []; + let consecutiveBadLowWindows = 0; + + const resetWindow = (): void => { + windowStartedAt = null; + samples = []; + }; + + const leaveRunningEpoch = (): void => { + if (epoch === null) { + return; + } + if (coolingDownUntil !== null) { + pausedCooldownRemaining = Math.max(0, coolingDownUntil - lastEpochTime); + } + epoch = null; + coolingDownUntil = null; + resetWindow(); + }; + + const enterRunningEpoch = (now: number): void => { + if (!Number.isFinite(now)) { + leaveRunningEpoch(); + return; + } + leaveRunningEpoch(); + epoch = nextEpoch; + nextEpoch += 1; + epochStartedAt = now; + lastEpochTime = now; + if (pausedCooldownRemaining > 0) { + coolingDownUntil = now + pausedCooldownRemaining; + samplingReadyAt = coolingDownUntil + WARM_UP_MS; + pausedCooldownRemaining = 0; + } else { + coolingDownUntil = null; + samplingReadyAt = now + WARM_UP_MS; + } + resetWindow(); + }; + + const beginCooldown = (now: number): void => { + coolingDownUntil = now + COOLDOWN_MS; + samplingReadyAt = coolingDownUntil + WARM_UP_MS; + pausedCooldownRemaining = 0; + consecutiveBadLowWindows = 0; + resetWindow(); + }; + + const downgrade = ( + now: number, + reason: QualityDecision['reason'], + ): QualityDecision | null => { + const next = nextLowerTier(actual); + if (next === null) { + resetWindow(); + return null; + } + const decision: QualityDecision = { previous: actual, next, reason }; + actual = next; + beginCooldown(now); + return decision; + }; + + const sampleFrame = (start: number, end: number): QualityDecision | null => { + if ( + epoch === null + || !Number.isFinite(start) + || !Number.isFinite(end) + || end <= start + || start < epochStartedAt + ) { + return null; + } + + lastEpochTime = Math.max(lastEpochTime, end); + if (coolingDownUntil !== null) { + if (end < coolingDownUntil) { + return null; + } + coolingDownUntil = null; + } + if (start < samplingReadyAt || actual === 'static') { + return null; + } + + const interval = end - start; + if (interval > CATASTROPHIC_MS) { + return downgrade(end, 'catastrophic-frame'); + } + + if (windowStartedAt === null) { + windowStartedAt = samplingReadyAt; + } + samples.push(interval); + if (end - windowStartedAt < WINDOW_MS || samples.length < WINDOW_SAMPLES) { + return null; + } + + const p95 = nearestRankP95(samples); + resetWindow(); + samplingReadyAt = end; + + if (actual === 'high' && p95 > 20) { + return downgrade(end, 'p95-window'); + } + if (actual === 'medium' && p95 > 25) { + return downgrade(end, 'p95-window'); + } + if (actual === 'low') { + consecutiveBadLowWindows = p95 > 40 ? consecutiveBadLowWindows + 1 : 0; + if (consecutiveBadLowWindows >= 2) { + return downgrade(end, 'two-bad-low-windows'); + } + } + return null; + }; + + const setMaximum = ( + tier: QualityTier, + reason: 'resize' | 'manual', + ): QualityDecision | null => { + leaveRunningEpoch(); + pausedCooldownRemaining = 0; + coolingDownUntil = null; + consecutiveBadLowWindows = 0; + resetWindow(); + + maximum = tier; + const nextActual = reason === 'manual' + ? tier + : TIER_RANK[actual] > TIER_RANK[tier] ? tier : actual; + if (nextActual === actual) { + return null; + } + const decision: QualityDecision = { + previous: actual, + next: nextActual, + reason: 'maximum-change', + }; + actual = nextActual; + return decision; + }; + + const snapshot = (): AdaptiveQualitySnapshot => ({ + maximum, + actual, + epoch, + coolingDownUntil, + eligibleSamples: samples.length, + consecutiveBadLowWindows, + }); + + return { + enterRunningEpoch, + leaveRunningEpoch, + sampleFrame, + setMaximum, + snapshot, + }; +} + +function nearestRankP95(values: readonly number[]): number { + const sorted = [...values].sort((first, second) => first - second); + return sorted[Math.ceil(0.95 * sorted.length) - 1] ?? Number.POSITIVE_INFINITY; +} + +function nextLowerTier(tier: QualityTier): QualityTier | null { + switch (tier) { + case 'high': + return 'medium'; + case 'medium': + return 'low'; + case 'low': + return 'static'; + case 'static': + return null; + } +} diff --git a/src/journey/motion-policy.ts b/src/journey/motion-policy.ts new file mode 100644 index 0000000..b222ee9 --- /dev/null +++ b/src/journey/motion-policy.ts @@ -0,0 +1,79 @@ +export type QualityTier = 'high' | 'medium' | 'low' | 'static'; + +export interface MotionPolicy { + allowSceneRequest: boolean; + allowAutomaticVideo: boolean; + maximumTier: QualityTier; + reason: 'full' | 'system-reduce' | 'visitor-pause' | 'saved-static' | 'data-saver'; +} + +export interface InitialMaximumInput { + width: number; + coarsePointer: boolean; +} + +export interface MotionPolicyInput { + systemReduce: boolean; + savedPause: boolean; + savedStatic: boolean; + dataSaver?: boolean; + width?: number; + coarsePointer?: boolean; + savedMaximum?: QualityTier; +} + +const TIER_RANK: Readonly> = { + static: 0, + low: 1, + medium: 2, + high: 3, +}; + +export function initialMaximum({ width, coarsePointer }: InitialMaximumInput): QualityTier { + if (!Number.isFinite(width) || width < 768 || coarsePointer) { + return 'low'; + } + if (width < 1280) { + return 'medium'; + } + return 'high'; +} + +export function lowerTier(first: QualityTier, second: QualityTier): QualityTier { + return TIER_RANK[first] <= TIER_RANK[second] ? first : second; +} + +export function resolveMotionPolicy(input: MotionPolicyInput): MotionPolicy { + const savedMaximum = input.savedMaximum ?? 'high'; + if (input.savedStatic || savedMaximum === 'static') { + return blockedPolicy('saved-static'); + } + if (input.systemReduce) { + return blockedPolicy('system-reduce'); + } + if (input.savedPause) { + return blockedPolicy('visitor-pause'); + } + if (input.dataSaver === true) { + return blockedPolicy('data-saver'); + } + + return { + allowSceneRequest: true, + allowAutomaticVideo: true, + maximumTier: lowerTier(initialMaximum({ + width: input.width ?? 1280, + coarsePointer: input.coarsePointer ?? false, + }), savedMaximum), + reason: 'full', + }; +} + +function blockedPolicy(reason: Exclude): MotionPolicy { + return { + allowSceneRequest: false, + allowAutomaticVideo: false, + maximumTier: 'static', + reason, + }; +} diff --git a/tests/unit/adaptive-quality.test.ts b/tests/unit/adaptive-quality.test.ts new file mode 100644 index 0000000..eeadfca --- /dev/null +++ b/tests/unit/adaptive-quality.test.ts @@ -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('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 }); + }); +}); + +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 }; +} diff --git a/tests/unit/motion-policy.test.ts b/tests/unit/motion-policy.test.ts new file mode 100644 index 0000000..e6cd3e3 --- /dev/null +++ b/tests/unit/motion-policy.test.ts @@ -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('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: '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: '', + })); + + 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;