197 lines
7.7 KiB
TypeScript
197 lines
7.7 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
cancelDashcamCapture,
|
|
readDashcam,
|
|
requestDashcamCapture,
|
|
setDashcamOpen,
|
|
setDashcamPointer,
|
|
takeDashcamCapture,
|
|
} from '@/journey/dashcam-feed';
|
|
import { DASHCAM_LENS, createDashcamLens, driftTarget } from '@/scene/world/dashcam-lens';
|
|
import {
|
|
dashcamClock,
|
|
dashcamTitle,
|
|
frameBottomLeft,
|
|
frameTopLeft,
|
|
postcardCredit,
|
|
postcardFilename,
|
|
routePercent,
|
|
type DashcamTelemetry,
|
|
} from '@/lib/dashcam-postcard';
|
|
import { attachMusicPulse, createPulseTap, readSwitchboard, setMusicPulseAudible } from '@/audio/music-pulse';
|
|
import { playShutterClick } from '@/audio/switchboard-click';
|
|
|
|
afterEach(() => {
|
|
setDashcamOpen(false, false);
|
|
setMusicPulseAudible(false);
|
|
attachMusicPulse(null);
|
|
});
|
|
|
|
describe('dashcam feed', () => {
|
|
it('opens with drift only when asked, and resets the pointer on every change', () => {
|
|
setDashcamOpen(true, true);
|
|
setDashcamPointer(0.5, -0.25, 100);
|
|
expect(readDashcam()).toMatchObject({ open: true, drift: true, px: 0.5, py: -0.25, movedAt: 100 });
|
|
setDashcamOpen(false, true);
|
|
expect(readDashcam()).toMatchObject({ open: false, drift: false, px: 0, py: 0 });
|
|
setDashcamOpen(true, false);
|
|
expect(readDashcam().drift).toBe(false);
|
|
});
|
|
|
|
it('clamps the pointer to [-1, 1]', () => {
|
|
setDashcamOpen(true, true);
|
|
setDashcamPointer(4, -9, 0);
|
|
expect(readDashcam()).toMatchObject({ px: 1, py: -1 });
|
|
});
|
|
|
|
it('hands a capture to the scene exactly once', () => {
|
|
const resolve = vi.fn();
|
|
requestDashcamCapture(resolve);
|
|
expect(takeDashcamCapture()).toBe(resolve);
|
|
expect(takeDashcamCapture()).toBeNull();
|
|
});
|
|
|
|
it('drops a queued capture on cancel and on close', () => {
|
|
requestDashcamCapture(vi.fn());
|
|
cancelDashcamCapture();
|
|
expect(takeDashcamCapture()).toBeNull();
|
|
setDashcamOpen(true, false);
|
|
requestDashcamCapture(vi.fn());
|
|
setDashcamOpen(false, false);
|
|
expect(takeDashcamCapture()).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('dashcam lens', () => {
|
|
const feed = (overrides: Partial<ReturnType<typeof readDashcam>> = {}) => ({
|
|
open: true, drift: true, px: 1, py: 1, movedAt: 0, ...overrides,
|
|
});
|
|
|
|
it('targets the pointer while open with drift, centre otherwise', () => {
|
|
expect(driftTarget(feed({ px: 0.4, py: -0.6 }), 10)).toEqual({ x: 0.4, y: -0.6 });
|
|
expect(driftTarget(feed({ px: 7, py: -7 }), 10)).toEqual({ x: 1, y: -1 });
|
|
expect(driftTarget(feed({ open: false }), 10)).toEqual({ x: 0, y: 0 });
|
|
expect(driftTarget(feed({ drift: false }), 10)).toEqual({ x: 0, y: 0 });
|
|
});
|
|
|
|
it('returns to centre after 3 s without pointer movement', () => {
|
|
expect(driftTarget(feed(), DASHCAM_LENS.idleMs)).toEqual({ x: 1, y: 1 });
|
|
expect(driftTarget(feed(), DASHCAM_LENS.idleMs + 1)).toEqual({ x: 0, y: 0 });
|
|
});
|
|
|
|
it('eases the drift and never exceeds +-1.6 x and +-0.5 target height', () => {
|
|
const lens = createDashcamLens();
|
|
const first = { ...lens.update(feed(), 1 / 60, 0) };
|
|
expect(first.x).toBeGreaterThan(0);
|
|
expect(first.x).toBeLessThan(DASHCAM_LENS.driftX * 0.1);
|
|
let state = first;
|
|
for (let i = 0; i < 600; i += 1) state = lens.update(feed(), 1 / 60, 0);
|
|
expect(state.x).toBeCloseTo(1.6, 3);
|
|
expect(state.targetY).toBeCloseTo(-0.5, 3);
|
|
for (let i = 0; i < 600; i += 1) state = lens.update(feed({ px: -1, py: -1 }), 1 / 60, 0);
|
|
expect(state.x).toBeGreaterThanOrEqual(-1.6);
|
|
expect(state.targetY).toBeLessThanOrEqual(0.5);
|
|
});
|
|
|
|
it('closes the letterbox to 0.16 over 400 ms and opens it again', () => {
|
|
const lens = createDashcamLens();
|
|
expect(lens.update(feed(), 0.2, 0).letterbox).toBeCloseTo(0.08, 5);
|
|
expect(lens.update(feed(), 0.2, 0).letterbox).toBeCloseTo(0.16, 5);
|
|
expect(lens.update(feed({ open: false }), 0.2, 0).letterbox).toBeCloseTo(0.08, 5);
|
|
expect(lens.update(feed({ open: false }), 0.2, 0).letterbox).toBe(0);
|
|
});
|
|
|
|
it('snaps the bars and holds centre without drift (touch, low tier)', () => {
|
|
const lens = createDashcamLens();
|
|
const open = lens.update(feed({ drift: false }), 1 / 60, 0);
|
|
expect(Math.abs(open.x) + Math.abs(open.targetY)).toBe(0);
|
|
expect(open.letterbox).toBeCloseTo(0.16, 10);
|
|
expect(lens.update(feed({ open: false, drift: false }), 1 / 60, 0).letterbox).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('dashcam telemetry', () => {
|
|
const telemetry: DashcamTelemetry = {
|
|
sectionId: 'infrastructure',
|
|
sectionLabel: 'Infrastructure',
|
|
chapter: 'pre-dawn',
|
|
exit: '04',
|
|
routePercent: 45,
|
|
clock: '03:51',
|
|
name: 'Luca Pîslaru',
|
|
city: 'Bucharest',
|
|
};
|
|
|
|
it('formats the document title', () => {
|
|
expect(dashcamTitle(telemetry)).toBe('DASHCAM · 45% · INFRASTRUCTURE · 03:51');
|
|
});
|
|
|
|
it('formats the frame plates and the credit', () => {
|
|
expect(frameTopLeft(telemetry)).toBe('DASHCAM · EXIT 04');
|
|
expect(frameBottomLeft(telemetry)).toBe('ROUTE 45% · INFRASTRUCTURE');
|
|
expect(postcardCredit(telemetry.name, telemetry.city)).toBe('NIGHT SHIFT · LUCA PÎSLARU · BUCHAREST');
|
|
});
|
|
|
|
it('rounds and clamps route progress on the tab title\'s 5% step', () => {
|
|
expect(routePercent(0.474)).toBe(45);
|
|
expect(routePercent(0.476)).toBe(50);
|
|
expect(routePercent(1.4)).toBe(100);
|
|
expect(routePercent(-1)).toBe(0);
|
|
expect(routePercent(Number.NaN)).toBe(0);
|
|
});
|
|
|
|
it('names the postcard after the section and the clock', () => {
|
|
expect(postcardFilename('infrastructure', '03:51')).toBe('night-shift-infrastructure-0351.png');
|
|
expect(postcardFilename('selected-builds', '23:05')).toBe('night-shift-selected-builds-2305.png');
|
|
expect(postcardFilename('', '7:5')).toBe('night-shift-route-0075.png');
|
|
});
|
|
|
|
it('uses the live Bucharest clock outside the maintenance window', () => {
|
|
const now = new Date('2026-01-15T01:51:00Z');
|
|
expect(dashcamClock('profile', null, now)).toBe('03:51');
|
|
expect(dashcamClock('infrastructure', null, now)).toBe('03:51');
|
|
});
|
|
});
|
|
|
|
describe('switchboard click', () => {
|
|
it('stays silent and creates nothing without an audible soundtrack', () => {
|
|
expect(readSwitchboard()).toBeNull();
|
|
expect(playShutterClick()).toBe(false);
|
|
});
|
|
|
|
it('plays into the mix bus at the switchboard gain when audible', () => {
|
|
const gains: { gain: { setValueAtTime: ReturnType<typeof vi.fn>; linearRampToValueAtTime: ReturnType<typeof vi.fn>; exponentialRampToValueAtTime: ReturnType<typeof vi.fn> }; connect: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }[] = [];
|
|
const context = {
|
|
state: 'running',
|
|
sampleRate: 1000,
|
|
currentTime: 2,
|
|
createGain: () => {
|
|
const node = {
|
|
gain: { value: 1, setValueAtTime: vi.fn(), linearRampToValueAtTime: vi.fn(), exponentialRampToValueAtTime: vi.fn() },
|
|
connect: vi.fn(),
|
|
disconnect: vi.fn(),
|
|
context: undefined as unknown,
|
|
};
|
|
node.context = context;
|
|
gains.push(node);
|
|
return node;
|
|
},
|
|
createAnalyser: () => { throw new Error('no analyser'); },
|
|
createBuffer: (_c: number, length: number) => ({ getChannelData: () => new Float32Array(length) }),
|
|
createBufferSource: () => ({ connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(), buffer: null, onended: null }),
|
|
};
|
|
const destination = {} as AudioNode;
|
|
createPulseTap(context as unknown as BaseAudioContext, destination);
|
|
expect(readSwitchboard()).toBeNull();
|
|
setMusicPulseAudible(true);
|
|
const bus = readSwitchboard();
|
|
expect(bus).toBe(gains[0]);
|
|
expect(playShutterClick()).toBe(true);
|
|
const envelope = gains[1]!;
|
|
expect(envelope.connect).toHaveBeenCalledWith(bus);
|
|
expect(envelope.gain.linearRampToValueAtTime).toHaveBeenCalledWith(0.18, expect.any(Number));
|
|
attachMusicPulse(null);
|
|
expect(readSwitchboard()).toBeNull();
|
|
});
|
|
});
|