velxio/frontend/src/__tests__/i2c-real-firmware.test.ts

398 lines
13 KiB
TypeScript
Raw Normal View History

/**
* i2c-real-firmware.test.ts
*
* Full-fidelity end-to-end I2C tests. The flow mirrors Velxio
* production exactly:
*
* [.ino source on disk]
* │
* ▼ spawnSync('arduino-cli', ['compile', …])
* [arduino-cli compile] ← same call the FastAPI backend
* │ makes in services/arduino_cli.py
* ▼
* [intel-hex string]
* │
* ▼ sim.loadHex(hex)
* [AVRSimulator + real avr8js CPU + real AVRTWI peripheral]
* │
* ▼ sim.step() loop
* [real I2CBusManager]
* │
* ▼
* [registered virtual device | bridged peer bus | HD44780 decoder]
*
* The only thing skipped relative to production is the FastAPI HTTP
* transport between frontend and backend, which is plain JSON
* serialisation of the hex string — no I2C logic lives there.
*
* Fixtures
* --------
* The sketches live under `velxio/test/test_custom_chips/sketches/`
* as committed .ino source — the editor content the user would type.
* Compiled hex files are produced on-demand and cached in the OS
* tmpdir so subsequent test runs reuse them.
*
* Requirements
* ------------
* - arduino-cli on PATH
* - arduino:avr core installed
* - LiquidCrystal_I2C library installed (for the LCD tests)
*
* If any of the above is missing the affected tests skip with a
* clear note instead of failing — matches the convention in
* pong-emulation.test.ts and mega-emulation.test.ts.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { spawnSync } from 'node:child_process';
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { AVRSimulator } from '../simulation/AVRSimulator';
import { PinManager } from '../simulation/PinManager';
import {
I2CBusManager,
I2CMemoryDevice,
VirtualPCF8574,
} from '../simulation/I2CBusManager';
import { HD44780Decoder } from '../simulation/HD44780Decoder';
import { PartSimulationRegistry } from '../simulation/parts/PartSimulationRegistry';
import '../simulation/parts/ProtocolParts';
// ─── Availability gates ──────────────────────────────────────────────────────
const ARDUINO_CLI_AVAILABLE = (() => {
const r = spawnSync('arduino-cli', ['version'], { encoding: 'utf-8' });
return r.error == null && r.status === 0;
})();
/**
* Cheap probe: is a library installed and usable? We compile a
* tiny shim that imports the library header — much faster than
* parsing `arduino-cli lib list` output and immune to install
* paths / sketchbook customisations.
*/
function hasLibrary(header: string): boolean {
if (!ARDUINO_CLI_AVAILABLE) return false;
const dir = mkdtempSync(join(tmpdir(), 'velxio-libprobe-'));
const sketchDir = join(dir, 'probe');
mkdirSync(sketchDir);
writeFileSync(
join(sketchDir, 'probe.ino'),
`#include <${header}>\nvoid setup(){}\nvoid loop(){}\n`,
);
const r = spawnSync(
'arduino-cli',
['compile', '--fqbn', 'arduino:avr:uno', sketchDir],
{ encoding: 'utf-8', timeout: 60_000 },
);
return r.status === 0;
}
const LIQUID_CRYSTAL_I2C_AVAILABLE = ARDUINO_CLI_AVAILABLE
? hasLibrary('LiquidCrystal_I2C.h')
: false;
// ─── Compile-on-demand helper (mirrors backend/arduino_cli.py) ───────────────
/**
* Resolve the path to one of the committed test sketches.
*/
function sketchSourcePath(name: string): string {
return resolve(
__dirname,
'../../../test/test_custom_chips/sketches',
name,
`${name}.ino`,
);
}
/**
* Compile a sketch with arduino-cli (subprocess), exactly like the
* Velxio FastAPI backend does in services/arduino_cli.py:compile().
* Caches the produced hex per (sketch, fqbn, source mtime) so repeated
* test runs against unchanged source are instant.
*
* Throws if arduino-cli is unavailable or compilation fails — the
* caller is expected to gate on `ARDUINO_CLI_AVAILABLE` first.
*/
function compileSketch(name: string, fqbn = 'arduino:avr:uno'): string {
const sourcePath = sketchSourcePath(name);
if (!existsSync(sourcePath)) {
throw new Error(`Sketch source missing: ${sourcePath}`);
}
// Cache key — invalidate when the .ino content changes.
const source = readFileSync(sourcePath, 'utf-8');
let hash = 0;
for (let i = 0; i < source.length; i++) {
hash = (hash * 31 + source.charCodeAt(i)) | 0;
}
const hexCache = join(
tmpdir(),
`velxio-${name}-${fqbn.replace(/[^a-z0-9]/gi, '_')}-${hash >>> 0}.hex`,
);
if (existsSync(hexCache)) {
return readFileSync(hexCache, 'utf-8');
}
// Stage the sketch in a temp dir — arduino-cli requires the
// directory name to match the sketch name (so name/name.ino).
const work = mkdtempSync(join(tmpdir(), `velxio-${name}-`));
const sketchDir = join(work, name);
mkdirSync(sketchDir);
writeFileSync(join(sketchDir, `${name}.ino`), source);
const buildDir = join(work, 'build');
mkdirSync(buildDir);
const result = spawnSync(
'arduino-cli',
[
'compile',
'--fqbn',
fqbn,
'--output-dir',
buildDir,
'--build-path',
buildDir,
sketchDir,
],
{ encoding: 'utf-8', timeout: 180_000 },
);
if (result.error) {
throw new Error(`arduino-cli not available: ${result.error.message}`);
}
if (result.status !== 0) {
throw new Error(
`arduino-cli compile failed (exit ${result.status}):\n${result.stderr}\n${result.stdout}`,
);
}
// Find the produced hex. arduino-cli emits "<sketch>.ino.hex".
let hex: string | null = null;
for (const candidate of [
`${name}.ino.hex`,
`${name}.hex`,
'sketch.ino.hex',
]) {
const p = join(buildDir, candidate);
if (existsSync(p)) {
hex = readFileSync(p, 'utf-8');
break;
}
}
if (!hex) {
const files = readdirSync(buildDir, { recursive: true });
throw new Error(
`Could not locate .hex output in ${buildDir} (saw: ${files.join(', ')})`,
);
}
writeFileSync(hexCache, hex);
return hex;
}
// ─── Runtime helpers ─────────────────────────────────────────────────────────
const EEPROM_STEP_BUDGET = 1_000_000; // Wire setup() takes ~200k cycles
/**
* LiquidCrystal_I2C v2.0.0 init contains delay(50) + delay(1000) +
* 4 × delayMicroseconds(4500) BEFORE the first useful write. That's
* ~1.07 s = ~17 M CPU cycles at 16 MHz. Plus the print() phase.
* 30 M keeps us comfortably above that with margin for library bumps.
*/
const LCD_STEP_BUDGET = 30_000_000;
function runUntil(sim: AVRSimulator, budget: number, predicate: () => boolean): number {
for (let i = 0; i < budget; i++) {
sim.step();
if ((i & 0x3ff) === 0 && predicate()) return i + 1;
}
return budget;
}
// ─── EEPROM-demo tests (compile + load + run) ────────────────────────────────
describe.runIf(ARDUINO_CLI_AVAILABLE)(
'I2C E2E — Wire master at 0x50 (full Velxio compile flow)',
() => {
let HEX: string;
beforeAll(() => {
HEX = compileSketch('i2c_eeprom_demo');
});
it('single board: master writes 0xAA..0xDD into a locally-registered device', () => {
const sim = new AVRSimulator(new PinManager(), 'uno');
sim.loadHex(HEX);
const eeprom = new I2CMemoryDevice(0x50);
sim.addI2CDevice(eeprom);
const steps = runUntil(
sim,
EEPROM_STEP_BUDGET,
() =>
eeprom.registers[0] === 0xaa &&
eeprom.registers[1] === 0xbb &&
eeprom.registers[2] === 0xcc &&
eeprom.registers[3] === 0xdd,
);
expect(steps).toBeLessThan(EEPROM_STEP_BUDGET);
expect(eeprom.registers[0]).toBe(0xaa);
expect(eeprom.registers[1]).toBe(0xbb);
expect(eeprom.registers[2]).toBe(0xcc);
expect(eeprom.registers[3]).toBe(0xdd);
});
it('single board: master reads back via Wire.requestFrom and echoes via Serial', () => {
const sim = new AVRSimulator(new PinManager(), 'uno');
sim.loadHex(HEX);
sim.addI2CDevice(new I2CMemoryDevice(0x50));
const serialOut: number[] = [];
sim.onSerialData = (ch) => serialOut.push(ch.charCodeAt(0));
runUntil(sim, EEPROM_STEP_BUDGET, () => serialOut.length >= 4);
expect(serialOut.slice(0, 4)).toEqual([0xaa, 0xbb, 0xcc, 0xdd]);
});
it('multi-board bridge: master on board A reaches device on board B', () => {
const simA = new AVRSimulator(new PinManager(), 'uno');
simA.loadHex(HEX);
// Peer "board" — only its I2CBusManager + device matter for
// the slave-side path. A real RP2040Simulator or AVRSimulator
// would equally work; we use a bare bus to keep the test fast
// (no second CPU to step).
const peerBus = new I2CBusManager({
completeStart() {},
completeStop() {},
completeConnect() {},
completeWrite() {},
completeRead() {},
});
const eeprom = new I2CMemoryDevice(0x50);
peerBus.addDevice(eeprom);
simA.i2cBus.attachBridge(peerBus);
peerBus.attachBridge(simA.i2cBus);
const serialOut: number[] = [];
simA.onSerialData = (ch) => serialOut.push(ch.charCodeAt(0));
runUntil(simA, EEPROM_STEP_BUDGET, () => serialOut.length >= 4);
// Writes landed on the PEER bus's device (not the master's,
// which has no local 0x50).
expect(eeprom.registers[0]).toBe(0xaa);
expect(eeprom.registers[1]).toBe(0xbb);
expect(eeprom.registers[2]).toBe(0xcc);
expect(eeprom.registers[3]).toBe(0xdd);
// Reads also flowed back through the bridge.
expect(serialOut.slice(0, 4)).toEqual([0xaa, 0xbb, 0xcc, 0xdd]);
});
},
);
// ─── LCD-I2C tests (need the LiquidCrystal_I2C library installed) ────────────
describe.runIf(LIQUID_CRYSTAL_I2C_AVAILABLE)(
'I2C E2E — LiquidCrystal_I2C → HD44780 decoder (full Velxio compile flow)',
() => {
let HEX: string;
beforeAll(() => {
HEX = compileSketch('lcd_i2c_hello');
});
it('renders "Hello" on row 0 and "World" on row 1', () => {
const sim = new AVRSimulator(new PinManager(), 'uno');
sim.loadHex(HEX);
const pcf = new VirtualPCF8574(0x27);
const decoder = new HD44780Decoder({ cols: 16, rows: 2 });
const bytesSeen: number[] = [];
pcf.onWrite = (v) => {
bytesSeen.push(v);
decoder.feedPCF8574Byte(v);
};
sim.addI2CDevice(pcf);
runUntil(sim, LCD_STEP_BUDGET, () => {
const c = decoder.snapshot().characters;
return c[0] === 'H'.charCodeAt(0) && c[20] === 'd'.charCodeAt(0);
});
expect(bytesSeen.length).toBeGreaterThan(20);
const snap = decoder.snapshot();
expect(String.fromCharCode(...snap.characters.slice(0, 5))).toBe('Hello');
expect(String.fromCharCode(...snap.characters.slice(16, 21))).toBe('World');
expect(snap.backlight).toBe(true);
expect(snap.displayOn).toBe(true);
});
it('end-to-end via the lcd1602-i2c part: element.characters reflects the print', () => {
const sim = new AVRSimulator(new PinManager(), 'uno');
sim.loadHex(HEX);
// Mock wokwi-lcd1602 element exactly as DynamicComponent
// would render and pass to the part.
const el: any = {
pins: 'full',
characters: new Uint8Array(32),
backlight: true,
i2cAddress: '0x27',
addEventListener: () => {},
removeEventListener: () => {},
};
const detach = PartSimulationRegistry.get('lcd1602-i2c')!.attachEvents!(
el as HTMLElement,
sim as unknown as object as any,
() => null,
'lcd-1',
);
expect(el.pins).toBe('i2c');
runUntil(sim, LCD_STEP_BUDGET, () => {
const c = el.characters as Uint8Array;
return c[0] === 'H'.charCodeAt(0) && c[20] === 'd'.charCodeAt(0);
});
const c = el.characters as Uint8Array;
expect(String.fromCharCode(...c.slice(0, 5))).toBe('Hello');
expect(String.fromCharCode(...c.slice(16, 21))).toBe('World');
detach();
});
},
);
// ─── Skip notices when prerequisites are missing ─────────────────────────────
describe.skipIf(ARDUINO_CLI_AVAILABLE)(
'I2C E2E — skipped because arduino-cli is not on PATH',
() => {
it('install arduino-cli to enable the full-fidelity I2C tests', () => {
expect(true).toBe(true);
});
},
);
describe.skipIf(LIQUID_CRYSTAL_I2C_AVAILABLE || !ARDUINO_CLI_AVAILABLE)(
'I2C E2E — LCD tests skipped because LiquidCrystal_I2C library is missing',
() => {
it('run: arduino-cli lib install "LiquidCrystal_I2C"', () => {
expect(true).toBe(true);
});
},
);