spreadlab/web/src/lib/__tests__/urlState.spec.ts
Justin Visser 71baf10e73 web: the dashboard page, slice 3 of M3
The milestone 2 parity page becomes the spec's dashboard: app bar with
wordmark, disclaimer badge and About popover, theme toggle (persisted,
defaults to prefers-color-scheme, applied pre-mount to avoid a flash),
hero with the preset narrative and live toned percentages, scenario
toolbar, panel cards with skeleton loading and dim-while-refreshing,
legend, and footer. ComparisonTable evolves into the collapsed
ResultsTable (test moved along, plus a formatPct rounding case).

NetworkView renders the real topology with the spec's shape encodings.
The layout is seeded d3-force (mulberry32 random source, seeded initial
positions, 300 synchronous ticks, cached per graph hash); anisotropic
forceX/forceY pulls settle the cloud into the wide card shape so the
fit stays uniform-scale and the spacing organic. Justin asked for a
taller network area (380x230) and a reading caption under the legend;
the caption is study copy, so StudyPreset gains a readingCaption field.

Slices still to come keep their controls visibly parked: Export and the
panel kebab render disabled until slices 11 and 7.
2026-06-10 16:55:14 +02:00

122 lines
4.4 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { parseUrlState, serializeUrlState } from '../urlState'
import type { PanelSpec, StudyPreset } from '@/presets/types'
// A small hand-made preset so these tests do not move when the study
// preset's copy or defaults change.
const preset: StudyPreset = {
headline: 'h',
narrative: 'n',
disclaimerShort: 'short',
disclaimerLong: 'long',
readingCaption: 'caption',
toneThresholdPct: 30,
base: {
numStudents: 120,
edgesPerNode: 3,
triangleProb: 0.45,
forwardProb: 0.38,
numEducated: 36,
origin: 0,
graphSeed: 17,
thresholdSeed: 2,
educationSeed: 1,
},
panels: [
{ id: 'preset-a', label: 'No program', strategy: 'none', overrides: {} },
{ id: 'preset-b', label: 'Educate at random', strategy: 'random', overrides: {} },
],
}
function idFactory(): () => string {
let nextId = 0
return () => `test-id-${nextId++}`
}
describe('serializeUrlState', () => {
it('serializes the default state to an empty string', () => {
expect(serializeUrlState(preset.base, preset.panels, null, preset)).toBe('')
})
it('includes only base fields that differ from the preset', () => {
const base = { ...preset.base, forwardProb: 0.5, graphSeed: 99 }
expect(serializeUrlState(base, preset.panels, null, preset)).toBe(
'?forwardProb=0.5&graphSeed=99',
)
})
it('emits one encoded panel param per panel once the set differs', () => {
const panels: PanelSpec[] = [
{ id: 'a', label: 'No program', strategy: 'none', overrides: {} },
{ id: 'b', label: 'Big budget', strategy: 'random', overrides: { numEducated: 60 } },
]
expect(serializeUrlState(preset.base, panels, null, preset)).toBe(
'?panel=No%20program~none~&panel=Big%20budget~random~numEducated:60',
)
})
it('serializes focus as the panel index', () => {
expect(serializeUrlState(preset.base, preset.panels, 'preset-b', preset)).toBe('?focus=1')
})
})
describe('parseUrlState', () => {
it('returns the preset with fresh panel ids when the URL is empty', () => {
const parsed = parseUrlState('', preset, idFactory())
expect(parsed).not.toBeNull()
expect(parsed!.base).toEqual(preset.base)
expect(parsed!.panels.map((panel) => panel.label)).toEqual(['No program', 'Educate at random'])
expect(parsed!.panels.map((panel) => panel.id)).toEqual(['test-id-0', 'test-id-1'])
expect(parsed!.focusIndex).toBeNull()
})
it('ignores unknown params', () => {
const parsed = parseUrlState('?utm_source=x&flag', preset, idFactory())
expect(parsed).not.toBeNull()
expect(parsed!.base).toEqual(preset.base)
})
it('round-trips a customized state', () => {
const base = { ...preset.base, forwardProb: 0.5 }
const panels: PanelSpec[] = [
{ id: 'a', label: 'Wave ~ one, two: go', strategy: 'most-connected', overrides: {} },
{
id: 'b',
label: 'Big budget',
strategy: 'random',
overrides: { numEducated: 60, graphSeed: 7 },
},
]
const query = serializeUrlState(base, panels, 'b', preset)
const parsed = parseUrlState(query, preset, idFactory())
expect(parsed).not.toBeNull()
expect(parsed!.base).toEqual(base)
expect(
parsed!.panels.map(({ label, strategy, overrides }) => ({ label, strategy, overrides })),
).toEqual([
{ label: 'Wave ~ one, two: go', strategy: 'most-connected', overrides: {} },
{ label: 'Big budget', strategy: 'random', overrides: { numEducated: 60, graphSeed: 7 } },
])
expect(parsed!.focusIndex).toBe(1)
})
it.each([
['a non-numeric base field', '?forwardProb=fast'],
['an empty base field', '?forwardProb='],
['an unknown strategy', '?panel=X~telepathy~'],
['a malformed panel param', '?panel=onlylabel'],
['an empty panel label', '?panel=~none~'],
['an unknown override field', '?panel=X~none~bogus:1'],
['a non-numeric override value', '?panel=X~none~numEducated:lots'],
['an override without a value', '?panel=X~none~numEducated'],
['a focus index out of range', '?focus=2'],
['a non-integer focus', '?focus=1.5'],
['a negative focus', '?focus=-1'],
[
'more than six panels',
`?${Array.from({ length: 7 }, (_, index) => `panel=P${index}~none~`).join('&')}`,
],
])('rejects %s so the app falls back to the preset', (_description, search) => {
expect(parseUrlState(search, preset, idFactory())).toBeNull()
})
})