From 2f21248c209460f0e69063ac34bb30d2cfc47e95 Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Wed, 10 Jun 2026 16:36:26 +0200 Subject: [PATCH] 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). --- .gitignore | 3 + web/.prettierignore | 2 + .../composables/__tests__/useSimStore.spec.ts | 270 ++++++++++++++++ web/src/composables/useSimStore.ts | 298 ++++++++++++++++++ web/src/lib/__tests__/format.spec.ts | 19 ++ web/src/lib/__tests__/urlState.spec.ts | 121 +++++++ web/src/lib/api.ts | 42 ++- web/src/lib/format.ts | 11 + web/src/lib/graph.ts | 7 + web/src/lib/urlState.ts | 153 +++++++++ web/src/presets/deepfake-school.ts | 49 +++ web/src/presets/types.ts | 26 ++ web/tsconfig.vitest.json | 6 +- 13 files changed, 1003 insertions(+), 4 deletions(-) create mode 100644 web/.prettierignore create mode 100644 web/src/composables/__tests__/useSimStore.spec.ts create mode 100644 web/src/composables/useSimStore.ts create mode 100644 web/src/lib/__tests__/format.spec.ts create mode 100644 web/src/lib/__tests__/urlState.spec.ts create mode 100644 web/src/lib/format.ts create mode 100644 web/src/lib/graph.ts create mode 100644 web/src/lib/urlState.ts create mode 100644 web/src/presets/deepfake-school.ts create mode 100644 web/src/presets/types.ts diff --git a/.gitignore b/.gitignore index cbdf792..ff6fc96 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ # binary from `go build ./cmd/spreadlab` /spreadlab + +# prettier cache (npm run format in web/ walks up here) +/node_modules/ diff --git a/web/.prettierignore b/web/.prettierignore new file mode 100644 index 0000000..38ef09b --- /dev/null +++ b/web/.prettierignore @@ -0,0 +1,2 @@ +# Generated by tygo from the Go structs; must stay byte-identical to its output (CI drift guard). +src/types/ diff --git a/web/src/composables/__tests__/useSimStore.spec.ts b/web/src/composables/__tests__/useSimStore.spec.ts new file mode 100644 index 0000000..8c219de --- /dev/null +++ b/web/src/composables/__tests__/useSimStore.spec.ts @@ -0,0 +1,270 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createSimStore, RUN_DEBOUNCE_MS } from '../useSimStore' +import { ApiError, runScenario } from '@/lib/api' +import type { ScenarioRequest, ScenarioResponse } from '@/types/api' + +vi.mock('@/lib/api', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, runScenario: vi.fn() } +}) + +const runScenarioMock = vi.mocked(runScenario) + +// The fake engine: numReached mirrors numEducated so tests can tell which +// config produced a result, and the no-program panel runs the longest. +function fakeResponse(request: ScenarioRequest): ScenarioResponse { + const { numStudents, numEducated } = request.config + return { + config: request.config, + result: { + strategy: request.strategy, + educated: [], + reachedAtRound: Array.from({ length: numStudents }, (_, node) => (node < 3 ? node : -1)), + numReached: numEducated, + numRounds: request.strategy === 'none' ? 9 : 3, + reachedPct: (numEducated / numStudents) * 100, + }, + edges: [ + [0, 1], + [1, 2], + ], + } +} + +// Settles promise chains (Promise.allSettled and friends) without real time. +async function flushAsync() { + for (let i = 0; i < 8; i++) await Promise.resolve() +} + +beforeEach(() => { + vi.useFakeTimers() + runScenarioMock.mockReset() + runScenarioMock.mockImplementation(async (request) => fakeResponse(request)) + window.history.replaceState(null, '', '/') +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('useSimStore initialization', () => { + it('runs every preset panel in parallel and swaps results in', async () => { + const store = createSimStore() + await store.initialize('') + + expect(runScenarioMock).toHaveBeenCalledTimes(3) + expect(store.state.panels).toHaveLength(3) + for (const panel of store.state.panels) { + expect(store.state.resultsByPanelId[panel.id]).toBeDefined() + } + expect(store.state.runState).toBe('idle') + expect(store.state.round).toBe(9) // the global max across panels + // All preset panels share the base graph: one cached edge list. + expect(Object.keys(store.state.edgesByGraphHash)).toHaveLength(1) + }) + + it('applies state from the opened URL', async () => { + const store = createSimStore() + await store.initialize('?forwardProb=0.5&panel=Custom~random~numEducated:60') + + expect(store.state.base.forwardProb).toBe(0.5) + expect(store.state.panels).toHaveLength(1) + expect(store.state.panels[0]!.label).toBe('Custom') + expect(store.state.panels[0]!.overrides).toEqual({ numEducated: 60 }) + const request = runScenarioMock.mock.calls[0]![0] + expect(request.config.forwardProb).toBe(0.5) + expect(request.config.numEducated).toBe(60) + expect(request.strategy).toBe('random') + }) + + it('falls back to the preset on a malformed URL and flags it', async () => { + const store = createSimStore() + await store.initialize('?panel=Bad~telepathy~') + + expect(store.state.urlStateInvalid).toBe(true) + expect(store.state.panels).toHaveLength(3) + expect(store.state.runState).toBe('idle') + }) +}) + +describe('useSimStore runs', () => { + it('debounces base changes and reruns every panel with the final value', async () => { + const store = createSimStore() + await store.initialize('') + runScenarioMock.mockClear() + + store.setBaseField('forwardProb', 0.5) + store.setBaseField('forwardProb', 0.6) + expect(runScenarioMock).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(RUN_DEBOUNCE_MS) + await flushAsync() + + expect(runScenarioMock).toHaveBeenCalledTimes(3) + for (const call of runScenarioMock.mock.calls) { + expect(call[0].config.forwardProb).toBe(0.6) + } + expect(store.state.runState).toBe('idle') + expect(window.location.search).toBe('?forwardProb=0.6') + }) + + it('reruns only the edited panel', async () => { + const store = createSimStore() + await store.initialize('') + runScenarioMock.mockClear() + const secondPanel = store.state.panels[1]! + + store.setPanelOverride(secondPanel.id, 'numEducated', 60) + await vi.advanceTimersByTimeAsync(RUN_DEBOUNCE_MS) + await flushAsync() + + expect(runScenarioMock).toHaveBeenCalledTimes(1) + expect(runScenarioMock.mock.calls[0]![0].config.numEducated).toBe(60) + expect(store.state.resultsByPanelId[secondPanel.id]!.numReached).toBe(60) + }) + + it('widens a pending single-panel rerun when a base change follows', async () => { + const store = createSimStore() + await store.initialize('') + runScenarioMock.mockClear() + + store.setPanelOverride(store.state.panels[1]!.id, 'numEducated', 60) + store.setBaseField('forwardProb', 0.6) + await vi.advanceTimersByTimeAsync(RUN_DEBOUNCE_MS) + await flushAsync() + + expect(runScenarioMock).toHaveBeenCalledTimes(3) + }) + + it('keeps the last good results on a network failure and recovers on retry', async () => { + const store = createSimStore() + await store.initialize('') + const goodResults = { ...store.state.resultsByPanelId } + runScenarioMock.mockImplementation(async () => { + throw new TypeError('fetch failed') + }) + + store.setBaseField('forwardProb', 0.6) + await vi.advanceTimersByTimeAsync(RUN_DEBOUNCE_MS) + await flushAsync() + + expect(store.state.runState).toBe('error') + expect(store.state.errorMessage).toBe('fetch failed') + expect(store.state.validationError).toBeNull() + expect(store.state.resultsByPanelId).toEqual(goodResults) + + runScenarioMock.mockImplementation(async (request) => fakeResponse(request)) + await store.retry() + expect(store.state.runState).toBe('idle') + expect(store.state.errorMessage).toBeNull() + }) + + it('routes a 400 to validationError instead of the banner', async () => { + const store = createSimStore() + await store.initialize('') + runScenarioMock.mockImplementation(async () => { + throw new ApiError(400, 'forwardProb must be between 0 and 1') + }) + + store.setBaseField('forwardProb', 7) + await vi.advanceTimersByTimeAsync(RUN_DEBOUNCE_MS) + await flushAsync() + + expect(store.state.validationError).toBe('forwardProb must be between 0 and 1') + expect(store.state.errorMessage).toBeNull() + expect(store.state.runState).toBe('error') + }) + + it('drops a stale response when a newer run supersedes it', async () => { + const store = createSimStore() + await store.initialize('') + runScenarioMock.mockClear() + const secondPanel = store.state.panels[1]! + + let resolveSlowRun!: (response: ScenarioResponse) => void + let slowRequest!: ScenarioRequest + runScenarioMock.mockImplementationOnce((request) => { + slowRequest = request + return new Promise((resolve) => { + resolveSlowRun = resolve + }) + }) + + store.setPanelOverride(secondPanel.id, 'numEducated', 60) + await vi.advanceTimersByTimeAsync(RUN_DEBOUNCE_MS) // slow run in flight + + store.setPanelOverride(secondPanel.id, 'numEducated', 70) + await vi.advanceTimersByTimeAsync(RUN_DEBOUNCE_MS) + await flushAsync() + expect(store.state.resultsByPanelId[secondPanel.id]!.numReached).toBe(70) + + resolveSlowRun(fakeResponse(slowRequest)) // lands late, must be ignored + await flushAsync() + expect(store.state.resultsByPanelId[secondPanel.id]!.numReached).toBe(70) + }) +}) + +describe('useSimStore panel management', () => { + it('adds panels up to the cap of six and runs each new one', async () => { + const store = createSimStore() + await store.initialize('') + runScenarioMock.mockClear() + + const added = store.addPanel() + expect(added).not.toBeNull() + expect(added!.label).toBe('Scenario 4') + await vi.advanceTimersByTimeAsync(RUN_DEBOUNCE_MS) + await flushAsync() + expect(runScenarioMock).toHaveBeenCalledTimes(1) + expect(store.state.resultsByPanelId[added!.id]).toBeDefined() + + expect(store.addPanel()).not.toBeNull() + expect(store.addPanel()).not.toBeNull() + expect(store.state.panels).toHaveLength(6) + expect(store.addPanel()).toBeNull() + }) + + it('seeds a duplicate from the source result without a rerun', async () => { + const store = createSimStore() + await store.initialize('') + runScenarioMock.mockClear() + const sourcePanel = store.state.panels[0]! + + const copy = store.duplicatePanel(sourcePanel.id) + expect(copy).not.toBeNull() + expect(copy!.label).toBe('No program copy') + expect(store.state.resultsByPanelId[copy!.id]).toBe( + store.state.resultsByPanelId[sourcePanel.id], + ) + + await vi.advanceTimersByTimeAsync(RUN_DEBOUNCE_MS) + expect(runScenarioMock).not.toHaveBeenCalled() + }) + + it('removes a panel with its result but never the last one', async () => { + const store = createSimStore() + await store.initialize('') + const [first, second, third] = store.state.panels + + store.removePanel(first!.id) + store.removePanel(second!.id) + expect(store.state.panels).toHaveLength(1) + expect(store.state.resultsByPanelId[first!.id]).toBeUndefined() + + store.removePanel(third!.id) + expect(store.state.panels).toHaveLength(1) // the last panel stays + }) + + it('reflects focus in the URL and clears it when the panel goes away', async () => { + const store = createSimStore() + await store.initialize('') + const secondPanel = store.state.panels[1]! + + store.setFocusPanel(secondPanel.id) + expect(window.location.search).toBe('?focus=1') + + store.removePanel(secondPanel.id) + expect(store.state.focusPanelId).toBeNull() + expect(window.location.search).not.toContain('focus=') + }) +}) diff --git a/web/src/composables/useSimStore.ts b/web/src/composables/useSimStore.ts new file mode 100644 index 0000000..18ece8c --- /dev/null +++ b/web/src/composables/useSimStore.ts @@ -0,0 +1,298 @@ +import { reactive } from 'vue' +import { ApiError, runScenario } from '@/lib/api' +import { graphKey } from '@/lib/graph' +import { parseUrlState, serializeUrlState } from '@/lib/urlState' +import { deepfakeSchoolPreset } from '@/presets/deepfake-school' +import { MAX_PANELS, type PanelSpec, type StudyPreset } from '@/presets/types' +import type { Config, Result, Strategy } from '@/types/engine' +import { StrategyNone } from '@/types/engine' + +// The one store (spec section 4): a plain reactive module singleton, no +// Pinia. All scenario runs flow through here: input changes mutate state +// immediately, runs are debounced 400 ms, requests go out in parallel, and +// results swap in atomically only when every request succeeded. On failure +// the last good results stay on screen. + +export const RUN_DEBOUNCE_MS = 400 + +export type RunState = 'idle' | 'running' | 'error' +export type PlaybackSpeed = 0.5 | 1 | 2 + +export interface SimState { + base: Config + panels: PanelSpec[] + resultsByPanelId: Record + edgesByGraphHash: Record + round: number + playing: boolean + speed: PlaybackSpeed + focusPanelId: string | null + hoveredNode: number | null + runState: RunState + errorMessage: string | null // network/server failure, shown in the ErrorBanner + validationError: string | null // 400 from the engine, shown inline under the controls + urlStateInvalid: boolean // the opened link held malformed state; preset shown instead +} + +function createPanelId(): string { + return crypto.randomUUID() +} + +function clonePanel(panel: PanelSpec): PanelSpec { + return { ...panel, overrides: { ...panel.overrides } } +} + +export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) { + const state: SimState = reactive({ + base: { ...preset.base }, + panels: preset.panels.map(clonePanel), + resultsByPanelId: {}, + edgesByGraphHash: {}, + round: 0, + playing: false, + speed: 1, + focusPanelId: null, + hoveredNode: null, + runState: 'idle', + errorMessage: null, + validationError: null, + urlStateInvalid: false, + }) + + let debounceTimer: ReturnType | null = null + // null accumulates to "all panels"; a set collects single-panel reruns. + let pendingPanelIds: Set | null = new Set() + let runCounter = 0 + // A panel's result may only be written by the latest run that requested + // it; older in-flight responses for the same panel are dropped. + const latestRunIdByPanelId = new Map() + + function effectiveConfig(panel: PanelSpec): Config { + return { ...state.base, ...panel.overrides } + } + + function panelById(panelId: string): PanelSpec | undefined { + return state.panels.find((panel) => panel.id === panelId) + } + + function maxRounds(): number { + return state.panels.reduce((roundsSoFar, panel) => { + const result = state.resultsByPanelId[panel.id] + return result ? Math.max(roundsSoFar, result.numRounds) : roundsSoFar + }, 0) + } + + function scheduleRun(panelId?: string) { + if (panelId === undefined) { + pendingPanelIds = null + } else if (pendingPanelIds !== null) { + pendingPanelIds.add(panelId) + } + if (debounceTimer !== null) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { + debounceTimer = null + const panelIds = pendingPanelIds + pendingPanelIds = new Set() + void runPanels(panelIds) + }, RUN_DEBOUNCE_MS) + } + + async function runPanels(panelIds: Set | null): Promise { + const targets = + panelIds === null ? [...state.panels] : state.panels.filter((panel) => panelIds.has(panel.id)) + if (targets.length === 0) return + const runId = ++runCounter + for (const panel of targets) latestRunIdByPanelId.set(panel.id, runId) + state.runState = 'running' + + const settled = await Promise.allSettled( + targets.map((panel) => + runScenario({ config: effectiveConfig(panel), strategy: panel.strategy }), + ), + ) + + const isLatestRun = runId === runCounter + const firstFailure = settled.find( + (outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected', + ) + if (firstFailure) { + // Keep the last good results untouched; only the latest run may + // surface its error. + if (!isLatestRun) return + const reason: unknown = firstFailure.reason + state.runState = 'error' + if (reason instanceof ApiError && reason.status === 400) { + state.validationError = reason.message + state.errorMessage = null + } else { + state.errorMessage = reason instanceof Error ? reason.message : String(reason) + state.validationError = null + } + return + } + + targets.forEach((panel, index) => { + if (latestRunIdByPanelId.get(panel.id) !== runId) return + const outcome = settled[index] + if (outcome?.status !== 'fulfilled') return + state.resultsByPanelId[panel.id] = outcome.value.result + state.edgesByGraphHash[graphKey(outcome.value.config)] = outcome.value.edges + }) + + if (!isLatestRun) return + state.runState = 'idle' + state.errorMessage = null + state.validationError = null + state.round = maxRounds() + syncToUrl() + } + + function setBaseField( + field: FieldName, + value: Config[FieldName], + ) { + state.base[field] = value + scheduleRun() + } + + function addPanel(): PanelSpec | null { + if (state.panels.length >= MAX_PANELS) return null + const panel: PanelSpec = { + id: createPanelId(), + label: `Scenario ${state.panels.length + 1}`, + strategy: StrategyNone, + overrides: {}, + } + state.panels.push(panel) + scheduleRun(panel.id) + return panel + } + + function duplicatePanel(panelId: string): PanelSpec | null { + if (state.panels.length >= MAX_PANELS) return null + const source = panelById(panelId) + if (source === undefined) return null + const copy: PanelSpec = { + ...clonePanel(source), + id: createPanelId(), + label: `${source.label} copy`, + } + state.panels.push(copy) + const sourceResult = state.resultsByPanelId[source.id] + if (sourceResult) { + // Identical config, identical result: seed the copy instead of + // re-running the same scenario. + state.resultsByPanelId[copy.id] = sourceResult + syncToUrl() + } else { + scheduleRun(copy.id) + } + return copy + } + + function removePanel(panelId: string) { + if (state.panels.length <= 1) return // the last panel cannot be removed + const index = state.panels.findIndex((panel) => panel.id === panelId) + if (index < 0) return + state.panels.splice(index, 1) + delete state.resultsByPanelId[panelId] + if (state.focusPanelId === panelId) state.focusPanelId = null + syncToUrl() + } + + function renamePanel(panelId: string, label: string) { + const panel = panelById(panelId) + if (panel === undefined) return + panel.label = label + syncToUrl() + } + + function setPanelStrategy(panelId: string, strategy: Strategy) { + const panel = panelById(panelId) + if (panel === undefined || panel.strategy === strategy) return + panel.strategy = strategy + scheduleRun(panelId) + } + + function setPanelOverride( + panelId: string, + field: FieldName, + value: Config[FieldName], + ) { + const panel = panelById(panelId) + if (panel === undefined) return + if (value === state.base[field]) { + // Typing the base value back is "no override" (spec 5.7). + delete panel.overrides[field] + } else { + panel.overrides[field] = value + } + scheduleRun(panelId) + } + + function clearPanelOverride(panelId: string, field: keyof Config) { + const panel = panelById(panelId) + if (panel === undefined || !(field in panel.overrides)) return + delete panel.overrides[field] + scheduleRun(panelId) + } + + function setFocusPanel(panelId: string | null) { + state.focusPanelId = panelId + syncToUrl() + } + + function syncToUrl() { + if (typeof window === 'undefined') return + const query = serializeUrlState(state.base, state.panels, state.focusPanelId, preset) + window.history.replaceState(null, '', `${window.location.pathname}${query}`) + } + + // Parse the opened URL (or fall back to the preset) and run every panel. + // The initial run is not debounced: nothing is on screen yet. + function initialize( + search: string = typeof window === 'undefined' ? '' : window.location.search, + ): Promise { + const parsed = parseUrlState(search, preset, createPanelId) + if (parsed === null) { + state.urlStateInvalid = true // preset defaults are already loaded + } else { + state.base = parsed.base + state.panels = parsed.panels + state.focusPanelId = + parsed.focusIndex !== null ? (parsed.panels[parsed.focusIndex]?.id ?? null) : null + } + return runPanels(null) + } + + function retry(): Promise { + return runPanels(null) + } + + return { + state, + preset, + effectiveConfig, + maxRounds, + initialize, + retry, + setBaseField, + addPanel, + duplicatePanel, + removePanel, + renamePanel, + setPanelStrategy, + setPanelOverride, + clearPanelOverride, + setFocusPanel, + } +} + +export type SimStore = ReturnType + +let activeStore: SimStore | null = null + +export function useSimStore(): SimStore { + activeStore ??= createSimStore() + return activeStore +} diff --git a/web/src/lib/__tests__/format.spec.ts b/web/src/lib/__tests__/format.spec.ts new file mode 100644 index 0000000..6d6eb7f --- /dev/null +++ b/web/src/lib/__tests__/format.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { formatPct, roundPct } from '../format' + +describe('formatPct', () => { + it('rounds 99/120 to 83%, the class of mismatch Go printf produced', () => { + const reachedPct = (99 / 120) * 100 // 82.5; Go's %.0f said 82 + expect(formatPct(reachedPct)).toBe('83%') + expect(roundPct(reachedPct)).toBe(83) + }) + + it('rounds down below the midpoint', () => { + expect(formatPct((7 / 120) * 100)).toBe('6%') + }) + + it('keeps whole numbers untouched', () => { + expect(formatPct(0)).toBe('0%') + expect(formatPct(100)).toBe('100%') + }) +}) diff --git a/web/src/lib/__tests__/urlState.spec.ts b/web/src/lib/__tests__/urlState.spec.ts new file mode 100644 index 0000000..dae83ce --- /dev/null +++ b/web/src/lib/__tests__/urlState.spec.ts @@ -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() + }) +}) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 7dd3210..29ef2da 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,15 +1,45 @@ import type { Config } from '@/types/engine' -import type { ComparisonResponse } from '@/types/api' +import type { ComparisonResponse, ScenarioRequest, ScenarioResponse } from '@/types/api' // Thin typed wrappers around the JSON API. The types come from // src/types/, which is generated from the Go structs (single source of // truth); nothing here redefines a shape. +// The API answers invalid input with 400 and {"error": "..."}; carrying the +// status lets the store route engine validation errors to the inline spot +// under the controls instead of the error banner. +export class ApiError extends Error { + readonly status: number + + constructor(status: number, message: string) { + super(message) + this.name = 'ApiError' + this.status = status + } +} + +function apiErrorMessage(response: Response, body: string): string { + try { + const parsed: unknown = JSON.parse(body) + if ( + parsed !== null && + typeof parsed === 'object' && + 'error' in parsed && + typeof parsed.error === 'string' + ) { + return parsed.error + } + } catch { + // not JSON; fall through to the status line + } + return `${response.status} ${response.statusText}${body ? `: ${body}` : ''}` +} + async function requestJSON(input: string, init?: RequestInit): Promise { const response = await fetch(input, init) if (!response.ok) { const body = await response.text() - throw new Error(`${response.status} ${response.statusText}: ${body}`) + throw new ApiError(response.status, apiErrorMessage(response, body)) } return response.json() as Promise } @@ -25,3 +55,11 @@ export function runComparison(config: Config): Promise { body: JSON.stringify(config), }) } + +export function runScenario(request: ScenarioRequest): Promise { + return requestJSON('/api/scenario', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }) +} diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts new file mode 100644 index 0000000..db8bbcd --- /dev/null +++ b/web/src/lib/format.ts @@ -0,0 +1,11 @@ +// The single rounding rule for percentages (spec section 6): round to a +// whole percent. Every surface (hero, panels, strip, chart labels, table) +// goes through here so 99/120 reads as 83% everywhere, never 82%. + +export function roundPct(percent: number): number { + return Math.round(percent) +} + +export function formatPct(percent: number): string { + return `${roundPct(percent)}%` +} diff --git a/web/src/lib/graph.ts b/web/src/lib/graph.ts new file mode 100644 index 0000000..6216739 --- /dev/null +++ b/web/src/lib/graph.ts @@ -0,0 +1,7 @@ +import type { Config } from '@/types/engine' + +// Panels that agree on these four fields get the same topology from the +// engine, so the frontend caches edges (and later, layouts) per key. +export function graphKey(config: Config): string { + return `${config.numStudents}|${config.edgesPerNode}|${config.triangleProb}|${config.graphSeed}` +} diff --git a/web/src/lib/urlState.ts b/web/src/lib/urlState.ts new file mode 100644 index 0000000..fad2da1 --- /dev/null +++ b/web/src/lib/urlState.ts @@ -0,0 +1,153 @@ +import type { Config, Strategy } from '@/types/engine' +import { StrategyMostConnected, StrategyNone, StrategyRandom } from '@/types/engine' +import { MAX_PANELS, type PanelSpec, type StudyPreset } from '@/presets/types' + +// URL state scheme (spec section 7): human-readable query parameters. +// Base fields appear by JSON name only when they differ from the preset; +// each panel is one repeated `panel=label~strategy~overrides` param, +// omitted entirely while the panel set equals the preset's; `focus=` holds +// the focused panel index. Unknown params are ignored; anything malformed +// makes the whole parse fail so the app falls back to the preset. + +const KNOWN_STRATEGIES: readonly Strategy[] = [StrategyNone, StrategyRandom, StrategyMostConnected] + +export interface ParsedUrlState { + base: Config + panels: PanelSpec[] + focusIndex: number | null +} + +// The runtime base config carries exactly the generated Config fields, so +// deriving names from it stays in sync with the Go structs automatically. +function configFieldNames(base: Config): (keyof Config)[] { + return Object.keys(base) as (keyof Config)[] +} + +function isConfigField(base: Config, name: string): name is keyof Config { + return Object.hasOwn(base, name) +} + +function encodePanelLabel(label: string): string { + // encodeURIComponent leaves ~ alone, but ~ is our segment separator. + return encodeURIComponent(label).replaceAll('~', '%7E') +} + +function encodePanel(panel: PanelSpec): string { + const overridePairs = Object.entries(panel.overrides).map(([field, value]) => `${field}:${value}`) + return `${encodePanelLabel(panel.label)}~${panel.strategy}~${overridePairs.join(',')}` +} + +function overridesEqual(a: Partial, b: Partial): boolean { + const aFields = Object.keys(a) as (keyof Config)[] + return aFields.length === Object.keys(b).length && aFields.every((field) => a[field] === b[field]) +} + +function panelsMatchPreset(panels: PanelSpec[], preset: StudyPreset): boolean { + return ( + panels.length === preset.panels.length && + panels.every((panel, index) => { + const presetPanel = preset.panels[index] + return ( + presetPanel !== undefined && + panel.label === presetPanel.label && + panel.strategy === presetPanel.strategy && + overridesEqual(panel.overrides, presetPanel.overrides) + ) + }) + ) +} + +export function serializeUrlState( + base: Config, + panels: PanelSpec[], + focusPanelId: string | null, + preset: StudyPreset, +): string { + const parts: string[] = [] + for (const field of configFieldNames(base)) { + if (base[field] !== preset.base[field]) parts.push(`${field}=${base[field]}`) + } + if (!panelsMatchPreset(panels, preset)) { + for (const panel of panels) parts.push(`panel=${encodePanel(panel)}`) + } + if (focusPanelId !== null) { + const focusIndex = panels.findIndex((panel) => panel.id === focusPanelId) + if (focusIndex >= 0) parts.push(`focus=${focusIndex}`) + } + return parts.length > 0 ? `?${parts.join('&')}` : '' +} + +function decodePanel( + base: Config, + rawValue: string, + createPanelId: () => string, +): PanelSpec | null { + const segments = rawValue.split('~') + if (segments.length !== 3) return null + const [rawLabel = '', strategy = '', rawOverrides = ''] = segments + let label: string + try { + label = decodeURIComponent(rawLabel) + } catch { + return null + } + if (label === '') return null + if (!KNOWN_STRATEGIES.includes(strategy)) return null + const overrides: Partial = {} + if (rawOverrides !== '') { + for (const pair of rawOverrides.split(',')) { + const colonAt = pair.indexOf(':') + if (colonAt < 0) return null + const field = pair.slice(0, colonAt) + const value = Number(pair.slice(colonAt + 1)) + if (!isConfigField(base, field) || !Number.isFinite(value)) return null + overrides[field] = value + } + } + return { id: createPanelId(), label, strategy, overrides } +} + +export function parseUrlState( + search: string, + preset: StudyPreset, + createPanelId: () => string, +): ParsedUrlState | null { + const query = search.startsWith('?') ? search.slice(1) : search + const base: Config = { ...preset.base } + const urlPanels: PanelSpec[] = [] + let sawPanelParam = false + let focusIndex: number | null = null + + for (const rawParam of query.split('&')) { + if (rawParam === '') continue + const equalsAt = rawParam.indexOf('=') + if (equalsAt < 0) continue // bare keys are ignored like unknown params + const key = rawParam.slice(0, equalsAt) + const rawValue = rawParam.slice(equalsAt + 1) + if (key === 'panel') { + sawPanelParam = true + const panel = decodePanel(base, rawValue, createPanelId) + if (panel === null) return null + urlPanels.push(panel) + } else if (key === 'focus') { + const parsedIndex = Number(rawValue) + if (rawValue === '' || !Number.isInteger(parsedIndex) || parsedIndex < 0) return null + focusIndex = parsedIndex + } else if (isConfigField(base, key)) { + const value = Number(rawValue) + if (rawValue === '' || !Number.isFinite(value)) return null + base[key] = value + } + } + + if (sawPanelParam && urlPanels.length > MAX_PANELS) return null + const panels = sawPanelParam + ? urlPanels + : preset.panels.map((panel) => ({ + ...panel, + id: createPanelId(), + overrides: { ...panel.overrides }, + })) + if (focusIndex !== null && focusIndex >= panels.length) return null + return { base, panels, focusIndex } +} diff --git a/web/src/presets/deepfake-school.ts b/web/src/presets/deepfake-school.ts new file mode 100644 index 0000000..9eea8dc --- /dev/null +++ b/web/src/presets/deepfake-school.ts @@ -0,0 +1,49 @@ +import { StrategyMostConnected, StrategyNone, StrategyRandom } from '@/types/engine' +import type { StudyPreset } from './types' + +// The one study this deployment tells. A different research question is a +// new preset file plus a changed import in the app; no component changes. + +export const deepfakeSchoolPreset: StudyPreset = { + headline: 'How a non‑consensual deepfake spreads through a school', + narrative: + 'One simulated year group, 120 students. Educate the same 30% of them, ' + + 'but change who: the fake reaches {0} of the school with no program, ' + + '{1} educating at random, and {2} educating the best‑connected students.', + disclaimerShort: 'Illustrative model, not validated', + disclaimerLong: + 'spreadlab runs a seeded agent-based toy simulation: a synthetic ' + + 'friendship network of one school year group, a fixed chance to forward ' + + 'the fake along each friendship per round, and an education program that ' + + 'teaches some students to refuse. Its parameters are chosen for ' + + 'illustration, not fitted to data, so it is not a validated prediction ' + + 'of any real school. Use it to build intuition about who to educate, ' + + 'not to forecast outcomes.', + 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-no-program', label: 'No program', strategy: StrategyNone, overrides: {} }, + { + id: 'preset-random', + label: 'Educate 30% at random', + strategy: StrategyRandom, + overrides: {}, + }, + { + id: 'preset-most-connected', + label: 'Educate best-connected 30%', + strategy: StrategyMostConnected, + overrides: {}, + }, + ], +} diff --git a/web/src/presets/types.ts b/web/src/presets/types.ts new file mode 100644 index 0000000..a21e953 --- /dev/null +++ b/web/src/presets/types.ts @@ -0,0 +1,26 @@ +import type { Config, Strategy } from '@/types/engine' + +// Frontend-only presentation types (spec section 1). They describe study +// copy and the initial scenario set, not simulation data: Config, Result +// and Strategy stay imported from the generated types. + +export interface PanelSpec { + id: string // stable client id + label: string // user editable + strategy: Strategy + overrides: Partial // sparse diff against the base config +} + +export interface StudyPreset { + headline: string // hero h1 + // Hero paragraph; {0} {1} {2}... placeholders are replaced by each + // panel's reached%, formatted by the shared formatPct. + narrative: string + disclaimerShort: string // badge text + disclaimerLong: string // About popover body + toneThresholdPct: number // reached% <= threshold renders "good" (teal) + base: Config // the default world + panels: PanelSpec[] // initial panels +} + +export const MAX_PANELS = 6 diff --git a/web/tsconfig.vitest.json b/web/tsconfig.vitest.json index ff1d8c9..8d12beb 100644 --- a/web/tsconfig.vitest.json +++ b/web/tsconfig.vitest.json @@ -8,8 +8,10 @@ "compilerOptions": { // Vitest runs in a different environment than the application code. - // Adjust lib and types accordingly. - "lib": [], + // Adjust lib and types accordingly. The language lib stays ES2022 (as in + // the app config) so app code imported by tests type-checks the same; + // only the DOM globals come from jsdom instead. + "lib": ["ES2022"], "types": ["node", "jsdom"], // `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.