// Tests for WebBLEManager import { BLEErrorCode } from '../errors'; import { BLEConnectionState } from '../types'; // Mock the webBluetooth module before importing WebBLEManager jest.mock('../webBluetooth', () => ({ checkWebBluetoothSupport: jest.fn(() => ({ supported: true, browserName: 'Chrome', browserVersion: '120', })), getUnsupportedBrowserMessage: jest.fn(() => ({ title: 'Browser Not Supported', message: 'Safari does not support Web Bluetooth.', suggestion: 'Please use Chrome.', })), })); import { WebBLEManager } from '../WebBLEManager'; import { checkWebBluetoothSupport } from '../webBluetooth'; // Mock Web Bluetooth API const mockDevice: any = { id: 'device-123', name: 'WP_497_81a14c', gatt: { connected: false, connect: jest.fn(), disconnect: jest.fn(), getPrimaryService: jest.fn(), }, addEventListener: jest.fn(), removeEventListener: jest.fn(), }; const mockCharacteristic: any = { startNotifications: jest.fn(), stopNotifications: jest.fn(), writeValueWithResponse: jest.fn(), addEventListener: jest.fn(), removeEventListener: jest.fn(), }; const mockService: any = { getCharacteristic: jest.fn(() => Promise.resolve(mockCharacteristic)), }; const mockServer: any = { device: mockDevice, connected: true, connect: jest.fn(() => Promise.resolve(mockServer)), disconnect: jest.fn(), getPrimaryService: jest.fn(() => Promise.resolve(mockService)), }; describe('WebBLEManager', () => { let manager: WebBLEManager; const originalNavigator = global.navigator; beforeEach(() => { jest.clearAllMocks(); // Reset mock implementation (checkWebBluetoothSupport as jest.Mock).mockReturnValue({ supported: true, browserName: 'Chrome', browserVersion: '120', }); // Mock navigator.bluetooth Object.defineProperty(global, 'navigator', { value: { ...originalNavigator, bluetooth: { requestDevice: jest.fn(() => Promise.resolve(mockDevice)), }, }, writable: true, configurable: true, }); // Reset device mocks mockDevice.gatt = { connected: false, connect: jest.fn(() => { mockDevice.gatt.connected = true; return Promise.resolve(mockServer); }), disconnect: jest.fn(() => { mockDevice.gatt.connected = false; }), getPrimaryService: jest.fn(() => Promise.resolve(mockService)), }; mockServer.connected = true; mockServer.getPrimaryService.mockResolvedValue(mockService); mockCharacteristic.startNotifications.mockResolvedValue(mockCharacteristic); manager = new WebBLEManager(); }); afterEach(() => { Object.defineProperty(global, 'navigator', { value: originalNavigator, writable: true, configurable: true, }); }); describe('constructor', () => { it('should create manager when Web Bluetooth is supported', () => { expect(manager).toBeDefined(); }); it('should warn when Web Bluetooth is not supported', () => { (checkWebBluetoothSupport as jest.Mock).mockReturnValue({ supported: false, browserName: 'Safari', browserVersion: '17', reason: 'unsupported_browser', }); const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); new WebBLEManager(); expect(consoleSpy).toHaveBeenCalled(); consoleSpy.mockRestore(); }); }); describe('scanDevices', () => { it('should return device when user selects one', async () => { const devices = await manager.scanDevices(); expect(devices).toHaveLength(1); expect(devices[0].id).toBe('device-123'); expect(devices[0].name).toBe('WP_497_81a14c'); expect(devices[0].wellId).toBe(497); expect(devices[0].mac).toBe('81A14C'); }); it('should return empty array when user cancels', async () => { (navigator.bluetooth!.requestDevice as jest.Mock).mockRejectedValue( new Error('User cancelled') ); const devices = await manager.scanDevices(); expect(devices).toHaveLength(0); }); it('should throw BLEError for permission denied', async () => { const permissionError = new Error('Permission denied'); (permissionError as any).name = 'NotAllowedError'; (navigator.bluetooth!.requestDevice as jest.Mock).mockRejectedValue(permissionError); await expect(manager.scanDevices()).rejects.toMatchObject({ code: BLEErrorCode.PERMISSION_DENIED, }); }); it('should throw BLEError when Web Bluetooth not supported', async () => { (checkWebBluetoothSupport as jest.Mock).mockReturnValue({ supported: false, browserName: 'Safari', browserVersion: '17', reason: 'unsupported_browser', }); const newManager = new WebBLEManager(); await expect(newManager.scanDevices()).rejects.toMatchObject({ code: BLEErrorCode.BLUETOOTH_DISABLED, }); }); }); describe('connectDevice', () => { beforeEach(async () => { // First scan to get device reference await manager.scanDevices(); }); it('should connect to device successfully', async () => { const result = await manager.connectDevice('device-123'); expect(result).toBe(true); expect(manager.getConnectionState('device-123')).toBe(BLEConnectionState.READY); expect(mockDevice.gatt.connect).toHaveBeenCalled(); expect(mockCharacteristic.startNotifications).toHaveBeenCalled(); }); it('should throw error for unknown device', async () => { const result = await manager.connectDevice('unknown-device'); expect(result).toBe(false); }); it('should return true if already connected', async () => { // First connection await manager.connectDevice('device-123'); // Second connection attempt const result = await manager.connectDevice('device-123'); expect(result).toBe(true); }); it('should emit state_changed event with ready state on successful connection', async () => { const listener = jest.fn(); manager.addEventListener(listener); await manager.connectDevice('device-123'); expect(listener).toHaveBeenCalledWith( 'device-123', 'state_changed', expect.objectContaining({ state: 'ready' }) ); }); }); describe('disconnectDevice', () => { beforeEach(async () => { await manager.scanDevices(); await manager.connectDevice('device-123'); }); it('should disconnect device', async () => { await manager.disconnectDevice('device-123'); expect(manager.getConnectionState('device-123')).toBe(BLEConnectionState.DISCONNECTED); expect(manager.isDeviceConnected('device-123')).toBe(false); }); it('should emit disconnected event', async () => { const listener = jest.fn(); manager.addEventListener(listener); await manager.disconnectDevice('device-123'); expect(listener).toHaveBeenCalledWith( 'device-123', 'disconnected', undefined ); }); }); describe('isDeviceConnected', () => { it('should return false for unknown device', () => { expect(manager.isDeviceConnected('unknown')).toBe(false); }); it('should return true for connected device', async () => { await manager.scanDevices(); await manager.connectDevice('device-123'); expect(manager.isDeviceConnected('device-123')).toBe(true); }); }); describe('event listeners', () => { it('should add and remove event listeners', () => { const listener = jest.fn(); manager.addEventListener(listener); // Trigger an event by changing state (manager as any).emitEvent('device-123', 'ready', {}); expect(listener).toHaveBeenCalledTimes(1); manager.removeEventListener(listener); (manager as any).emitEvent('device-123', 'ready', {}); expect(listener).toHaveBeenCalledTimes(1); // Still 1, not called again }); it('should not add duplicate listeners', () => { const listener = jest.fn(); manager.addEventListener(listener); manager.addEventListener(listener); (manager as any).emitEvent('device-123', 'ready', {}); expect(listener).toHaveBeenCalledTimes(1); }); }); describe('cleanup', () => { it('should disconnect all devices and clear state', async () => { await manager.scanDevices(); await manager.connectDevice('device-123'); await manager.cleanup(); expect(manager.getAllConnections().size).toBe(0); expect(manager.isDeviceConnected('device-123')).toBe(false); }); }); describe('reconnect functionality', () => { it('should set and get reconnect config', () => { manager.setReconnectConfig({ maxAttempts: 5 }); const config = manager.getReconnectConfig(); expect(config.maxAttempts).toBe(5); }); it('should enable and disable auto reconnect', async () => { await manager.scanDevices(); await manager.connectDevice('device-123'); manager.enableAutoReconnect('device-123', 'Test Device'); let state = manager.getReconnectState('device-123'); expect(state).toBeDefined(); expect(state?.deviceName).toBe('Test Device'); manager.disableAutoReconnect('device-123'); state = manager.getReconnectState('device-123'); expect(state).toBeUndefined(); }); it('should cancel reconnect', async () => { await manager.scanDevices(); manager.enableAutoReconnect('device-123', 'Test Device'); // Set as reconnecting (manager as any).reconnectStates.set('device-123', { deviceId: 'device-123', deviceName: 'Test Device', attempts: 1, lastAttemptTime: Date.now(), isReconnecting: true, }); manager.cancelReconnect('device-123'); const state = manager.getReconnectState('device-123'); expect(state?.isReconnecting).toBe(false); }); }); describe('bulk operations', () => { beforeEach(async () => { await manager.scanDevices(); await manager.connectDevice('device-123'); }); it('should bulk disconnect devices', async () => { const results = await manager.bulkDisconnect(['device-123']); expect(results).toHaveLength(1); expect(results[0].success).toBe(true); expect(results[0].deviceId).toBe('device-123'); }); it('should handle errors in bulk disconnect', async () => { const results = await manager.bulkDisconnect(['unknown-device']); expect(results).toHaveLength(1); expect(results[0].success).toBe(true); // disconnect on unknown is no-op }); }); });