Merge branch 'polish/speedometer' into redesign/overnight
This commit is contained in:
@@ -73,6 +73,7 @@ describe('release budgets', () => {
|
||||
expect(classifyArtifact('assets/DashcamFrame-c.js', 'postcard')).toBe('deferred-non-video');
|
||||
expect(classifyArtifact('assets/DashcamFrame-c.css', '.dashcam-frame{}')).toBe('deferred-non-video');
|
||||
expect(classifyArtifact('assets/tab-icon-c.js', 'renderTabIcon')).toBe('deferred-non-video');
|
||||
expect(classifyArtifact('assets/speedometer-dial-c.js', 'createSpeedometerDial')).toBe('deferred-non-video');
|
||||
expect(() => classifyArtifact('assets/mystery.bin', '')).toThrow(/unclassified/u);
|
||||
});
|
||||
|
||||
|
||||
217
tests/unit/speedometer.test.ts
Normal file
217
tests/unit/speedometer.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user