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