/** * Phase 1b end-to-end integration — real ngspice driving real * SpiceResolvedPinResolver subscribers. * * Scenario: Arduino pin 9 → 1k → BJT base; collector via 220Ω to 5V; * emitter to GND. When pin 9 is HIGH (5V), the BJT saturates and the * collector pulls LOW; when pin 9 is LOW, the collector floats HIGH. * A SpiceResolvedPinResolver subscribed to "led:A" (whose net equals * the BJT collector net) must emit HIGH / LOW transitions tracking the * real SPICE solve. * * Coverage: * - NetlistBuilder produces a usable netlist + pinNetMap * - runNetlist (eecircuit-engine, real ngspice) solves it * - connectLegacySolverToMixedModeFor publishes the voltages * - SpiceResolvedPinResolver threshold-converts via AVR_HC family * * Skips the mock — this is the highest-confidence proof we can run in * a node test that the pipe doesn't have hidden conversion bugs. */ import { describe, it, expect, vi, afterEach } from 'vitest'; import { buildNetlist } from '../simulation/spice/NetlistBuilder'; import { runNetlist } from './helpers/testSolver'; import { getMixedModeScheduler, __resetMixedModeScheduler, } from '../simulation/spice/MixedModeScheduler'; import { createSpiceResolvedPinResolver, configFromLogicFamily, } from '../simulation/PinResolver'; import { FAMILIES } from '../simulation/LogicFamilies'; import type { BuildNetlistInput } from '../simulation/spice/types'; afterEach(() => { __resetMixedModeScheduler(); }); function bjtSwitchNetlist(pin9V: number): BuildNetlistInput { return { components: [ { id: 'rb', metadataId: 'resistor', properties: { value: '1k' } }, { id: 'rc', metadataId: 'resistor', properties: { value: '220' } }, { id: 'q1', metadataId: 'bjt-2n2222', properties: {} }, // 'led' isn't here — we model just the BJT and read its collector net. // Adding a real LED + V-sense would prove the same idea but makes the // assertion noisier. ], wires: [ // Arduino 9 → Rb → BJT base { id: 'w1', start: { componentId: 'uno', pinName: '9' }, end: { componentId: 'rb', pinName: '1' } }, { id: 'w2', start: { componentId: 'rb', pinName: '2' }, end: { componentId: 'q1', pinName: 'B' } }, // 5V → Rc → BJT collector { id: 'w3', start: { componentId: 'uno', pinName: '5V' }, end: { componentId: 'rc', pinName: '1' } }, { id: 'w4', start: { componentId: 'rc', pinName: '2' }, end: { componentId: 'q1', pinName: 'C' } }, // BJT emitter → GND { id: 'w5', start: { componentId: 'q1', pinName: 'E' }, end: { componentId: 'uno', pinName: 'GND' } }, ], boards: [ { id: 'uno', vcc: 5, pins: { '5V': { type: 'digital', v: 5 }, GND: { type: 'digital', v: 0 }, '9': { type: 'digital', v: pin9V }, }, groundPinNames: ['GND'], vccPinNames: ['5V'], }, ], analysis: { kind: 'op' }, }; } /** Solve, then return the (nodeVoltages, pinNetMap) snapshot in the shape * connectLegacySolverToMixedModeFor expects. */ async function solveAndSnapshot(input: BuildNetlistInput): Promise<{ nodeVoltages: Record; pinNetMap: Map; }> { const { netlist, pinNetMap } = buildNetlist(input); const result = await runNetlist(netlist); const nodeVoltages: Record = {}; for (const name of result.variableNames) { const m = name.toLowerCase().match(/^v\((.+)\)$/); if (!m) continue; nodeVoltages[m[1]] = result.dcValue(name); } return { nodeVoltages, pinNetMap }; } describe('Mixed-mode end-to-end — BJT switch with real ngspice', () => { it( 'SpiceResolvedPinResolver emits LOW when BJT is saturated (pin 9 HIGH)', { timeout: 30_000 }, async () => { const scheduler = getMixedModeScheduler(); const snapshot = await solveAndSnapshot(bjtSwitchNetlist(5)); // Publish the snapshot voltages into the scheduler cache so // SpiceResolvedPinResolver consumers see them. for (const [key, net] of snapshot.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 = snapshot.nodeVoltages[net]; if (typeof v === 'number') scheduler.publishVoltage(componentId, pinName, v); } // Resolver for "q1:C" — the BJT collector pin. const resolver = createSpiceResolvedPinResolver( 'q1', 'C', scheduler, configFromLogicFamily(FAMILIES.AVR_HC), ); const cb = vi.fn(); resolver.onChange(cb); // BJT saturated → Vc ≈ Vce(sat) ≈ 0.1–0.3 V, well below AVR_HC vil=1.0V → LOW. expect(resolver.getCurrentState()).toBe('LOW'); expect(resolver.getCurrentVoltage()).toBeLessThan(1.0); }, ); it( 'SpiceResolvedPinResolver emits HIGH when BJT is cut off (pin 9 LOW)', { timeout: 30_000 }, async () => { const scheduler = getMixedModeScheduler(); const snapshot = await solveAndSnapshot(bjtSwitchNetlist(0)); for (const [key, net] of snapshot.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 = snapshot.nodeVoltages[net]; if (typeof v === 'number') scheduler.publishVoltage(componentId, pinName, v); } const resolver = createSpiceResolvedPinResolver( 'q1', 'C', scheduler, configFromLogicFamily(FAMILIES.AVR_HC), ); // BJT cut off → Vc ≈ 5V (no current through Rc) → above AVR_HC vih=3.0V → HIGH. expect(resolver.getCurrentVoltage()).toBeGreaterThan(3.0); expect(resolver.getCurrentState()).toBe('HIGH'); }, ); });