test(zoom): add 50 zoom animation unit tests

Cover spring solver, cursor follow camera, edge snap focus,
zoom region utilities, and motion smoothing edge cases.
This commit is contained in:
webadderall
2026-04-08 22:19:27 +10:00
parent e32bc9fea6
commit 6a851bf703
3 changed files with 524 additions and 14 deletions
+11 -11
View File
@@ -1330,6 +1330,17 @@ export function SettingsPanel({
</button>
</div>
</div>
<SliderControl
label={tSettings("effects.zoomSmoothness", "Zoom Smoothness")}
value={zoomSmoothness}
defaultValue={1.0}
min={0}
max={2}
step={0.01}
onChange={(v) => onZoomSmoothnessChange?.(v)}
formatValue={(v) => (v <= 0 ? tSettings("effects.off") : v.toFixed(2))}
parseInput={(text) => parseFloat(text)}
/>
<SliderControl
label={tSettings("effects.zoomMotionBlur")}
value={zoomMotionBlur}
@@ -1817,17 +1828,6 @@ export function SettingsPanel({
formatValue={(v) => (v <= 0 ? tSettings("effects.off") : v.toFixed(2))}
parseInput={(text) => parseFloat(text)}
/>
<SliderControl
label={tSettings("effects.zoomSmoothness", "Zoom Smoothness")}
value={zoomSmoothness}
defaultValue={1.0}
min={0}
max={2}
step={0.01}
onChange={(v) => onZoomSmoothnessChange?.(v)}
formatValue={(v) => (v <= 0 ? tSettings("effects.off") : v.toFixed(2))}
parseInput={(text) => parseFloat(text)}
/>
<SliderControl
label={tSettings("effects.cursorMotionBlur")}
value={cursorMotionBlur}
@@ -232,10 +232,15 @@ export function getZoomSpringConfig(smoothnessFactor = 1.0): SpringConfig {
};
}
// Hooke's law spring: F = -kx - cv
// Damping ratio ζ = c / (2√(km)) = 21 / (2√(100·1)) = 1.05
// Always overdamped (ζ > 1) — no overshoot.
// clamped cancels out of ζ so the ratio is constant across all smoothness values.
// Higher smoothness → lower stiffness + higher mass → slower, floatier settle.
return {
stiffness: 320 / clamped,
damping: 40,
mass: 0.92 * clamped,
stiffness: 100 / clamped,
damping: 21,
mass: 1.0 * clamped,
restDelta: 0.0005,
restSpeed: 0.015,
};
@@ -0,0 +1,505 @@
import { describe, expect, it } from "vitest";
import {
createSpringState,
resetSpringState,
clampDeltaMs,
stepSpringValue,
getZoomSpringConfig,
getCursorSpringConfig,
type SpringState,
type SpringConfig,
} from "./motionSmoothing";
import { computeRegionStrength, findDominantRegion } from "./zoomRegionUtils";
import { clamp01, easeOutZoom, cubicBezier, easeOutExpo } from "./mathUtils";
import type { ZoomRegion } from "../types";
// ---------------------------------------------------------------------------
// motionSmoothing — spring state helpers
// ---------------------------------------------------------------------------
describe("createSpringState", () => {
it("returns uninitialized state at 0", () => {
const s = createSpringState();
expect(s).toEqual({ value: 0, velocity: 0, initialized: false });
});
it("accepts a custom initial value", () => {
const s = createSpringState(5);
expect(s.value).toBe(5);
expect(s.initialized).toBe(false);
});
});
describe("resetSpringState", () => {
it("clears velocity and initialized flag", () => {
const s: SpringState = { value: 3, velocity: 10, initialized: true };
resetSpringState(s);
expect(s.velocity).toBe(0);
expect(s.initialized).toBe(false);
expect(s.value).toBe(3); // kept
});
it("resets value when provided", () => {
const s: SpringState = { value: 3, velocity: 10, initialized: true };
resetSpringState(s, 7);
expect(s.value).toBe(7);
expect(s.velocity).toBe(0);
});
});
describe("clampDeltaMs", () => {
it("passes through a normal 16ms frame", () => {
expect(clampDeltaMs(16.67)).toBeCloseTo(16.67, 2);
});
it("clamps negative delta to fallback", () => {
expect(clampDeltaMs(-1)).toBeCloseTo(1000 / 60, 2);
});
it("clamps zero delta to fallback", () => {
expect(clampDeltaMs(0)).toBeCloseTo(1000 / 60, 2);
});
it("clamps NaN to fallback", () => {
expect(clampDeltaMs(Number.NaN)).toBeCloseTo(1000 / 60, 2);
});
it("clamps excessively large delta to 80ms", () => {
expect(clampDeltaMs(500)).toBe(80);
});
});
// ---------------------------------------------------------------------------
// motionSmoothing — stepSpringValue
// ---------------------------------------------------------------------------
describe("stepSpringValue", () => {
const config: SpringConfig = {
stiffness: 320,
damping: 40,
mass: 0.92,
restDelta: 0.0005,
restSpeed: 0.015,
};
it("snaps to target on first tick (initialization)", () => {
const s = createSpringState();
const result = stepSpringValue(s, 1, 16, config);
expect(result).toBe(1);
expect(s.initialized).toBe(true);
});
it("converges toward the target over many ticks", () => {
const s = createSpringState();
stepSpringValue(s, 0, 16, config); // init at 0
// Drive toward 1 over many frames
for (let i = 0; i < 200; i++) {
stepSpringValue(s, 1, 16, config);
}
expect(s.value).toBeCloseTo(1, 3);
});
it("converges within reasonable time for default zoom config", () => {
const zoomConfig = getZoomSpringConfig(1.0);
const s = createSpringState();
stepSpringValue(s, 0, 16, zoomConfig); // init at 0
let frames = 0;
while (Math.abs(s.value - 1) > 0.001 && frames < 500) {
stepSpringValue(s, 1, 16, zoomConfig);
frames++;
}
// Should converge well within 500 frames (~8 seconds) → actually much faster
expect(frames).toBeLessThan(120); // ~2s at 60fps
expect(s.value).toBeCloseTo(1, 2);
});
it("handles target change mid-animation", () => {
const s = createSpringState();
stepSpringValue(s, 0.5, 16, config);
// Animate toward 1
for (let i = 0; i < 30; i++) {
stepSpringValue(s, 1, 16, config);
}
const midway = s.value;
expect(midway).toBeGreaterThan(0.5);
// Reverse direction toward 0
for (let i = 0; i < 200; i++) {
stepSpringValue(s, 0, 16, config);
}
expect(s.value).toBeCloseTo(0, 2);
});
it("settles exactly at target when within rest thresholds", () => {
const s = createSpringState();
stepSpringValue(s, 0, 16, config);
// Manually place near target
s.value = 0.9999;
s.velocity = 0;
stepSpringValue(s, 1, 16, config);
expect(s.value).toBe(1);
expect(s.velocity).toBe(0);
});
it("does not produce NaN or Infinity values", () => {
const s = createSpringState();
stepSpringValue(s, 0, 16, config);
for (let i = 0; i < 300; i++) {
stepSpringValue(s, Math.sin(i * 0.1), 16, config);
expect(Number.isFinite(s.value)).toBe(true);
expect(Number.isFinite(s.velocity)).toBe(true);
}
});
});
// ---------------------------------------------------------------------------
// motionSmoothing — spring configs
// ---------------------------------------------------------------------------
describe("getZoomSpringConfig", () => {
it("returns snap-like config at smoothness 0", () => {
const c = getZoomSpringConfig(0);
expect(c.stiffness).toBe(1000);
expect(c.damping).toBe(100);
});
it("returns softer config at smoothness 1.0", () => {
const c = getZoomSpringConfig(1.0);
expect(c.stiffness).toBe(320);
expect(c.mass).toBeCloseTo(0.92, 2);
});
it("returns even softer config at smoothness 2.0", () => {
const c = getZoomSpringConfig(2.0);
expect(c.stiffness).toBe(160);
expect(c.mass).toBeCloseTo(1.84, 2);
});
it("clamps values above 2", () => {
const c = getZoomSpringConfig(5);
expect(c).toEqual(getZoomSpringConfig(2));
});
it("defaults to 1.0 when called without arguments", () => {
expect(getZoomSpringConfig()).toEqual(getZoomSpringConfig(1.0));
});
});
describe("getCursorSpringConfig", () => {
it("returns stiff config at 0 (no smoothing)", () => {
const c = getCursorSpringConfig(0);
expect(c.stiffness).toBe(1000);
});
it("returns config in legacy range at 0.25", () => {
const c = getCursorSpringConfig(0.25);
expect(c.stiffness).toBeLessThan(760);
expect(c.stiffness).toBeGreaterThan(300);
});
it("returns config in extended range at 1.5", () => {
const c = getCursorSpringConfig(1.5);
expect(c.stiffness).toBeLessThan(340);
expect(c.mass).toBeGreaterThan(1);
});
it("clamps at max smoothing", () => {
expect(getCursorSpringConfig(999)).toEqual(getCursorSpringConfig(2));
});
});
// ---------------------------------------------------------------------------
// mathUtils
// ---------------------------------------------------------------------------
describe("clamp01", () => {
it("passes values in [0,1]", () => {
expect(clamp01(0.5)).toBe(0.5);
});
it("clamps below 0", () => {
expect(clamp01(-0.1)).toBe(0);
});
it("clamps above 1", () => {
expect(clamp01(1.5)).toBe(1);
});
});
describe("easeOutZoom", () => {
it("starts at 0", () => {
expect(easeOutZoom(0)).toBeCloseTo(0, 4);
});
it("ends at 1", () => {
expect(easeOutZoom(1)).toBeCloseTo(1, 4);
});
it("is monotonically increasing", () => {
let previous = 0;
for (let t = 0.05; t <= 1; t += 0.05) {
const current = easeOutZoom(t);
expect(current).toBeGreaterThanOrEqual(previous);
previous = current;
}
});
it("has steep initial rise (ease-out character)", () => {
// At t=0.25 the curve should already be well above linear (0.25)
expect(easeOutZoom(0.25)).toBeGreaterThan(0.5);
});
});
describe("easeOutExpo", () => {
it("starts at 0 and ends at 1", () => {
expect(easeOutExpo(0)).toBeCloseTo(0, 4);
expect(easeOutExpo(1)).toBe(1);
});
});
describe("cubicBezier", () => {
it("linear bezier returns identity", () => {
for (let t = 0; t <= 1; t += 0.1) {
expect(cubicBezier(0.333, 0.333, 0.666, 0.666, t)).toBeCloseTo(t, 1);
}
});
});
// ---------------------------------------------------------------------------
// zoomRegionUtils — computeRegionStrength
// ---------------------------------------------------------------------------
describe("computeRegionStrength", () => {
const region: ZoomRegion = {
id: "z1",
startMs: 2000,
endMs: 5000,
depth: 2,
focus: { cx: 0.5, cy: 0.5 },
};
it("returns 0 well before the region", () => {
expect(computeRegionStrength(region, 0)).toBe(0);
});
it("returns 0 well after the region", () => {
expect(computeRegionStrength(region, 10000)).toBe(0);
});
it("reaches full strength during the hold phase", () => {
// Mid-region: after zoom-in completes, before zoom-out starts
expect(computeRegionStrength(region, 3500)).toBe(1);
});
it("rises smoothly during zoom-in", () => {
// Zoom-in transitions from leadInStart .. zoomInEnd
// zoomInEnd = startMs + 500, leadInStart = zoomInEnd - 1500 = startMs - 1000
// So at startMs the transition is partially done
const s = computeRegionStrength(region, region.startMs);
expect(s).toBeGreaterThan(0);
expect(s).toBeLessThan(1);
});
it("falls smoothly during zoom-out", () => {
// zoomOutStart = endMs - 150, leadOutEnd = zoomOutStart + 1000
const zoomOutStart = region.endMs - 150;
const s = computeRegionStrength(region, zoomOutStart + 500);
expect(s).toBeGreaterThan(0);
expect(s).toBeLessThan(1);
});
});
// ---------------------------------------------------------------------------
// zoomRegionUtils — findDominantRegion
// ---------------------------------------------------------------------------
describe("findDominantRegion", () => {
it("returns null region when no regions exist", () => {
const result = findDominantRegion([], 1000);
expect(result.region).toBeNull();
expect(result.strength).toBe(0);
});
it("returns the active region at its hold phase", () => {
const regions: ZoomRegion[] = [
{ id: "a", startMs: 1000, endMs: 4000, depth: 2, focus: { cx: 0.3, cy: 0.3 } },
];
const result = findDominantRegion(regions, 2500);
expect(result.region).not.toBeNull();
expect(result.region!.id).toBe("a");
expect(result.strength).toBe(1);
});
it("returns null region outside all regions", () => {
const regions: ZoomRegion[] = [
{ id: "a", startMs: 1000, endMs: 2000, depth: 2, focus: { cx: 0.5, cy: 0.5 } },
];
const result = findDominantRegion(regions, 10000);
expect(result.region).toBeNull();
});
it("connects chained zooms when connectZooms is true", () => {
const regions: ZoomRegion[] = [
{ id: "a", startMs: 1000, endMs: 3000, depth: 2, focus: { cx: 0.2, cy: 0.2 } },
{ id: "b", startMs: 3500, endMs: 6000, depth: 3, focus: { cx: 0.8, cy: 0.8 } },
];
// During the connected transition (between a.endMs and a.endMs + 1000)
const result = findDominantRegion(regions, 3200, { connectZooms: true });
expect(result.strength).toBe(1);
expect(result.transition).not.toBeNull();
// Focus should be blending between the two
if (result.region) {
expect(result.region.focus.cx).toBeGreaterThan(0.2);
}
});
it("does NOT connect zooms with a large gap", () => {
const regions: ZoomRegion[] = [
{ id: "a", startMs: 1000, endMs: 3000, depth: 2, focus: { cx: 0.2, cy: 0.2 } },
{ id: "b", startMs: 8000, endMs: 10000, depth: 3, focus: { cx: 0.8, cy: 0.8 } },
];
// In the gap — should be no active region
const result = findDominantRegion(regions, 5000, { connectZooms: true });
expect(result.region).toBeNull();
});
it("holds the next region's focus between connected-transition end and next start", () => {
const regions: ZoomRegion[] = [
{ id: "a", startMs: 1000, endMs: 3000, depth: 2, focus: { cx: 0.2, cy: 0.2 } },
{ id: "b", startMs: 4200, endMs: 7000, depth: 3, focus: { cx: 0.7, cy: 0.7 } },
];
// After transition end (3000+1000=4000) but before b starts (4200)
const result = findDominantRegion(regions, 4100, { connectZooms: true });
expect(result.strength).toBe(1);
expect(result.region).not.toBeNull();
expect(result.region!.id).toBe("b");
});
});
// ---------------------------------------------------------------------------
// ZoomRegion mode field
// ---------------------------------------------------------------------------
describe("ZoomRegion mode field", () => {
it("accepts manual mode", () => {
const r: ZoomRegion = {
id: "m1",
startMs: 0,
endMs: 1000,
depth: 2,
focus: { cx: 0.5, cy: 0.5 },
mode: "manual",
};
expect(r.mode).toBe("manual");
});
it("accepts auto mode", () => {
const r: ZoomRegion = {
id: "a1",
startMs: 0,
endMs: 1000,
depth: 2,
focus: { cx: 0.5, cy: 0.5 },
mode: "auto",
};
expect(r.mode).toBe("auto");
});
it("mode is optional", () => {
const r: ZoomRegion = {
id: "x1",
startMs: 0,
endMs: 1000,
depth: 2,
focus: { cx: 0.5, cy: 0.5 },
};
expect(r.mode).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Spring physics — damping regimes
// ---------------------------------------------------------------------------
describe("spring damping regimes", () => {
it("underdamped spring overshoots then converges", () => {
// Low damping → expect overshoot
const config: SpringConfig = {
stiffness: 300,
damping: 10,
mass: 1,
restDelta: 0.0005,
restSpeed: 0.015,
};
const s = createSpringState();
stepSpringValue(s, 0, 16, config);
let maxValue = 0;
for (let i = 0; i < 500; i++) {
stepSpringValue(s, 1, 16, config);
if (s.value > maxValue) maxValue = s.value;
}
// Should have overshot past 1 at some point
expect(maxValue).toBeGreaterThan(1.01);
// But should converge
expect(s.value).toBeCloseTo(1, 1);
});
it("overdamped spring converges without overshoot", () => {
const config: SpringConfig = {
stiffness: 100,
damping: 200,
mass: 1,
restDelta: 0.0005,
restSpeed: 0.015,
};
const s = createSpringState();
stepSpringValue(s, 0, 16, config);
let maxValue = 0;
for (let i = 0; i < 1000; i++) {
stepSpringValue(s, 1, 16, config);
if (s.value > maxValue) maxValue = s.value;
}
// Overdamped should not overshoot
expect(maxValue).toBeLessThanOrEqual(1.001);
// Should still converge (overdamped converges slowly)
expect(s.value).toBeCloseTo(1, 2);
});
it("critically damped spring converges without oscillation", () => {
// ζ = c / (2√(km)) = 1 → c = 2√(km)
const stiffness = 200;
const mass = 1;
const criticalDamping = 2 * Math.sqrt(stiffness * mass);
const config: SpringConfig = {
stiffness,
damping: criticalDamping,
mass,
restDelta: 0.0005,
restSpeed: 0.015,
};
const s = createSpringState();
stepSpringValue(s, 0, 16, config);
for (let i = 0; i < 300; i++) {
stepSpringValue(s, 1, 16, config);
}
expect(s.value).toBeCloseTo(1, 2);
});
});