velxio/frontend/src/__tests__/RP2040Simulator.test.ts

267 lines
9.3 KiB
TypeScript
Raw Normal View History

/**
* 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);
});
});