From b6cbaef9ae44ba59e7cea1523d59f93c781e90f7 Mon Sep 17 00:00:00 2001 From: Sergei Date: Sun, 1 Feb 2026 08:49:31 -0800 Subject: [PATCH] Add reusable BLE Scanner component for WP sensor discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- components/ble/BLEScanner.tsx | 822 +++++++++++++++++++ components/ble/__tests__/BLEScanner.test.tsx | 581 +++++++++++++ components/ble/index.ts | 2 + 3 files changed, 1405 insertions(+) create mode 100644 components/ble/BLEScanner.tsx create mode 100644 components/ble/__tests__/BLEScanner.test.tsx create mode 100644 components/ble/index.ts diff --git a/components/ble/BLEScanner.tsx b/components/ble/BLEScanner.tsx new file mode 100644 index 0000000..9f7a429 --- /dev/null +++ b/components/ble/BLEScanner.tsx @@ -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; + /** 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 ( + + {selectionMode !== 'none' && ( + + + {isSelected && ( + + )} + + + )} + + + + + + + + + {device.name} + + + {device.wellId && ( + + Well ID: {device.wellId} + + )} + + + + + {getSignalStrengthLabel(device.rssi)} + + ({device.rssi} dBm) + + + + + {isDisabled && ( + + Added + + )} + + ); +} + +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>(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 }) => ( + 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 ( + + {/* Simulator Warning */} + {!isBLEAvailable && ( + + + + Simulator mode - showing mock sensors + + + )} + + {/* Permission Error */} + {permissionError && error && ( + + + + {error} + + + Open Settings + + + + )} + + {/* Instructions */} + {showInstructions && !isScanning && filteredDevices.length === 0 && ( + + Scanning for Sensors + + + + Make sure sensors are powered on + + + + + + Stay within 10 meters of sensors + + + + + + Bluetooth must be enabled + + + + )} + + {/* Scan Button (when not scanning and no devices) */} + {!isScanning && filteredDevices.length === 0 && ( + + + + Scan for Sensors + + + )} + + {/* Scanning State */} + {isScanning && ( + + + + Scanning for WP sensors... + + + Stop Scan + + + )} + + {/* Device List Header */} + {!isScanning && filteredDevices.length > 0 && ( + + + Found Sensors ({filteredDevices.length}) + + + {selectionMode === 'multiple' && selectableCount > 0 && ( + + + + {allSelected ? 'Deselect All' : 'Select All'} + + + )} + + + Rescan + + + + )} + + {/* Device List */} + {!isScanning && filteredDevices.length > 0 && ( + + )} + + {/* Empty State (after scan, no devices) */} + {!isScanning && hasScanned && filteredDevices.length === 0 && !error && ( + + + + + + No Sensors Found + + + {emptyStateMessage} + + + + Try Again + + + )} + + ); +} + +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; diff --git a/components/ble/__tests__/BLEScanner.test.tsx b/components/ble/__tests__/BLEScanner.test.tsx new file mode 100644 index 0000000..42d6fab --- /dev/null +++ b/components/ble/__tests__/BLEScanner.test.tsx @@ -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(), + 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(); + + expect(getByTestId('scan-button')).toBeTruthy(); + }); + + it('shows instructions when showInstructions is true', () => { + const { getByText } = render( + + ); + + 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(); + + expect(getByText('Simulator mode - showing mock sensors')).toBeTruthy(); + }); + }); + + describe('Scanning', () => { + it('calls scanDevices when scan button is pressed', async () => { + const { getByTestId } = render(); + + fireEvent.press(getByTestId('scan-button')); + + await waitFor(() => { + expect(mockScanDevices).toHaveBeenCalledTimes(1); + }); + }); + + it('shows scanning indicator when scanning', () => { + mockBLEContext.isScanning = true; + + const { getByText, getByTestId } = render(); + + 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(); + + fireEvent.press(getByTestId('stop-scan-button')); + + expect(mockStopScan).toHaveBeenCalledTimes(1); + }); + + it('clears error before scanning', async () => { + const { getByTestId } = render(); + + fireEvent.press(getByTestId('scan-button')); + + await waitFor(() => { + expect(mockClearError).toHaveBeenCalled(); + }); + }); + }); + + describe('Device List', () => { + beforeEach(() => { + mockBLEContext.foundDevices = mockDevices; + }); + + it('displays found devices', () => { + const { getByText } = render(); + + 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(); + + expect(getByText('Well ID: 497')).toBeTruthy(); + expect(getByText('Well ID: 523')).toBeTruthy(); + }); + + it('shows rescan button when devices are displayed', () => { + const { getByTestId } = render(); + + expect(getByTestId('rescan-button')).toBeTruthy(); + }); + + it('rescans when rescan button is pressed', async () => { + const { getByTestId } = render(); + + 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( + + ); + + expect(getByTestId('select-all-button')).toBeTruthy(); + }); + + it('toggles device selection on press', () => { + const onDevicesSelected = jest.fn(); + const { getByTestId } = render( + + ); + + // 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( + + ); + + // 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( + + ); + + // 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( + + ); + + expect(queryByTestId('select-all-button')).toBeNull(); + }); + + it('calls onDeviceSelected when a device is selected', () => { + const onDeviceSelected = jest.fn(); + const { getByTestId } = render( + + ); + + 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( + + ); + + 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( + + ); + + // 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( + + ); + + 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( + + ); + + expect(getByText('Added')).toBeTruthy(); + }); + + it('does not allow selecting disabled devices', () => { + const onDevicesSelected = jest.fn(); + const { getByTestId } = render( + + ); + + // 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( + + ); + + 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( + + ); + + // 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( + + ); + + 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(); + + // Trigger scan + fireEvent.press(getByTestId('scan-button')); + + // Re-render with scan completed + mockBLEContext.foundDevices = []; + rerender(); + + // 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( + + ); + + // Trigger scan + fireEvent.press(getByTestId('scan-button')); + + // Re-render after scan + rerender(); + + await waitFor(() => { + expect(getByText(customMessage)).toBeTruthy(); + }); + }); + + it('allows retry from empty state', async () => { + const { getByTestId, rerender } = render(); + + // First scan + fireEvent.press(getByTestId('scan-button')); + rerender(); + + // 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(); + + 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(); + + // 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(); + + expect(getByText('WP_497_81a14c')).toBeTruthy(); + }); + + it('shows compact instructions', () => { + mockBLEContext.foundDevices = []; + + const { getByText } = render( + + ); + + expect(getByText('Make sure sensors are powered on')).toBeTruthy(); + }); + }); + + describe('Auto Scan', () => { + it('starts scan automatically when autoScan is true', async () => { + render(); + + await waitFor(() => { + expect(mockScanDevices).toHaveBeenCalledTimes(1); + }); + }); + + it('does not auto scan when autoScan is false', () => { + render(); + + expect(mockScanDevices).not.toHaveBeenCalled(); + }); + + it('only auto scans once', async () => { + const { rerender } = render(); + + await waitFor(() => { + expect(mockScanDevices).toHaveBeenCalledTimes(1); + }); + + // Re-render + rerender(); + + // 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( + + ); + + 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( + + ); + + fireEvent.press(getByTestId('device-item-device-1')); + + expect(onDeviceSelected).toHaveBeenCalled(); + expect(onDevicesSelected).toHaveBeenCalled(); + }); + }); +}); diff --git a/components/ble/index.ts b/components/ble/index.ts new file mode 100644 index 0000000..f880d04 --- /dev/null +++ b/components/ble/index.ts @@ -0,0 +1,2 @@ +export { BLEScanner, type BLEScannerProps, type SelectionMode } from './BLEScanner'; +export { ConnectionStatusIndicator } from './ConnectionStatusIndicator';