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