feat(sim): Phase 1b continued, step 4 — bridge legacy solver into MixedModeScheduler
Connects the existing electrical solver's output (nodeVoltages + pinNetMap from useElectricalStore) to the mixed-mode scheduler's voltage cache. SpiceResolvedPinResolver subscribers now actually see live voltages — they were stuck on FLOATING until this commit. Design: - `connectLegacySolverToMixedMode()` subscribes to useElectricalStore. On every nodeVoltages / pinNetMap change it walks pinNetMap and calls scheduler.publishVoltage(componentId, pinName, v) for each pin. Ground pins (canonical net '0') resolve to 0 V directly. NaN / Infinity voltages are skipped. - `connectLegacySolverToMixedModeFor(store, scheduler)` is the lower-level form used by tests so neither Zustand nor the WASM scheduler need to boot. - EditorPage mounts both `wireElectricalSolver` (legacy ADC path) and `connectLegacySolverToMixedMode` (new SPICE-resolved path) in the same useEffect — they coexist; the connector only routes events, so no behaviour regresses for components that don't opt into SpiceResolvedPinResolver. 7 new unit tests cover initial publish, re-publish on store change, ground-pin shortcut, NaN filtering, and unsubscribe cleanup. This is the wiring that completes Phase 1b's end-to-end pipe. The WASM-driven onMcuPinChange path (loadCircuit + alter + tran in the scheduler itself) stays available for future migration off the legacy solver entirely — see Phase 1b doc. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
61b04e46f8
commit
da07345bc0
|
|
@ -0,0 +1,167 @@
|
|||
/**
|
||||
* Phase 1b continued, step 4 — tests for connectLegacySolverToMixedMode.
|
||||
*
|
||||
* Verifies that voltages produced by the legacy CircuitScheduler reach
|
||||
* the MixedModeScheduler's voltage cache, so SpiceResolvedPinResolver
|
||||
* subscribers actually see live voltages.
|
||||
*
|
||||
* Uses fake store + fake scheduler — no Zustand, no WASM. Real
|
||||
* EditorPage wiring is verified by manual smoke testing.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
connectLegacySolverToMixedModeFor,
|
||||
type ElectricalStoreLike,
|
||||
} from '../simulation/spice/connectLegacySolverToMixedMode';
|
||||
|
||||
function makeStore(initial: {
|
||||
nodeVoltages: Record<string, number>;
|
||||
pinNetMap: Map<string, string>;
|
||||
}): {
|
||||
store: ElectricalStoreLike;
|
||||
set(
|
||||
next: Partial<{ nodeVoltages: Record<string, number>; pinNetMap: Map<string, string> }>,
|
||||
): void;
|
||||
} {
|
||||
let state = { ...initial };
|
||||
const listeners: Array<
|
||||
(
|
||||
state: { nodeVoltages: Record<string, number>; pinNetMap: Map<string, string> },
|
||||
prev: { nodeVoltages: Record<string, number>; pinNetMap: Map<string, string> },
|
||||
) => void
|
||||
> = [];
|
||||
return {
|
||||
store: {
|
||||
getState() {
|
||||
return 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(): {
|
||||
publishVoltage: (id: string, pin: string, v: number) => void;
|
||||
calls: Array<{ id: string; pin: string; v: number }>;
|
||||
} {
|
||||
const calls: Array<{ id: string; pin: string; v: number }> = [];
|
||||
return {
|
||||
calls,
|
||||
publishVoltage(id, pin, v) {
|
||||
calls.push({ id, pin, v });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('connectLegacySolverToMixedMode', () => {
|
||||
it('publishes the initial voltages immediately on subscribe', () => {
|
||||
const { store } = makeStore({
|
||||
nodeVoltages: { net_drain: 4.2, net_gate: 0.1 },
|
||||
pinNetMap: new Map([
|
||||
['q1:D', 'net_drain'],
|
||||
['q1:G', 'net_gate'],
|
||||
]),
|
||||
});
|
||||
const sched = makeScheduler();
|
||||
const cancel = connectLegacySolverToMixedModeFor(store, sched);
|
||||
|
||||
expect(sched.calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ id: 'q1', pin: 'D', v: 4.2 },
|
||||
{ id: 'q1', pin: 'G', v: 0.1 },
|
||||
]),
|
||||
);
|
||||
cancel();
|
||||
});
|
||||
|
||||
it('skips nets that have no voltage in the solver result', () => {
|
||||
const { store } = makeStore({
|
||||
nodeVoltages: { net_drain: 3.3 },
|
||||
pinNetMap: new Map([
|
||||
['q1:D', 'net_drain'],
|
||||
['q1:G', 'net_missing'],
|
||||
]),
|
||||
});
|
||||
const sched = makeScheduler();
|
||||
connectLegacySolverToMixedModeFor(store, sched);
|
||||
expect(sched.calls).toEqual([{ id: 'q1', pin: 'D', v: 3.3 }]);
|
||||
});
|
||||
|
||||
it('publishes 0 V for canonical ground pins regardless of nodeVoltages map', () => {
|
||||
const { store } = makeStore({
|
||||
nodeVoltages: {}, // ground is implicit — never appears in nodeVoltages
|
||||
pinNetMap: new Map([
|
||||
['q1:S', '0'],
|
||||
['q1:D', 'net_drain'],
|
||||
]),
|
||||
});
|
||||
const sched = makeScheduler();
|
||||
connectLegacySolverToMixedModeFor(store, sched);
|
||||
expect(sched.calls).toEqual([{ id: 'q1', pin: 'S', v: 0 }]);
|
||||
});
|
||||
|
||||
it('re-publishes when nodeVoltages changes (subsequent solves)', () => {
|
||||
const initial = makeStore({
|
||||
nodeVoltages: { net: 1.0 },
|
||||
pinNetMap: new Map([['c:p', 'net']]),
|
||||
});
|
||||
const sched = makeScheduler();
|
||||
connectLegacySolverToMixedModeFor(initial.store, sched);
|
||||
expect(sched.calls).toEqual([{ id: 'c', pin: 'p', v: 1.0 }]);
|
||||
|
||||
initial.set({ nodeVoltages: { net: 2.5 } });
|
||||
expect(sched.calls).toEqual([
|
||||
{ id: 'c', pin: 'p', v: 1.0 },
|
||||
{ id: 'c', pin: 'p', v: 2.5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('re-publishes when pinNetMap changes (circuit rebuild)', () => {
|
||||
const initial = makeStore({
|
||||
nodeVoltages: { net_a: 1.5, net_b: 3.0 },
|
||||
pinNetMap: new Map([['c:p', 'net_a']]),
|
||||
});
|
||||
const sched = makeScheduler();
|
||||
connectLegacySolverToMixedModeFor(initial.store, sched);
|
||||
initial.set({ pinNetMap: new Map([['c:p', 'net_b']]) });
|
||||
expect(sched.calls.at(-1)).toEqual({ id: 'c', pin: 'p', v: 3.0 });
|
||||
});
|
||||
|
||||
it('drops NaN / Infinity voltages silently — never publishes them', () => {
|
||||
const { store } = makeStore({
|
||||
nodeVoltages: { net_nan: Number.NaN, net_inf: Number.POSITIVE_INFINITY, net_ok: 1.2 },
|
||||
pinNetMap: new Map([
|
||||
['c:a', 'net_nan'],
|
||||
['c:b', 'net_inf'],
|
||||
['c:c', 'net_ok'],
|
||||
]),
|
||||
});
|
||||
const sched = makeScheduler();
|
||||
connectLegacySolverToMixedModeFor(store, sched);
|
||||
expect(sched.calls).toEqual([{ id: 'c', pin: 'c', v: 1.2 }]);
|
||||
});
|
||||
|
||||
it('unsubscribe stops future updates', () => {
|
||||
const { store, set } = makeStore({
|
||||
nodeVoltages: { n: 0.5 },
|
||||
pinNetMap: new Map([['c:p', 'n']]),
|
||||
});
|
||||
const sched = makeScheduler();
|
||||
const cancel = connectLegacySolverToMixedModeFor(store, sched);
|
||||
cancel();
|
||||
set({ nodeVoltages: { n: 1.5 } });
|
||||
// Only the initial publish — no update after unsubscribe.
|
||||
expect(sched.calls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
import React, { useRef, useState, useCallback, useEffect, lazy, Suspense } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { wireElectricalSolver } from '../simulation/spice/subscribeToStore';
|
||||
import { connectLegacySolverToMixedMode } from '../simulation/spice/connectLegacySolverToMixedMode';
|
||||
import { useSEO } from '../utils/useSEO';
|
||||
import { CodeEditor } from '../components/editor/CodeEditor';
|
||||
import { EditorToolbar } from '../components/editor/EditorToolbar';
|
||||
|
|
@ -91,7 +92,11 @@ export const EditorPage: React.FC = () => {
|
|||
// ── Electrical simulation subscriber (one-time, idempotent) ───────────────
|
||||
useEffect(() => {
|
||||
const unsub = wireElectricalSolver();
|
||||
return unsub;
|
||||
const unsubMixedMode = connectLegacySolverToMixedMode();
|
||||
return () => {
|
||||
unsub();
|
||||
unsubMixedMode();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── GitHub star prompt (show once: 2nd visit OR after 3 min) ──────────────
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
/**
|
||||
* Bridges the legacy electrical solver's output into the MixedModeScheduler's
|
||||
* voltage cache. Phase 1b continued, step 4.
|
||||
*
|
||||
* Why:
|
||||
* The Phase 1b skeleton introduced `SpiceResolvedPinResolver`, which lets a
|
||||
* component pin downstream of a BJT/MOSFET/op-amp consume voltages from a
|
||||
* SpiceVoltageSource (the scheduler). Until step 4, nothing wrote to that
|
||||
* cache, so SPICE-resolved components saw FLOATING forever.
|
||||
*
|
||||
* The cleanest first wiring is to reuse the existing solver: every time
|
||||
* the legacy `CircuitScheduler` produces fresh `nodeVoltages`, walk the
|
||||
* pinNetMap and republish each (component, pin) voltage into the mixed-mode
|
||||
* scheduler. The legacy ADC injection path is unchanged.
|
||||
*
|
||||
* What this does NOT do:
|
||||
* - It does not call `scheduler.loadCircuit` or `scheduler.onMcuPinChange`
|
||||
* (the WASM-driven path). Those wait until we're ready to replace the
|
||||
* legacy CircuitScheduler entirely.
|
||||
* - It does not start the WASM engine. `getMixedModeScheduler()` is used
|
||||
* purely as a fan-out for voltage events.
|
||||
*
|
||||
* Lifecycle:
|
||||
* Call from `EditorPage` alongside `wireElectricalSolver()`. Returns an
|
||||
* unsubscribe function for cleanup on unmount.
|
||||
*/
|
||||
import { useElectricalStore } from '../../store/useElectricalStore';
|
||||
import { getMixedModeScheduler } from './MixedModeScheduler';
|
||||
|
||||
/** Stripped-down store shape so this module can be unit-tested with a fake. */
|
||||
export interface ElectricalStoreLike {
|
||||
getState(): {
|
||||
nodeVoltages: Record<string, number>;
|
||||
pinNetMap: Map<string, string>;
|
||||
};
|
||||
subscribe(
|
||||
listener: (
|
||||
state: { nodeVoltages: Record<string, number>; pinNetMap: Map<string, string> },
|
||||
prev: { nodeVoltages: Record<string, number>; pinNetMap: Map<string, string> },
|
||||
) => void,
|
||||
): () => void;
|
||||
}
|
||||
|
||||
interface SchedulerLike {
|
||||
publishVoltage(componentId: string, pinName: string, voltage: number): void;
|
||||
}
|
||||
|
||||
function publishOnce(store: ElectricalStoreLike, scheduler: SchedulerLike): void {
|
||||
const { nodeVoltages, pinNetMap } = store.getState();
|
||||
for (const [key, net] of pinNetMap) {
|
||||
const idx = key.indexOf(':');
|
||||
if (idx < 0) continue;
|
||||
const componentId = key.slice(0, idx);
|
||||
const pinName = key.slice(idx + 1);
|
||||
if (net === '0') {
|
||||
scheduler.publishVoltage(componentId, pinName, 0);
|
||||
continue;
|
||||
}
|
||||
const v = nodeVoltages[net];
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
scheduler.publishVoltage(componentId, pinName, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default entry point — subscribes against the live useElectricalStore and
|
||||
* the singleton MixedModeScheduler. Returns an unsubscribe handle.
|
||||
*/
|
||||
export function connectLegacySolverToMixedMode(): () => void {
|
||||
return connectLegacySolverToMixedModeFor(
|
||||
useElectricalStore as unknown as ElectricalStoreLike,
|
||||
getMixedModeScheduler(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lower-level form for tests — accepts the store and scheduler explicitly.
|
||||
*/
|
||||
export function connectLegacySolverToMixedModeFor(
|
||||
store: ElectricalStoreLike,
|
||||
scheduler: SchedulerLike,
|
||||
): () => void {
|
||||
publishOnce(store, scheduler);
|
||||
return store.subscribe((state, prev) => {
|
||||
if (state.nodeVoltages !== prev.nodeVoltages || state.pinNetMap !== prev.pinNetMap) {
|
||||
publishOnce(store, scheduler);
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue