421 lines
13 KiB
TypeScript
421 lines
13 KiB
TypeScript
/**
|
|
* Integration Tests
|
|
*
|
|
* End-to-end tests that combine real simulators with PinManager and
|
|
* component-style callbacks to verify the full simulation pipeline.
|
|
*
|
|
* Covers:
|
|
* - AVRSimulator + PinManager: HEX execution drives pins, ADC, PWM
|
|
* - RP2040Simulator + PinManager: GPIO updates, setPinState, reset
|
|
* - Board switching: both simulators can share the same PinManager
|
|
* - PWM pipeline: OCR register → PinManager.updatePwm → component callback
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { AVRSimulator } from '../simulation/AVRSimulator';
|
|
import { RP2040Simulator } from '../simulation/RP2040Simulator';
|
|
import { PinManager } from '../simulation/PinManager';
|
|
|
|
// ─── RAF depth-limited mock ───────────────────────────────────────────────────
|
|
// Calls execute() once synchronously per start(); prevents infinite recursion.
|
|
beforeEach(() => {
|
|
let counter = 0;
|
|
let depth = 0;
|
|
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
|
if (depth === 0) {
|
|
depth++;
|
|
cb(0);
|
|
depth--;
|
|
}
|
|
return ++counter;
|
|
});
|
|
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
|
});
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
// ─── HEX / binary fixtures ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Minimal Intel HEX that sets pin 13 HIGH and loops:
|
|
* LDI r16, 0xFF → OUT DDRB → LDI r16, 0x20 → OUT PORTB → RJMP .-2
|
|
*/
|
|
const BLINK_HEX =
|
|
':0A0000000FEF04B900E205B9FFCFCD\n' +
|
|
':00000001FF\n';
|
|
|
|
/** Empty program (EOF record only) */
|
|
const EMPTY_HEX = ':00000001FF\n';
|
|
|
|
/** Create a base64-encoded all-zero binary of the given size in KB */
|
|
function zeroBinary(sizeKb = 1): string {
|
|
const bytes = new Uint8Array(sizeKb * 1024);
|
|
let s = '';
|
|
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
|
return btoa(s);
|
|
}
|
|
|
|
// ─── AVRSimulator + PinManager ────────────────────────────────────────────────
|
|
|
|
describe('Integration — AVRSimulator + PinManager', () => {
|
|
let pm: PinManager;
|
|
let sim: AVRSimulator;
|
|
|
|
beforeEach(() => {
|
|
pm = new PinManager();
|
|
sim = new AVRSimulator(pm);
|
|
});
|
|
afterEach(() => sim.stop());
|
|
|
|
// ── step()-based tests (no RAF needed) ──────────────────────────────────────
|
|
|
|
it('executes BLINK_HEX and drives pin 13 HIGH after 4 instructions', () => {
|
|
sim.loadHex(BLINK_HEX);
|
|
|
|
const changes: boolean[] = [];
|
|
pm.onPinChange(13, (_pin, state) => changes.push(state));
|
|
|
|
sim.step(); // LDI r16, 0xFF
|
|
sim.step(); // OUT DDRB, r16
|
|
sim.step(); // LDI r16, 0x20
|
|
sim.step(); // OUT PORTB, r16 → pin 13 HIGH
|
|
|
|
expect(pm.getPinState(13)).toBe(true);
|
|
expect(changes).toContain(true);
|
|
});
|
|
|
|
it('pin 13 is LOW before any instructions execute', () => {
|
|
sim.loadHex(BLINK_HEX);
|
|
expect(pm.getPinState(13)).toBe(false);
|
|
});
|
|
|
|
it('LED component callback fires when pin 13 goes HIGH', () => {
|
|
sim.loadHex(BLINK_HEX);
|
|
|
|
const ledState = { on: false };
|
|
pm.onPinChange(13, (_pin, state) => { ledState.on = state; });
|
|
|
|
sim.step(); sim.step(); sim.step(); sim.step();
|
|
|
|
expect(ledState.on).toBe(true);
|
|
});
|
|
|
|
it('pin 13 stays HIGH through the RJMP loop', () => {
|
|
sim.loadHex(BLINK_HEX);
|
|
sim.step(); sim.step(); sim.step(); sim.step(); // set pin HIGH
|
|
|
|
for (let i = 0; i < 10; i++) sim.step(); // execute 10 more (RJMP loops)
|
|
|
|
expect(pm.getPinState(13)).toBe(true);
|
|
});
|
|
|
|
it('ADC channel is accessible and writeable after loadHex()', () => {
|
|
sim.loadHex(EMPTY_HEX);
|
|
|
|
const adc = sim.getADC();
|
|
expect(adc).not.toBeNull();
|
|
|
|
adc!.channelValues[0] = 3.3; // Inject 3.3V on A0
|
|
expect(adc!.channelValues[0]).toBe(3.3);
|
|
|
|
adc!.channelValues[3] = 1.8; // A3
|
|
expect(adc!.channelValues[3]).toBe(1.8);
|
|
});
|
|
|
|
it('ADC resets to default on reset()', () => {
|
|
sim.loadHex(EMPTY_HEX);
|
|
sim.getADC()!.channelValues[0] = 4.5;
|
|
sim.reset();
|
|
// Fresh AVRADC: channelValues[0] is unset (undefined) or 0 — either means no voltage
|
|
expect(sim.getADC()!.channelValues[0] ?? 0).toBe(0);
|
|
});
|
|
|
|
// ── start()/stop() — one frame via depth-limited RAF ───────────────────────
|
|
|
|
it('PWM OCR register write propagates to PinManager via one frame', () => {
|
|
sim.loadHex(EMPTY_HEX);
|
|
|
|
const pwmChanges: Array<{ pin: number; dc: number }> = [];
|
|
pm.onPwmChange(9, (pin, dc) => pwmChanges.push({ pin, dc }));
|
|
|
|
// Directly set OCR1AL = 128 (pin 9, ~50% duty cycle)
|
|
(sim as any).cpu.data[0x88] = 128;
|
|
|
|
sim.start(); // one frame executes, polls PWM registers
|
|
sim.stop();
|
|
|
|
expect(pwmChanges.length).toBeGreaterThan(0);
|
|
expect(pwmChanges[0].pin).toBe(9);
|
|
expect(pwmChanges[0].dc).toBeCloseTo(128 / 255, 2);
|
|
});
|
|
|
|
it('all six PWM pins propagate their OCR values in one frame', () => {
|
|
sim.loadHex(EMPTY_HEX);
|
|
|
|
const PWM_MAP = [
|
|
{ addr: 0x47, pin: 6, value: 50 },
|
|
{ addr: 0x48, pin: 5, value: 100 },
|
|
{ addr: 0x88, pin: 9, value: 150 },
|
|
{ addr: 0x8A, pin: 10, value: 200 },
|
|
{ addr: 0xB3, pin: 11, value: 25 },
|
|
{ addr: 0xB4, pin: 3, value: 75 },
|
|
];
|
|
|
|
const received: Record<number, number> = {};
|
|
PWM_MAP.forEach(({ pin }) => {
|
|
pm.onPwmChange(pin, (_p, dc) => { received[pin] = dc; });
|
|
});
|
|
|
|
const cpu = (sim as any).cpu;
|
|
PWM_MAP.forEach(({ addr, value }) => { cpu.data[addr] = value; });
|
|
|
|
sim.start();
|
|
sim.stop();
|
|
|
|
PWM_MAP.forEach(({ pin, value }) => {
|
|
expect(received[pin]).toBeCloseTo(value / 255, 2);
|
|
});
|
|
});
|
|
|
|
it('setPinState drives external input pins without throwing', () => {
|
|
sim.loadHex(EMPTY_HEX);
|
|
// setPinState drives external INPUT (button/switch); PinManager tracks CPU PORT OUTPUT.
|
|
// These should not throw regardless of pin range.
|
|
expect(() => sim.setPinState(4, true)).not.toThrow(); // PORTD
|
|
expect(() => sim.setPinState(13, true)).not.toThrow(); // PORTB
|
|
expect(() => sim.setPinState(14, true)).not.toThrow(); // PORTC/A0
|
|
});
|
|
|
|
it('multiple onPinChange subscribers all fire for the same pin', () => {
|
|
sim.loadHex(BLINK_HEX);
|
|
|
|
const cb1 = vi.fn();
|
|
const cb2 = vi.fn();
|
|
pm.onPinChange(13, cb1);
|
|
pm.onPinChange(13, cb2);
|
|
|
|
sim.step(); sim.step(); sim.step(); sim.step();
|
|
|
|
expect(cb1).toHaveBeenCalledWith(13, true);
|
|
expect(cb2).toHaveBeenCalledWith(13, true);
|
|
});
|
|
});
|
|
|
|
// ─── RP2040Simulator + PinManager ────────────────────────────────────────────
|
|
|
|
describe('Integration — RP2040Simulator + PinManager', () => {
|
|
let pm: PinManager;
|
|
let sim: RP2040Simulator;
|
|
|
|
beforeEach(() => {
|
|
pm = new PinManager();
|
|
sim = new RP2040Simulator(pm);
|
|
});
|
|
afterEach(() => sim.stop());
|
|
|
|
it('triggerPinChange on GPIO25 fires LED_BUILTIN listener', () => {
|
|
sim.loadBinary(zeroBinary());
|
|
|
|
const ledState = { on: false };
|
|
pm.onPinChange(25, (_pin, state) => { ledState.on = state; });
|
|
|
|
pm.triggerPinChange(25, true);
|
|
expect(ledState.on).toBe(true);
|
|
});
|
|
|
|
it('triggerPinChange HIGH then LOW fires listener twice', () => {
|
|
sim.loadBinary(zeroBinary());
|
|
|
|
const cb = vi.fn();
|
|
pm.onPinChange(25, cb);
|
|
|
|
pm.triggerPinChange(25, true);
|
|
pm.triggerPinChange(25, false);
|
|
|
|
expect(cb).toHaveBeenCalledTimes(2);
|
|
expect(cb).toHaveBeenNthCalledWith(1, 25, true);
|
|
expect(cb).toHaveBeenNthCalledWith(2, 25, false);
|
|
});
|
|
|
|
it('setPinState does not throw for all 30 GPIO pins', () => {
|
|
sim.loadBinary(zeroBinary());
|
|
for (let i = 0; i < 30; i++) {
|
|
expect(() => sim.setPinState(i, true)).not.toThrow();
|
|
expect(() => sim.setPinState(i, false)).not.toThrow();
|
|
}
|
|
});
|
|
|
|
it('multiple independent GPIO listeners fire only their own pin', () => {
|
|
sim.loadBinary(zeroBinary());
|
|
|
|
const cb7 = vi.fn();
|
|
const cb25 = vi.fn();
|
|
const cb0 = vi.fn();
|
|
|
|
pm.onPinChange(7, cb7);
|
|
pm.onPinChange(25, cb25);
|
|
pm.onPinChange(0, cb0);
|
|
|
|
pm.triggerPinChange(25, true);
|
|
|
|
expect(cb25).toHaveBeenCalledWith(25, true);
|
|
expect(cb7).not.toHaveBeenCalled();
|
|
expect(cb0).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('GPIO listeners are still active after reset()', () => {
|
|
sim.loadBinary(zeroBinary());
|
|
sim.reset();
|
|
|
|
const cb = vi.fn();
|
|
pm.onPinChange(25, cb);
|
|
pm.triggerPinChange(25, true);
|
|
|
|
expect(cb).toHaveBeenCalledWith(25, true);
|
|
});
|
|
|
|
it('getADC() returns null before loadBinary()', () => {
|
|
expect(sim.getADC()).toBeNull();
|
|
});
|
|
|
|
it('getADC() returns an object after loadBinary()', () => {
|
|
sim.loadBinary(zeroBinary());
|
|
expect(sim.getADC()).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
// ─── Board switching ──────────────────────────────────────────────────────────
|
|
|
|
describe('Integration — board switching (AVR ↔ RP2040)', () => {
|
|
it('both simulators share the same PinManager instance', () => {
|
|
const pm = new PinManager();
|
|
const avrSim = new AVRSimulator(pm);
|
|
const rpSim = new RP2040Simulator(pm);
|
|
|
|
expect((avrSim as any).pinManager).toBe(pm);
|
|
expect((rpSim as any).pinManager).toBe(pm);
|
|
});
|
|
|
|
it('stopping AVR and starting RP2040 does not corrupt PinManager', () => {
|
|
const pm = new PinManager();
|
|
|
|
const avrSim = new AVRSimulator(pm);
|
|
avrSim.loadHex(EMPTY_HEX);
|
|
avrSim.start();
|
|
avrSim.stop();
|
|
|
|
// Load RP2040 binary (no start() to avoid running 2M ARM cycles in tests)
|
|
const rpSim = new RP2040Simulator(pm);
|
|
rpSim.loadBinary(zeroBinary());
|
|
|
|
// PinManager must still dispatch callbacks correctly after board switch
|
|
const cb = vi.fn();
|
|
pm.onPinChange(13, cb);
|
|
pm.triggerPinChange(13, true);
|
|
expect(cb).toHaveBeenCalledWith(13, true);
|
|
});
|
|
|
|
it('clearAllListeners resets PinManager between board switches', () => {
|
|
const pm = new PinManager();
|
|
|
|
const cb = vi.fn();
|
|
pm.onPinChange(5, cb);
|
|
expect(pm.getListenersCount()).toBe(1);
|
|
|
|
pm.clearAllListeners();
|
|
pm.triggerPinChange(5, true);
|
|
|
|
expect(cb).not.toHaveBeenCalled();
|
|
expect(pm.getListenersCount()).toBe(0);
|
|
});
|
|
|
|
it('AVR pin 13 HIGH does not affect RP2040 GPIO13 listener (no cross-talk)', () => {
|
|
const pm = new PinManager();
|
|
|
|
// Step 1: AVR drives pin 13 HIGH via CPU execution
|
|
const avrSim = new AVRSimulator(pm);
|
|
avrSim.loadHex(BLINK_HEX);
|
|
|
|
const avrCb = vi.fn();
|
|
pm.onPinChange(13, avrCb);
|
|
avrSim.step(); avrSim.step(); avrSim.step(); avrSim.step();
|
|
expect(pm.getPinState(13)).toBe(true);
|
|
|
|
// Step 2: Switch to RP2040 — clear AVR listeners, load RP2040 binary
|
|
pm.clearAllListeners();
|
|
const rpCb = vi.fn();
|
|
pm.onPinChange(13, rpCb);
|
|
|
|
const rpSim = new RP2040Simulator(pm);
|
|
rpSim.loadBinary(zeroBinary());
|
|
|
|
// Simulate RP2040 driving GPIO13 LOW via triggerPinChange
|
|
pm.triggerPinChange(13, false);
|
|
expect(rpCb).toHaveBeenCalledWith(13, false);
|
|
});
|
|
});
|
|
|
|
// ─── PWM pipeline: OCR → PinManager → component callback ─────────────────────
|
|
|
|
describe('Integration — PWM pipeline (AVR → PinManager → component)', () => {
|
|
it('OCR1AL=128 → onPwmChange(9) fires with duty 128/255', () => {
|
|
const pm = new PinManager();
|
|
const sim = new AVRSimulator(pm);
|
|
sim.loadHex(EMPTY_HEX);
|
|
|
|
const pwmValues: number[] = [];
|
|
pm.onPwmChange(9, (_pin, dc) => pwmValues.push(dc));
|
|
|
|
(sim as any).cpu.data[0x88] = 128;
|
|
sim.start();
|
|
sim.stop();
|
|
|
|
expect(pwmValues.length).toBeGreaterThan(0);
|
|
expect(pwmValues[0]).toBeCloseTo(128 / 255, 3);
|
|
});
|
|
|
|
it('RGB LED component receives correct brightness via PWM', () => {
|
|
const pm = new PinManager();
|
|
const sim = new AVRSimulator(pm);
|
|
sim.loadHex(EMPTY_HEX);
|
|
|
|
// Simulate RGB LED component listening to PWM on pins 9, 10, 11
|
|
const rgb = { red: 0, green: 0, blue: 0 };
|
|
pm.onPwmChange(9, (_p, dc) => { rgb.red = Math.round(dc * 255); });
|
|
pm.onPwmChange(10, (_p, dc) => { rgb.green = Math.round(dc * 255); });
|
|
pm.onPwmChange(11, (_p, dc) => { rgb.blue = Math.round(dc * 255); });
|
|
|
|
(sim as any).cpu.data[0x88] = 255; // OCR1A → D9 = 100%
|
|
(sim as any).cpu.data[0x8A] = 128; // OCR1B → D10 = ~50%
|
|
(sim as any).cpu.data[0xB3] = 0; // OCR2A → D11 = 0%
|
|
|
|
sim.start();
|
|
sim.stop();
|
|
|
|
expect(rgb.red).toBe(255);
|
|
expect(rgb.green).toBe(128);
|
|
// OCR2A=0 → updatePwm fires only if it differs from -1 (initial lastOcrValue)
|
|
// With lastOcrValue initialized to -1 and data[0xB3]=0, 0 !== -1 → fires
|
|
expect(rgb.blue).toBe(0);
|
|
});
|
|
|
|
it('PWM value does not re-fire if OCR register stays the same', () => {
|
|
const pm = new PinManager();
|
|
const sim = new AVRSimulator(pm);
|
|
sim.loadHex(EMPTY_HEX);
|
|
|
|
const pwmCb = vi.fn();
|
|
pm.onPwmChange(9, pwmCb);
|
|
|
|
(sim as any).cpu.data[0x88] = 200;
|
|
sim.start(); // frame 1: fires updatePwm(9, 200/255)
|
|
sim.stop();
|
|
|
|
const callsAfterFrame1 = pwmCb.mock.calls.length;
|
|
|
|
sim.start(); // frame 2: OCR value unchanged → should NOT fire again
|
|
sim.stop();
|
|
|
|
expect(pwmCb.mock.calls.length).toBe(callsAfterFrame1); // no additional calls
|
|
});
|
|
});
|