project-bifrost-platform/tests/roadmap-layout.test.ts
Jonathan Hvid b4df8e10f1 fix(route): span ~80% of viewport + scroll padding back to 60 + track 420
Three coupled fixes to the route's geometry:

- computeRouteLayout's width calculation flipped to Math.max(viewport
  * 0.80, itemCount * minSpacing + padding * 2). On a wide screen with
  few items the 80% target wins and the path stretches across the
  page; once item count makes the data-driven width exceed 80% (the
  carousel case), the data-driven value wins and the track extends
  past the viewport unchanged.
- .rr-scroll horizontal padding 140 → 60 each side. The previous 140
  was overcompensating; with the new 80% target the milestones already
  sit inside their own breathing room. 60 is card-half + 30px buffer,
  enough for a 220px card centred under a dot 60px from the edge.
  scroll-padding kept in sync at 60 for snap-stop landings.
- trackHeight default 580 → 420; midY 290 → 210. The 580 was bandaging
  the vertical-clipping issue — that fix lands in the next commit. With
  the clip properly addressed, 420 fits the path's amplitude 120 swing
  cleanly with no wasted vertical space.

Tests rewritten to match the new width semantics: 1 item @ 1000 →
920px (0.8 * 1000 + 120); 3 items @ 1400 → 1240px; 20 items @ 800 →
6200px (data-driven wins). midY assertion 290 → 210.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:32:29 +02:00

86 lines
4 KiB
TypeScript

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