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:
parent
340323c1d3
commit
173037b593
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<{
|
||||
|
|
|
|||
Loading…
Reference in New Issue