Files
MoviePilot-Frontend/src/utils/__tests__/glassOptics.spec.ts
T

463 lines
18 KiB
TypeScript

import {
canUseGlassWallpaperTexture,
GLASS_OPTICAL_MOTION_MAX_SCALE,
GLASS_OPTICAL_REFLECTION_MAX_SCALE,
getAvailableGlassOpticalPresets,
getGlassCoverScale,
getGlassOpticalCssTransmissionBrightness,
getGlassOpticalDecay,
getGlassOpticalBufferSize,
getGlassOpticalMaxRefractionPixels,
getGlassOpticalMotionEnergy,
getGlassOpticalMotionExpansion,
getGlassOpticalMotionStrengthScale,
getGlassOpticalPresetParameters,
getGlassOpticalReflectionStrengthScale,
getGlassOpticalRenderProfile,
getGlassOpticalTransparency,
getGlassOpticalTransmissionStrength,
getGlassOpticalSurfaceTransitionWeights,
getGlassOpticalWakeDirection,
getGlassOpticalWakeSample,
getGlassScrollBufferSize,
getGlassWallpaperTransitionProgress,
normalizeGlassOpticalRect,
normalizeGlassOpticalStrength,
reconcileGlassOpticalSurfaceSlots,
selectGlassOpticalRects,
stepGlassOpticalSpring,
type GlassOpticalRect,
} from '@/utils/glassOptics'
import { describe, expect, it } from 'vitest'
describe('glass optics geometry', () => {
it('caps the renderer buffer independently from device pixel ratio', () => {
expect(getGlassOpticalBufferSize(3456, 2234, false)).toEqual({ height: 931, width: 1440 })
expect(getGlassOpticalBufferSize(390, 844, true)).toEqual({ height: 844, width: 390 })
expect(getGlassOpticalBufferSize(1920, 1080, false, 'high', 2)).toEqual({ height: 1080, width: 1920 })
expect(getGlassOpticalBufferSize(390, 844, true, 'high', 3)).toEqual({ height: 1266, width: 585 })
expect(getGlassScrollBufferSize(1440, 4200, 'balanced', 2)).toEqual({ height: 3072, width: 1440 })
expect(getGlassScrollBufferSize(1440, 4200, 'high', 2)).toEqual({ height: 4096, width: 1920 })
})
it('matches cover cropping on wide and tall images', () => {
expect(getGlassCoverScale(1600, 900, 2400, 1600)).toEqual({ x: 1, y: 0.84375 })
expect(getGlassCoverScale(900, 1600, 2400, 1600)).toEqual({ x: 0.375, y: 1 })
})
it('maps user strength sliders to accelerated high-range optical response', () => {
expect(normalizeGlassOpticalStrength(Number.NaN)).toBe(50)
expect(normalizeGlassOpticalStrength(-12)).toBe(0)
expect(normalizeGlassOpticalStrength(44.6)).toBe(45)
expect(normalizeGlassOpticalStrength(160)).toBe(100)
expect(getGlassOpticalMotionStrengthScale(0)).toBe(0)
expect(getGlassOpticalMotionStrengthScale(50)).toBe(1)
expect(getGlassOpticalMotionStrengthScale(80)).toBeGreaterThan(2)
expect(getGlassOpticalMotionStrengthScale(100)).toBeCloseTo(GLASS_OPTICAL_MOTION_MAX_SCALE)
expect(getGlassOpticalMotionStrengthScale(75) - getGlassOpticalMotionStrengthScale(50)).toBeGreaterThan(
getGlassOpticalMotionStrengthScale(50) - getGlassOpticalMotionStrengthScale(25),
)
expect(getGlassOpticalMotionExpansion(50)).toBe(0)
expect(getGlassOpticalMotionExpansion(80)).toBeGreaterThan(0.4)
expect(getGlassOpticalMotionExpansion(100)).toBe(1)
expect(getGlassOpticalMaxRefractionPixels(9, 50)).toBe(9)
expect(getGlassOpticalMaxRefractionPixels(9, 80)).toBe(9)
expect(getGlassOpticalMaxRefractionPixels(9, 100)).toBe(9)
expect(getGlassOpticalReflectionStrengthScale(0)).toBe(0)
expect(getGlassOpticalReflectionStrengthScale(50)).toBe(1)
expect(getGlassOpticalReflectionStrengthScale(100)).toBeCloseTo(GLASS_OPTICAL_REFLECTION_MAX_SCALE)
expect(getGlassOpticalTransparency(0)).toBeCloseTo(0.28)
expect(getGlassOpticalTransparency(50)).toBeGreaterThan(0.6)
expect(getGlassOpticalTransparency(70)).toBeCloseTo(0.96)
expect(getGlassOpticalTransparency(100)).toBeCloseTo(1.1)
expect(getGlassOpticalTransparency(100) - getGlassOpticalTransparency(70)).toBeLessThan(
getGlassOpticalTransparency(70) - getGlassOpticalTransparency(50),
)
expect(getGlassOpticalTransmissionStrength(0)).toBe(0)
expect(getGlassOpticalTransmissionStrength(70)).toBe(1)
expect(getGlassOpticalTransmissionStrength(100)).toBe(1.3)
expect(getGlassOpticalCssTransmissionBrightness(0)).toBeCloseTo(0.82)
expect(getGlassOpticalCssTransmissionBrightness(70)).toBeCloseTo(1.28)
expect(getGlassOpticalCssTransmissionBrightness(100)).toBeCloseTo(1.48)
})
it('keeps presets as concrete six-parameter values', () => {
const natural = getGlassOpticalPresetParameters('clear', 'balanced', 'natural')
const glide = getGlassOpticalPresetParameters('clear', 'balanced', 'glide')
const liquid = getGlassOpticalPresetParameters('frosted', 'high', 'liquid')
expect(natural).toEqual({
deformation: 50,
flow: 50,
reflection: 35,
transmission: 70,
translation: 50,
transparency: 70,
})
expect(glide.translation).toBeGreaterThan(glide.deformation)
expect(liquid.deformation).toBeGreaterThan(glide.deformation)
expect(liquid.flow).toBeGreaterThan(glide.flow)
expect(getAvailableGlassOpticalPresets('css')).toEqual(['natural'])
expect(getAvailableGlassOpticalPresets('balanced')).toEqual(['natural', 'glide', 'liquid'])
})
it('returns preset copies so previews cannot mutate the shared matrix', () => {
const first = getGlassOpticalPresetParameters('tinted', 'high', 'glide')
first.translation = 0
expect(getGlassOpticalPresetParameters('tinted', 'high', 'glide').translation).toBe(72)
})
it('matches the monotonic CSS ease timeline used by wallpaper crossfades', () => {
const samples = [0, 250, 750, 1250, 1500].map(elapsed => getGlassWallpaperTransitionProgress(elapsed, 1500))
expect(samples[0]).toBe(0)
expect(samples.at(-1)).toBe(1)
expect(samples[2]).toBeGreaterThan(0.5)
expect(samples.every((sample, index) => index === 0 || sample > samples[index - 1])).toBe(true)
})
it('keeps the selected optical quality on every route', () => {
expect(getGlassOpticalRenderProfile('high', '/dashboard')).toEqual({
bufferQuality: 'high',
contentProtection: true,
diffusionSamples: 9,
flowField: true,
flowHalfLife: 130,
maxRefractionPixels: 9,
motionDuration: 540,
motionHalfLife: 125,
pixelRatioCap: 1.5,
pointerImmediateResponse: 0.58,
springDamping: 0.78,
springFrequency: 18,
textureLimit: 4096,
textureSource: 'wallpaper',
trailCount: 4,
})
expect(getGlassOpticalRenderProfile('high', '/recommend?source=tmdb')).toEqual({
bufferQuality: 'high',
contentProtection: true,
diffusionSamples: 9,
flowField: true,
flowHalfLife: 130,
maxRefractionPixels: 9,
motionDuration: 540,
motionHalfLife: 125,
pixelRatioCap: 1.5,
pointerImmediateResponse: 0.58,
springDamping: 0.78,
springFrequency: 18,
textureLimit: 4096,
textureSource: 'wallpaper',
trailCount: 4,
})
expect(getGlassOpticalRenderProfile('high', '/subscribe/movie')).toEqual({
bufferQuality: 'high',
contentProtection: true,
diffusionSamples: 9,
flowField: true,
flowHalfLife: 130,
maxRefractionPixels: 9,
motionDuration: 540,
motionHalfLife: 125,
pixelRatioCap: 1.5,
pointerImmediateResponse: 0.58,
springDamping: 0.78,
springFrequency: 18,
textureLimit: 4096,
textureSource: 'wallpaper',
trailCount: 4,
})
expect(getGlassOpticalRenderProfile('balanced', '/dashboard')).toEqual({
bufferQuality: 'balanced',
contentProtection: false,
diffusionSamples: 5,
flowField: false,
flowHalfLife: 0,
maxRefractionPixels: 6,
motionDuration: 360,
motionHalfLife: 82,
pixelRatioCap: 1,
pointerImmediateResponse: 0.7,
springDamping: 0.9,
springFrequency: 24,
textureLimit: 3072,
textureSource: 'wallpaper',
trailCount: 2,
})
expect(getGlassOpticalRenderProfile('high', '/login')).toEqual({
bufferQuality: 'high',
contentProtection: true,
diffusionSamples: 9,
flowField: true,
flowHalfLife: 130,
maxRefractionPixels: 9,
motionDuration: 540,
motionHalfLife: 125,
pixelRatioCap: 1.5,
pointerImmediateResponse: 0.58,
springDamping: 0.78,
springFrequency: 18,
textureLimit: 4096,
textureSource: 'auto',
trailCount: 4,
})
})
it('spends high-quality cost on visible detail and content protection', () => {
const balanced = getGlassOpticalRenderProfile('balanced', '/dashboard')
const high = getGlassOpticalRenderProfile('high', '/dashboard')
expect(balanced).toMatchObject({
contentProtection: false,
diffusionSamples: 5,
flowField: false,
maxRefractionPixels: 6,
trailCount: 2,
})
expect(high).toMatchObject({
contentProtection: true,
diffusionSamples: 9,
flowField: true,
maxRefractionPixels: 9,
trailCount: 4,
})
expect(high.motionDuration).toBeGreaterThan(balanced.motionDuration)
expect(high.pixelRatioCap).toBeGreaterThan(balanced.pixelRatioCap)
})
it('uses refresh-rate independent decay and reaches a deterministic static state', () => {
expect(getGlassOpticalDecay(100, 100)).toBeCloseTo(0.5)
expect(getGlassOpticalDecay(100, 50) ** 2).toBeCloseTo(getGlassOpticalDecay(100, 100))
expect(getGlassOpticalMotionEnergy(0, 420, 90)).toBe(1)
expect(getGlassOpticalMotionEnergy(90, 420, 90)).toBeCloseTo(0.5)
expect(getGlassOpticalMotionEnergy(400, 420, 90)).toBeLessThan(0.005)
expect(getGlassOpticalMotionEnergy(410, 420, 90)).toBeLessThan(getGlassOpticalMotionEnergy(400, 420, 90))
expect(getGlassOpticalMotionEnergy(420, 420, 90)).toBe(0)
const samples = Array.from({ length: 29 }, (_, index) => getGlassOpticalMotionEnergy(index * 15, 420, 90))
expect(samples.every((sample, index) => index === 0 || sample <= samples[index - 1])).toBe(true)
})
it('keeps one crest and one trough in the event-relative liquid wake', () => {
const samples = Array.from({ length: 161 }, (_, index) => getGlassOpticalWakeSample(-4 + index * 0.05))
const extrema = samples
.slice(1, -1)
.filter(
(sample, index) =>
(sample > samples[index] && sample > samples[index + 2]) ||
(sample < samples[index] && sample < samples[index + 2]),
)
expect(extrema).toHaveLength(2)
expect(Math.min(...samples)).toBeLessThan(0)
expect(Math.max(...samples)).toBeGreaterThan(0)
expect(getGlassOpticalWakeSample(0)).toBe(0)
})
it('locks wake direction until a deliberate turn starts a new impulse', () => {
expect(
getGlassOpticalWakeDirection({ x: 1, y: 0 }, { x: Math.cos(Math.PI / 8), y: Math.sin(Math.PI / 8) }, 0.04, false),
).toEqual({ x: 1, y: 0 })
expect(getGlassOpticalWakeDirection({ x: 1, y: 0 }, { x: 0, y: 1 }, 0.04, false)).toEqual({ x: 0, y: 1 })
expect(getGlassOpticalWakeDirection({ x: 1, y: 0 }, { x: 0, y: 1 }, 0.002, false)).toEqual({ x: 1, y: 0 })
expect(getGlassOpticalWakeDirection({ x: 1, y: 0 }, { x: 0, y: 2 }, 0.04, true)).toEqual({ x: 0, y: 1 })
})
it('uses a frame-rate independent spring with at most one visible overshoot', () => {
const profile = getGlassOpticalRenderProfile('high', '/dashboard')
const simulate = (deltaMs: number) => {
let state = { position: 0, velocity: 0 }
const samples: number[] = []
for (let elapsed = 0; elapsed < profile.motionDuration; elapsed += deltaMs) {
state = stepGlassOpticalSpring(state, 1, deltaMs, profile.springFrequency, profile.springDamping)
samples.push(state.position)
}
return { samples, state }
}
const sixtyHertz = simulate(1000 / 60)
const oneTwentyHertz = simulate(1000 / 120)
const visibleCrossings = sixtyHertz.samples.slice(1).filter((sample, index) => {
const previousOffset = sixtyHertz.samples[index] - 1
const currentOffset = sample - 1
return previousOffset * currentOffset < 0 && Math.max(Math.abs(previousOffset), Math.abs(currentOffset)) > 0.002
})
expect(visibleCrossings.length).toBeLessThanOrEqual(1)
expect(Math.max(...sixtyHertz.samples)).toBeLessThan(1.04)
expect(sixtyHertz.state.position).toBeCloseTo(1, 2)
expect(sixtyHertz.state.position).toBeCloseTo(oneTwentyHertz.state.position, 3)
})
it('reconciles capped surface slots without reshuffling stable cards', () => {
const candidates = Array.from({ length: 10 }, (_, index) => ({
key: `card-${index}`,
rect: {
height: 80,
radii: [12, 12, 12, 12] as [number, number, number, number],
rank: index + 1,
width: 90,
x: index * 100,
y: 20,
},
}))
const initial = reconcileGlassOpticalSurfaceSlots([], candidates, 8)
const onCardA = reconcileGlassOpticalSurfaceSlots(initial, candidates, 8, 'card-8')
const throughGap = reconcileGlassOpticalSurfaceSlots(onCardA, candidates, 8, 'card-8')
const onCardB = reconcileGlassOpticalSurfaceSlots(throughGap, candidates, 8, 'card-9', 'card-8')
expect(initial.map(slot => slot.key)).toEqual([
'card-0',
'card-1',
'card-2',
'card-3',
'card-4',
'card-5',
'card-6',
'card-7',
])
expect(onCardA.map(slot => slot.key)).toEqual([
'card-0',
'card-1',
'card-2',
'card-3',
'card-4',
'card-5',
'card-6',
'card-8',
])
expect(throughGap.map(slot => slot.key)).toEqual(onCardA.map(slot => slot.key))
expect(onCardB.map(slot => slot.key)).toEqual([
'card-0',
'card-1',
'card-2',
'card-3',
'card-4',
'card-5',
'card-8',
'card-9',
])
expect(onCardB.at(-2)?.role).toBe('outgoing')
expect(onCardB.at(-1)?.role).toBe('active')
})
it('crossfades active surface weights monotonically', () => {
expect(getGlassOpticalSurfaceTransitionWeights(0, 96)).toEqual({ incoming: 0.35, outgoing: 1 })
expect(getGlassOpticalSurfaceTransitionWeights(48, 96)).toEqual({ incoming: 0.675, outgoing: 0.5 })
expect(getGlassOpticalSurfaceTransitionWeights(96, 96)).toEqual({ incoming: 1, outgoing: 0 })
})
it('attempts browser-supported wallpaper protocols without allowing mixed content', () => {
const documentUrl = 'https://moviepilot.example/login'
expect(canUseGlassWallpaperTexture('/api/v1/login/wallpaper/0', documentUrl)).toBe(true)
expect(canUseGlassWallpaperTexture('https://moviepilot.example/assets/login.jpg', documentUrl)).toBe(true)
expect(canUseGlassWallpaperTexture('blob:https://moviepilot.example/texture', documentUrl)).toBe(true)
expect(canUseGlassWallpaperTexture('data:image/png;base64,AA==', documentUrl)).toBe(true)
expect(canUseGlassWallpaperTexture('https://image.tmdb.org/t/p/original/poster.jpg', documentUrl)).toBe(true)
expect(canUseGlassWallpaperTexture('http://image.tmdb.org/t/p/original/poster.jpg', documentUrl)).toBe(false)
expect(canUseGlassWallpaperTexture('ftp://image.tmdb.org/poster.jpg', documentUrl)).toBe(false)
expect(canUseGlassWallpaperTexture('', documentUrl)).toBe(false)
})
it('keeps high-value outer surfaces and removes nested repeats', () => {
const candidates: GlassOpticalRect[] = [
{ height: 900, radii: [0, 0, 0, 0], rank: 2, width: 260, x: 0, y: 0 },
{ height: 300, radii: [16, 16, 16, 16], rank: 4, width: 500, x: 300, y: 100 },
{ height: 100, radii: [12, 12, 12, 12], rank: 5, width: 200, x: 320, y: 120 },
{ height: 120, radii: [12, 12, 12, 12], rank: 1, width: 400, x: 500, y: 700 },
]
const selected = selectGlassOpticalRects(candidates, 1440, 900, false)
expect(selected).toHaveLength(3)
expect(selected.map(rect => rect.rank)).toEqual([1, 2, 4])
})
it('keeps original geometry while using the viewport intersection for visibility', () => {
const selected = selectGlassOpticalRects(
[{ height: 160, radii: [20, 20, 20, 20], rank: 1, width: 180, x: -30, y: -40 }],
320,
240,
false,
)
expect(selected).toEqual([{ height: 160, radii: [20, 20, 20, 20], rank: 1, width: 180, x: -30, y: -40 }])
})
it('budgets partially visible surfaces by their visible pixels', () => {
const selected = selectGlassOpticalRects(
[
{ height: 1000, radii: [20, 20, 20, 20], rank: 1, width: 1000, x: -950, y: -900 },
{ height: 120, radii: [16, 16, 16, 16], rank: 2, width: 200, x: 80, y: 80 },
],
320,
240,
false,
)
expect(selected.map(rect => rect.rank)).toEqual([1, 2])
})
it('converts DOM top-origin rectangles while preserving pixel radius', () => {
expect(
normalizeGlassOpticalRect(
{ height: 100, radii: [20, 18, 16, 14], rank: 1, width: 200, x: 100, y: 50 },
1000,
500,
),
).toEqual({ radii: [20, 18, 16, 14], rect: [0.1, 0.7, 0.2, 0.2] })
expect(
normalizeGlassOpticalRect(
{ height: 100, radii: [80, 70, 60, 40], rank: 1, width: 200, x: -20, y: 50 },
1000,
500,
),
).toEqual({
radii: [80 * (10 / 13), 70 * (10 / 13), 60 * (10 / 13), 40 * (10 / 13)],
rect: [-0.02, 0.7, 0.2, 0.2],
})
expect(
normalizeGlassOpticalRect({ height: 100, radii: [80, 30, 0, 0], rank: 1, width: 100, x: 0, y: 0 }, 100, 100)
.radii,
).toEqual([80 * (10 / 11), 30 * (10 / 11), 0, 0])
})
it('keeps visible surfaces without an area-based hard cutoff', () => {
const selected = selectGlassOpticalRects(
[
{ height: 400, radii: [20, 20, 20, 20], rank: 1, width: 700, x: 0, y: 0 },
{ height: 400, radii: [20, 20, 20, 20], rank: 2, width: 700, x: 0, y: 400 },
],
700,
800,
false,
)
expect(selected).toHaveLength(2)
})
it('prioritizes the surface under the current interaction when the count is capped', () => {
const candidates = Array.from({ length: 9 }, (_, index): GlassOpticalRect => ({
height: 80,
radii: [12, 12, 12, 12],
rank: index + 1,
width: 100,
x: index * 110,
y: 20,
}))
const selected = selectGlassOpticalRects(candidates, 1000, 200, false, { x: 930, y: 60 })
expect(selected).toHaveLength(8)
expect(selected).toContain(candidates[8])
expect(selected).not.toContain(candidates[7])
})
})