pos_bluetooth_thermal_printer/static/src/tests/connection_properties.test.js
2025-11-21 05:52:53 +07:00

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