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>
This commit is contained in:
Sergei 2026-02-01 08:49:31 -08:00
parent ba4c31399a
commit b6cbaef9ae
3 changed files with 1405 additions and 0 deletions

View File

@ -0,0 +1,822 @@
import React, { useState, useCallback, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
FlatList,
Linking,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useBLE } from '@/contexts/BLEContext';
import { WPDevice } from '@/services/ble/types';
import {
WiFiSignalIndicator,
getSignalStrengthLabel,
getSignalStrengthColor,
} from '@/components/WiFiSignalIndicator';
import {
AppColors,
BorderRadius,
FontSizes,
FontWeights,
Spacing,
Shadows,
} from '@/constants/theme';
export type SelectionMode = 'single' | 'multiple' | 'none';
export interface BLEScannerProps {
/** Callback when device(s) are selected */
onDevicesSelected?: (devices: WPDevice[]) => void;
/** Callback when a single device is selected (convenience for single mode) */
onDeviceSelected?: (device: WPDevice) => void;
/** Selection mode: 'single', 'multiple', or 'none' */
selectionMode?: SelectionMode;
/** Whether to auto-start scanning when component mounts */
autoScan?: boolean;
/** Custom filter function for devices */
deviceFilter?: (device: WPDevice) => boolean;
/** Device IDs that are already connected/added (will show as disabled) */
disabledDeviceIds?: Set<string>;
/** Show instructions header */
showInstructions?: boolean;
/** Custom empty state message */
emptyStateMessage?: string;
/** Compact mode with smaller styling */
compact?: boolean;
/** Test ID for testing */
testID?: string;
}
interface DeviceItemProps {
device: WPDevice;
isSelected: boolean;
isDisabled: boolean;
selectionMode: SelectionMode;
onPress: () => void;
compact: boolean;
}
function DeviceItem({
device,
isSelected,
isDisabled,
selectionMode,
onPress,
compact,
}: DeviceItemProps) {
return (
<TouchableOpacity
style={[
styles.deviceCard,
compact && styles.deviceCardCompact,
isSelected && styles.deviceCardSelected,
isDisabled && styles.deviceCardDisabled,
]}
onPress={onPress}
disabled={isDisabled || selectionMode === 'none'}
activeOpacity={0.7}
testID={`device-item-${device.id}`}
>
{selectionMode !== 'none' && (
<View style={styles.checkboxContainer}>
<View
style={[
styles.checkbox,
compact && styles.checkboxCompact,
isSelected && styles.checkboxSelected,
isDisabled && styles.checkboxDisabled,
]}
>
{isSelected && (
<Ionicons
name="checkmark"
size={compact ? 12 : 16}
color={AppColors.white}
/>
)}
</View>
</View>
)}
<View style={styles.deviceInfo}>
<View style={[styles.deviceIcon, compact && styles.deviceIconCompact]}>
<Ionicons
name="bluetooth"
size={compact ? 18 : 24}
color={isDisabled ? AppColors.textMuted : AppColors.primary}
/>
</View>
<View style={styles.deviceDetails}>
<Text
style={[
styles.deviceName,
compact && styles.deviceNameCompact,
isDisabled && styles.deviceNameDisabled,
]}
numberOfLines={1}
>
{device.name}
</Text>
{device.wellId && (
<Text style={[styles.deviceMeta, compact && styles.deviceMetaCompact]}>
Well ID: {device.wellId}
</Text>
)}
<View style={styles.signalRow}>
<WiFiSignalIndicator
rssi={device.rssi}
size={compact ? 'small' : 'small'}
/>
<Text
style={[
styles.signalLabel,
{ color: getSignalStrengthColor(device.rssi) },
]}
>
{getSignalStrengthLabel(device.rssi)}
</Text>
<Text style={styles.signalDbm}>({device.rssi} dBm)</Text>
</View>
</View>
</View>
{isDisabled && (
<View style={styles.disabledBadge}>
<Text style={styles.disabledBadgeText}>Added</Text>
</View>
)}
</TouchableOpacity>
);
}
export function BLEScanner({
onDevicesSelected,
onDeviceSelected,
selectionMode = 'multiple',
autoScan = false,
deviceFilter,
disabledDeviceIds = new Set(),
showInstructions = false,
emptyStateMessage = 'No sensors found. Make sure your WP sensor is powered on and nearby.',
compact = false,
testID,
}: BLEScannerProps) {
const {
foundDevices,
isScanning,
isBLEAvailable,
error,
permissionError,
scanDevices,
stopScan,
clearError,
} = useBLE();
const [selectedDevices, setSelectedDevices] = useState<Set<string>>(new Set());
const [hasScanned, setHasScanned] = useState(false);
// Filter devices if filter function provided
const filteredDevices = deviceFilter
? foundDevices.filter(deviceFilter)
: foundDevices;
// Auto-scan on mount if enabled
useEffect(() => {
if (autoScan && !hasScanned) {
handleScan();
}
}, [autoScan, hasScanned]);
// Select all devices by default in multiple selection mode when scan completes
useEffect(() => {
if (
selectionMode === 'multiple' &&
filteredDevices.length > 0 &&
!isScanning &&
hasScanned
) {
const selectableIds = filteredDevices
.filter((d) => !disabledDeviceIds.has(d.id))
.map((d) => d.id);
setSelectedDevices(new Set(selectableIds));
}
}, [filteredDevices, isScanning, hasScanned, selectionMode, disabledDeviceIds]);
// Notify parent of selection changes
useEffect(() => {
const selected = filteredDevices.filter((d) => selectedDevices.has(d.id));
if (selectionMode === 'single' && selected.length === 1 && onDeviceSelected) {
onDeviceSelected(selected[0]);
}
if (onDevicesSelected) {
onDevicesSelected(selected);
}
}, [selectedDevices, filteredDevices, selectionMode, onDeviceSelected, onDevicesSelected]);
const handleScan = useCallback(async () => {
try {
clearError();
setHasScanned(true);
setSelectedDevices(new Set());
await scanDevices();
} catch {
// Error is handled by BLE context
}
}, [clearError, scanDevices]);
const handleDevicePress = useCallback(
(deviceId: string) => {
if (selectionMode === 'none') return;
if (selectionMode === 'single') {
setSelectedDevices(new Set([deviceId]));
} else {
setSelectedDevices((prev) => {
const next = new Set(prev);
if (next.has(deviceId)) {
next.delete(deviceId);
} else {
next.add(deviceId);
}
return next;
});
}
},
[selectionMode]
);
const toggleSelectAll = useCallback(() => {
const selectableDevices = filteredDevices.filter(
(d) => !disabledDeviceIds.has(d.id)
);
if (selectedDevices.size === selectableDevices.length) {
setSelectedDevices(new Set());
} else {
setSelectedDevices(new Set(selectableDevices.map((d) => d.id)));
}
}, [filteredDevices, disabledDeviceIds, selectedDevices]);
const handleOpenSettings = useCallback(() => {
Linking.openSettings();
}, []);
const renderDevice = useCallback(
({ item }: { item: WPDevice }) => (
<DeviceItem
device={item}
isSelected={selectedDevices.has(item.id)}
isDisabled={disabledDeviceIds.has(item.id)}
selectionMode={selectionMode}
onPress={() => handleDevicePress(item.id)}
compact={compact}
/>
),
[selectedDevices, disabledDeviceIds, selectionMode, handleDevicePress, compact]
);
const keyExtractor = useCallback((item: WPDevice) => item.id, []);
const selectableCount = filteredDevices.filter(
(d) => !disabledDeviceIds.has(d.id)
).length;
const allSelected = selectedDevices.size === selectableCount && selectableCount > 0;
return (
<View style={[styles.container, compact && styles.containerCompact]} testID={testID}>
{/* Simulator Warning */}
{!isBLEAvailable && (
<View style={styles.simulatorWarning}>
<Ionicons name="information-circle" size={16} color={AppColors.warning} />
<Text style={styles.simulatorWarningText}>
Simulator mode - showing mock sensors
</Text>
</View>
)}
{/* Permission Error */}
{permissionError && error && (
<View style={styles.permissionError}>
<View style={styles.permissionErrorContent}>
<Ionicons name="warning" size={18} color={AppColors.error} />
<Text style={styles.permissionErrorText}>{error}</Text>
</View>
<TouchableOpacity
style={styles.settingsButton}
onPress={handleOpenSettings}
>
<Text style={styles.settingsButtonText}>Open Settings</Text>
<Ionicons name="settings-outline" size={14} color={AppColors.error} />
</TouchableOpacity>
</View>
)}
{/* Instructions */}
{showInstructions && !isScanning && filteredDevices.length === 0 && (
<View style={[styles.instructionsCard, compact && styles.instructionsCardCompact]}>
<Text style={styles.instructionsTitle}>Scanning for Sensors</Text>
<View style={styles.instruction}>
<Ionicons name="power" size={16} color={AppColors.textMuted} />
<Text style={styles.instructionText}>
Make sure sensors are powered on
</Text>
</View>
<View style={styles.instruction}>
<Ionicons name="locate" size={16} color={AppColors.textMuted} />
<Text style={styles.instructionText}>
Stay within 10 meters of sensors
</Text>
</View>
<View style={styles.instruction}>
<Ionicons name="bluetooth" size={16} color={AppColors.textMuted} />
<Text style={styles.instructionText}>
Bluetooth must be enabled
</Text>
</View>
</View>
)}
{/* Scan Button (when not scanning and no devices) */}
{!isScanning && filteredDevices.length === 0 && (
<TouchableOpacity
style={[styles.scanButton, compact && styles.scanButtonCompact]}
onPress={handleScan}
testID="scan-button"
>
<Ionicons name="bluetooth" size={compact ? 20 : 24} color={AppColors.white} />
<Text style={[styles.scanButtonText, compact && styles.scanButtonTextCompact]}>
Scan for Sensors
</Text>
</TouchableOpacity>
)}
{/* Scanning State */}
{isScanning && (
<View style={[styles.scanningCard, compact && styles.scanningCardCompact]}>
<ActivityIndicator
size={compact ? 'small' : 'large'}
color={AppColors.primary}
/>
<Text style={[styles.scanningText, compact && styles.scanningTextCompact]}>
Scanning for WP sensors...
</Text>
<TouchableOpacity
style={styles.stopScanButton}
onPress={stopScan}
testID="stop-scan-button"
>
<Text style={styles.stopScanText}>Stop Scan</Text>
</TouchableOpacity>
</View>
)}
{/* Device List Header */}
{!isScanning && filteredDevices.length > 0 && (
<View style={styles.listHeader}>
<Text style={styles.listHeaderTitle}>
Found Sensors ({filteredDevices.length})
</Text>
<View style={styles.listHeaderActions}>
{selectionMode === 'multiple' && selectableCount > 0 && (
<TouchableOpacity
style={styles.selectAllButton}
onPress={toggleSelectAll}
testID="select-all-button"
>
<Ionicons
name={allSelected ? 'checkbox' : 'square-outline'}
size={16}
color={AppColors.primary}
/>
<Text style={styles.selectAllText}>
{allSelected ? 'Deselect All' : 'Select All'}
</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={styles.rescanButton}
onPress={handleScan}
testID="rescan-button"
>
<Ionicons name="refresh" size={16} color={AppColors.primary} />
<Text style={styles.rescanText}>Rescan</Text>
</TouchableOpacity>
</View>
</View>
)}
{/* Device List */}
{!isScanning && filteredDevices.length > 0 && (
<FlatList
data={filteredDevices}
renderItem={renderDevice}
keyExtractor={keyExtractor}
contentContainerStyle={styles.deviceList}
showsVerticalScrollIndicator={false}
testID="device-list"
/>
)}
{/* Empty State (after scan, no devices) */}
{!isScanning && hasScanned && filteredDevices.length === 0 && !error && (
<View style={[styles.emptyState, compact && styles.emptyStateCompact]}>
<View style={styles.emptyIconContainer}>
<Ionicons
name="bluetooth-outline"
size={compact ? 32 : 48}
color={AppColors.textMuted}
/>
</View>
<Text style={[styles.emptyTitle, compact && styles.emptyTitleCompact]}>
No Sensors Found
</Text>
<Text style={[styles.emptyText, compact && styles.emptyTextCompact]}>
{emptyStateMessage}
</Text>
<TouchableOpacity
style={[styles.retryButton, compact && styles.retryButtonCompact]}
onPress={handleScan}
testID="retry-scan-button"
>
<Ionicons name="refresh" size={18} color={AppColors.primary} />
<Text style={styles.retryButtonText}>Try Again</Text>
</TouchableOpacity>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
containerCompact: {
flex: 0,
},
// Simulator Warning
simulatorWarning: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.warningLight,
paddingVertical: Spacing.xs,
paddingHorizontal: Spacing.md,
gap: Spacing.xs,
borderRadius: BorderRadius.sm,
marginBottom: Spacing.sm,
},
simulatorWarningText: {
fontSize: FontSizes.xs,
color: AppColors.warning,
fontWeight: FontWeights.medium,
},
// Permission Error
permissionError: {
backgroundColor: AppColors.errorLight,
padding: Spacing.md,
borderRadius: BorderRadius.md,
marginBottom: Spacing.md,
gap: Spacing.sm,
},
permissionErrorContent: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: Spacing.xs,
},
permissionErrorText: {
fontSize: FontSizes.sm,
color: AppColors.error,
fontWeight: FontWeights.medium,
flex: 1,
lineHeight: 18,
},
settingsButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.white,
paddingVertical: Spacing.xs,
paddingHorizontal: Spacing.sm,
borderRadius: BorderRadius.sm,
gap: Spacing.xs,
alignSelf: 'flex-start',
},
settingsButtonText: {
fontSize: FontSizes.xs,
fontWeight: FontWeights.semibold,
color: AppColors.error,
},
// Instructions
instructionsCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
marginBottom: Spacing.md,
gap: Spacing.sm,
...Shadows.xs,
},
instructionsCardCompact: {
padding: Spacing.sm,
gap: Spacing.xs,
},
instructionsTitle: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
marginBottom: Spacing.xs,
},
instruction: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
},
instructionText: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
},
// Scan Button
scanButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: AppColors.primary,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
gap: Spacing.sm,
...Shadows.md,
},
scanButtonCompact: {
paddingVertical: Spacing.sm,
},
scanButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
scanButtonTextCompact: {
fontSize: FontSizes.sm,
},
// Scanning State
scanningCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.xl,
alignItems: 'center',
...Shadows.sm,
},
scanningCardCompact: {
padding: Spacing.md,
},
scanningText: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
marginTop: Spacing.md,
marginBottom: Spacing.sm,
},
scanningTextCompact: {
fontSize: FontSizes.sm,
marginTop: Spacing.sm,
},
stopScanButton: {
paddingVertical: Spacing.xs,
paddingHorizontal: Spacing.md,
},
stopScanText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.error,
},
// List Header
listHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: Spacing.sm,
},
listHeaderTitle: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.textSecondary,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
listHeaderActions: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.md,
},
selectAllButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
selectAllText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.primary,
},
rescanButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
rescanText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.primary,
},
// Device List
deviceList: {
gap: Spacing.sm,
},
// Device Card
deviceCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
flexDirection: 'row',
alignItems: 'center',
...Shadows.xs,
},
deviceCardCompact: {
padding: Spacing.sm,
},
deviceCardSelected: {
borderWidth: 2,
borderColor: AppColors.primary,
},
deviceCardDisabled: {
opacity: 0.6,
},
// Checkbox
checkboxContainer: {
marginRight: Spacing.sm,
},
checkbox: {
width: 24,
height: 24,
borderRadius: BorderRadius.sm,
borderWidth: 2,
borderColor: AppColors.border,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: AppColors.white,
},
checkboxCompact: {
width: 20,
height: 20,
},
checkboxSelected: {
backgroundColor: AppColors.primary,
borderColor: AppColors.primary,
},
checkboxDisabled: {
backgroundColor: AppColors.surfaceSecondary,
borderColor: AppColors.border,
},
// Device Info
deviceInfo: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.md,
},
deviceIcon: {
width: 48,
height: 48,
borderRadius: BorderRadius.lg,
backgroundColor: AppColors.primaryLighter,
justifyContent: 'center',
alignItems: 'center',
},
deviceIconCompact: {
width: 36,
height: 36,
},
deviceDetails: {
flex: 1,
},
deviceName: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
marginBottom: 2,
},
deviceNameCompact: {
fontSize: FontSizes.sm,
},
deviceNameDisabled: {
color: AppColors.textMuted,
},
deviceMeta: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
marginBottom: 4,
},
deviceMetaCompact: {
marginBottom: 2,
},
signalRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
signalLabel: {
fontSize: FontSizes.xs,
fontWeight: FontWeights.semibold,
},
signalDbm: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
},
// Disabled Badge
disabledBadge: {
backgroundColor: AppColors.successLight,
paddingVertical: Spacing.xs,
paddingHorizontal: Spacing.sm,
borderRadius: BorderRadius.sm,
},
disabledBadgeText: {
fontSize: FontSizes.xs,
fontWeight: FontWeights.medium,
color: AppColors.success,
},
// Empty State
emptyState: {
alignItems: 'center',
padding: Spacing.xl,
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
...Shadows.sm,
},
emptyStateCompact: {
padding: Spacing.md,
},
emptyIconContainer: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: AppColors.surfaceSecondary,
justifyContent: 'center',
alignItems: 'center',
marginBottom: Spacing.md,
},
emptyTitle: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
marginBottom: Spacing.xs,
},
emptyTitleCompact: {
fontSize: FontSizes.base,
},
emptyText: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
textAlign: 'center',
lineHeight: 20,
marginBottom: Spacing.md,
},
emptyTextCompact: {
fontSize: FontSizes.xs,
},
retryButton: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.xs,
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.md,
borderRadius: BorderRadius.md,
borderWidth: 1,
borderColor: AppColors.primary,
},
retryButtonCompact: {
paddingVertical: Spacing.xs,
paddingHorizontal: Spacing.sm,
},
retryButtonText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.primary,
},
});
export default BLEScanner;

View File

@ -0,0 +1,581 @@
/**
* 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();
});
});
});

2
components/ble/index.ts Normal file
View File

@ -0,0 +1,2 @@
export { BLEScanner, type BLEScannerProps, type SelectionMode } from './BLEScanner';
export { ConnectionStatusIndicator } from './ConnectionStatusIndicator';