feat: ignition opening with cold start, lamps, crossfade and shader warm-up
This commit is contained in:
177
tests/unit/ignition.test.ts
Normal file
177
tests/unit/ignition.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createIgnition, IGNITION, ignitionAt } from '@/scene/world/ignition';
|
||||
import { readIgnition } from '@/journey/ignition-feed';
|
||||
import { SceneResourceBag } from '@/scene/resources';
|
||||
import { createCar } from '@/scene/world/car';
|
||||
import { createRoad } from '@/scene/world/road';
|
||||
import { renderQualityFor } from '@/scene/render-quality';
|
||||
|
||||
const FRAME = 1 / 60;
|
||||
const root = (): HTMLElement => document.documentElement;
|
||||
const run = (seconds: number, step: (t: number) => { cut: boolean; short: boolean }, ignition = createIgnition()) => {
|
||||
const frames = [];
|
||||
for (let t = 0; t <= seconds + 1e-9; t += FRAME) frames.push({ t, ...ignition.update(t === 0 ? 0 : FRAME, step(t)), attr: root().dataset.ignition });
|
||||
return { frames, ignition };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
const feed = readIgnition();
|
||||
feed.played = false;
|
||||
feed.phase = 3;
|
||||
delete root().dataset.ignition;
|
||||
});
|
||||
afterEach(() => { delete root().dataset.ignition; });
|
||||
|
||||
describe('ignition timeline', () => {
|
||||
it('starts cold: lamps out, road still, low exposure', () => {
|
||||
expect(ignitionAt(0)).toEqual({ lamps: 0, roll: 0, exposure: IGNITION.coldExposure, phase: 0, attr: 'cold' });
|
||||
});
|
||||
|
||||
it('dips the lamps at 0.6 s, then the main beam at 1.0 s', () => {
|
||||
const at06 = ignitionAt(0.6);
|
||||
expect(at06.lamps).toBe(0);
|
||||
expect(at06.phase).toBe(1);
|
||||
expect(at06.attr).toBe('lamps');
|
||||
expect(ignitionAt(0.6 + IGNITION.dipFor).lamps).toBeCloseTo(IGNITION.dipped);
|
||||
const at10 = ignitionAt(1.0);
|
||||
expect(at10.lamps).toBeCloseTo(IGNITION.dipped);
|
||||
expect(at10.roll).toBe(0);
|
||||
expect(at10.exposure).toBeCloseTo(IGNITION.coldExposure);
|
||||
expect(at10.phase).toBe(2);
|
||||
expect(at10.attr).toBe('rolling');
|
||||
expect(ignitionAt(1.0 + IGNITION.beamFor).lamps).toBe(1);
|
||||
});
|
||||
|
||||
it('rolls to cruise and full exposure by 3.0 s, easing in', () => {
|
||||
expect(ignitionAt(2.0).roll).toBeCloseTo(0.125);
|
||||
const at30 = ignitionAt(3.0);
|
||||
expect(at30).toMatchObject({ lamps: 1, roll: 1, exposure: IGNITION.exposure, phase: 3, attr: 'rolling' });
|
||||
expect(ignitionAt(1.0 + IGNITION.sublineFor).attr).toBeNull();
|
||||
});
|
||||
|
||||
it('only ever raises the lamps, the roll and the exposure', () => {
|
||||
let previous = ignitionAt(0);
|
||||
for (let t = 0; t <= 5; t += 0.005) {
|
||||
const next = ignitionAt(t);
|
||||
expect(next.lamps).toBeGreaterThanOrEqual(previous.lamps);
|
||||
expect(next.roll).toBeGreaterThanOrEqual(previous.roll);
|
||||
expect(next.exposure).toBeGreaterThanOrEqual(previous.exposure - 1e-12);
|
||||
expect(next.phase).toBeGreaterThanOrEqual(previous.phase);
|
||||
previous = next;
|
||||
}
|
||||
});
|
||||
|
||||
it('short form (returning visitor): lamps on, no hold, rolling from the first frame', () => {
|
||||
const first = ignitionAt(0, true);
|
||||
expect(first).toMatchObject({ lamps: 1, roll: 0, phase: 2, attr: 'rolling' });
|
||||
expect(first.exposure).toBeGreaterThan(IGNITION.coldExposure);
|
||||
expect(ignitionAt(IGNITION.shortRoll, true)).toMatchObject({ roll: 1, exposure: IGNITION.exposure, phase: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('createIgnition', () => {
|
||||
it('steps html[data-ignition] cold, lamps, rolling, then removes it, phases in order', () => {
|
||||
const { frames } = run(5, () => ({ cut: false, short: false }));
|
||||
const attrs = frames.map((f) => f.attr ?? null).filter((a, i, all) => i === 0 || a !== all[i - 1]);
|
||||
expect(attrs).toEqual(['cold', 'lamps', 'rolling', null]);
|
||||
const phases = frames.map((f) => f.phase).filter((p, i, all) => i === 0 || p !== all[i - 1]);
|
||||
expect(phases).toEqual([0, 1, 2, 3]);
|
||||
expect(readIgnition().phase).toBe(3);
|
||||
expect(root().dataset.ignition).toBeUndefined();
|
||||
for (let i = 1; i < frames.length; i++) expect(frames[i]!.lamps).toBeGreaterThanOrEqual(frames[i - 1]!.lamps);
|
||||
});
|
||||
|
||||
it('publishes the phase for the DOM (the hero sweep waits for 2)', () => {
|
||||
const ignition = createIgnition();
|
||||
ignition.update(0, { cut: false, short: false });
|
||||
expect(readIgnition().phase).toBe(0);
|
||||
for (let t = 0; t < 1.05; t += FRAME) ignition.update(FRAME, { cut: false, short: false });
|
||||
expect(readIgnition().phase).toBe(2);
|
||||
ignition.dispose();
|
||||
expect(readIgnition().phase).toBe(3);
|
||||
expect(root().dataset.ignition).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a scroll before 3 s cuts to the driving state within 0.4 s, lamps never dropping', () => {
|
||||
const { frames } = run(2, (t) => ({ cut: t >= 0.7, short: false }));
|
||||
const cutAt = frames.findIndex((f) => f.t >= 0.7);
|
||||
const driving = frames.findIndex((f) => f.phase === 3);
|
||||
expect((driving - cutAt) * FRAME).toBeLessThanOrEqual(IGNITION.cutFor + FRAME * 1.5);
|
||||
expect(frames[driving]).toMatchObject({ lamps: 1, roll: 1, exposure: IGNITION.exposure });
|
||||
expect(frames[cutAt]!.phase).toBe(2);
|
||||
for (let i = 1; i < frames.length; i++) expect(frames[i]!.lamps).toBeGreaterThanOrEqual(frames[i - 1]!.lamps);
|
||||
expect(root().dataset.ignition).toBeUndefined();
|
||||
});
|
||||
|
||||
it('landing mid-page skips the ignition entirely', () => {
|
||||
const ignition = createIgnition();
|
||||
expect(ignition.update(0, { cut: true, short: false })).toMatchObject({ lamps: 1, roll: 1, phase: 3 });
|
||||
expect(root().dataset.ignition).toBeUndefined();
|
||||
});
|
||||
|
||||
it('short form skips the hold', () => {
|
||||
const ignition = createIgnition();
|
||||
const first = ignition.update(0, { cut: false, short: true });
|
||||
expect(first).toMatchObject({ lamps: 1, phase: 2 });
|
||||
expect(root().dataset.ignition).toBe('rolling');
|
||||
});
|
||||
|
||||
it('a rebuilt generation (tier swap, context restore) starts driving', () => {
|
||||
const first = createIgnition();
|
||||
first.update(0, { cut: false, short: false });
|
||||
first.dispose();
|
||||
const second = createIgnition();
|
||||
expect(second.update(0, { cut: false, short: false })).toMatchObject({ lamps: 1, roll: 1, phase: 3 });
|
||||
expect(root().dataset.ignition).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ignition feed', () => {
|
||||
afterEach(() => {
|
||||
window.localStorage.removeItem('nightdrive:route');
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('reads a returning visitor once, before the route memory writes', async () => {
|
||||
window.localStorage.setItem('nightdrive:route', JSON.stringify({ visited: ['start', 'profile'], last: 'profile' }));
|
||||
vi.resetModules();
|
||||
const returning = (await import('@/journey/ignition-feed')).readIgnition().returning;
|
||||
expect(returning).toBe(true);
|
||||
window.localStorage.removeItem('nightdrive:route');
|
||||
vi.resetModules();
|
||||
expect((await import('@/journey/ignition-feed')).readIgnition().returning).toBe(false);
|
||||
});
|
||||
|
||||
it('treats unreadable storage as a first visit', async () => {
|
||||
window.localStorage.setItem('nightdrive:route', '{not json');
|
||||
vi.resetModules();
|
||||
expect((await import('@/journey/ignition-feed')).readIgnition().returning).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ignition lamp factor', () => {
|
||||
it('the car is dark when cold and lit at 1', () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const car = createCar(resources);
|
||||
const light = car.group.getObjectByName('rear-light-bar') as unknown as { material: { emissiveIntensity: number } };
|
||||
const glow = car.group.getObjectByName('car-underglow') as unknown as { material: { uniforms: Record<string, { value: number }> } };
|
||||
car.update(0, 0, 0, 0, 0, 0);
|
||||
expect(light.material.emissiveIntensity).toBe(0);
|
||||
expect(glow.material.uniforms.uOpacity!.value).toBe(0);
|
||||
car.update(0, 0, 0.1, 0, 0, 1);
|
||||
expect(light.material.emissiveIntensity).toBeGreaterThan(1);
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it('the road throw follows the lamps (default fully lit)', () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const road = createRoad(resources, renderQualityFor('high', 1, false));
|
||||
const surface = road.group.getObjectByName('road-surface') as unknown as { material: { uniforms: Record<string, { value: number }> } };
|
||||
expect(surface.material.uniforms.uThrow!.value).toBe(1);
|
||||
road.setBeam(0, 0, 0);
|
||||
expect(surface.material.uniforms.uThrow!.value).toBe(0);
|
||||
road.setBeam(0, 0);
|
||||
expect(surface.material.uniforms.uThrow!.value).toBe(1);
|
||||
resources.dispose();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user