diff --git a/src/app/(app)/__tests__/protocols.test.tsx b/src/app/(app)/__tests__/protocols.test.tsx
new file mode 100644
index 0000000..cbb164d
--- /dev/null
+++ b/src/app/(app)/__tests__/protocols.test.tsx
@@ -0,0 +1,389 @@
+import { describe, expect, it, jest } from '@jest/globals';
+import { render, screen, waitFor, fireEvent } from '@testing-library/react-native';
+import React from 'react';
+
+import { CallProtocolsResultData } from '@/models/v4/callProtocols/callProtocolsResultData';
+
+import Protocols from '../protocols';
+
+// Mock dependencies
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+jest.mock('@/components/common/loading', () => ({
+ Loading: () => {
+ const { Text } = require('react-native');
+ return Loading;
+ },
+}));
+
+jest.mock('@/components/common/zero-state', () => ({
+ __esModule: true,
+ default: ({ heading, description }: { heading: string; description: string }) => {
+ const { Text } = require('react-native');
+ return {`ZeroState: ${heading}`};
+ },
+}));
+
+jest.mock('@/components/protocols/protocol-card', () => ({
+ ProtocolCard: ({ protocol, onPress }: { protocol: any; onPress: (id: string) => void }) => {
+ const { Pressable, Text } = require('react-native');
+ return (
+ onPress(protocol.Id)}>
+ {protocol.Name}
+
+ );
+ },
+}));
+
+jest.mock('@/components/protocols/protocol-details-sheet', () => ({
+ ProtocolDetailsSheet: () => {
+ const { Text } = require('react-native');
+ return ProtocolDetailsSheet;
+ },
+}));
+
+// Mock the protocols store
+const mockProtocolsStore = {
+ protocols: [],
+ searchQuery: '',
+ setSearchQuery: jest.fn(),
+ selectProtocol: jest.fn(),
+ isLoading: false,
+ fetchProtocols: jest.fn(),
+};
+
+jest.mock('@/stores/protocols/store', () => ({
+ useProtocolsStore: () => mockProtocolsStore,
+}));
+
+// Mock protocols test data
+const mockProtocols: CallProtocolsResultData[] = [
+ {
+ Id: '1',
+ DepartmentId: 'dept1',
+ Name: 'Fire Emergency Response',
+ Code: 'FIRE001',
+ Description: 'Standard fire emergency response protocol',
+ ProtocolText: '
Fire emergency response protocol content
',
+ CreatedOn: '2023-01-01T00:00:00Z',
+ CreatedByUserId: 'user1',
+ IsDisabled: false,
+ UpdatedOn: '2023-01-02T00:00:00Z',
+ UpdatedByUserId: 'user1',
+ MinimumWeight: 0,
+ State: 1,
+ Triggers: [],
+ Attachments: [],
+ Questions: [],
+ },
+ {
+ Id: '2',
+ DepartmentId: 'dept1',
+ Name: 'Medical Emergency',
+ Code: 'MED001',
+ Description: 'Medical emergency response protocol',
+ ProtocolText: 'Medical emergency response protocol content
',
+ CreatedOn: '2023-01-01T00:00:00Z',
+ CreatedByUserId: 'user1',
+ IsDisabled: false,
+ UpdatedOn: '2023-01-02T00:00:00Z',
+ UpdatedByUserId: 'user1',
+ MinimumWeight: 0,
+ State: 1,
+ Triggers: [],
+ Attachments: [],
+ Questions: [],
+ },
+ {
+ Id: '3',
+ DepartmentId: 'dept1',
+ Name: 'Hazmat Response',
+ Code: 'HAZ001',
+ Description: 'Hazardous material response protocol',
+ ProtocolText: 'Hazmat response protocol content
',
+ CreatedOn: '2023-01-01T00:00:00Z',
+ CreatedByUserId: 'user1',
+ IsDisabled: false,
+ UpdatedOn: '2023-01-02T00:00:00Z',
+ UpdatedByUserId: 'user1',
+ MinimumWeight: 0,
+ State: 1,
+ Triggers: [],
+ Attachments: [],
+ Questions: [],
+ },
+ {
+ Id: '', // Empty ID to test the keyExtractor fix
+ DepartmentId: 'dept1',
+ Name: 'Protocol with Empty ID',
+ Code: 'EMPTY001',
+ Description: 'Protocol with empty ID for testing',
+ ProtocolText: 'Protocol with empty ID content
',
+ CreatedOn: '2023-01-01T00:00:00Z',
+ CreatedByUserId: 'user1',
+ IsDisabled: false,
+ UpdatedOn: '2023-01-02T00:00:00Z',
+ UpdatedByUserId: 'user1',
+ MinimumWeight: 0,
+ State: 1,
+ Triggers: [],
+ Attachments: [],
+ Questions: [],
+ },
+];
+
+describe('Protocols Page', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ // Reset mock store to default state
+ Object.assign(mockProtocolsStore, {
+ protocols: [],
+ searchQuery: '',
+ setSearchQuery: jest.fn(),
+ selectProtocol: jest.fn(),
+ isLoading: false,
+ fetchProtocols: jest.fn(),
+ });
+ });
+
+ it('should render loading state during initial fetch', () => {
+ Object.assign(mockProtocolsStore, {
+ isLoading: true,
+ protocols: [],
+ });
+
+ render();
+
+ expect(screen.getByText('Loading')).toBeTruthy();
+ });
+
+ it('should render protocols list when data is loaded', async () => {
+ Object.assign(mockProtocolsStore, {
+ protocols: mockProtocols,
+ isLoading: false,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('protocol-card-1')).toBeTruthy();
+ expect(screen.getByTestId('protocol-card-2')).toBeTruthy();
+ expect(screen.getByTestId('protocol-card-3')).toBeTruthy();
+ });
+
+ expect(mockProtocolsStore.fetchProtocols).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle protocols with empty IDs using keyExtractor fallback', async () => {
+ Object.assign(mockProtocolsStore, {
+ protocols: mockProtocols,
+ isLoading: false,
+ });
+
+ render();
+
+ await waitFor(() => {
+ // The protocol with empty ID should render with fallback key
+ expect(screen.getByText('Protocol with Empty ID')).toBeTruthy();
+ });
+ });
+
+ it('should render zero state when no protocols are available', () => {
+ Object.assign(mockProtocolsStore, {
+ protocols: [],
+ isLoading: false,
+ });
+
+ render();
+
+ expect(screen.getByText('ZeroState: protocols.empty')).toBeTruthy();
+ });
+
+ it('should filter protocols based on search query by name', async () => {
+ Object.assign(mockProtocolsStore, {
+ protocols: mockProtocols,
+ searchQuery: 'fire',
+ isLoading: false,
+ });
+
+ render();
+
+ // Only Fire Emergency Response should be visible in filtered results
+ await waitFor(() => {
+ expect(screen.getByTestId('protocol-card-1')).toBeTruthy();
+ expect(screen.queryByTestId('protocol-card-2')).toBeFalsy();
+ expect(screen.queryByTestId('protocol-card-3')).toBeFalsy();
+ });
+ });
+
+ it('should filter protocols based on search query by code', async () => {
+ Object.assign(mockProtocolsStore, {
+ protocols: mockProtocols,
+ searchQuery: 'MED001',
+ isLoading: false,
+ });
+
+ render();
+
+ // Only Medical Emergency should be visible in filtered results
+ await waitFor(() => {
+ expect(screen.queryByTestId('protocol-card-1')).toBeFalsy();
+ expect(screen.getByTestId('protocol-card-2')).toBeTruthy();
+ expect(screen.queryByTestId('protocol-card-3')).toBeFalsy();
+ });
+ });
+
+ it('should filter protocols based on search query by description', async () => {
+ Object.assign(mockProtocolsStore, {
+ protocols: mockProtocols,
+ searchQuery: 'hazardous',
+ isLoading: false,
+ });
+
+ render();
+
+ // Only Hazmat Response should be visible in filtered results
+ await waitFor(() => {
+ expect(screen.queryByTestId('protocol-card-1')).toBeFalsy();
+ expect(screen.queryByTestId('protocol-card-2')).toBeFalsy();
+ expect(screen.getByTestId('protocol-card-3')).toBeTruthy();
+ });
+ });
+
+ it('should show zero state when search returns no results', () => {
+ Object.assign(mockProtocolsStore, {
+ protocols: mockProtocols,
+ searchQuery: 'nonexistent',
+ isLoading: false,
+ });
+
+ render();
+
+ expect(screen.getByText('ZeroState: protocols.empty')).toBeTruthy();
+ });
+
+ it('should handle search input changes', async () => {
+ Object.assign(mockProtocolsStore, {
+ protocols: mockProtocols,
+ searchQuery: '',
+ isLoading: false,
+ });
+
+ render();
+
+ const searchInput = screen.getByPlaceholderText('protocols.search');
+ fireEvent.changeText(searchInput, 'fire');
+
+ expect(mockProtocolsStore.setSearchQuery).toHaveBeenCalledWith('fire');
+ });
+
+ it('should clear search query when X button is pressed', async () => {
+ Object.assign(mockProtocolsStore, {
+ protocols: mockProtocols,
+ searchQuery: 'fire',
+ isLoading: false,
+ });
+
+ render();
+
+ const searchInput = screen.getByDisplayValue('fire');
+ expect(searchInput).toBeTruthy();
+
+ // Test that the clear functionality would work
+ fireEvent.changeText(searchInput, '');
+ expect(mockProtocolsStore.setSearchQuery).toHaveBeenCalledWith('');
+ });
+
+ it('should handle protocol selection', async () => {
+ Object.assign(mockProtocolsStore, {
+ protocols: mockProtocols,
+ searchQuery: '',
+ isLoading: false,
+ });
+
+ render();
+
+ const protocolCard = screen.getByTestId('protocol-card-1');
+ fireEvent.press(protocolCard);
+
+ expect(mockProtocolsStore.selectProtocol).toHaveBeenCalledWith('1');
+ });
+
+ it('should handle pull-to-refresh', async () => {
+ Object.assign(mockProtocolsStore, {
+ protocols: mockProtocols,
+ isLoading: false,
+ });
+
+ render();
+
+ // The FlatList should be rendered with RefreshControl
+ await waitFor(() => {
+ expect(screen.getByTestId('protocol-card-1')).toBeTruthy();
+ });
+
+ expect(mockProtocolsStore.fetchProtocols).toHaveBeenCalledTimes(1);
+ });
+
+ it('should render protocol details sheet', () => {
+ Object.assign(mockProtocolsStore, {
+ protocols: mockProtocols,
+ isLoading: false,
+ });
+
+ render();
+
+ expect(screen.getByText('ProtocolDetailsSheet')).toBeTruthy();
+ });
+
+ it('should handle case-insensitive search', async () => {
+ Object.assign(mockProtocolsStore, {
+ protocols: mockProtocols,
+ searchQuery: 'FIRE',
+ isLoading: false,
+ });
+
+ render();
+
+ // Should match "Fire Emergency Response" despite different case
+ await waitFor(() => {
+ expect(screen.getByTestId('protocol-card-1')).toBeTruthy();
+ });
+ });
+
+ it('should handle empty search query by showing all protocols', async () => {
+ Object.assign(mockProtocolsStore, {
+ protocols: mockProtocols,
+ searchQuery: '',
+ isLoading: false,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('protocol-card-1')).toBeTruthy();
+ expect(screen.getByTestId('protocol-card-2')).toBeTruthy();
+ expect(screen.getByTestId('protocol-card-3')).toBeTruthy();
+ });
+ });
+
+ it('should handle whitespace-only search query by showing all protocols', async () => {
+ Object.assign(mockProtocolsStore, {
+ protocols: mockProtocols,
+ searchQuery: ' ',
+ isLoading: false,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('protocol-card-1')).toBeTruthy();
+ expect(screen.getByTestId('protocol-card-2')).toBeTruthy();
+ expect(screen.getByTestId('protocol-card-3')).toBeTruthy();
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/app/(app)/__tests__/signalr-lifecycle.test.tsx b/src/app/(app)/__tests__/signalr-lifecycle.test.tsx
new file mode 100644
index 0000000..7d9cda9
--- /dev/null
+++ b/src/app/(app)/__tests__/signalr-lifecycle.test.tsx
@@ -0,0 +1,164 @@
+import { renderHook, waitFor } from '@testing-library/react-native';
+import React from 'react';
+import { AppStateStatus } from 'react-native';
+
+import { useSignalRStore } from '@/stores/signalr/signalr-store';
+
+// Mock the SignalR store
+jest.mock('@/stores/signalr/signalr-store');
+
+const mockUseSignalRStore = useSignalRStore as jest.MockedFunction;
+
+// Create a custom hook to test the SignalR lifecycle logic
+function useSignalRLifecycle(isActive: boolean, appState: AppStateStatus, isSignedIn: boolean, hasInitialized: boolean) {
+ const signalRStore = useSignalRStore();
+
+ React.useEffect(() => {
+ // Handle app going to background
+ if (!isActive && (appState === 'background' || appState === 'inactive') && hasInitialized && isSignedIn) {
+ signalRStore.disconnectUpdateHub();
+ signalRStore.disconnectGeolocationHub();
+ }
+ }, [isActive, appState, hasInitialized, isSignedIn, signalRStore]);
+
+ React.useEffect(() => {
+ // Handle app resuming from background
+ if (isActive && appState === 'active' && hasInitialized && isSignedIn) {
+ signalRStore.connectUpdateHub();
+ signalRStore.connectGeolocationHub();
+ }
+ }, [isActive, appState, hasInitialized, isSignedIn, signalRStore]);
+
+ return signalRStore;
+}
+
+describe('SignalR Lifecycle Management', () => {
+ const mockConnectUpdateHub = jest.fn();
+ const mockDisconnectUpdateHub = jest.fn();
+ const mockConnectGeolocationHub = jest.fn();
+ const mockDisconnectGeolocationHub = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ // Mock SignalR store
+ mockUseSignalRStore.mockReturnValue({
+ connectUpdateHub: mockConnectUpdateHub,
+ disconnectUpdateHub: mockDisconnectUpdateHub,
+ connectGeolocationHub: mockConnectGeolocationHub,
+ disconnectGeolocationHub: mockDisconnectGeolocationHub,
+ isUpdateHubConnected: false,
+ isGeolocationHubConnected: false,
+ } as any);
+ });
+
+ it('should disconnect SignalR when app goes to background', async () => {
+ const { rerender } = renderHook(
+ ({ isActive, appState, isSignedIn, hasInitialized }) =>
+ useSignalRLifecycle(isActive, appState, isSignedIn, hasInitialized),
+ {
+ initialProps: {
+ isActive: true,
+ appState: 'active' as AppStateStatus,
+ isSignedIn: true,
+ hasInitialized: true,
+ },
+ }
+ );
+
+ // Simulate app going to background
+ rerender({
+ isActive: false,
+ appState: 'background' as AppStateStatus,
+ isSignedIn: true,
+ hasInitialized: true,
+ });
+
+ await waitFor(() => {
+ expect(mockDisconnectUpdateHub).toHaveBeenCalled();
+ expect(mockDisconnectGeolocationHub).toHaveBeenCalled();
+ });
+ });
+
+ it('should reconnect SignalR when app becomes active again', async () => {
+ const { rerender } = renderHook(
+ ({ isActive, appState, isSignedIn, hasInitialized }) =>
+ useSignalRLifecycle(isActive, appState, isSignedIn, hasInitialized),
+ {
+ initialProps: {
+ isActive: false,
+ appState: 'background' as AppStateStatus,
+ isSignedIn: true,
+ hasInitialized: true,
+ },
+ }
+ );
+
+ // Simulate app becoming active
+ rerender({
+ isActive: true,
+ appState: 'active' as AppStateStatus,
+ isSignedIn: true,
+ hasInitialized: true,
+ });
+
+ await waitFor(() => {
+ expect(mockConnectUpdateHub).toHaveBeenCalled();
+ expect(mockConnectGeolocationHub).toHaveBeenCalled();
+ });
+ });
+
+ it('should not manage SignalR connections when user is not signed in', async () => {
+ const { rerender } = renderHook(
+ ({ isActive, appState, isSignedIn, hasInitialized }) =>
+ useSignalRLifecycle(isActive, appState, isSignedIn, hasInitialized),
+ {
+ initialProps: {
+ isActive: true,
+ appState: 'active' as AppStateStatus,
+ isSignedIn: false,
+ hasInitialized: true,
+ },
+ }
+ );
+
+ // Simulate app going to background
+ rerender({
+ isActive: false,
+ appState: 'background' as AppStateStatus,
+ isSignedIn: false,
+ hasInitialized: true,
+ });
+
+ // Should not call SignalR methods when user is not signed in
+ expect(mockDisconnectUpdateHub).not.toHaveBeenCalled();
+ expect(mockDisconnectGeolocationHub).not.toHaveBeenCalled();
+ });
+
+ it('should not manage SignalR connections when app is not initialized', async () => {
+ const { rerender } = renderHook(
+ ({ isActive, appState, isSignedIn, hasInitialized }) =>
+ useSignalRLifecycle(isActive, appState, isSignedIn, hasInitialized),
+ {
+ initialProps: {
+ isActive: true,
+ appState: 'active' as AppStateStatus,
+ isSignedIn: true,
+ hasInitialized: false,
+ },
+ }
+ );
+
+ // Simulate app going to background
+ rerender({
+ isActive: false,
+ appState: 'background' as AppStateStatus,
+ isSignedIn: true,
+ hasInitialized: false,
+ });
+
+ // Should not call SignalR methods when app is not initialized
+ expect(mockDisconnectUpdateHub).not.toHaveBeenCalled();
+ expect(mockDisconnectGeolocationHub).not.toHaveBeenCalled();
+ });
+});
\ No newline at end of file
diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx
index 7fa4d10..58a19e9 100644
--- a/src/app/(app)/_layout.tsx
+++ b/src/app/(app)/_layout.tsx
@@ -16,6 +16,7 @@ import { Drawer, DrawerBackdrop, DrawerBody, DrawerContent, DrawerFooter, Drawer
import { Icon } from '@/components/ui/icon';
import { Text } from '@/components/ui/text';
import { useAppLifecycle } from '@/hooks/use-app-lifecycle';
+import { useSignalRLifecycle } from '@/hooks/use-signalr-lifecycle';
import { useAuthStore } from '@/lib/auth';
import { logger } from '@/lib/logging';
import { useIsFirstTime } from '@/lib/storage';
@@ -26,6 +27,7 @@ import { useCoreStore } from '@/stores/app/core-store';
import { useCallsStore } from '@/stores/calls/store';
import { useRolesStore } from '@/stores/roles/store';
import { securityStore } from '@/stores/security/store';
+import { useSignalRStore } from '@/stores/signalr/signalr-store';
export default function TabLayout() {
const { t } = useTranslation();
@@ -96,10 +98,14 @@ export default function TabLayout() {
await securityStore.getState().getRights();
await useCoreStore.getState().fetchConfig();
+ await useSignalRStore.getState().connectUpdateHub();
+ await useSignalRStore.getState().connectGeolocationHub();
+
+ hasInitialized.current = true;
+
// Initialize Bluetooth service
await bluetoothAudioService.initialize();
- hasInitialized.current = true;
logger.info({
message: 'App initialization completed successfully',
});
@@ -123,6 +129,7 @@ export default function TabLayout() {
});
try {
+ // Refresh data
await Promise.all([useCoreStore.getState().fetchConfig(), useCallsStore.getState().fetchCalls(), useRolesStore.getState().fetchRoles()]);
} catch (error) {
logger.error({
@@ -132,6 +139,12 @@ export default function TabLayout() {
}
}, [status]);
+ // Handle SignalR lifecycle management
+ useSignalRLifecycle({
+ isSignedIn: status === 'signedIn',
+ hasInitialized: hasInitialized.current,
+ });
+
// Handle splash screen hiding
useEffect(() => {
if (status !== 'idle' && !hasHiddenSplash.current) {
diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx
index 7e0435e..06ac62e 100644
--- a/src/app/(app)/index.tsx
+++ b/src/app/(app)/index.tsx
@@ -105,7 +105,7 @@ export default function Map() {
};
fetchMapDataAndMarkers();
- }, [setMapPins]);
+ }, []);
useEffect(() => {
Animated.loop(
diff --git a/src/app/(app)/protocols.tsx b/src/app/(app)/protocols.tsx
index 9bcbae7..1ba3e1e 100644
--- a/src/app/(app)/protocols.tsx
+++ b/src/app/(app)/protocols.tsx
@@ -55,7 +55,7 @@ export default function Protocols() {
) : filteredProtocols.length > 0 ? (
item.Id}
+ keyExtractor={(item, index) => item.Id || `protocol-${index}`}
renderItem={({ item }) => }
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 100 }}
diff --git a/src/components/livekit/__tests__/livekit-bottom-sheet.test.tsx b/src/components/livekit/__tests__/livekit-bottom-sheet.test.tsx
index 562e022..b53f0c2 100644
--- a/src/components/livekit/__tests__/livekit-bottom-sheet.test.tsx
+++ b/src/components/livekit/__tests__/livekit-bottom-sheet.test.tsx
@@ -1,4 +1,4 @@
-import { render } from '@testing-library/react-native';
+import { render, act } from '@testing-library/react-native';
import React from 'react';
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
@@ -334,8 +334,7 @@ describe('LiveKitBottomSheet', () => {
it('should handle view transitions', () => {
const fetchVoiceSettings = jest.fn();
- // Start with room selection
- const { rerender } = render();
+ // Start with room selection view
mockUseLiveKitStore.mockReturnValue({
...defaultLiveKitState,
isBottomSheetVisible: true,
@@ -343,10 +342,11 @@ describe('LiveKitBottomSheet', () => {
fetchVoiceSettings,
});
- rerender();
+ const { rerender } = render();
expect(fetchVoiceSettings).toHaveBeenCalled();
- // Connect to room
+ // Connect to room - fetchVoiceSettings should not be called again since we're now in connected view
+ fetchVoiceSettings.mockClear();
mockUseLiveKitStore.mockReturnValue({
...defaultLiveKitState,
isBottomSheetVisible: true,
@@ -357,14 +357,13 @@ describe('LiveKitBottomSheet', () => {
});
rerender();
- expect(fetchVoiceSettings).toHaveBeenCalled();
+ expect(fetchVoiceSettings).not.toHaveBeenCalled(); // Should not be called in connected view
});
it('should handle microphone state changes', () => {
const fetchVoiceSettings = jest.fn();
- // Start with muted microphone
- const { rerender } = render();
+ // Start with muted microphone in connected state
mockUseLiveKitStore.mockReturnValue({
...defaultLiveKitState,
isBottomSheetVisible: true,
@@ -374,7 +373,9 @@ describe('LiveKitBottomSheet', () => {
fetchVoiceSettings,
});
- rerender();
+ const { rerender } = render();
+ // Clear the initial call that happens during render before the view switches
+ fetchVoiceSettings.mockClear();
// Enable microphone
const enabledMockRoom = {
@@ -394,7 +395,7 @@ describe('LiveKitBottomSheet', () => {
});
rerender();
- expect(fetchVoiceSettings).toHaveBeenCalled();
+ expect(fetchVoiceSettings).not.toHaveBeenCalled(); // Should not be called when just changing microphone state
});
});
});
\ No newline at end of file
diff --git a/src/components/protocols/__tests__/protocol-card.test.tsx b/src/components/protocols/__tests__/protocol-card.test.tsx
new file mode 100644
index 0000000..3fda919
--- /dev/null
+++ b/src/components/protocols/__tests__/protocol-card.test.tsx
@@ -0,0 +1,277 @@
+import { render, screen, fireEvent } from '@testing-library/react-native';
+import React from 'react';
+
+import { CallProtocolsResultData } from '@/models/v4/callProtocols/callProtocolsResultData';
+
+import { ProtocolCard } from '../protocol-card';
+
+// Mock dependencies
+jest.mock('@/lib/utils', () => ({
+ formatDateForDisplay: jest.fn((date) => date ? '2023-01-01 12:00 UTC' : ''),
+ parseDateISOString: jest.fn((dateString) => dateString ? new Date(dateString) : null),
+ stripHtmlTags: jest.fn((html) => html ? html.replace(/<[^>]*>/g, '') : ''),
+}));
+
+describe('ProtocolCard', () => {
+ const mockOnPress = jest.fn();
+
+ beforeEach(() => {
+ mockOnPress.mockClear();
+ });
+
+ const baseProtocol: CallProtocolsResultData = {
+ Id: '1',
+ DepartmentId: 'dept1',
+ Name: 'Fire Emergency Response',
+ Code: 'FIRE001',
+ Description: 'Standard fire emergency response protocol',
+ ProtocolText: 'Fire emergency response protocol content
',
+ CreatedOn: '2023-01-01T00:00:00Z',
+ CreatedByUserId: 'user1',
+ IsDisabled: false,
+ UpdatedOn: '2023-01-02T00:00:00Z',
+ UpdatedByUserId: 'user1',
+ MinimumWeight: 0,
+ State: 1,
+ Triggers: [],
+ Attachments: [],
+ Questions: [],
+ };
+
+ const protocolWithoutOptionalFields: CallProtocolsResultData = {
+ Id: '2',
+ DepartmentId: 'dept1',
+ Name: 'Basic Protocol',
+ Code: '',
+ Description: '',
+ ProtocolText: '',
+ CreatedOn: '2023-01-01T00:00:00Z',
+ CreatedByUserId: 'user1',
+ IsDisabled: false,
+ UpdatedOn: '',
+ UpdatedByUserId: '',
+ MinimumWeight: 0,
+ State: 1,
+ Triggers: [],
+ Attachments: [],
+ Questions: [],
+ };
+
+ const protocolWithHtmlDescription: CallProtocolsResultData = {
+ Id: '3',
+ DepartmentId: 'dept1',
+ Name: 'Protocol with HTML',
+ Code: 'HTML001',
+ Description: 'This is a description with HTML tags
',
+ ProtocolText: 'Protocol content
',
+ CreatedOn: '2023-01-01T00:00:00Z',
+ CreatedByUserId: 'user1',
+ IsDisabled: false,
+ UpdatedOn: '2023-01-02T00:00:00Z',
+ UpdatedByUserId: 'user1',
+ MinimumWeight: 0,
+ State: 1,
+ Triggers: [],
+ Attachments: [],
+ Questions: [],
+ };
+
+ describe('Basic Rendering', () => {
+ it('should render protocol card with all fields', () => {
+ render();
+
+ expect(screen.getByText('Fire Emergency Response')).toBeTruthy();
+ expect(screen.getByText('Standard fire emergency response protocol')).toBeTruthy();
+ expect(screen.getByText('FIRE001')).toBeTruthy();
+ expect(screen.getByText('2023-01-01 12:00 UTC')).toBeTruthy();
+ });
+
+ it('should render protocol card without optional fields', () => {
+ render();
+
+ expect(screen.getByText('Basic Protocol')).toBeTruthy();
+ expect(screen.getByText('2023-01-01 12:00 UTC')).toBeTruthy();
+ // Code badge should not be rendered when code is empty - we can't test for empty string as it's always rendered
+ expect(screen.queryByText('FIRE001')).toBeFalsy();
+ });
+
+ it('should handle protocol with HTML in description', () => {
+ render();
+
+ expect(screen.getByText('Protocol with HTML')).toBeTruthy();
+ expect(screen.getByText('HTML001')).toBeTruthy();
+ // Description should be stripped of HTML tags
+ expect(screen.getByText('This is a description with HTML tags')).toBeTruthy();
+ });
+ });
+
+ describe('Interactions', () => {
+ it('should call onPress with protocol ID when card is pressed', () => {
+ render();
+
+ const card = screen.getByText('Fire Emergency Response');
+ fireEvent.press(card);
+
+ expect(mockOnPress).toHaveBeenCalledWith('1');
+ });
+
+ it('should call onPress with correct ID for different protocols', () => {
+ render();
+
+ const card = screen.getByText('Basic Protocol');
+ fireEvent.press(card);
+
+ expect(mockOnPress).toHaveBeenCalledWith('2');
+ });
+
+ it('should handle multiple press events', () => {
+ render();
+
+ const card = screen.getByText('Fire Emergency Response');
+ fireEvent.press(card);
+ fireEvent.press(card);
+
+ expect(mockOnPress).toHaveBeenCalledTimes(2);
+ expect(mockOnPress).toHaveBeenCalledWith('1');
+ });
+ });
+
+ describe('Date Display', () => {
+ it('should display UpdatedOn date when available', () => {
+ render();
+
+ expect(screen.getByText('2023-01-01 12:00 UTC')).toBeTruthy();
+ });
+
+ it('should fall back to CreatedOn when UpdatedOn is not available', () => {
+ render();
+
+ expect(screen.getByText('2023-01-01 12:00 UTC')).toBeTruthy();
+ });
+ });
+
+ describe('Code Badge Display', () => {
+ it('should display code badge when code is provided', () => {
+ render();
+
+ expect(screen.getByText('FIRE001')).toBeTruthy();
+ });
+
+ it('should not display code badge when code is empty', () => {
+ render();
+
+ // The code badge section should not exist - we can't test for empty string as it's always rendered
+ expect(screen.queryByText('FIRE001')).toBeFalsy();
+ });
+
+ it('should not display code badge when code is null', () => {
+ const protocolWithNullCode = { ...baseProtocol, Code: null as any };
+ render();
+
+ // The code badge section should not exist
+ expect(screen.queryByText('null')).toBeFalsy();
+ });
+ });
+
+ describe('Description Display', () => {
+ it('should display description when provided', () => {
+ render();
+
+ expect(screen.getByText('Standard fire emergency response protocol')).toBeTruthy();
+ });
+
+ it('should handle empty description', () => {
+ render();
+
+ expect(screen.getByText('Basic Protocol')).toBeTruthy();
+ // Empty description should render empty text
+ expect(screen.getByText('')).toBeTruthy();
+ });
+
+ it('should strip HTML tags from description', () => {
+ render();
+
+ expect(screen.getByText('This is a description with HTML tags')).toBeTruthy();
+ // Should not contain HTML tags
+ expect(screen.queryByText('This is a description with HTML tags
')).toBeFalsy();
+ });
+
+ it('should handle null description', () => {
+ const protocolWithNullDescription = { ...baseProtocol, Description: null as any };
+ render();
+
+ expect(screen.getByText('Fire Emergency Response')).toBeTruthy();
+ expect(screen.getByText('')).toBeTruthy();
+ });
+ });
+
+ describe('Text Truncation', () => {
+ it('should limit description to 2 lines', () => {
+ const protocolWithLongDescription = {
+ ...baseProtocol,
+ Description: 'This is a very long description that should be truncated when it exceeds two lines of text in the protocol card component',
+ };
+
+ render();
+
+ expect(screen.getByText('This is a very long description that should be truncated when it exceeds two lines of text in the protocol card component')).toBeTruthy();
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('should handle protocol with empty ID', () => {
+ const protocolWithEmptyId = { ...baseProtocol, Id: '' };
+ render();
+
+ const card = screen.getByText('Fire Emergency Response');
+ fireEvent.press(card);
+
+ expect(mockOnPress).toHaveBeenCalledWith('');
+ });
+
+ it('should handle protocol with special characters in name', () => {
+ const protocolWithSpecialChars = {
+ ...baseProtocol,
+ Name: 'Protocol & Emergency ',
+ };
+
+ render();
+
+ expect(screen.getByText('Protocol & Emergency ')).toBeTruthy();
+ });
+
+ it('should handle protocol with very long name', () => {
+ const protocolWithLongName = {
+ ...baseProtocol,
+ Name: 'Very Long Protocol Name That Might Overflow The Card Layout And Should Be Handled Gracefully',
+ };
+
+ render();
+
+ expect(screen.getByText('Very Long Protocol Name That Might Overflow The Card Layout And Should Be Handled Gracefully')).toBeTruthy();
+ });
+
+ it('should handle protocol with very long code', () => {
+ const protocolWithLongCode = {
+ ...baseProtocol,
+ Code: 'VERY_LONG_CODE_THAT_MIGHT_OVERFLOW_THE_BADGE',
+ };
+
+ render();
+
+ expect(screen.getByText('VERY_LONG_CODE_THAT_MIGHT_OVERFLOW_THE_BADGE')).toBeTruthy();
+ });
+ });
+
+ describe('Accessibility', () => {
+ it('should be accessible for screen readers', () => {
+ render();
+
+ const card = screen.getByText('Fire Emergency Response');
+ expect(card).toBeTruthy();
+ // The card should be pressable
+ fireEvent.press(card);
+ expect(mockOnPress).toHaveBeenCalled();
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/components/protocols/__tests__/protocol-details-sheet.test.tsx b/src/components/protocols/__tests__/protocol-details-sheet.test.tsx
new file mode 100644
index 0000000..25c01a3
--- /dev/null
+++ b/src/components/protocols/__tests__/protocol-details-sheet.test.tsx
@@ -0,0 +1,381 @@
+import { render, screen, fireEvent } from '@testing-library/react-native';
+import React from 'react';
+
+import { CallProtocolsResultData } from '@/models/v4/callProtocols/callProtocolsResultData';
+
+import { ProtocolDetailsSheet } from '../protocol-details-sheet';
+
+// Mock dependencies
+jest.mock('@/lib/utils', () => ({
+ formatDateForDisplay: jest.fn((date) => date ? '2023-01-01 12:00 UTC' : ''),
+ parseDateISOString: jest.fn((dateString) => dateString ? new Date(dateString) : null),
+ stripHtmlTags: jest.fn((html) => html ? html.replace(/<[^>]*>/g, '') : ''),
+}));
+
+jest.mock('nativewind', () => ({
+ useColorScheme: () => ({ colorScheme: 'light' }),
+ cssInterop: jest.fn(),
+}));
+
+jest.mock('react-native-webview', () => ({
+ __esModule: true,
+ default: ({ source }: { source: any }) => {
+ const { View, Text } = require('react-native');
+ return (
+
+ {source.html}
+
+ );
+ },
+}));
+
+// Mock the protocols store
+const mockProtocolsStore = {
+ protocols: [],
+ selectedProtocolId: null,
+ isDetailsOpen: false,
+ closeDetails: jest.fn(),
+};
+
+jest.mock('@/stores/protocols/store', () => ({
+ useProtocolsStore: () => mockProtocolsStore,
+}));
+
+// Mock the UI components
+jest.mock('@/components/ui/actionsheet', () => ({
+ Actionsheet: ({ children }: { children: React.ReactNode }) => {
+ const { View } = require('react-native');
+ return {children};
+ },
+ ActionsheetBackdrop: ({ children }: { children: React.ReactNode }) => {
+ const { View } = require('react-native');
+ return {children};
+ },
+ ActionsheetContent: ({ children }: { children: React.ReactNode }) => {
+ const { View } = require('react-native');
+ return {children};
+ },
+ ActionsheetDragIndicator: () => {
+ const { View } = require('react-native');
+ return ;
+ },
+ ActionsheetDragIndicatorWrapper: ({ children }: { children: React.ReactNode }) => {
+ const { View } = require('react-native');
+ return {children};
+ },
+}));
+
+// Mock protocols test data
+const mockProtocols: CallProtocolsResultData[] = [
+ {
+ Id: '1',
+ DepartmentId: 'dept1',
+ Name: 'Fire Emergency Response',
+ Code: 'FIRE001',
+ Description: 'Standard fire emergency response protocol',
+ ProtocolText: 'Fire emergency response protocol content
',
+ CreatedOn: '2023-01-01T00:00:00Z',
+ CreatedByUserId: 'user1',
+ IsDisabled: false,
+ UpdatedOn: '2023-01-02T00:00:00Z',
+ UpdatedByUserId: 'user1',
+ MinimumWeight: 0,
+ State: 1,
+ Triggers: [],
+ Attachments: [],
+ Questions: [],
+ },
+ {
+ Id: '2',
+ DepartmentId: 'dept1',
+ Name: 'Basic Protocol',
+ Code: '',
+ Description: '',
+ ProtocolText: 'Basic protocol content
',
+ CreatedOn: '2023-01-01T00:00:00Z',
+ CreatedByUserId: 'user1',
+ IsDisabled: false,
+ UpdatedOn: '',
+ UpdatedByUserId: '',
+ MinimumWeight: 0,
+ State: 1,
+ Triggers: [],
+ Attachments: [],
+ Questions: [],
+ },
+];
+
+describe('ProtocolDetailsSheet', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ // Reset mock store to default state
+ Object.assign(mockProtocolsStore, {
+ protocols: [],
+ selectedProtocolId: null,
+ isDetailsOpen: false,
+ closeDetails: jest.fn(),
+ });
+ });
+
+ describe('Sheet Visibility', () => {
+ it('should render when isDetailsOpen is true and selectedProtocol exists', () => {
+ Object.assign(mockProtocolsStore, {
+ protocols: mockProtocols,
+ selectedProtocolId: '1',
+ isDetailsOpen: true,
+ });
+
+ render();
+
+ expect(screen.getByText('Fire Emergency Response')).toBeTruthy();
+ });
+ });
+
+ describe('Protocol Information Display', () => {
+ beforeEach(() => {
+ Object.assign(mockProtocolsStore, {
+ protocols: mockProtocols,
+ selectedProtocolId: '1',
+ isDetailsOpen: true,
+ });
+ });
+
+ it('should display protocol name in header', () => {
+ render();
+
+ expect(screen.getByText('Fire Emergency Response')).toBeTruthy();
+ });
+
+ it('should display protocol code when available', () => {
+ render();
+
+ expect(screen.getByText('FIRE001')).toBeTruthy();
+ });
+
+ it('should display protocol description when available', () => {
+ render();
+
+ expect(screen.getByText('Standard fire emergency response protocol')).toBeTruthy();
+ });
+
+ it('should display formatted date', () => {
+ render();
+
+ expect(screen.getByText('2023-01-01 12:00 UTC')).toBeTruthy();
+ });
+
+ it('should display protocol content in WebView', () => {
+ render();
+
+ const webview = screen.getByTestId('webview');
+ expect(webview).toBeTruthy();
+
+ const webviewContent = screen.getByTestId('webview-content');
+ expect(webviewContent).toBeTruthy();
+ expect(webviewContent.props.children).toContain('Fire emergency response protocol content
');
+ });
+ });
+
+ describe('Protocol without Optional Fields', () => {
+ beforeEach(() => {
+ Object.assign(mockProtocolsStore, {
+ protocols: mockProtocols,
+ selectedProtocolId: '2',
+ isDetailsOpen: true,
+ });
+ });
+
+ it('should not display code section when code is empty', () => {
+ render();
+
+ expect(screen.getByText('Basic Protocol')).toBeTruthy();
+ expect(screen.queryByText('FIRE001')).toBeFalsy();
+ });
+
+ it('should not display description section when description is empty', () => {
+ render();
+
+ expect(screen.getByText('Basic Protocol')).toBeTruthy();
+ expect(screen.queryByText('Standard fire emergency response protocol')).toBeFalsy();
+ });
+
+ it('should still display WebView with protocol content', () => {
+ render();
+
+ const webview = screen.getByTestId('webview');
+ expect(webview).toBeTruthy();
+
+ const webviewContent = screen.getByTestId('webview-content');
+ expect(webviewContent.props.children).toContain('Basic protocol content
');
+ });
+ });
+
+ describe('Close Functionality', () => {
+ beforeEach(() => {
+ Object.assign(mockProtocolsStore, {
+ protocols: mockProtocols,
+ selectedProtocolId: '1',
+ isDetailsOpen: true,
+ });
+ });
+
+ it('should have close button in header', () => {
+ render();
+
+ const closeButton = screen.getByTestId('close-button');
+ expect(closeButton).toBeTruthy();
+ });
+
+ it('should call closeDetails when close button is pressed', () => {
+ render();
+
+ const closeButton = screen.getByTestId('close-button');
+ fireEvent.press(closeButton);
+
+ expect(mockProtocolsStore.closeDetails).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('WebView Content', () => {
+ beforeEach(() => {
+ Object.assign(mockProtocolsStore, {
+ protocols: mockProtocols,
+ selectedProtocolId: '1',
+ isDetailsOpen: true,
+ });
+ });
+
+ it('should render WebView with proper HTML structure', () => {
+ render();
+
+ const webviewContent = screen.getByTestId('webview-content');
+ const htmlContent = webviewContent.props.children;
+
+ expect(htmlContent).toContain('');
+ expect(htmlContent).toContain('');
+ expect(htmlContent).toContain('');
+ expect(htmlContent).toContain('');
+ expect(htmlContent).toContain('');
+ expect(htmlContent).toContain('Fire emergency response protocol content
');
+ });
+
+ it('should include proper CSS styles for light theme', () => {
+ render();
+
+ const webviewContent = screen.getByTestId('webview-content');
+ const htmlContent = webviewContent.props.children;
+
+ expect(htmlContent).toContain('color: #1F2937'); // gray-800 for light theme
+ expect(htmlContent).toContain('background-color: #F9FAFB'); // light theme background
+ });
+
+ it('should include responsive CSS', () => {
+ render();
+
+ const webviewContent = screen.getByTestId('webview-content');
+ const htmlContent = webviewContent.props.children;
+
+ expect(htmlContent).toContain('max-width: 100%');
+ expect(htmlContent).toContain('font-family: system-ui, -apple-system, sans-serif');
+ });
+ });
+
+ describe('Dark Theme Support', () => {
+ it('should handle dark theme rendering', () => {
+ Object.assign(mockProtocolsStore, {
+ protocols: mockProtocols,
+ selectedProtocolId: '1',
+ isDetailsOpen: true,
+ });
+
+ render();
+
+ const webview = screen.getByTestId('webview');
+ expect(webview).toBeTruthy();
+
+ const webviewContent = screen.getByTestId('webview-content');
+ expect(webviewContent).toBeTruthy();
+
+ // The WebView should render with proper HTML structure
+ expect(webviewContent.props.children).toContain('');
+ });
+ });
+
+ describe('Date Display Logic', () => {
+ it('should prefer UpdatedOn over CreatedOn when both are available', () => {
+ Object.assign(mockProtocolsStore, {
+ protocols: mockProtocols,
+ selectedProtocolId: '1',
+ isDetailsOpen: true,
+ });
+
+ render();
+
+ expect(screen.getByText('2023-01-01 12:00 UTC')).toBeTruthy();
+ });
+
+ it('should fall back to CreatedOn when UpdatedOn is empty', () => {
+ Object.assign(mockProtocolsStore, {
+ protocols: mockProtocols,
+ selectedProtocolId: '2',
+ isDetailsOpen: true,
+ });
+
+ render();
+
+ expect(screen.getByText('2023-01-01 12:00 UTC')).toBeTruthy();
+ });
+ });
+
+ describe('HTML Content Handling', () => {
+ it('should strip HTML tags from description but keep them in WebView', () => {
+ const protocolWithHtml = {
+ ...mockProtocols[0],
+ Description: 'Description with HTML tags
',
+ ProtocolText: 'Protocol with HTML content
',
+ };
+
+ Object.assign(mockProtocolsStore, {
+ protocols: [protocolWithHtml],
+ selectedProtocolId: '1',
+ isDetailsOpen: true,
+ });
+
+ render();
+
+ // Description should be stripped of HTML
+ expect(screen.getByText('Description with HTML tags')).toBeTruthy();
+
+ // WebView should contain original HTML
+ const webviewContent = screen.getByTestId('webview-content');
+ expect(webviewContent.props.children).toContain('Protocol with HTML content
');
+ });
+ });
+
+ describe('Accessibility', () => {
+ beforeEach(() => {
+ Object.assign(mockProtocolsStore, {
+ protocols: mockProtocols,
+ selectedProtocolId: '1',
+ isDetailsOpen: true,
+ });
+ });
+
+ it('should be accessible for screen readers', () => {
+ render();
+
+ expect(screen.getByText('Fire Emergency Response')).toBeTruthy();
+ expect(screen.getByTestId('close-button')).toBeTruthy();
+ });
+
+ it('should support keyboard navigation', () => {
+ render();
+
+ const closeButton = screen.getByTestId('close-button');
+ fireEvent.press(closeButton);
+
+ expect(mockProtocolsStore.closeDetails).toHaveBeenCalled();
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/components/protocols/protocol-card.tsx b/src/components/protocols/protocol-card.tsx
index 125df91..e9eb4aa 100644
--- a/src/components/protocols/protocol-card.tsx
+++ b/src/components/protocols/protocol-card.tsx
@@ -16,7 +16,7 @@ interface ProtocolCardProps {
export const ProtocolCard: React.FC = ({ protocol, onPress }) => {
return (
- onPress(protocol.Id)}>
+ onPress(protocol.Id)} testID={`protocol-card-${protocol.Id}`}>
{protocol.Name}
diff --git a/src/components/protocols/protocol-details-sheet.tsx b/src/components/protocols/protocol-details-sheet.tsx
index 77c0c31..62bc205 100644
--- a/src/components/protocols/protocol-details-sheet.tsx
+++ b/src/components/protocols/protocol-details-sheet.tsx
@@ -39,7 +39,7 @@ export const ProtocolDetailsSheet: React.FC = () => {
{selectedProtocol.Name}
-