457 lines
19 KiB
TypeScript
457 lines
19 KiB
TypeScript
/**
|
|
* esp32-dht22-flow.test.ts
|
|
*
|
|
* Tests for the ESP32 sensor registration flow, focusing on the DHT22 example.
|
|
* Verifies:
|
|
* 1. Esp32BridgeShim delegates registerSensor → bridge.sendSensorAttach
|
|
* 2. DHT22 attachEvents detects ESP32 shim and calls registerSensor
|
|
* 3. DHT22 attachEvents falls back to local protocol on AVR (registerSensor → false)
|
|
* 4. Sensor update flow: updateSensor → bridge.sendSensorUpdate
|
|
* 5. Cleanup: unregisterSensor → bridge.sendSensorDetach
|
|
* 6. Esp32Bridge includes pre-registered sensors in start_esp32 payload
|
|
* 7. Race condition fix: sensors are pre-registered before firmware executes
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
|
|
// ── Mocks ────────────────────────────────────────────────────────────────────
|
|
|
|
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();
|
|
this.isRunning = vi.fn().mockReturnValue(false);
|
|
this.registerSensor = vi.fn().mockReturnValue(false);
|
|
this.updateSensor = vi.fn();
|
|
this.unregisterSensor = 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.isRunning = vi.fn().mockReturnValue(false);
|
|
this.registerSensor = vi.fn().mockReturnValue(false);
|
|
this.updateSensor = vi.fn();
|
|
this.unregisterSensor = 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.onPwmChange = vi.fn().mockReturnValue(() => {});
|
|
this.getListenersCount = vi.fn().mockReturnValue(0);
|
|
this.hardResetPinStates = vi.fn();
|
|
this.updatePwm = vi.fn();
|
|
}),
|
|
}));
|
|
|
|
vi.mock('../simulation/I2CBusManager', async () => {
|
|
const actual = await vi.importActual<typeof import('../simulation/I2CBusManager')>(
|
|
'../simulation/I2CBusManager',
|
|
);
|
|
return actual;
|
|
});
|
|
|
|
vi.mock('../store/useOscilloscopeStore', () => ({
|
|
useOscilloscopeStore: {
|
|
getState: vi.fn().mockReturnValue({ channels: [], pushSample: vi.fn() }),
|
|
},
|
|
}));
|
|
|
|
// WebSocket mock
|
|
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: ((e?: any) => void) | null = null;
|
|
onerror: ((e?: any) => void) | null = null;
|
|
sent: string[] = [];
|
|
|
|
send(data: string) {
|
|
this.sent.push(data);
|
|
}
|
|
close() {
|
|
this.readyState = MockWebSocket.CLOSED;
|
|
this.onclose?.({ code: 1000 });
|
|
}
|
|
open() {
|
|
this.readyState = MockWebSocket.OPEN;
|
|
this.onopen?.();
|
|
}
|
|
receive(payload: object) {
|
|
this.onmessage?.({ data: JSON.stringify(payload) });
|
|
}
|
|
|
|
/** Parse all sent JSON messages */
|
|
get messages(): Array<{ type: string; data: Record<string, unknown> }> {
|
|
return this.sent.map((s) => JSON.parse(s));
|
|
}
|
|
}
|
|
|
|
vi.stubGlobal('WebSocket', MockWebSocket);
|
|
vi.stubGlobal('requestAnimationFrame', (_cb: FrameRequestCallback) => 1);
|
|
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
|
vi.stubGlobal('sessionStorage', {
|
|
getItem: vi.fn().mockReturnValue('test-session-id'),
|
|
setItem: vi.fn(),
|
|
});
|
|
|
|
// ── Imports (after mocks) ────────────────────────────────────────────────────
|
|
|
|
import { Esp32Bridge } from '../simulation/Esp32Bridge';
|
|
import { PartSimulationRegistry } from '../simulation/parts/PartSimulationRegistry';
|
|
import '../simulation/parts/ProtocolParts';
|
|
|
|
// ─── Mock factories ──────────────────────────────────────────────────────────
|
|
|
|
function makeElement(props: Record<string, unknown> = {}): HTMLElement {
|
|
return {
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
...props,
|
|
} as unknown as HTMLElement;
|
|
}
|
|
|
|
/** Simulator mock that mimics Esp32BridgeShim (registerSensor returns true) */
|
|
function makeEsp32Shim() {
|
|
const bridge = {
|
|
sendSensorAttach: vi.fn(),
|
|
sendSensorUpdate: vi.fn(),
|
|
sendSensorDetach: vi.fn(),
|
|
};
|
|
return {
|
|
bridge,
|
|
pinManager: {
|
|
onPinChange: vi.fn().mockReturnValue(() => {}),
|
|
onPwmChange: vi.fn().mockReturnValue(() => {}),
|
|
},
|
|
setPinState: vi.fn(),
|
|
isRunning: vi.fn().mockReturnValue(true),
|
|
registerSensor(type: string, pin: number, properties: Record<string, unknown>): boolean {
|
|
bridge.sendSensorAttach(type, pin, properties);
|
|
return true;
|
|
},
|
|
updateSensor(pin: number, properties: Record<string, unknown>): void {
|
|
bridge.sendSensorUpdate(pin, properties);
|
|
},
|
|
unregisterSensor(pin: number): void {
|
|
bridge.sendSensorDetach(pin);
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Simulator mock that mimics AVR (registerSensor returns false) */
|
|
function makeAVRSim() {
|
|
return {
|
|
pinManager: {
|
|
onPinChange: vi.fn().mockReturnValue(() => {}),
|
|
onPwmChange: vi.fn().mockReturnValue(() => {}),
|
|
},
|
|
setPinState: vi.fn(),
|
|
isRunning: vi.fn().mockReturnValue(true),
|
|
registerSensor: vi.fn().mockReturnValue(false),
|
|
updateSensor: vi.fn(),
|
|
unregisterSensor: vi.fn(),
|
|
schedulePinChange: vi.fn(),
|
|
getCurrentCycles: vi.fn().mockReturnValue(1000),
|
|
cpu: { data: new Uint8Array(512).fill(0), cycles: 1000 },
|
|
};
|
|
}
|
|
|
|
const pinMap =
|
|
(map: Record<string, number>) =>
|
|
(name: string): number | null =>
|
|
name in map ? map[name] : null;
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 1. Esp32BridgeShim — registerSensor delegates to bridge
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('Esp32BridgeShim — sensor registration', () => {
|
|
it('registerSensor calls bridge.sendSensorAttach and returns true', () => {
|
|
const shim = makeEsp32Shim();
|
|
const result = shim.registerSensor('dht22', 4, { temperature: 28, humidity: 65 });
|
|
expect(result).toBe(true);
|
|
expect(shim.bridge.sendSensorAttach).toHaveBeenCalledWith('dht22', 4, {
|
|
temperature: 28,
|
|
humidity: 65,
|
|
});
|
|
});
|
|
|
|
it('updateSensor calls bridge.sendSensorUpdate', () => {
|
|
const shim = makeEsp32Shim();
|
|
shim.updateSensor(4, { temperature: 30, humidity: 70 });
|
|
expect(shim.bridge.sendSensorUpdate).toHaveBeenCalledWith(4, { temperature: 30, humidity: 70 });
|
|
});
|
|
|
|
it('unregisterSensor calls bridge.sendSensorDetach', () => {
|
|
const shim = makeEsp32Shim();
|
|
shim.unregisterSensor(4);
|
|
expect(shim.bridge.sendSensorDetach).toHaveBeenCalledWith(4);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 2. DHT22 attachEvents — ESP32 shim detection
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('dht22 — ESP32 shim detection', () => {
|
|
const logic = () => PartSimulationRegistry.get('dht22')!;
|
|
|
|
it('detects ESP32 shim and delegates sensor to backend', () => {
|
|
const shim = makeEsp32Shim();
|
|
const el = makeElement({ temperature: 28, humidity: 65 });
|
|
logic().attachEvents!(el, shim as any, pinMap({ SDA: 4 }), 'dht22-1');
|
|
|
|
// Should have called registerSensor → sendSensorAttach
|
|
expect(shim.bridge.sendSensorAttach).toHaveBeenCalledWith('dht22', 4, {
|
|
temperature: 28,
|
|
humidity: 65,
|
|
});
|
|
|
|
// Should NOT register onPinChange (local protocol) — ESP32 backend handles it
|
|
expect(shim.pinManager.onPinChange).not.toHaveBeenCalled();
|
|
// Should NOT call setPinState (no local pin driving)
|
|
expect(shim.setPinState).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('cleanup calls unregisterSensor', () => {
|
|
const shim = makeEsp32Shim();
|
|
const el = makeElement({ temperature: 28, humidity: 65 });
|
|
const cleanup = logic().attachEvents!(el, shim as any, pinMap({ SDA: 4 }), 'dht22-1');
|
|
|
|
cleanup();
|
|
expect(shim.bridge.sendSensorDetach).toHaveBeenCalledWith(4);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 3. DHT22 attachEvents — AVR fallback (local protocol)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('dht22 — AVR local protocol fallback', () => {
|
|
const logic = () => PartSimulationRegistry.get('dht22')!;
|
|
|
|
it('falls back to local protocol when registerSensor returns false', () => {
|
|
const sim = makeAVRSim();
|
|
const el = makeElement({ temperature: 25, humidity: 50 });
|
|
logic().attachEvents!(el, sim as any, pinMap({ SDA: 7 }), 'dht22-avr');
|
|
|
|
// AVR registerSensor returns false → local protocol path
|
|
expect(sim.registerSensor).toHaveBeenCalledWith('dht22', 7, { temperature: 25, humidity: 50 });
|
|
|
|
// Local protocol: onPinChange registered for start-signal detection
|
|
expect(sim.pinManager.onPinChange).toHaveBeenCalledWith(7, expect.any(Function));
|
|
// Local protocol: DATA pin set HIGH (idle)
|
|
expect(sim.setPinState).toHaveBeenCalledWith(7, true);
|
|
});
|
|
|
|
it('local protocol does NOT call registerSensor on bridge methods', () => {
|
|
const sim = makeAVRSim();
|
|
const el = makeElement({ temperature: 25, humidity: 50 });
|
|
logic().attachEvents!(el, sim as any, pinMap({ SDA: 7 }), 'dht22-avr2');
|
|
|
|
// AVR path: uses onPinChange, not bridge sensor methods
|
|
expect(sim.pinManager.onPinChange).toHaveBeenCalledWith(7, expect.any(Function));
|
|
// updateSensor/unregisterSensor should NOT have been called
|
|
expect(sim.updateSensor).not.toHaveBeenCalled();
|
|
expect(sim.unregisterSensor).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 4. Sensor update flow
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('dht22 — sensor update via SensorUpdateRegistry', () => {
|
|
const logic = () => PartSimulationRegistry.get('dht22')!;
|
|
|
|
it('registerSensorUpdate callback forwards to updateSensor', async () => {
|
|
const shim = makeEsp32Shim();
|
|
const el = makeElement({ temperature: 28, humidity: 65 }) as any;
|
|
logic().attachEvents!(el, shim as any, pinMap({ SDA: 4 }), 'dht22-update');
|
|
|
|
// Import the registry and dispatch an update
|
|
const { dispatchSensorUpdate } = await import('../simulation/SensorUpdateRegistry');
|
|
dispatchSensorUpdate('dht22-update', { temperature: 35 });
|
|
|
|
// Should have forwarded the update to the bridge
|
|
expect(shim.bridge.sendSensorUpdate).toHaveBeenCalledWith(
|
|
4,
|
|
expect.objectContaining({ temperature: 35 }),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 5. Esp32Bridge — WebSocket sensor messages
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('Esp32Bridge — sensor WebSocket protocol', () => {
|
|
let bridge: Esp32Bridge;
|
|
let ws: MockWebSocket;
|
|
|
|
beforeEach(() => {
|
|
bridge = new Esp32Bridge('test-esp32', 'esp32');
|
|
bridge.connect();
|
|
// Get the WebSocket instance created in connect()
|
|
ws = (bridge as any).socket as MockWebSocket;
|
|
ws.open(); // Trigger onopen → sends start_esp32
|
|
ws.sent = []; // Clear the start_esp32 message
|
|
});
|
|
|
|
afterEach(() => {
|
|
bridge.disconnect();
|
|
});
|
|
|
|
it('sendSensorAttach sends esp32_sensor_attach message', () => {
|
|
bridge.sendSensorAttach('dht22', 4, { temperature: 28, humidity: 65 });
|
|
expect(ws.messages).toEqual([
|
|
{
|
|
type: 'esp32_sensor_attach',
|
|
data: { sensor_type: 'dht22', pin: 4, temperature: 28, humidity: 65 },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('connect() on a lingering (non-CLOSED) socket force-reconnects instead of no-op', () => {
|
|
// Regression: the agent leaves the board running (socket OPEN); the user's
|
|
// Run → startBoard → connect() must boot a FRESH session, not silently
|
|
// return because a socket already exists. Reproduces "run after the agent
|
|
// did nothing until I reloaded".
|
|
const firstWs = (bridge as any).socket as MockWebSocket;
|
|
expect(firstWs.readyState).toBe(MockWebSocket.OPEN);
|
|
|
|
bridge.connect(); // second connect while the first socket is still OPEN
|
|
const secondWs = (bridge as any).socket as MockWebSocket;
|
|
|
|
expect(firstWs.readyState).toBe(MockWebSocket.CLOSED); // old zombie torn down
|
|
expect(secondWs).not.toBe(firstWs); // a brand-new socket
|
|
secondWs.open();
|
|
const startMsg = secondWs.messages.find((m) => m.type === 'start_esp32');
|
|
expect(startMsg).toBeDefined(); // fresh boot actually happened
|
|
});
|
|
|
|
it('sendSensorUpdate sends esp32_sensor_update message', () => {
|
|
bridge.sendSensorUpdate(4, { temperature: 35, humidity: 70 });
|
|
expect(ws.messages).toEqual([
|
|
{
|
|
type: 'esp32_sensor_update',
|
|
data: { pin: 4, temperature: 35, humidity: 70 },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('sendSensorDetach sends esp32_sensor_detach message', () => {
|
|
bridge.sendSensorDetach(4);
|
|
expect(ws.messages).toEqual([
|
|
{
|
|
type: 'esp32_sensor_detach',
|
|
data: { pin: 4 },
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 6. Esp32Bridge — sensor pre-registration in start_esp32 payload
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('Esp32Bridge — sensor pre-registration', () => {
|
|
it('includes sensors in start_esp32 payload when setSensors is called before connect', () => {
|
|
const bridge = new Esp32Bridge('test-esp32', 'esp32');
|
|
bridge.setSensors([{ sensor_type: 'dht22', pin: 4, temperature: 28, humidity: 65 }]);
|
|
bridge.loadFirmware('AAAA'); // base64 firmware
|
|
|
|
bridge.connect();
|
|
const ws = (bridge as any).socket as MockWebSocket;
|
|
ws.open(); // Trigger onopen → sends start_esp32
|
|
|
|
const startMsg = ws.messages.find((m) => m.type === 'start_esp32');
|
|
expect(startMsg).toBeDefined();
|
|
expect(startMsg!.data.sensors).toEqual([
|
|
{ sensor_type: 'dht22', pin: 4, temperature: 28, humidity: 65 },
|
|
]);
|
|
});
|
|
|
|
it('start_esp32 payload has empty sensors array when none pre-registered', () => {
|
|
const bridge = new Esp32Bridge('test-esp32', 'esp32');
|
|
bridge.loadFirmware('AAAA');
|
|
bridge.connect();
|
|
const ws = (bridge as any).socket as MockWebSocket;
|
|
ws.open();
|
|
|
|
const startMsg = ws.messages.find((m) => m.type === 'start_esp32');
|
|
expect(startMsg).toBeDefined();
|
|
expect(startMsg!.data.sensors).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 7. Race condition: sensors pre-registered before firmware starts
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('Race condition fix — sensors arrive before firmware', () => {
|
|
it('sensor_attach message is part of start_esp32, not a separate later message', () => {
|
|
const bridge = new Esp32Bridge('test-esp32', 'esp32');
|
|
bridge.setSensors([{ sensor_type: 'dht22', pin: 4, temperature: 28, humidity: 65 }]);
|
|
bridge.loadFirmware('AAAA');
|
|
|
|
bridge.connect();
|
|
const ws = (bridge as any).socket as MockWebSocket;
|
|
ws.open();
|
|
|
|
// The very first message should be start_esp32 with sensors included
|
|
const firstMsg = ws.messages[0];
|
|
expect(firstMsg.type).toBe('start_esp32');
|
|
expect(firstMsg.data.sensors).toHaveLength(1);
|
|
expect(firstMsg.data.sensors[0].sensor_type).toBe('dht22');
|
|
expect(firstMsg.data.sensors[0].pin).toBe(4);
|
|
|
|
// No separate sensor_attach message should be needed at this point
|
|
const sensorAttachMsgs = ws.messages.filter((m) => m.type === 'esp32_sensor_attach');
|
|
expect(sensorAttachMsgs).toHaveLength(0);
|
|
});
|
|
|
|
it('sensors can still be attached dynamically after start', () => {
|
|
const bridge = new Esp32Bridge('test-esp32', 'esp32');
|
|
bridge.loadFirmware('AAAA');
|
|
bridge.connect();
|
|
const ws = (bridge as any).socket as MockWebSocket;
|
|
ws.open();
|
|
ws.sent = []; // Clear start_esp32
|
|
|
|
// Dynamic attachment (e.g. user adds a sensor during simulation)
|
|
bridge.sendSensorAttach('hc-sr04', 12, { distance: 50 });
|
|
expect(ws.messages).toEqual([
|
|
{
|
|
type: 'esp32_sensor_attach',
|
|
data: { sensor_type: 'hc-sr04', pin: 12, distance: 50 },
|
|
},
|
|
]);
|
|
});
|
|
});
|