- Add BLEError class with error codes, severity levels, and recovery actions - Create error types for connection, permission, communication, WiFi, and sensor errors - Add user-friendly error messages with localized titles - Implement BLELogger for consistent logging with batch progress tracking - Add parseBLEError utility to parse native BLE errors into typed BLEErrors - Update BLEManager to use new error types with proper logging - Update MockBLEManager to match error handling behavior for consistency - Add comprehensive tests for error handling utilities (41 tests passing) This enables proper error categorization, user-friendly messages, and recovery suggestions for BLE operations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
412 lines
14 KiB
TypeScript
412 lines
14 KiB
TypeScript
import {
|
|
BLEError,
|
|
BLEErrorCode,
|
|
BLEErrorSeverity,
|
|
BLERecoveryAction,
|
|
BLE_ERROR_MESSAGES,
|
|
BLE_RECOVERY_ACTIONS,
|
|
parseBLEError,
|
|
isBLEError,
|
|
getErrorInfo,
|
|
getRecoveryActionLabel,
|
|
BLELogger,
|
|
} from '@/services/ble/errors';
|
|
|
|
describe('BLEError', () => {
|
|
describe('constructor', () => {
|
|
it('creates error with correct code and default message', () => {
|
|
const error = new BLEError(BLEErrorCode.CONNECTION_FAILED);
|
|
|
|
expect(error.code).toBe(BLEErrorCode.CONNECTION_FAILED);
|
|
expect(error.name).toBe('BLEError');
|
|
expect(error.userMessage.title).toBe('Connection Failed');
|
|
expect(error.userMessage.message).toContain('Could not connect');
|
|
expect(error.timestamp).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('creates error with custom message', () => {
|
|
const error = new BLEError(BLEErrorCode.CONNECTION_TIMEOUT, {
|
|
message: 'Custom timeout message',
|
|
});
|
|
|
|
expect(error.message).toBe('Custom timeout message');
|
|
expect(error.userMessage.title).toBe('Connection Timeout');
|
|
});
|
|
|
|
it('creates error with device context', () => {
|
|
const error = new BLEError(BLEErrorCode.DEVICE_NOT_FOUND, {
|
|
deviceId: 'device-123',
|
|
deviceName: 'WP_497_81a14c',
|
|
});
|
|
|
|
expect(error.deviceId).toBe('device-123');
|
|
expect(error.deviceName).toBe('WP_497_81a14c');
|
|
});
|
|
|
|
it('wraps original error', () => {
|
|
const originalError = new Error('Native BLE error');
|
|
const error = new BLEError(BLEErrorCode.COMMAND_FAILED, {
|
|
originalError,
|
|
});
|
|
|
|
expect(error.originalError).toBe(originalError);
|
|
});
|
|
|
|
it('sets correct severity for critical errors', () => {
|
|
const permissionError = new BLEError(BLEErrorCode.PERMISSION_DENIED);
|
|
const bluetoothError = new BLEError(BLEErrorCode.BLUETOOTH_DISABLED);
|
|
|
|
expect(permissionError.severity).toBe(BLEErrorSeverity.CRITICAL);
|
|
expect(bluetoothError.severity).toBe(BLEErrorSeverity.CRITICAL);
|
|
});
|
|
|
|
it('sets correct severity for warning errors', () => {
|
|
const busyError = new BLEError(BLEErrorCode.DEVICE_BUSY);
|
|
const scanError = new BLEError(BLEErrorCode.WIFI_SCAN_IN_PROGRESS);
|
|
|
|
expect(busyError.severity).toBe(BLEErrorSeverity.WARNING);
|
|
expect(scanError.severity).toBe(BLEErrorSeverity.WARNING);
|
|
});
|
|
|
|
it('sets recovery actions from mapping', () => {
|
|
const connectionError = new BLEError(BLEErrorCode.CONNECTION_FAILED);
|
|
const passwordError = new BLEError(BLEErrorCode.WIFI_PASSWORD_INCORRECT);
|
|
|
|
expect(connectionError.recoveryActions).toContain(BLERecoveryAction.RETRY);
|
|
expect(connectionError.recoveryActions).toContain(BLERecoveryAction.MOVE_CLOSER);
|
|
|
|
expect(passwordError.recoveryActions).toContain(BLERecoveryAction.CHECK_WIFI_PASSWORD);
|
|
});
|
|
});
|
|
|
|
describe('toLogString', () => {
|
|
it('formats error for logging with device name', () => {
|
|
const error = new BLEError(BLEErrorCode.CONNECTION_FAILED, {
|
|
deviceName: 'WP_497_81a14c',
|
|
message: 'Connection failed',
|
|
});
|
|
|
|
const logString = error.toLogString();
|
|
|
|
expect(logString).toContain('[BLE]');
|
|
expect(logString).toContain('[BLE_CONNECTION_FAILED]');
|
|
expect(logString).toContain('[WP_497_81a14c]');
|
|
expect(logString).toContain('Connection failed');
|
|
});
|
|
|
|
it('uses deviceId when deviceName is not available', () => {
|
|
const error = new BLEError(BLEErrorCode.COMMAND_TIMEOUT, {
|
|
deviceId: 'device-123',
|
|
});
|
|
|
|
const logString = error.toLogString();
|
|
|
|
expect(logString).toContain('[device-123]');
|
|
});
|
|
});
|
|
|
|
describe('isRetryable', () => {
|
|
it('returns true for retryable errors', () => {
|
|
const connectionError = new BLEError(BLEErrorCode.CONNECTION_FAILED);
|
|
const timeoutError = new BLEError(BLEErrorCode.COMMAND_TIMEOUT);
|
|
|
|
expect(connectionError.isRetryable()).toBe(true);
|
|
expect(timeoutError.isRetryable()).toBe(true);
|
|
});
|
|
|
|
it('returns false for non-retryable errors', () => {
|
|
const cancelledError = new BLEError(BLEErrorCode.OPERATION_CANCELLED);
|
|
const alreadyConnectedError = new BLEError(BLEErrorCode.ALREADY_CONNECTED);
|
|
|
|
expect(cancelledError.isRetryable()).toBe(false);
|
|
expect(alreadyConnectedError.isRetryable()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('isSkippable', () => {
|
|
it('returns true for skippable errors in batch operations', () => {
|
|
const commandError = new BLEError(BLEErrorCode.COMMAND_FAILED);
|
|
const rebootError = new BLEError(BLEErrorCode.SENSOR_REBOOT_FAILED);
|
|
|
|
expect(commandError.isSkippable()).toBe(true);
|
|
expect(rebootError.isSkippable()).toBe(true);
|
|
});
|
|
|
|
it('returns false for non-skippable errors', () => {
|
|
const permissionError = new BLEError(BLEErrorCode.PERMISSION_DENIED);
|
|
const bluetoothError = new BLEError(BLEErrorCode.BLUETOOTH_DISABLED);
|
|
|
|
expect(permissionError.isSkippable()).toBe(false);
|
|
expect(bluetoothError.isSkippable()).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('parseBLEError', () => {
|
|
it('parses permission denied errors', () => {
|
|
const error = new Error('Bluetooth permissions not granted');
|
|
const bleError = parseBLEError(error);
|
|
|
|
expect(bleError.code).toBe(BLEErrorCode.PERMISSION_DENIED);
|
|
});
|
|
|
|
it('parses bluetooth disabled errors', () => {
|
|
const error = new Error('Bluetooth is disabled');
|
|
const bleError = parseBLEError(error);
|
|
|
|
expect(bleError.code).toBe(BLEErrorCode.BLUETOOTH_DISABLED);
|
|
});
|
|
|
|
it('parses timeout errors', () => {
|
|
const error = new Error('Connection timeout');
|
|
const bleError = parseBLEError(error);
|
|
|
|
expect(bleError.code).toBe(BLEErrorCode.CONNECTION_TIMEOUT);
|
|
});
|
|
|
|
it('parses connection in progress errors', () => {
|
|
const error = new Error('Connection already in progress');
|
|
const bleError = parseBLEError(error);
|
|
|
|
expect(bleError.code).toBe(BLEErrorCode.CONNECTION_IN_PROGRESS);
|
|
});
|
|
|
|
it('parses WiFi password incorrect errors', () => {
|
|
const error = new Error('WiFi password is incorrect');
|
|
const bleError = parseBLEError(error);
|
|
|
|
expect(bleError.code).toBe(BLEErrorCode.WIFI_PASSWORD_INCORRECT);
|
|
});
|
|
|
|
it('parses WiFi network not found errors', () => {
|
|
const error = new Error('WiFi network not found');
|
|
const bleError = parseBLEError(error);
|
|
|
|
expect(bleError.code).toBe(BLEErrorCode.WIFI_NETWORK_NOT_FOUND);
|
|
});
|
|
|
|
it('parses device disconnected errors', () => {
|
|
const error = new Error('Device disconnected unexpectedly');
|
|
const bleError = parseBLEError(error);
|
|
|
|
expect(bleError.code).toBe(BLEErrorCode.DEVICE_DISCONNECTED);
|
|
});
|
|
|
|
it('parses cancelled operation errors', () => {
|
|
const error = new Error('Operation was cancelled');
|
|
const bleError = parseBLEError(error);
|
|
|
|
expect(bleError.code).toBe(BLEErrorCode.OPERATION_CANCELLED);
|
|
});
|
|
|
|
it('includes device context when provided', () => {
|
|
const error = new Error('Connection timeout');
|
|
const bleError = parseBLEError(error, {
|
|
deviceId: 'device-123',
|
|
deviceName: 'WP_497_81a14c',
|
|
});
|
|
|
|
expect(bleError.deviceId).toBe('device-123');
|
|
expect(bleError.deviceName).toBe('WP_497_81a14c');
|
|
});
|
|
|
|
it('defaults to unknown error for unrecognized messages', () => {
|
|
const error = new Error('Some random error message');
|
|
const bleError = parseBLEError(error);
|
|
|
|
expect(bleError.code).toBe(BLEErrorCode.UNKNOWN_ERROR);
|
|
});
|
|
|
|
it('handles non-Error objects', () => {
|
|
const bleError = parseBLEError('string error');
|
|
|
|
expect(bleError).toBeInstanceOf(BLEError);
|
|
expect(bleError.code).toBe(BLEErrorCode.UNKNOWN_ERROR);
|
|
});
|
|
});
|
|
|
|
describe('isBLEError', () => {
|
|
it('returns true for BLEError instances', () => {
|
|
const error = new BLEError(BLEErrorCode.CONNECTION_FAILED);
|
|
|
|
expect(isBLEError(error)).toBe(true);
|
|
});
|
|
|
|
it('returns false for regular Error instances', () => {
|
|
const error = new Error('Regular error');
|
|
|
|
expect(isBLEError(error)).toBe(false);
|
|
});
|
|
|
|
it('returns false for non-error objects', () => {
|
|
expect(isBLEError('string')).toBe(false);
|
|
expect(isBLEError(null)).toBe(false);
|
|
expect(isBLEError(undefined)).toBe(false);
|
|
expect(isBLEError({})).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('getErrorInfo', () => {
|
|
it('extracts info from BLEError', () => {
|
|
const error = new BLEError(BLEErrorCode.WIFI_PASSWORD_INCORRECT);
|
|
const info = getErrorInfo(error);
|
|
|
|
expect(info.code).toBe(BLEErrorCode.WIFI_PASSWORD_INCORRECT);
|
|
expect(info.title).toBe('Wrong Password');
|
|
expect(info.message).toContain('incorrect');
|
|
expect(info.severity).toBe(BLEErrorSeverity.ERROR);
|
|
expect(info.recoveryActions).toContain(BLERecoveryAction.CHECK_WIFI_PASSWORD);
|
|
expect(info.isRetryable).toBe(true);
|
|
});
|
|
|
|
it('parses and extracts info from regular Error', () => {
|
|
const error = new Error('Bluetooth is disabled');
|
|
const info = getErrorInfo(error);
|
|
|
|
expect(info.code).toBe(BLEErrorCode.BLUETOOTH_DISABLED);
|
|
expect(info.title).toBe('Bluetooth Disabled');
|
|
expect(info.severity).toBe(BLEErrorSeverity.CRITICAL);
|
|
expect(info.recoveryActions).toContain(BLERecoveryAction.ENABLE_BLUETOOTH);
|
|
});
|
|
});
|
|
|
|
describe('getRecoveryActionLabel', () => {
|
|
it('returns correct labels for all actions', () => {
|
|
expect(getRecoveryActionLabel(BLERecoveryAction.RETRY)).toBe('Retry');
|
|
expect(getRecoveryActionLabel(BLERecoveryAction.SKIP)).toBe('Skip');
|
|
expect(getRecoveryActionLabel(BLERecoveryAction.CANCEL)).toBe('Cancel');
|
|
expect(getRecoveryActionLabel(BLERecoveryAction.ENABLE_BLUETOOTH)).toBe('Enable Bluetooth');
|
|
expect(getRecoveryActionLabel(BLERecoveryAction.GRANT_PERMISSIONS)).toBe('Grant Permission');
|
|
expect(getRecoveryActionLabel(BLERecoveryAction.MOVE_CLOSER)).toBe('Move Closer');
|
|
expect(getRecoveryActionLabel(BLERecoveryAction.CHECK_WIFI_PASSWORD)).toBe('Check Password');
|
|
expect(getRecoveryActionLabel(BLERecoveryAction.CONTACT_SUPPORT)).toBe('Contact Support');
|
|
});
|
|
});
|
|
|
|
describe('BLE_ERROR_MESSAGES', () => {
|
|
it('has messages for all error codes', () => {
|
|
const errorCodes = Object.values(BLEErrorCode);
|
|
|
|
for (const code of errorCodes) {
|
|
expect(BLE_ERROR_MESSAGES[code]).toBeDefined();
|
|
expect(BLE_ERROR_MESSAGES[code].title).toBeTruthy();
|
|
expect(BLE_ERROR_MESSAGES[code].message).toBeTruthy();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('BLE_RECOVERY_ACTIONS', () => {
|
|
it('has recovery actions for all error codes', () => {
|
|
const errorCodes = Object.values(BLEErrorCode);
|
|
|
|
for (const code of errorCodes) {
|
|
expect(BLE_RECOVERY_ACTIONS[code]).toBeDefined();
|
|
expect(Array.isArray(BLE_RECOVERY_ACTIONS[code])).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('BLELogger', () => {
|
|
// Spy on console methods
|
|
const consoleSpy = {
|
|
log: jest.spyOn(console, 'log').mockImplementation(),
|
|
warn: jest.spyOn(console, 'warn').mockImplementation(),
|
|
error: jest.spyOn(console, 'error').mockImplementation(),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
BLELogger.enable();
|
|
});
|
|
|
|
afterAll(() => {
|
|
consoleSpy.log.mockRestore();
|
|
consoleSpy.warn.mockRestore();
|
|
consoleSpy.error.mockRestore();
|
|
});
|
|
|
|
it('logs messages with BLE prefix', () => {
|
|
BLELogger.log('Test message');
|
|
|
|
expect(consoleSpy.log).toHaveBeenCalledWith('[BLE] Test message', '');
|
|
});
|
|
|
|
it('logs warnings with BLE prefix', () => {
|
|
BLELogger.warn('Warning message');
|
|
|
|
expect(consoleSpy.warn).toHaveBeenCalledWith('[BLE] Warning message', '');
|
|
});
|
|
|
|
it('logs errors with BLE prefix', () => {
|
|
BLELogger.error('Error message');
|
|
|
|
expect(consoleSpy.error).toHaveBeenCalledWith('[BLE] Error message', '');
|
|
});
|
|
|
|
it('logs BLEError with formatted string', () => {
|
|
const error = new BLEError(BLEErrorCode.CONNECTION_FAILED, {
|
|
deviceName: 'WP_497',
|
|
});
|
|
BLELogger.error('Failed', error);
|
|
|
|
expect(consoleSpy.error).toHaveBeenCalled();
|
|
const loggedMessage = consoleSpy.error.mock.calls[0][0];
|
|
expect(loggedMessage).toContain('[BLE]');
|
|
expect(loggedMessage).toContain('[BLE_CONNECTION_FAILED]');
|
|
expect(loggedMessage).toContain('[WP_497]');
|
|
});
|
|
|
|
it('does not log when disabled', () => {
|
|
BLELogger.disable();
|
|
BLELogger.log('Should not appear');
|
|
BLELogger.warn('Should not appear');
|
|
BLELogger.error('Should not appear');
|
|
|
|
expect(consoleSpy.log).not.toHaveBeenCalled();
|
|
expect(consoleSpy.warn).not.toHaveBeenCalled();
|
|
expect(consoleSpy.error).not.toHaveBeenCalled();
|
|
});
|
|
|
|
describe('logBatchProgress', () => {
|
|
it('logs batch progress with success indicator', () => {
|
|
BLELogger.logBatchProgress(1, 5, 'WP_497', 'connected', true);
|
|
|
|
expect(consoleSpy.log).toHaveBeenCalled();
|
|
const loggedMessage = consoleSpy.log.mock.calls[0][0];
|
|
expect(loggedMessage).toContain('[1/5]');
|
|
expect(loggedMessage).toContain('WP_497');
|
|
expect(loggedMessage).toContain('✓');
|
|
expect(loggedMessage).toContain('connected');
|
|
});
|
|
|
|
it('logs batch progress with failure indicator', () => {
|
|
BLELogger.logBatchProgress(2, 5, 'WP_498', 'connection failed', false);
|
|
|
|
expect(consoleSpy.log).toHaveBeenCalled();
|
|
const loggedMessage = consoleSpy.log.mock.calls[0][0];
|
|
expect(loggedMessage).toContain('[2/5]');
|
|
expect(loggedMessage).toContain('✗');
|
|
});
|
|
|
|
it('logs batch progress with duration', () => {
|
|
BLELogger.logBatchProgress(3, 5, 'WP_499', 'SUCCESS', true, 8500);
|
|
|
|
expect(consoleSpy.log).toHaveBeenCalled();
|
|
const loggedMessage = consoleSpy.log.mock.calls[0][0];
|
|
expect(loggedMessage).toContain('(8.5s)');
|
|
});
|
|
});
|
|
|
|
describe('logBatchSummary', () => {
|
|
it('logs batch summary with counts', () => {
|
|
BLELogger.logBatchSummary(5, 4, 1, 45000);
|
|
|
|
expect(consoleSpy.log).toHaveBeenCalled();
|
|
const loggedMessage = consoleSpy.log.mock.calls[0][0];
|
|
expect(loggedMessage).toContain('4/5 succeeded');
|
|
expect(loggedMessage).toContain('1 failed');
|
|
expect(loggedMessage).toContain('45.0s');
|
|
});
|
|
});
|
|
});
|