feat(sim): Phase 1c step 1 — feature-flagged WASM-driven connector

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) <noreply@anthropic.com>
This commit is contained in:
davidmonterocrespo24 2026-05-15 18:53:36 +02:00
parent 340323c1d3
commit 173037b593
4 changed files with 396 additions and 1 deletions

View File

@ -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<string, unknown> }>;
wires: Array<{
id: string;
start: { componentId: string; pinName: string };
end: { componentId: string; pinName: string };
}>;
boards: Array<{ id: string; boardKind: string; pinStates?: Record<string, unknown> }>;
}): {
store: SimulatorStoreLike;
set(next: Partial<typeof initial>): 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<void>; resolveDc?: () => Promise<void> } = {}) {
const calls = { loadCircuit: 0, resolveDc: 0 };
return {
calls,
scheduler: {
async loadCircuit(_netlist: string, _pinNetMap: Map<string, string>): Promise<void> {
calls.loadCircuit++;
if (opts.loadCircuit) await opts.loadCircuit();
},
async resolveDc(): Promise<void> {
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<void>((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);
});
});

View File

@ -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();
};
}, []);

View File

@ -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<string, unknown> }>;
wires: Array<{
id: string;
start: { componentId: string; pinName: string };
end: { componentId: string; pinName: string };
}>;
boards: Array<{
id: string;
boardKind: string;
pinStates?: Record<string, unknown>;
}>;
};
subscribe(listener: (state: unknown, prev: unknown) => void): () => void;
}
interface SchedulerLike {
loadCircuit(netlist: string, pinNetMap: Map<string, string>): Promise<void>;
resolveDc(): Promise<void>;
}
/**
* 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<typeof collectPinStates>[1],
wires as Parameters<typeof collectPinStates>[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<string, unknown>,
): () => void {
let inFlight = false;
let pending = false;
const solve = async (): Promise<void> => {
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<typeof buildInputFromStore>[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<typeof store.getState>;
const p = prev as ReturnType<typeof store.getState>;
if (n.components !== p.components || n.wires !== p.wires || n.boards !== p.boards) {
void solve();
}
});
void solve(); // kick off initial solve
return unsubscribe;
}

View File

@ -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<{