velxio/frontend/src/__tests__/component-to-spice.test.ts

413 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<string, unknown>;
topology?: Record<string, 'vcc' | 'gnd' | 'load'>;
}
const MINIMAL_FIXTURES: Record<string, Fixture> = {
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<string, string[]> = {
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<string, string>) => (pin: string) => wired[pin] ?? null;
const emit = (wired: Record<string, string>, 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<string, unknown>;
}> = [];
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);
});
}
});