import { describe, it, expect } from 'vitest'; import { computeRouteLayout, travelledStopFor } from '../src/lib/roadmap-layout.js'; function isStrictlyIncreasing(xs: number[]): boolean { for (let i = 1; i < xs.length; i += 1) if (xs[i] <= xs[i - 1]) return false; return true; } describe('computeRouteLayout', () => { it('1 item — produces a valid single-point M path on the centreline', () => { const out = computeRouteLayout({ itemCount: 1, viewportWidth: 1000 }); expect(out.itemX).toHaveLength(1); expect(out.midY).toBe(210); // trackHeight 420 / 2 expect(out.itemY).toEqual([out.midY]); expect(out.cardSide).toEqual(['below']); expect(out.pathD.startsWith('M ')).toBe(true); expect(out.pathD).not.toContain('C '); // Target usable width = 1000 * 0.8 = 800; trackWidth = 800 + 60*2 = 920 expect(out.trackWidth).toBe(920); }); it('2 items — itemX strictly increasing, cardSide alternates below/above', () => { const out = computeRouteLayout({ itemCount: 2, viewportWidth: 1000 }); expect(out.itemX).toHaveLength(2); expect(isStrictlyIncreasing(out.itemX)).toBe(true); expect(out.cardSide).toEqual(['below', 'above']); expect(out.pathD.startsWith('M ')).toBe(true); expect((out.pathD.match(/C /g) ?? []).length).toBe(1); }); it('3 items — first card below, then above, then below; amplitude multiplier ramps', () => { const out = computeRouteLayout({ itemCount: 3, viewportWidth: 1000 }); expect(out.cardSide).toEqual(['below', 'above', 'below']); expect(isStrictlyIncreasing(out.itemX)).toBe(true); // First item on centreline, second above (smaller y), third below. expect(out.itemY[0]).toBe(out.midY); expect(out.itemY[1]).toBeLessThan(out.midY); expect(out.itemY[2]).toBeGreaterThan(out.midY); // Amplitude varies: multiplier at index 1 is 0.78 + (1/2)*0.4 = 0.98; // at index 2 it's 0.78 + (2/2)*0.4 = 1.18. So |itemY[2] - midY| should // exceed |itemY[1] - midY| (further items swing wider). expect(Math.abs(out.itemY[2] - out.midY)).toBeGreaterThan(Math.abs(out.itemY[1] - out.midY)); }); it('7 items — strictly increasing itemX + correct alternation', () => { const out = computeRouteLayout({ itemCount: 7, viewportWidth: 1200 }); expect(out.itemX).toHaveLength(7); expect(isStrictlyIncreasing(out.itemX)).toBe(true); expect(out.cardSide).toEqual(['below', 'above', 'below', 'above', 'below', 'above', 'below']); expect(out.pathD.startsWith('M ')).toBe(true); expect((out.pathD.match(/C /g) ?? []).length).toBe(6); }); it('20 items — data-driven width wins over the 80% target', () => { const out = computeRouteLayout({ itemCount: 20, viewportWidth: 800 }); expect(isStrictlyIncreasing(out.itemX)).toBe(true); // (20 - 1) * 320 + 60 * 2 = 6200; clearly beats 800 * 0.8 + 120 = 760. expect(out.trackWidth).toBe(6200); }); it('few items on a wide viewport — track ≈ 80% of viewport + padding', () => { // 3 items at viewport 1400. Data-driven = 2 * 320 + 120 = 760. // 80% target = 1400 * 0.8 + 120 = 1240 — should win. const out = computeRouteLayout({ itemCount: 3, viewportWidth: 1400 }); expect(out.trackWidth).toBe(1240); }); }); describe('travelledStopFor', () => { it('returns 0 when no items have shipped', () => { expect(travelledStopFor(['exploring', 'considering'])).toBe(0); expect(travelledStopFor([])).toBe(0); }); it('returns (lastShippingIndex + 0.5) / itemCount', () => { // [shipping, shipping, in_beta, exploring] → lastShipping = 1 → (1.5)/4 = 0.375 expect(travelledStopFor(['shipping', 'shipping', 'in_beta', 'exploring'])).toBeCloseTo(0.375, 5); }); it('clamps to 0.98 when every item has shipped', () => { expect(travelledStopFor(['shipping', 'shipping', 'shipping'])).toBeCloseTo(0.833, 2); // even with 100 items all shipping, clamps to 0.98 const allShipping = Array(100).fill('shipping') as ('shipping')[]; expect(travelledStopFor(allShipping)).toBeLessThanOrEqual(0.98); }); });