Files
Portfolio/tests/unit/billboard.test.ts

235 lines
11 KiB
TypeScript

import { afterEach, describe, expect, it } from 'vitest';
import { SceneResourceBag } from '@/scene/resources';
import { publishBillboard, billboardStops, type BillboardProject } from '@/journey/billboard-progress';
import {
BILLBOARD_DECK_CLEAR_Z,
BILLBOARD_TIMING,
BILLBOARD_Z_FROM,
BILLBOARD_Z_TO,
billboardNear,
billboardZ,
composeScreen,
createBillboard,
deckAhead,
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<void> => {
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> = {}): 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<typeof createBillboard>, 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('holds down until the EXIT 05 deck has passed the camera, then rises', () => {
const board = createBillboard(new SceneResourceBag(), { mode: 'full', textureSize: 512, io: fakeIO().io });
// The deck comes down the road from the haze toward the camera (z ~25).
for (let z = -400; z < BILLBOARD_DECK_CLEAR_Z; z += 4) {
board.update(frame('infrastructure', null, { deckZ: z, deckExit: 'selected-builds' }));
expect(board.inspect().rise).toBe(0);
}
// Another chapter's deck, or no deck, never holds it.
expect(deckAhead({ deckZ: -80, deckExit: 'additional-work' })).toBe(false);
expect(deckAhead({ deckZ: null, deckExit: 'selected-builds' })).toBe(false);
const up = run(board, 10, frame('infrastructure', null, { deckZ: BILLBOARD_DECK_CLEAR_Z + 2, deckExit: 'selected-builds' }));
expect(up.at(-1)!.rise).toBe(1);
// Standing (reverse drive back under the deck): it stays up.
const back = run(board, 3, frame('infrastructure', null, { deckZ: -60, deckExit: 'selected-builds', direction: -1 }));
expect(back.at(-1)!.rise).toBe(1);
});
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();
});
});