/** * 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'); }, ); }); // ── 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([]); }, ); });