WellNuo/components/ble/__tests__/BLEScanner.test.tsx
Sergei b6cbaef9ae Add reusable BLE Scanner component for WP sensor discovery
- 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>
2026-02-01 08:49:31 -08:00

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