Files
esp-emu-peris/tests/unit/core.test.mjs
T
darceye 9cd5863d36 MAX7219 display + logic analyzer with Saleae-style floating window (0.9.0 + 0.10.0)
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.
2026-11-02 00:05:48 +08:00

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