0.9.0 - MAX7219 Display (max7219.js v1.0.0, engine in max7219-lib.js): - 8-digit LED driver on a firmware bit-banged DIN/CLK/LOAD bus (hardware SPI stays invisible, M0). Level-driven shift engine samples DIN on the CLK rising edge, latches MSB-first 16-bit words on the LOAD rising edge; misaligned frames are dropped and counted in `dropped`, never garbled. Datasheet register model: Code B decode (independent DP), intensity PWM, scan limit, shutdown/display test, power-on defaults. 8x8 matrix and 7-segment Views auto-selected from the decode-mode register; daisy chains share the three pads (sharedBus) and latch their chain position. Lease sizing measured against the tightest real firmware (gun 0.1.0, delay-free pulses ~20 instructions apart): fine at the scheduler floor 20, arm 60. Smoke S12 boots the gun 0.1.0 release (machine-local, skips when absent) with zero drops; examples/esp32s3-gun.json wires it. 0.10.0 - Logic analyzer (logic-analyzer.js v1.0.0, logic in la-lib.js): - Multi-channel GPIO waveform capture: up to 8 pads, channel+edge or immediate trigger, 1-1000 ms guest-time window, auto re-arm, Z shown when the pad is released; rides the M4 scheduler (arm 1000, fine lease per resolution preset 20/100/300, renewed per edge); 20 000-edge buffer freezes with an overflow flag. sharedBus makes it a pure tap on other bus devices' pads (smoke S13 arms it on the P4 soft-I2C bus: SCL 1950 / SDA 352 alternating edges in a 40 ms window, frozen afterwards). - Floating analyzer window: draggable, resizable, non-modal popup (position fixed, no backdrop - the page underneath keeps every pointer event; geometry clamped + persisted). One drawWaveform painter feeds the popup, the details mini-preview and the row icon (AGENT 3.8); channels picked from a GPIO grid, occupied pins greyed. - Saleae-style plot interactions: wheel zoom anchored at the cursor (1 ns floor), left/middle-drag pan, double-click/F fit; hover measurement shades the pulse and reads level + width + period + frequency + PWM duty (rise-to-rise cycle); A/B cursors with delta-t and 1/delta-t; lane selection + </> buttons and arrow keys jump view-centre onto the previous/next edge; lane gutter shows the right-edge level; a fresh capture re-fits the view and drops cursors (capId). - Device API v1 additions (backwards compatible): ctx.pinLevel(pin) snapshot read of the driven level; ViewHost.pinInfo() for multi-pin pickers; View.destroy() teardown on row removal / list rebuild so Views owning DOM outside their row (floating windows) clean it up. - Tests: la-lib units (parsePins, levelAt, axis ticks, fit span, pixel transform, zoom/pan view model, pulseAround, pwmOf, fmtHz/levelChar), the capture state machine via fake ctx (immediate/triggered starts, direction filtering incl. Z-as-falling, window end, stop/clear/auto, buffer overflow, onEmuReady reset, capId), sigbus.pinLevel against a fake emulator; smoke S13 + S1 grows to 12 devices; examples/esp32c3-probe.json wires the analyzer on the I2C pair. - Docs (both languages): CHANGELOG 0.9.0 + 0.10.0 + device version table, ROADMAP shipped sections, DEVICE-API instrument section + new ctx/host/ View members, ARCHITECTURE GUI checklist + floating-window note, README capability table + tree, AGENT rule 3.11 + GUI-smoke bug-table rows; version synced to 0.10.0 in all three places.
151 lines
5.7 KiB
JavaScript
151 lines
5.7 KiB
JavaScript
/*
|
|
* Unit tests for core logic (no emulator, no DOM): sigbus bit dispatch,
|
|
* chips tables, mmio helpers, config normalisation + module validation.
|
|
* Run: node --test tests/unit/
|
|
*/
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { Sigbus } from '../../build/core/sigbus.js';
|
|
import { gpioCount, validPins, absentPins } from '../../build/core/chips.js';
|
|
import { mmioFor, funcOutSelAddr, ioMuxAddr } from '../../build/core/mmio.js';
|
|
import { normaliseConfig, validateDeviceModule } from '../../build/core/types.js';
|
|
|
|
// ---- sigbus ----
|
|
|
|
/** Fake emulator: gpio words scripted per sample call. */
|
|
class FakeEmu {
|
|
constructor(state = [0, 0, 0, 0]) { this.state = state; this.cyc = 0; this.inputs = []; }
|
|
gpio_state() { return this.state; }
|
|
gpio_set_input(pin, high) { this.inputs.push([pin, high]); }
|
|
read_memory() { return new Uint8Array(4); }
|
|
cycles() { return this.cyc; }
|
|
}
|
|
|
|
test('sigbus dispatches only changed pins to subscribers', () => {
|
|
const emu = new FakeEmu([0, 0, 0, 0]);
|
|
const bus = new Sigbus(emu);
|
|
const seen = [];
|
|
const dev = { onPinState: (pin, s) => seen.push([pin, s.out, s.oe]) };
|
|
bus.subscribe(() => dev, [8, 9]);
|
|
|
|
emu.state = [1 << 8, 0, (1 << 8) | (1 << 9), 0]; // pin8 out+oe flip, pin9 oe flips
|
|
bus.sample();
|
|
// pin8: out 0->1, oe 0->1 (one notification, union mask);
|
|
// pin9: out unchanged 0, oe 0->1 (still a change — it is now driven)
|
|
assert.deepEqual(seen, [[8, true, true], [9, false, true]]);
|
|
|
|
emu.state = [(1 << 8) | (1 << 9), 0, (1 << 8) | (1 << 9), 0]; // pin9 out 0->1
|
|
bus.sample();
|
|
assert.deepEqual(seen.slice(-1), [[9, true, true]]);
|
|
|
|
bus.sample(); // no change
|
|
assert.equal(seen.length, 3);
|
|
});
|
|
|
|
test('sigbus constructor-time getter resolves instance lazily', () => {
|
|
const emu = new FakeEmu([0, 0, 0, 0]);
|
|
const bus = new Sigbus(emu);
|
|
let dev = null; // "instance" registered after subscribe, like the runtime
|
|
bus.subscribe(() => dev, [3]);
|
|
emu.state = [1 << 3, 0, 1 << 3, 0];
|
|
bus.sample(); // dev still null: must not throw
|
|
dev = { onPinState: (pin, s) => { throw new Error('should not run: no new edge'); } };
|
|
bus.sample(); // no further change: not called
|
|
});
|
|
|
|
test('sigbus dispatches high-word pins (>=32)', () => {
|
|
const emu = new FakeEmu([0, 0, 0, 0]);
|
|
const bus = new Sigbus(emu);
|
|
const seen = [];
|
|
bus.subscribe(() => ({ onPinState: (pin, s) => seen.push([pin, s.out]) }), [33]);
|
|
emu.state = [0, 1 << 1, 0, 1 << 1];
|
|
bus.sample();
|
|
assert.deepEqual(seen, [[33, true]]);
|
|
});
|
|
|
|
test('sigbus setInput forwards to emulator', () => {
|
|
const emu = new FakeEmu();
|
|
const bus = new Sigbus(emu);
|
|
bus.setInput(4, false);
|
|
bus.setInput(4, true);
|
|
assert.deepEqual(emu.inputs, [[4, false], [4, true]]);
|
|
});
|
|
|
|
test('sigbus pinLevel reads the driven level, null when released', () => {
|
|
// pin8 driven high, pin9 driven low, pin33 driven high (word 1), pin4 released
|
|
const emu = new FakeEmu([(1 << 8), (1 << 1), (1 << 8) | (1 << 9), (1 << 1)]);
|
|
const bus = new Sigbus(emu);
|
|
assert.equal(bus.pinLevel(8), true);
|
|
assert.equal(bus.pinLevel(9), false);
|
|
assert.equal(bus.pinLevel(33), true);
|
|
assert.equal(bus.pinLevel(4), null, 'oe clear → released, level unknowable');
|
|
assert.equal(new Sigbus(null).pinLevel(8), null, 'no emulator attached');
|
|
});
|
|
|
|
// ---- chips ----
|
|
|
|
test('chips tables: counts, absent pins, valid pins', () => {
|
|
assert.equal(gpioCount('esp32c3'), 22);
|
|
assert.deepEqual([...absentPins('esp32s3')], [22, 23, 24, 25]);
|
|
const s3 = validPins('esp32s3');
|
|
assert.ok(!s3.includes(22) && s3.includes(21) && s3.includes(26));
|
|
assert.equal(validPins('esp32c3').length, 22);
|
|
// unknown chip falls back to 32 pins
|
|
assert.equal(gpioCount('nope'), 32);
|
|
});
|
|
|
|
// ---- mmio ----
|
|
|
|
test('mmio c3: addresses match M0-verified values', () => {
|
|
const m = mmioFor('esp32c3');
|
|
assert.ok(m);
|
|
assert.equal(m.ledc.ch(0).dutyR, 0x60019010);
|
|
assert.equal(m.ledc.ch(1).dutyR, 0x60019024); // stride 0x14
|
|
assert.equal(m.rmt.memBase, 0x60016400);
|
|
assert.equal(m.rmt.memWordsPerChannel, 48);
|
|
assert.equal(funcOutSelAddr(m, 9), 0x60004000 + 0x554 + 36);
|
|
assert.equal(ioMuxAddr(m, 9), 0x60009028);
|
|
assert.equal(ioMuxAddr(m, 12), null); // only GPIO0..11 contiguous
|
|
assert.equal(mmioFor('esp32c6'), null); // not yet mapped
|
|
});
|
|
|
|
// ---- config helpers ----
|
|
|
|
/** Class-shaped stub (validateDeviceModule requires a constructor). */
|
|
class StubMeta {
|
|
static typeId = 'stub';
|
|
static title = 'Stub';
|
|
static defaultConfig = { name: 'S', pin: 5, keep: true };
|
|
static configSchema = [
|
|
{ key: 'pin', label: 'GPIO pin', type: 'pin' },
|
|
{ key: 'name', label: 'Label', type: 'text' },
|
|
];
|
|
static pins(cfg) { return [cfg.pin]; }
|
|
}
|
|
const metaStub = StubMeta;
|
|
|
|
test('normaliseConfig merges defaults and strips unknown keys', () => {
|
|
const cfg = normaliseConfig(metaStub, { id: 'x', type: 'stub', pin: 9, bogus: 1 });
|
|
assert.equal(cfg.pin, 9);
|
|
assert.equal(cfg.keep, true); // default retained
|
|
assert.equal('bogus' in cfg, false);
|
|
assert.equal(cfg.id, 'x');
|
|
});
|
|
|
|
test('validateDeviceModule rejects missing static pins (real M1 bug class)', () => {
|
|
assert.equal(validateDeviceModule({ Device: StubMeta }), null);
|
|
class NoPins {
|
|
static typeId = 'stub';
|
|
static title = 'Stub';
|
|
static defaultConfig = {};
|
|
}
|
|
assert.match(validateDeviceModule({ Device: NoPins }), /pins\(cfg\) is required/);
|
|
assert.match(validateDeviceModule({}), /does not export a Device class/);
|
|
class NoId {
|
|
static title = 'S';
|
|
static defaultConfig = {};
|
|
static pins() { return []; }
|
|
}
|
|
assert.match(validateDeviceModule({ Device: NoId }), /typeId/);
|
|
});
|