398 lines
13 KiB
TypeScript
398 lines
13 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
},
|
||
);
|