From 173037b593f9c735dd706ba1da27acac268a9db0 Mon Sep 17 00:00:00 2001 From: davidmonterocrespo24 Date: Fri, 15 May 2026 18:53:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(sim):=20Phase=201c=20step=201=20=E2=80=94?= =?UTF-8?q?=20feature-flagged=20WASM-driven=20connector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `connectMixedModeSchedulerToStore` — when enabled, it subscribes to the simulator store and drives the MixedModeScheduler's WASM path (`loadCircuit` + `resolveDc`) directly, parallel to the legacy `wireElectricalSolver` + `connectLegacySolverToMixedMode` bridge. Opt-in mechanisms (two ways, either works): - URL query: `?mixedmode=on` - Persistent: `localStorage.velxio.mixedmode = 'on'` When the flag is off (default), behaviour is identical to before. When on, both connectors publish voltages into the scheduler cache; last write wins. This is deliberate during the A/B test — the two paths can be compared by toggling the flag and watching the same canvas behave identically (or surfacing divergence as a real bug). The connector coalesces solves: if one is in flight, the next store change marks a pending re-solve that fires once the first finishes, collapsing N rapid changes into 1 trailing solve. Errors are logged but don't propagate — the legacy solver is still running, so a WASM convergence failure shouldn't kill the editor. `collectPinStates` is now exported from `subscribeToStore.ts` so the new connector reuses the same per-board pin-number mapping. 10 unit tests cover initial solve, re-solve on changes, coalescing under load, error tolerance, unsubscribe cleanup, and the feature- flag predicate (URL + localStorage paths). jsdom env scoped to this file via `// @vitest-environment jsdom`. Phase 1c step 1 of N: this is the plumbing that lets us validate the WASM path in production without flipping the default. Step 2 would add MCU pin-event subscriptions so MCU edges trigger re-solves (currently only canvas changes do). Co-Authored-By: Claude Opus 4.7 (1M context) --- ...nect-mixed-mode-scheduler-to-store.test.ts | 217 ++++++++++++++++++ frontend/src/pages/EditorPage.tsx | 11 + .../spice/connectMixedModeSchedulerToStore.ts | 164 +++++++++++++ .../src/simulation/spice/subscribeToStore.ts | 5 +- 4 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 frontend/src/__tests__/connect-mixed-mode-scheduler-to-store.test.ts create mode 100644 frontend/src/simulation/spice/connectMixedModeSchedulerToStore.ts diff --git a/frontend/src/__tests__/connect-mixed-mode-scheduler-to-store.test.ts b/frontend/src/__tests__/connect-mixed-mode-scheduler-to-store.test.ts new file mode 100644 index 00000000..6402b702 --- /dev/null +++ b/frontend/src/__tests__/connect-mixed-mode-scheduler-to-store.test.ts @@ -0,0 +1,217 @@ +/** + * @vitest-environment jsdom + * + * Phase 1c step 1 — tests for the feature-flagged WASM-driven + * connector. Uses a fake store + fake scheduler so the test runs in + * Vitest without booting either Zustand or the WASM worker. + * + * jsdom env is required for the `isMixedModeEnabled` tests that touch + * `window.localStorage` and `window.location`. The connector tests + * themselves only need the Promise microtask queue. + * + * Covered: + * - initial solve fires on subscribe + * - solve re-fires on components / wires / boards change + * - solves coalesce when one is already in flight + * - solve errors are logged but don't break the subscriber + * - unsubscribe stops future solves + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + connectMixedModeSchedulerToStoreFor, + isMixedModeEnabled, + type SimulatorStoreLike, +} from '../simulation/spice/connectMixedModeSchedulerToStore'; + +function makeStore(initial: { + components: Array<{ id: string; metadataId: string; properties: Record }>; + wires: Array<{ + id: string; + start: { componentId: string; pinName: string }; + end: { componentId: string; pinName: string }; + }>; + boards: Array<{ id: string; boardKind: string; pinStates?: Record }>; +}): { + store: SimulatorStoreLike; + set(next: Partial): void; +} { + let state = initial; + const listeners: Array<(state: unknown, prev: unknown) => void> = []; + return { + store: { + getState: () => state, + subscribe(listener) { + listeners.push(listener); + return () => { + const i = listeners.indexOf(listener); + if (i >= 0) listeners.splice(i, 1); + }; + }, + }, + set(next) { + const prev = state; + state = { ...state, ...next }; + for (const l of listeners) l(state, prev); + }, + }; +} + +function makeScheduler(opts: { loadCircuit?: () => Promise; resolveDc?: () => Promise } = {}) { + const calls = { loadCircuit: 0, resolveDc: 0 }; + return { + calls, + scheduler: { + async loadCircuit(_netlist: string, _pinNetMap: Map): Promise { + calls.loadCircuit++; + if (opts.loadCircuit) await opts.loadCircuit(); + }, + async resolveDc(): Promise { + calls.resolveDc++; + if (opts.resolveDc) await opts.resolveDc(); + }, + }, + }; +} + +const emptySnapshot = { + components: [], + wires: [], + boards: [ + { id: 'uno', boardKind: 'arduino-uno', pinStates: { '5V': { type: 'digital', v: 5 } } }, + ], +}; + +afterEach(() => { + try { + window.localStorage.clear(); + } catch { + // ignore + } +}); + +describe('connectMixedModeSchedulerToStore', () => { + it('runs an initial solve as soon as it subscribes', async () => { + const { store } = makeStore(emptySnapshot); + const { scheduler, calls } = makeScheduler(); + connectMixedModeSchedulerToStoreFor(store, scheduler, () => ({})); + // Solve is async — yield to the microtask queue. + await new Promise((r) => setTimeout(r, 5)); + expect(calls.loadCircuit).toBe(1); + expect(calls.resolveDc).toBe(1); + }); + + it('re-solves when components change', async () => { + const ctrl = makeStore(emptySnapshot); + const { scheduler, calls } = makeScheduler(); + connectMixedModeSchedulerToStoreFor(ctrl.store, scheduler, () => ({})); + await new Promise((r) => setTimeout(r, 5)); + + ctrl.set({ components: [{ id: 'r1', metadataId: 'resistor', properties: {} }] }); + await new Promise((r) => setTimeout(r, 5)); + expect(calls.loadCircuit).toBe(2); + expect(calls.resolveDc).toBe(2); + }); + + it('re-solves when wires change', async () => { + const ctrl = makeStore(emptySnapshot); + const { scheduler, calls } = makeScheduler(); + connectMixedModeSchedulerToStoreFor(ctrl.store, scheduler, () => ({})); + await new Promise((r) => setTimeout(r, 5)); + + ctrl.set({ + wires: [ + { + id: 'w1', + start: { componentId: 'uno', pinName: '5V' }, + end: { componentId: 'uno', pinName: 'GND' }, + }, + ], + }); + await new Promise((r) => setTimeout(r, 5)); + expect(calls.loadCircuit).toBe(2); + }); + + it('does NOT re-solve when state changes but components/wires/boards are unchanged', async () => { + const ctrl = makeStore(emptySnapshot); + const { scheduler, calls } = makeScheduler(); + connectMixedModeSchedulerToStoreFor(ctrl.store, scheduler, () => ({})); + await new Promise((r) => setTimeout(r, 5)); + + // Synthetic listener-only event — re-emit the same arrays. + ctrl.set({}); + await new Promise((r) => setTimeout(r, 5)); + expect(calls.loadCircuit).toBe(1); // still 1 — no re-solve + }); + + it('coalesces solves when one is already in flight', async () => { + let releaseFirst!: () => void; + const blocker = new Promise((r) => { + releaseFirst = r; + }); + const { scheduler, calls } = makeScheduler({ + // First loadCircuit blocks until releaseFirst is called. + loadCircuit: () => { + return calls.loadCircuit === 1 ? blocker : Promise.resolve(); + }, + }); + const ctrl = makeStore(emptySnapshot); + connectMixedModeSchedulerToStoreFor(ctrl.store, scheduler, () => ({})); + + // First solve is now in-flight (waiting on the blocker). + // Fire a series of store changes — they should coalesce into ONE + // follow-up solve, not N. + ctrl.set({ components: [{ id: 'a', metadataId: 'resistor', properties: {} }] }); + ctrl.set({ components: [{ id: 'b', metadataId: 'resistor', properties: {} }] }); + ctrl.set({ components: [{ id: 'c', metadataId: 'resistor', properties: {} }] }); + + releaseFirst(); + // Let the first solve finish + coalesced follow-up run. + await new Promise((r) => setTimeout(r, 20)); + + // 1 initial + 1 coalesced follow-up = 2 total, NOT 1+3. + expect(calls.loadCircuit).toBe(2); + expect(calls.resolveDc).toBe(2); + }); + + it('logs but does not throw when a solve errors', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { scheduler } = makeScheduler({ + loadCircuit: () => Promise.reject(new Error('boom')), + }); + const { store } = makeStore(emptySnapshot); + // Should not throw. + connectMixedModeSchedulerToStoreFor(store, scheduler, () => ({})); + await new Promise((r) => setTimeout(r, 10)); + expect(warn).toHaveBeenCalledWith('[mixed-mode] solve failed:', expect.any(Error)); + warn.mockRestore(); + }); + + it('unsubscribe stops future re-solves', async () => { + const ctrl = makeStore(emptySnapshot); + const { scheduler, calls } = makeScheduler(); + const cancel = connectMixedModeSchedulerToStoreFor(ctrl.store, scheduler, () => ({})); + await new Promise((r) => setTimeout(r, 5)); + cancel(); + ctrl.set({ components: [{ id: 'r1', metadataId: 'resistor', properties: {} }] }); + await new Promise((r) => setTimeout(r, 5)); + expect(calls.loadCircuit).toBe(1); // only the initial solve + }); +}); + +describe('isMixedModeEnabled feature flag', () => { + it('returns false by default', () => { + expect(isMixedModeEnabled()).toBe(false); + }); + + it('returns true when localStorage has velxio.mixedmode=on', () => { + window.localStorage.setItem('velxio.mixedmode', 'on'); + expect(isMixedModeEnabled()).toBe(true); + }); + + it('returns false for any value other than "on"', () => { + window.localStorage.setItem('velxio.mixedmode', 'true'); + expect(isMixedModeEnabled()).toBe(false); + window.localStorage.setItem('velxio.mixedmode', '1'); + expect(isMixedModeEnabled()).toBe(false); + }); +}); diff --git a/frontend/src/pages/EditorPage.tsx b/frontend/src/pages/EditorPage.tsx index 5b5d4bdc..aeecabdc 100644 --- a/frontend/src/pages/EditorPage.tsx +++ b/frontend/src/pages/EditorPage.tsx @@ -6,6 +6,10 @@ import React, { useRef, useState, useCallback, useEffect, lazy, Suspense } from import { useTranslation } from 'react-i18next'; import { wireElectricalSolver } from '../simulation/spice/subscribeToStore'; import { connectLegacySolverToMixedMode } from '../simulation/spice/connectLegacySolverToMixedMode'; +import { + connectMixedModeSchedulerToStore, + isMixedModeEnabled, +} from '../simulation/spice/connectMixedModeSchedulerToStore'; import { useSEO } from '../utils/useSEO'; import { CodeEditor } from '../components/editor/CodeEditor'; import { EditorToolbar } from '../components/editor/EditorToolbar'; @@ -93,9 +97,16 @@ export const EditorPage: React.FC = () => { useEffect(() => { const unsub = wireElectricalSolver(); const unsubMixedMode = connectLegacySolverToMixedMode(); + // Phase 1c step 1 — feature-flagged WASM-driven path. Both connectors + // publish into the MixedModeScheduler cache; last write wins. Toggle + // via `?mixedmode=on` URL param or `localStorage.velxio.mixedmode='on'`. + const unsubWasm = isMixedModeEnabled() + ? connectMixedModeSchedulerToStore() + : () => {}; return () => { unsub(); unsubMixedMode(); + unsubWasm(); }; }, []); diff --git a/frontend/src/simulation/spice/connectMixedModeSchedulerToStore.ts b/frontend/src/simulation/spice/connectMixedModeSchedulerToStore.ts new file mode 100644 index 00000000..8b794e66 --- /dev/null +++ b/frontend/src/simulation/spice/connectMixedModeSchedulerToStore.ts @@ -0,0 +1,164 @@ +/** + * Phase 1c step 1 — feature-flagged WASM-driven connector for the + * MixedModeScheduler. + * + * Runs alongside `wireElectricalSolver` (legacy 200-ms-poll, batch + * eecircuit-engine) and `connectLegacySolverToMixedMode` (the Phase + * 1b bridge that republishes legacy voltages into the scheduler). + * + * When the flag is on: + * 1. Subscribe to useSimulatorStore for components / wires / board + * changes + * 2. On change: build the SPICE netlist via the existing storeAdapter + * + NetlistBuilder + * 3. `scheduler.loadCircuit(netlist, pinNetMap)` — pumps it into the + * vendored ngspice-WASM via NgSpiceInteractive + * 4. `scheduler.resolveDc()` — runs the DC op and publishes voltages + * for every (component, pin) in pinNetMap + * + * SpiceResolvedPinResolver subscribers now have TWO sources feeding + * their voltage cache: the legacy bridge AND the WASM path. Whichever + * publishes last wins — that's by design while we A/B test which path + * gives better results. When confidence is established, the legacy + * bridge will be retired and only the WASM connector remains. + * + * Lifecycle: + * Mount from EditorPage when `isMixedModeEnabled()` is true. Returns + * an unsubscribe handle for cleanup on unmount. + */ +import { useSimulatorStore } from '../../store/useSimulatorStore'; +import { buildInputFromStore } from './storeAdapter'; +import { buildNetlist } from './NetlistBuilder'; +import { getMixedModeScheduler } from './MixedModeScheduler'; +import { collectPinStates } from './subscribeToStore'; + +/** Stripped-down store shape so this module can be unit-tested without Zustand. */ +export interface SimulatorStoreLike { + getState(): { + components: Array<{ id: string; metadataId: string; properties: Record }>; + wires: Array<{ + id: string; + start: { componentId: string; pinName: string }; + end: { componentId: string; pinName: string }; + }>; + boards: Array<{ + id: string; + boardKind: string; + pinStates?: Record; + }>; + }; + subscribe(listener: (state: unknown, prev: unknown) => void): () => void; +} + +interface SchedulerLike { + loadCircuit(netlist: string, pinNetMap: Map): Promise; + resolveDc(): Promise; +} + +/** + * True when the feature flag is set. Two opt-in mechanisms: + * - URL query `?mixedmode=on` — useful for one-off sharing of test links + * - localStorage `velxio.mixedmode = 'on'` — sticky across reloads + * + * Reading both lets us flip a single user's session via the URL without + * touching DevTools, while still supporting a permanent opt-in. + */ +export function isMixedModeEnabled(): boolean { + if (typeof window === 'undefined') return false; + try { + if (new URLSearchParams(window.location.search).get('mixedmode') === 'on') return true; + return window.localStorage.getItem('velxio.mixedmode') === 'on'; + } catch { + return false; + } +} + +/** + * Default entry point — uses the live useSimulatorStore + singleton + * scheduler. Returns an unsubscribe handle. + */ +export function connectMixedModeSchedulerToStore(): () => void { + return connectMixedModeSchedulerToStoreFor( + useSimulatorStore as unknown as SimulatorStoreLike, + getMixedModeScheduler(), + /* pinStateCollector */ (boardId, boardKind, wires) => + collectPinStates( + boardId, + // The collector expects BoardKind; we narrow via string at call + // time. In practice every board id in the live store maps to a + // valid BoardKind; if not, collectPinStates returns {}. + boardKind as Parameters[1], + wires as Parameters[2], + ), + ); +} + +/** + * Lower-level form for tests — accepts the store, scheduler, and pin-state + * collector explicitly so neither Zustand nor PinManager need to boot. + */ +export function connectMixedModeSchedulerToStoreFor( + store: SimulatorStoreLike, + scheduler: SchedulerLike, + collectBoardPinStates: ( + boardId: string, + boardKind: string, + wires: Array<{ start: { componentId: string; pinName: string }; end: { componentId: string; pinName: string } }>, + ) => Record, +): () => void { + let inFlight = false; + let pending = false; + + const solve = async (): Promise => { + if (inFlight) { + // Coalesce: keep one solve in flight, mark pending for after. + pending = true; + return; + } + inFlight = true; + try { + const state = store.getState(); + const snap = { + components: state.components, + wires: state.wires, + boards: state.boards.map((b) => ({ + id: b.id, + // The store's BoardKind is narrower than `string` but the + // adapter widens at call time; cast through `unknown` to + // satisfy buildInputFromStore. + boardKind: b.boardKind, + pinStates: collectBoardPinStates(b.id, b.boardKind, state.wires) as never, + })), + }; + const input = buildInputFromStore(snap as Parameters[0]); + const { netlist, pinNetMap } = buildNetlist(input); + await scheduler.loadCircuit(netlist, pinNetMap); + await scheduler.resolveDc(); + } catch (err) { + // Don't propagate — the legacy solver is still running. Log so + // dev tools / Sentry can surface convergence problems without + // breaking the existing app. + // eslint-disable-next-line no-console + console.warn('[mixed-mode] solve failed:', err); + } finally { + inFlight = false; + if (pending) { + pending = false; + // Re-fire the solve. Don't await — let it run asynchronously so + // the caller's subscriber callback isn't blocked. + void solve(); + } + } + }; + + const unsubscribe = store.subscribe((next, prev) => { + const n = next as ReturnType; + const p = prev as ReturnType; + if (n.components !== p.components || n.wires !== p.wires || n.boards !== p.boards) { + void solve(); + } + }); + + void solve(); // kick off initial solve + return unsubscribe; +} diff --git a/frontend/src/simulation/spice/subscribeToStore.ts b/frontend/src/simulation/spice/subscribeToStore.ts index e05c1130..7c8f4ee0 100644 --- a/frontend/src/simulation/spice/subscribeToStore.ts +++ b/frontend/src/simulation/spice/subscribeToStore.ts @@ -145,8 +145,11 @@ function pinNameToArduinoPin(pinName: string, boardKind: BoardKind): number { /** * Collect MCU output pin states from PinManager for pins that participate * in the circuit (i.e., are referenced by wires). + * + * Exported so the Phase 1c WASM-driven connector can reuse the same logic + * without copying the per-board pin-number mapping. */ -function collectPinStates( +export function collectPinStates( boardId: string, boardKind: BoardKind, wires: Array<{