One shared window (journey/card-gaps.ts, CARD_GAP) describes the open road after each chapter's panel in section-local progress; the district relight and cutover, the last yard lift and the relay station's beams now key off it instead of their own numbers, and the chapter margin widens from 44vh to 48vh so the gap reads as a stretch of road. Unit tests prove every payoff's peak sits inside the window for every chapter span.
76 lines
4.1 KiB
TypeScript
76 lines
4.1 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { CARD_CLEAR_BAND, CARD_GAP, CARD_GAP_OPEN_ROAD_VH, CARD_GAP_SPANS_VH, CARD_TITLE_VH, gapWindow } from '@/journey/card-gaps';
|
|
import { MAINTENANCE_BOUNDS, maintenanceStep } from '@/journey/maintenance-progress';
|
|
import { RELAY_STOPS, stationLive } from '@/journey/relay-progress';
|
|
import { plateLit, spreaderDrop, YARD_HOLD, YARD_STOPS } from '@/journey/yard-progress';
|
|
import { maintenanceTimeline } from '@/scene/world/maintenance-window';
|
|
import { relayPresence } from '@/scene/world/relay-masts';
|
|
|
|
const sweep = (from: number, to: number, steps = 200): number[] =>
|
|
Array.from({ length: steps + 1 }, (_, i) => from + ((to - from) * i) / steps);
|
|
const spans = sweep(CARD_GAP_SPANS_VH.min, CARD_GAP_SPANS_VH.max, 40);
|
|
|
|
describe('card gaps', () => {
|
|
it('matches the open road the stylesheet leaves between chapters', () => {
|
|
const css = readFileSync(resolve(process.cwd(), 'src/styles/route.css'), 'utf8');
|
|
const rule = /\.reading-stage \.portfolio-section \{[^}]*margin-block: clamp\(\d+px, (\d+)vh, \d+px\) 0;\s*padding-block-end: clamp\(\d+px, (\d+)vh, \d+px\);/u.exec(css);
|
|
expect(rule).not.toBeNull();
|
|
expect(Number(rule![1]) + Number(rule![2])).toBe(CARD_GAP_OPEN_ROAD_VH);
|
|
expect(CARD_TITLE_VH).toBeGreaterThan(0);
|
|
expect(CARD_CLEAR_BAND.top).toBeLessThan(CARD_CLEAR_BAND.bottom);
|
|
});
|
|
|
|
it('keeps the static window inside the measured gap for every chapter span', () => {
|
|
for (const span of spans) {
|
|
const window = gapWindow(span);
|
|
expect(window.from).toBeLessThanOrEqual(CARD_GAP.from);
|
|
expect(window.to).toBeGreaterThanOrEqual(CARD_GAP.to);
|
|
// The static peak sits where the gap is fully in frame, whatever the span.
|
|
expect(CARD_GAP.peak).toBeGreaterThan(window.from);
|
|
expect(CARD_GAP.peak).toBeLessThan(window.to);
|
|
}
|
|
expect(CARD_GAP.from).toBeLessThan(CARD_GAP.peak);
|
|
expect(CARD_GAP.peak).toBeLessThan(CARD_GAP.to);
|
|
expect(CARD_GAP.to).toBeLessThan(1);
|
|
});
|
|
|
|
it('relights the district and cuts over in the gap', () => {
|
|
expect(maintenanceTimeline(CARD_GAP.from).relight).toBe(0);
|
|
expect(maintenanceTimeline(CARD_GAP.from - 0.01).relight).toBe(0);
|
|
expect(maintenanceTimeline(CARD_GAP.peak).relight).toBe(1);
|
|
expect(maintenanceTimeline(CARD_GAP.from).packetPhase).toBe(2);
|
|
expect(MAINTENANCE_BOUNDS.verify).toBe(CARD_GAP.from);
|
|
expect(maintenanceStep(CARD_GAP.peak)).toBe('done');
|
|
// The peak of the relight (its steepest frame) lies inside the window.
|
|
const ps = sweep(0, 1, 1000);
|
|
const slopes = ps.map((p, i) => (i === 0 ? 0 : maintenanceTimeline(p).relight - maintenanceTimeline(ps[i - 1]!).relight));
|
|
const steepest = ps[slopes.indexOf(Math.max(...slopes))]!;
|
|
expect(steepest).toBeGreaterThan(CARD_GAP.from);
|
|
expect(steepest).toBeLessThan(CARD_GAP.to);
|
|
});
|
|
|
|
it('lands the last yard lift in the gap, with the spreader down and the plate lit before the yard fades', () => {
|
|
const last = YARD_STOPS[YARD_STOPS.length - 1]!;
|
|
expect(last).toBe(CARD_GAP.peak);
|
|
expect(spreaderDrop(last)).toBe(1);
|
|
expect(plateLit(last, YARD_STOPS.length - 1)).toBe(1);
|
|
expect(last + YARD_HOLD).toBeGreaterThan(CARD_GAP.to);
|
|
// The spreader is still up (no work-lamp strike yet) as the gap comes fully into frame.
|
|
expect(spreaderDrop(CARD_GAP.from)).toBeLessThan(0.6);
|
|
expect(plateLit(CARD_GAP.from, YARD_STOPS.length - 1)).toBe(0);
|
|
for (let i = 1; i < YARD_STOPS.length; i += 1) expect(YARD_STOPS[i]!).toBeGreaterThan(YARD_STOPS[i - 1]! + 2 * YARD_HOLD);
|
|
});
|
|
|
|
it('brings the relay station live in the gap, after every mast is online and before they fade', () => {
|
|
expect(stationLive(null)).toBe(0);
|
|
expect(stationLive(CARD_GAP.from)).toBe(0);
|
|
expect(stationLive(CARD_GAP.peak)).toBe(1);
|
|
expect(Math.max(...RELAY_STOPS)).toBeLessThan(CARD_GAP.from);
|
|
for (const p of sweep(CARD_GAP.from, CARD_GAP.to)) expect(relayPresence(p)).toBe(1);
|
|
const forward = sweep(0, 1).map(stationLive);
|
|
forward.forEach((v, i) => { if (i > 0) expect(v).toBeGreaterThanOrEqual(forward[i - 1]!); });
|
|
});
|
|
});
|