/** * Smoke-test every metadataId registered in componentToSpice: * build a trivial circuit with that component and verify ngspice accepts * the resulting netlist without error. * * This acts as a canary — if a mapping produces malformed SPICE * (wrong pin count, bogus .model), this test will catch it. */ import { describe, it, expect } from 'vitest'; import { buildNetlist } from '../simulation/spice/NetlistBuilder'; import { mappedMetadataIds, componentToSpice, PASSIVE_PRESETS, } from '../simulation/spice/componentToSpice'; import { runNetlist } from './helpers/testSolver'; /** * Fixture describing how to wire one component for the ngspice-acceptance test. * * `topology` — optional per-pin override. For each pin we can specify: * - 'vcc' → tie pin directly to the +5 V rail * - 'gnd' → tie pin directly to the 0 V rail * - 'load' → connect pin through a 1 MΩ resistor to GND (high-Z observation) * * Pins NOT listed in `topology` fall back to the default wiring strategy: * first pin → VCC, last pin → GND, every middle pin → 'load'. * * Why the override exists: components that DRIVE one of their own pins with a * B-source (logic gates → Y, regulators → VOUT, signal-generator → SIG) would * conflict with the default "last pin → GND" short. For those, we declare the * output pin as 'load' so ngspice can actually observe it. */ interface Fixture { pins: string[]; properties?: Record; topology?: Record; } const MINIMAL_FIXTURES: Record = { resistor: { pins: ['1', '2'] }, 'resistor-us': { pins: ['1', '2'] }, capacitor: { pins: ['1', '2'] }, 'capacitor-electrolytic': { pins: ['+', '−'] }, inductor: { pins: ['1', '2'] }, 'analog-resistor': { pins: ['A', 'B'], properties: { value: '10k' } }, 'analog-capacitor': { pins: ['A', 'B'], properties: { value: '1u' } }, 'analog-inductor': { pins: ['A', 'B'], properties: { value: '10m' } }, led: { pins: ['A', 'C'], properties: { color: 'red' } }, diode: { pins: ['A', 'C'] }, 'diode-1n4148': { pins: ['A', 'C'] }, 'diode-1n4007': { pins: ['A', 'C'] }, 'zener-1n4733': { pins: ['A', 'C'] }, 'bjt-2n2222': { pins: ['C', 'B', 'E'] }, 'bjt-bc547': { pins: ['C', 'B', 'E'] }, 'bjt-2n3055': { pins: ['C', 'B', 'E'] }, 'bjt-2n3906': { pins: ['C', 'B', 'E'] }, 'bjt-bc557': { pins: ['C', 'B', 'E'] }, 'mosfet-2n7000': { pins: ['D', 'G', 'S'] }, 'mosfet-irf540': { pins: ['D', 'G', 'S'] }, 'mosfet-irf9540': { pins: ['D', 'G', 'S'] }, 'mosfet-fqp27p06': { pins: ['D', 'G', 'S'] }, 'opamp-ideal': { pins: ['IN+', 'IN-', 'OUT'] }, 'opamp-lm358': { pins: ['IN+', 'IN-', 'OUT'] }, 'opamp-lm741': { pins: ['IN+', 'IN-', 'OUT'] }, 'opamp-tl072': { pins: ['IN+', 'IN-', 'OUT'] }, 'opamp-lm324': { pins: ['IN+', 'IN-', 'OUT'] }, // Linear regulators drive VOUT via a B-source. Don't short VOUT to GND — // load it so ngspice can actually observe the output voltage. 'reg-7805': { pins: ['VIN', 'GND', 'VOUT'], topology: { VIN: 'vcc', GND: 'gnd', VOUT: 'load' } }, 'reg-7812': { pins: ['VIN', 'GND', 'VOUT'], topology: { VIN: 'vcc', GND: 'gnd', VOUT: 'load' } }, 'reg-7905': { pins: ['VIN', 'GND', 'VOUT'], topology: { VIN: 'vcc', GND: 'gnd', VOUT: 'load' } }, 'reg-lm317': { pins: ['VIN', 'ADJ', 'VOUT'], topology: { VIN: 'vcc', ADJ: 'gnd', VOUT: 'load' } }, 'battery-9v': { pins: ['+', '−'] }, 'battery-aa': { pins: ['+', '−'] }, 'battery-coin-cell': { pins: ['+', '−'] }, // Signal generator drives SIG via a V-source; load it to GND, don't short it. 'signal-generator': { pins: ['SIG', 'GND'], properties: { waveform: 'sine', frequency: 1000, amplitude: 1, offset: 0 }, topology: { SIG: 'load', GND: 'gnd' }, }, // Regulated power supply — same 2-pin shape as battery but with mode + // voltage + currentLimit knobs. Loaded to GND so its ideal V-source has // somewhere to push current without colliding with another voltage source. 'power-supply': { pins: ['+', '−'], properties: { mode: 'dc', voltage: 5, frequency: 50, currentLimit: 1 }, topology: { '+': 'load', '−': 'gnd' }, }, pushbutton: { pins: ['A', 'B'] }, // SPDT: pin 2 is the common wiper; at value=1 it shorts to pin 3, so pin 3 // must be wired or the mapper emits no card. 'slide-switch': { pins: ['1', '2', '3'], properties: { value: 1 } }, 'slide-potentiometer': { pins: ['VCC', 'SIG', 'GND'], properties: { value: '10k', position: 50 }, }, potentiometer: { pins: ['VCC', 'SIG', 'GND'], properties: { min: 0, max: 1023, value: 512 } }, 'ntc-temperature-sensor': { pins: ['VCC', 'OUT', 'GND'], properties: { temperature: 25 } }, photoresistor: { pins: ['VCC', 'AO', 'GND'], properties: { lux: 500 } }, // Same physical part as `photoresistor`, exposed under the alias metadataId // the components-metadata generator emits. The SPICE mapper for both is // identical (componentToSpice.ts:MAPPERS['photoresistor-sensor'] = MAPPERS['photoresistor']). 'photoresistor-sensor': { pins: ['VCC', 'AO', 'GND'], properties: { lux: 500 } }, 'instr-voltmeter': { pins: ['V+', 'V-'] }, 'instr-ammeter': { pins: ['A+', 'A-'] }, // Logic gates drive Y via a B-source. Drive inputs to VCC (high), observe // Y via a 1 MΩ load — shorting Y to GND would collide with the B-source. 'logic-gate-and': { pins: ['A', 'B', 'Y'], topology: { A: 'vcc', B: 'vcc', Y: 'load' } }, 'logic-gate-nand': { pins: ['A', 'B', 'Y'], topology: { A: 'vcc', B: 'vcc', Y: 'load' } }, 'logic-gate-or': { pins: ['A', 'B', 'Y'], topology: { A: 'vcc', B: 'vcc', Y: 'load' } }, 'logic-gate-nor': { pins: ['A', 'B', 'Y'], topology: { A: 'vcc', B: 'vcc', Y: 'load' } }, 'logic-gate-xor': { pins: ['A', 'B', 'Y'], topology: { A: 'vcc', B: 'vcc', Y: 'load' } }, 'logic-gate-xnor': { pins: ['A', 'B', 'Y'], topology: { A: 'vcc', B: 'vcc', Y: 'load' } }, 'logic-gate-not': { pins: ['A', 'Y'], topology: { A: 'vcc', Y: 'load' } }, 'logic-gate-and-3': { pins: ['A', 'B', 'C', 'Y'], topology: { A: 'vcc', B: 'vcc', C: 'vcc', Y: 'load' }, }, 'logic-gate-or-3': { pins: ['A', 'B', 'C', 'Y'], topology: { A: 'vcc', B: 'vcc', C: 'vcc', Y: 'load' }, }, 'logic-gate-nand-3': { pins: ['A', 'B', 'C', 'Y'], topology: { A: 'vcc', B: 'vcc', C: 'vcc', Y: 'load' }, }, 'logic-gate-nor-3': { pins: ['A', 'B', 'C', 'Y'], topology: { A: 'vcc', B: 'vcc', C: 'vcc', Y: 'load' }, }, 'logic-gate-and-4': { pins: ['A', 'B', 'C', 'D', 'Y'], topology: { A: 'vcc', B: 'vcc', C: 'vcc', D: 'vcc', Y: 'load' }, }, 'logic-gate-or-4': { pins: ['A', 'B', 'C', 'D', 'Y'], topology: { A: 'vcc', B: 'vcc', C: 'vcc', D: 'vcc', Y: 'load' }, }, 'logic-gate-nand-4': { pins: ['A', 'B', 'C', 'D', 'Y'], topology: { A: 'vcc', B: 'vcc', C: 'vcc', D: 'vcc', Y: 'load' }, }, 'logic-gate-nor-4': { pins: ['A', 'B', 'C', 'D', 'Y'], topology: { A: 'vcc', B: 'vcc', C: 'vcc', D: 'vcc', Y: 'load' }, }, 'diode-1n5817': { pins: ['A', 'C'] }, 'diode-1n5819': { pins: ['A', 'C'] }, photodiode: { pins: ['A', 'C'], properties: { lux: 500 } }, relay: { pins: ['COIL+', 'COIL-', 'COM', 'NO', 'NC'], properties: { coil_voltage: 5 } }, 'opto-4n25': { pins: ['AN', 'CAT', 'COL', 'EMIT'] }, 'opto-pc817': { pins: ['AN', 'CAT', 'COL', 'EMIT'] }, // ICs: include at least one full gate (1A, 1B, 1Y) so the mapper emits // cards. Listed in NEEDS_CUSTOM_TOPOLOGY so the ngspice-acceptance test // doesn't try to short the output to GND. 'ic-74hc00': { pins: ['1A', '1B', '1Y'] }, 'ic-74hc02': { pins: ['1A', '1B', '1Y'] }, 'ic-74hc04': { pins: ['1A', '1Y'] }, 'ic-74hc08': { pins: ['1A', '1B', '1Y'] }, 'ic-74hc14': { pins: ['1A', '1Y'] }, 'ic-74hc32': { pins: ['1A', '1B', '1Y'] }, 'ic-74hc86': { pins: ['1A', '1B', '1Y'] }, 'motor-driver-l293d': { pins: ['EN1', 'IN1', 'OUT1'] }, }; // Each PASSIVE_PRESETS entry shares pins with its base, so we derive the // fixture instead of restating it (keeps the two lists from drifting). const BASE_PINS: Record = { resistor: ['1', '2'], capacitor: ['1', '2'], 'capacitor-electrolytic': ['+', '−'], inductor: ['1', '2'], }; for (const [presetId, baseId] of Object.entries(PASSIVE_PRESETS)) { MINIMAL_FIXTURES[presetId] = { pins: BASE_PINS[baseId] }; } describe('PASSIVE_PRESETS — preset variants share their base mapper', () => { it('every preset emits the same card prefix as its base (just the value/id differ)', () => { const PREFIX = { resistor: 'R_', capacitor: 'C_', 'capacitor-electrolytic': 'C_', inductor: 'L_', } as const; for (const [presetId, baseId] of Object.entries(PASSIVE_PRESETS)) { const fx = MINIMAL_FIXTURES[presetId]; const netLookup = (pin: string) => (fx.pins.includes(pin) ? `n_${pin}` : null); const emission = componentToSpice( { id: 'p', metadataId: presetId, properties: { value: '47' } }, netLookup, { vcc: 5 }, ); expect(emission, `${presetId} emitted nothing`).not.toBeNull(); expect( emission!.cards[0].startsWith(PREFIX[baseId]), `${presetId} should emit a ${PREFIX[baseId]}… card, got: ${emission!.cards[0]}`, ).toBe(true); } }); it('electrolytic uses the +/− pin names (not 1/2)', () => { const onlyOnePinLookup = (pin: string) => (pin === '+' || pin === '−' ? `n_${pin}` : null); const emission = componentToSpice( { id: 'e1', metadataId: 'capacitor-electrolytic', properties: { value: '100u' } }, onlyOnePinLookup, { vcc: 5 }, ); expect(emission).not.toBeNull(); expect(emission!.cards[0]).toContain('n_+'); expect(emission!.cards[0]).toContain('n_−'); }); it('electrolytic returns null if pin names are wrong', () => { const wrongPinLookup = (pin: string) => (pin === '1' || pin === '2' ? `n_${pin}` : null); const emission = componentToSpice( { id: 'e1', metadataId: 'capacitor-electrolytic', properties: { value: '100u' } }, wrongPinLookup, { vcc: 5 }, ); expect(emission).toBeNull(); }); }); describe('pushbutton — 4-pin tactile model', () => { const lookup = (wired: Record) => (pin: string) => wired[pin] ?? null; const emit = (wired: Record, pressed = false) => componentToSpice( { id: 'b1', metadataId: 'pushbutton', properties: { pressed } }, lookup(wired), { vcc: 3.3 }, ); it('switches between terminal 1 and 2 wired on the .l legs', () => { const e = emit({ '1.l': 'gpio', '2.l': 'gnd' }); expect(e!.cards).toEqual(['R_b1_sw gpio gnd 1000000000']); }); it('works wired on the .r legs too (terminal legs are interchangeable)', () => { const e = emit({ '1.r': 'gpio', '2.r': 'gnd' }); expect(e!.cards).toEqual(['R_b1_sw gpio gnd 1000000000']); }); it('pressing closes the switch (low resistance)', () => { const e = emit({ '1.l': 'gpio', '2.l': 'gnd' }, true); expect(e!.cards).toEqual(['R_b1_sw gpio gnd 0.01']); }); it('GPIO and GND on the SAME terminal is a dead short, like real hardware', () => { // The classic miswire: GND lands on 1.r while the GPIO is on 1.l. Both legs // belong to terminal 1, so they are internally shorted — the pin can never // read anything but the GND it is tied to, and there is no switch path. const e = emit({ '1.l': 'gpio', '1.r': 'gnd' }); expect(e!.cards).toEqual(['R_b1_t1 gpio gnd 0.01']); expect(e!.cards.some((c) => c.includes('_sw'))).toBe(false); }); it('back-compat: 2-pin A/B variant still emits a switch', () => { const e = emit({ A: 'n1', B: 'n2' }); expect(e!.cards).toEqual(['R_b1_sw n1 n2 1000000000']); }); it('returns null when nothing is wired', () => { expect(emit({})).toBeNull(); }); }); // Mappers whose output depends on live runtime state rather than the static // component (pins + properties) a fixture can describe. `custom-chip` emits its // SPICE sources from getChipDrivenPins(comp.id) — the chip's currently-driven // output pins — so a static fixture always yields null. It is exercised by the // chip-bus integration tests instead, not this catalog harness. const RUNTIME_STATE_MAPPERS = new Set(['custom-chip']); describe('componentToSpice — catalog completeness', () => { it('every mapped metadataId has a test fixture', () => { const missing = mappedMetadataIds().filter( (id) => !MINIMAL_FIXTURES[id] && !RUNTIME_STATE_MAPPERS.has(id), ); expect(missing, `Missing fixtures for: ${missing.join(', ')}`).toEqual([]); }); it('every mapping emits at least one card', () => { for (const id of mappedMetadataIds()) { const fx = MINIMAL_FIXTURES[id]; if (!fx) continue; const netLookup = (pin: string) => (fx.pins.includes(pin) ? `n_${pin}` : null); const emission = componentToSpice( { id: 'test', metadataId: id, properties: fx.properties ?? {} }, netLookup, { vcc: 5 }, ); expect(emission, `${id} emitted nothing`).not.toBeNull(); expect(emission!.cards.length, `${id} emitted 0 cards`).toBeGreaterThan(0); } }); }); // Components that can't be tested with the one-component harness regardless // of topology overrides: // - Op-amps have huge open-loop gain; need real feedback (e.g. inverting // or follower) to converge. Covered by spice-opamps.test.ts. // - Multi-gate ICs (74hc*, motor-driver) have many output pins that all // need individual loads. Covered by spice-active.test.ts and // spice_mapped_74hc.test.js. const NEEDS_CUSTOM_TOPOLOGY = new Set([ 'opamp-ideal', 'opamp-lm358', 'opamp-lm741', 'opamp-tl072', 'opamp-lm324', 'ic-74hc00', 'ic-74hc02', 'ic-74hc04', 'ic-74hc08', 'ic-74hc14', 'ic-74hc32', 'ic-74hc86', 'motor-driver-l293d', ]); describe('componentToSpice — ngspice accepts every card', () => { for (const id of Object.keys(MINIMAL_FIXTURES)) { if (NEEDS_CUSTOM_TOPOLOGY.has(id)) continue; it(`${id} produces a netlist ngspice can solve`, { timeout: 30_000 }, async () => { const fx = MINIMAL_FIXTURES[id]; const pins = fx.pins; const board = { id: 'brd', vcc: 5, pins: {}, groundPinNames: ['GND'], vccPinNames: ['VCC'], }; // Decide where each pin goes: explicit topology override wins; otherwise // fall back to the "first → VCC, last → GND, middle → load" default. type PinRole = 'vcc' | 'gnd' | 'load'; const roleOf = (pinName: string, idx: number): PinRole => { if (fx.topology && fx.topology[pinName]) return fx.topology[pinName]; if (idx === 0) return 'vcc'; if (idx === pins.length - 1) return 'gnd'; return 'load'; }; const wires: Array<{ id: string; start: { componentId: string; pinName: string }; end: { componentId: string; pinName: string }; }> = []; const loadResistors: Array<{ id: string; metadataId: string; properties: Record; }> = []; pins.forEach((pinName, idx) => { const role = roleOf(pinName, idx); if (role === 'vcc') { wires.push({ id: `w_${idx}_vcc`, start: { componentId: 'brd', pinName: 'VCC' }, end: { componentId: 'dut', pinName }, }); } else if (role === 'gnd') { wires.push({ id: `w_${idx}_gnd`, start: { componentId: 'dut', pinName }, end: { componentId: 'brd', pinName: 'GND' }, }); } else { // 'load': wire pin through an auto-created 1 MΩ resistor to GND. const loadId = `load_${idx}`; loadResistors.push({ id: loadId, metadataId: 'resistor', properties: { value: '1Meg' } }); wires.push({ id: `w_${idx}_load_a`, start: { componentId: 'dut', pinName }, end: { componentId: loadId, pinName: '1' }, }); wires.push({ id: `w_${idx}_load_b`, start: { componentId: loadId, pinName: '2' }, end: { componentId: 'brd', pinName: 'GND' }, }); } }); const { netlist } = buildNetlist({ components: [ { id: 'dut', metadataId: id, properties: fx.properties ?? {} }, ...loadResistors, ], wires, boards: [board], analysis: { kind: 'op' }, }); // Must at least contain the device's card. Accepted prefixes: // R/C/L (passives), D (diode), Q/M (BJT/MOSFET), E (VCVS), // S (switch), V (voltage source, e.g. signal-generator, battery), // B (behavioral source, e.g. logic gates, regulators, op-amps). expect(netlist).toMatch(new RegExp(`[RCLDQMESVB]_dut`)); const result = await runNetlist(netlist); // Accept if ngspice returned any voltage variable without throwing expect(result.variableNames.length).toBeGreaterThan(0); expect(Number.isFinite(result.dcValue(result.variableNames[0]))).toBe(true); }); } });