WellNuo/services/ble/__tests__/WebBLEManager.test.ts
Sergei c2064a76eb Add Web Bluetooth support for browser-based sensor setup
- Add webBluetooth.ts with browser detection and compatibility checks
- Add WebBLEManager implementing IBLEManager for Web Bluetooth API
- Add BrowserNotSupported component showing clear error for Safari/Firefox
- Update services/ble/index.ts to use WebBLEManager on web platform
- Add comprehensive tests for browser detection and WebBLEManager

Works in Chrome/Edge/Opera, shows user-friendly error in unsupported browsers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-01 10:48:01 -08:00

358 lines
10 KiB
TypeScript

// 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
});
});
});