Merge branch 'polish/speedometer' into redesign/overnight

This commit is contained in:
2026-10-12 18:44:48 +03:00
33 changed files with 929 additions and 2 deletions

View File

@@ -0,0 +1,217 @@
import { describe, expect, it } from 'vitest';
import {
BRAKE_KMH,
CRUISE_DRIVE,
CRUISE_KMH,
MAX_KMH,
ROUTE_KM,
SWEEP_MS,
createBeamFlashDetector,
createSpeedometerModel,
engineStateFor,
formatSpeed,
formatTrip,
gearFor,
needleDegrees,
speedTargetKmh,
tripKmFor,
type SpeedometerInput,
} from '@/journey/speedometer';
const driving = (overrides: Partial<SpeedometerInput> = {}): SpeedometerInput => ({
driveSpeed: CRUISE_DRIVE,
direction: 0,
acceleration: 0,
routeProgress: 0,
ignition: 3,
jump: false,
arrived: false,
...overrides,
});
describe('speedTargetKmh', () => {
it('reads the idle cruise at the controller cruise speed', () => {
expect(speedTargetKmh(driving())).toBe(CRUISE_KMH);
});
it('pins the needle at full scale for the fastest scroll', () => {
expect(speedTargetKmh(driving({ driveSpeed: 1, acceleration: 1, direction: 1 }))).toBe(MAX_KMH);
});
it('maps the drive speed linearly between cruise and full scale', () => {
const half = speedTargetKmh(driving({ driveSpeed: CRUISE_DRIVE + (1 - CRUISE_DRIVE) / 2, direction: 1, acceleration: 0.5 }));
expect(half).toBeCloseTo((CRUISE_KMH + MAX_KMH) / 2, 6);
});
it('brakes on a reverse scroll, down to the brake floor', () => {
expect(speedTargetKmh(driving({ driveSpeed: 1, direction: -1, acceleration: 1 }))).toBe(BRAKE_KMH);
expect(speedTargetKmh(driving({ driveSpeed: 0.5, direction: -1, acceleration: 0.5 }))).toBeCloseTo((CRUISE_KMH + BRAKE_KMH) / 2, 6);
});
it('ignores a reverse direction with no real push (a settling sample)', () => {
expect(speedTargetKmh(driving({ direction: -1, acceleration: 0.01 }))).toBe(CRUISE_KMH);
});
it('goes to overdrive on a navigator jump and to zero when parked or before ignition', () => {
expect(speedTargetKmh(driving({ jump: true }))).toBe(MAX_KMH);
expect(speedTargetKmh(driving({ arrived: true, driveSpeed: 1 }))).toBe(0);
expect(speedTargetKmh(driving({ ignition: 0 }))).toBe(0);
expect(speedTargetKmh(driving({ ignition: 1 }))).toBe(0);
expect(speedTargetKmh(driving({ ignition: 2 }))).toBe(CRUISE_KMH);
});
it('never leaves the dial', () => {
for (const driveSpeed of [-1, 0, 0.1, 2, Number.NaN]) {
const target = speedTargetKmh(driving({ driveSpeed, direction: 1, acceleration: 1 }));
expect(target).toBeGreaterThanOrEqual(0);
expect(target).toBeLessThanOrEqual(MAX_KMH);
}
});
});
describe('engineStateFor', () => {
it('follows the ignition phase, and parks at the destination', () => {
expect(engineStateFor({ ignition: 0, arrived: false })).toBe('off');
expect(engineStateFor({ ignition: 1, arrived: false })).toBe('lamps');
expect(engineStateFor({ ignition: 2, arrived: false })).toBe('on');
expect(engineStateFor({ ignition: 3, arrived: false })).toBe('on');
expect(engineStateFor({ ignition: 3, arrived: true })).toBe('off');
});
});
describe('readouts', () => {
it('rotates the needle over 240 degrees from 8 to 4 o\'clock', () => {
expect(needleDegrees(0)).toBe(-120);
expect(needleDegrees(MAX_KMH)).toBe(120);
expect(needleDegrees(MAX_KMH / 2)).toBeCloseTo(0, 9);
expect(needleDegrees(-50)).toBe(-120);
expect(needleDegrees(999)).toBe(120);
});
it('shifts up through the gears, and parks when the engine is off', () => {
expect(gearFor(0, 'on')).toBe('N');
expect(gearFor(10, 'on')).toBe('1');
expect(gearFor(CRUISE_KMH, 'on')).toBe('4');
expect(gearFor(MAX_KMH, 'on')).toBe('6');
expect(gearFor(CRUISE_KMH, 'off')).toBe('P');
expect(gearFor(CRUISE_KMH, 'lamps')).toBe('P');
});
it('formats three digits in either unit', () => {
expect(formatSpeed(CRUISE_KMH, 'kmh')).toBe('088');
expect(formatSpeed(CRUISE_KMH, 'mph')).toBe('055');
expect(formatSpeed(0, 'kmh')).toBe('000');
expect(formatSpeed(MAX_KMH, 'mph')).toBe('149');
expect(formatSpeed(5000, 'kmh')).toBe('240');
expect(formatSpeed(-5, 'kmh')).toBe('000');
});
it('runs the trip odometer along the route', () => {
expect(tripKmFor(0)).toBe(0);
expect(tripKmFor(0.5)).toBe(ROUTE_KM / 2);
expect(tripKmFor(1.7)).toBe(ROUTE_KM);
expect(formatTrip(12.44, 'kmh')).toBe('012.4');
expect(formatTrip(0, 'kmh')).toBe('000.0');
expect(formatTrip(ROUTE_KM, 'mph')).toBe('029.8');
});
});
describe('createSpeedometerModel', () => {
const run = (
model: ReturnType<typeof createSpeedometerModel>,
input: SpeedometerInput,
from: number,
to: number,
animated = true,
step = 16,
): number[] => {
const trace: number[] = [];
for (let now = from; now <= to; now += step) trace.push(model.step(now, input, animated).kmh);
return trace;
};
it('starts dark, runs the self-test sweep on ignition, then settles at cruise', () => {
const model = createSpeedometerModel();
expect(model.step(0, driving({ ignition: 0 }), true)).toMatchObject({ kmh: 0, engine: 'off', gear: 'P', settled: true });
expect(model.step(300, driving({ ignition: 1 }), true)).toMatchObject({ kmh: 0, engine: 'lamps', settled: true });
const sweep = run(model, driving({ ignition: 2 }), 316, 316 + SWEEP_MS);
expect(Math.max(...sweep)).toBeGreaterThan(CRUISE_KMH * 1.6);
const settle = run(model, driving(), 316 + SWEEP_MS + 16, 5_000);
const last = settle[settle.length - 1];
expect(last).toBe(CRUISE_KMH);
expect(model.step(5_016, driving(), true)).toMatchObject({ kmh: CRUISE_KMH, gear: '4', settled: true });
});
it('overshoots like a real needle, then comes to rest', () => {
const model = createSpeedometerModel();
run(model, driving(), 0, 4_000);
const push = driving({ driveSpeed: 1, direction: 1, acceleration: 1 });
const trace = run(model, push, 4_016, 8_000);
const peak = Math.max(...trace);
expect(peak).toBeGreaterThan(MAX_KMH);
expect(peak).toBeLessThan(MAX_KMH * 1.2);
expect(trace[trace.length - 1]).toBe(MAX_KMH);
expect(model.step(8_016, push, true)).toMatchObject({ throttle: 1, brake: false, settled: true });
});
it('never reads below zero, even on the fall after a jump', () => {
const model = createSpeedometerModel();
run(model, driving({ jump: true }), 0, 3_000);
const fall = run(model, driving({ arrived: true }), 3_016, 7_000);
expect(Math.min(...fall)).toBeGreaterThanOrEqual(0);
expect(fall[fall.length - 1]).toBe(0);
expect(model.step(7_016, driving({ arrived: true }), true)).toMatchObject({ engine: 'off', gear: 'P', settled: true });
});
it('snaps without a sweep when not animated (reduced motion)', () => {
const model = createSpeedometerModel();
expect(model.step(0, driving({ ignition: 0 }), false).kmh).toBe(0);
const first = model.step(16, driving(), false);
expect(first.kmh).toBe(CRUISE_KMH);
expect(first.settled).toBe(true);
expect(model.step(32, driving({ driveSpeed: 1, direction: 1, acceleration: 1 }), false).kmh).toBe(MAX_KMH);
});
it('lights the brake lamp on a reverse scroll and the throttle on a push', () => {
const model = createSpeedometerModel();
const brake = model.step(0, driving({ direction: -1, acceleration: 0.6 }), true);
expect(brake.brake).toBe(true);
expect(brake.throttle).toBeCloseTo(0.6, 6);
expect(model.step(16, driving({ direction: 1, acceleration: 0.3 }), true)).toMatchObject({ brake: false, throttle: 0.3 });
expect(model.step(32, driving({ direction: -1, acceleration: 0.6, jump: true }), true).brake).toBe(false);
});
it('clamps a long gap between frames to one small step', () => {
const model = createSpeedometerModel();
model.step(0, driving(), true);
const after = model.step(60_000, driving(), true).kmh;
expect(after).toBeGreaterThan(0);
expect(after).toBeLessThan(CRUISE_KMH);
});
it('reuses one frame object and tolerates non-finite input', () => {
const model = createSpeedometerModel();
const a = model.step(0, driving(), true);
const b = model.step(16, driving({ driveSpeed: Number.NaN, acceleration: Number.NaN, routeProgress: Number.NaN }), true);
expect(b).toBe(a);
expect(Number.isFinite(b.kmh)).toBe(true);
expect(Number.isFinite(b.tripKm)).toBe(true);
});
});
describe('createBeamFlashDetector', () => {
const ramp = (from: number, to: number, steps: number): number[] =>
Array.from({ length: steps + 1 }, (_, i) => from + ((to - from) * i) / steps);
it('ignores the intro sweep and its fall to the rest level', () => {
const flash = createBeamFlashDetector();
const seen = [...ramp(0, 1, 40), ...ramp(1, 0.72, 20)].map(flash);
expect(seen.some(Boolean)).toBe(false);
});
it('fires on a lift from rest and holds while the beam is high', () => {
const flash = createBeamFlashDetector();
for (const v of [...ramp(0, 1, 40), ...ramp(1, 0.72, 20)]) flash(v);
const lift = [...ramp(0.72, 1, 6), 1, 1, 1, ...ramp(1, 0.72, 10)].map(flash);
expect(lift.filter(Boolean).length).toBeGreaterThanOrEqual(3);
expect(lift[lift.length - 1]).toBe(false);
// Settled back at rest, the next lift fires again.
expect([...ramp(0.72, 1, 6)].map(flash).some(Boolean)).toBe(true);
});
it('does not fire from a dark beam, nor from a lift straight out of the dark', () => {
const flash = createBeamFlashDetector();
expect([0, 0, 0, 0, ...ramp(0, 1, 5), 1, 1].map(flash).some(Boolean)).toBe(false);
});
});