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:
parent
ba4c31399a
commit
b6cbaef9ae
822
components/ble/BLEScanner.tsx
Normal file
822
components/ble/BLEScanner.tsx
Normal 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;
|
||||
581
components/ble/__tests__/BLEScanner.test.tsx
Normal file
581
components/ble/__tests__/BLEScanner.test.tsx
Normal 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
2
components/ble/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { BLEScanner, type BLEScannerProps, type SelectionMode } from './BLEScanner';
|
||||
export { ConnectionStatusIndicator } from './ConnectionStatusIndicator';
|
||||
Loading…
x
Reference in New Issue
Block a user