119 lines
5.1 KiB
TypeScript
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] });
|
|
});
|
|
});
|