From fd2c8463fa98f614778ba331a5c2b03f6c51c1c4 Mon Sep 17 00:00:00 2001 From: walterlow Date: Thu, 21 May 2026 21:24:30 +0800 Subject: [PATCH 1/2] test(transitions): add coverage for renderers/gpu.ts pure helpers + registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file had no test coverage after the dead-code purge in PR #252. Covers what's testable without a real OffscreenCanvas: - 6 pure helpers (clamp01, smoothStep, getNumericProperty, seededRandom, fadeOpacity, crossDissolveT) — exported and tested for invariants. fadeOpacity's constant-power crossfade is asserted explicitly so a future "just use linear opacity" rewrite fails loudly. - Registry smoke: registerGpuTransitions on a fresh TransitionRegistry registers exactly 15 known IDs, each with renderCanvas and a matching gpuTransitionId. Locks the GPU transition contract — the IDs appear on persisted projects and are looked up by the GPU pipeline (TransitionPipeline) keyed by gpuTransitionId, so adding/removing one is a schema-affecting change worth flagging. Per-renderer pixel tests skipped — jsdom can't run OffscreenCanvas without the canvas npm package, so they'd be mock-call assertions with low signal. 39 tests added. Full suite: 367 files / 2304 tests pass. --- .../transitions/renderers/gpu.test.ts | 205 ++++++++++++++++++ .../timeline/transitions/renderers/gpu.ts | 12 +- 2 files changed, 211 insertions(+), 6 deletions(-) create mode 100644 src/shared/timeline/transitions/renderers/gpu.test.ts diff --git a/src/shared/timeline/transitions/renderers/gpu.test.ts b/src/shared/timeline/transitions/renderers/gpu.test.ts new file mode 100644 index 00000000..a2516950 --- /dev/null +++ b/src/shared/timeline/transitions/renderers/gpu.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from 'vite-plus/test' +import { TransitionRegistry } from '../registry' +import { + clamp01, + crossDissolveT, + fadeOpacity, + getNumericProperty, + registerGpuTransitions, + seededRandom, + smoothStep, +} from './gpu' + +// GPU transition registrations are stable contract: the IDs are referenced by +// transition data on persisted projects and by the GPU pipeline (TransitionPipeline) +// keyed by gpuTransitionId. Adding or removing one is a schema-affecting change. +const EXPECTED_GPU_TRANSITION_IDS = [ + 'dissolve', + 'additiveDissolve', + 'blurDissolve', + 'dipToColorDissolve', + 'nonAdditiveDissolve', + 'smoothCut', + 'sparkles', + 'glitch', + 'pixelate', + 'chromatic', + 'radialBlur', + 'liquidDistort', + 'lensWarpZoom', + 'lightLeakBurn', + 'filmGateSlip', +] as const + +describe('clamp01', () => { + it('passes values inside [0, 1] through', () => { + expect(clamp01(0)).toBe(0) + expect(clamp01(0.5)).toBe(0.5) + expect(clamp01(1)).toBe(1) + }) + + it('clamps below 0 to 0', () => { + expect(clamp01(-1)).toBe(0) + expect(clamp01(-Infinity)).toBe(0) + }) + + it('clamps above 1 to 1', () => { + expect(clamp01(2)).toBe(1) + expect(clamp01(Infinity)).toBe(1) + }) +}) + +describe('smoothStep', () => { + it('returns 0 at or below the lower edge', () => { + expect(smoothStep(0, 1, 0)).toBe(0) + expect(smoothStep(0, 1, -5)).toBe(0) + }) + + it('returns 1 at or above the upper edge', () => { + expect(smoothStep(0, 1, 1)).toBe(1) + expect(smoothStep(0, 1, 5)).toBe(1) + }) + + it('returns 0.5 at the midpoint of a symmetric interval', () => { + expect(smoothStep(0, 1, 0.5)).toBeCloseTo(0.5, 5) + }) + + it('is smooth at the midpoint (3x^2 - 2x^3 with x = 0.5)', () => { + // 3 * 0.25 - 2 * 0.125 = 0.5; derivative at midpoint is 3/2 * 0.25 = (smooth, not linear) + const left = smoothStep(0, 1, 0.49) + const right = smoothStep(0, 1, 0.51) + expect(right - left).toBeGreaterThan(0) + expect(right - left).toBeLessThan(0.1) // shallower than linear (0.02) + }) + + it('handles a zero-width interval (edge0 === edge1) without NaN', () => { + const value = smoothStep(0.5, 0.5, 0.6) + expect(Number.isFinite(value)).toBe(true) + expect(value).toBe(1) + }) +}) + +describe('getNumericProperty', () => { + it('returns the property when it is a finite number', () => { + expect(getNumericProperty({ radius: 5 }, 'radius', 0)).toBe(5) + expect(getNumericProperty({ radius: 0 }, 'radius', 99)).toBe(0) + expect(getNumericProperty({ radius: -3.14 }, 'radius', 0)).toBe(-3.14) + }) + + it('falls back when the property is missing', () => { + expect(getNumericProperty({}, 'radius', 7)).toBe(7) + expect(getNumericProperty(undefined, 'radius', 7)).toBe(7) + }) + + it('falls back when the property is not a number', () => { + expect(getNumericProperty({ radius: '5' }, 'radius', 9)).toBe(9) + expect(getNumericProperty({ radius: null }, 'radius', 9)).toBe(9) + expect(getNumericProperty({ radius: true }, 'radius', 9)).toBe(9) + }) + + it('falls back when the property is non-finite (NaN or Infinity)', () => { + expect(getNumericProperty({ radius: NaN }, 'radius', 1)).toBe(1) + expect(getNumericProperty({ radius: Infinity }, 'radius', 1)).toBe(1) + expect(getNumericProperty({ radius: -Infinity }, 'radius', 1)).toBe(1) + }) +}) + +describe('seededRandom', () => { + it('is deterministic — same seed gives same value', () => { + expect(seededRandom(0)).toBe(seededRandom(0)) + expect(seededRandom(1)).toBe(seededRandom(1)) + expect(seededRandom(12345.6789)).toBe(seededRandom(12345.6789)) + }) + + it('returns a value in [0, 1)', () => { + for (const seed of [0, 1, 7, 42, 100, -1, 12345.6789]) { + const value = seededRandom(seed) + expect(value).toBeGreaterThanOrEqual(0) + expect(value).toBeLessThan(1) + } + }) + + it('produces distinct values for nearby seeds', () => { + // Pseudo-random hash should not collide for adjacent integer seeds. + expect(seededRandom(0)).not.toBe(seededRandom(1)) + expect(seededRandom(1)).not.toBe(seededRandom(2)) + }) +}) + +describe('fadeOpacity', () => { + it('outgoing clip is fully visible at progress 0 and fully transparent at progress 1', () => { + expect(fadeOpacity(0, true)).toBeCloseTo(1, 5) + expect(fadeOpacity(1, true)).toBeCloseTo(0, 5) + }) + + it('incoming clip is fully transparent at progress 0 and fully visible at progress 1', () => { + expect(fadeOpacity(0, false)).toBeCloseTo(0, 5) + expect(fadeOpacity(1, false)).toBeCloseTo(1, 5) + }) + + it('outgoing + incoming sum to a constant-power crossfade (not linear)', () => { + // cos² + sin² = 1, but the values themselves are cos/sin so at p=0.5 + // each is sqrt(2)/2 ≈ 0.707 and they sum to ~1.414 — confirming the + // intended constant-power crossfade rather than a 0.5+0.5 linear mix. + const out = fadeOpacity(0.5, true) + const inc = fadeOpacity(0.5, false) + expect(out).toBeCloseTo(Math.SQRT1_2, 5) + expect(inc).toBeCloseTo(Math.SQRT1_2, 5) + }) +}) + +describe('crossDissolveT', () => { + it('returns 0 at progress 0 and 1 at progress 1', () => { + expect(crossDissolveT(0)).toBeCloseTo(0, 5) + expect(crossDissolveT(1)).toBeCloseTo(1, 5) + }) + + it('returns 0.5 at progress 0.5 (cosine eased curve crosses midpoint at midpoint)', () => { + expect(crossDissolveT(0.5)).toBeCloseTo(0.5, 5) + }) + + it('clamps out-of-range progress before easing', () => { + expect(crossDissolveT(-1)).toBe(crossDissolveT(0)) + expect(crossDissolveT(2)).toBe(crossDissolveT(1)) + }) + + it('is monotonically non-decreasing across the range', () => { + let prev = crossDissolveT(0) + for (const p of [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1]) { + const value = crossDissolveT(p) + expect(value).toBeGreaterThanOrEqual(prev) + prev = value + } + }) +}) + +describe('registerGpuTransitions', () => { + it('registers exactly 15 transitions', () => { + const registry = new TransitionRegistry() + registerGpuTransitions(registry) + expect(registry.size).toBe(EXPECTED_GPU_TRANSITION_IDS.length) + expect(registry.getIds().sort()).toEqual([...EXPECTED_GPU_TRANSITION_IDS].sort()) + }) + + it.each(EXPECTED_GPU_TRANSITION_IDS)( + 'registers "%s" with a renderCanvas method and a matching gpuTransitionId', + (id) => { + const registry = new TransitionRegistry() + registerGpuTransitions(registry) + const renderer = registry.getRenderer(id) + expect(renderer, `${id} renderer should be registered`).toBeDefined() + expect(typeof renderer?.renderCanvas, `${id} should have renderCanvas`).toBe('function') + expect(renderer?.gpuTransitionId, `${id} should set gpuTransitionId`).toBe(id) + }, + ) + + it('attaches a TransitionDefinition for every registered transition', () => { + const registry = new TransitionRegistry() + registerGpuTransitions(registry) + for (const id of EXPECTED_GPU_TRANSITION_IDS) { + const definition = registry.getDefinition(id) + expect(definition, `${id} should have a definition`).toBeDefined() + expect(definition?.id).toBe(id) + } + }) +}) diff --git a/src/shared/timeline/transitions/renderers/gpu.ts b/src/shared/timeline/transitions/renderers/gpu.ts index 3a33b907..74245524 100644 --- a/src/shared/timeline/transitions/renderers/gpu.ts +++ b/src/shared/timeline/transitions/renderers/gpu.ts @@ -12,17 +12,17 @@ import type { TransitionDefinition, WipeDirection } from '@/types/transition' const ALL_TIMINGS = ['linear', 'ease-in', 'ease-out', 'ease-in-out', 'cubic-bezier'] as const -function clamp01(v: number): number { +export function clamp01(v: number): number { return Math.max(0, Math.min(1, v)) } -function smoothStep(edge0: number, edge1: number, x: number): number { +export function smoothStep(edge0: number, edge1: number, x: number): number { const width = Math.max(edge1 - edge0, Number.EPSILON) const t = clamp01((x - edge0) / width) return t * t * (3 - 2 * t) } -function getNumericProperty( +export function getNumericProperty( properties: Record | undefined, key: string, fallback: number, @@ -34,16 +34,16 @@ function getNumericProperty( return value } -function seededRandom(seed: number): number { +export function seededRandom(seed: number): number { const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453123 return x - Math.floor(x) } -function fadeOpacity(progress: number, isOutgoing: boolean): number { +export function fadeOpacity(progress: number, isOutgoing: boolean): number { return isOutgoing ? Math.cos((progress * Math.PI) / 2) : Math.sin((progress * Math.PI) / 2) } -function crossDissolveT(progress: number): number { +export function crossDissolveT(progress: number): number { return 0.5 - 0.5 * Math.cos(clamp01(progress) * Math.PI) } From 610f50d4fad1c2d472b731fddfe7a48f0f4946ee Mon Sep 17 00:00:00 2001 From: walterlow Date: Thu, 21 May 2026 21:36:41 +0800 Subject: [PATCH 2/2] test(transitions): address PR #258 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two P2 comments on the new gpu.test.ts: 1. The "is smooth" test had its math backwards in both the comment and the bound. d/dt(3t²-2t³) at t=0.5 is 1.5, so the central difference over [0.49, 0.51] is ~0.03 — STEEPER than the linear 0.02, not shallower as the comment claimed. The upper bound 0.1 was also too loose to catch a "just use linear" rewrite. Replaced with two independent assertions: - eased S-shape: smoothStep(0.25) < 0.25 and smoothStep(0.75) > 0.75 (a linear curve fails both) - midpoint slope in a tight band [0.025, 0.035] around the analytical 0.03 (a linear curve at 0.02 or a step at 0 fails) 2. it.each rebuilt the registry on every iteration — 15 redundant registrations per test block. Hoisted the registry construction to a shared const inside the describe (the registry is the unit under test and never mutates, so reuse is safe and matches the pattern used in the surrounding describe blocks). 39 tests still pass; tsc clean, lint 0/0. --- .../transitions/renderers/gpu.test.ts | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/shared/timeline/transitions/renderers/gpu.test.ts b/src/shared/timeline/transitions/renderers/gpu.test.ts index a2516950..2c4adc46 100644 --- a/src/shared/timeline/transitions/renderers/gpu.test.ts +++ b/src/shared/timeline/transitions/renderers/gpu.test.ts @@ -64,12 +64,19 @@ describe('smoothStep', () => { expect(smoothStep(0, 1, 0.5)).toBeCloseTo(0.5, 5) }) - it('is smooth at the midpoint (3x^2 - 2x^3 with x = 0.5)', () => { - // 3 * 0.25 - 2 * 0.125 = 0.5; derivative at midpoint is 3/2 * 0.25 = (smooth, not linear) - const left = smoothStep(0, 1, 0.49) - const right = smoothStep(0, 1, 0.51) - expect(right - left).toBeGreaterThan(0) - expect(right - left).toBeLessThan(0.1) // shallower than linear (0.02) + it('produces a smoothed S-curve, not a linear interpolation', () => { + // smoothStep(0,1,x) = 3x²-2x³ is below the linear line on the lower + // half and above it on the upper half — that's the defining S-shape + // and what protects against a "just use linear" rewrite. + expect(smoothStep(0, 1, 0.25)).toBeLessThan(0.25) + expect(smoothStep(0, 1, 0.75)).toBeGreaterThan(0.75) + + // Slope at the midpoint is d/dt(3t²-2t³) = 6t(1-t) = 1.5, so the + // central difference over [0.49, 0.51] is ~0.03 — 1.5× steeper than + // the linear 0.02. Tight band so a flatter or steeper curve fails. + const midpointSlope = smoothStep(0, 1, 0.51) - smoothStep(0, 1, 0.49) + expect(midpointSlope).toBeGreaterThan(0.025) + expect(midpointSlope).toBeLessThan(0.035) }) it('handles a zero-width interval (edge0 === edge1) without NaN', () => { @@ -174,9 +181,13 @@ describe('crossDissolveT', () => { }) describe('registerGpuTransitions', () => { + // Shared across every test in this block — the registry is the unit under + // test and doesn't mutate, so 17 tests get one registration pass instead + // of 17. + const registry = new TransitionRegistry() + registerGpuTransitions(registry) + it('registers exactly 15 transitions', () => { - const registry = new TransitionRegistry() - registerGpuTransitions(registry) expect(registry.size).toBe(EXPECTED_GPU_TRANSITION_IDS.length) expect(registry.getIds().sort()).toEqual([...EXPECTED_GPU_TRANSITION_IDS].sort()) }) @@ -184,8 +195,6 @@ describe('registerGpuTransitions', () => { it.each(EXPECTED_GPU_TRANSITION_IDS)( 'registers "%s" with a renderCanvas method and a matching gpuTransitionId', (id) => { - const registry = new TransitionRegistry() - registerGpuTransitions(registry) const renderer = registry.getRenderer(id) expect(renderer, `${id} renderer should be registered`).toBeDefined() expect(typeof renderer?.renderCanvas, `${id} should have renderCanvas`).toBe('function') @@ -194,8 +203,6 @@ describe('registerGpuTransitions', () => { ) it('attaches a TransitionDefinition for every registered transition', () => { - const registry = new TransitionRegistry() - registerGpuTransitions(registry) for (const id of EXPECTED_GPU_TRANSITION_IDS) { const definition = registry.getDefinition(id) expect(definition, `${id} should have a definition`).toBeDefined()