import { describe, expect, it } from 'vitest'; import { createHeadlightBeam, FLASH_HOLD_MS, FLASH_MIN_INTERVAL_MS, IDLE_AFTER_MS, REST_INTENSITY, SWAY_AMPLITUDE, SWEEP_FROM, SWEEP_MS, SWEEP_TO, type HeadlightBeam, } from '@/journey/headlight-beam'; /** Samples every 16 ms from `from` up to and including `to`. */ const run = (beam: HeadlightBeam, from: number, to: number) => { let sample = beam.sample(from); for (let now = from + 16; now <= to; now += 16) sample = beam.sample(now); return beam.sample(to); }; const swept = (start = 0): HeadlightBeam => { const beam = createHeadlightBeam(); beam.sample(start); beam.sweep(start); run(beam, start, start + SWEEP_MS + 2_000); return beam; }; describe('headlight beam: smoothing', () => { it('points dead ahead and dark before anything happens', () => { const beam = createHeadlightBeam(); expect(beam.sample(0)).toEqual({ x: 0, y: 0, intensity: 0 }); expect(run(beam, 0, 1_000)).toEqual({ x: 0, y: 0, intensity: 0 }); }); it('follows the pointer critically damped: no overshoot, close within half a second', () => { const beam = createHeadlightBeam(); beam.sample(0); beam.input(0.8, -0.4, 'mouse', 0); let previous = 0; let peak = 0; for (let now = 16; now <= 600; now += 16) { const { x } = beam.sample(now); expect(x).toBeGreaterThanOrEqual(previous - 1e-9); previous = x; peak = Math.max(peak, x); if (now === 176) { // About the smooth time (180 ms): more than a third, not there yet. expect(x).toBeGreaterThan(0.8 * 0.35); expect(x).toBeLessThan(0.8 * 0.8); } } expect(peak).toBeLessThanOrEqual(0.8 + 1e-9); const settled = beam.sample(600); expect(settled.x).toBeCloseTo(0.8, 1); expect(settled.y).toBeCloseTo(-0.4, 1); }); it('clamps input to the viewport and ignores touch (a tap is a flash)', () => { const beam = createHeadlightBeam(); beam.sample(0); beam.input(0.5, 0, 'touch', 0); expect(run(beam, 0, 800).x).toBe(0); beam.input(4, -9, 'pen', 800); const sample = run(beam, 800, 2_400); expect(sample.x).toBeCloseTo(1, 2); expect(sample.y).toBeCloseTo(-1, 2); }); it('survives a long frame gap without jumping past the target', () => { const beam = createHeadlightBeam(); beam.sample(0); beam.input(1, 0, 'mouse', 0); // One 3 s gap (a backgrounded tab) is integrated as a single short step. const sample = beam.sample(3_000); expect(sample.x).toBeGreaterThan(0); expect(sample.x).toBeLessThanOrEqual(1); }); it('returns the same sample for the same frame time', () => { const beam = createHeadlightBeam(); beam.sample(0); beam.input(1, 0, 'mouse', 0); const a = beam.sample(50); expect(beam.sample(50)).toBe(a); expect(beam.sample(40)).toBe(a); }); }); describe('headlight beam: idle return', () => { it('holds the pointer, then drifts back to dead ahead after 4 s idle', () => { const beam = createHeadlightBeam(); beam.sample(0); beam.input(0.9, 0.5, 'mouse', 0); expect(run(beam, 0, IDLE_AFTER_MS - 50).x).toBeCloseTo(0.9, 2); expect(beam.idleAt()).toBe(IDLE_AFTER_MS); // Slower than the follow: a third of a second in, it has barely moved. const early = run(beam, IDLE_AFTER_MS - 50, IDLE_AFTER_MS + 300); expect(early.x).toBeGreaterThan(0.6); const back = run(beam, IDLE_AFTER_MS + 300, IDLE_AFTER_MS + 5_000); expect(back.x).toBeCloseTo(0, 2); expect(back.y).toBeCloseTo(0, 2); expect(beam.settled(IDLE_AFTER_MS + 5_000)).toBe(true); }); it('a new input cancels the idle drift', () => { const beam = createHeadlightBeam(); beam.sample(0); beam.input(0.9, 0, 'mouse', 0); run(beam, 0, IDLE_AFTER_MS + 400); beam.input(-0.5, 0, 'mouse', IDLE_AFTER_MS + 400); expect(run(beam, IDLE_AFTER_MS + 400, IDLE_AFTER_MS + 1_400).x).toBeCloseTo(-0.5, 2); }); }); describe('headlight beam: intro sweep', () => { it('arcs from -1 to +0.25 over 1.6 s, ease-out cubic, intensity 0 to 1', () => { const beam = createHeadlightBeam(); beam.sample(0); expect(beam.sweep(100, -0.4)).toBe(true); const start = beam.sample(100); expect(start.x).toBeCloseTo(SWEEP_FROM, 6); expect(start.intensity).toBeCloseTo(0, 6); expect(start.y).toBeCloseTo(-0.4, 6); // Ease-out: well past halfway in x at half time. const half = beam.sample(100 + SWEEP_MS / 2); const eased = 1 - 0.5 ** 3; expect(half.x).toBeCloseTo(SWEEP_FROM + (SWEEP_TO - SWEEP_FROM) * eased, 6); expect(half.intensity).toBeCloseTo(eased, 6); expect(beam.swept(100 + SWEEP_MS - 1)).toBe(false); const end = beam.sample(100 + SWEEP_MS); expect(end.x).toBeCloseTo(SWEEP_TO, 6); expect(end.intensity).toBeCloseTo(1, 6); expect(beam.swept(100 + SWEEP_MS)).toBe(true); }); it('is monotonic in x and plays only once', () => { const beam = createHeadlightBeam(); beam.sample(0); beam.sweep(0); let previous = -Infinity; for (let now = 0; now <= SWEEP_MS; now += 16) { const { x } = beam.sample(now); expect(x).toBeGreaterThanOrEqual(previous); previous = x; } expect(beam.sweep(SWEEP_MS + 10)).toBe(false); }); it('holds the arc end, then settles at dipped beam until the idle drift', () => { const beam = swept(); const rest = beam.sample(SWEEP_MS + 2_000); expect(rest.x).toBeCloseTo(SWEEP_TO, 3); expect(rest.intensity).toBeCloseTo(REST_INTENSITY, 3); expect(run(beam, SWEEP_MS + 2_000, SWEEP_MS + IDLE_AFTER_MS + 6_000).x).toBeCloseTo(0, 2); }); it('hands over to a pointer that moved during the sweep', () => { const beam = createHeadlightBeam(); beam.sample(0); beam.sweep(0); beam.input(-0.6, 0.2, 'mouse', 400); expect(beam.sample(800).x).toBeLessThan(SWEEP_TO); const after = run(beam, 800, SWEEP_MS + 1_000); expect(after.x).toBeCloseTo(-0.6, 2); }); }); describe('headlight beam: flash', () => { it('lifts to full intensity for 420 ms, then falls back to dipped beam', () => { const beam = swept(); const t0 = SWEEP_MS + 2_000; expect(beam.flash(t0)).toBe(true); const lifted = run(beam, t0, t0 + 300); expect(lifted.intensity).toBeGreaterThan(0.98); const after = run(beam, t0 + 300, t0 + FLASH_HOLD_MS + 1_500); expect(after.intensity).toBeCloseTo(REST_INTENSITY, 2); }); it('eases in rather than stepping (no hard flash)', () => { const beam = swept(); const t0 = SWEEP_MS + 2_000; beam.flash(t0); const first = beam.sample(t0 + 16); expect(first.intensity - REST_INTENSITY).toBeLessThan((1 - REST_INTENSITY) * 0.3); }); it('is rate-limited to one per 600 ms: never more than 3 per second', () => { expect(FLASH_MIN_INTERVAL_MS).toBeGreaterThanOrEqual(1000 / 3); const beam = swept(); const t0 = 10_000; const accepted: number[] = []; // A visitor hammering the hero: 10 clicks a second for 5 seconds. for (let now = t0; now < t0 + 5_000; now += 100) { if (beam.flash(now)) accepted.push(now); } for (let i = 1; i < accepted.length; i++) { expect(accepted[i]! - accepted[i - 1]!).toBeGreaterThanOrEqual(FLASH_MIN_INTERVAL_MS); } for (let start = t0; start < t0 + 5_000; start += 100) { expect(accepted.filter((at) => at >= start && at < start + 1_000).length).toBeLessThanOrEqual(2); } expect(accepted.length).toBeGreaterThan(0); }); it('the intensity crosses its midpoint at most 3 times a second under hammering', () => { const beam = swept(); const t0 = 10_000; const mid = (1 + REST_INTENSITY) / 2; let above = false; const crossings: number[] = []; for (let now = t0; now < t0 + 6_000; now += 8) { if (now % 100 === 0) beam.flash(now); const high = beam.sample(now).intensity > mid; if (high && !above) crossings.push(now); above = high; } for (let start = t0; start < t0 + 5_000; start += 50) { expect(crossings.filter((at) => at >= start && at < start + 1_000).length).toBeLessThanOrEqual(3); } }); }); describe('headlight beam: auto-sway (touch, coarse, low tier)', () => { it('sways slowly within +-0.3 and never settles', () => { const beam = swept(); beam.setAutoSway(true); let min = Infinity; let max = -Infinity; const t0 = SWEEP_MS + 2_000; for (let now = t0; now <= t0 + 25_000; now += 32) { const { x } = beam.sample(now); min = Math.min(min, x); max = Math.max(max, x); } expect(max).toBeLessThanOrEqual(SWEEP_AMPLITUDE_GUARD); expect(min).toBeGreaterThanOrEqual(-SWEEP_AMPLITUDE_GUARD); expect(max - min).toBeGreaterThan(SWAY_AMPLITUDE); expect(beam.settled(t0 + 25_000)).toBe(false); }); }); /** The sway target is +-0.3; the arc end (0.25) is inside it too. */ const SWEEP_AMPLITUDE_GUARD = SWAY_AMPLITUDE + 1e-6;