Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions jest-setup.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
import '@testing-library/react-native/extend-expect';

// Mock react-native-safe-area-context — its source build reads StyleSheet at import time,
// which explodes in suites that stub react-native with a minimal factory (e.g. navigation tests).
jest.mock('react-native-safe-area-context', () => {
const React = require('react');

const SafeAreaView = ({ children }: any) => React.createElement(React.Fragment, null, children);

return {
SafeAreaView,
SafeAreaProvider: ({ children }: any) => children,
Comment on lines +8 to +12

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use a typed interface for mock children.

The two mock components use any. This removes type checking for children. Define one MockChildrenProps interface with children?: ReactNode and use it for both components.

As per coding guidelines, **/*.{ts,tsx} requires precise types and interfaces and forbids any.

Proposed fix
+import type { ReactNode } from 'react';
+
+interface MockChildrenProps {
+  children?: ReactNode;
+}
+
 const SafeAreaView = ({ children }: any) => React.createElement(React.Fragment, null, children);
+const SafeAreaView = ({ children }: MockChildrenProps) => React.createElement(React.Fragment, null, children);

-    SafeAreaProvider: ({ children }: any) => children,
+    SafeAreaProvider: ({ children }: MockChildrenProps) => children,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@jest-setup.ts` around lines 8 - 12, Replace the any-typed props in
SafeAreaView and SafeAreaProvider with a shared MockChildrenProps interface
defining optional children as ReactNode, and use that interface for both mock
component parameters.

Source: Coding guidelines

useSafeAreaInsets: jest.fn(() => ({ top: 0, bottom: 0, left: 0, right: 0 })),
useSafeAreaFrame: jest.fn(() => ({ x: 0, y: 0, width: 375, height: 667 })),
initialWindowMetrics: {
insets: { top: 0, bottom: 0, left: 0, right: 0 },
frame: { x: 0, y: 0, width: 375, height: 667 },
},
Comment on lines +5 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

# Expect: every runtime import is provided by the mock.
rg -n -C 2 "react-native-safe-area-context" \
  --glob '*.ts' --glob '*.tsx' --glob '*.js' .

Repository: Resgrid/Unit

Length of output: 7338


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- jest-setup.ts ---'
cat -n jest-setup.ts | sed -n '1,35p'

printf '%s\n' '--- named imports from react-native-safe-area-context ---'
python3 - <<'PY'
import re
from pathlib import Path

for path in Path('.').rglob('*'):
    if not path.is_file() or path.suffix not in {'.ts', '.tsx', '.js'}:
        continue
    try:
        text = path.read_text()
    except UnicodeDecodeError:
        continue
    for match in re.finditer(
        r"""(?:import\s+(?:type\s+)?\{([^}]+)\}\s+from|require\()\s*['"]react-native-safe-area-context['"]""",
        text,
    ):
        if match.group(1) is not None:
            names = [
                re.sub(r'\s+', ' ', item.strip())
                for item in match.group(1).split(',')
                if item.strip()
            ]
            print(f'{path}: {", ".join(names)}')
        else:
            print(f'{path}: require(...)')
PY

printf '%s\n' '--- module export references in source and tests ---'
rg -n -C 1 \
  "SafeArea(View|Provider)|useSafeArea(Insets|Frame)|initialWindowMetrics|SafeAreaListener|useSafeArea" \
  --glob '*.ts' --glob '*.tsx' --glob '*.js' .

Repository: Resgrid/Unit

Length of output: 20016


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- non-type safe-area imports outside the repository source ---'
rg -n -C 2 \
  "from ['\"]react-native-safe-area-context['\"]|require\(['\"]react-native-safe-area-context['\"]\)" \
  node_modules \
  --glob '!**/*.d.ts' \
  --glob '!**/example/**' \
  | sed -n '1,240p'

printf '%s\n' '--- package resolution and Jest configuration ---'
rg -n -C 3 \
  "jest-setup|setupFiles|setupFilesAfterEnv|react-native-screens|expo-router|jest-expo" \
  package.json jest.config.* app.json app.config.* \
  --glob '!node_modules/**' 2>/dev/null || true

Repository: Resgrid/Unit

Length of output: 50369


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

python3 - <<'PY'
import re
from pathlib import Path

pattern = re.compile(
    r"""(?:import\s+(?:(?:type\s+)?\{([^}]+)\}|([^;\n]+?))\s+from|require\()\s*['"]react-native-safe-area-context['"]"""
)
seen = set()

for path in Path('node_modules').rglob('*'):
    if not path.is_file() or path.suffix in {'.map', '.d.ts'}:
        continue
    try:
        text = path.read_text(errors='ignore')
    except OSError:
        continue
    for line_no, line in enumerate(text.splitlines(), 1):
        if 'react-native-safe-area-context' not in line:
            continue
        match = pattern.search(line)
        if match:
            names = (match.group(1) or match.group(2) or 'require(...)').strip()
            item = (str(path), line_no, names, line.strip())
            if item not in seen:
                seen.add(item)
                print(f'{item[0]}:{item[1]}: {item[3]}')
PY

printf '%s\n' '--- Jest configuration ---'
for file in package.json jest.config.js jest.config.ts jest.config.cjs jest.config.mjs; do
  if [ -f "$file" ]; then
    printf '%s\n' "--- $file ---"
    rg -n -C 4 \
      "jest-setup|setupFiles|setupFilesAfterEnv|preset|react-native-screens|expo-router" \
      "$file" || true
  fi
done

Repository: Resgrid/Unit

Length of output: 8749


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

python3 - <<'PY'
import re
from pathlib import Path
from collections import defaultdict

uses = defaultdict(set)
for path in Path('node_modules').rglob('*'):
    if not path.is_file() or path.suffix in {'.map', '.d.ts'}:
        continue
    if 'example' in path.parts:
        continue
    try:
        text = path.read_text(errors='ignore')
    except OSError:
        continue
    if 'react-native-safe-area-context' not in text:
        continue

    for name in re.findall(r'_reactNativeSafeAreaContext\.([A-Za-z_$][\w$]*)', text):
        uses[str(path)].add(name)

    for line in text.splitlines():
        if 'react-native-safe-area-context' in line and (
            'import ' in line or 'require(' in line
        ):
            uses[str(path)].add(f'IMPORT: {line.strip()}')

for path, names in sorted(uses.items()):
    print(path)
    for name in sorted(names):
        print(f'  {name}')
PY

printf '%s\n' '--- direct references to missing candidate exports ---'
rg -n \
  "SafeAreaInsetsContext|SafeAreaFrameContext|useSafeAreaFrame|initialWindowMetrics|SafeAreaProvider|SafeAreaView|useSafeAreaInsets" \
  node_modules/expo-router/build node_modules/react-native-css/dist node_modules/@gluestack-ui/core/lib node_modules/react-native-screens/lib \
  --glob '!**/*.map' \
  --glob '!**/*.d.ts' \
  | sed -n '1,240p'

Repository: Resgrid/Unit

Length of output: 25314


Add SafeAreaInsetsContext to the global safe-area mock. Expo Router accesses SafeAreaInsetsContext.Consumer; the mock currently returns undefined, which can fail navigation suites during rendering.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@jest-setup.ts` around lines 5 - 18, Add SafeAreaInsetsContext to the
react-native-safe-area-context mock in jest.mock, exposing a Consumer compatible
with the zero-inset mock values so Expo Router can render safely. Preserve the
existing SafeAreaView, SafeAreaProvider, hook, and initialWindowMetrics
behavior.

};
});

// Mock @sentry/react-native — native module (RNSentry) is unavailable in Jest
jest.mock('@sentry/react-native', () => ({
captureException: jest.fn(),
Expand Down
5 changes: 3 additions & 2 deletions src/components/chat/new-conversation-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,10 @@ export function NewConversationSheet({ isOpen, onClose, mode, onCreated }: NewCo
);

const createGroup = useCallback(async () => {
if (!groupName.trim() || selected.size === 0) return;
if (selected.size === 0) return;
setSubmitting(true);
try {
// Name is optional — the server auto-names the group after its members when empty.
const response = await createAdHocChannel({ Name: groupName.trim(), MemberUserIds: Array.from(selected) });
if (response.Data?.ChatChannelId) {
onCreated(response.Data.ChatChannelId);
Expand Down Expand Up @@ -191,7 +192,7 @@ export function NewConversationSheet({ isOpen, onClose, mode, onCreated }: NewCo
)}

{mode === 'group' ? (
<Button className="mb-2 w-full bg-primary-600" onPress={createGroup} isDisabled={submitting || !groupName.trim() || selected.size === 0}>
<Button className="mb-2 w-full bg-primary-600" onPress={createGroup} isDisabled={submitting || selected.size === 0}>
<Users size={18} color="#ffffff" />
<ButtonText className="ml-2">{t('chat.create_group_with', { count: selected.size })}</ButtonText>
</Button>
Expand Down
11 changes: 11 additions & 0 deletions src/hooks/__tests__/use-signalr-lifecycle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ describe('useSignalRLifecycle', () => {
const mockDisconnectUpdateHub = jest.fn();
const mockConnectGeolocationHub = jest.fn();
const mockDisconnectGeolocationHub = jest.fn();
const mockConnectChatHub = jest.fn();
const mockDisconnectChatHub = jest.fn();
Comment on lines +20 to +21

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

rg -n -C 6 '\b(connectChatHub|disconnectChatHub)\b' src/hooks src/stores

Repository: Resgrid/Unit

Length of output: 13383


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- test file ---'
cat -n src/hooks/__tests__/use-signalr-lifecycle.test.tsx | sed -n '1,190p'

printf '%s\n' '--- hook implementation ---'
cat -n src/hooks/use-signalr-lifecycle.ts | sed -n '1,155p'

printf '%s\n' '--- relevant test assertions ---'
rg -n -C 4 'mockConnectChatHub|mockDisconnectChatHub|Promise\.allSettled|reject|resolve|connectChatHub|disconnectChatHub' \
  src/hooks/__tests__/use-signalr-lifecycle.test.tsx

Repository: Resgrid/Unit

Length of output: 16623


🏁 Script executed:

node - <<'JS'
const plainMockResult = undefined;
const promiseMockResult = Promise.resolve(undefined);

Promise.allSettled([plainMockResult, promiseMockResult]).then((results) => {
  console.log(JSON.stringify(results));
});
JS

Repository: Resgrid/Unit

Length of output: 198


Make the Chat hub mocks Promise-compatible.

The hook currently handles plain undefined values with Promise.allSettled, so existing tests do not fail. However, the production methods return Promise<void>. Use jest.fn().mockResolvedValue(undefined) to preserve the async contract and support completion or rejection tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/__tests__/use-signalr-lifecycle.test.tsx` around lines 20 - 21,
Update the mockConnectChatHub and mockDisconnectChatHub Jest mocks to use
mockResolvedValue(undefined), preserving the production Promise<void> contract
and enabling async completion or rejection tests.


// Create shared state for app lifecycle that can be updated
let appLifecycleState = {
Expand Down Expand Up @@ -48,15 +50,21 @@ describe('useSignalRLifecycle', () => {
disconnectUpdateHub: mockDisconnectUpdateHub,
connectGeolocationHub: mockConnectGeolocationHub,
disconnectGeolocationHub: mockDisconnectGeolocationHub,
connectChatHub: mockConnectChatHub,
disconnectChatHub: mockDisconnectChatHub,
isUpdateHubConnected: false,
isGeolocationHubConnected: false,
isChatHubConnected: false,
} as any) : {
connectUpdateHub: mockConnectUpdateHub,
disconnectUpdateHub: mockDisconnectUpdateHub,
connectGeolocationHub: mockConnectGeolocationHub,
disconnectGeolocationHub: mockDisconnectGeolocationHub,
connectChatHub: mockConnectChatHub,
disconnectChatHub: mockDisconnectChatHub,
isUpdateHubConnected: false,
isGeolocationHubConnected: false,
isChatHubConnected: false,
} as any);

// Also mock getState for direct store access
Expand All @@ -65,8 +73,11 @@ describe('useSignalRLifecycle', () => {
disconnectUpdateHub: mockDisconnectUpdateHub,
connectGeolocationHub: mockConnectGeolocationHub,
disconnectGeolocationHub: mockDisconnectGeolocationHub,
connectChatHub: mockConnectChatHub,
disconnectChatHub: mockDisconnectChatHub,
isUpdateHubConnected: false,
isGeolocationHubConnected: false,
isChatHubConnected: false,
});

// Mock useAppLifecycle to return shared state
Expand Down
10 changes: 10 additions & 0 deletions src/lib/__tests__/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ import { Platform, Linking } from 'react-native';
import { describe, expect, it, beforeEach, afterEach } from '@jest/globals';
import { openMapsWithDirections, openMapsWithAddress } from '../navigation';

// Mock expo-router — the real module pulls in its vendored react-navigation tree, which
// needs far more of react-native than the minimal stub below provides.
jest.mock('expo-router', () => ({
router: {
push: jest.fn(),
replace: jest.fn(),
navigate: jest.fn(),
},
}));

// Mock React Native modules
jest.mock('react-native', () => ({
Platform: {
Expand Down
6 changes: 6 additions & 0 deletions src/services/__tests__/push-notification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ jest.mock('react-native', () => ({
},
}));

// Mock the navigation lib — the real module imports expo-router, whose import chain
// needs far more of react-native/expo than the minimal stubs above provide.
jest.mock('@/lib/navigation', () => ({
routerPushWithRetry: jest.fn().mockResolvedValue(undefined),
}));

// Mock other dependencies
jest.mock('@/lib/logging', () => ({
logger: {
Expand Down
13 changes: 13 additions & 0 deletions src/stores/chat/__tests__/hub-invoke-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* JoinChannel(string channelId, int? asUnitId)
* Typing(string channelId, string displayName, bool isTyping, int? asUnitId)
* MarkRead(string channelId, long seq, int? asUnitId)
* SetActiveChannel(string channelId, int? asUnitId)
*/
const mockInvoke = jest.fn().mockResolvedValue(undefined);

Expand Down Expand Up @@ -75,6 +76,18 @@ describe('chat hub invocations', () => {
expect(mockInvoke).toHaveBeenCalledWith('chatHub', 'JoinChannel', 'channel-1', 42);
});

it('sends both SetActiveChannel arguments', () => {
useChatStore.getState().setActiveChannel('channel-1');

expect(mockInvoke).toHaveBeenCalledWith('chatHub', 'SetActiveChannel', 'channel-1', 42);
});

it('clears the active channel marker with a null channelId and the unit id', () => {
useChatStore.getState().setActiveChannel(null);

expect(mockInvoke).toHaveBeenCalledWith('chatHub', 'SetActiveChannel', null, 42);
});

it('sends all four Typing arguments in hub order', () => {
useChatStore.getState().sendTyping('channel-1', true);

Expand Down
6 changes: 6 additions & 0 deletions src/stores/chat/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,10 @@ export const useChatStore = create<ChatState>()(

setActiveChannel: (channelId: string | null) => {
set({ activeChannelId: channelId });
// Tell the hub which conversation is on screen so it suppresses chat push
// notifications for that channel — including the unit-device push when the
// active unit id is supplied. A null channelId clears the marker.
void safeInvoke('SetActiveChannel', channelId ?? null, activeUnitIdNumber() ?? null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

WHAT: The void prefix discards the return value of safeInvoke, which (given the void) likely returns a Promise whose rejections are now unhandled. WHY: Rule [1] requires every async operation be guarded; a fire-and-forget void with no .catch() leaves rejections unhandled and can crash the app or hide failures. HOW: Either await inside a try/catch (if the caller can be async) or chain .catch(err => logger.error('SetActiveChannel failed', { channelId, err })) before discarding, or ensure safeInvoke itself swallows/logs internally and document that.

Also found in:

  • src/stores/chat/store.ts:861-861

Kody rule violation: Handle async operations with proper error handling

Prompt for LLM

File src/stores/chat/store.ts:

Line 286:

WHAT: The `void` prefix discards the return value of `safeInvoke`, which (given the `void`) likely returns a Promise whose rejections are now unhandled. WHY: Rule [1] requires every async operation be guarded; a fire-and-forget `void` with no `.catch()` leaves rejections unhandled and can crash the app or hide failures. HOW: Either `await` inside a try/catch (if the caller can be async) or chain `.catch(err => logger.error('SetActiveChannel failed', { channelId, err }))` before discarding, or ensure `safeInvoke` itself swallows/logs internally and document that.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

},

// ------------------------------------------------------------------
Expand Down Expand Up @@ -853,6 +857,8 @@ export const useChatStore = create<ChatState>()(
if (activeChannelId) {
void get().joinChannel(activeChannelId);
void get().loadNewerMessages(activeChannelId);
// Re-assert the active-channel marker so push suppression survives reconnects.
void safeInvoke('SetActiveChannel', activeChannelId, activeUnitIdNumber() ?? null);
}
},

Expand Down
2 changes: 1 addition & 1 deletion src/translations/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@
"flag_reason": "لماذا تبلغ عن هذا؟",
"flag_sensitive": "معلومات حساسة",
"flag_spam": "بريد عشوائي",
"group_name": "اسم المجموعة",
"group_name": "اسم المجموعة (اختياري)",
"is_typing": "{{name}} يكتب...",
"load_people_failed": "تعذر تحميل الأشخاص",
"message_deleted": "تم حذف هذه الرسالة",
Expand Down
2 changes: 1 addition & 1 deletion src/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@
"flag_reason": "Warum melden Sie dies?",
"flag_sensitive": "Sensible Informationen",
"flag_spam": "Spam",
"group_name": "Gruppenname",
"group_name": "Gruppenname (optional)",
"is_typing": "{{name}} schreibt...",
"load_people_failed": "Personen konnten nicht geladen werden",
"message_deleted": "Diese Nachricht wurde gelöscht",
Expand Down
2 changes: 1 addition & 1 deletion src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@
"flag_reason": "Why are you reporting this?",
"flag_sensitive": "Sensitive information",
"flag_spam": "Spam",
"group_name": "Group name",
"group_name": "Group name (optional)",
"is_typing": "{{name}} is typing...",
"load_people_failed": "Could not load people",
"message_deleted": "This message was deleted",
Expand Down
2 changes: 1 addition & 1 deletion src/translations/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@
"flag_reason": "¿Por qué informas de esto?",
"flag_sensitive": "Información sensible",
"flag_spam": "Spam",
"group_name": "Nombre del grupo",
"group_name": "Nombre del grupo (opcional)",
"is_typing": "{{name}} está escribiendo...",
"load_people_failed": "No se pudieron cargar las personas",
"message_deleted": "Este mensaje fue eliminado",
Expand Down
2 changes: 1 addition & 1 deletion src/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@
"flag_reason": "Pourquoi le signalez-vous ?",
"flag_sensitive": "Informations sensibles",
"flag_spam": "Spam",
"group_name": "Nom du groupe",
"group_name": "Nom du groupe (optionnel)",
"is_typing": "{{name}} est en train d'écrire...",
"load_people_failed": "Impossible de charger les personnes",
"message_deleted": "Ce message a été supprimé",
Expand Down
2 changes: 1 addition & 1 deletion src/translations/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@
"flag_reason": "Perché lo stai segnalando?",
"flag_sensitive": "Informazioni sensibili",
"flag_spam": "Spam",
"group_name": "Nome del gruppo",
"group_name": "Nome del gruppo (opzionale)",
"is_typing": "{{name}} sta scrivendo...",
"load_people_failed": "Impossibile caricare le persone",
"message_deleted": "Questo messaggio è stato eliminato",
Expand Down
2 changes: 1 addition & 1 deletion src/translations/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@
"flag_reason": "Dlaczego to zgłaszasz?",
"flag_sensitive": "Informacje poufne",
"flag_spam": "Spam",
"group_name": "Nazwa grupy",
"group_name": "Nazwa grupy (opcjonalnie)",
"is_typing": "{{name}} pisze...",
"load_people_failed": "Nie można załadować osób",
"message_deleted": "Ta wiadomość została usunięta",
Expand Down
2 changes: 1 addition & 1 deletion src/translations/sv.json
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@
"flag_reason": "Varför rapporterar du detta?",
"flag_sensitive": "Känslig information",
"flag_spam": "Skräppost",
"group_name": "Gruppnamn",
"group_name": "Gruppnamn (valfritt)",
"is_typing": "{{name}} skriver...",
"load_people_failed": "Det gick inte att ladda personer",
"message_deleted": "Detta meddelande har raderats",
Expand Down
2 changes: 1 addition & 1 deletion src/translations/uk.json
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@
"flag_reason": "Чому ви це повідомляєте?",
"flag_sensitive": "Конфіденційна інформація",
"flag_spam": "Спам",
"group_name": "Назва групи",
"group_name": "Назва групи (необов'язково)",
"is_typing": "{{name}} пише...",
"load_people_failed": "Не вдалося завантажити людей",
"message_deleted": "Це повідомлення було видалено",
Expand Down
Loading