import { describe, expect, it } from 'vitest'; import { angleFromNormalized, angleFromPoint, clamp, decimalsFromStep, describeArc, findClosest, linearTaper, logTaper, normalizedFromAngle, powTaper, roundTo, snapToStep, } from './math'; describe('clamp', () => { it('clamps into range', () => { expect(clamp(5, 0, 10)).toBe(5); expect(clamp(-1, 0, 10)).toBe(0); expect(clamp(11, 0, 10)).toBe(10); }); }); describe('decimalsFromStep', () => { it('infers decimal places', () => { expect(decimalsFromStep(1)).toBe(0); expect(decimalsFromStep(0.5)).toBe(1); expect(decimalsFromStep(0.25)).toBe(2); expect(decimalsFromStep(0.001)).toBe(3); expect(decimalsFromStep(1e-7)).toBe(7); }); it('falls back for continuous knobs', () => { expect(decimalsFromStep(0)).toBe(3); expect(decimalsFromStep(NaN)).toBe(3); }); it('shaves float noise from computed steps', () => { expect(decimalsFromStep(0.1 + 0.2)).toBe(1); // 0.30000000000000004 -> 1 }); }); describe('roundTo', () => { it('avoids float drift', () => { expect(roundTo(0.1 + 0.2, 2)).toBe(0.3); expect(roundTo(1.005, 2)).toBe(1.01); expect(roundTo(123.456789, 4)).toBe(123.4568); expect(roundTo(5, 0)).toBe(5); }); it('handles values that stringify exponentially', () => { expect(roundTo(1e-8, 10)).toBe(1e-8); expect(roundTo(2.5e-7, 7)).toBe(3e-7); expect(roundTo(1.5e21, 0)).toBe(1.5e21); expect(Number.isNaN(roundTo(1e-9, 3))).toBe(false); }); it('passes non-finite values through', () => { expect(roundTo(NaN, 2)).toBeNaN(); expect(roundTo(Infinity, 2)).toBe(Infinity); }); }); describe('snapToStep', () => { it('snaps anchored at min', () => { expect(snapToStep(7.3, 0.5, 0)).toBe(7.5); expect(snapToStep(7.2, 0.5, 0)).toBe(7.0); expect(roundTo(snapToStep(0.07, 0.02, 0.01), 2)).toBe(0.07); }); it('ignores non-positive steps', () => { expect(snapToStep(7.3, 0, 0)).toBe(7.3); }); }); describe('findClosest', () => { it('finds the nearest entry regardless of order', () => { expect(findClosest([1, 100, 5, 50], 42)).toBe(50); expect(findClosest([0.25, 0.5, 1, 2, 4], 0.8)).toBe(1); }); it('returns the input value for an empty list', () => { expect(findClosest([], 5)).toBe(5); }); }); describe('tapers', () => { it('linear round-trips', () => { expect(linearTaper.fromNormalized(0.5, 0, 100)).toBe(50); expect(linearTaper.toNormalized(50, 0, 100)).toBe(0.5); }); it('log taper maps geometric midpoints', () => { expect(roundTo(logTaper.fromNormalized(0.5, 20, 20000), 3)).toBe(632.456); expect(roundTo(logTaper.toNormalized(2000, 20, 20000), 4)).toBe( roundTo(Math.log(100) / Math.log(1000), 4), ); }); it('log taper falls back to linear for invalid ranges instead of NaN', () => { expect(logTaper.fromNormalized(0.5, 0, 100)).toBe(50); expect(logTaper.toNormalized(0, -10, 10)).toBe(0.5); expect(Number.isNaN(logTaper.fromNormalized(0.5, -10, 10))).toBe(false); }); it('pow taper round-trips', () => { const t = powTaper(2); const n = t.toNormalized(t.fromNormalized(0.3, 0, 10), 0, 10); expect(roundTo(n, 10)).toBe(0.3); }); }); describe('angles', () => { it('maps pointer position to clockwise angle from 12 o\'clock', () => { expect(angleFromPoint(50, 0, 50, 50)).toBe(0); // above center expect(angleFromPoint(100, 50, 50, 50)).toBe(90); // right expect(angleFromPoint(50, 100, 50, 50)).toBe(180); // below expect(angleFromPoint(0, 50, 50, 50)).toBe(270); // left }); it('maps angle to normalized travel (225°/270° audio knob)', () => { expect(normalizedFromAngle(225, 225, 270)).toBe(0); expect(normalizedFromAngle(0, 225, 270)).toBe(0.5); // 12 o'clock is mid travel expect(normalizedFromAngle(135, 225, 270)).toBe(1); }); it('snaps the dead zone to the nearest end (rc-knob feel)', () => { // Dead zone spans 135..225; below its midpoint sticks to max. expect(normalizedFromAngle(150, 225, 270)).toBe(1); expect(normalizedFromAngle(210, 225, 270)).toBe(0); }); it('round-trips normalized to angle', () => { expect(angleFromNormalized(0.5, 225, 270)).toBe(360); expect(angleFromNormalized(0, 225, 270)).toBe(225); }); }); describe('describeArc', () => { it('produces a drawable path', () => { const d = describeArc(50, 50, 40, 225, 495); expect(d.startsWith('M ')).toBe(true); expect(d).toContain('A 40 40'); }); it('returns empty for zero sweep', () => { expect(describeArc(50, 50, 40, 100, 100)).toBe(''); }); it('caps at just under a full circle', () => { expect(describeArc(50, 50, 40, 0, 720)).not.toBe(''); }); });