/** * CircuitSimulationService tests. * * Fully isolated from useSimulatorStore + useElectricalStore + the * WASM scheduler. Uses fakes for every port so the service's * orchestration logic is exercised standalone. */ import { describe, it, expect, vi, afterEach } from 'vitest'; import { CircuitSimulationService, type SimulatorStorePort, type ElectricalStorePort, type MixedModeSchedulerPort, type ElectricalSnapshot, } from '../simulation/spice/CircuitSimulationService'; import { getMixedModeScheduler, __resetMixedModeScheduler, __setSchedulerSolverFactoryForTests, } from '../simulation/spice/MixedModeScheduler'; import { FakeSolverAdapter } from '../simulation/spice/adapters/FakeSolverAdapter'; // Tracks unsubscribe handles returned by `service.start()` so the // store subscription is released after every test. Without this, the // listener pins the simStore (and via closure the service + scheduler) // in memory, and vitest's forks pool can't terminate cleanly when the // suite finishes — manifesting as "Worker exited unexpectedly / // Timeout terminating forks worker" on CI. The hang doesn't surface // any failed assertion; everything passes, but the worker process // never exits. const _activeUnsubs: Array<() => void> = []; function startTracked(service: { start: () => () => void }): () => void { const unsub = service.start(); _activeUnsubs.push(unsub); return unsub; } afterEach(() => { for (const unsub of _activeUnsubs.splice(0)) { try { unsub(); } catch { /* ignore */ } } __resetMixedModeScheduler(); }); function makeSimStore(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 }>; }): { port: SimulatorStorePort; set(next: Partial): void } { let state: typeof initial = initial; const listeners: Array<(s: unknown, p: unknown) => void> = []; return { port: { getState: () => state, subscribe(l) { listeners.push(l); return () => { const i = listeners.indexOf(l); if (i >= 0) listeners.splice(i, 1); }; }, }, set(next) { const prev = state; state = { ...state, ...next }; for (const l of listeners) l(state, prev); }, }; } function makeElectricalStore(): { port: ElectricalStorePort; snapshots: ElectricalSnapshot[]; } { const snapshots: ElectricalSnapshot[] = []; return { snapshots, port: { publish(s) { snapshots.push(s); }, }, }; } const simpleBoardWithBoard = { components: [{ id: 'r1', metadataId: 'resistor', properties: { value: '1k' } }], wires: [ { id: 'w1', start: { componentId: 'uno', pinName: '5V' }, end: { componentId: 'r1', pinName: '1' }, }, { id: 'w2', start: { componentId: 'r1', pinName: '2' }, end: { componentId: 'uno', pinName: 'GND' }, }, ], boards: [{ id: 'uno', boardKind: 'arduino-uno' }], }; describe('CircuitSimulationService — orchestration', () => { it('runs an initial solve when started', async () => { const fake = new FakeSolverAdapter({ vectors: { 'v(vcc_rail)': 5 } }); __setSchedulerSolverFactoryForTests(() => fake); const sim = makeSimStore(simpleBoardWithBoard); const elec = makeElectricalStore(); const service = new CircuitSimulationService( sim.port, elec.port, getMixedModeScheduler() as unknown as MixedModeSchedulerPort, { collectBoardPinStates: () => ({ '5V': { type: 'digital', v: 5 } }) }, ); startTracked(service); await new Promise((r) => setTimeout(r, 20)); expect(fake.calls.loadCircuit.length).toBe(1); expect(fake.calls.solve.length).toBe(1); expect(elec.snapshots.length).toBe(1); expect(elec.snapshots[0]?.analysisMode).toBe('op'); expect(elec.snapshots[0]?.nodeVoltages.vcc_rail).toBeCloseTo(5); }); it('extracts branch currents from i(v_*) vectors', async () => { const fake = new FakeSolverAdapter({ vectors: { 'v(vcc_rail)': 5, 'i(v_vcc_rail)': -0.005 }, }); __setSchedulerSolverFactoryForTests(() => fake); const sim = makeSimStore(simpleBoardWithBoard); const elec = makeElectricalStore(); const service = new CircuitSimulationService( sim.port, elec.port, getMixedModeScheduler() as unknown as MixedModeSchedulerPort, { collectBoardPinStates: () => ({ '5V': { type: 'digital', v: 5 } }) }, ); startTracked(service); await new Promise((r) => setTimeout(r, 20)); const snap = elec.snapshots[0]; expect(snap?.branchCurrents.v_vcc_rail).toBeCloseTo(-0.005); }); it('re-solves on components / wires / boards changes', async () => { const fake = new FakeSolverAdapter({ vectors: { 'v(vcc_rail)': 5 } }); __setSchedulerSolverFactoryForTests(() => fake); const sim = makeSimStore(simpleBoardWithBoard); const elec = makeElectricalStore(); const service = new CircuitSimulationService( sim.port, elec.port, getMixedModeScheduler() as unknown as MixedModeSchedulerPort, { collectBoardPinStates: () => ({ '5V': { type: 'digital', v: 5 } }) }, ); startTracked(service); await new Promise((r) => setTimeout(r, 10)); expect(fake.calls.solve.length).toBe(1); sim.set({ components: [...simpleBoardWithBoard.components, { id: 'r2', metadataId: 'resistor', properties: {} }] }); await new Promise((r) => setTimeout(r, 10)); expect(fake.calls.solve.length).toBe(2); }); it('does NOT re-solve when an unrelated field changes', async () => { const fake = new FakeSolverAdapter({ vectors: { 'v(vcc_rail)': 5 } }); __setSchedulerSolverFactoryForTests(() => fake); const sim = makeSimStore(simpleBoardWithBoard); const elec = makeElectricalStore(); const service = new CircuitSimulationService( sim.port, elec.port, getMixedModeScheduler() as unknown as MixedModeSchedulerPort, { collectBoardPinStates: () => ({}) }, ); startTracked(service); await new Promise((r) => setTimeout(r, 10)); sim.set({}); // same arrays — should NOT trigger a re-solve await new Promise((r) => setTimeout(r, 10)); expect(fake.calls.solve.length).toBe(1); }); it('coalesces solves when one is in flight', async () => { const fake = new FakeSolverAdapter({ vectors: { 'v(vcc_rail)': 5 }, solveDelayMs: 30, }); __setSchedulerSolverFactoryForTests(() => fake); const sim = makeSimStore(simpleBoardWithBoard); const elec = makeElectricalStore(); const service = new CircuitSimulationService( sim.port, elec.port, getMixedModeScheduler() as unknown as MixedModeSchedulerPort, { collectBoardPinStates: () => ({}) }, ); startTracked(service); // While initial solve runs, fire 3 store changes — should coalesce // into 1 trailing solve. sim.set({ components: [{ id: 'a', metadataId: 'resistor', properties: {} }] }); sim.set({ components: [{ id: 'b', metadataId: 'resistor', properties: {} }] }); sim.set({ components: [{ id: 'c', metadataId: 'resistor', properties: {} }] }); await new Promise((r) => setTimeout(r, 100)); expect(fake.calls.solve.length).toBe(2); // initial + 1 trailing }); it('publishes .tran waveforms when analysis is transient', async () => { const fake = new FakeSolverAdapter({ vectors: { 'v(n_out)': new Float64Array([0, 1, 2, 3, 4]), 'i(v_src)': new Float64Array([0.01, 0.02, 0.03, 0.04, 0.05]), }, timeAxis: new Float64Array([0, 1e-4, 2e-4, 3e-4, 4e-4]), }); __setSchedulerSolverFactoryForTests(() => fake); const sim = makeSimStore({ components: [ // A signal-generator forces .tran in buildInputFromStore. { id: 'sg1', metadataId: 'signal-generator', properties: { waveform: 'sine', frequency: 100 }, }, ], wires: [], boards: [{ id: 'uno', boardKind: 'arduino-uno' }], }); const elec = makeElectricalStore(); const service = new CircuitSimulationService( sim.port, elec.port, getMixedModeScheduler() as unknown as MixedModeSchedulerPort, { collectBoardPinStates: () => ({}) }, ); startTracked(service); await new Promise((r) => setTimeout(r, 20)); const snap = elec.snapshots[0]; expect(snap?.analysisMode).toBe('tran'); expect(snap?.timeWaveforms).toBeDefined(); expect(snap?.timeWaveforms?.time.length).toBe(5); }); it('publishes warnings from the solver', async () => { const fake = new FakeSolverAdapter({ vectors: { 'v(vcc_rail)': 5 } }); // FakeSolverAdapter does not currently emit warnings; vetting that the // service forwards them is sufficient — see solver-port-contract for // the warnings-field contract. __setSchedulerSolverFactoryForTests(() => fake); const sim = makeSimStore(simpleBoardWithBoard); const elec = makeElectricalStore(); const service = new CircuitSimulationService( sim.port, elec.port, getMixedModeScheduler() as unknown as MixedModeSchedulerPort, { collectBoardPinStates: () => ({}) }, ); startTracked(service); await new Promise((r) => setTimeout(r, 10)); // The FakeSolverAdapter returns empty warnings, so the snapshot // also has empty warnings — but the field exists. expect(elec.snapshots[0]?.warnings).toEqual([]); }); }); describe('handleMcuEdge (Phase 1c D1)', () => { it('runs an initial full solve, then alter + republish on edge', async () => { let gateV = 0; let drainV = 4.9; const fake = new FakeSolverAdapter({ vectors: () => ({ 'v(net_gate)': gateV, 'v(net_drain)': drainV, 'v(vcc_rail)': 5, 'i(v_vcc_rail)': -0.005, }), }); fake.onAlter = (name, value) => { if (name === 'V_uno_9') { gateV = value; drainV = value >= 1.6 ? 0.05 : 4.9; } }; __setSchedulerSolverFactoryForTests(() => fake); const sim = makeSimStore({ components: [ { id: 'q1', metadataId: 'bjt-2n2222', properties: {} }, { id: 'rb', metadataId: 'resistor', properties: { value: '1k' } }, ], wires: [ { id: 'w1', start: { componentId: 'uno', pinName: '9' }, end: { componentId: 'rb', pinName: '1' }, }, { id: 'w2', start: { componentId: 'rb', pinName: '2' }, end: { componentId: 'q1', pinName: 'B' }, }, ], boards: [{ id: 'uno', boardKind: 'arduino-uno' }], }); const elec = makeElectricalStore(); const service = new CircuitSimulationService( sim.port, elec.port, getMixedModeScheduler() as unknown as MixedModeSchedulerPort, { collectBoardPinStates: () => ({}) }, ); startTracked(service); await new Promise((r) => setTimeout(r, 20)); const initialSolves = fake.calls.solve.length; const initialSnapshots = elec.snapshots.length; expect(initialSolves).toBe(1); // initial full solve expect(initialSnapshots).toBe(1); await service.handleMcuEdge('uno', '9', true, 5); // Solve count went up by exactly 1 (alter + .op), no new loadCircuit. expect(fake.calls.solve.length).toBe(initialSolves + 1); expect(fake.calls.loadCircuit.length).toBe(1); // still 1 expect(fake.calls.alterSource).toEqual([['V_uno_9', 5]]); expect(elec.snapshots.length).toBe(initialSnapshots + 1); }); it('coalesces an edge with an in-flight full solve', async () => { const fake = new FakeSolverAdapter({ vectors: { 'v(vcc_rail)': 5 }, solveDelayMs: 30, }); __setSchedulerSolverFactoryForTests(() => fake); const sim = makeSimStore(simpleBoardWithBoard); const elec = makeElectricalStore(); const service = new CircuitSimulationService( sim.port, elec.port, getMixedModeScheduler() as unknown as MixedModeSchedulerPort, { collectBoardPinStates: () => ({}) }, ); startTracked(service); // While initial solve is running, fire an edge. void service.handleMcuEdge('uno', '9', true, 5); await new Promise((r) => setTimeout(r, 100)); // After initial solve, the pending edge replays — total solves = 2. expect(fake.calls.solve.length).toBe(2); expect(fake.calls.alterSource).toEqual([['V_uno_9', 5]]); }); it('kicks a full tick when no circuit has been loaded yet', async () => { const fake = new FakeSolverAdapter({ vectors: { 'v(vcc_rail)': 5 } }); __setSchedulerSolverFactoryForTests(() => fake); const sim = makeSimStore(simpleBoardWithBoard); const elec = makeElectricalStore(); const service = new CircuitSimulationService( sim.port, elec.port, getMixedModeScheduler() as unknown as MixedModeSchedulerPort, { collectBoardPinStates: () => ({}) }, ); // No service.start() — first call is handleMcuEdge. await service.handleMcuEdge('uno', '9', true, 5); await new Promise((r) => setTimeout(r, 10)); // Should have done a FULL tick (loadCircuit + solve), not just alter. expect(fake.calls.loadCircuit.length).toBe(1); expect(fake.calls.alterSource).toEqual([]); }); }); describe('CircuitSimulationService — error handling', () => { it('logs but does not throw when solver fails', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const fake = new FakeSolverAdapter(); // Override solve to reject on first call. let calls = 0; fake.solve = async () => { calls++; throw new Error('boom'); }; __setSchedulerSolverFactoryForTests(() => fake); const sim = makeSimStore(simpleBoardWithBoard); const elec = makeElectricalStore(); const service = new CircuitSimulationService( sim.port, elec.port, getMixedModeScheduler() as unknown as MixedModeSchedulerPort, { collectBoardPinStates: () => ({}) }, ); startTracked(service); await new Promise((r) => setTimeout(r, 10)); expect(warn).toHaveBeenCalled(); expect(elec.snapshots.length).toBe(0); // no publish on failure expect(calls).toBeGreaterThan(0); warn.mockRestore(); }); });