import { describe, it, expect } from 'vitest'; import { runNetlist } from '../src/spice/SpiceEngine.js'; import { AVRHarness } from '../src/avr/AVRHarness.js'; import { adcReadProgram, potToPwmProgram } from '../src/avr/programs.js'; /** * Board-level integration tests: exercise the Velxio supported boards under * realistic external circuits through ngspice. Boards split into two groups: * * 5 V group — Arduino Uno, Nano, Mega, Raspberry Pi 3 (GPIO @ 3.3 V but * often powers 5 V peripherals through its 5V rail). * Uno/Nano use the ATmega328P to AVRHarness (cycle-accurate). * Mega (ATmega2560) is modelled at the circuit level only * since we don't have a Mega AVR firmware harness here. * * 3.3 V group — ESP32 variants, Raspberry Pi Pico, Pico W, Xiao boards. * Validated by running the same topologies at 3.3 V and * checking that logic-level MOSFETs / BJTs still switch. * * These tests validate that the SPICE netlists produced by Velxio's pin-group * config (see frontend/src/simulation/spice/boardPinGroups.ts) stay internally * consistent: GND at 0 V, supply rail at the expected Vcc, peripherals behave. */ // ── Arduino Uno / Nano (ATmega328P, 5 V) ────────────────────────────────── describe('Arduino Uno — AVR + SPICE mixed-signal (5V)', () => { it('reads a potentiometer via ADC: wiper position ⇔ ADC value', { timeout: 60_000 }, async () => { const wiperPositions = [ { pos: 0.1, expectedAdcApprox: 102 }, { pos: 0.5, expectedAdcApprox: 511 }, { pos: 0.9, expectedAdcApprox: 920 }, ]; for (const wp of wiperPositions) { // ngspice: build a 10 kΩ pot divider at 5 V with the wiper at position wp const Rtop = Math.max(1, (1 - wp.pos) * 10000); const Rbot = Math.max(1, wp.pos * 10000); const { dcValue } = await runNetlist(`Uno pot divider Vcc vcc 0 DC 5 Rtop vcc a0 ${Rtop} Rbot a0 0 ${Rbot} .op .end`); const va0 = dcValue('v(a0)'); // AVR reads it const avr = new AVRHarness(); avr.loadProgram(adcReadProgram()); avr.setAnalogVoltage(0, va0); avr.runCycles(500_000); const ADCH = avr.cpu.data[0x79]; const ADCL = avr.cpu.data[0x78]; const raw = (ADCH << 2) | (ADCL >> 6); expect(Math.abs(raw - wp.expectedAdcApprox)).toBeLessThan(10); } }); it('5 V rail sags < 0.1 V when driving a 220Ω-series LED (sanity for pin-drive tests)', { timeout: 30_000 }, async () => { // Model a pin outputting 5V with a realistic 40Ω output impedance (ATmega328P // typical) driving a red LED through a 220Ω series resistor. // Use a standard diode model (Is=1e-14) — the ultra-small Is values used // for LED Vf-tuning in Velxio's SPICE layer give ngspice convergence // trouble under Newton's method during .op; for system-level verification // a generic diode captures the topology fine. const netlist = `Uno pin to LED Vpin pin_src 0 DC 5 Rpin pin_src pin 40 R1 pin anode 220 D1 anode 0 DLED .model DLED D(Is=1e-14 N=1.8) .op .end`; const { dcValue } = await runNetlist(netlist); const vpin = dcValue('v(pin)'); // Loaded pin voltage should stay above 4.4 V (40·I drop + LED forward drop + Rs) expect(vpin).toBeGreaterThan(4.4); expect(vpin).toBeLessThan(5.0); }); }); describe('Arduino Nano — ATmega328P-compatible firmware (5V)', () => { it('PWM (Timer0A pin 6) to LED through 220Ω produces proportional brightness', { timeout: 60_000 }, async () => { // Set the ADC input to 4 V so the potToPwm sketch commands ~80% duty. const avr = new AVRHarness(); avr.loadProgram(potToPwmProgram()); avr.setAnalogVoltage(0, 4.0); avr.runCycles(400_000); const duty = avr.getPWMDuty(6); expect(duty).toBeGreaterThan(0.78); expect(duty).toBeLessThan(0.86); // Model the time-average of that PWM as V = duty · 5 feeding an LED driver const Vavg = duty * 5; const { dcValue } = await runNetlist(`Nano PWM-avg LED Vavg pin 0 DC ${Vavg.toFixed(4)} R1 pin anode 220 D1 anode 0 LED_GREEN .model LED_GREEN D(Is=1e-14 N=2.0) .op .end`); const vled = dcValue('v(anode)'); // LED forward drop ≈ 0.5–2.5 V depending on current through 220 Ω expect(vled).toBeGreaterThan(0.5); expect(vled).toBeLessThan(2.6); }); }); // ── Arduino Mega (ATmega2560, 5 V) — ngspice-only ───────────────────────── describe('Arduino Mega — multi-output circuits (5V, ngspice-only)', () => { it('4-channel PWM drives 4 LEDs independently — all currents > 5 mA when duty ≥ 25%', { timeout: 30_000 }, async () => { // Four PWM-averaged voltages feed four LEDs in parallel, each through 220Ω. // The Mega has 15 PWM pins total — we stress-test 4 to confirm the netlist // stays solvable with many independent branches. const netlist = `Mega 4-channel PWM LEDs V1 pwm1 0 DC 1.25 V2 pwm2 0 DC 2.5 V3 pwm3 0 DC 3.75 V4 pwm4 0 DC 5.0 R1 pwm1 a1 220 R2 pwm2 a2 220 R3 pwm3 a3 220 R4 pwm4 a4 220 D1 a1 0 LED_RED D2 a2 0 LED_RED D3 a3 0 LED_RED D4 a4 0 LED_RED .model LED_RED D(Is=1e-14 N=1.8) .op .end`; const { dcValue } = await runNetlist(netlist); // Each LED anode voltage ≈ Vf ≈ 1.8 V when conducting // Brightness order: V1 < V2 < V3 < V4 const va1 = dcValue('v(a1)'); const va2 = dcValue('v(a2)'); const va3 = dcValue('v(a3)'); const va4 = dcValue('v(a4)'); // All forward-biased: anode must be ≥ 0.5V (some conduction) for (const v of [va1, va2, va3, va4]) { expect(v).toBeGreaterThan(0.5); expect(v).toBeLessThan(2.5); } // Brighter channel has lower anode voltage drop across LED (more current = clamp near Vf) // but higher current through R means less drop across LED becomes negligible. // Monotonic test: channels 2–4 all higher than channel 1 (which barely conducts). expect(va4).toBeGreaterThan(va1); }); it('Mega driving a 4-relay board: each BJT switch engages its coil independently', { timeout: 45_000 }, async () => { // Common Mega use-case: a 4-relay module. Each relay has a 2N2222 driving a // coil (here modelled as 400Ω resistor), with the pin input through 1kΩ. const cases = [ { drives: [5, 0, 0, 0], onIdx: 0 }, { drives: [0, 5, 0, 0], onIdx: 1 }, { drives: [0, 0, 5, 0], onIdx: 2 }, { drives: [0, 0, 0, 5], onIdx: 3 }, ]; for (const c of cases) { const netlist = `Mega 4-relay Vcc vcc 0 DC 5 V1 pin1 0 DC ${c.drives[0]} V2 pin2 0 DC ${c.drives[1]} V3 pin3 0 DC ${c.drives[2]} V4 pin4 0 DC ${c.drives[3]} RB1 pin1 b1 1k RB2 pin2 b2 1k RB3 pin3 b3 1k RB4 pin4 b4 1k Rcoil1 vcc c1 400 Rcoil2 vcc c2 400 Rcoil3 vcc c3 400 Rcoil4 vcc c4 400 Q1 c1 b1 0 Q2N2222 Q2 c2 b2 0 Q2N2222 Q3 c3 b3 0 Q2N2222 Q4 c4 b4 0 Q2N2222 .model Q2N2222 NPN(Is=14.34f Bf=200 Vaf=74) .op .end`; const { dcValue } = await runNetlist(netlist); const vc = [dcValue('v(c1)'), dcValue('v(c2)'), dcValue('v(c3)'), dcValue('v(c4)')]; for (let i = 0; i < 4; i++) { if (i === c.onIdx) { // Relay ON to collector near 0 expect(vc[i]).toBeLessThan(0.3); } else { // Relay OFF to collector near Vcc expect(vc[i]).toBeGreaterThan(4.5); } } } }); }); // ── ESP32 / Pi Pico (3.3 V group) — ngspice-only ────────────────────────── describe('ESP32 / Pi Pico — 3.3V logic-level circuits', () => { it('logic-level MOSFET (Vth=1.6 V) fully switches a 5 V load from a 3.3 V GPIO', { timeout: 30_000 }, async () => { // Key use-case: 3.3 V MCUs driving peripherals through a logic-level MOSFET. // Vgs_th ≈ 1.6 V to fully on with 3.3 V gate. Uses Level-1 with // numerically stable W/L to avoid ngspice convergence issues. const netlist = `3.3V GPIO to logic-level NMOS to 5V load V_sys vsys 0 DC 5 Vgpio gate 0 DC 3.3 RL vsys drain 1k M1 drain gate 0 0 NLOGIC L=2u W=200u .model NLOGIC NMOS(Level=1 Vto=1.6 Kp=50u Lambda=0.01) .op .end`; const { dcValue } = await runNetlist(netlist); const vd = dcValue('v(drain)'); // MOSFET firmly in linear region to drain near 0 expect(vd).toBeLessThan(1.0); }); it('non-logic-level MOSFET (Vth=3.0 V) barely responds to a 3.3 V GPIO: drain stays high', { timeout: 30_000 }, async () => { // A 3 V-threshold FET with only 0.3 V of overdrive from a 3.3 V GPIO sits // in weak inversion — the drain barely drops. const netlist = `3.3V GPIO to non-logic-level NMOS to 5V load V_sys vsys 0 DC 5 Vgpio gate 0 DC 3.3 RL vsys drain 1k M1 drain gate 0 0 NSTD L=2u W=200u .model NSTD NMOS(Level=1 Vto=3.0 Kp=50u Lambda=0.01) .op .end`; const { dcValue } = await runNetlist(netlist); const vd = dcValue('v(drain)'); // Overdrive only 0.3 V to minimal conduction to drain stays near Vsys. expect(vd).toBeGreaterThan(3.0); }); it('3.3V ↔ 5V bidirectional level shifter (N-MOSFET + pull-ups)', { timeout: 30_000 }, async () => { // Classic I2C level shifter: N-MOS + pull-ups on each side. // When the LV side is pulled low, HV side follows low (through body diode + MOS). // When LV side is high (3.3V), HV side is pulled up to 5V by its pull-up. for (const driveLow of [false, true]) { const netlist = `Level shifter lowSide=${driveLow ? 'DRIVE_LOW' : 'FLOAT'} V_lv lv_rail 0 DC 3.3 V_hv hv_rail 0 DC 5 Rpu_lv lv_rail lv_node 10k Rpu_hv hv_rail hv_node 10k M1 hv_node lv_rail lv_node 0 NLOGIC L=2u W=200u .model NLOGIC NMOS(Level=1 Vto=1.6 Kp=50u Lambda=0.01) ${driveLow ? 'Vdrv lv_node 0 DC 0' : 'Rfloat lv_node 0 10Meg'} .op .end`; const { dcValue } = await runNetlist(netlist); const hv = dcValue('v(hv_node)'); const lv = dcValue('v(lv_node)'); if (driveLow) { // Pulling LV side low should translate to HV side low (through MOSFET body diode) expect(lv).toBeLessThan(0.1); expect(hv).toBeLessThan(1.0); } else { // Both sides pulled high: LV ≈ 3.3, HV ≈ 5 expect(lv).toBeGreaterThan(3.2); expect(hv).toBeGreaterThan(4.8); } } }); }); // ── Cross-board: identical topology at 5 V vs 3.3 V ─────────────────────── describe('Cross-board — same divider at 5V vs 3.3V supplies', () => { it('voltage divider scales linearly with supply: Vo/Vcc stays constant', { timeout: 30_000 }, async () => { for (const vcc of [5, 3.3]) { const netlist = `Divider Vcc=${vcc} V1 vcc 0 DC ${vcc} R1 vcc mid 1k R2 mid 0 2k .op .end`; const { dcValue } = await runNetlist(netlist); const vmid = dcValue('v(mid)'); // Ratio is 2/3 — must hold for any Vcc expect(vmid / vcc).toBeCloseTo(2 / 3, 3); } }); });