import { afterEach, describe, expect, it } from 'vitest'; import { activeStop, billboardProgress, billboardStops, floodTargets, publishBillboard, readBillboard, RIGHT_FLOOD_T, screenKind, } from '@/journey/billboard-progress'; const IDS = ['private-medication-tracker', 'footy-tracker', 'opinion-dynamics-simulation']; // Document tops measured at 1440: chapter heading 7044, headers 7171/8647/9809, next 11000. const STOPS = billboardStops(7044, 11000, [7171, 8647, 9809]); const sweep = (from: number, to: number, steps = 400): number[] => Array.from({ length: steps + 1 }, (_, i) => from + ((to - from) * i) / steps); describe('billboard stops', () => { it('maps header tops to chapter-local fractions, in order and clamped', () => { expect(STOPS).toHaveLength(3); expect(STOPS[0]).toBe(0); expect(STOPS[1]).toBeCloseTo((8647 - 7044) / (11000 - 7044)); expect([...STOPS].sort((a, b) => a - b)).toEqual(STOPS); expect(billboardStops(0, 100, [-10, 50, 150])).toEqual([0, 0.5, 1]); }); it('is empty when the chapter has no span (unmeasured layout)', () => { expect(billboardStops(100, 100, [100])).toEqual([]); expect(billboardProgress(0.5, [], IDS).activeProjectId).toBeNull(); }); }); describe('billboard progress', () => { it('activates the projects in reading order, one at a time', () => { const order: (string | null)[] = []; for (const p of sweep(0, 1)) { const id = billboardProgress(p, STOPS, IDS, 1).activeProjectId; if (order.at(-1) !== id) order.push(id); } // The first case study holds the board from the chapter's first scroll (stop 0). expect(order).toEqual([...IDS]); expect(billboardProgress(null, STOPS, IDS, 1).activeProjectId).toBeNull(); }); it('holds nothing before the first header or outside the chapter', () => { expect(billboardProgress(STOPS[0]! - 0.001, STOPS, IDS).activeProjectId).toBeNull(); expect(billboardProgress(null, STOPS, IDS)).toMatchObject({ activeProjectId: null, index: -1, t: 0 }); expect(billboardProgress(STOPS[0]!, STOPS, IDS)).toMatchObject({ activeProjectId: IDS[0], index: 0, t: 0 }); }); it('ramps t from 0 at each header to 1 at the next, monotonically', () => { IDS.forEach((id, index) => { const from = STOPS[index]!; const to = STOPS[index + 1] ?? 1; let previous = -1; for (const p of sweep(from, to - 1e-9, 50)) { const progress = billboardProgress(p, STOPS, IDS); expect(progress.activeProjectId).toBe(id); expect(progress.t).toBeGreaterThanOrEqual(previous); expect(progress.t).toBeGreaterThanOrEqual(0); expect(progress.t).toBeLessThanOrEqual(1); previous = progress.t; } expect(billboardProgress(from + (to - from) / 2, STOPS, IDS).t).toBeCloseTo(0.5); }); }); it('switches the left flood on at t 0 and the right at t 0.08', () => { const at = (t: number) => floodTargets(billboardProgress(STOPS[1]! + t * (STOPS[2]! - STOPS[1]!), STOPS, IDS)); expect(floodTargets(billboardProgress(null, STOPS, IDS))).toEqual([0, 0]); expect(floodTargets(billboardProgress(0, STOPS, IDS))).toEqual([1, 0]); expect(at(0)).toEqual([1, 0]); expect(at(RIGHT_FLOOD_T - 0.01)).toEqual([1, 0]); expect(at(RIGHT_FLOOD_T + 0.001)).toEqual([1, 1]); }); it('runs backwards on reverse scroll: the same state at every p, direction carried', () => { const forward = sweep(0, 1).map((p) => billboardProgress(p, STOPS, IDS, 1)); const backward = sweep(1, 0).map((p) => billboardProgress(p, STOPS, IDS, -1)).reverse(); forward.forEach((state, i) => { expect(backward[i]!.activeProjectId).toBe(state.activeProjectId); expect(backward[i]!.t).toBeCloseTo(state.t); expect(floodTargets(backward[i]!)).toEqual(floodTargets(state)); expect(backward[i]!.direction).toBe(-1); }); const order: (string | null)[] = []; for (const state of [...backward].reverse()) if (order.at(-1) !== state.activeProjectId) order.push(state.activeProjectId); expect(order).toEqual([...IDS].reverse()); }); }); describe('DOM and scene agreement', () => { it('lights the same case study in the page (activeStop) as on the board', () => { for (const p of [null, ...sweep(0, 1)]) { const index = activeStop(p, STOPS, IDS.length); expect(IDS[index] ?? null).toBe(billboardProgress(p, STOPS, IDS).activeProjectId); } }); }); describe('billboard screens', () => { afterEach(() => publishBillboard([], [])); it('shows a triptych, a chart pair, or the typographic plate when there is no media', () => { const shot = (kind: string) => ({ id: kind, url: `/assets/${kind}.webp`, kind }); expect(screenKind({ shots: [shot('image'), shot('image'), shot('image')] })).toBe('triptych'); expect(screenKind({ shots: [shot('chart'), shot('chart')] })).toBe('pair'); expect(screenKind({ shots: [] })).toBe('plate'); }); it('shares the published projects and stops with the scene', () => { const projects = [{ id: 'footy-tracker', title: 'Footy tracker', plate: '05.2 / PUBLIC / ACTIVE', shots: [] }]; publishBillboard(projects, [0.2]); expect(readBillboard()).toEqual({ projects, stops: [0.2] }); }); });