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

119 lines
5.1 KiB
TypeScript

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