feat: Web Serial bridge + harness/submission libs — TDD

- src/lib/serial.ts: serialSupported, parseLine, classifyFrame (pure),
  requestPort with deterministic mock fallback for tests/unsupported browsers
- src/lib/useSerial.ts: React hook streaming frames into stats via recordEvent,
  classifying against the live (re-readable) harness; inject() for test triggers
- src/lib/harness.ts: harnessToToml renderer
- src/lib/submission.ts: deterministic makeSubmissionCode
- TeamRegistration.onConnect now uses requestPort (sync mock fallback keeps
  the existing flow test green)

20 new tests; suite 49/49 green, typecheck + lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-16 19:05:08 -07:00
co-authored by Claude Opus 4.8
parent 8163dd7829
commit 66191217ec
9 changed files with 490 additions and 3 deletions
+32
View File
@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest'
import { harnessToToml } from './harness'
import type { Harness } from '@/store/session'
const harness: Harness = {
thresholdG: 0.8,
thresholdDb: 65,
callsPerMinute: 8,
provider: 'anthropic',
model: 'claude-haiku-4-5',
}
describe('harnessToToml', () => {
it('renders a [harness] section with the tuned thresholds', () => {
const toml = harnessToToml(harness)
expect(toml).toContain('[harness]')
expect(toml).toContain('threshold_g = 0.8')
expect(toml).toContain('threshold_db = 65')
expect(toml).toContain('calls_per_minute = 8')
})
it('renders a [provider] section with quoted string values', () => {
const toml = harnessToToml(harness)
expect(toml).toContain('[provider]')
expect(toml).toContain('name = "anthropic"')
expect(toml).toContain('model = "claude-haiku-4-5"')
})
it('reflects live edits to the threshold values', () => {
expect(harnessToToml({ ...harness, thresholdG: 1.25 })).toContain('threshold_g = 1.25')
})
})
+16
View File
@@ -0,0 +1,16 @@
import type { Harness } from '@/store/session'
/** Render the live harness config as a `harness.toml`-style document. */
export function harnessToToml(h: Harness): string {
return [
'[harness]',
`threshold_g = ${h.thresholdG}`,
`threshold_db = ${h.thresholdDb}`,
`calls_per_minute = ${h.callsPerMinute}`,
'',
'[provider]',
`name = "${h.provider}"`,
`model = "${h.model}"`,
'',
].join('\n')
}
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { serialSupported, parseLine, classifyFrame, requestPort } from './serial'
describe('serialSupported', () => {
it('is false when navigator.serial is absent (jsdom)', () => {
expect(serialSupported()).toBe(false)
})
})
describe('parseLine', () => {
it('parses a 4-field CSV frame', () => {
expect(parseLine('0.10,0.00,0.98,42')).toEqual({ ax: 0.1, ay: 0, az: 0.98, db: 42, t: 0 })
})
it('tolerates surrounding whitespace and trailing newline', () => {
expect(parseLine(' 1,2,3,4 \r')).toEqual({ ax: 1, ay: 2, az: 3, db: 4, t: 0 })
})
it('returns null for junk, partial, or non-numeric lines', () => {
expect(parseLine('garbage')).toBeNull()
expect(parseLine('1,2,3')).toBeNull()
expect(parseLine('1,2,3,x')).toBeNull()
expect(parseLine('')).toBeNull()
})
})
describe('classifyFrame', () => {
const h = { thresholdG: 0.8, thresholdDb: 65 }
const frame = (ax: number, db: number) => ({ ax, ay: 0, az: 0, db, t: 0 })
it('is nominal below both thresholds', () => {
expect(classifyFrame(frame(0.2, 40), h)).toBe('nominal')
})
it('is anomalous when only the g-magnitude exceeds threshold', () => {
expect(classifyFrame(frame(1.5, 40), h)).toBe('anomalous')
})
it('is anomalous when only the dB level exceeds threshold', () => {
expect(classifyFrame(frame(0.2, 80), h)).toBe('anomalous')
})
it('is critical when both g-magnitude and dB exceed their thresholds', () => {
expect(classifyFrame(frame(1.5, 80), h)).toBe('critical')
})
})
describe('requestPort (mock fallback)', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('returns a mocked connection when Web Serial is unsupported', async () => {
const conn = await requestPort()
expect(conn.mocked).toBe(true)
expect(conn.port).toBe('mock-serial://uno-r4-wifi')
await conn.close()
})
it('emits synthetic frames on a timer and stops on unsubscribe/close', async () => {
const conn = await requestPort()
const seen: number[] = []
const unsub = conn.onFrame((f) => seen.push(f.t))
await vi.advanceTimersByTimeAsync(1200)
expect(seen.length).toBeGreaterThan(0)
const afterUnsub = seen.length
unsub()
await vi.advanceTimersByTimeAsync(1200)
expect(seen.length).toBe(afterUnsub)
await conn.close()
})
})
+181
View File
@@ -0,0 +1,181 @@
// Web Serial bridge to the Arduino UNO R4 WiFi, with a deterministic mock
// fallback so the workshop flow + tests run without hardware (or in browsers
// that lack the Web Serial API). Modules 1 & 2 consume `requestPort` via the
// `useSerial` hook; the classifier is pure so it is trivially unit-tested.
export interface ImuFrame {
ax: number
ay: number
az: number
db: number
t: number
}
export type SerialEvent = 'nominal' | 'anomalous' | 'critical'
export interface HarnessThresholds {
thresholdG: number
thresholdDb: number
}
export interface SerialConn {
port: string
mocked: boolean
onFrame(cb: (f: ImuFrame) => void): () => void
close(): Promise<void>
}
const MOCK_PORT = 'mock-serial://uno-r4-wifi'
const BAUD_RATE = 115200
export function serialSupported(): boolean {
return typeof navigator !== 'undefined' && 'serial' in navigator
}
/** Parse one line of the firmware wire format: `ax,ay,az,db` (CSV). */
export function parseLine(line: string): ImuFrame | null {
const parts = line.trim().split(',')
if (parts.length !== 4) return null
const nums = parts.map((p) => Number(p.trim()))
if (nums.some((n) => !Number.isFinite(n))) return null
const [ax, ay, az, db] = nums
return { ax, ay, az, db, t: 0 }
}
/**
* Map a frame to a verdict against the tuned harness.
* Rule (pending firmware sign-off): magnitude is the raw acceleration vector
* length; critical requires BOTH the g-magnitude and the dB level to exceed
* their thresholds, anomalous requires either, otherwise nominal.
*/
export function classifyFrame(f: ImuFrame, h: HarnessThresholds): SerialEvent {
const mag = Math.sqrt(f.ax * f.ax + f.ay * f.ay + f.az * f.az)
const overG = mag > h.thresholdG
const overDb = f.db > h.thresholdDb
if (overG && overDb) return 'critical'
if (overG || overDb) return 'anomalous'
return 'nominal'
}
interface MockOptions {
intervalMs?: number
// deterministic synthetic frame generator keyed by tick count
frameAt?: (tick: number) => ImuFrame
}
function createMockConn(opts: MockOptions = {}): SerialConn {
const intervalMs = opts.intervalMs ?? 500
const frameAt =
opts.frameAt ??
((tick: number): ImuFrame => {
// Frames are gravity-compensated (linear) acceleration, so rest sits near
// zero. Mostly-nominal wander with a periodic event every 7th tick — a
// lively-but-deterministic demo stream when no board is attached.
const spike = tick % 7 === 0
const wobble = Math.sin(tick / 3) * 0.08
return {
ax: spike ? 1.4 : 0.06 + wobble,
ay: spike ? 0.5 : wobble / 2,
az: spike ? 0.3 : 0.04 + wobble / 4,
db: spike ? 72 : 40 + (tick % 4),
t: tick,
}
})
const subs = new Set<(f: ImuFrame) => void>()
let tick = 0
const timer = setInterval(() => {
const f = frameAt(++tick)
subs.forEach((cb) => cb(f))
}, intervalMs)
return {
port: MOCK_PORT,
mocked: true,
onFrame(cb) {
subs.add(cb)
return () => subs.delete(cb)
},
async close() {
clearInterval(timer)
subs.clear()
},
}
}
async function createRealConn(): Promise<SerialConn> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const serial = (navigator as any).serial
const port = await serial.requestPort()
await port.open({ baudRate: BAUD_RATE })
const subs = new Set<(f: ImuFrame) => void>()
let closed = false
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let reader: any = null
const pump = async () => {
const decoder = new TextDecoderStream()
const readable = port.readable.pipeThrough(decoder)
reader = readable.getReader()
let buf = ''
try {
while (!closed) {
const { value, done } = await reader.read()
if (done) break
buf += value
let nl: number
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl)
buf = buf.slice(nl + 1)
const frame = parseLine(line)
if (frame) {
frame.t = Date.now()
subs.forEach((cb) => cb(frame))
}
}
}
} catch {
// read loop ended (port closed / unplugged) — surfaced via close()
}
}
void pump()
return {
port: typeof port.getInfo === 'function' ? `usb-serial://uno-r4-wifi` : 'usb-serial',
mocked: false,
onFrame(cb) {
subs.add(cb)
return () => subs.delete(cb)
},
async close() {
closed = true
subs.clear()
try {
await reader?.cancel()
} catch {
/* ignore */
}
try {
await port.close()
} catch {
/* ignore */
}
},
}
}
/**
* Open a serial connection. Uses the real Web Serial API when available
* (prompts the user to pick the board), otherwise returns a deterministic
* mock connection so tests and unsupported browsers still drive the flow.
*/
export async function requestPort(opts?: MockOptions): Promise<SerialConn> {
if (!serialSupported()) return createMockConn(opts)
try {
return await createRealConn()
} catch {
// user cancelled the port picker or open failed → fall back to mock
return createMockConn(opts)
}
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest'
import { makeSubmissionCode } from './submission'
import type { Team, AddLayers } from '@/store/session'
const team: Team = { name: 'team_resonance', members: ['A', 'B'], kit: 'KIT-07' }
const add: AddLayers = { L1: { goal: 'x' }, L2: 'two', L3: 'three', L4: 'four', L5: 'five' }
describe('makeSubmissionCode', () => {
it('is prefixed with the team kit', () => {
expect(makeSubmissionCode(team, add)).toMatch(/^KIT-07-/)
})
it('is deterministic for the same team + ADD', () => {
expect(makeSubmissionCode(team, add)).toBe(makeSubmissionCode(team, add))
})
it('changes when the ADD content changes', () => {
const changed = makeSubmissionCode(team, { ...add, L4: 'different failure mode' })
expect(changed).not.toBe(makeSubmissionCode(team, add))
})
it('changes when the team changes', () => {
const other = makeSubmissionCode({ ...team, name: 'team_other' }, add)
expect(other).not.toBe(makeSubmissionCode(team, add))
})
})
+20
View File
@@ -0,0 +1,20 @@
import type { Team, AddLayers } from '@/store/session'
/** Small, stable string hash (djb2 → base36). */
function hash(input: string): string {
let h = 5381
for (let i = 0; i < input.length; i++) {
h = ((h << 5) + h + input.charCodeAt(i)) >>> 0
}
return h.toString(36).toUpperCase().padStart(7, '0')
}
/**
* Deterministic submission code derived from the team and the full ADD.
* Same inputs → same code, so a team that re-submits an unchanged document
* gets a stable identifier; any edit produces a new one.
*/
export function makeSubmissionCode(team: Team, add: AddLayers): string {
const payload = JSON.stringify({ name: team.name, add })
return `${team.kit}-${hash(payload)}`
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useSerial } from './useSerial'
import { useSession } from '@/store/session'
describe('useSerial', () => {
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
vi.useFakeTimers()
})
afterEach(() => vi.useRealTimers())
it('connects via the mock fallback and reports mocked state', async () => {
const { result } = renderHook(() => useSerial())
expect(result.current.supported).toBe(false)
await act(async () => {
await result.current.connect()
})
expect(result.current.connected).toBe(true)
expect(result.current.mocked).toBe(true)
})
it('streams frames into stats via recordEvent', async () => {
const { result } = renderHook(() => useSerial())
await act(async () => {
await result.current.connect()
})
await act(async () => {
await vi.advanceTimersByTimeAsync(2000)
})
expect(useSession.getState().stats.calls).toBeGreaterThan(0)
expect(result.current.last).not.toBeNull()
})
it('inject() classifies against the live tuned harness', async () => {
const { result } = renderHook(() => useSerial())
useSession.getState().setHarness({ thresholdG: 0.5, thresholdDb: 50 })
act(() => {
result.current.inject({ ax: 2, ay: 0, az: 0, db: 90, t: 1 })
})
expect(useSession.getState().stats.critical).toBe(1)
expect(useSession.getState().stats.calls).toBe(1)
})
})
+84
View File
@@ -0,0 +1,84 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useSession } from '@/store/session'
import {
requestPort,
classifyFrame,
serialSupported,
type SerialConn,
type ImuFrame,
} from './serial'
const FRAME_BUFFER = 50
export interface UseSerial {
supported: boolean
connected: boolean
mocked: boolean
last: ImuFrame | null
frames: ImuFrame[]
connect: () => Promise<SerialConn>
disconnect: () => Promise<void>
/** push a synthetic frame through the same classify→record path (test triggers) */
inject: (f: ImuFrame) => void
}
/**
* React binding over the serial bridge. Opens a connection on demand, keeps a
* rolling frame buffer, and records each frame's verdict into the session
* `stats` using the *live* harness thresholds (read at emit time, so Module 2's
* tuning takes effect immediately). Pages consume this; never `navigator.serial`.
*/
export function useSerial(): UseSerial {
const recordEvent = useSession((s) => s.recordEvent)
const connRef = useRef<SerialConn | null>(null)
const [connected, setConnected] = useState(false)
const [mocked, setMocked] = useState(false)
const [last, setLast] = useState<ImuFrame | null>(null)
const [frames, setFrames] = useState<ImuFrame[]>([])
const handleFrame = useCallback(
(f: ImuFrame) => {
setLast(f)
setFrames((prev) => [...prev.slice(-(FRAME_BUFFER - 1)), f])
// read the harness fresh so tuned thresholds apply without re-subscribing
recordEvent(classifyFrame(f, useSession.getState().harness))
},
[recordEvent],
)
const connect = useCallback(async () => {
if (connRef.current) return connRef.current
const conn = await requestPort()
connRef.current = conn
conn.onFrame(handleFrame)
setConnected(true)
setMocked(conn.mocked)
return conn
}, [handleFrame])
const disconnect = useCallback(async () => {
await connRef.current?.close()
connRef.current = null
setConnected(false)
}, [])
const inject = useCallback((f: ImuFrame) => handleFrame(f), [handleFrame])
useEffect(() => {
return () => {
void connRef.current?.close()
connRef.current = null
}
}, [])
return {
supported: serialSupported(),
connected,
mocked,
last,
frames,
connect,
disconnect,
inject,
}
}
+11 -2
View File
@@ -8,6 +8,7 @@ import { MemberChips } from '@/components/MemberChips'
import { KitSelector } from '@/components/KitSelector' import { KitSelector } from '@/components/KitSelector'
import { PhaseStrip } from '@/components/PhaseStrip' import { PhaseStrip } from '@/components/PhaseStrip'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
import { requestPort, serialSupported } from '@/lib/serial'
export function TeamRegistration() { export function TeamRegistration() {
const navigate = useNavigate() const navigate = useNavigate()
@@ -30,8 +31,15 @@ export function TeamRegistration() {
navigate('/workshop/setup') navigate('/workshop/setup')
} }
const onConnect = () => { const onConnect = async () => {
// Without Web Serial (tests, unsupported browsers) connect synchronously to
// the mock so the flow stays usable; otherwise prompt for the real board.
if (!serialSupported()) {
setDevice({ connected: true, port: 'mock-serial://uno-r4-wifi', uptimeS: 0 }) setDevice({ connected: true, port: 'mock-serial://uno-r4-wifi', uptimeS: 0 })
return
}
const conn = await requestPort()
setDevice({ connected: true, port: conn.port, uptimeS: 0 })
} }
return ( return (
@@ -122,7 +130,8 @@ export function TeamRegistration() {
</Button> </Button>
)} )}
<p className="font-mono text-[10px] text-muted-foreground leading-relaxed"> <p className="font-mono text-[10px] text-muted-foreground leading-relaxed">
Web Serial wiring lands in the next slice; this stub flips the device flag so flow tests pass. Uses the browser's Web Serial API to talk to the board over USB. Falls back to a
simulated device on browsers without Web Serial support.
</p> </p>
</div> </div>
</CardContent> </CardContent>