267 lines
9.3 KiB
TypeScript
267 lines
9.3 KiB
TypeScript
|
|
/**
|
||
|
|
* RP2040Simulator Tests
|
||
|
|
*
|
||
|
|
* Tests the Raspberry Pi Pico (RP2040) emulator including:
|
||
|
|
* - Lifecycle: create, loadBinary, start, stop, reset
|
||
|
|
* - GPIO pin listeners (all 30 pins)
|
||
|
|
* - ADC access
|
||
|
|
* - External pin driving (setPinState)
|
||
|
|
* - Binary loading (base64 decode)
|
||
|
|
* - LED_BUILTIN pin (GPIO25)
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||
|
|
import { RP2040Simulator } from '../simulation/RP2040Simulator';
|
||
|
|
import { PinManager } from '../simulation/PinManager';
|
||
|
|
|
||
|
|
// ─── Mock requestAnimationFrame ──────────────────────────────────────────────
|
||
|
|
// No-op mock: returns an ID but never invokes the callback.
|
||
|
|
// The RP2040 execute loop runs ~2M ARM cycles per frame which causes OOM in tests.
|
||
|
|
// Since lifecycle tests only need isRunning() (set before RAF fires), a no-op is safe.
|
||
|
|
beforeEach(() => {
|
||
|
|
let counter = 0;
|
||
|
|
vi.stubGlobal('requestAnimationFrame', (_cb: FrameRequestCallback) => ++counter);
|
||
|
|
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
||
|
|
});
|
||
|
|
afterEach(() => vi.unstubAllGlobals());
|
||
|
|
|
||
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create a minimal base64-encoded RP2040 binary.
|
||
|
|
* A real binary would start with the 256-byte second stage bootloader.
|
||
|
|
* For lifecycle tests, we just need *some* bytes.
|
||
|
|
*/
|
||
|
|
function minimalBinary(sizeKb = 1): string {
|
||
|
|
const bytes = new Uint8Array(sizeKb * 1024); // all zeros = NOP-like
|
||
|
|
let binary = '';
|
||
|
|
for (let i = 0; i < bytes.length; i++) {
|
||
|
|
binary += String.fromCharCode(bytes[i]);
|
||
|
|
}
|
||
|
|
return btoa(binary);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Lifecycle ────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
describe('RP2040Simulator — lifecycle', () => {
|
||
|
|
let pm: PinManager;
|
||
|
|
let sim: RP2040Simulator;
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
pm = new PinManager();
|
||
|
|
sim = new RP2040Simulator(pm);
|
||
|
|
});
|
||
|
|
afterEach(() => sim.stop());
|
||
|
|
|
||
|
|
it('creates instance in idle state', () => {
|
||
|
|
expect(sim).toBeDefined();
|
||
|
|
expect(sim.isRunning()).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('loadBinary() accepts valid base64 without throwing', () => {
|
||
|
|
expect(() => sim.loadBinary(minimalBinary())).not.toThrow();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('start() transitions to running after loadBinary()', () => {
|
||
|
|
sim.loadBinary(minimalBinary());
|
||
|
|
sim.start();
|
||
|
|
expect(sim.isRunning()).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('stop() transitions out of running state', () => {
|
||
|
|
sim.loadBinary(minimalBinary());
|
||
|
|
sim.start();
|
||
|
|
sim.stop();
|
||
|
|
expect(sim.isRunning()).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('stop() is idempotent before start()', () => {
|
||
|
|
expect(() => sim.stop()).not.toThrow();
|
||
|
|
expect(sim.isRunning()).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('reset() restores idle state and preserves flash', () => {
|
||
|
|
sim.loadBinary(minimalBinary(4));
|
||
|
|
sim.start();
|
||
|
|
sim.reset();
|
||
|
|
expect(sim.isRunning()).toBe(false);
|
||
|
|
// After reset, ADC should still be accessible (new RP2040 instance created)
|
||
|
|
expect(sim.getADC()).not.toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('warns but does not throw on loadHex() (wrong method)', () => {
|
||
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||
|
|
expect(() => sim.loadHex(':00000001FF')).not.toThrow();
|
||
|
|
expect(warnSpy).toHaveBeenCalled();
|
||
|
|
warnSpy.mockRestore();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('setSpeed() clamps to valid range', () => {
|
||
|
|
sim.setSpeed(0.001);
|
||
|
|
expect((sim as any).speed).toBe(0.1);
|
||
|
|
sim.setSpeed(99);
|
||
|
|
expect((sim as any).speed).toBe(10.0);
|
||
|
|
sim.setSpeed(3.0);
|
||
|
|
expect((sim as any).speed).toBe(3.0);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ─── ADC ─────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
describe('RP2040Simulator — ADC', () => {
|
||
|
|
it('getADC() returns null before loadBinary()', () => {
|
||
|
|
const pm = new PinManager();
|
||
|
|
const sim = new RP2040Simulator(pm);
|
||
|
|
expect(sim.getADC()).toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('getADC() returns RPADC instance after loadBinary()', () => {
|
||
|
|
const pm = new PinManager();
|
||
|
|
const sim = new RP2040Simulator(pm);
|
||
|
|
sim.loadBinary(minimalBinary());
|
||
|
|
const adc = sim.getADC();
|
||
|
|
expect(adc).not.toBeNull();
|
||
|
|
expect(adc).toBeDefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('ADC object has expected shape', () => {
|
||
|
|
const pm = new PinManager();
|
||
|
|
const sim = new RP2040Simulator(pm);
|
||
|
|
sim.loadBinary(minimalBinary());
|
||
|
|
const adc = sim.getADC();
|
||
|
|
// RP2040 ADC has a different API from AVRADC — just ensure it's an object
|
||
|
|
expect(typeof adc).toBe('object');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ─── GPIO pin listeners ───────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
describe('RP2040Simulator — GPIO listeners', () => {
|
||
|
|
it('setPinState() drives a GPIO pin and PinManager reflects it', () => {
|
||
|
|
const pm = new PinManager();
|
||
|
|
const sim = new RP2040Simulator(pm);
|
||
|
|
sim.loadBinary(minimalBinary());
|
||
|
|
|
||
|
|
const cb = vi.fn();
|
||
|
|
pm.onPinChange(25, cb); // LED_BUILTIN = GPIO25
|
||
|
|
|
||
|
|
sim.setPinState(25, true);
|
||
|
|
// setPinState uses gpio.setInputValue — the GPIO listener fires via rp2040js
|
||
|
|
expect(() => sim.setPinState(25, false)).not.toThrow();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('GPIO listeners are set up for all 30 pins after loadBinary()', () => {
|
||
|
|
const pm = new PinManager();
|
||
|
|
const sim = new RP2040Simulator(pm);
|
||
|
|
sim.loadBinary(minimalBinary());
|
||
|
|
|
||
|
|
// 30 GPIO listeners should be registered
|
||
|
|
const unsubscribers = (sim as any).gpioUnsubscribers as Array<() => void>;
|
||
|
|
expect(unsubscribers).toHaveLength(30);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('GPIO listeners are cleaned up and recreated on reset()', () => {
|
||
|
|
const pm = new PinManager();
|
||
|
|
const sim = new RP2040Simulator(pm);
|
||
|
|
sim.loadBinary(minimalBinary());
|
||
|
|
const beforeCount = (sim as any).gpioUnsubscribers.length;
|
||
|
|
|
||
|
|
sim.reset();
|
||
|
|
const afterCount = (sim as any).gpioUnsubscribers.length;
|
||
|
|
|
||
|
|
expect(beforeCount).toBe(30);
|
||
|
|
expect(afterCount).toBe(30);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('setPinState() works for all valid GPIO indices (0-29)', () => {
|
||
|
|
const pm = new PinManager();
|
||
|
|
const sim = new RP2040Simulator(pm);
|
||
|
|
sim.loadBinary(minimalBinary());
|
||
|
|
|
||
|
|
for (let gpio = 0; gpio < 30; gpio++) {
|
||
|
|
expect(() => sim.setPinState(gpio, true)).not.toThrow();
|
||
|
|
expect(() => sim.setPinState(gpio, false)).not.toThrow();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it('setPinState() on out-of-range pin does not throw', () => {
|
||
|
|
const pm = new PinManager();
|
||
|
|
const sim = new RP2040Simulator(pm);
|
||
|
|
// No loadBinary — rp2040 is null
|
||
|
|
expect(() => sim.setPinState(0, true)).not.toThrow();
|
||
|
|
expect(() => sim.setPinState(99, true)).not.toThrow();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ─── Binary loading ───────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
describe('RP2040Simulator — binary loading', () => {
|
||
|
|
it('loads exact byte count into flash', () => {
|
||
|
|
const pm = new PinManager();
|
||
|
|
const sim = new RP2040Simulator(pm);
|
||
|
|
const sizeBytes = 2048;
|
||
|
|
const b64 = minimalBinary(sizeBytes / 1024);
|
||
|
|
sim.loadBinary(b64);
|
||
|
|
|
||
|
|
const rp2040 = (sim as any).rp2040;
|
||
|
|
expect(rp2040).not.toBeNull();
|
||
|
|
// The first `sizeBytes` of flash should match our binary (all zeros)
|
||
|
|
const flashSlice = rp2040.flash.slice(0, sizeBytes);
|
||
|
|
expect(flashSlice.every((b: number) => b === 0)).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('larger binary loads without overflow', () => {
|
||
|
|
const pm = new PinManager();
|
||
|
|
const sim = new RP2040Simulator(pm);
|
||
|
|
// 256 KB = largest practical sketch
|
||
|
|
const b64 = minimalBinary(256);
|
||
|
|
expect(() => sim.loadBinary(b64)).not.toThrow();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('flash content is preserved after reset()', () => {
|
||
|
|
const pm = new PinManager();
|
||
|
|
const sim = new RP2040Simulator(pm);
|
||
|
|
|
||
|
|
// Create a binary with a known pattern
|
||
|
|
const bytes = new Uint8Array(256);
|
||
|
|
bytes[0] = 0xAA;
|
||
|
|
bytes[1] = 0xBB;
|
||
|
|
bytes[255] = 0xFF;
|
||
|
|
let binary = '';
|
||
|
|
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
||
|
|
const b64 = btoa(binary);
|
||
|
|
|
||
|
|
sim.loadBinary(b64);
|
||
|
|
sim.reset();
|
||
|
|
|
||
|
|
const rp2040 = (sim as any).rp2040;
|
||
|
|
expect(rp2040.flash[0]).toBe(0xAA);
|
||
|
|
expect(rp2040.flash[1]).toBe(0xBB);
|
||
|
|
expect(rp2040.flash[255]).toBe(0xFF);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ─── PinManager integration ───────────────────────────────────────────────────
|
||
|
|
|
||
|
|
describe('RP2040Simulator — PinManager integration', () => {
|
||
|
|
it('pinManager reference is accessible', () => {
|
||
|
|
const pm = new PinManager();
|
||
|
|
const sim = new RP2040Simulator(pm);
|
||
|
|
expect(sim.pinManager).toBe(pm);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('triggerPinChange from external code fires PinManager listeners', () => {
|
||
|
|
const pm = new PinManager();
|
||
|
|
const sim = new RP2040Simulator(pm);
|
||
|
|
sim.loadBinary(minimalBinary());
|
||
|
|
|
||
|
|
const cb = vi.fn();
|
||
|
|
pm.onPinChange(25, cb);
|
||
|
|
|
||
|
|
// Simulate what would happen when GPIO25 goes HIGH inside the RP2040
|
||
|
|
pm.triggerPinChange(25, true);
|
||
|
|
|
||
|
|
expect(cb).toHaveBeenCalledWith(25, true);
|
||
|
|
});
|
||
|
|
});
|