feat: interactive traffic with car-following, overtakes and flash-to-pass
This commit is contained in:
36
tests/unit/flash-target.test.ts
Normal file
36
tests/unit/flash-target.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isFlashTarget } from '@/journey/flash-target';
|
||||
|
||||
function dom(html: string): Document {
|
||||
document.body.innerHTML = html;
|
||||
return document;
|
||||
}
|
||||
|
||||
describe('click to flash: which clicks lift the high beam', () => {
|
||||
it('flashes from the open page: body, a section, a paragraph', () => {
|
||||
const d = dom('<main id="m"><section class="plate"><p id="p">road</p></section></main>');
|
||||
expect(isFlashTarget(d.body, '')).toBe(true);
|
||||
expect(isFlashTarget(d.getElementById('m'), null)).toBe(true);
|
||||
expect(isFlashTarget(d.getElementById('p'), undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('never from a control, its descendants, a form field or the dashcam frame', () => {
|
||||
const d = dom(`
|
||||
<a href="#x" id="a"><span id="in-a">link</span></a>
|
||||
<button id="b"><svg id="svg"></svg></button>
|
||||
<div role="button" id="rb">go</div>
|
||||
<input id="i"><textarea id="t"></textarea><select id="s"><option id="o">1</option></select>
|
||||
<label id="l">label</label><details><summary id="sum">more</summary></details>
|
||||
<div class="dashcam-frame"><div id="dc">frame</div></div>`);
|
||||
for (const id of ['a', 'in-a', 'b', 'svg', 'rb', 'i', 't', 's', 'o', 'l', 'sum', 'dc']) {
|
||||
expect(isFlashTarget(d.getElementById(id), ''), id).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('never while text is selected, and never from a non-element target', () => {
|
||||
const d = dom('<p id="p">road</p>');
|
||||
expect(isFlashTarget(d.getElementById('p'), 'road')).toBe(false);
|
||||
expect(isFlashTarget(null, '')).toBe(false);
|
||||
expect(isFlashTarget(d, '')).toBe(false);
|
||||
});
|
||||
});
|
||||
616
tests/unit/traffic-model.test.ts
Normal file
616
tests/unit/traffic-model.test.ts
Normal file
@@ -0,0 +1,616 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { EnvironmentChapter } from '@/journey/types';
|
||||
import {
|
||||
BRAKE_GLOBAL_PER_SECOND,
|
||||
BRAKE_MIN_ON_SECONDS,
|
||||
BRAKE_MIN_SPACING_SECONDS,
|
||||
CENTRE_DEADLINE_Z,
|
||||
CENTRE_ENTRY_Z,
|
||||
FADE_IN_SECONDS,
|
||||
FORCED_LANE_CHANGE_SECONDS,
|
||||
HERO_HALF_WIDTH,
|
||||
HERO_NOSE_Z,
|
||||
HERO_TAIL_Z,
|
||||
INDICATOR_DUTY,
|
||||
INDICATOR_HZ,
|
||||
TRAFFIC_ASPHALT_HALF_WIDTH,
|
||||
TRAFFIC_BEHIND,
|
||||
TRAFFIC_DENSITY,
|
||||
TRAFFIC_ENTRY_FAR_Z,
|
||||
TRAFFIC_ENTRY_NEAR_Z,
|
||||
TRAFFIC_ENTRY_NEAR_SPAN,
|
||||
TRAFFIC_EXIT_NOSE_Z,
|
||||
TRAFFIC_KINDS,
|
||||
TRAFFIC_LANE_X,
|
||||
TRAFFIC_NEAR_CAMERA_SIDE,
|
||||
TRAFFIC_NEAR_OPEN,
|
||||
TRAFFIC_PENDING,
|
||||
TRAFFIC_TRUCK,
|
||||
TRAFFIC_VISIBLE,
|
||||
createFlashDetector,
|
||||
createTrafficModel,
|
||||
indicatorBlink,
|
||||
type TrafficConfig,
|
||||
type TrafficFrame,
|
||||
type TrafficInput,
|
||||
} from '@/scene/world/traffic-model';
|
||||
|
||||
const CRUISE = 18 + 0.18 * 54;
|
||||
const CHAPTERS: EnvironmentChapter[] = ['deep-night', 'systems', 'infrastructure', 'builds', 'pre-dawn', 'destination'];
|
||||
|
||||
const input = (patch: Partial<TrafficInput> = {}): TrafficInput => ({
|
||||
dt: 1 / 60,
|
||||
heroSpeed: CRUISE,
|
||||
chapter: 'deep-night',
|
||||
sweeping: false,
|
||||
flash: false,
|
||||
cameraX: -5.6,
|
||||
engineOff: 0,
|
||||
...patch,
|
||||
});
|
||||
|
||||
const drawn = (f: TrafficFrame, i: number): boolean => f.active[i] !== TRAFFIC_PENDING && f.active[i] !== TRAFFIC_BEHIND;
|
||||
const size = (f: TrafficFrame, i: number) => TRAFFIC_KINDS[f.kind[i]!]!;
|
||||
|
||||
/** Invariants 1-4 and the lamp limits for one frame; returns a message or null. */
|
||||
function violation(f: TrafficFrame): string | null {
|
||||
let corridor = 0;
|
||||
let indicators = 0;
|
||||
for (let i = 0; i < f.count; i += 1) {
|
||||
if (f.active[i] === TRAFFIC_PENDING) continue;
|
||||
const { halfWidth, length } = size(f, i);
|
||||
const x = f.x[i]!;
|
||||
const z = f.z[i]!;
|
||||
if (!Number.isFinite(x) || !Number.isFinite(z)) return `vehicle ${i} non-finite`;
|
||||
if (Math.abs(x) + halfWidth > TRAFFIC_ASPHALT_HALF_WIDTH) return `vehicle ${i} off the asphalt at x ${x}`;
|
||||
const inCorridor = Math.abs(x) < HERO_HALF_WIDTH + halfWidth;
|
||||
if (inCorridor && z > HERO_NOSE_Z && z - length < HERO_TAIL_Z) return `vehicle ${i} overlaps the hero at x ${x} z ${z}`;
|
||||
if (inCorridor && f.active[i] !== TRAFFIC_BEHIND) corridor += 1;
|
||||
if (f.indicator[i] !== 0) indicators += 1;
|
||||
for (let j = i + 1; j < f.count; j += 1) {
|
||||
if (f.active[j] === TRAFFIC_PENDING) continue;
|
||||
const other = size(f, j);
|
||||
const lateral = Math.abs(x - f.x[j]!) < halfWidth + other.halfWidth;
|
||||
const along = z > f.z[j]! - other.length && f.z[j]! > z - length;
|
||||
if (lateral && along) return `vehicles ${i} and ${j} overlap (x ${x}/${f.x[j]} z ${z}/${f.z[j]})`;
|
||||
}
|
||||
}
|
||||
if (corridor > 1) return `${corridor} vehicles in the centre lane`;
|
||||
if (indicators > 2) return `${indicators} indicators at once`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A small deterministic generator for the drive scripts. */
|
||||
function rng(seed: number) {
|
||||
let s = seed % 2147483647 || 1;
|
||||
return () => {
|
||||
s = (s * 48271) % 2147483647;
|
||||
return (s - 1) / 2147483646;
|
||||
};
|
||||
}
|
||||
|
||||
describe('traffic model: long randomised drives', () => {
|
||||
it('never overlaps, stays on the asphalt and keeps the lamp limits (20 seeds x 10 min)', () => {
|
||||
for (let seed = 1; seed <= 20; seed += 1) {
|
||||
const pool = ([12, 10, 6] as const)[seed % 3]!;
|
||||
const model = createTrafficModel({ pool, seed: seed * 7919, lite: pool === 6 });
|
||||
const random = rng(seed * 104729);
|
||||
const step = input();
|
||||
let time = 0;
|
||||
let hero = CRUISE;
|
||||
let target = CRUISE;
|
||||
let nextSpeed = 0;
|
||||
let nextChapter = 20;
|
||||
let nextFlash = 3;
|
||||
let sweepUntil = -1;
|
||||
const onsetsAt: number[] = [];
|
||||
const lastOnset = new Float64Array(pool).fill(Number.NEGATIVE_INFINITY);
|
||||
const brakeWas = new Uint8Array(pool);
|
||||
const brakeOnAt = new Float64Array(pool);
|
||||
const lastZ = new Float64Array(pool);
|
||||
let previousOnsets = 0;
|
||||
const end = 600;
|
||||
while (time < end) {
|
||||
const dt = random() < 0.01 ? 0.1 : 1 / 60;
|
||||
time += dt;
|
||||
if (time >= nextSpeed) {
|
||||
const roll = random();
|
||||
target = roll < 0.35 ? CRUISE : roll < 0.6 ? 120 : 18 + random() * 102;
|
||||
if (random() < 0.4) hero = target; // instant steps
|
||||
nextSpeed = time + 0.3 + random() * 3.7;
|
||||
}
|
||||
hero += (target - hero) * Math.min(1, dt * 3);
|
||||
if (time >= nextChapter) {
|
||||
step.chapter = CHAPTERS[Math.floor(random() * CHAPTERS.length)]!;
|
||||
nextChapter = time + 20 + random() * 40;
|
||||
if (random() < 0.4) sweepUntil = time + 0.5 + random();
|
||||
}
|
||||
step.flash = time >= nextFlash;
|
||||
if (step.flash) nextFlash = time + 0.6 + random() * 5;
|
||||
step.sweeping = time < sweepUntil;
|
||||
step.engineOff = time > end - 30 ? Math.min(1, (time - (end - 30)) / 10) : 0;
|
||||
if (step.engineOff > 0) step.chapter = 'destination';
|
||||
step.heroSpeed = hero * (1 - step.engineOff);
|
||||
step.cameraX = Math.sin(time * 0.05) * 5.6;
|
||||
step.dt = dt;
|
||||
const f = model.step(step);
|
||||
const problem = violation(f);
|
||||
if (problem) throw new Error(`seed ${seed} t ${time.toFixed(2)}: ${problem}`);
|
||||
const events = model.events();
|
||||
if (events.brakeOnsets > previousOnsets) {
|
||||
for (let k = previousOnsets; k < events.brakeOnsets; k += 1) onsetsAt.push(time);
|
||||
previousOnsets = events.brakeOnsets;
|
||||
}
|
||||
for (let i = 0; i < pool; i += 1) {
|
||||
const on = f.brake[i]! > 0 ? 1 : 0;
|
||||
const respawned = Math.abs(f.z[i]! - lastZ[i]!) > 20;
|
||||
lastZ[i] = f.z[i]!;
|
||||
if (on === 1 && brakeWas[i] === 0 && !respawned) {
|
||||
expect(time - lastOnset[i]!).toBeGreaterThanOrEqual(BRAKE_MIN_SPACING_SECONDS - 1e-6);
|
||||
lastOnset[i] = time;
|
||||
brakeOnAt[i] = time;
|
||||
}
|
||||
if (on === 0 && brakeWas[i] === 1 && f.active[i] !== TRAFFIC_PENDING && !respawned) {
|
||||
// The lamp has ramped down to zero: it was held on for the minimum first.
|
||||
expect(time - brakeOnAt[i]!).toBeGreaterThanOrEqual(BRAKE_MIN_ON_SECONDS);
|
||||
}
|
||||
brakeWas[i] = on;
|
||||
}
|
||||
}
|
||||
for (let k = BRAKE_GLOBAL_PER_SECOND; k < onsetsAt.length; k += 1) {
|
||||
expect(onsetsAt[k]! - onsetsAt[k - BRAKE_GLOBAL_PER_SECOND]!).toBeGreaterThanOrEqual(1 - 1e-6);
|
||||
}
|
||||
const events = model.events();
|
||||
expect(events.laneChanges, `seed ${seed} lane changes`).toBeGreaterThan(0);
|
||||
expect(events.passes, `seed ${seed} passes`).toBeGreaterThan(0);
|
||||
expect(events.brakeOnsets, `seed ${seed} brake onsets`).toBeGreaterThan(0);
|
||||
expect(events.emergencyExits, `seed ${seed} emergency exits`).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('traffic model: adversarial', () => {
|
||||
/** Drive the model until a car is in the centre lane, then return it. */
|
||||
function withCentreCar(config: TrafficConfig) {
|
||||
const model = createTrafficModel(config);
|
||||
const step = input({ chapter: 'infrastructure' });
|
||||
for (let t = 0; t < 400; t += 1 / 60) {
|
||||
const f = model.step(step);
|
||||
for (let i = 0; i < f.count; i += 1) {
|
||||
if (drawn(f, i) && Math.abs(f.x[i]!) < 0.5) return { model, step, index: i };
|
||||
}
|
||||
}
|
||||
throw new Error('no overtake in 400 s');
|
||||
}
|
||||
|
||||
it('a right-lane car pulls into the centre to pass a truck at idle', () => {
|
||||
const { model, index } = withCentreCar({ pool: 12, seed: 3, lite: false });
|
||||
expect(model.frame.kind[index]).toBe(0);
|
||||
expect(model.frame.z[index]!).toBeLessThan(-150);
|
||||
});
|
||||
|
||||
it('an instant step to 120 u/s with a car in the centre still never overlaps', () => {
|
||||
for (const seed of [3, 11, 29, 41]) {
|
||||
const { model, step, index } = withCentreCar({ pool: 12, seed, lite: false });
|
||||
step.heroSpeed = 120;
|
||||
for (let t = 0; t < 12; t += 1 / 60) {
|
||||
const f = model.step(step);
|
||||
expect(violation(f)).toBeNull();
|
||||
}
|
||||
expect(Math.abs(model.frame.x[index]!) > 4 || model.frame.active[index] !== TRAFFIC_VISIBLE).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('0.1 s frames at 120 u/s keep every invariant', () => {
|
||||
const model = createTrafficModel({ pool: 12, seed: 5, lite: false });
|
||||
const step = input({ chapter: 'infrastructure', dt: 0.1, heroSpeed: 120 });
|
||||
for (let k = 0; k < 3000; k += 1) {
|
||||
step.heroSpeed = k % 40 < 20 ? 120 : CRUISE;
|
||||
expect(violation(model.step(step))).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('clamps dt and ignores non-finite input', () => {
|
||||
const model = createTrafficModel({ pool: 10, seed: 9, lite: false });
|
||||
model.step(input());
|
||||
const before = Array.from(model.frame.z);
|
||||
model.step(input({ dt: Number.NaN, heroSpeed: Number.NaN, cameraX: Number.POSITIVE_INFINITY, engineOff: Number.NaN }));
|
||||
expect(Array.from(model.frame.z)).toEqual(before);
|
||||
model.step(input({ dt: 5 }));
|
||||
for (let i = 0; i < model.frame.count; i += 1) {
|
||||
if (model.frame.active[i] === TRAFFIC_PENDING) continue;
|
||||
// At most one 0.1 s step of relative motion (40 u/s fast lane - 27.7 + jitter).
|
||||
expect(Math.abs(model.frame.z[i]! - before[i]!)).toBeLessThan(4);
|
||||
}
|
||||
expect(violation(model.frame)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('traffic model: determinism and allocation', () => {
|
||||
it('the same seed and inputs give the same frames', () => {
|
||||
const run = () => {
|
||||
const model = createTrafficModel({ pool: 12, seed: 77, lite: false });
|
||||
const out: number[] = [];
|
||||
for (let k = 0; k < 3600; k += 1) {
|
||||
const f = model.step(input({ heroSpeed: k % 600 < 300 ? CRUISE : 90, chapter: k < 1800 ? 'systems' : 'infrastructure' }));
|
||||
if (k % 60 === 0) out.push(...f.z, ...f.x, ...f.active);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
expect(run()).toEqual(run());
|
||||
});
|
||||
|
||||
it('returns the same preallocated frame every step', () => {
|
||||
const model = createTrafficModel({ pool: 6, seed: 2, lite: true });
|
||||
const first = model.step(input());
|
||||
const z = first.z;
|
||||
for (let k = 0; k < 600; k += 1) expect(model.step(input())).toBe(first);
|
||||
expect(first.z).toBe(z);
|
||||
expect(model.frame).toBe(first);
|
||||
});
|
||||
|
||||
it('freezes its tables', () => {
|
||||
expect(Object.isFrozen(TRAFFIC_LANE_X)).toBe(true);
|
||||
expect(Object.isFrozen(TRAFFIC_KINDS)).toBe(true);
|
||||
expect(Object.isFrozen(TRAFFIC_DENSITY)).toBe(true);
|
||||
expect(Object.isFrozen(TRAFFIC_DENSITY.infrastructure)).toBe(true);
|
||||
});
|
||||
|
||||
it('reset replays the run', () => {
|
||||
const model = createTrafficModel({ pool: 10, seed: 13, lite: false });
|
||||
const take = () => {
|
||||
for (let k = 0; k < 1200; k += 1) model.step(input({ chapter: 'builds' }));
|
||||
return Array.from(model.frame.z);
|
||||
};
|
||||
const a = take();
|
||||
model.reset();
|
||||
expect(take()).toEqual(a);
|
||||
});
|
||||
});
|
||||
|
||||
describe('traffic model: density', () => {
|
||||
const visible = (f: TrafficFrame, side: number, truck?: boolean): number => {
|
||||
let count = 0;
|
||||
for (let i = 0; i < f.count; i += 1) {
|
||||
if (f.active[i] !== TRAFFIC_VISIBLE) continue;
|
||||
if (Math.sign(f.x[i]!) !== side) continue;
|
||||
if (truck !== undefined && (f.kind[i] === TRAFFIC_TRUCK) !== truck) continue;
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
it('converges to each chapter target within 30 s at cruise', () => {
|
||||
for (const chapter of CHAPTERS) {
|
||||
const model = createTrafficModel({ pool: 12, seed: 21, lite: false });
|
||||
let f = model.frame;
|
||||
for (let t = 0; t < 30; t += 1 / 60) f = model.step(input({ chapter }));
|
||||
const want = TRAFFIC_DENSITY[chapter];
|
||||
// Vehicles mid-pass on the hero's side may sit in the centre, so allow one short.
|
||||
expect(visible(f, -1), `${chapter} left`).toBeGreaterThanOrEqual(want.left - 1);
|
||||
expect(visible(f, -1), `${chapter} left`).toBeLessThanOrEqual(want.left + 1);
|
||||
expect(visible(f, 1) + visible(f, 0), `${chapter} right`).toBeGreaterThanOrEqual(want.right - 1);
|
||||
}
|
||||
});
|
||||
|
||||
it('never despawns a drawn vehicle on screen when the chapter thins out', () => {
|
||||
const model = createTrafficModel({ pool: 12, seed: 4, lite: false });
|
||||
for (let t = 0; t < 30; t += 1 / 60) model.step(input({ chapter: 'infrastructure' }));
|
||||
for (let t = 0; t < 20; t += 1 / 60) {
|
||||
const before = Array.from(model.frame.active);
|
||||
const z = Array.from(model.frame.z);
|
||||
const f = model.step(input({ chapter: 'destination' }));
|
||||
for (let i = 0; i < f.count; i += 1) {
|
||||
if (before[i] === TRAFFIC_VISIBLE && f.active[i] === TRAFFIC_PENDING) {
|
||||
// Leaving is only allowed at the far haze or behind the camera.
|
||||
expect(z[i]! < -600 || z[i]! - size(f, i).length > TRAFFIC_EXIT_NOSE_Z - 1).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('empties the road once the engine is off', () => {
|
||||
const model = createTrafficModel({ pool: 12, seed: 8, lite: false });
|
||||
for (let t = 0; t < 120; t += 1 / 60) model.step(input({ chapter: 'destination', engineOff: 1, heroSpeed: 0 }));
|
||||
// Nothing new enters; what is left drives off.
|
||||
const events = model.events();
|
||||
let drawnNow = 0;
|
||||
for (let i = 0; i < model.frame.count; i += 1) if (drawn(model.frame, i)) drawnNow += 1;
|
||||
expect(drawnNow).toBeLessThanOrEqual(2);
|
||||
expect(events.laneChanges).toBe(0);
|
||||
});
|
||||
|
||||
it('the low tier caps trucks and lanes', () => {
|
||||
const model = createTrafficModel({ pool: 6, seed: 6, lite: true });
|
||||
let maxTrucks = 0;
|
||||
let maxRight = 0;
|
||||
for (let t = 0; t < 300; t += 1 / 60) {
|
||||
const f = model.step(input({ chapter: 'infrastructure', heroSpeed: t % 40 < 10 ? 90 : CRUISE }));
|
||||
maxTrucks = Math.max(maxTrucks, visible(f, 1, true) + visible(f, -1, true));
|
||||
maxRight = Math.max(maxRight, visible(f, 1));
|
||||
}
|
||||
expect(maxTrucks).toBeLessThanOrEqual(2);
|
||||
expect(maxRight).toBeLessThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('traffic model: interaction', () => {
|
||||
it('a flash sends a centre-lane car back to its lane', () => {
|
||||
for (const seed of [3, 11, 29]) {
|
||||
const model = createTrafficModel({ pool: 12, seed, lite: false });
|
||||
const step = input({ chapter: 'infrastructure' });
|
||||
let index = -1;
|
||||
for (let t = 0; t < 400 && index < 0; t += 1 / 60) {
|
||||
const f = model.step(step);
|
||||
for (let i = 0; i < f.count; i += 1) if (drawn(f, i) && Math.abs(f.x[i]!) < 0.5 && f.z[i]! > -320) index = i;
|
||||
}
|
||||
if (index < 0) continue;
|
||||
model.step(input({ chapter: 'infrastructure', flash: true }));
|
||||
for (let t = 0; t < 2.5; t += 1 / 60) model.step(step);
|
||||
expect(Math.abs(model.frame.x[index]!), `seed ${seed}`).toBeGreaterThan(4);
|
||||
return;
|
||||
}
|
||||
throw new Error('no centre-lane car within flash reach');
|
||||
});
|
||||
|
||||
it('sweeping starts no manoeuvre', () => {
|
||||
const model = createTrafficModel({ pool: 12, seed: 3, lite: false });
|
||||
for (let t = 0; t < 300; t += 1 / 60) model.step(input({ chapter: 'infrastructure', sweeping: true }));
|
||||
expect(model.events().laneChanges).toBe(0);
|
||||
});
|
||||
|
||||
it('nothing in the centre lane survives past the deadline', () => {
|
||||
const model = createTrafficModel({ pool: 12, seed: 17, lite: false });
|
||||
for (let t = 0; t < 600; t += 1 / 60) {
|
||||
const f = model.step(input({ chapter: 'infrastructure', heroSpeed: Math.floor(t / 7) % 2 ? 110 : CRUISE }));
|
||||
for (let i = 0; i < f.count; i += 1) {
|
||||
if (f.active[i] !== TRAFFIC_VISIBLE) continue;
|
||||
if (Math.abs(f.x[i]!) < HERO_HALF_WIDTH + size(f, i).halfWidth) expect(f.z[i]!).toBeLessThan(CENTRE_DEADLINE_Z + 160);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('traffic model: idle choreography', () => {
|
||||
it('a left-lane car passes the reader within 60 s and a right-lane truck is passed', () => {
|
||||
const model = createTrafficModel({ pool: 12, seed: 1, lite: false });
|
||||
const mid = new Float64Array(12).fill(Number.NaN);
|
||||
let leftPass = false;
|
||||
let truckPassed = false;
|
||||
for (let t = 0; t < 90; t += 1 / 60) {
|
||||
const f = model.step(input({ chapter: 'deep-night' }));
|
||||
for (let i = 0; i < f.count; i += 1) {
|
||||
if (f.active[i] === TRAFFIC_PENDING) {
|
||||
mid[i] = Number.NaN;
|
||||
continue;
|
||||
}
|
||||
const now = f.z[i]! - size(f, i).length / 2;
|
||||
const was = mid[i]!;
|
||||
if (Number.isFinite(was)) {
|
||||
if (t < 60 && f.x[i]! < -3 && was > 2 && now <= 2) leftPass = true;
|
||||
if (f.kind[i] === TRAFFIC_TRUCK && f.x[i]! > 3 && was < 2 && now >= 2) truckPassed = true;
|
||||
}
|
||||
mid[i] = now;
|
||||
}
|
||||
}
|
||||
expect(leftPass).toBe(true);
|
||||
expect(truckPassed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('traffic model: lamps', () => {
|
||||
it('indicators blink at 1.5 Hz with a 40 % duty', () => {
|
||||
const period = 1 / INDICATOR_HZ;
|
||||
let on = 0;
|
||||
let rises = 0;
|
||||
let was = 0;
|
||||
const samples = 6000;
|
||||
for (let k = 0; k < samples; k += 1) {
|
||||
const level = indicatorBlink((k / samples) * period * 4);
|
||||
if (level > 0.5) on += 1;
|
||||
if (level > 0.5 && was <= 0.5) rises += 1;
|
||||
was = level;
|
||||
}
|
||||
expect(on / samples).toBeCloseTo(INDICATOR_DUTY, 1);
|
||||
expect(rises).toBe(4);
|
||||
expect(indicatorBlink(0.02)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('the high-mount stop lamp is dark at steady cruise', () => {
|
||||
const model = createTrafficModel({ pool: 12, seed: 31, lite: false });
|
||||
for (let t = 0; t < 30; t += 1 / 60) model.step(input({ chapter: 'deep-night' }));
|
||||
let lit = 0;
|
||||
for (let i = 0; i < model.frame.count; i += 1) if (drawn(model.frame, i) && model.frame.brake[i]! > 0.5) lit += 1;
|
||||
expect(lit).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('a queue behind a truck lights brake lamps, with hysteresis', () => {
|
||||
const model = createTrafficModel({ pool: 12, seed: 2, lite: false });
|
||||
for (let t = 0; t < 120; t += 1 / 60) model.step(input({ chapter: 'infrastructure' }));
|
||||
expect(model.events().brakeOnsets).toBeGreaterThan(0);
|
||||
// Brake levels stay in range.
|
||||
for (let i = 0; i < model.frame.count; i += 1) {
|
||||
expect(model.frame.brake[i]!).toBeGreaterThanOrEqual(0);
|
||||
expect(model.frame.brake[i]!).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('traffic model: near limit', () => {
|
||||
it('latches a camera-side truck to the near band and leaves cars open', () => {
|
||||
const model = createTrafficModel({ pool: 12, seed: 12, lite: false });
|
||||
// Camera over the right lane: right-lane trucks are camera side.
|
||||
let latched = false;
|
||||
for (let t = 0; t < 200 && !latched; t += 1 / 60) {
|
||||
const f = model.step(input({ chapter: 'infrastructure', cameraX: 4.5 }));
|
||||
for (let i = 0; i < f.count; i += 1) {
|
||||
if (!drawn(f, i)) continue;
|
||||
if (f.kind[i] !== TRAFFIC_TRUCK) expect(f.nearLimit[i]).toBe(TRAFFIC_NEAR_OPEN);
|
||||
else if (f.x[i]! > 3 && f.z[i]! > -40) {
|
||||
expect(f.nearLimit[i]).toBe(TRAFFIC_NEAR_CAMERA_SIDE);
|
||||
latched = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(latched).toBe(true);
|
||||
});
|
||||
|
||||
it('a truck on the far side of the camera stays open', () => {
|
||||
const model = createTrafficModel({ pool: 12, seed: 12, lite: false });
|
||||
for (let t = 0; t < 200; t += 1 / 60) {
|
||||
const f = model.step(input({ chapter: 'infrastructure', cameraX: -5.6 }));
|
||||
for (let i = 0; i < f.count; i += 1) {
|
||||
if (drawn(f, i) && f.x[i]! > 3) expect(f.nearLimit[i]).toBe(TRAFFIC_NEAR_OPEN);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps every lane on the asphalt and clear of the hero', () => {
|
||||
for (const side of [0, 2]) {
|
||||
for (const kind of TRAFFIC_KINDS) {
|
||||
const x = TRAFFIC_LANE_X[side]!;
|
||||
expect(Math.abs(x) + kind.halfWidth + 0.1).toBeLessThanOrEqual(TRAFFIC_ASPHALT_HALF_WIDTH);
|
||||
expect(Math.abs(x) - kind.halfWidth - 0.1).toBeGreaterThanOrEqual(HERO_HALF_WIDTH);
|
||||
}
|
||||
}
|
||||
expect(HERO_TAIL_Z - HERO_NOSE_Z).toBe(16);
|
||||
});
|
||||
});
|
||||
|
||||
describe('traffic model: the interaction is in frame', () => {
|
||||
/** Centre-lane entries and exits (rear z) of right-lane cars over a cruise. */
|
||||
function passes(config: TrafficConfig, seconds: number) {
|
||||
const model = createTrafficModel(config);
|
||||
const step = input({ chapter: 'infrastructure' });
|
||||
const prev = new Float64Array(config.pool);
|
||||
const entries: number[] = [];
|
||||
const exits: number[] = [];
|
||||
for (let t = 0; t < seconds; t += 1 / 60) {
|
||||
const f = model.step(step);
|
||||
for (let i = 0; i < f.count; i += 1) {
|
||||
const x = f.active[i] === TRAFFIC_VISIBLE ? f.x[i]! : 9;
|
||||
if (prev[i]! > 3 && x <= 3) entries.push(f.z[i]!);
|
||||
if (Math.abs(prev[i]!) <= 3 && Math.abs(x) > 3 && x !== 9) exits.push(f.z[i]!);
|
||||
prev[i] = x;
|
||||
}
|
||||
}
|
||||
return { entries, exits };
|
||||
}
|
||||
|
||||
it('overtakes start 150-215 m ahead and end before the deadline, on every tier', () => {
|
||||
const configs: TrafficConfig[] = [
|
||||
{ pool: 12, seed: 3, lite: false },
|
||||
{ pool: 12, seed: 9, lite: false },
|
||||
{ pool: 6, seed: 5, lite: true },
|
||||
{ pool: 6, seed: 8, lite: true },
|
||||
];
|
||||
for (const config of configs) {
|
||||
const { entries, exits } = passes(config, 240);
|
||||
expect(entries.length, `seed ${config.seed} entries`).toBeGreaterThanOrEqual(2);
|
||||
for (const z of entries) {
|
||||
expect(z, `seed ${config.seed} entry`).toBeGreaterThan(-215);
|
||||
expect(z, `seed ${config.seed} entry`).toBeLessThan(CENTRE_ENTRY_Z + 20);
|
||||
}
|
||||
for (const z of exits) expect(z, `seed ${config.seed} exit`).toBeLessThan(CENTRE_DEADLINE_Z + 1);
|
||||
}
|
||||
});
|
||||
|
||||
it('a right car joining a truck in the haze fades in, in the haze, and the road refills from the near entry', () => {
|
||||
const model = createTrafficModel({ pool: 6, seed: 5, lite: true });
|
||||
const step = input({ chapter: 'infrastructure' });
|
||||
const seen = new Uint8Array(6);
|
||||
let faded = 0;
|
||||
const fading = new Float64Array(6).fill(-1);
|
||||
for (let t = 0; t < 400; t += 1 / 60) {
|
||||
const f = model.step(step);
|
||||
for (let i = 0; i < f.count; i += 1) {
|
||||
if (f.active[i] !== TRAFFIC_VISIBLE) {
|
||||
seen[i] = 0;
|
||||
continue;
|
||||
}
|
||||
if (!seen[i]) {
|
||||
seen[i] = 1;
|
||||
if (t === 0) continue;
|
||||
if (f.z[i]! > TRAFFIC_ENTRY_FAR_Z + 1 && f.z[i]! < TRAFFIC_EXIT_NOSE_Z - 1) {
|
||||
// Nearer than the far entry: screen-doored in, still in the haze.
|
||||
expect(f.z[i]!, `vehicle ${i} at ${t.toFixed(1)} s`).toBeLessThan(TRAFFIC_ENTRY_NEAR_Z + 2);
|
||||
expect(f.z[i]!).toBeGreaterThan(TRAFFIC_ENTRY_NEAR_Z - TRAFFIC_ENTRY_NEAR_SPAN - 50);
|
||||
expect(f.dither[i]!).toBeGreaterThan(0.9);
|
||||
fading[i] = t;
|
||||
faded += 1;
|
||||
} else expect(f.dither[i]!).toBe(0);
|
||||
} else if (fading[i]! >= 0) {
|
||||
if (t - fading[i]! > FADE_IN_SECONDS + 0.05) {
|
||||
expect(f.dither[i]!).toBe(0);
|
||||
fading[i] = -1;
|
||||
} else expect(f.dither[i]!).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(faded).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
it('a yield the reader forces is a swerve that clears the corridor in under a second', () => {
|
||||
for (const seed of [3, 11, 29]) {
|
||||
const model = createTrafficModel({ pool: 12, seed, lite: false });
|
||||
const step = input({ chapter: 'infrastructure' });
|
||||
let index = -1;
|
||||
for (let t = 0; t < 400 && index < 0; t += 1 / 60) {
|
||||
const f = model.step(step);
|
||||
for (let i = 0; i < f.count; i += 1) if (drawn(f, i) && Math.abs(f.x[i]!) < 0.3 && f.z[i]! > -200) index = i;
|
||||
}
|
||||
if (index < 0) continue;
|
||||
step.heroSpeed = 120;
|
||||
let clearAt = -1;
|
||||
for (let t = 0; t < 3 && clearAt < 0; t += 1 / 60) {
|
||||
const f = model.step(step);
|
||||
expect(violation(f)).toBeNull();
|
||||
if (Math.abs(f.x[index]!) >= 4.47 || f.active[index] !== TRAFFIC_VISIBLE) clearAt = t;
|
||||
}
|
||||
expect(clearAt, `seed ${seed}`).toBeGreaterThanOrEqual(0);
|
||||
expect(clearAt, `seed ${seed}`).toBeLessThan(FORCED_LANE_CHANGE_SECONDS * 0.71 + 0.25);
|
||||
expect(model.events().emergencyExits).toBe(0);
|
||||
return;
|
||||
}
|
||||
throw new Error('no centre-lane car to force');
|
||||
});
|
||||
});
|
||||
|
||||
describe('flash detector', () => {
|
||||
it('ignores the intro sweep (a climb from dark) and fires on a flash from the dipped beam', () => {
|
||||
const detect = createFlashDetector();
|
||||
const fired: number[] = [];
|
||||
// Sweep: 0 -> 1 over 96 frames, held at 1, then the fall to the dipped 0.72.
|
||||
for (let k = 0; k <= 96; k += 1) if (detect(k / 96)) fired.push(k);
|
||||
for (let k = 0; k < 5; k += 1) if (detect(1)) fired.push(100 + k);
|
||||
let i = 1;
|
||||
for (let k = 0; k < 120; k += 1) {
|
||||
i = 0.72 + (i - 0.72) * 0.9;
|
||||
if (detect(i)) fired.push(200 + k);
|
||||
}
|
||||
expect(fired).toEqual([]);
|
||||
// A flash: 0.72 -> 1 (rise), hold, fall.
|
||||
i = 0.72;
|
||||
for (let k = 0; k < 30; k += 1) {
|
||||
i = 1 + (i - 1) * 0.8;
|
||||
if (detect(i)) fired.push(300 + k);
|
||||
}
|
||||
for (let k = 0; k < 25; k += 1) if (detect(1)) fired.push(400 + k);
|
||||
expect(fired.length).toBe(1);
|
||||
// Nothing more until it has fallen back below 0.85.
|
||||
for (let k = 0; k < 20; k += 1) {
|
||||
i = 0.9 + (i - 0.9) * 0.8;
|
||||
if (detect(i)) fired.push(500 + k);
|
||||
}
|
||||
expect(fired.length).toBe(1);
|
||||
for (let k = 0; k < 60; k += 1) {
|
||||
i = 0.72 + (i - 0.72) * 0.8;
|
||||
if (detect(i)) fired.push(600 + k);
|
||||
}
|
||||
for (let k = 0; k < 30; k += 1) {
|
||||
i = 1 + (i - 1) * 0.8;
|
||||
if (detect(i)) fired.push(700 + k);
|
||||
}
|
||||
expect(fired.length).toBe(2);
|
||||
});
|
||||
});
|
||||
78
tests/unit/traffic-pass.test.ts
Normal file
78
tests/unit/traffic-pass.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { createPulseTap, readSwitchboard, setMusicPulseAudible } from '@/audio/music-pulse';
|
||||
import { TRAFFIC_PASS_GAP_SECONDS, TRAFFIC_PASS_PAN, playTrafficPass } from '@/audio/traffic-pass';
|
||||
|
||||
/** A minimal audio graph that records what the swell builds. */
|
||||
function fakeContext() {
|
||||
const pans: number[] = [];
|
||||
const param = () => ({ setValueAtTime: (v: number) => pans.push(v), exponentialRampToValueAtTime: () => undefined });
|
||||
const node = (extra: object = {}) => ({ connect: () => undefined, disconnect: () => undefined, ...extra });
|
||||
const context = {
|
||||
state: 'running',
|
||||
currentTime: 10,
|
||||
sampleRate: 8000,
|
||||
started: 0,
|
||||
createGain: () => node({ context, gain: { setValueAtTime: () => undefined, exponentialRampToValueAtTime: () => undefined } }),
|
||||
createAnalyser: () => {
|
||||
throw new Error('no analyser');
|
||||
},
|
||||
createBuffer: (_channels: number, length: number) => ({ sampleRate: 8000, getChannelData: () => new Float32Array(length) }),
|
||||
createBufferSource: () =>
|
||||
node({
|
||||
buffer: null,
|
||||
onended: null,
|
||||
start: () => {
|
||||
context.started += 1;
|
||||
},
|
||||
stop: () => undefined,
|
||||
}),
|
||||
createBiquadFilter: () =>
|
||||
node({
|
||||
type: '',
|
||||
Q: { setValueAtTime: () => undefined },
|
||||
frequency: { setValueAtTime: () => undefined, exponentialRampToValueAtTime: () => undefined },
|
||||
}),
|
||||
createStereoPanner: () => node({ pan: param() }),
|
||||
};
|
||||
return { context, pans };
|
||||
}
|
||||
|
||||
describe('traffic pass swell', () => {
|
||||
afterEach(() => setMusicPulseAudible(false));
|
||||
|
||||
it('is silent with no audible soundtrack', () => {
|
||||
expect(readSwitchboard()).toBeNull();
|
||||
expect(playTrafficPass(1)).toBe(false);
|
||||
});
|
||||
|
||||
it('plays once per gap, panned to the passing side', () => {
|
||||
const { context, pans } = fakeContext();
|
||||
createPulseTap(context as unknown as BaseAudioContext, { connect: () => undefined } as unknown as AudioNode);
|
||||
setMusicPulseAudible(true);
|
||||
expect(playTrafficPass(-1)).toBe(true);
|
||||
expect(pans).toContain(-TRAFFIC_PASS_PAN);
|
||||
expect(playTrafficPass(1)).toBe(false);
|
||||
context.currentTime += TRAFFIC_PASS_GAP_SECONDS + 0.01;
|
||||
expect(playTrafficPass(1)).toBe(true);
|
||||
expect(pans).toContain(TRAFFIC_PASS_PAN);
|
||||
expect(context.started).toBe(2);
|
||||
});
|
||||
|
||||
it('never plays on a suspended context', () => {
|
||||
const { context } = fakeContext();
|
||||
context.state = 'suspended';
|
||||
createPulseTap(context as unknown as BaseAudioContext, { connect: () => undefined } as unknown as AudioNode);
|
||||
setMusicPulseAudible(true);
|
||||
expect(playTrafficPass(1)).toBe(false);
|
||||
});
|
||||
|
||||
it('stays out of the initial bundle: only the scene chunk imports it, lazily, never on lite', () => {
|
||||
const environment = readFileSync(resolve(process.cwd(), 'src/scene/world/environment.ts'), 'utf8');
|
||||
expect(environment).toContain("import('@/audio/traffic-pass')");
|
||||
expect(environment).not.toMatch(/from '@\/audio\/traffic-pass'/u);
|
||||
const budgets = readFileSync(resolve(process.cwd(), 'scripts/lib/bundle-budgets.mjs'), 'utf8');
|
||||
expect(budgets).toContain('traffic-pass-');
|
||||
});
|
||||
});
|
||||
@@ -1,72 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
HERO_CAR_HALF_WIDTH,
|
||||
LAMPS_PER_VEHICLE,
|
||||
TRAFFIC_BODY,
|
||||
TRAFFIC_FADE_LENGTH,
|
||||
TRAFFIC_LANE_JITTER,
|
||||
TRAFFIC_LANES,
|
||||
TRAFFIC_NEAR_Z,
|
||||
trafficLamps,
|
||||
trafficNearFade,
|
||||
wrapTrafficZ,
|
||||
trafficCameraSide,
|
||||
trafficPoolSize,
|
||||
} from '@/scene/world/traffic';
|
||||
import {
|
||||
HERO_HALF_WIDTH,
|
||||
TRAFFIC_ASPHALT_HALF_WIDTH,
|
||||
TRAFFIC_KINDS,
|
||||
TRAFFIC_LANE_JITTER,
|
||||
TRAFFIC_LANE_X,
|
||||
TRAFFIC_NEAR_CAMERA_SIDE,
|
||||
TRAFFIC_NEAR_OPEN,
|
||||
} from '@/scene/world/traffic-model';
|
||||
|
||||
const kinds = ['car', 'truck'] as const;
|
||||
const sideLanes = [TRAFFIC_LANE_X[0]!, TRAFFIC_LANE_X[2]!];
|
||||
|
||||
describe('expressway traffic stays clear of the hero car', () => {
|
||||
it('keeps every body outside the hero car, with lane jitter', () => {
|
||||
for (const lane of TRAFFIC_LANES) {
|
||||
const inner = Math.abs(lane.x) - TRAFFIC_BODY[lane.kind].halfWidth - TRAFFIC_LANE_JITTER;
|
||||
expect(inner).toBeGreaterThan(HERO_CAR_HALF_WIDTH);
|
||||
}
|
||||
it('agrees with the model on the hero envelope', () => {
|
||||
expect(HERO_CAR_HALF_WIDTH).toBe(HERO_HALF_WIDTH);
|
||||
});
|
||||
|
||||
it('never lets bodies in adjacent lanes overlap, and keeps them on the road', () => {
|
||||
const spans = TRAFFIC_LANES.map((lane) => {
|
||||
const half = TRAFFIC_BODY[lane.kind].halfWidth + TRAFFIC_LANE_JITTER;
|
||||
return [lane.x - half, lane.x + half] as const;
|
||||
}).sort((a, b) => a[0] - b[0]);
|
||||
for (let i = 1; i < spans.length; i++) expect(spans[i]![0]).toBeGreaterThan(spans[i - 1]![1]);
|
||||
for (const [left, right] of spans) {
|
||||
expect(left).toBeGreaterThan(-8);
|
||||
expect(right).toBeLessThan(8);
|
||||
}
|
||||
it('scales the 1x bodies to the model sizes', () => {
|
||||
kinds.forEach((kind, k) => {
|
||||
const size = TRAFFIC_KINDS[k]!;
|
||||
expect(TRAFFIC_BODY[kind].halfWidth * size.scale).toBeCloseTo(size.halfWidth, 1);
|
||||
expect(TRAFFIC_BODY[kind].roof * size.scale).toBeCloseTo(size.roof, 1);
|
||||
// The truck's length runs from the under-run bar to the cab's nose.
|
||||
expect(size.length).toBeGreaterThanOrEqual(TRAFFIC_BODY[kind].length * size.scale - 0.01);
|
||||
});
|
||||
});
|
||||
|
||||
it('runs each lane at one speed, so no vehicle drives through another', () => {
|
||||
const xs = TRAFFIC_LANES.map((lane) => lane.x);
|
||||
expect(new Set(xs).size).toBe(xs.length);
|
||||
it('keeps every side-lane body outside the hero car and on the asphalt, with lane jitter', () => {
|
||||
for (const x of sideLanes) {
|
||||
for (const size of TRAFFIC_KINDS) {
|
||||
expect(Math.abs(x) - size.halfWidth - TRAFFIC_LANE_JITTER).toBeGreaterThan(HERO_CAR_HALF_WIDTH);
|
||||
expect(Math.abs(x) + size.halfWidth + TRAFFIC_LANE_JITTER).toBeLessThan(TRAFFIC_ASPHALT_HALF_WIDTH);
|
||||
}
|
||||
}
|
||||
expect(TRAFFIC_LANE_X[1]).toBe(0);
|
||||
});
|
||||
|
||||
it('puts every lamp on the vehicle body', () => {
|
||||
for (const kind of ['car', 'truck'] as const) {
|
||||
const lamps = trafficLamps(kind, 0.5);
|
||||
expect(lamps.length).toBeGreaterThanOrEqual(3);
|
||||
for (const lamp of lamps) expect(Math.abs(lamp.x) + lamp.w / 2).toBeLessThanOrEqual(TRAFFIC_BODY[kind].halfWidth + 0.01);
|
||||
for (const kind of kinds) {
|
||||
const lamps = trafficLamps(kind);
|
||||
expect(lamps.length).toBeGreaterThanOrEqual(5);
|
||||
expect(lamps.length).toBeLessThanOrEqual(LAMPS_PER_VEHICLE);
|
||||
for (const lamp of lamps) {
|
||||
expect(Math.abs(lamp.x) + lamp.w / 2).toBeLessThanOrEqual(TRAFFIC_BODY[kind].halfWidth + 0.01);
|
||||
expect(lamp.y + lamp.h / 2).toBeLessThanOrEqual(TRAFFIC_BODY[kind].roof + 0.01);
|
||||
}
|
||||
}
|
||||
// A readable pair: two tail lamps mirrored at one height.
|
||||
const [left, right] = trafficLamps('car', 0);
|
||||
const [left, right] = trafficLamps('car');
|
||||
expect(left!.x).toBe(-right!.x);
|
||||
expect(left!.y).toBe(right!.y);
|
||||
});
|
||||
|
||||
it('is fully dark before the car near field and lit beyond the fade', () => {
|
||||
expect(trafficNearFade(TRAFFIC_NEAR_Z)).toBe(0);
|
||||
expect(trafficNearFade(0)).toBe(0);
|
||||
expect(trafficNearFade(40)).toBe(0);
|
||||
expect(trafficNearFade(TRAFFIC_NEAR_Z - TRAFFIC_FADE_LENGTH)).toBe(1);
|
||||
expect(trafficNearFade(-300)).toBe(1);
|
||||
// The car's nose is at about z = -5.5: the near field starts well ahead.
|
||||
expect(TRAFFIC_NEAR_Z).toBeLessThan(-18);
|
||||
});
|
||||
|
||||
it('wraps vehicles inside the hidden band at any scroll speed', () => {
|
||||
// 110 units/s at a 0.1 s frame clamp is 11 units per step; test far more.
|
||||
for (const step of [0.5, 11, 64, 900, -11, -900]) {
|
||||
let z = -200;
|
||||
for (let i = 0; i < 400; i++) {
|
||||
z = wrapTrafficZ(z + step);
|
||||
expect(z).toBeLessThanOrEqual(TRAFFIC_NEAR_Z);
|
||||
expect(z).toBeGreaterThanOrEqual(-660);
|
||||
it('gives every vehicle a left and a right indicator beside, not on, its tail lamps', () => {
|
||||
for (const kind of kinds) {
|
||||
const lamps = trafficLamps(kind);
|
||||
const indicators = lamps.filter((lamp) => lamp.role === 'indicator');
|
||||
expect(indicators.map((lamp) => lamp.side).sort()).toEqual([-1, 1]);
|
||||
for (const indicator of indicators) {
|
||||
expect(Math.sign(indicator.x)).toBe(indicator.side);
|
||||
for (const tail of lamps.filter((lamp) => lamp.role === 'tail')) {
|
||||
if (Math.sign(tail.x) !== indicator.side) continue;
|
||||
const apart = Math.abs(indicator.x - tail.x) - (indicator.w + tail.w) / 2;
|
||||
expect(apart).toBeGreaterThanOrEqual(-0.005);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only cars carry a high-mount stop lamp; trucks carry clearance lamps.
|
||||
expect(trafficLamps('car').filter((lamp) => lamp.role === 'stop')).toHaveLength(1);
|
||||
expect(trafficLamps('truck').filter((lamp) => lamp.role === 'clearance')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('fades only before a near limit', () => {
|
||||
expect(trafficNearFade(TRAFFIC_NEAR_CAMERA_SIDE, TRAFFIC_NEAR_CAMERA_SIDE)).toBe(0);
|
||||
expect(trafficNearFade(0, TRAFFIC_NEAR_CAMERA_SIDE)).toBe(0);
|
||||
expect(trafficNearFade(TRAFFIC_NEAR_CAMERA_SIDE - TRAFFIC_FADE_LENGTH, TRAFFIC_NEAR_CAMERA_SIDE)).toBe(1);
|
||||
expect(trafficNearFade(-300, TRAFFIC_NEAR_CAMERA_SIDE)).toBe(1);
|
||||
// Open vehicles stay lit all the way past the hero and under the camera.
|
||||
expect(trafficNearFade(20, TRAFFIC_NEAR_OPEN)).toBe(1);
|
||||
});
|
||||
|
||||
it('dissolves only the lane beneath an off-centre chase camera', () => {
|
||||
expect(trafficCameraSide(-5.4, -5.6)).toBe(true);
|
||||
expect(trafficCameraSide(5.4, -5.6)).toBe(false);
|
||||
expect(trafficCameraSide(5.4, 4.5)).toBe(true);
|
||||
// A centred camera, or a car in the centre lane, never.
|
||||
expect(trafficCameraSide(-5.4, 0)).toBe(false);
|
||||
expect(trafficCameraSide(-1, -5.6)).toBe(false);
|
||||
expect(trafficCameraSide(-5.4, Number.NaN)).toBe(false);
|
||||
});
|
||||
|
||||
it('sizes the pool per tier', () => {
|
||||
expect(trafficPoolSize({ postEffects: true, postScale: 1 })).toBe(12);
|
||||
expect(trafficPoolSize({ postEffects: true, postScale: 0.75 })).toBe(10);
|
||||
expect(trafficPoolSize({ postEffects: false, postScale: 1 })).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user