feat: participant flow screens setup/module1/module2/add — TDD

All four workshop phases are now real screens, replacing WorkshopStub:
- EnvSetup (/workshop/setup): device check + serial self-test (no stats
  pollution) + provider/model selection; gated Proceed
- Module1 (/workshop/module1): live IMU feed via useSerial, actor map,
  ADD Layer 1 capture; gated on observed frames + L1 goal
- Module2 (/workshop/module2): harness tuner + live TOML preview, test
  triggers that classify against the tuned threshold, ADD L2/L3
- AddBuilder (/workshop/add): ADD L4/L5, assembled AddDocument, print-to-PDF
  export, deterministic submission code; completes the run
- New components: ActorMap, HarnessTuner, TriggerButtons, AddDocument

23 new tests; suite 86/86 green, typecheck + lint clean, build OK.
Only /admin and /judge remain stubbed (need the backend).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-16 19:13:07 -07:00
co-authored by Claude Opus 4.8
parent 965af7e5c0
commit 7c141dcd89
14 changed files with 968 additions and 4 deletions
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { Module2 } from './Module2'
import { useSession } from '@/store/session'
function renderPage() {
return render(
<MemoryRouter>
<Module2 />
</MemoryRouter>,
)
}
describe('Module2', () => {
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
})
it('renders the phase strip set to m2 and the heading', () => {
renderPage()
expect(screen.getByRole('heading', { name: /harness engineering/i })).toBeInTheDocument()
expect(
screen.getByTestId('phase-strip').querySelector('[data-phase="m2"]'),
).toHaveAttribute('data-state', 'active')
})
it('tunes a threshold and reflects it in the TOML preview', async () => {
const user = userEvent.setup()
renderPage()
const input = screen.getByLabelText(/threshold · g/i)
await user.clear(input)
await user.type(input, '1.2')
expect(useSession.getState().harness.thresholdG).toBe(1.2)
expect(screen.getByTestId('harness-toml')).toHaveTextContent('threshold_g = 1.2')
})
it('fires a trigger that classifies against the tuned threshold', async () => {
const user = userEvent.setup()
renderPage()
// tune low so the impact frame is unambiguously critical
const g = screen.getByLabelText(/threshold · g/i)
await user.clear(g)
await user.type(g, '0.5')
await user.click(screen.getByRole('button', { name: /impact/i }))
expect(useSession.getState().stats.critical).toBe(1)
})
it('gates Proceed until a trigger fired and L2 + L3 are filled', async () => {
const user = userEvent.setup()
renderPage()
const proceed = screen.getByRole('button', { name: /proceed/i })
expect(proceed).toBeDisabled()
await user.type(screen.getByLabelText(/layer 2/i), 'escalate on critical')
await user.type(screen.getByLabelText(/layer 3/i), 'drive damper on critical')
expect(proceed).toBeDisabled() // no trigger fired yet
await user.click(screen.getByRole('button', { name: /shake/i }))
expect(proceed).toBeEnabled()
})
it('marks m2 complete on Proceed', async () => {
const user = userEvent.setup()
renderPage()
await user.type(screen.getByLabelText(/layer 2/i), 'L2 text')
await user.type(screen.getByLabelText(/layer 3/i), 'L3 text')
await user.click(screen.getByRole('button', { name: /impact/i }))
await user.click(screen.getByRole('button', { name: /proceed/i }))
expect(useSession.getState().phases.m2).toBe(true)
})
})