/** * circuitVerifier — unit tests against hand-crafted netlists that * deliberately violate each safety rule. * * Each test feeds the verifier the *normalised* shape that the live store * already uses (components + wires arrays), runs an actual ngspice solve, * and asserts that the right warning code surfaces. */ import { describe, it, expect } from 'vitest'; import { verifyCircuit } from '../simulation/verify/circuitVerifier'; import type { BuildNetlistInput } from '../simulation/spice/types'; // ── Building blocks ────────────────────────────────────────────────────── function pwr(id = 'src', volts = 5): BuildNetlistInput['components'][number] { return { id, metadataId: 'signal-generator', properties: { waveform: 'dc', offset: volts, amplitude: 0, frequency: 1 }, }; } function res(id: string, ohms: string): BuildNetlistInput['components'][number] { return { id, metadataId: 'resistor', properties: { value: ohms } }; } function led(id: string, color = 'red'): BuildNetlistInput['components'][number] { return { id, metadataId: 'led', properties: { color } }; } function w( id: string, from: [string, string], to: [string, string], ): BuildNetlistInput['wires'][number] { return { id, start: { componentId: from[0], pinName: from[1] }, end: { componentId: to[0], pinName: to[1] }, }; } // ── Tests ──────────────────────────────────────────────────────────────── describe('verifyCircuit — clean circuits report no errors', () => { it( 'a 220Ω + red LED across 5 V is fine', { timeout: 30_000 }, async () => { const input: BuildNetlistInput = { components: [pwr('src'), res('r1', '220'), led('led1')], wires: [ w('w1', ['src', 'SIG'], ['r1', '1']), w('w2', ['r1', '2'], ['led1', 'A']), w('w3', ['led1', 'C'], ['src', 'GND']), ], boards: [], analysis: { kind: 'op' }, }; const result = await verifyCircuit(input); expect(result.errors, JSON.stringify(result.errors)).toEqual([]); // No "led-no-current" warning either — 5V/220Ω is well above µA. expect( result.warnings.filter((w) => w.code !== 'solver-failed'), ).toEqual([]); }, ); }); describe('verifyCircuit — short circuit detection', () => { it( 'fires an error when 5V is wired straight to GND', { timeout: 30_000 }, async () => { // Even with ESR-zero this represents a dead short — SPICE pushes huge // current through the source. Use a tiny series R so SPICE doesn't // produce a singular matrix, but well below 1Ω. const input: BuildNetlistInput = { components: [pwr('src'), res('rShort', '0.01')], wires: [ w('w1', ['src', 'SIG'], ['rShort', '1']), w('w2', ['rShort', '2'], ['src', 'GND']), ], boards: [], analysis: { kind: 'op' }, }; const result = await verifyCircuit(input); const codes = result.errors.map((e) => e.code); expect(codes, JSON.stringify(result.errors)).toContain('short-circuit'); }, ); }); describe('verifyCircuit — LED overcurrent', () => { it( 'fires an error when an LED is wired with too small a series resistor', { timeout: 30_000 }, async () => { // 5V → 10Ω → LED (Vf ≈ 2V) → GND. I ≈ (5-2)/10 = 300 mA — well above // the 20 mA absolute maximum. const input: BuildNetlistInput = { components: [pwr('src'), res('r1', '10'), led('led1')], wires: [ w('w1', ['src', 'SIG'], ['r1', '1']), w('w2', ['r1', '2'], ['led1', 'A']), w('w3', ['led1', 'C'], ['src', 'GND']), ], boards: [], analysis: { kind: 'op' }, }; const result = await verifyCircuit(input); const codes = result.errors.map((e) => e.code); expect(codes, JSON.stringify(result.errors)).toContain('led-overcurrent'); // The LED itself should be tagged. const ledErr = result.errors.find((e) => e.code === 'led-overcurrent'); expect(ledErr?.componentId).toBe('led1'); }, ); }); describe('verifyCircuit — resistor over-power', () => { it( 'fires a warning when a small resistor across 5 V dissipates too much', { timeout: 30_000 }, async () => { // 5V across 10Ω → I = 0.5 A → P = 2.5 W. Way past the 1/4 W default. const input: BuildNetlistInput = { components: [pwr('src'), res('rHot', '10')], wires: [ w('w1', ['src', 'SIG'], ['rHot', '1']), w('w2', ['rHot', '2'], ['src', 'GND']), ], boards: [], analysis: { kind: 'op' }, }; const result = await verifyCircuit(input); const warnCodes = result.warnings.map((e) => e.code); // Resistor over-power is a non-blocking warning (real-world physical // concern, not a sim error). Short-circuit and other safety issues // still hit `errors`. expect(warnCodes, JSON.stringify(result.warnings)).toContain('resistor-overpower'); }, ); it( 'respects a custom power property on the resistor', { timeout: 30_000 }, async () => { // 5V across 10Ω with explicit 5W rating → no overpower warning. const r: BuildNetlistInput['components'][number] = { id: 'rBig', metadataId: 'resistor', properties: { value: '10', power: 5 }, }; const input: BuildNetlistInput = { components: [pwr('src'), r], wires: [ w('w1', ['src', 'SIG'], ['rBig', '1']), w('w2', ['rBig', '2'], ['src', 'GND']), ], boards: [], analysis: { kind: 'op' }, }; const result = await verifyCircuit(input); const overpowerErr = result.errors.find((e) => e.code === 'resistor-overpower'); const overpowerWarn = result.warnings.find((e) => e.code === 'resistor-overpower'); expect(overpowerErr, JSON.stringify(result.errors)).toBeUndefined(); expect(overpowerWarn, JSON.stringify(result.warnings)).toBeUndefined(); }, ); }); describe('verifyCircuit — threshold overrides', () => { it( 'bumping shortCircuitAmps suppresses the short-circuit error', { timeout: 30_000 }, async () => { const input: BuildNetlistInput = { components: [pwr('src'), res('rShort', '0.01')], wires: [ w('w1', ['src', 'SIG'], ['rShort', '1']), w('w2', ['rShort', '2'], ['src', 'GND']), ], boards: [], analysis: { kind: 'op' }, }; const result = await verifyCircuit(input, { shortCircuitAmps: 1000 }); const codes = result.errors.map((e) => e.code); expect(codes).not.toContain('short-circuit'); }, ); }); describe('verifyCircuit — over-voltage on rated parts', () => { function part(id: string, metadataId: string): BuildNetlistInput['components'][number] { return { id, metadataId, properties: {} }; } it( 'warns when a 3.3-5V module (SSD1306 VIN) is fed 9 V', { timeout: 30_000 }, async () => { const input: BuildNetlistInput = { components: [pwr('src', 9), part('oled1', 'ssd1306')], wires: [ w('w1', ['src', 'SIG'], ['oled1', 'VIN']), w('w2', ['oled1', 'GND'], ['src', 'GND']), ], boards: [], analysis: { kind: 'op' }, }; const result = await verifyCircuit(input); const ov = result.warnings.find((x) => x.code === 'over-voltage'); expect(ov, JSON.stringify(result.warnings)).toBeDefined(); expect(ov?.componentId).toBe('oled1'); // over-voltage is non-blocking expect(result.errors.map((e) => e.code)).not.toContain('over-voltage'); }, ); it( 'does NOT warn when the same module is fed a safe 5 V on VIN', { timeout: 30_000 }, async () => { const input: BuildNetlistInput = { components: [pwr('src', 5), part('oled2', 'ssd1306')], wires: [ w('w1', ['src', 'SIG'], ['oled2', 'VIN']), w('w2', ['oled2', 'GND'], ['src', 'GND']), ], boards: [], analysis: { kind: 'op' }, }; const result = await verifyCircuit(input); expect(result.warnings.map((x) => x.code)).not.toContain('over-voltage'); }, ); it( 'warns when a strict 3.3 V pin (SSD1306 3V3) sits on a 5 V rail', { timeout: 30_000 }, async () => { // VCC-like pin names (VCC/VDD/3V3/5V) canonicalise to the shared // `vcc_rail` net, which defaults to 5 V. A 10k load gives the rail a // real path to ground so the .op solves. The OLED's 3V3 pin (abs max // 3.6 V) on that 5 V rail must warn. const input: BuildNetlistInput = { components: [part('oled3', 'ssd1306'), res('rl', '10k')], wires: [ w('w1', ['oled3', '3V3'], ['rl', '1']), w('w2', ['rl', '2'], ['oled3', 'GND']), ], boards: [], analysis: { kind: 'op' }, }; const result = await verifyCircuit(input); const ov = result.warnings.find((x) => x.code === 'over-voltage'); expect(ov, JSON.stringify(result.warnings)).toBeDefined(); expect(ov?.componentId).toBe('oled3'); }, ); }); describe('verifyCircuit — board over-voltage (graph-based)', () => { function board(id: string, boardKind: string): BuildNetlistInput['boards'][number] { return { id, boardKind, vcc: 3.3, pins: {}, groundPinNames: ['GND', 'GND.1', 'GND.2'], vccPinNames: ['3V3', 'VIN', '5V'], }; } it( 'warns when a 9 V battery is wired to an ESP32 VIN pin', { timeout: 30_000 }, async () => { const input: BuildNetlistInput = { components: [{ id: 'bat', metadataId: 'battery-9v', properties: {} }], wires: [ { id: 'w1', start: { componentId: 'bat', pinName: '+' }, end: { componentId: 'esp32', pinName: 'VIN' } }, { id: 'w2', start: { componentId: 'esp32', pinName: 'GND' }, end: { componentId: 'bat', pinName: '−' } }, ], boards: [board('esp32', 'esp32')], analysis: { kind: 'op' }, }; const result = await verifyCircuit(input); const ov = result.warnings.find((w) => w.code === 'over-voltage' && w.componentId === 'esp32'); expect(ov, JSON.stringify(result.warnings)).toBeDefined(); }, ); it( 'does NOT warn when an AA battery (1.5 V) powers the VIN pin', { timeout: 30_000 }, async () => { const input: BuildNetlistInput = { components: [{ id: 'bat', metadataId: 'battery-aa', properties: {} }], wires: [ { id: 'w1', start: { componentId: 'bat', pinName: '+' }, end: { componentId: 'esp32', pinName: 'VIN' } }, { id: 'w2', start: { componentId: 'esp32', pinName: 'GND' }, end: { componentId: 'bat', pinName: '−' } }, ], boards: [board('esp32', 'esp32')], analysis: { kind: 'op' }, }; const result = await verifyCircuit(input); expect(result.warnings.map((w) => w.code)).not.toContain('over-voltage'); }, ); }); describe('verifyCircuit — electrolytic capacitor', () => { function cap(id: string, voltage: string): BuildNetlistInput['components'][number] { return { id, metadataId: 'capacitor-electrolytic', properties: { value: '10u', voltage } }; } it( 'warns when the voltage across it exceeds its rating', { timeout: 30_000 }, async () => { // 24 V across a 16 V cap. const input: BuildNetlistInput = { components: [pwr('src', 24), cap('c1', '16')], wires: [ w('w1', ['src', 'SIG'], ['c1', '+']), w('w2', ['c1', '−'], ['src', 'GND']), ], boards: [], analysis: { kind: 'op' }, }; const result = await verifyCircuit(input); const ov = result.warnings.find((x) => x.code === 'over-voltage' && x.componentId === 'c1'); expect(ov, JSON.stringify(result.warnings)).toBeDefined(); }, ); it( 'does NOT warn when within its rating', { timeout: 30_000 }, async () => { const input: BuildNetlistInput = { components: [pwr('src', 5), cap('c2', '25')], wires: [ w('w1', ['src', 'SIG'], ['c2', '+']), w('w2', ['c2', '−'], ['src', 'GND']), ], boards: [], analysis: { kind: 'op' }, }; const result = await verifyCircuit(input); expect(result.warnings.map((x) => x.code)).not.toContain('over-voltage'); expect(result.warnings.map((x) => x.code)).not.toContain('reverse-polarity'); }, ); it( 'warns on reverse polarity', { timeout: 30_000 }, async () => { // +5 V on the minus pin, plus pin to ground → reverse-biased. const input: BuildNetlistInput = { components: [pwr('src', 5), cap('c3', '25')], wires: [ w('w1', ['src', 'SIG'], ['c3', '−']), w('w2', ['c3', '+'], ['src', 'GND']), ], boards: [], analysis: { kind: 'op' }, }; const result = await verifyCircuit(input); const rp = result.warnings.find((x) => x.code === 'reverse-polarity' && x.componentId === 'c3'); expect(rp, JSON.stringify(result.warnings)).toBeDefined(); }, ); }); describe('verifyCircuit — wiring ERC (bad connections)', () => { it( 'warns when a 2-terminal part is connected on only one side', { timeout: 30_000 }, async () => { const input: BuildNetlistInput = { components: [pwr('src', 5), res('r1', '1k')], wires: [w('w1', ['src', 'SIG'], ['r1', '1'])], // r1 pin '2' left floating boards: [], analysis: { kind: 'op' }, }; const result = await verifyCircuit(input); const mc = result.warnings.find((x) => x.code === 'missing-connection' && x.componentId === 'r1'); expect(mc, JSON.stringify(result.warnings)).toBeDefined(); }, ); it( 'does NOT warn when both terminals are wired', { timeout: 30_000 }, async () => { const input: BuildNetlistInput = { components: [pwr('src', 5), res('r1', '1k')], wires: [ w('w1', ['src', 'SIG'], ['r1', '1']), w('w2', ['r1', '2'], ['src', 'GND']), ], boards: [], analysis: { kind: 'op' }, }; const result = await verifyCircuit(input); expect(result.warnings.map((x) => x.code)).not.toContain('missing-connection'); }, ); it( 'warns when a powered module is missing its ground connection', { timeout: 30_000 }, async () => { const input: BuildNetlistInput = { components: [pwr('src', 5), { id: 'o1', metadataId: 'ssd1306', properties: {} }], wires: [w('w1', ['src', 'SIG'], ['o1', 'VIN'])], // VIN wired, GND not boards: [], analysis: { kind: 'op' }, }; const result = await verifyCircuit(input); const mc = result.warnings.find((x) => x.code === 'missing-connection' && x.componentId === 'o1'); expect(mc, JSON.stringify(result.warnings)).toBeDefined(); }, ); it( 'errors when a VCC pin is wired directly to a GND pin', { timeout: 30_000 }, async () => { const input: BuildNetlistInput = { components: [{ id: 'o1', metadataId: 'ssd1306', properties: {} }], wires: [w('w1', ['o1', '3V3'], ['o1', 'GND'])], // VCC tied straight to GND boards: [], analysis: { kind: 'op' }, }; const result = await verifyCircuit(input); const codes = result.errors.map((e) => e.code); expect(codes, JSON.stringify(result.errors)).toContain('power-short'); }, ); it( 'warns when a part has both terminals on the same node (shorted out)', { timeout: 30_000 }, async () => { const input: BuildNetlistInput = { components: [pwr('src', 5), res('r1', '1k')], wires: [ w('w1', ['src', 'SIG'], ['r1', '1']), w('w2', ['src', 'SIG'], ['r1', '2']), // both terminals on the SIG net ], boards: [], analysis: { kind: 'op' }, }; const result = await verifyCircuit(input); const sc = result.warnings.find((x) => x.code === 'shorted-component' && x.componentId === 'r1'); expect(sc, JSON.stringify(result.warnings)).toBeDefined(); }, ); }); // ── Sanity: shipping examples never trigger errors ───────────────────────── // If any gallery example produces a verifier error, that's a bug in the // example itself. Loop a handful of representative ones to catch // regressions early. import { digitalExamples } from '../data/examples-digital'; import { analogExamples } from '../data/examples-analog'; function toInput(ex: { components: any[]; wires: any[] }): BuildNetlistInput { return { components: ex.components.map((c: any) => ({ id: c.id, metadataId: c.type.replace(/^(wokwi|velxio)-/, ''), properties: c.properties ?? {}, })), wires: ex.wires.map((wire: any) => ({ id: wire.id, start: { componentId: wire.start.componentId, pinName: wire.start.pinName }, end: { componentId: wire.end.componentId, pinName: wire.end.pinName }, })), boards: [], analysis: { kind: 'op' }, }; } describe('verifyCircuit — shipping gallery examples are clean', () => { it( 'every digital example passes pre-flight verification', { timeout: 180_000 }, async () => { const failures: string[] = []; for (const ex of digitalExamples) { const result = await verifyCircuit(toInput(ex)); if (result.errors.length > 0) { failures.push( `${ex.id}: ${result.errors.map((e) => `${e.code}(${e.componentId ?? '-'})`).join(', ')}`, ); } } expect(failures, failures.join('\n')).toEqual([]); }, ); it( 'every analog example passes pre-flight verification', { timeout: 180_000 }, async () => { const failures: string[] = []; for (const ex of analogExamples) { const result = await verifyCircuit(toInput(ex)); if (result.errors.length > 0) { failures.push( `${ex.id}: ${result.errors.map((e) => `${e.code}(${e.componentId ?? '-'})`).join(', ')}`, ); } } expect(failures, failures.join('\n')).toEqual([]); }, ); });