import { describe, expect, it } from 'vitest'; import { CRANE_FROM_Z, CRANE_TO_Z, craneZ, createLamp, FLOOD_WARMUP_SECONDS, floodLight, floodWarmup, LAMP_RAMP_SECONDS, LAMP_STRIKE_GAP_SECONDS, plateLit, spreaderDrop, STACK_ZS, stepLamp, YARD_HOLD, YARD_STOPS, yardStopsPassed, } from '@/journey/yard-progress'; import { DECK_PASS_Z } from '@/journey/underpass'; import { UNDERPASS_GEOMETRY } from '@/scene/world/underpass'; const sweep = (steps = 400): number[] => Array.from({ length: steps + 1 }, (_, i) => i / steps); describe('night freight progress', () => { it('has four ordered stops and stacks far to near', () => { expect(YARD_STOPS).toEqual([0.1, 0.33, 0.56, 0.79]); for (let i = 1; i < YARD_STOPS.length; i += 1) { expect(YARD_STOPS[i]!).toBeGreaterThan(YARD_STOPS[i - 1]! + 2 * YARD_HOLD); expect(STACK_ZS[i]!).toBeGreaterThan(STACK_ZS[i - 1]!); } }); it('runs the crane monotonically toward the camera, parked over each stack at its stop', () => { let previous = Number.NEGATIVE_INFINITY; for (const p of sweep()) { const z = craneZ(p); expect(z).toBeGreaterThanOrEqual(previous - 1e-9); previous = z; } expect(craneZ(0)).toBeCloseTo(CRANE_FROM_Z); expect(craneZ(1)).toBeCloseTo(CRANE_TO_Z); YARD_STOPS.forEach((stop, i) => { for (const d of [-YARD_HOLD, -0.02, 0, 0.02, YARD_HOLD]) expect(craneZ(stop + d)).toBeCloseTo(STACK_ZS[i]!); }); }); it('keeps the crane in its clear range: in front of the camera, short of the deck pass line', () => { for (const p of sweep()) { const z = craneZ(p); expect(z).toBeGreaterThanOrEqual(CRANE_FROM_Z); expect(z).toBeLessThanOrEqual(CRANE_TO_Z); // The deck is overhead at z 25 (+- half its depth): the crane never reaches it. expect(z).toBeLessThan(DECK_PASS_Z - UNDERPASS_GEOMETRY.depth / 2 - 6); } }); it('lowers the spreader only at the stops, and lights each plate as it lands', () => { expect(spreaderDrop(null)).toBe(0); YARD_STOPS.forEach((stop, i) => { expect(spreaderDrop(stop)).toBe(1); expect(plateLit(stop, i)).toBe(1); expect(plateLit(stop - 0.05, i)).toBe(0); }); expect(spreaderDrop(0.215)).toBe(0); expect(spreaderDrop(0.95)).toBe(0); expect(plateLit(null, 0)).toBe(0); expect(plateLit(0.5, 9)).toBe(0); expect([0, 0.1, 0.2, 0.33, 0.6, 0.8, 1].map(yardStopsPassed)).toEqual([0, 1, 1, 2, 3, 4, 4]); }); it('is symmetric in reverse: the same progress gives the same frame either way', () => { const frame = (p: number) => [craneZ(p), spreaderDrop(p), ...YARD_STOPS.map((_, i) => plateLit(p, i))]; const ps = sweep(200); const forward = ps.map(frame); const backward = [...ps].reverse().map(frame).reverse(); expect(backward).toEqual(forward); }); it('warms the floods over 1.5 s (never faster than 300 ms) and cools them back', () => { let level = 0; let seconds = 0; while (level < 1) { level = floodWarmup(level, true, 1 / 60); seconds += 1 / 60; } expect(seconds).toBeGreaterThanOrEqual(0.3); expect(seconds).toBeCloseTo(FLOOD_WARMUP_SECONDS, 1); expect(floodWarmup(0, true, 10)).toBe(1); expect(floodLight(0).output).toBe(0); expect(floodLight(0.2).hue).toBeLessThan(0.1); expect(floodLight(1)).toEqual({ output: 1, hue: 1 }); expect(floodWarmup(1, false, 0.75)).toBeCloseTo(0.5); }); it('strikes a work lamp over at least 300 ms, at most once per 0.6 s however fast the scroll', () => { expect(LAMP_RAMP_SECONDS).toBeGreaterThanOrEqual(0.3); const lamp = createLamp(); stepLamp(lamp, 1, 1 / 60); expect(lamp.strikes).toBe(1); let t = 1 / 60; while (lamp.level < 1) { stepLamp(lamp, 1, 1 / 60); t += 1 / 60; } expect(t).toBeGreaterThanOrEqual(0.3 - 1e-9); // A frantic scroll toggling the stop every frame for 3 s. const frantic = createLamp(); const starts: number[] = []; for (let frame = 0; frame < 180; frame += 1) { const before = frantic.strikes; stepLamp(frantic, frame % 2, 1 / 60); if (frantic.strikes > before) starts.push(frame / 60); } expect(starts.length).toBeGreaterThan(1); for (let i = 1; i < starts.length; i += 1) expect(starts[i]! - starts[i - 1]!).toBeGreaterThanOrEqual(LAMP_STRIKE_GAP_SECONDS - 1e-9); }); });