import { afterEach, describe, expect, it } from 'vitest'; import { SceneResourceBag } from '@/scene/resources'; import { publishBillboard, billboardStops, type BillboardProject } from '@/journey/billboard-progress'; import { BILLBOARD_TIMING, BILLBOARD_Z_FROM, BILLBOARD_Z_TO, billboardNear, billboardZ, composeScreen, createBillboard, SCREEN_ASPECT, STANDING, textureSize, zoomFit, type BillboardFrame, type BillboardIO, } from '@/scene/world/billboard'; import type { SectionId } from '@/content/types'; const PROJECTS: BillboardProject[] = [ { id: 'private-medication-tracker', title: 'Medication tracker', plate: '05.1 / PRIVATE / ACTIVE', shots: ['logs', 'data', 'notify'].map((id) => ({ id, url: `/assets/${id}.webp`, kind: 'image' })), }, { id: 'footy-tracker', title: 'Footy tracker', plate: '05.2 / PUBLIC / ACTIVE', shots: [] }, { id: 'opinion-dynamics-simulation', title: 'Opinion dynamics', plate: '05.3 / PUBLIC / SHIPPED', shots: ['polar', 'final'].map((id) => ({ id, url: `/assets/${id}.webp`, kind: 'chart' })), }, ]; const STOPS = billboardStops(0, 100, [5, 40, 70]); /** A canvas stand-in that records what was drawn (happy-dom has no 2D context). */ function fakeIO(fail: (url: string) => boolean = () => false) { const drawn: string[] = []; const io: BillboardIO = { loadImage: (url) => (fail(url) ? Promise.reject(new Error(`404 ${url}`)) : Promise.resolve({ width: 720, height: 1280 } as never)), canvas(width, height) { const canvas = { width, height } as HTMLCanvasElement; const context = { fillRect: () => undefined, fillText: (text: string) => drawn.push(`text:${text}`), drawImage: () => drawn.push('image'), measureText: (text: string) => ({ width: text.length * 10 }), } as unknown as CanvasRenderingContext2D; return { canvas, context }; }, }; return { io, drawn }; } const flush = async (): Promise => { for (let i = 0; i < 5; i += 1) await new Promise((resolve) => setTimeout(resolve, 0)); }; const frame = (section: SectionId, p: number | null, extra: Partial = {}): BillboardFrame => ({ section, p, direction: 1, near: true, pulse: 0, energy: 0, focus: undefined, deltaSeconds: 1 / 60, ...extra, }); /** Runs `seconds` of frames and returns each frame's state. */ const run = (board: ReturnType, seconds: number, f: BillboardFrame) => Array.from({ length: Math.round(seconds * 60) }, () => { board.update(f); return board.inspect(); }); describe('billboard placement', () => { it('drifts from BILLBOARD_Z_FROM to BILLBOARD_Z_TO across the chapter, monotonically', () => { let previous = Number.NEGATIVE_INFINITY; for (let i = 0; i <= 100; i += 1) { const z = billboardZ(i / 100); expect(z).toBeGreaterThanOrEqual(previous); previous = z; } expect(billboardZ(0)).toBe(BILLBOARD_Z_FROM); expect(billboardZ(1)).toBe(BILLBOARD_Z_TO); expect(billboardZ(Number.NaN)).toBe(BILLBOARD_Z_FROM); }); it('starts building textures within one viewport of the chapter, not before', () => { const at = (activeSectionId: SectionId, nextHeadingOffsetVh: number | null) => billboardNear({ activeSectionId, sectionActivation: { nextHeadingOffsetVh } as never }); expect(at('start', 0.5)).toBe(false); expect(at('infrastructure', 3)).toBe(false); expect(at('infrastructure', 1.2)).toBe(true); expect(at('selected-builds', null)).toBe(true); expect(textureSize('high')).toBe(1024); expect(textureSize('medium')).toBe(512); expect(textureSize('low')).toBe(512); }); }); describe('billboard screens', () => { it('lays the triptych and chart pair out at their own aspect on a screen-shaped canvas', async () => { const { io, drawn } = fakeIO(); const triptych = await composeScreen(PROJECTS[0]!, 1024, io); expect(triptych?.kind).toBe('triptych'); expect(triptych?.canvas.width).toBe(1024); expect(triptych?.canvas.height).toBe(Math.round(1024 / SCREEN_ASPECT)); expect(drawn.filter((entry) => entry === 'image')).toHaveLength(3); for (const rect of triptych!.rects.values()) { expect(rect.u).toBeGreaterThanOrEqual(0); expect(rect.u + rect.w).toBeLessThanOrEqual(1); // Portrait shots keep 720:1280 in screen space. expect((rect.w * SCREEN_ASPECT) / rect.h).toBeCloseTo(720 / 1280, 2); const [sx, sy] = zoomFit(rect); expect(sx).toBeGreaterThanOrEqual(1); expect(sy).toBe(1); } expect((await composeScreen(PROJECTS[2]!, 512, io))?.kind).toBe('pair'); }); it('draws the typographic plate for a project with no media: title and route plate, no images', async () => { const { io, drawn } = fakeIO(); const plate = await composeScreen(PROJECTS[1]!, 1024, io); expect(plate?.kind).toBe('plate'); expect(drawn).toEqual(['text:05.2 / PUBLIC / ACTIVE', 'text:Footy tracker']); }); it('falls back to the plate when any shot fails to load, and never rejects', async () => { const { io, drawn } = fakeIO((url) => url.includes('data')); const screen = await composeScreen(PROJECTS[0]!, 512, io); expect(screen?.kind).toBe('plate'); expect(drawn).toContain('text:Medication tracker'); const noCanvas: BillboardIO = { ...io, canvas: () => null }; await expect(composeScreen(PROJECTS[0]!, 512, noCanvas)).resolves.toBeNull(); const throwing: BillboardIO = { ...io, canvas: () => { throw new Error('no 2d'); } }; await expect(composeScreen(PROJECTS[0]!, 512, throwing)).resolves.toBeNull(); }); }); describe('billboard world', () => { afterEach(() => publishBillboard([], [])); it('rises monotonically on the Selected builds envelope and sinks after', () => { const board = createBillboard(new SceneResourceBag(), { mode: 'full', textureSize: 512, io: fakeIO().io }); const up = run(board, 10, frame('selected-builds', 0)); up.slice(1).forEach((state, i) => expect(state.rise).toBeGreaterThanOrEqual(up[i]!.rise)); expect(up.at(-1)!.rise).toBe(1); const down = run(board, 10, frame('start', null)); down.slice(1).forEach((state, i) => expect(state.rise).toBeLessThanOrEqual(down[i]!.rise)); expect(down.at(-1)!.rise).toBe(0); expect(board.mesh.visible).toBe(false); }); it('keeps the floods off while rising, then lights each project in order and reverses', async () => { publishBillboard(PROJECTS, STOPS); const board = createBillboard(new SceneResourceBag(), { mode: 'full', textureSize: 512, io: fakeIO().io }); const rising = run(board, 0.5, frame('selected-builds', 0.06)); expect(rising.every((state) => state.floods[0] === 0 && state.floods[1] === 0)).toBe(true); await flush(); expect(board.inspect().textures).toBe(3); run(board, 5, frame('selected-builds', 0.02)); // t = 0.05 < 0.08: only the left flood. expect(board.inspect()).toMatchObject({ shown: 'private-medication-tracker', floods: [1, 0] }); expect(board.inspect().lit).toBe(1); expect(board.inspect().nudge).toBe(1); run(board, 0.5, frame('selected-builds', 0.2)); expect(board.inspect().floods).toEqual([1, 1]); // The next project: grey first, then the swap, then lit over 0.6 s. const swap = run(board, 1, frame('selected-builds', 0.5)); const swapAt = swap.findIndex((state) => state.shown === 'footy-tracker'); expect(swapAt).toBeGreaterThan(0); expect(swap[swapAt]!.lit).toBe(0); const litAt = swap.findIndex((state) => state.shown === 'footy-tracker' && state.lit === 1); expect((litAt - swapAt) / 60).toBeCloseTo(BILLBOARD_TIMING.lit, 1); // Reverse: the first case study keeps the board back to the chapter's first scroll... run(board, 2, frame('selected-builds', 0.01, { direction: -1 })); expect(board.inspect()).toMatchObject({ shown: 'private-medication-tracker', floods: [1, 0], lit: 1 }); // ...and it stays lit on the approach band; leaving the chapter drops it and everything goes dark. run(board, 2, frame('infrastructure', null, { direction: -1 })); expect(board.inspect()).toMatchObject({ floods: [1, 0], lit: 1 }); run(board, 4, frame('infrastructure', null, { direction: -1, near: false })); expect(board.inspect()).toMatchObject({ floods: [0, 0], lit: 0, nudge: 0 }); expect(board.inspect().rise).toBeLessThan(STANDING); }); it('flickers at most twice a second on bass hits, and only in the full tier', async () => { publishBillboard(PROJECTS, STOPS); const full = createBillboard(new SceneResourceBag(), { mode: 'full', textureSize: 512, io: fakeIO().io }); const lite = createBillboard(new SceneResourceBag(), { mode: 'lite', textureSize: 512, io: fakeIO().io }); for (const board of [full, lite]) run(board, 5, frame('selected-builds', 0.2)); await flush(); // A bass hit every other frame for 3 s: 90 rising edges. for (let i = 0; i < 180; i += 1) { const f = frame('selected-builds', 0.2, { pulse: i % 2 === 0 ? 0.9 : 0.1, energy: 0.8, focus: 'logs' }); full.update(f); lite.update(f); } expect(full.inspect().flickers).toBeGreaterThan(0); expect(full.inspect().flickers).toBeLessThanOrEqual(Math.ceil(3 / BILLBOARD_TIMING.flickerGap)); expect(full.inspect().shimmers).toBe(1); expect(full.inspect().zoom).toBe(1); // Lite: both floods steady (the whole face lit), no pulse, flicker, shimmer or hover zoom. expect(lite.inspect()).toMatchObject({ floods: [1, 1], flickers: 0, shimmers: 0, zoom: 0 }); }); it('never throws into the render loop', () => { const board = createBillboard(new SceneResourceBag(), { mode: 'full', textureSize: 512, io: fakeIO().io }); expect(() => board.update(frame('selected-builds', Number.NaN, { deltaSeconds: Number.NaN }))).not.toThrow(); }); });