feat: add motion policy and adaptive quality
This commit is contained in:
117
src/composables/useAdaptiveQuality.ts
Normal file
117
src/composables/useAdaptiveQuality.ts
Normal file
@@ -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<Ref<QualityTier>>;
|
||||||
|
sceneState: Readonly<Ref<string>>;
|
||||||
|
pageLifecycle: Readonly<Ref<'active' | 'frozen' | 'disposed'>>;
|
||||||
|
now?: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdaptiveQualityBinding {
|
||||||
|
snapshot: Readonly<Ref<AdaptiveQualitySnapshot>>;
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
195
src/composables/useMotionPreferences.ts
Normal file
195
src/composables/useMotionPreferences.ts
Normal file
@@ -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<QualityTier>(['high', 'medium', 'low', 'static']);
|
||||||
|
|
||||||
|
interface NetworkInformationLike extends EventTarget {
|
||||||
|
saveData?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MotionPreferences {
|
||||||
|
policy: Readonly<Ref<MotionPolicy>>;
|
||||||
|
paused: Readonly<Ref<boolean>>;
|
||||||
|
maximumQuality: Readonly<Ref<QualityTier>>;
|
||||||
|
volume: Readonly<Ref<number>>;
|
||||||
|
systemReduce: Readonly<Ref<boolean>>;
|
||||||
|
dataSaver: Readonly<Ref<boolean>>;
|
||||||
|
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<MotionPolicy>(failClosedPolicy());
|
||||||
|
const paused = shallowRef(true);
|
||||||
|
const maximumQuality = shallowRef<QualityTier>('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;
|
||||||
|
}
|
||||||
228
src/journey/adaptive-quality.ts
Normal file
228
src/journey/adaptive-quality.ts
Normal file
@@ -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<Record<QualityTier, number>> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/journey/motion-policy.ts
Normal file
79
src/journey/motion-policy.ts
Normal file
@@ -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<Record<QualityTier, number>> = {
|
||||||
|
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['reason'], 'full'>): MotionPolicy {
|
||||||
|
return {
|
||||||
|
allowSceneRequest: false,
|
||||||
|
allowAutomaticVideo: false,
|
||||||
|
maximumTier: 'static',
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
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