velxio/frontend/src/__tests__/attiny85-simulation.test.ts

449 lines
16 KiB
TypeScript

/**
* attiny85-simulation.test.ts
*
* Unit and integration tests for the ATtiny85 emulator variant ('tiny85').
*
* UNIT tests (no compilation required):
* - AVRSimulator initialises with boardVariant='tiny85'
* - Program memory is 4096 words (8 KB flash)
* - PORTB listener fires when PB1 goes HIGH (PORTB = 0x02)
* - PinManager receives pin 1 HIGH after blink HEX execution
* - setPinState() drives PB0-PB5 correctly
* - No PORTC/PORTD (ATtiny85 only has PORTB)
* - No hardware USART
* - PWM OCR0B at address 0x48 triggers pin 1 update
* - boardPinMapping: PB0-PB5 → 0-5, GND/VCC → -1
*
* END-TO-END test (requires arduino-cli + ATTinyCore):
* - Compiles attiny85-blink.ino for ATTinyCore:avr:attinyx5:chip=85
* - Loads .hex into AVRSimulator('tiny85')
* - Verifies PB1 goes HIGH after setup() runs
*
* ─── ATtiny85 register map ────────────────────────────────────────────────────
* ATtiny85 PINB = 0x36 (ATmega328P PINB = 0x23)
* ATtiny85 DDRB = 0x37 (ATmega328P DDRB = 0x24)
* ATtiny85 PORTB = 0x38 (ATmega328P PORTB = 0x25)
* ATtiny85 OCR0B = 0x48 (I/O 0x28) [0x5C is EECR — do not use]
*
* ─── ATtiny85 blink HEX (hand-assembled) ─────────────────────────────────────
* LDI r16, 0xFF ; 0F EF — r16 = 0xFF (all outputs)
* OUT 0x17, r16 ; 07 BB — DDRB (I/O 0x17 → mem 0x37): all outputs
* LDI r16, 0x02 ; 02 E0 — r16 = 0x02 (PB1)
* OUT 0x18, r16 ; 08 BB — PORTB (I/O 0x18 → mem 0x38): PB1 HIGH
* RJMP .-2 ; FF CF — infinite loop
* Checksum: -(sum mod 256) = 0xC3
*
* OUT Aa, Rr encoding: 1011 1AAr rrrr AAAA
* r16=10000 → r[4]=1 → bit 8 set, so 0xBAxx → 0xBBxx
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { spawnSync } from 'child_process';
import { mkdtempSync, writeFileSync, readFileSync, rmSync, mkdirSync, readdirSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { avrInstruction } from 'avr8js';
import { AVRSimulator } from '../simulation/AVRSimulator';
import { PinManager } from '../simulation/PinManager';
// ─── RAF stubs ─────────────────────────────────────────────────────────────────
// Depth-limited: calls cb once synchronously, prevents re-entrancy loops.
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());
// ─── Helpers ──────────────────────────────────────────────────────────────────
function runCycles(sim: AVRSimulator, n: number): void {
for (let i = 0; i < n; i++) sim.step();
}
// ─── Minimal Intel HEX payloads ───────────────────────────────────────────────
const EMPTY_HEX = ':00000001FF\n';
/**
* TINY85_BLINK_HEX — sets PB1 HIGH then loops.
* Uses ATtiny85 DDRB (I/O 0x17 → mem 0x37) and PORTB (I/O 0x18 → mem 0x38).
*/
const TINY85_BLINK_HEX = ':0A0000000FEF07BB02E008BBFFCFC3\n' + ':00000001FF\n';
/**
* TINY85_PB0_HEX — sets PB0 HIGH (bit 0).
* LDI r16, 0xFF → OUT DDRB → LDI r16, 0x01 → OUT PORTB → RJMP .-2
* Checksum: 0xC4
*/
const TINY85_PB0_HEX = ':0A0000000FEF07BB01E008BBFFCFC4\n' + ':00000001FF\n';
/**
* TINY85_PWM_HEX — writes 0x80 to OCR0B (I/O 0x28 → mem 0x48) to test PWM.
* LDI r16, 0xFF → OUT DDRB (0x17) → LDI r16, 0x80 → OUT OCR0B (0x28) → RJMP .-2
*
* OUT 0x28, r16 encoding:
* A = 0x28 = 0b101000; A[5:4]=0b10, A[3:0]=0b1000; r=16
* word = 1011 1 10 1 0000 1000 = 0xBD08 → bytes 0x08 0xBD
* Checksum: 0xBB
*/
const TINY85_PWM_HEX = ':0A0000000FEF07BB00E808BDFFCFBB\n' + ':00000001FF\n';
// ─── Lifecycle ────────────────────────────────────────────────────────────────
describe('ATtiny85 — lifecycle', () => {
let pm: PinManager;
let sim: AVRSimulator;
beforeEach(() => {
pm = new PinManager();
sim = new AVRSimulator(pm, 'tiny85');
});
afterEach(() => sim.stop());
it('starts in idle state', () => {
expect(sim.isRunning()).toBe(false);
expect(sim.getSpeed()).toBe(1.0);
});
it('loads a valid HEX without throwing', () => {
expect(() => sim.loadHex(EMPTY_HEX)).not.toThrow();
expect(() => sim.loadHex(TINY85_BLINK_HEX)).not.toThrow();
});
it('start() transitions to running state', () => {
sim.loadHex(EMPTY_HEX);
sim.start();
expect(sim.isRunning()).toBe(true);
sim.stop();
expect(sim.isRunning()).toBe(false);
});
it('reset() stops simulation and reinitialises', () => {
sim.loadHex(TINY85_BLINK_HEX);
sim.start();
expect(sim.isRunning()).toBe(true);
sim.reset();
expect(sim.isRunning()).toBe(false);
});
it('clamps speed within [0.1, 10.0]', () => {
sim.setSpeed(0.001);
expect(sim.getSpeed()).toBe(0.1);
sim.setSpeed(999);
expect(sim.getSpeed()).toBe(10.0);
sim.setSpeed(2.0);
expect(sim.getSpeed()).toBe(2.0);
});
});
// ─── Flash / memory ──────────────────────────────────────────────────────────
describe('ATtiny85 — memory', () => {
it('program memory is 4096 words (8 KB flash)', () => {
const pm = new PinManager();
const sim = new AVRSimulator(pm, 'tiny85');
sim.loadHex(EMPTY_HEX);
const prog = (sim as any).program as Uint16Array;
expect(prog.length).toBe(4096);
sim.stop();
});
});
// ─── Peripheral configuration ─────────────────────────────────────────────────
describe('ATtiny85 — peripheral configuration', () => {
it('portB is initialised, portC and portD are null', () => {
const pm = new PinManager();
const sim = new AVRSimulator(pm, 'tiny85');
sim.loadHex(EMPTY_HEX);
expect((sim as any).portB).not.toBeNull();
expect((sim as any).portC).toBeNull();
expect((sim as any).portD).toBeNull();
sim.stop();
});
it('usart is null (no hardware serial on ATtiny85)', () => {
const pm = new PinManager();
const sim = new AVRSimulator(pm, 'tiny85');
sim.loadHex(EMPTY_HEX);
expect(sim.usart).toBeNull();
sim.stop();
});
it('megaPorts map is empty (ATtiny85 has only PORTB)', () => {
const pm = new PinManager();
const sim = new AVRSimulator(pm, 'tiny85');
sim.loadHex(EMPTY_HEX);
const megaPorts = (sim as any).megaPorts as Map<string, unknown>;
expect(megaPorts.size).toBe(0);
sim.stop();
});
});
// ─── Pin state — blink HEX execution ─────────────────────────────────────────
describe('ATtiny85 — pin state from HEX execution', () => {
it('TINY85_BLINK_HEX: PB1 goes HIGH after 4 steps', () => {
const pm = new PinManager();
const sim = new AVRSimulator(pm, 'tiny85');
sim.loadHex(TINY85_BLINK_HEX);
const changes: boolean[] = [];
pm.onPinChange(1, (_pin, state) => changes.push(state));
// LDI r16,0xFF / OUT DDRB / LDI r16,0x02 / OUT PORTB
runCycles(sim, 4);
expect(changes).toContain(true);
expect(pm.getPinState(1)).toBe(true);
sim.stop();
});
it('TINY85_PB0_HEX: PB0 goes HIGH after 4 steps', () => {
const pm = new PinManager();
const sim = new AVRSimulator(pm, 'tiny85');
sim.loadHex(TINY85_PB0_HEX);
const changes: boolean[] = [];
pm.onPinChange(0, (_pin, state) => changes.push(state));
runCycles(sim, 4);
expect(changes).toContain(true);
expect(pm.getPinState(0)).toBe(true);
sim.stop();
});
it('TINY85_BLINK_HEX: only PB1 changes (PB0/PB2 stay LOW)', () => {
const pm = new PinManager();
const sim = new AVRSimulator(pm, 'tiny85');
sim.loadHex(TINY85_BLINK_HEX);
const pb0Changes: boolean[] = [];
const pb2Changes: boolean[] = [];
pm.onPinChange(0, (_p, s) => pb0Changes.push(s));
pm.onPinChange(2, (_p, s) => pb2Changes.push(s));
runCycles(sim, 4);
expect(pb0Changes.filter(Boolean)).toHaveLength(0);
expect(pb2Changes.filter(Boolean)).toHaveLength(0);
sim.stop();
});
});
// ─── setPinState ──────────────────────────────────────────────────────────────
describe('ATtiny85 — setPinState()', () => {
it('does not throw for valid PB0-PB5', () => {
const pm = new PinManager();
const sim = new AVRSimulator(pm, 'tiny85');
sim.loadHex(EMPTY_HEX);
for (let pin = 0; pin <= 5; pin++) {
expect(() => sim.setPinState(pin, true)).not.toThrow();
expect(() => sim.setPinState(pin, false)).not.toThrow();
}
sim.stop();
});
it('does not throw for out-of-range pin 6 (no-op)', () => {
const pm = new PinManager();
const sim = new AVRSimulator(pm, 'tiny85');
sim.loadHex(EMPTY_HEX);
expect(() => sim.setPinState(6, true)).not.toThrow();
sim.stop();
});
});
// ─── PWM — OCR0B ─────────────────────────────────────────────────────────────
describe('ATtiny85 — PWM monitoring', () => {
it('PinManager receives PWM update on pin 1 when OCR0B (0x48) is written', () => {
const pm = new PinManager();
const sim = new AVRSimulator(pm, 'tiny85');
sim.loadHex(EMPTY_HEX);
const pwmCb = vi.fn();
pm.onPwmChange(1, pwmCb); // PB1 = OCR0B pin
// Directly write a PWM value to OCR0B in CPU data memory.
// ATtiny85 OCR0B = I/O 0x08 → data 0x48 (NOT 0x5C, which is EECR).
const cpu = (sim as any).cpu;
cpu.data[0x48] = 128; // 50% duty cycle
sim.start();
sim.stop();
expect(pwmCb).toHaveBeenCalledWith(1, 128 / 255);
});
it('PinManager receives PWM update on pin 0 when OCR0A (0x49) is written', () => {
const pm = new PinManager();
const sim = new AVRSimulator(pm, 'tiny85');
sim.loadHex(EMPTY_HEX);
const pwmCb = vi.fn();
pm.onPwmChange(0, pwmCb); // PB0 = OCR0A pin
// ATtiny85 OCR0A = I/O 0x09 → data 0x49 (NOT 0x56, which is PINB).
const cpu = (sim as any).cpu;
cpu.data[0x49] = 64;
sim.start();
sim.stop();
expect(pwmCb).toHaveBeenCalledWith(0, 64 / 255);
});
it('ATtiny85 PWM covers 4 pins (OCR0A/OCR0B/OCR1A/OCR1B)', () => {
const TINY85_PWM_MAP = [
{ addr: 0x49, pin: 0 }, // OCR0A → PB0
{ addr: 0x48, pin: 1 }, // OCR0B → PB1
{ addr: 0x4e, pin: 1 }, // OCR1A → PB1
{ addr: 0x4b, pin: 4 }, // OCR1B → PB4
];
const pm = new PinManager();
const sim = new AVRSimulator(pm, 'tiny85');
sim.loadHex(EMPTY_HEX);
const cbs: Record<number, ReturnType<typeof vi.fn>> = {};
const uniquePins = [...new Set(TINY85_PWM_MAP.map((m) => m.pin))];
uniquePins.forEach((pin) => {
cbs[pin] = vi.fn();
pm.onPwmChange(pin, cbs[pin]);
});
const cpu = (sim as any).cpu;
TINY85_PWM_MAP.forEach(({ addr }, i) => {
cpu.data[addr] = (i + 1) * 50; // 50, 100, 150, 200
});
sim.start();
sim.stop();
// PB0 should receive OCR0A update
expect(cbs[0]).toHaveBeenCalled();
// PB1 should receive at least one OCR update (OCR0B or OCR1A)
expect(cbs[1]).toHaveBeenCalled();
// PB4 should receive OCR1B update
expect(cbs[4]).toHaveBeenCalled();
});
});
// ─── boardPinMapping ──────────────────────────────────────────────────────────
describe('ATtiny85 — boardPinMapping', () => {
it('PB0-PB5 map to pins 0-5', async () => {
const { boardPinToNumber } = await import('../utils/boardPinMapping');
for (let i = 0; i <= 5; i++) {
expect(boardPinToNumber('attiny85', `PB${i}`)).toBe(i);
}
});
it('numeric pin names 0-5 map correctly', async () => {
const { boardPinToNumber } = await import('../utils/boardPinMapping');
for (let i = 0; i <= 5; i++) {
expect(boardPinToNumber('attiny85', String(i))).toBe(i);
}
});
it('GND and VCC return -1 (power-only, not GPIOs)', async () => {
const { boardPinToNumber } = await import('../utils/boardPinMapping');
expect(boardPinToNumber('attiny85', 'GND')).toBe(-1);
expect(boardPinToNumber('attiny85', 'VCC')).toBe(-1);
});
it('unknown pin names return null', async () => {
const { boardPinToNumber } = await import('../utils/boardPinMapping');
expect(boardPinToNumber('attiny85', 'PB6')).toBeNull();
expect(boardPinToNumber('attiny85', 'A0')).toBeNull();
expect(boardPinToNumber('attiny85', 'D13')).toBeNull();
expect(boardPinToNumber('attiny85', 'TX')).toBeNull();
});
it("'attiny85' is in BOARD_COMPONENT_IDS", async () => {
const { BOARD_COMPONENT_IDS } = await import('../utils/boardPinMapping');
expect(BOARD_COMPONENT_IDS).toContain('attiny85');
});
it("isBoardComponent('attiny85') returns true", async () => {
const { isBoardComponent } = await import('../utils/boardPinMapping');
expect(isBoardComponent('attiny85')).toBe(true);
expect(isBoardComponent('attiny85-1')).toBe(true);
});
});
// ─── End-to-end: arduino-cli compilation (skipped if ATTinyCore not installed) ─
describe('ATtiny85 — end-to-end compilation', () => {
const FQBN = 'ATTinyCore:avr:attinyx5:chip=85,clock=internal16mhz';
function isATTinyCoreAvailable(): boolean {
const cliCheck = spawnSync('arduino-cli', ['version'], { encoding: 'utf8' });
if (cliCheck.status !== 0) return false;
const coreCheck = spawnSync('arduino-cli', ['core', 'list'], { encoding: 'utf8' });
return coreCheck.stdout?.includes('ATTinyCore') ?? false;
}
it('compiles blink sketch and PB1 goes HIGH after setup()', { timeout: 90_000 }, () => {
if (!isATTinyCoreAvailable()) {
console.log('ATTinyCore not installed — skipping E2E compilation test');
return;
}
const tmpDir = mkdtempSync(join(tmpdir(), 'attiny85-e2e-'));
const sketchDir = join(tmpDir, 'sketch');
mkdirSync(sketchDir);
writeFileSync(
join(sketchDir, 'sketch.ino'),
`
void setup() {
pinMode(1, OUTPUT);
digitalWrite(1, HIGH);
}
void loop() {}
`,
);
try {
const result = spawnSync(
'arduino-cli',
['compile', '--fqbn', FQBN, '--output-dir', sketchDir, sketchDir],
{ encoding: 'utf8', timeout: 60000 },
);
expect(result.status).toBe(0);
const hexFiles = readdirSync(sketchDir).filter((f) => f.endsWith('.hex'));
expect(hexFiles.length).toBeGreaterThan(0);
const hexContent = readFileSync(join(sketchDir, hexFiles[0]), 'utf8');
const pm = new PinManager();
const sim = new AVRSimulator(pm, 'tiny85');
sim.loadHex(hexContent);
// Run ~800k instructions — enough for setup() to complete at 16 MHz
const cpu = (sim as any).cpu;
for (let i = 0; i < 800_000; i++) {
avrInstruction(cpu);
cpu.tick();
}
expect(pm.getPinState(1)).toBe(true);
sim.stop();
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
});
});