velxio/frontend/src/__tests__/pin-trace-boards.test.ts

365 lines
14 KiB
TypeScript

// @vitest-environment jsdom
/**
* Per-board regression net for the wire walk (simulation/PinTrace).
*
* The HC-SR04 divider bug (see hcsr04-divider-echo.test.ts) was reported on an
* ESP32-S3, but nothing about it is ESP32-specific: the walk is shared by every
* board family, and the two behaviours the fix introduced — exploring the whole
* NODE instead of following a chain, and demoting a supply pad to the weakest
* possible answer — can regress on one family while the reported board keeps
* working. Each board here re-plays the same shapes through its own
* boardPinToNumber naming ('7', 'GP15', 'PC13', bare GPIO numbers).
*
* The shapes, and why each one is here:
*
* - the level-shifting divider ECHO ─[1k]─┬─[2k2]─ GND
* └─────── GPIO
* the tap carries TWO wires; the walk must look at the other one instead of
* answering "tied to a rail" with the first branch that reaches GND.
*
* - the pull-up drawn BEFORE the signal wire: same demotion, without a
* divider, and with the rail sitting on the FIRST wire the walk sees.
*
* - a pad that genuinely reaches nothing but a rail must STILL answer -1
* (an LED cathode returning a GPIO would be a worse bug than the one fixed),
* and a diode must not be traced through the way a resistor is.
*
* - a breadboard strip: the pre-existing multi-hole hop, guarded here so the
* node-walk rewrite cannot quietly cost us the ESP32 clock circuit.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
// ── Mocks (same shape as hcsr04-divider-echo.test.ts) ───────────────────────
// addBoard builds a real simulator / bridge per family; none of them is under
// test here, and the AVR and RP2040 ones pull in WASM.
vi.mock('../simulation/AVRSimulator', () => ({
AVRSimulator: vi.fn(function (this: any) {
this.onSerialData = null;
this.onBaudRateChange = null;
this.onPinChangeWithTime = null;
this.start = vi.fn();
this.stop = vi.fn();
this.reset = vi.fn();
this.loadHex = vi.fn();
this.addI2CDevice = vi.fn();
this.setPinState = vi.fn();
}),
}));
vi.mock('../simulation/RP2040Simulator', () => ({
RP2040Simulator: vi.fn(function (this: any) {
this.onSerialData = null;
this.onPinChangeWithTime = null;
this.start = vi.fn();
this.stop = vi.fn();
this.reset = vi.fn();
this.loadBinary = vi.fn();
this.addI2CDevice = vi.fn();
this.attachPioPeripheral = vi.fn();
this.spi = { onByte: null, completeTransfer: vi.fn() };
}),
}));
vi.mock('../simulation/PinManager', () => ({
PinManager: vi.fn(function (this: any) {
this.updatePort = vi.fn();
this.onPinChange = vi.fn().mockReturnValue(() => {});
this.getListenersCount = vi.fn().mockReturnValue(0);
this.hardResetPinStates = vi.fn();
this.resetPinStates = vi.fn();
this.getOutputPins = vi.fn().mockReturnValue(new Set<number>());
}),
}));
vi.mock('../store/useOscilloscopeStore', () => ({
useOscilloscopeStore: {
getState: vi.fn().mockReturnValue({ channels: [], pushSample: vi.fn() }),
},
}));
class MockWebSocket {
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
readyState = MockWebSocket.OPEN;
onopen: (() => void) | null = null;
onmessage: ((e: { data: string }) => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
sent: string[] = [];
send(data: string) {
this.sent.push(data);
}
close() {
this.readyState = MockWebSocket.CLOSED;
this.onclose?.();
}
}
vi.stubGlobal('WebSocket', MockWebSocket);
vi.stubGlobal('requestAnimationFrame', (_cb: FrameRequestCallback) => 1);
vi.stubGlobal('cancelAnimationFrame', vi.fn());
import { useSimulatorStore } from '../store/useSimulatorStore';
import { traceDetailed, traceBoardGpio } from '../simulation/PinTrace';
import { boardPinToNumber } from '../utils/boardPinMapping';
import { isStm32BoardKind } from '../types/board';
function wire(
id: string,
a: [string, string],
b: [string, string],
): Record<string, unknown> {
return {
id,
waypoints: [],
color: '#000',
start: { componentId: a[0], pinName: a[1], x: 0, y: 0 },
end: { componentId: b[0], pinName: b[1], x: 0, y: 0 },
};
}
/** Fresh canvas with exactly one board. The store is a module singleton, so a
* leftover board from the previous describe would give the walk a second
* place to land. */
function freshCanvas(boardKind: string, boardId: string) {
useSimulatorStore.setState({ boards: [], components: [], wires: [] } as never);
useSimulatorStore.getState().addBoard(boardKind as never, 0, 0, boardId);
}
function trace(componentId: string, pinName: string) {
return traceDetailed(useSimulatorStore.getState() as never, componentId, pinName, 0);
}
/** The reported divider, parameterised by board: the sensor's ECHO goes into
* the top resistor, the bottom one goes to GND, and the GPIO hangs off the
* tap between them. Returns the wires so a caller can append its own. */
function dividerWires(
boardId: string,
gndPad: string,
gpioPad: string,
): Array<Record<string, unknown>> {
return [
wire('d1', ['sensor', 'ECHO'], ['r_top', '1']),
wire('d2', ['r_top', '2'], ['r_bottom', '1']),
wire('d3', [boardId, gndPad], ['r_bottom', '2']),
wire('d4', [boardId, gpioPad], ['r_bottom', '1']),
];
}
const DIVIDER_PARTS = [
{ id: 'sensor', metadataId: 'hc-sr04', x: 0, y: 0, properties: {} },
{ id: 'r_top', metadataId: 'resistor-1k', x: 0, y: 0, properties: { value: '1000' } },
{ id: 'r_bottom', metadataId: 'resistor-2k2', x: 0, y: 0, properties: { value: '2200' } },
];
// ── Arduino Uno (AVR, bare digital pin names) ───────────────────────────────
describe('arduino-uno', () => {
const BOARD = 'arduino-uno';
beforeEach(() => {
freshCanvas('arduino-uno', BOARD);
const s = useSimulatorStore.getState();
s.setComponents([
...DIVIDER_PARTS,
{ id: 'led1', metadataId: 'led', x: 0, y: 0, properties: {} },
{ id: 'r_led', metadataId: 'resistor-220', x: 0, y: 0, properties: { value: '220' } },
] as never);
s.setWires([
wire('u1', [BOARD, '5V'], ['sensor', 'VCC']),
wire('u2', ['sensor', 'TRIG'], [BOARD, '9']),
wire('u3', [BOARD, 'GND.1'], ['sensor', 'GND']),
...dividerWires(BOARD, 'GND.1', '7'),
// A perfectly ordinary LED: anode on D13, cathode to GND through 220R.
wire('u4', [BOARD, '13'], ['led1', 'A']),
wire('u5', ['led1', 'C'], ['r_led', '1']),
wire('u6', ['r_led', '2'], [BOARD, 'GND.1']),
] as never);
});
it('traces ECHO across the 1k/2k2 divider to D7', () => {
const echo = trace('sensor', 'ECHO');
expect(echo.arduinoPin, 'ECHO must reach D7 through the tap').toBe(7);
expect(echo.boardId).toBe(BOARD);
});
it('traces the directly wired TRIG to D9', () => {
expect(trace('sensor', 'TRIG').arduinoPin).toBe(9);
});
it('still answers -1 for an LED cathode that only reaches GND', () => {
// Two ways this could regress: the walk crossing the LED (a diode is not a
// passive the digital layer may traverse, so D13 must NOT come back), or
// railHit being dropped now that it no longer returns inline.
const cathode = trace('led1', 'C');
expect(cathode.arduinoPin, 'the cathode net is the GND rail, nothing else').toBe(-1);
expect(traceBoardGpio(useSimulatorStore.getState() as never, 'led1', 'C')).toBeNull();
});
});
// ── Raspberry Pi Pico (RP2040, 'GPnn' pad names) ────────────────────────────
describe('raspberry-pi-pico', () => {
const BOARD = 'raspberry-pi-pico';
beforeEach(() => {
freshCanvas('raspberry-pi-pico', BOARD);
const s = useSimulatorStore.getState();
s.setComponents(DIVIDER_PARTS as never);
s.setWires([
wire('p1', [BOARD, 'VSYS'], ['sensor', 'VCC']),
wire('p2', ['sensor', 'TRIG'], [BOARD, 'GP14']),
wire('p3', [BOARD, 'GND.3'], ['sensor', 'GND']),
// The pad is named 'GP15' here; the walk must hand the NAME to
// boardPinToNumber rather than assume a bare number like the ESP32 pads.
...dividerWires(BOARD, 'GND.3', 'GP15'),
] as never);
});
it('traces ECHO across the divider to GP15', () => {
const echo = trace('sensor', 'ECHO');
expect(echo.arduinoPin).toBe(15);
expect(echo.boardId).toBe(BOARD);
});
it('traces the directly wired TRIG to GP14', () => {
expect(trace('sensor', 'TRIG').arduinoPin).toBe(14);
});
});
// ── A pull-up drawn before the signal wire ──────────────────────────────────
// Wire ORDER is the whole point: the first wire on the pin leads to a supply
// pad through a resistor, so a walk that returns the first non-null answer
// reports "rail" and the GPIO on the second wire is never seen.
describe('a pull-up drawn before the signal wire', () => {
const CASES: Array<{ kind: string; id: string; rail: string; gpio: string; expect: number }> = [
{ kind: 'arduino-uno', id: 'arduino-uno', rail: '5V', gpio: '2', expect: 2 },
{ kind: 'esp32', id: 'esp32', rail: '3V3', gpio: '4', expect: 4 },
];
for (const c of CASES) {
it(`resolves the GPIO, not the ${c.rail} pad behind the resistor (${c.kind})`, () => {
freshCanvas(c.kind, c.id);
const s = useSimulatorStore.getState();
s.setComponents([
{ id: 'dht', metadataId: 'dht22', x: 0, y: 0, properties: {} },
{ id: 'r_pu', metadataId: 'resistor-10k', x: 0, y: 0, properties: { value: '10000' } },
] as never);
s.setWires([
wire('a1', ['dht', 'SDA'], ['r_pu', '1']),
wire('a2', ['r_pu', '2'], [c.id, c.rail]),
wire('a3', ['dht', 'SDA'], [c.id, c.gpio]),
] as never);
const hit = trace('dht', 'SDA');
expect(hit.arduinoPin).toBe(c.expect);
expect(hit.boardId).toBe(c.id);
});
}
});
// ── STM32 ('PA0' / 'PC13' port-pin names) ───────────────────────────────────
// The family only reaches the walk if boardPinToNumber knows its pad names;
// check that first so a missing mapping reads as "skipped", not as a trace bug.
const STM32_KIND = 'stm32-bluepill';
const STM32_PA0 = boardPinToNumber(STM32_KIND, 'PA0');
const STM32_PC13 = boardPinToNumber(STM32_KIND, 'PC13');
const STM32_MAPPED =
isStm32BoardKind(STM32_KIND) && STM32_PA0 !== null && STM32_PC13 !== null;
describe.skipIf(!STM32_MAPPED)('stm32-bluepill', () => {
const BOARD = 'stm32-bluepill';
beforeEach(() => {
freshCanvas(STM32_KIND, BOARD);
const s = useSimulatorStore.getState();
s.setComponents(DIVIDER_PARTS as never);
s.setWires([
wire('s1', [BOARD, '5V'], ['sensor', 'VCC']),
wire('s2', ['sensor', 'TRIG'], [BOARD, 'PA0']),
wire('s3', [BOARD, 'GND.1'], ['sensor', 'GND']),
...dividerWires(BOARD, 'GND.1', 'PC13'),
] as never);
});
it('traces ECHO across the divider to PC13', () => {
// Linear numbering, port*16+pin — the same number Stm32Bridge speaks.
expect(trace('sensor', 'ECHO').arduinoPin).toBe(STM32_PC13);
});
it('traces the directly wired TRIG to PA0', () => {
// PA0 is linear 0: a legitimate driven pin, and the one value that a
// truthiness check instead of `>= 0` would silently drop.
expect(STM32_PA0).toBe(0);
expect(trace('sensor', 'TRIG').arduinoPin).toBe(0);
});
});
// ── A pin that only ever reaches a supply pad ───────────────────────────────
describe('a pin that only reaches a supply pad', () => {
const BOARD = 'esp32';
beforeEach(() => {
freshCanvas('esp32', BOARD);
const s = useSimulatorStore.getState();
s.setComponents([
{ id: 'sensor', metadataId: 'hc-sr04', x: 0, y: 0, properties: {} },
{ id: 'r_pu', metadataId: 'resistor-10k', x: 0, y: 0, properties: { value: '10000' } },
] as never);
s.setWires([
// Straight to GND.
wire('g1', ['sensor', 'GND'], [BOARD, 'GND.4']),
// And to 3V3 through a resistor, with no signal wire anywhere: the
// demoted rail is the ONLY answer left, so it has to survive the walk.
wire('g2', ['sensor', 'VCC'], ['r_pu', '1']),
wire('g3', ['r_pu', '2'], [BOARD, '3V3']),
] as never);
});
it('answers -1 for a GND pad', () => {
expect(trace('sensor', 'GND').arduinoPin).toBe(-1);
});
it('answers -1 for a 3V3 pad behind a resistor', () => {
expect(trace('sensor', 'VCC').arduinoPin).toBe(-1);
});
it('reports both as "no GPIO" through traceBoardGpio', () => {
const st = useSimulatorStore.getState() as never;
expect(traceBoardGpio(st, 'sensor', 'GND')).toBeNull();
expect(traceBoardGpio(st, 'sensor', 'VCC')).toBeNull();
});
});
// ── Through a breadboard strip ──────────────────────────────────────────────
describe('through a breadboard strip', () => {
const BOARD = 'arduino-uno';
beforeEach(() => {
freshCanvas('arduino-uno', BOARD);
const s = useSimulatorStore.getState();
s.setComponents([
{ id: 'sensor', metadataId: 'hc-sr04', x: 0, y: 0, properties: {} },
{ id: 'bb', metadataId: 'breadboard', x: 0, y: 0, properties: {} },
] as never);
s.setWires([
// Two different holes of the same 5-hole strip (column 18, top half):
// internally shorted, with no component to trace "through".
wire('b1', ['sensor', 'ECHO'], ['bb', '18t.a']),
wire('b2', ['bb', '18t.e'], [BOARD, '6']),
] as never);
});
it('resolves a pin that reaches the board through a strip', () => {
expect(trace('sensor', 'ECHO').arduinoPin).toBe(6);
});
it('does not invent a connection to a different column', () => {
// col17t is a separate net; nothing is wired there, so the answer is
// "unreached" (null) rather than a neighbouring GPIO.
useSimulatorStore.getState().setWires([
wire('b1', ['sensor', 'ECHO'], ['bb', '17t.a']),
wire('b2', ['bb', '18t.e'], [BOARD, '6']),
] as never);
expect(trace('sensor', 'ECHO').arduinoPin).toBeNull();
});
});