/** * 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( '../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 }> { 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 = {}): 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): boolean { bridge.sendSensorAttach(type, pin, properties); return true; }, updateSensor(pin: number, properties: Record): 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) => (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 }, }, ]); }); });