web: sim store, URL state scheme, debounced parallel runs (M3 slice 2)
useSimStore is the one reactive module singleton from the spec: panels over a shared base config, runs debounced 400 ms, one POST /api/scenario per panel in parallel, results swapped in atomically only when every request succeeds. Failures keep the last good results; engine 400s are routed to an inline validationError, everything else to the banner message. Stale in-flight responses lose to newer runs per panel. URL state follows spec section 7 (readable params, repeated panel=, focus index, full fallback to the preset on any malformed part) and is covered by round-trip and rejection tests. The study copy and initial panels live in the deepfake-school preset module; formatPct is the single percent-rounding rule. The vitest tsconfig now keeps lib at ES2022 like the app config, and prettier skips the tygo-generated src/types (drift guard stays green).
This commit is contained in:
parent
2feb6ea5a3
commit
2f21248c20
13 changed files with 1003 additions and 4 deletions
121
web/src/lib/__tests__/urlState.spec.ts
Normal file
121
web/src/lib/__tests__/urlState.spec.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
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',
|
||||
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()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue