610 lines
24 KiB
JavaScript
610 lines
24 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|