From c935071bd1323f8c6ac279130a418559a63359cb Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Wed, 10 Jun 2026 13:47:01 +0200 Subject: [PATCH] web: parity page renders the three-scenario comparison from live data App.vue fetches the default config, posts it to /api/comparison, and renders ComparisonTable; the Vite dev server proxies /api to the Go server so the browser sees one origin. src/lib/api.ts is the only fetch code and uses exclusively generated types: no shape is defined on the frontend. Scaffold example components removed. Engine fix surfaced by the smoke test: a nil Go slice marshals to JSON null, violating the generated 'educated: number[]' contract; RunScenario now returns an empty slice instead. Verified end to end: curl through the Vite proxy returns the golden 99/70/7. Vitest covers the table rendering; type-check, oxlint, eslint clean. This completes the milestone 2 parity check. --- internal/engine/scenario.go | 3 + web/index.html | 2 +- web/src/App.vue | 65 ++++++------- web/src/assets/logo.svg | 1 - web/src/components/ComparisonTable.vue | 63 ++++++++++++ web/src/components/HelloWorld.vue | 41 -------- web/src/components/TheWelcome.vue | 95 ------------------- web/src/components/WelcomeItem.vue | 87 ----------------- .../__tests__/ComparisonTable.spec.ts | 68 +++++++++++++ .../components/__tests__/HelloWorld.spec.ts | 11 --- web/src/components/icons/IconCommunity.vue | 7 -- .../components/icons/IconDocumentation.vue | 7 -- web/src/components/icons/IconEcosystem.vue | 7 -- web/src/components/icons/IconSupport.vue | 7 -- web/src/components/icons/IconTooling.vue | 19 ---- web/src/lib/api.ts | 27 ++++++ web/vite.config.ts | 7 ++ 17 files changed, 198 insertions(+), 319 deletions(-) delete mode 100644 web/src/assets/logo.svg create mode 100644 web/src/components/ComparisonTable.vue delete mode 100644 web/src/components/HelloWorld.vue delete mode 100644 web/src/components/TheWelcome.vue delete mode 100644 web/src/components/WelcomeItem.vue create mode 100644 web/src/components/__tests__/ComparisonTable.spec.ts delete mode 100644 web/src/components/__tests__/HelloWorld.spec.ts delete mode 100644 web/src/components/icons/IconCommunity.vue delete mode 100644 web/src/components/icons/IconDocumentation.vue delete mode 100644 web/src/components/icons/IconEcosystem.vue delete mode 100644 web/src/components/icons/IconSupport.vue delete mode 100644 web/src/components/icons/IconTooling.vue create mode 100644 web/src/lib/api.ts diff --git a/internal/engine/scenario.go b/internal/engine/scenario.go index e229cdd..89262a4 100644 --- a/internal/engine/scenario.go +++ b/internal/engine/scenario.go @@ -93,6 +93,9 @@ func RunScenario(config Config, strategy Strategy) (Result, error) { return Result{}, fmt.Errorf("scenario: unknown strategy %q", strategy) } + if educated == nil { + educated = []int{} // a nil slice marshals to JSON null, not [] + } cascade := RunCascade(graph, config.Origin, config.ForwardProb, educated, thresholds) return Result{ Strategy: strategy, diff --git a/web/index.html b/web/index.html index 9e5fc8f..32253a8 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ - Vite App + spreadlab
diff --git a/web/src/App.vue b/web/src/App.vue index d05208d..342b1f1 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,47 +1,40 @@ diff --git a/web/src/assets/logo.svg b/web/src/assets/logo.svg deleted file mode 100644 index 7565660..0000000 --- a/web/src/assets/logo.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/web/src/components/ComparisonTable.vue b/web/src/components/ComparisonTable.vue new file mode 100644 index 0000000..eb88996 --- /dev/null +++ b/web/src/components/ComparisonTable.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/web/src/components/HelloWorld.vue b/web/src/components/HelloWorld.vue deleted file mode 100644 index a2eabd1..0000000 --- a/web/src/components/HelloWorld.vue +++ /dev/null @@ -1,41 +0,0 @@ - - - - - diff --git a/web/src/components/TheWelcome.vue b/web/src/components/TheWelcome.vue deleted file mode 100644 index 8b731d9..0000000 --- a/web/src/components/TheWelcome.vue +++ /dev/null @@ -1,95 +0,0 @@ - - - diff --git a/web/src/components/WelcomeItem.vue b/web/src/components/WelcomeItem.vue deleted file mode 100644 index 6d7086a..0000000 --- a/web/src/components/WelcomeItem.vue +++ /dev/null @@ -1,87 +0,0 @@ - - - diff --git a/web/src/components/__tests__/ComparisonTable.spec.ts b/web/src/components/__tests__/ComparisonTable.spec.ts new file mode 100644 index 0000000..810c3b4 --- /dev/null +++ b/web/src/components/__tests__/ComparisonTable.spec.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import ComparisonTable from '../ComparisonTable.vue' +import type { ComparisonResponse } from '@/types/api' + +// A small hand-made comparison; the real numbers come from the API and are +// pinned by the Go tests. Here we only care that the table renders them. +const comparison: ComparisonResponse = { + config: { + numStudents: 10, + edgesPerNode: 2, + triangleProb: 0.4, + forwardProb: 0.5, + numEducated: 3, + origin: 0, + graphSeed: 1, + thresholdSeed: 2, + educationSeed: 3, + }, + results: [ + { + strategy: 'none', + educated: [], + reachedAtRound: [0, 1, 1, 2, 2, 2, 3, -1, -1, -1], + numReached: 7, + numRounds: 4, + reachedPct: 70, + }, + { + strategy: 'random', + educated: [2, 5, 8], + reachedAtRound: [0, 1, 1, 2, -1, -1, -1, -1, -1, -1], + numReached: 4, + numRounds: 3, + reachedPct: 40, + }, + { + strategy: 'most-connected', + educated: [1, 2, 3], + reachedAtRound: [0, 1, -1, -1, -1, -1, -1, -1, -1, -1], + numReached: 2, + numRounds: 2, + reachedPct: 20, + }, + ], +} + +describe('ComparisonTable', () => { + it('renders one row per strategy with its reach', () => { + const wrapper = mount(ComparisonTable, { props: { comparison } }) + + const rows = wrapper.findAll('tbody tr') + expect(rows).toHaveLength(3) + expect(rows[0]!.text()).toContain('No program') + expect(rows[0]!.text()).toContain('7 / 10') + expect(rows[2]!.text()).toContain('Educate the most connected') + expect(rows[2]!.text()).toContain('20%') + }) + + it('falls back to the raw strategy name for unknown strategies', () => { + const unknown: ComparisonResponse = { + config: comparison.config, + results: [{ ...comparison.results[0]!, strategy: 'telepathy' }], + } + const wrapper = mount(ComparisonTable, { props: { comparison: unknown } }) + expect(wrapper.find('tbody th').text()).toBe('telepathy') + }) +}) diff --git a/web/src/components/__tests__/HelloWorld.spec.ts b/web/src/components/__tests__/HelloWorld.spec.ts deleted file mode 100644 index 2533202..0000000 --- a/web/src/components/__tests__/HelloWorld.spec.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, it, expect } from 'vitest' - -import { mount } from '@vue/test-utils' -import HelloWorld from '../HelloWorld.vue' - -describe('HelloWorld', () => { - it('renders properly', () => { - const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } }) - expect(wrapper.text()).toContain('Hello Vitest') - }) -}) diff --git a/web/src/components/icons/IconCommunity.vue b/web/src/components/icons/IconCommunity.vue deleted file mode 100644 index 2dc8b05..0000000 --- a/web/src/components/icons/IconCommunity.vue +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/web/src/components/icons/IconDocumentation.vue b/web/src/components/icons/IconDocumentation.vue deleted file mode 100644 index 6d4791c..0000000 --- a/web/src/components/icons/IconDocumentation.vue +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/web/src/components/icons/IconEcosystem.vue b/web/src/components/icons/IconEcosystem.vue deleted file mode 100644 index c3a4f07..0000000 --- a/web/src/components/icons/IconEcosystem.vue +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/web/src/components/icons/IconSupport.vue b/web/src/components/icons/IconSupport.vue deleted file mode 100644 index 7452834..0000000 --- a/web/src/components/icons/IconSupport.vue +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/web/src/components/icons/IconTooling.vue b/web/src/components/icons/IconTooling.vue deleted file mode 100644 index 660598d..0000000 --- a/web/src/components/icons/IconTooling.vue +++ /dev/null @@ -1,19 +0,0 @@ - - diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..7dd3210 --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,27 @@ +import type { Config } from '@/types/engine' +import type { ComparisonResponse } 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. + +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}`) + } + return response.json() as Promise +} + +export function fetchDefaultConfig(): Promise { + return requestJSON('/api/config/default') +} + +export function runComparison(config: Config): Promise { + return requestJSON('/api/comparison', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }) +} diff --git a/web/vite.config.ts b/web/vite.config.ts index 4217010..4939dcf 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -15,4 +15,11 @@ export default defineConfig({ '@': fileURLToPath(new URL('./src', import.meta.url)) }, }, + server: { + // The Go server owns /api; run it next to `npm run dev` with: + // go run ./cmd/spreadlab + proxy: { + '/api': 'http://localhost:8080', + }, + }, })