diff --git a/frontend/src/__tests__/connect-legacy-solver-to-mixed-mode.test.ts b/frontend/src/__tests__/connect-legacy-solver-to-mixed-mode.test.ts new file mode 100644 index 00000000..7cfbfe45 --- /dev/null +++ b/frontend/src/__tests__/connect-legacy-solver-to-mixed-mode.test.ts @@ -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; + pinNetMap: Map; +}): { + store: ElectricalStoreLike; + set( + next: Partial<{ nodeVoltages: Record; pinNetMap: Map }>, + ): void; +} { + let state = { ...initial }; + const listeners: Array< + ( + state: { nodeVoltages: Record; pinNetMap: Map }, + prev: { nodeVoltages: Record; pinNetMap: Map }, + ) => 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); + }); +}); diff --git a/frontend/src/pages/EditorPage.tsx b/frontend/src/pages/EditorPage.tsx index 45b656e1..5b5d4bdc 100644 --- a/frontend/src/pages/EditorPage.tsx +++ b/frontend/src/pages/EditorPage.tsx @@ -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) ────────────── diff --git a/frontend/src/simulation/spice/connectLegacySolverToMixedMode.ts b/frontend/src/simulation/spice/connectLegacySolverToMixedMode.ts new file mode 100644 index 00000000..6d1d16fc --- /dev/null +++ b/frontend/src/simulation/spice/connectLegacySolverToMixedMode.ts @@ -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; + pinNetMap: Map; + }; + subscribe( + listener: ( + state: { nodeVoltages: Record; pinNetMap: Map }, + prev: { nodeVoltages: Record; pinNetMap: Map }, + ) => 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); + } + }); +}