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
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,2 +1,5 @@
|
|||
# binary from `go build ./cmd/spreadlab`
|
||||
/spreadlab
|
||||
|
||||
# prettier cache (npm run format in web/ walks up here)
|
||||
/node_modules/
|
||||
|
|
|
|||
2
web/.prettierignore
Normal file
2
web/.prettierignore
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Generated by tygo from the Go structs; must stay byte-identical to its output (CI drift guard).
|
||||
src/types/
|
||||
270
web/src/composables/__tests__/useSimStore.spec.ts
Normal file
270
web/src/composables/__tests__/useSimStore.spec.ts
Normal file
|
|
@ -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<typeof import('@/lib/api')>()
|
||||
return { ...actual, runScenario: vi.fn<typeof actual.runScenario>() }
|
||||
})
|
||||
|
||||
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=')
|
||||
})
|
||||
})
|
||||
298
web/src/composables/useSimStore.ts
Normal file
298
web/src/composables/useSimStore.ts
Normal file
|
|
@ -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<string, Result>
|
||||
edgesByGraphHash: Record<string, number[][]>
|
||||
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<typeof setTimeout> | null = null
|
||||
// null accumulates to "all panels"; a set collects single-panel reruns.
|
||||
let pendingPanelIds: Set<string> | 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<string, number>()
|
||||
|
||||
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<string> | null): Promise<void> {
|
||||
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<FieldName extends keyof Config>(
|
||||
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<FieldName extends keyof Config>(
|
||||
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<void> {
|
||||
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<void> {
|
||||
return runPanels(null)
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
preset,
|
||||
effectiveConfig,
|
||||
maxRounds,
|
||||
initialize,
|
||||
retry,
|
||||
setBaseField,
|
||||
addPanel,
|
||||
duplicatePanel,
|
||||
removePanel,
|
||||
renamePanel,
|
||||
setPanelStrategy,
|
||||
setPanelOverride,
|
||||
clearPanelOverride,
|
||||
setFocusPanel,
|
||||
}
|
||||
}
|
||||
|
||||
export type SimStore = ReturnType<typeof createSimStore>
|
||||
|
||||
let activeStore: SimStore | null = null
|
||||
|
||||
export function useSimStore(): SimStore {
|
||||
activeStore ??= createSimStore()
|
||||
return activeStore
|
||||
}
|
||||
19
web/src/lib/__tests__/format.spec.ts
Normal file
19
web/src/lib/__tests__/format.spec.ts
Normal file
|
|
@ -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%')
|
||||
})
|
||||
})
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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<T>(input: string, init?: RequestInit): Promise<T> {
|
||||
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<T>
|
||||
}
|
||||
|
|
@ -25,3 +55,11 @@ export function runComparison(config: Config): Promise<ComparisonResponse> {
|
|||
body: JSON.stringify(config),
|
||||
})
|
||||
}
|
||||
|
||||
export function runScenario(request: ScenarioRequest): Promise<ScenarioResponse> {
|
||||
return requestJSON<ScenarioResponse>('/api/scenario', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
11
web/src/lib/format.ts
Normal file
11
web/src/lib/format.ts
Normal file
|
|
@ -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)}%`
|
||||
}
|
||||
7
web/src/lib/graph.ts
Normal file
7
web/src/lib/graph.ts
Normal file
|
|
@ -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}`
|
||||
}
|
||||
153
web/src/lib/urlState.ts
Normal file
153
web/src/lib/urlState.ts
Normal file
|
|
@ -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<Config>, b: Partial<Config>): 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<Config> = {}
|
||||
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 }
|
||||
}
|
||||
49
web/src/presets/deepfake-school.ts
Normal file
49
web/src/presets/deepfake-school.ts
Normal file
|
|
@ -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: {},
|
||||
},
|
||||
],
|
||||
}
|
||||
26
web/src/presets/types.ts
Normal file
26
web/src/presets/types.ts
Normal file
|
|
@ -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<Config> // 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue