/** * Tests for BLE Connection State Machine */ import { MockBLEManager } from '../MockBLEManager'; import { BLEConnectionState, BLEConnectionEvent } from '../types'; describe('BLE Connection State Machine', () => { let manager: MockBLEManager; beforeEach(() => { manager = new MockBLEManager(); }); afterEach(async () => { await manager.cleanup(); }); describe('State Transitions', () => { it('should start in DISCONNECTED state', () => { const state = manager.getConnectionState('test-device'); expect(state).toBe(BLEConnectionState.DISCONNECTED); }); it('should transition through states during connection', async () => { const states: BLEConnectionState[] = []; const listener = (deviceId: string, event: BLEConnectionEvent, data?: any) => { if (event === 'state_changed') { states.push(data.state); } }; manager.addEventListener(listener); await manager.connectDevice('test-device'); // Should go through: CONNECTING -> CONNECTED -> DISCOVERING -> READY expect(states).toContain(BLEConnectionState.CONNECTING); expect(states).toContain(BLEConnectionState.CONNECTED); expect(states).toContain(BLEConnectionState.DISCOVERING); expect(states).toContain(BLEConnectionState.READY); // Final state should be READY expect(manager.getConnectionState('test-device')).toBe(BLEConnectionState.READY); }); it('should transition to DISCONNECTING then DISCONNECTED on disconnect', async () => { const states: BLEConnectionState[] = []; const listener = (deviceId: string, event: BLEConnectionEvent, data?: any) => { if (event === 'state_changed') { states.push(data.state); } }; manager.addEventListener(listener); await manager.connectDevice('test-device'); states.length = 0; // Clear connection states await manager.disconnectDevice('test-device'); expect(states).toContain(BLEConnectionState.DISCONNECTING); expect(states).toContain(BLEConnectionState.DISCONNECTED); expect(manager.getConnectionState('test-device')).toBe(BLEConnectionState.DISCONNECTED); }); }); describe('Event Emission', () => { it('should emit ready event when connection completes', async () => { let readyEmitted = false; const listener = (deviceId: string, event: BLEConnectionEvent) => { if (event === 'ready') { readyEmitted = true; } }; manager.addEventListener(listener); await manager.connectDevice('test-device'); expect(readyEmitted).toBe(true); }); it('should emit disconnected event on disconnect', async () => { let disconnectedEmitted = false; const listener = (deviceId: string, event: BLEConnectionEvent) => { if (event === 'disconnected') { disconnectedEmitted = true; } }; manager.addEventListener(listener); await manager.connectDevice('test-device'); await manager.disconnectDevice('test-device'); expect(disconnectedEmitted).toBe(true); }); it('should emit state_changed event on each state transition', async () => { let stateChangedCount = 0; const listener = (deviceId: string, event: BLEConnectionEvent) => { if (event === 'state_changed') { stateChangedCount++; } }; manager.addEventListener(listener); await manager.connectDevice('test-device'); // Should have multiple state changes during connection expect(stateChangedCount).toBeGreaterThanOrEqual(4); }); }); describe('Multiple Devices', () => { it('should track state for multiple devices independently', async () => { await manager.connectDevice('device-1'); await manager.connectDevice('device-2'); expect(manager.getConnectionState('device-1')).toBe(BLEConnectionState.READY); expect(manager.getConnectionState('device-2')).toBe(BLEConnectionState.READY); await manager.disconnectDevice('device-1'); expect(manager.getConnectionState('device-1')).toBe(BLEConnectionState.DISCONNECTED); expect(manager.getConnectionState('device-2')).toBe(BLEConnectionState.READY); }); it('should emit events with correct deviceId', async () => { const events: Array<{ deviceId: string; event: BLEConnectionEvent }> = []; const listener = (deviceId: string, event: BLEConnectionEvent) => { events.push({ deviceId, event }); }; manager.addEventListener(listener); await manager.connectDevice('device-1'); await manager.connectDevice('device-2'); const device1Events = events.filter(e => e.deviceId === 'device-1'); const device2Events = events.filter(e => e.deviceId === 'device-2'); expect(device1Events.length).toBeGreaterThan(0); expect(device2Events.length).toBeGreaterThan(0); }); }); describe('Connection Info', () => { it('should store connection metadata', async () => { await manager.connectDevice('test-device'); const connections = manager.getAllConnections(); const deviceConnection = connections.get('test-device'); expect(deviceConnection).toBeDefined(); expect(deviceConnection?.deviceId).toBe('test-device'); expect(deviceConnection?.state).toBe(BLEConnectionState.READY); expect(deviceConnection?.connectedAt).toBeDefined(); expect(deviceConnection?.lastActivity).toBeDefined(); }); it('should update lastActivity on state changes', async () => { await manager.connectDevice('test-device'); const connections1 = manager.getAllConnections(); const firstActivity = connections1.get('test-device')?.lastActivity; // Wait a bit and disconnect await new Promise(resolve => setTimeout(resolve, 100)); await manager.disconnectDevice('test-device'); const connections2 = manager.getAllConnections(); const secondActivity = connections2.get('test-device')?.lastActivity; expect(secondActivity).toBeGreaterThan(firstActivity!); }); }); describe('Event Listeners', () => { it('should add and remove listeners', () => { const listener1 = jest.fn(); const listener2 = jest.fn(); manager.addEventListener(listener1); manager.addEventListener(listener2); manager.removeEventListener(listener1); // Trigger an event manager.connectDevice('test-device'); // listener2 should be called, listener1 should not expect(listener2).toHaveBeenCalled(); // Note: In real implementation, this would work, but here we need to wait }); it('should not add duplicate listeners', async () => { let callCount = 0; const listener = () => { callCount++; }; manager.addEventListener(listener); manager.addEventListener(listener); // Try to add again await manager.connectDevice('test-device'); // Each event should only increment once // (multiple state changes will still cause multiple calls) const expectedCalls = 4; // CONNECTING, CONNECTED, DISCOVERING, READY expect(callCount).toBeLessThan(expectedCalls * 2); // Not doubled }); it('should handle listener errors gracefully', async () => { const errorListener = () => { throw new Error('Listener error'); }; const goodListener = jest.fn(); manager.addEventListener(errorListener); manager.addEventListener(goodListener); await manager.connectDevice('test-device'); // Good listener should still be called despite error listener expect(goodListener).toHaveBeenCalled(); }); }); describe('Cleanup', () => { it('should disconnect all devices on cleanup', async () => { await manager.connectDevice('device-1'); await manager.connectDevice('device-2'); await manager.connectDevice('device-3'); expect(manager.getConnectionState('device-1')).toBe(BLEConnectionState.READY); expect(manager.getConnectionState('device-2')).toBe(BLEConnectionState.READY); expect(manager.getConnectionState('device-3')).toBe(BLEConnectionState.READY); await manager.cleanup(); expect(manager.getConnectionState('device-1')).toBe(BLEConnectionState.DISCONNECTED); expect(manager.getConnectionState('device-2')).toBe(BLEConnectionState.DISCONNECTED); expect(manager.getConnectionState('device-3')).toBe(BLEConnectionState.DISCONNECTED); }); it('should clear all connection states on cleanup', async () => { await manager.connectDevice('device-1'); await manager.connectDevice('device-2'); let connections = manager.getAllConnections(); expect(connections.size).toBeGreaterThan(0); await manager.cleanup(); connections = manager.getAllConnections(); expect(connections.size).toBe(0); }); it('should clear all event listeners on cleanup', async () => { const listener = jest.fn(); manager.addEventListener(listener); await manager.cleanup(); // Try to trigger events await manager.connectDevice('test-device'); // Listener should not be called after cleanup // (Will be called during cleanup disconnect, but not after) const callsBeforeCleanup = listener.mock.calls.length; await manager.connectDevice('another-device'); expect(listener.mock.calls.length).toBe(callsBeforeCleanup); }); }); describe('Edge Cases', () => { it('should handle disconnect of non-connected device', async () => { // Should not throw await expect(manager.disconnectDevice('non-existent')).resolves.not.toThrow(); expect(manager.getConnectionState('non-existent')).toBe(BLEConnectionState.DISCONNECTED); }); it('should handle reconnection to same device', async () => { await manager.connectDevice('test-device'); await manager.disconnectDevice('test-device'); await manager.connectDevice('test-device'); expect(manager.getConnectionState('test-device')).toBe(BLEConnectionState.READY); }); it('should preserve connection info across state changes', async () => { await manager.connectDevice('test-device'); const connections1 = manager.getAllConnections(); const connectedAt = connections1.get('test-device')?.connectedAt; // Trigger state change without disconnecting // (In real implementation, this would be a command send) await manager.sendCommand('test-device', 'test'); const connections2 = manager.getAllConnections(); const stillConnectedAt = connections2.get('test-device')?.connectedAt; expect(stillConnectedAt).toBe(connectedAt); }); }); });