- Create BLEScanner.tsx with multiple selection modes (single/multiple/none) - Support device filtering, disabled devices, and auto-scan on mount - Include RSSI signal strength visualization with color-coded indicators - Add "Select All" functionality for batch sensor setup - Create comprehensive test suite with mocked BLEContext - Export component via components/ble/index.ts The component integrates with existing BLEContext and follows the established patterns from add-sensor.tsx screen. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
582 lines
16 KiB
TypeScript
582 lines
16 KiB
TypeScript
/**
|
|
* Tests for BLEScanner component
|
|
*/
|
|
|
|
import React from 'react';
|
|
import { render, fireEvent, waitFor } from '@testing-library/react-native';
|
|
import { BLEScanner, SelectionMode } from '../BLEScanner';
|
|
import { WPDevice } from '@/services/ble/types';
|
|
|
|
// Mock the BLE context
|
|
const mockScanDevices = jest.fn();
|
|
const mockStopScan = jest.fn();
|
|
const mockClearError = jest.fn();
|
|
|
|
const mockBLEContext = {
|
|
foundDevices: [] as WPDevice[],
|
|
isScanning: false,
|
|
connectedDevices: new Set<string>(),
|
|
isBLEAvailable: true,
|
|
error: null as string | null,
|
|
permissionError: false,
|
|
scanDevices: mockScanDevices,
|
|
stopScan: mockStopScan,
|
|
clearError: mockClearError,
|
|
};
|
|
|
|
jest.mock('@/contexts/BLEContext', () => ({
|
|
useBLE: () => mockBLEContext,
|
|
}));
|
|
|
|
// Mock WiFiSignalIndicator to avoid native dependencies
|
|
jest.mock('@/components/WiFiSignalIndicator', () => ({
|
|
WiFiSignalIndicator: () => null,
|
|
getSignalStrengthLabel: (rssi: number) => {
|
|
if (rssi >= -50) return 'Excellent';
|
|
if (rssi >= -60) return 'Good';
|
|
if (rssi >= -70) return 'Fair';
|
|
return 'Weak';
|
|
},
|
|
getSignalStrengthColor: () => '#10B981',
|
|
}));
|
|
|
|
// Mock Linking
|
|
jest.mock('react-native/Libraries/Linking/Linking', () => ({
|
|
openSettings: jest.fn(),
|
|
}));
|
|
|
|
const mockDevices: WPDevice[] = [
|
|
{
|
|
id: 'device-1',
|
|
name: 'WP_497_81a14c',
|
|
mac: '142B2F81A14C',
|
|
rssi: -55,
|
|
wellId: 497,
|
|
},
|
|
{
|
|
id: 'device-2',
|
|
name: 'WP_523_81aad4',
|
|
mac: '142B2F81AAD4',
|
|
rssi: -67,
|
|
wellId: 523,
|
|
},
|
|
{
|
|
id: 'device-3',
|
|
name: 'WP_600_abc123',
|
|
mac: '142B2FABC123',
|
|
rssi: -75,
|
|
wellId: 600,
|
|
},
|
|
];
|
|
|
|
describe('BLEScanner', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
// Reset mock context
|
|
mockBLEContext.foundDevices = [];
|
|
mockBLEContext.isScanning = false;
|
|
mockBLEContext.isBLEAvailable = true;
|
|
mockBLEContext.error = null;
|
|
mockBLEContext.permissionError = false;
|
|
mockScanDevices.mockResolvedValue(undefined);
|
|
});
|
|
|
|
describe('Initial State', () => {
|
|
it('renders scan button when no devices are found', () => {
|
|
const { getByTestId } = render(<BLEScanner testID="ble-scanner" />);
|
|
|
|
expect(getByTestId('scan-button')).toBeTruthy();
|
|
});
|
|
|
|
it('shows instructions when showInstructions is true', () => {
|
|
const { getByText } = render(
|
|
<BLEScanner showInstructions={true} />
|
|
);
|
|
|
|
expect(getByText('Scanning for Sensors')).toBeTruthy();
|
|
expect(getByText('Make sure sensors are powered on')).toBeTruthy();
|
|
});
|
|
|
|
it('shows simulator warning when BLE is not available', () => {
|
|
mockBLEContext.isBLEAvailable = false;
|
|
|
|
const { getByText } = render(<BLEScanner />);
|
|
|
|
expect(getByText('Simulator mode - showing mock sensors')).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe('Scanning', () => {
|
|
it('calls scanDevices when scan button is pressed', async () => {
|
|
const { getByTestId } = render(<BLEScanner />);
|
|
|
|
fireEvent.press(getByTestId('scan-button'));
|
|
|
|
await waitFor(() => {
|
|
expect(mockScanDevices).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
it('shows scanning indicator when scanning', () => {
|
|
mockBLEContext.isScanning = true;
|
|
|
|
const { getByText, getByTestId } = render(<BLEScanner />);
|
|
|
|
expect(getByText('Scanning for WP sensors...')).toBeTruthy();
|
|
expect(getByTestId('stop-scan-button')).toBeTruthy();
|
|
});
|
|
|
|
it('calls stopScan when stop button is pressed', () => {
|
|
mockBLEContext.isScanning = true;
|
|
|
|
const { getByTestId } = render(<BLEScanner />);
|
|
|
|
fireEvent.press(getByTestId('stop-scan-button'));
|
|
|
|
expect(mockStopScan).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('clears error before scanning', async () => {
|
|
const { getByTestId } = render(<BLEScanner />);
|
|
|
|
fireEvent.press(getByTestId('scan-button'));
|
|
|
|
await waitFor(() => {
|
|
expect(mockClearError).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Device List', () => {
|
|
beforeEach(() => {
|
|
mockBLEContext.foundDevices = mockDevices;
|
|
});
|
|
|
|
it('displays found devices', () => {
|
|
const { getByText } = render(<BLEScanner />);
|
|
|
|
expect(getByText('Found Sensors (3)')).toBeTruthy();
|
|
expect(getByText('WP_497_81a14c')).toBeTruthy();
|
|
expect(getByText('WP_523_81aad4')).toBeTruthy();
|
|
expect(getByText('WP_600_abc123')).toBeTruthy();
|
|
});
|
|
|
|
it('shows Well ID for devices', () => {
|
|
const { getByText } = render(<BLEScanner />);
|
|
|
|
expect(getByText('Well ID: 497')).toBeTruthy();
|
|
expect(getByText('Well ID: 523')).toBeTruthy();
|
|
});
|
|
|
|
it('shows rescan button when devices are displayed', () => {
|
|
const { getByTestId } = render(<BLEScanner />);
|
|
|
|
expect(getByTestId('rescan-button')).toBeTruthy();
|
|
});
|
|
|
|
it('rescans when rescan button is pressed', async () => {
|
|
const { getByTestId } = render(<BLEScanner />);
|
|
|
|
fireEvent.press(getByTestId('rescan-button'));
|
|
|
|
await waitFor(() => {
|
|
expect(mockScanDevices).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Multiple Selection Mode', () => {
|
|
beforeEach(() => {
|
|
mockBLEContext.foundDevices = mockDevices;
|
|
});
|
|
|
|
it('shows select all button in multiple mode', () => {
|
|
const { getByTestId } = render(
|
|
<BLEScanner selectionMode="multiple" />
|
|
);
|
|
|
|
expect(getByTestId('select-all-button')).toBeTruthy();
|
|
});
|
|
|
|
it('toggles device selection on press', () => {
|
|
const onDevicesSelected = jest.fn();
|
|
const { getByTestId } = render(
|
|
<BLEScanner
|
|
selectionMode="multiple"
|
|
onDevicesSelected={onDevicesSelected}
|
|
/>
|
|
);
|
|
|
|
// Click first device
|
|
fireEvent.press(getByTestId('device-item-device-1'));
|
|
|
|
expect(onDevicesSelected).toHaveBeenCalled();
|
|
});
|
|
|
|
it('selects all devices when select all is pressed', () => {
|
|
const onDevicesSelected = jest.fn();
|
|
const { getByTestId, getByText } = render(
|
|
<BLEScanner
|
|
selectionMode="multiple"
|
|
onDevicesSelected={onDevicesSelected}
|
|
/>
|
|
);
|
|
|
|
// Toggle from "Select All" -> all selected
|
|
fireEvent.press(getByTestId('select-all-button'));
|
|
|
|
// Now should show "Deselect All"
|
|
expect(getByText('Deselect All')).toBeTruthy();
|
|
});
|
|
|
|
it('deselects all when deselect all is pressed', () => {
|
|
const onDevicesSelected = jest.fn();
|
|
const { getByTestId, getByText } = render(
|
|
<BLEScanner
|
|
selectionMode="multiple"
|
|
onDevicesSelected={onDevicesSelected}
|
|
/>
|
|
);
|
|
|
|
// First select all
|
|
fireEvent.press(getByTestId('select-all-button'));
|
|
|
|
// Then deselect all
|
|
fireEvent.press(getByTestId('select-all-button'));
|
|
|
|
expect(getByText('Select All')).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe('Single Selection Mode', () => {
|
|
beforeEach(() => {
|
|
mockBLEContext.foundDevices = mockDevices;
|
|
});
|
|
|
|
it('does not show select all button in single mode', () => {
|
|
const { queryByTestId } = render(
|
|
<BLEScanner selectionMode="single" />
|
|
);
|
|
|
|
expect(queryByTestId('select-all-button')).toBeNull();
|
|
});
|
|
|
|
it('calls onDeviceSelected when a device is selected', () => {
|
|
const onDeviceSelected = jest.fn();
|
|
const { getByTestId } = render(
|
|
<BLEScanner
|
|
selectionMode="single"
|
|
onDeviceSelected={onDeviceSelected}
|
|
/>
|
|
);
|
|
|
|
fireEvent.press(getByTestId('device-item-device-1'));
|
|
|
|
expect(onDeviceSelected).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
id: 'device-1',
|
|
name: 'WP_497_81a14c',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('replaces selection when another device is selected', () => {
|
|
const onDeviceSelected = jest.fn();
|
|
const { getByTestId } = render(
|
|
<BLEScanner
|
|
selectionMode="single"
|
|
onDeviceSelected={onDeviceSelected}
|
|
/>
|
|
);
|
|
|
|
fireEvent.press(getByTestId('device-item-device-1'));
|
|
fireEvent.press(getByTestId('device-item-device-2'));
|
|
|
|
// Last call should be with device-2
|
|
expect(onDeviceSelected).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
id: 'device-2',
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('None Selection Mode', () => {
|
|
beforeEach(() => {
|
|
mockBLEContext.foundDevices = mockDevices;
|
|
});
|
|
|
|
it('does not show checkboxes in none mode', () => {
|
|
const { queryByTestId } = render(
|
|
<BLEScanner selectionMode="none" />
|
|
);
|
|
|
|
// Should not have select all button
|
|
expect(queryByTestId('select-all-button')).toBeNull();
|
|
});
|
|
|
|
it('devices are not selectable in none mode', () => {
|
|
const onDevicesSelected = jest.fn();
|
|
const { getByTestId } = render(
|
|
<BLEScanner
|
|
selectionMode="none"
|
|
onDevicesSelected={onDevicesSelected}
|
|
/>
|
|
);
|
|
|
|
fireEvent.press(getByTestId('device-item-device-1'));
|
|
|
|
// Should be called with empty array (nothing selected)
|
|
expect(onDevicesSelected).toHaveBeenCalledWith([]);
|
|
});
|
|
});
|
|
|
|
describe('Disabled Devices', () => {
|
|
beforeEach(() => {
|
|
mockBLEContext.foundDevices = mockDevices;
|
|
});
|
|
|
|
it('shows disabled badge for disabled devices', () => {
|
|
const { getByText } = render(
|
|
<BLEScanner disabledDeviceIds={new Set(['device-1'])} />
|
|
);
|
|
|
|
expect(getByText('Added')).toBeTruthy();
|
|
});
|
|
|
|
it('does not allow selecting disabled devices', () => {
|
|
const onDevicesSelected = jest.fn();
|
|
const { getByTestId } = render(
|
|
<BLEScanner
|
|
selectionMode="multiple"
|
|
disabledDeviceIds={new Set(['device-1'])}
|
|
onDevicesSelected={onDevicesSelected}
|
|
/>
|
|
);
|
|
|
|
// Try to select disabled device
|
|
fireEvent.press(getByTestId('device-item-device-1'));
|
|
|
|
// Should not include disabled device in selection
|
|
const lastCall = onDevicesSelected.mock.calls[onDevicesSelected.mock.calls.length - 1];
|
|
expect(lastCall[0].find((d: WPDevice) => d.id === 'device-1')).toBeUndefined();
|
|
});
|
|
|
|
it('excludes disabled devices from select all', () => {
|
|
const onDevicesSelected = jest.fn();
|
|
const { getByTestId } = render(
|
|
<BLEScanner
|
|
selectionMode="multiple"
|
|
disabledDeviceIds={new Set(['device-1'])}
|
|
onDevicesSelected={onDevicesSelected}
|
|
/>
|
|
);
|
|
|
|
fireEvent.press(getByTestId('select-all-button'));
|
|
|
|
// Should only have 2 devices (not including device-1)
|
|
const lastCall = onDevicesSelected.mock.calls[onDevicesSelected.mock.calls.length - 1];
|
|
expect(lastCall[0].length).toBe(2);
|
|
expect(lastCall[0].find((d: WPDevice) => d.id === 'device-1')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('Device Filter', () => {
|
|
beforeEach(() => {
|
|
mockBLEContext.foundDevices = mockDevices;
|
|
});
|
|
|
|
it('applies custom filter to devices', () => {
|
|
const filter = (device: WPDevice) => device.rssi > -60;
|
|
|
|
const { getByText, queryByText } = render(
|
|
<BLEScanner deviceFilter={filter} />
|
|
);
|
|
|
|
// Only device-1 has rssi > -60
|
|
expect(getByText('WP_497_81a14c')).toBeTruthy();
|
|
expect(queryByText('WP_523_81aad4')).toBeNull();
|
|
expect(queryByText('WP_600_abc123')).toBeNull();
|
|
});
|
|
|
|
it('shows correct count with filter applied', () => {
|
|
const filter = (device: WPDevice) => device.rssi > -70;
|
|
|
|
const { getByText } = render(
|
|
<BLEScanner deviceFilter={filter} />
|
|
);
|
|
|
|
expect(getByText('Found Sensors (2)')).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe('Empty State', () => {
|
|
it('shows empty state after scan with no devices', () => {
|
|
mockBLEContext.foundDevices = [];
|
|
|
|
// Simulate scan completion
|
|
const { getByTestId, rerender, getByText } = render(<BLEScanner />);
|
|
|
|
// Trigger scan
|
|
fireEvent.press(getByTestId('scan-button'));
|
|
|
|
// Re-render with scan completed
|
|
mockBLEContext.foundDevices = [];
|
|
rerender(<BLEScanner />);
|
|
|
|
// Should show retry button (which appears in empty state after scan)
|
|
// Note: getByTestId will fail if element not present, so we check the retry button
|
|
expect(getByTestId('retry-scan-button')).toBeTruthy();
|
|
});
|
|
|
|
it('shows custom empty state message', async () => {
|
|
const customMessage = 'Custom empty message here';
|
|
|
|
const { getByTestId, getByText, rerender } = render(
|
|
<BLEScanner emptyStateMessage={customMessage} />
|
|
);
|
|
|
|
// Trigger scan
|
|
fireEvent.press(getByTestId('scan-button'));
|
|
|
|
// Re-render after scan
|
|
rerender(<BLEScanner emptyStateMessage={customMessage} />);
|
|
|
|
await waitFor(() => {
|
|
expect(getByText(customMessage)).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it('allows retry from empty state', async () => {
|
|
const { getByTestId, rerender } = render(<BLEScanner />);
|
|
|
|
// First scan
|
|
fireEvent.press(getByTestId('scan-button'));
|
|
rerender(<BLEScanner />);
|
|
|
|
// Retry
|
|
fireEvent.press(getByTestId('retry-scan-button'));
|
|
|
|
await waitFor(() => {
|
|
expect(mockScanDevices).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Error Handling', () => {
|
|
it('shows permission error with settings button', () => {
|
|
mockBLEContext.error = 'Bluetooth permissions not granted';
|
|
mockBLEContext.permissionError = true;
|
|
|
|
const { getByText } = render(<BLEScanner />);
|
|
|
|
expect(getByText('Bluetooth permissions not granted')).toBeTruthy();
|
|
expect(getByText('Open Settings')).toBeTruthy();
|
|
});
|
|
|
|
it('does not show error banner for non-permission errors', () => {
|
|
mockBLEContext.error = 'Some other error';
|
|
mockBLEContext.permissionError = false;
|
|
|
|
const { queryByText } = render(<BLEScanner />);
|
|
|
|
// Permission error banner should not be shown
|
|
expect(queryByText('Open Settings')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('Compact Mode', () => {
|
|
beforeEach(() => {
|
|
mockBLEContext.foundDevices = mockDevices;
|
|
});
|
|
|
|
it('renders in compact mode', () => {
|
|
const { getByText } = render(<BLEScanner compact={true} />);
|
|
|
|
expect(getByText('WP_497_81a14c')).toBeTruthy();
|
|
});
|
|
|
|
it('shows compact instructions', () => {
|
|
mockBLEContext.foundDevices = [];
|
|
|
|
const { getByText } = render(
|
|
<BLEScanner compact={true} showInstructions={true} />
|
|
);
|
|
|
|
expect(getByText('Make sure sensors are powered on')).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe('Auto Scan', () => {
|
|
it('starts scan automatically when autoScan is true', async () => {
|
|
render(<BLEScanner autoScan={true} />);
|
|
|
|
await waitFor(() => {
|
|
expect(mockScanDevices).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
it('does not auto scan when autoScan is false', () => {
|
|
render(<BLEScanner autoScan={false} />);
|
|
|
|
expect(mockScanDevices).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('only auto scans once', async () => {
|
|
const { rerender } = render(<BLEScanner autoScan={true} />);
|
|
|
|
await waitFor(() => {
|
|
expect(mockScanDevices).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// Re-render
|
|
rerender(<BLEScanner autoScan={true} />);
|
|
|
|
// Should still be 1
|
|
expect(mockScanDevices).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('Callbacks', () => {
|
|
beforeEach(() => {
|
|
mockBLEContext.foundDevices = mockDevices;
|
|
});
|
|
|
|
it('calls onDevicesSelected with selected devices', () => {
|
|
const onDevicesSelected = jest.fn();
|
|
|
|
const { getByTestId } = render(
|
|
<BLEScanner
|
|
selectionMode="multiple"
|
|
onDevicesSelected={onDevicesSelected}
|
|
/>
|
|
);
|
|
|
|
fireEvent.press(getByTestId('device-item-device-1'));
|
|
fireEvent.press(getByTestId('device-item-device-2'));
|
|
|
|
expect(onDevicesSelected).toHaveBeenCalled();
|
|
const lastCall = onDevicesSelected.mock.calls[onDevicesSelected.mock.calls.length - 1];
|
|
expect(lastCall[0].length).toBe(2);
|
|
});
|
|
|
|
it('calls both onDeviceSelected and onDevicesSelected in single mode', () => {
|
|
const onDeviceSelected = jest.fn();
|
|
const onDevicesSelected = jest.fn();
|
|
|
|
const { getByTestId } = render(
|
|
<BLEScanner
|
|
selectionMode="single"
|
|
onDeviceSelected={onDeviceSelected}
|
|
onDevicesSelected={onDevicesSelected}
|
|
/>
|
|
);
|
|
|
|
fireEvent.press(getByTestId('device-item-device-1'));
|
|
|
|
expect(onDeviceSelected).toHaveBeenCalled();
|
|
expect(onDevicesSelected).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|