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 } }; 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 } }; 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(); }); });