project-bifrost-platform/tests/roadmap-layout.test.ts
Jonathan Hvid ac52e97c28 feat(route): drop progress dots, move legend below, vary path amplitude
Three coupled changes that all serve the same goal — less furniture
above the route, more honest information below it.

Progress dots gone. At 5 pills × ~400px per pill the strip was too
coarse to feel meaningful; the arrows + edge fades already
communicate scroll position. .rr-progress markup, the script logic
that updated the .active class, and the .rr-progress / .rr-progress-dot
styles are all deleted.

Legend moves from beside 'The route' in the section header to below
the track, centred. Reading order is now title → walk the path → key,
which is the order it makes sense in. The header collapses to just
the title on the left and the two arrow buttons on the right.

Path amplitude is no longer constant. computeRouteLayout multiplies
the base amplitude (120) by a per-item factor that ramps 0.78 (first
off-axis item) → 1.18 (last item), so closer-in items swing tighter
and further-out items swing wider. The visual effect is subtle but
the path now feels hand-planned instead of strictly sinusoidal.

Test updated to verify the multiplier — |itemY[2] - midY| now exceeds
|itemY[1] - midY| in the 3-item case.

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

85 lines
3.9 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(290); // trackHeight 580 / 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 ');
expect(out.trackWidth).toBeGreaterThanOrEqual(1000);
});
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 — track expands beyond viewport, spacing respects minSpacingX', () => {
const out = computeRouteLayout({ itemCount: 20, viewportWidth: 800 });
expect(isStrictlyIncreasing(out.itemX)).toBe(true);
expect(out.trackWidth).toBeGreaterThan(800);
// With minSpacing 320 and 20 items, the route should be at least
// (20 - 1) * 320 + 2 * 60 = 6200px wide
expect(out.trackWidth).toBeGreaterThanOrEqual(6200);
});
it('trackWidth is never smaller than the viewport width', () => {
const out = computeRouteLayout({ itemCount: 1, viewportWidth: 2000 });
expect(out.trackWidth).toBeGreaterThanOrEqual(2000);
});
});
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);
});
});