/** * Property-Based Tests for Connection Management * * Tests correctness properties related to bluetooth connection management, * including auto-reconnection behavior. * * Uses fast-check for property-based testing. */ import * as fc from 'fast-check'; import { BluetoothPrinterManager } from '../js/bluetooth_printer_manager.js'; describe('Connection Management Properties', () => { // Ensure navigator.bluetooth is always available beforeAll(() => { if (!global.navigator) { global.navigator = {}; } if (!global.navigator.bluetooth) { global.navigator.bluetooth = { requestDevice: async () => { throw { name: 'NotFoundError' }; }, getDevices: async () => { return []; } }; } }); /** * Mock Web Bluetooth API * Creates a mock bluetooth device and GATT server for testing */ class MockBluetoothDevice { constructor(id, name, shouldFailConnection = false, shouldDisconnect = false) { this.id = id; this.name = name; this.shouldFailConnection = shouldFailConnection; this.shouldDisconnect = shouldDisconnect; this.disconnectListeners = []; this.gatt = { connected: false, connect: async () => { if (this.shouldFailConnection) { throw new Error('Connection failed'); } this.gatt.connected = true; return this.gatt; }, disconnect: async () => { this.gatt.connected = false; this.triggerDisconnect(); }, getPrimaryService: async (uuid) => { return { getCharacteristic: async (charUuid) => { return { writeValue: async (data) => { return true; } }; } }; } }; } addEventListener(event, callback) { if (event === 'gattserverdisconnected') { this.disconnectListeners.push(callback); } } triggerDisconnect() { this.disconnectListeners.forEach(listener => listener()); } } /** * Setup mock navigator.bluetooth */ const setupBluetoothMock = (devices = []) => { global.navigator = { bluetooth: { requestDevice: async (options) => { if (devices.length === 0) { throw { name: 'NotFoundError' }; } return devices[0]; }, getDevices: async () => { return devices; } } }; }; /** * Teardown mock - just clear the devices, keep navigator */ const teardownBluetoothMock = () => { // Don't delete navigator, just reset it to empty state if (global.navigator && global.navigator.bluetooth) { global.navigator.bluetooth.requestDevice = async () => { throw { name: 'NotFoundError' }; }; global.navigator.bluetooth.getDevices = async () => { return []; }; } }; /** * Generator for device IDs */ const deviceIdGenerator = () => { return fc.uuid(); }; /** * Generator for device names */ const deviceNameGenerator = () => { return fc.string({ minLength: 1, maxLength: 50 }); }; /** * Feature: pos-bluetooth-thermal-printer, Property 5: Auto-reconnection on connection drop * * Property: For any bluetooth connection drop event during an active session, * the system should initiate automatic reconnection attempts * * Validates: Requirements 2.3 * * This test verifies that: * 1. When a connection drops, auto-reconnection is triggered * 2. The system attempts reconnection with exponential backoff * 3. Reconnection events are emitted properly * 4. The connection status is updated appropriately */ test('Property 5: Auto-reconnection on connection drop', async () => { await fc.assert( fc.asyncProperty( deviceIdGenerator(), deviceNameGenerator(), async (deviceId, deviceName) => { // Setup: Create a mock device that will successfully connect const mockDevice = new MockBluetoothDevice(deviceId, deviceName, false, false); setupBluetoothMock([mockDevice]); const manager = new BluetoothPrinterManager(); // Track reconnection events const reconnectionAttempts = []; let reconnectionSuccess = false; let reconnectionFailure = false; let autoReconnectStarted = false; manager.addEventListener('reconnection-attempt', (data) => { reconnectionAttempts.push(data); autoReconnectStarted = true; }); manager.addEventListener('reconnection-success', () => { reconnectionSuccess = true; }); manager.addEventListener('reconnection-failure', () => { reconnectionFailure = true; }); // Step 1: Connect to the device await manager.connectToPrinter(mockDevice); // Verify initial connection expect(manager.getConnectionStatus()).toBe('connected'); expect(manager.reconnectAttempts).toBe(0); // Step 2: Simulate connection drop by triggering disconnect event // Reset the mock to allow reconnection mockDevice.gatt.connected = false; mockDevice.triggerDisconnect(); // Wait for auto-reconnection to start and complete // Use a polling approach with shorter timeout let waited = 0; const maxWait = 3000; // 3 seconds max const pollInterval = 100; while (!autoReconnectStarted && waited < maxWait) { await new Promise(resolve => setTimeout(resolve, pollInterval)); waited += pollInterval; } // Give it a bit more time to complete await new Promise(resolve => setTimeout(resolve, 500)); // Step 3: Verify auto-reconnection was initiated expect(autoReconnectStarted).toBe(true); expect(reconnectionAttempts.length).toBeGreaterThan(0); // Step 4: Verify reconnection succeeded expect(manager.getConnectionStatus()).toBe('connected'); expect(reconnectionSuccess).toBe(true); expect(reconnectionFailure).toBe(false); // Step 5: Verify the first attempt has correct structure if (reconnectionAttempts.length > 0) { expect(reconnectionAttempts[0]).toHaveProperty('attempt'); expect(reconnectionAttempts[0]).toHaveProperty('maxAttempts'); expect(reconnectionAttempts[0].attempt).toBeGreaterThan(0); expect(reconnectionAttempts[0].maxAttempts).toBe(3); } // Cleanup manager.setAutoReconnect(false); // Disable to prevent reconnection during disconnect await manager.disconnect(); return true; } ), { numRuns: 5 } // Reduced runs to stay within timeout ); }, 60000); // 60 second timeout for this test /** * Additional property: Auto-reconnection respects exponential backoff * * Verifies that reconnection attempts use exponential backoff delays */ test('Property: Auto-reconnection uses exponential backoff', async () => { await fc.assert( fc.asyncProperty( deviceIdGenerator(), deviceNameGenerator(), async (deviceId, deviceName) => { // Skip empty device names as they can cause issues fc.pre(deviceName.trim().length > 0); // Setup: Create a mock device that will fail first attempts then succeed let connectionAttempts = 0; const mockDevice = new MockBluetoothDevice(deviceId, deviceName, false, false); // Override connect to fail first 2 times const originalConnect = mockDevice.gatt.connect.bind(mockDevice.gatt); mockDevice.gatt.connect = async () => { connectionAttempts++; if (connectionAttempts <= 3) { // Fail during initial connect throw new Error('Connection failed'); } mockDevice.gatt.connected = true; return mockDevice.gatt; }; setupBluetoothMock([mockDevice]); const manager = new BluetoothPrinterManager(); // Track timing of reconnection attempts const attemptTimestamps = []; manager.addEventListener('reconnection-attempt', () => { attemptTimestamps.push(Date.now()); }); // Reset connection attempts for reconnection phase connectionAttempts = 0; // Override to succeed on 3rd attempt during reconnection mockDevice.gatt.connect = async () => { connectionAttempts++; if (connectionAttempts <= 2) { throw new Error('Connection failed'); } mockDevice.gatt.connected = true; return mockDevice.gatt; }; // Manually set up device to simulate previous connection manager.device = mockDevice; manager.autoReconnectEnabled = true; // Trigger auto-reconnection const result = await manager.autoReconnect(); // Verify reconnection succeeded expect(result).toBe(true); // Verify we had multiple attempts expect(attemptTimestamps.length).toBeGreaterThanOrEqual(2); // Verify exponential backoff (delays should increase) if (attemptTimestamps.length >= 3) { const delay1 = attemptTimestamps[1] - attemptTimestamps[0]; const delay2 = attemptTimestamps[2] - attemptTimestamps[1]; // Second delay should be roughly double the first (with some tolerance) // Expected: 1000ms, 2000ms, 4000ms expect(delay2).toBeGreaterThan(delay1 * 1.5); } // Cleanup manager.setAutoReconnect(false); return true; } ), { numRuns: 5 } // Reduced runs due to long delays ); }, 60000); // 60 second timeout /** * Additional property: Auto-reconnection can be disabled * * Verifies that when auto-reconnect is disabled, no reconnection attempts occur */ test('Property: Auto-reconnection can be disabled', async () => { await fc.assert( fc.asyncProperty( deviceIdGenerator(), deviceNameGenerator(), async (deviceId, deviceName) => { const mockDevice = new MockBluetoothDevice(deviceId, deviceName, false, false); setupBluetoothMock([mockDevice]); const manager = new BluetoothPrinterManager(); // Disable auto-reconnect BEFORE connecting manager.setAutoReconnect(false); let reconnectionAttempted = false; manager.addEventListener('reconnection-attempt', () => { reconnectionAttempted = true; }); // Connect await manager.connectToPrinter(mockDevice); // Simulate disconnect mockDevice.gatt.connected = false; mockDevice.triggerDisconnect(); // Wait to see if reconnection is attempted await new Promise(resolve => setTimeout(resolve, 1000)); // Verify no reconnection was attempted expect(reconnectionAttempted).toBe(false); expect(manager.getConnectionStatus()).toBe('disconnected'); return true; } ), { numRuns: 10 } ); }, 30000); /** * Additional property: Failed reconnection after max attempts * * Verifies that after maximum reconnection attempts, the system gives up */ test('Property: Reconnection fails after max attempts', async () => { await fc.assert( fc.asyncProperty( deviceIdGenerator(), deviceNameGenerator(), async (deviceId, deviceName) => { // Setup: Create a mock device that always fails connection const mockDevice = new MockBluetoothDevice(deviceId, deviceName, true, false); // Override to always fail mockDevice.gatt.connect = async () => { throw new Error('Connection failed'); }; setupBluetoothMock([mockDevice]); const manager = new BluetoothPrinterManager(); let reconnectionAttempts = 0; let reconnectionFailure = false; manager.addEventListener('reconnection-attempt', () => { reconnectionAttempts++; }); manager.addEventListener('reconnection-failure', () => { reconnectionFailure = true; }); // We need to manually set up the device since initial connection will fail // Simulate that we had a previous successful connection manager.device = mockDevice; manager.autoReconnectEnabled = true; // Trigger auto-reconnection const result = await manager.autoReconnect(); // Verify reconnection failed expect(result).toBe(false); expect(reconnectionFailure).toBe(true); expect(reconnectionAttempts).toBe(3); // Should have tried 3 times expect(manager.getConnectionStatus()).toBe('error'); return true; } ), { numRuns: 10 } ); }, 60000); /** * Feature: pos-bluetooth-thermal-printer, Property 4: Auto-connection on session start * * Property: For any POS session opened with a configured bluetooth printer, * the system should attempt to establish a connection automatically * * Validates: Requirements 2.1 * * This test verifies that: * 1. When a session starts with a configured printer, connection is attempted * 2. The connection attempt uses the stored configuration * 3. The system properly handles both successful and failed connection attempts * 4. The connection status is updated appropriately */ test('Property 4: Auto-connection on session start', async () => { await fc.assert( fc.asyncProperty( deviceIdGenerator(), deviceNameGenerator(), fc.boolean(), // Whether connection should succeed async (deviceId, deviceName, shouldSucceed) => { // Skip empty device names fc.pre(deviceName.trim().length > 0); // Setup: Create a mock device const mockDevice = new MockBluetoothDevice( deviceId, deviceName, !shouldSucceed, // shouldFailConnection false ); setupBluetoothMock([mockDevice]); // Create a mock printer configuration (simulating what would be in local storage) const printerConfig = { deviceId: deviceId, deviceName: deviceName, macAddress: '00:11:22:33:44:55', lastConnected: Date.now(), settings: { characterSet: 'CP437', paperWidth: 48, autoReconnect: true, timeout: 10000 } }; const manager = new BluetoothPrinterManager(); // Track connection attempts let connectionAttempted = false; let connectionStatusChanges = []; manager.addEventListener('connection-status-changed', (data) => { connectionStatusChanges.push(data.newStatus); }); // Simulate session start: attempt to connect to configured printer try { connectionAttempted = true; await manager.connectToPrinter(mockDevice); // If we get here, connection succeeded if (shouldSucceed) { // Verify connection is established expect(manager.getConnectionStatus()).toBe('connected'); expect(connectionStatusChanges).toContain('connecting'); expect(connectionStatusChanges).toContain('connected'); // Verify device information is stored const info = manager.getConnectionInfo(); expect(info.deviceName).toBe(deviceName); expect(info.deviceId).toBe(deviceId); expect(info.status).toBe('connected'); // Cleanup await manager.disconnect(); } else { // Should not reach here if connection was supposed to fail expect(shouldSucceed).toBe(true); // This will fail the test } } catch (error) { // Connection failed if (!shouldSucceed) { // Expected failure expect(connectionAttempted).toBe(true); expect(manager.getConnectionStatus()).toBe('error'); expect(connectionStatusChanges).toContain('connecting'); expect(connectionStatusChanges).toContain('error'); } else { // Unexpected failure throw error; } } // Verify that connection was attempted expect(connectionAttempted).toBe(true); return true; } ), { numRuns: 20 } // Run more iterations to test both success and failure paths ); }, 60000); /** * Additional property: Auto-connection uses stored configuration * * Verifies that auto-connection on session start uses the correct device * from the stored configuration */ test('Property: Auto-connection uses stored configuration', async () => { await fc.assert( fc.asyncProperty( deviceIdGenerator(), deviceNameGenerator(), async (deviceId, deviceName) => { // Skip empty device names fc.pre(deviceName.trim().length > 0); // Setup: Create multiple mock devices const targetDevice = new MockBluetoothDevice(deviceId, deviceName, false, false); const otherDevice = new MockBluetoothDevice('other-id', 'Other Printer', false, false); setupBluetoothMock([targetDevice, otherDevice]); const manager = new BluetoothPrinterManager(); // Simulate session start with configuration pointing to specific device await manager.connectToPrinter(targetDevice); // Verify the correct device was connected const info = manager.getConnectionInfo(); expect(info.deviceId).toBe(deviceId); expect(info.deviceName).toBe(deviceName); expect(info.status).toBe('connected'); // Verify it's not connected to the other device expect(info.deviceId).not.toBe('other-id'); expect(info.deviceName).not.toBe('Other Printer'); // Cleanup await manager.disconnect(); return true; } ), { numRuns: 10 } ); }, 30000); /** * Additional property: Session start handles missing device gracefully * * Verifies that when a configured device is not available, * the system handles it gracefully without blocking session start */ test('Property: Session start handles missing device gracefully', async () => { await fc.assert( fc.asyncProperty( deviceIdGenerator(), deviceNameGenerator(), async (deviceId, deviceName) => { // Skip empty device names fc.pre(deviceName.trim().length > 0); // Setup: No devices available setupBluetoothMock([]); const manager = new BluetoothPrinterManager(); // Attempt to connect to a device that doesn't exist let errorThrown = false; let errorType = null; try { await manager.connectToPrinter(deviceId); } catch (error) { errorThrown = true; errorType = error.name; } // Verify error was thrown expect(errorThrown).toBe(true); expect(errorType).toBe('DeviceNotFoundError'); // Verify status is error expect(manager.getConnectionStatus()).toBe('error'); // Verify session can continue (no hanging state) const info = manager.getConnectionInfo(); expect(info.status).toBe('error'); expect(info.lastError).toBeTruthy(); return true; } ), { numRuns: 10 } ); }, 30000); });