/** * protocol-parts.test.ts * * Tests for the 8 "protocol" components that use I2C, SPI, single-wire, or * custom serial communication: * * ssd1306 — I2C OLED (VirtualSSD1306) * ds1307 — I2C RTC (VirtualDS1307) * mpu6050 — I2C IMU (VirtualMPU6050) * dht22 — single-wire temp/humidity * hx711 — 2-wire load-cell ADC * ir-receiver — click → NEC pulse on OUT pin * ir-remote — button-press → ir-signal event * microsd-card — SPI SD init handshake */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { PartSimulationRegistry } from '../simulation/parts/PartSimulationRegistry'; import '../simulation/parts/ProtocolParts'; // ─── Globals ────────────────────────────────────────────────────────────────── beforeEach(() => { vi.useFakeTimers(); vi.stubGlobal('requestAnimationFrame', (_cb: FrameRequestCallback) => 1); vi.stubGlobal('cancelAnimationFrame', vi.fn()); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); // ─── Mock factories ─────────────────────────────────────────────────────────── function makeElement(props: Record = {}): HTMLElement { return { addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), ...props, } as unknown as HTMLElement; } function makeI2CSim() { return { addI2CDevice: vi.fn(), i2cBus: { removeDevice: vi.fn() }, removeI2CDevice: vi.fn(), setPinState: vi.fn(), pinManager: { onPinChange: vi.fn().mockReturnValue(() => {}), }, spi: null, cpu: { data: new Uint8Array(512).fill(0), cycles: 0 }, }; } function makePinSim() { return { pinManager: { onPinChange: vi.fn().mockReturnValue(() => {}), }, setPinState: vi.fn(), addI2CDevice: vi.fn(), i2cBus: { removeDevice: vi.fn() }, spi: null, cpu: { data: new Uint8Array(512).fill(0), cycles: 0 }, }; } function makeSPISim() { const spi = { onTransmit: null as ((b: number) => void) | null, completeTransmit: vi.fn(), }; return { spi, pinManager: { onPinChange: vi.fn().mockReturnValue(() => {}) }, setPinState: vi.fn(), addI2CDevice: vi.fn(), i2cBus: { removeDevice: vi.fn() }, cpu: { data: new Uint8Array(512).fill(0), cycles: 0 }, }; } const pinMap = (map: Record) => (name: string): number | null => name in map ? map[name] : null; const noPins = (_name: string): number | null => null; // ─── Registration ───────────────────────────────────────────────────────────── describe('Protocol parts — registration', () => { const IDS = [ 'ssd1306', 'ds1307', 'mpu6050', 'dht22', 'hx711', 'ir-receiver', 'ir-remote', 'microsd-card', ]; it('registers all 8 protocol components', () => { for (const id of IDS) { expect(PartSimulationRegistry.get(id), `missing: ${id}`).toBeDefined(); } }); }); // ─── ssd1306 ────────────────────────────────────────────────────────────────── describe('ssd1306 — I2C device', () => { it('calls addI2CDevice with address 0x3C', () => { const sim = makeI2CSim(); const logic = PartSimulationRegistry.get('ssd1306')!; logic.attachEvents!(makeElement(), sim as any, noPins); expect(sim.addI2CDevice).toHaveBeenCalledOnce(); const device = sim.addI2CDevice.mock.calls[0][0]; expect(device.address).toBe(0x3C); }); it('cleanup calls removeDevice on i2cBus', () => { const sim = makeI2CSim(); const logic = PartSimulationRegistry.get('ssd1306')!; const cleanup = logic.attachEvents!(makeElement(), sim as any, noPins); cleanup(); expect(sim.i2cBus.removeDevice).toHaveBeenCalledWith(0x3C); }); it('decodes horizontal addressing: write data bytes into buffer correctly', () => { const sim = makeI2CSim(); const logic = PartSimulationRegistry.get('ssd1306')!; logic.attachEvents!(makeElement(), sim as any, noPins); const device = sim.addI2CDevice.mock.calls[0][0]; // Set column address 0–127, page address 0–7 (via commands) device.writeByte(0x00); // control: command stream device.writeByte(0x21); // cmd: set column address device.writeByte(0x00); // col start = 0 device.writeByte(0x7F); // col end = 127 device.writeByte(0x22); // cmd: set page address device.writeByte(0x00); // page start = 0 device.writeByte(0x07); // page end = 7 device.stop(); // flushes command state device.writeByte(0x40); // control: data stream device.writeByte(0xAB); // column 0 of page 0 = 0xAB device.stop(); expect(device.buffer[0]).toBe(0xAB); }); it('readByte returns 0xFF (read not supported)', () => { const sim = makeI2CSim(); const logic = PartSimulationRegistry.get('ssd1306')!; logic.attachEvents!(makeElement(), sim as any, noPins); const device = sim.addI2CDevice.mock.calls[0][0]; expect(device.readByte()).toBe(0xFF); }); it('no-op when simulator has no addI2CDevice', () => { const sim = { ...makeI2CSim(), addI2CDevice: undefined }; const logic = PartSimulationRegistry.get('ssd1306')!; expect(() => { const c = logic.attachEvents!(makeElement(), sim as any, noPins); c(); }).not.toThrow(); }); }); // ─── ds1307 ─────────────────────────────────────────────────────────────────── describe('ds1307 — I2C RTC', () => { it('calls addI2CDevice with address 0x68', () => { const sim = makeI2CSim(); const logic = PartSimulationRegistry.get('ds1307')!; logic.attachEvents!(makeElement(), sim as any, noPins); const dev = sim.addI2CDevice.mock.calls[0][0]; expect(dev.address).toBe(0x68); }); it('readByte returns valid BCD for seconds (register 0)', () => { const sim = makeI2CSim(); const logic = PartSimulationRegistry.get('ds1307')!; logic.attachEvents!(makeElement(), sim as any, noPins); const dev = sim.addI2CDevice.mock.calls[0][0]; dev.writeByte(0x00); // set register pointer to 0 (seconds) const seconds = dev.readByte(); // BCD: upper nibble = tens digit, lower nibble = units digit const tens = (seconds >> 4) & 0xF; const units = seconds & 0xF; expect(tens).toBeLessThanOrEqual(5); expect(units).toBeLessThanOrEqual(9); }); it('cleanup removes device from i2cBus', () => { const sim = makeI2CSim(); const logic = PartSimulationRegistry.get('ds1307')!; const cleanup = logic.attachEvents!(makeElement(), sim as any, noPins); cleanup(); expect(sim.i2cBus.removeDevice).toHaveBeenCalledWith(0x68); }); }); // ─── mpu6050 ────────────────────────────────────────────────────────────────── describe('mpu6050 — I2C IMU', () => { it('calls addI2CDevice with address 0x68 (AD0=0)', () => { const sim = makeI2CSim(); const logic = PartSimulationRegistry.get('mpu6050')!; logic.attachEvents!(makeElement(), sim as any, noPins); const dev = sim.addI2CDevice.mock.calls[0][0]; expect(dev.address).toBe(0x68); }); it('uses address 0x69 when element.ad0 is true', () => { const sim = makeI2CSim(); const logic = PartSimulationRegistry.get('mpu6050')!; logic.attachEvents!(makeElement({ ad0: true }), sim as any, noPins); const dev = sim.addI2CDevice.mock.calls[0][0]; expect(dev.address).toBe(0x69); }); it('WHO_AM_I register (0x75) returns 0x68', () => { const sim = makeI2CSim(); const logic = PartSimulationRegistry.get('mpu6050')!; logic.attachEvents!(makeElement(), sim as any, noPins); const dev = sim.addI2CDevice.mock.calls[0][0]; dev.writeByte(0x75); // set register pointer expect(dev.readByte()).toBe(0x68); }); it('ACCEL_ZOUT reports +1g (0x40, 0x00)', () => { const sim = makeI2CSim(); const logic = PartSimulationRegistry.get('mpu6050')!; logic.attachEvents!(makeElement(), sim as any, noPins); const dev = sim.addI2CDevice.mock.calls[0][0]; dev.writeByte(0x3F); // ACCEL_ZOUT_H expect(dev.readByte()).toBe(0x40); expect(dev.readByte()).toBe(0x00); }); it('cleanup removes device', () => { const sim = makeI2CSim(); const logic = PartSimulationRegistry.get('mpu6050')!; const cleanup = logic.attachEvents!(makeElement(), sim as any, noPins); cleanup(); expect(sim.i2cBus.removeDevice).toHaveBeenCalledWith(0x68); }); }); // ─── dht22 ─────────────────────────────────────────────────────────────────── describe('dht22 — single-wire sensor', () => { it('sets DATA pin HIGH (idle) on attach', () => { const sim = makePinSim(); const logic = PartSimulationRegistry.get('dht22')!; logic.attachEvents!(makeElement(), sim as any, pinMap({ DATA: 7 })); expect(sim.setPinState).toHaveBeenCalledWith(7, true); }); it('registers onPinChange for DATA pin', () => { const sim = makePinSim(); const logic = PartSimulationRegistry.get('dht22')!; logic.attachEvents!(makeElement(), sim as any, pinMap({ DATA: 7 })); expect(sim.pinManager.onPinChange).toHaveBeenCalledWith(7, expect.any(Function)); }); it('drives DATA in response after LOW → HIGH start sequence', () => { const sim = makePinSim(); const logic = PartSimulationRegistry.get('dht22')!; logic.attachEvents!(makeElement({ temperature: 25.0, humidity: 50.0 }), sim as any, pinMap({ DATA: 7 })); const cb = sim.pinManager.onPinChange.mock.calls[0][1] as (pin: number, state: boolean) => void; sim.setPinState.mockClear(); cb(7, false); // MCU pulls DATA LOW (start signal) cb(7, true); // MCU releases DATA HIGH → DHT22 should begin response // Response should include at least one LOW pulse const calls = (sim.setPinState as ReturnType).mock.calls; expect(calls.some(([, s]) => s === false)).toBe(true); }); it('no-op if DATA pin not found', () => { const sim = makePinSim(); const logic = PartSimulationRegistry.get('dht22')!; expect(() => { const c = logic.attachEvents!(makeElement(), sim as any, noPins); c(); }).not.toThrow(); expect(sim.pinManager.onPinChange).not.toHaveBeenCalled(); }); it('cleanup drives DATA HIGH', () => { const sim = makePinSim(); const logic = PartSimulationRegistry.get('dht22')!; const cleanup = logic.attachEvents!(makeElement(), sim as any, pinMap({ DATA: 7 })); sim.setPinState.mockClear(); cleanup(); expect(sim.setPinState).toHaveBeenCalledWith(7, true); }); }); // ─── hx711 ─────────────────────────────────────────────────────────────────── describe('hx711 — load cell amplifier', () => { it('drives DOUT LOW (ready) on attach', () => { const sim = makePinSim(); const logic = PartSimulationRegistry.get('hx711')!; logic.attachEvents!(makeElement(), sim as any, pinMap({ SCK: 2, DOUT: 3 })); expect(sim.setPinState).toHaveBeenCalledWith(3, false); }); it('registers onPinChange for SCK pin', () => { const sim = makePinSim(); const logic = PartSimulationRegistry.get('hx711')!; logic.attachEvents!(makeElement(), sim as any, pinMap({ SCK: 2, DOUT: 3 })); expect(sim.pinManager.onPinChange).toHaveBeenCalledWith(2, expect.any(Function)); }); it('outputs 24 bits on 24 rising SCK edges (MSB first)', () => { const sim = makePinSim(); const logic = PartSimulationRegistry.get('hx711')!; // weight=100 → raw = 100000 = 0x0186A0 logic.attachEvents!(makeElement({ weight: 100 }), sim as any, pinMap({ SCK: 2, DOUT: 3 })); const cb = sim.pinManager.onPinChange.mock.calls[0][1] as (p: number, s: boolean) => void; sim.setPinState.mockClear(); // 24 rising edges const doutValues: boolean[] = []; for (let i = 0; i < 24; i++) { cb(2, true); // rising const last = (sim.setPinState as ReturnType).mock.lastCall; if (last && last[0] === 3) doutValues.push(last[1]); cb(2, false); // falling } expect(doutValues).toHaveLength(24); // Reconstruct 24-bit value const reconstructed = doutValues.reduce((acc, bit, i) => acc | ((bit ? 1 : 0) << (23 - i)), 0); const expected = (100 * 1000) & 0xFF_FFFF; // = 100000 = 0x0186A0 expect(reconstructed).toBe(expected); }); it('drives DOUT HIGH after 25 rising edges (gain select done)', () => { const sim = makePinSim(); const logic = PartSimulationRegistry.get('hx711')!; logic.attachEvents!(makeElement(), sim as any, pinMap({ SCK: 2, DOUT: 3 })); const cb = sim.pinManager.onPinChange.mock.calls[0][1] as (p: number, s: boolean) => void; // 24 data bits + 1 gain pulse for (let i = 0; i < 25; i++) { cb(2, true); cb(2, false); } // After 25th rising, DOUT goes HIGH const highCalls = (sim.setPinState as ReturnType).mock.calls .filter(([pin, state]) => pin === 3 && state === true); expect(highCalls.length).toBeGreaterThanOrEqual(1); }); it('no-op if SCK or DOUT not connected', () => { const sim = makePinSim(); const logic = PartSimulationRegistry.get('hx711')!; expect(() => { const c = logic.attachEvents!(makeElement(), sim as any, noPins); c(); }).not.toThrow(); expect(sim.pinManager.onPinChange).not.toHaveBeenCalled(); }); it('cleanup drives DOUT HIGH', () => { const sim = makePinSim(); const logic = PartSimulationRegistry.get('hx711')!; const cleanup = logic.attachEvents!(makeElement(), sim as any, pinMap({ SCK: 2, DOUT: 3 })); sim.setPinState.mockClear(); cleanup(); expect(sim.setPinState).toHaveBeenCalledWith(3, true); }); }); // ─── ir-receiver ───────────────────────────────────────────────────────────── describe('ir-receiver — NEC click simulation', () => { it('sets OUT pin HIGH (idle) on attach', () => { const sim = makePinSim(); const logic = PartSimulationRegistry.get('ir-receiver')!; logic.attachEvents!(makeElement(), sim as any, pinMap({ OUT: 5 })); expect(sim.setPinState).toHaveBeenCalledWith(5, true); }); it('registers a click listener on the element', () => { const sim = makePinSim(); const el = makeElement(); const logic = PartSimulationRegistry.get('ir-receiver')!; logic.attachEvents!(el, sim as any, pinMap({ OUT: 5 })); expect(el.addEventListener).toHaveBeenCalledWith('click', expect.any(Function)); }); it('click drives OUT LOW (IR burst start) and later HIGH via setTimeout', () => { const sim = makePinSim(); const el = makeElement(); const logic = PartSimulationRegistry.get('ir-receiver')!; logic.attachEvents!(el, sim as any, pinMap({ OUT: 5 })); const clickCb = (el.addEventListener as ReturnType).mock.calls.find( ([event]) => event === 'click', )?.[1] as (() => void) | undefined; expect(clickCb).toBeDefined(); sim.setPinState.mockClear(); clickCb!(); // First call should drive pin LOW (beginning of 9 ms preamble burst) const firstLow = (sim.setPinState as ReturnType).mock.calls[0]; expect(firstLow).toEqual([5, false]); }); it('NEC sequence produces more than 60 setPinState calls (35+ transitions)', () => { const sim = makePinSim(); const el = makeElement(); const logic = PartSimulationRegistry.get('ir-receiver')!; logic.attachEvents!(el, sim as any, pinMap({ OUT: 5 })); const clickCb = (el.addEventListener as ReturnType).mock.calls.find( ([ev]) => ev === 'click', )?.[1] as () => void; sim.setPinState.mockClear(); clickCb(); // Run all queued timeouts to exhaust the chain vi.runAllTimers(); expect((sim.setPinState as ReturnType).mock.calls.length).toBeGreaterThan(60); }); it('cleanup removes click listener and sets pin HIGH', () => { const sim = makePinSim(); const el = makeElement(); const logic = PartSimulationRegistry.get('ir-receiver')!; const cleanup = logic.attachEvents!(el, sim as any, pinMap({ OUT: 5 })); sim.setPinState.mockClear(); cleanup(); expect(el.removeEventListener).toHaveBeenCalledWith('click', expect.any(Function)); expect(sim.setPinState).toHaveBeenCalledWith(5, true); }); it('no-op when no pin connected (no throw)', () => { const sim = makePinSim(); const logic = PartSimulationRegistry.get('ir-receiver')!; expect(() => { const c = logic.attachEvents!(makeElement(), sim as any, noPins); c(); }).not.toThrow(); }); }); // ─── ir-remote ─────────────────────────────────────────────────────────────── describe('ir-remote — button dispatch', () => { it('registers button-press listener on element', () => { const sim = makePinSim(); const el = makeElement(); const logic = PartSimulationRegistry.get('ir-remote')!; logic.attachEvents!(el, sim as any, noPins); const events = (el.addEventListener as ReturnType).mock.calls.map(([e]) => e); expect(events).toContain('button-press'); }); it('button-press fires ir-signal CustomEvent with address and command', () => { const sim = makePinSim(); const el = makeElement(); const logic = PartSimulationRegistry.get('ir-remote')!; logic.attachEvents!(el, sim as any, noPins); const onButtonPress = (el.addEventListener as ReturnType).mock.calls.find( ([ev]) => ev === 'button-press', )?.[1] as ((e: Event) => void) | undefined; expect(onButtonPress).toBeDefined(); const fakeEvent = new CustomEvent('button-press', { detail: { key: 'power' } }); onButtonPress!(fakeEvent); expect(el.dispatchEvent).toHaveBeenCalledWith( expect.objectContaining({ type: 'ir-signal' }), ); const dispatched = (el.dispatchEvent as ReturnType).mock.calls[0][0] as CustomEvent; expect(dispatched.detail.command).toBe(0x45); // POWER key }); it('drives IR pin if connected', () => { const sim = makePinSim(); const el = makeElement(); const logic = PartSimulationRegistry.get('ir-remote')!; logic.attachEvents!(el, sim as any, pinMap({ IR: 4 })); const onButtonPress = (el.addEventListener as ReturnType).mock.calls.find( ([ev]) => ev === 'button-press', )?.[1] as (e: Event) => void; sim.setPinState.mockClear(); onButtonPress(new CustomEvent('button-press', { detail: { key: 'power' } })); // Should start the NEC pulse sequence (first edge LOW) expect(sim.setPinState).toHaveBeenCalledWith(4, false); }); it('cleanup removes all listeners', () => { const sim = makePinSim(); const el = makeElement(); const logic = PartSimulationRegistry.get('ir-remote')!; const cleanup = logic.attachEvents!(el, sim as any, noPins); cleanup(); expect(el.removeEventListener).toHaveBeenCalledTimes(2); }); }); // ─── microsd-card ───────────────────────────────────────────────────────────── describe('microsd-card — SPI init handshake', () => { it('hooks into simulator.spi.onTransmit', () => { const sim = makeSPISim(); const logic = PartSimulationRegistry.get('microsd-card')!; logic.attachEvents!(makeElement(), sim as any, noPins); expect(sim.spi.onTransmit).toBeTypeOf('function'); }); it('CMD0 (0x40 + 4 zeroes + CRC) returns R1=0x01 (idle)', () => { const sim = makeSPISim(); const logic = PartSimulationRegistry.get('microsd-card')!; logic.attachEvents!(makeElement(), sim as any, noPins); const tx = sim.spi.onTransmit as (b: number) => void; // Send CMD0: [0x40, 0x00, 0x00, 0x00, 0x00, 0x95] [0x40, 0x00, 0x00, 0x00, 0x00, 0x95].forEach(b => tx(b)); // Poll with 0xFF to receive response tx(0xFF); const replies = (sim.spi.completeTransmit as ReturnType).mock.calls.map(([v]) => v); expect(replies).toContain(0x01); }); it('CMD8 returns R7 with echo-back 0x1AA', () => { const sim = makeSPISim(); const logic = PartSimulationRegistry.get('microsd-card')!; logic.attachEvents!(makeElement(), sim as any, noPins); const tx = sim.spi.onTransmit as (b: number) => void; // CMD8: [0x48, 0x00, 0x00, 0x01, 0xAA, 0x87] [0x48, 0x00, 0x00, 0x01, 0xAA, 0x87].forEach(b => tx(b)); // Read 5 bytes of R7 response for (let i = 0; i < 5; i++) tx(0xFF); const replies = (sim.spi.completeTransmit as ReturnType).mock.calls.map(([v]) => v); // R7 = 0x01, 0x00, 0x00, 0x01, 0xAA expect(replies).toContain(0x01); expect(replies).toContain(0xAA); }); it('ACMD41 (CMD55 + CMD41) returns R1=0x00 (ready)', () => { const sim = makeSPISim(); const logic = PartSimulationRegistry.get('microsd-card')!; logic.attachEvents!(makeElement(), sim as any, noPins); const tx = sim.spi.onTransmit as (b: number) => void; [0x77, 0x00, 0x00, 0x00, 0x00, 0x65].forEach(b => tx(b)); // CMD55 tx(0xFF); // poll sim.spi.completeTransmit.mockClear(); [0x69, 0x40, 0x00, 0x00, 0x00, 0x77].forEach(b => tx(b)); // ACMD41 tx(0xFF); const replies = (sim.spi.completeTransmit as ReturnType).mock.calls.map(([v]) => v); expect(replies).toContain(0x00); }); it('0xFF clock bytes return 0xFF (idle) when no pending response', () => { const sim = makeSPISim(); const logic = PartSimulationRegistry.get('microsd-card')!; logic.attachEvents!(makeElement(), sim as any, noPins); const tx = sim.spi.onTransmit as (b: number) => void; tx(0xFF); expect(sim.spi.completeTransmit).toHaveBeenLastCalledWith(0xFF); }); it('cleanup restores previous onTransmit and is callable without SPI', () => { const sim = makeSPISim(); const logic = PartSimulationRegistry.get('microsd-card')!; const cleanup = logic.attachEvents!(makeElement(), sim as any, noPins); expect(() => cleanup()).not.toThrow(); expect(sim.spi.onTransmit).toBeNull(); }); it('no-op when simulator has no spi', () => { const sim = { ...makeSPISim(), spi: null }; const logic = PartSimulationRegistry.get('microsd-card')!; expect(() => { const c = logic.attachEvents!(makeElement(), sim as any, noPins); c(); }).not.toThrow(); }); });