diff --git a/__tests__/rntl/screens/DetectionResultsScreen.test.tsx b/__tests__/rntl/screens/DetectionResultsScreen.test.tsx index 11a6104f0..d202d056f 100644 --- a/__tests__/rntl/screens/DetectionResultsScreen.test.tsx +++ b/__tests__/rntl/screens/DetectionResultsScreen.test.tsx @@ -14,8 +14,8 @@ */ import React from 'react'; -import { render, fireEvent, waitFor } from '@testing-library/react-native'; -import { Alert } from 'react-native'; +import { render as renderComponent, fireEvent, waitFor, act } from '@testing-library/react-native'; +import { Alert, StyleSheet } from 'react-native'; // --------------------------------------------------------------------------- // Navigation mocks (must be before component import) @@ -118,6 +118,31 @@ jest.mock('../../../src/stores/wildlifeStore', () => ({ // --------------------------------------------------------------------------- import { DetectionResultsScreen } from '../../../src/screens/DetectionResultsScreen'; +const defaultGeometry = { + photo: { width: 1200, height: 800 }, + frame: { width: 600, height: 900 }, +}; + +function loadPhoto( + screen: ReturnType, + geometry = defaultGeometry, +) { + const photo = screen.getByTestId('observation-photo'); + fireEvent(photo, 'layout', { + persist: () => {}, + nativeEvent: { layout: { x: 0, y: 0, ...geometry.frame } }, + }); + fireEvent(photo, 'load', { nativeEvent: { source: geometry.photo } }); +} + +function render(element: React.ReactElement) { + const screen = renderComponent(element); + if (screen.queryByTestId('observation-photo')) { + loadPhoto(screen); + } + return screen; +} + /** Auto-presses "Save Anyway" on the review-confirmation dialog -- the * default for tests that only care whether the save itself happens. Tests * asserting the cancel path or the post-save error alert install their own @@ -231,6 +256,190 @@ describe('DetectionResultsScreen', () => { expect(queryByText('zebra_plains')).toBeNull(); }); + it.each([ + { + name: 'landscape image in a portrait frame', + photo: { width: 1200, height: 800 }, + frame: { width: 600, height: 900 }, + expected: { left: 0, top: 250, width: 600, height: 400 }, + }, + { + name: 'portrait image in a landscape frame', + photo: { width: 800, height: 1200 }, + frame: { width: 900, height: 600 }, + expected: { left: 250, top: 0, width: 400, height: 600 }, + }, + { + name: 'square image in a portrait frame', + photo: { width: 800, height: 800 }, + frame: { width: 600, height: 900 }, + expected: { left: 0, top: 150, width: 600, height: 600 }, + }, + { + name: 'matching aspect ratios', + photo: { width: 1200, height: 800 }, + frame: { width: 600, height: 400 }, + expected: { left: 0, top: 0, width: 600, height: 400 }, + }, + ])('aligns boxes to the contained $name', ({ photo, frame, expected }) => { + const screen = renderComponent(); + loadPhoto(screen, { photo, frame }); + + expect(StyleSheet.flatten(screen.getByTestId('detection-overlay').props.style)) + .toMatchObject(expected); + expect(StyleSheet.flatten(screen.getByTestId('bounding-box-det-1').props.style)) + .toMatchObject({ left: '10%', top: '20%', width: '30%', height: '40%' }); + expect(mockObservations[0].detections[0].boundingBox) + .toEqual({ x: 0.1, y: 0.2, width: 0.3, height: 0.4 }); + }); + + it('keeps boxes hidden until image dimensions and layout are available', () => { + const screen = renderComponent(); + expect(screen.queryByTestId('bounding-box-det-1')).toBeNull(); + fireEvent(screen.getByTestId('observation-photo'), 'load', { + nativeEvent: { source: defaultGeometry.photo }, + }); + expect(screen.queryByTestId('bounding-box-det-1')).toBeNull(); + fireEvent(screen.getByTestId('observation-photo'), 'layout', { + persist: () => {}, + nativeEvent: { layout: { x: 0, y: 0, ...defaultGeometry.frame } }, + }); + expect(screen.getByTestId('bounding-box-det-1')).toBeTruthy(); + }); + + it('recomputes image offsets after the available frame resizes', () => { + const screen = render(); + fireEvent(screen.getByTestId('observation-photo'), 'layout', { + persist: () => {}, + nativeEvent: { layout: { x: 0, y: 0, width: 900, height: 600 } }, + }); + + expect(StyleSheet.flatten(screen.getByTestId('detection-overlay').props.style)) + .toMatchObject({ left: 0, top: 0, width: 900, height: 600 }); + }); + + it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])( + 'does not show boxes for an invalid image width %s', + width => { + const screen = renderComponent(); + loadPhoto(screen, { + photo: { width, height: 800 }, frame: defaultGeometry.frame, + }); + expect(screen.queryByTestId('bounding-box-det-1')).toBeNull(); + }, + ); + + it('hides boxes when the image fails or its layout has no area', () => { + const screen = render(); + fireEvent(screen.getByTestId('observation-photo'), 'error', { + nativeEvent: { error: 'Image unavailable' }, + }); + expect(screen.queryByTestId('bounding-box-det-1')).toBeNull(); + loadPhoto(screen); + expect(screen.getByTestId('bounding-box-det-1')).toBeTruthy(); + fireEvent(screen.getByTestId('observation-photo'), 'layout', { + persist: () => {}, + nativeEvent: { layout: { x: 0, y: 0, width: 0, height: 900 } }, + }); + expect(screen.queryByTestId('bounding-box-det-1')).toBeNull(); + }); + + it('does not reuse image geometry after the photo source changes', () => { + const screen = render(); + const previousPhoto = screen.getByTestId('observation-photo'); + const previousLoad = previousPhoto.props.onLoad; + const previousError = previousPhoto.props.onError; + mockObservations = [{ ...makeObservation(), photoUri: 'file:///test/portrait.jpg' }]; + screen.rerender(); + expect(screen.queryByTestId('bounding-box-det-1')).toBeNull(); + loadPhoto(screen, { + photo: { width: 800, height: 1200 }, frame: defaultGeometry.frame, + }); + const expected = { left: 0, top: 0, width: 600, height: 900 }; + expect(StyleSheet.flatten(screen.getByTestId('detection-overlay').props.style)) + .toMatchObject(expected); + act(() => { + previousLoad({ nativeEvent: { source: defaultGeometry.photo } }); + previousError({ nativeEvent: { error: 'Old request failed' } }); + }); + expect(StyleSheet.flatten(screen.getByTestId('detection-overlay').props.style)) + .toMatchObject(expected); + }); + + it('does not display detection boxes without a photo', () => { + mockObservations = [{ ...makeObservation(), photoUri: '' }]; + const screen = renderComponent(); + expect(screen.queryByTestId('observation-photo')).toBeNull(); + expect(screen.queryByTestId('bounding-box-det-1')).toBeNull(); + }); + + it.each(['missing', 'failed', 'loading'] as const)( + 'keeps each detection reviewable when the photo is %s', + async state => { + mockObservations = [{ + ...makeObservation([makeDetection(), makeDetection({ id: 'det-2' })]), + photoUri: state === 'missing' ? '' : 'file:///test/photo.jpg', + }]; + const screen = renderComponent(); + if (state === 'failed') { + fireEvent(screen.getByTestId('observation-photo'), 'error', { + nativeEvent: { error: 'Decode failed' }, + }); + } + expect(screen.queryByTestId('bounding-box-det-1')).toBeNull(); + expect(screen.getByRole('button', { name: 'Review detection 1' })).toBeTruthy(); + fireEvent.changeText(screen.getByTestId('observation-notes-input'), ' Test notes '); + fireEvent.press(screen.getByRole('button', { name: 'Review detection 2' })); + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('MatchReview', { + observationId: 'obs-1', detectionId: 'det-2', + })); + expect(mockUpdateObservationNotes).toHaveBeenCalledWith('obs-1', 'Test notes'); + expect(mockObservations[0].detections[1].matchResult.reviewStatus).toBe('pending'); + }, + ); + + it('offers a retry after decode failure and restores aligned boxes when it loads', () => { + const screen = render(); + const oldPhoto = screen.getByTestId('observation-photo'); + const oldLoad = oldPhoto.props.onLoad; + const oldError = oldPhoto.props.onError; + fireEvent(screen.getByTestId('observation-photo'), 'error', { + nativeEvent: { error: 'Private file path must not be displayed' }, + }); + expect(screen.getByText('Photo unavailable')).toBeTruthy(); + expect(screen.queryByText('Private file path must not be displayed')).toBeNull(); + expect(screen.queryByTestId('detection-overlay')).toBeNull(); + fireEvent.press(screen.getByRole('button', { name: 'Retry photo' })); + expect(screen.queryByText('Photo unavailable')).toBeNull(); + expect(screen.getByRole('button', { name: 'Review detection 1' })).toBeTruthy(); + loadPhoto(screen); + act(() => { + oldError({ nativeEvent: { error: 'Old attempt failed' } }); + oldLoad({ nativeEvent: { source: { width: 800, height: 1200 } } }); + }); + expect(screen.getByTestId('bounding-box-det-1')).toBeTruthy(); + expect(StyleSheet.flatten(screen.getByTestId('detection-overlay').props.style)) + .toMatchObject({ left: 0, top: 250, width: 600, height: 400 }); + expect(screen.queryByRole('button', { name: 'Review detection 1' })).toBeNull(); + }); + + it('shows a missing-photo state without a nonfunctional retry', () => { + mockObservations = [{ ...makeObservation(), photoUri: '' }]; + const screen = renderComponent(); + expect(screen.getByText('Photo unavailable')).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Retry photo' })).toBeNull(); + expect(screen.getByRole('button', { name: 'Review detection 1' })).toBeTruthy(); + }); + + it('does not bypass note persistence when using fallback review', async () => { + mockObservations = [{ ...makeObservation(), photoUri: '' }]; + mockUpdateObservationNotes.mockRejectedValueOnce(new Error('disk full')); + const screen = renderComponent(); + fireEvent.press(screen.getByRole('button', { name: 'Review detection 1' })); + await waitFor(() => expect(Alert.alert).toHaveBeenCalledWith('Could not save observation', 'disk full')); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + // ========================================================================== // Navigation // ========================================================================== diff --git a/android/app/build.gradle b/android/app/build.gradle index 8b271836c..ae2623584 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -112,8 +112,8 @@ android { applicationId "org.ganesha.elebook" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 1787551545 - versionName "0.1.0-field.4" + versionCode 1787551546 + versionName "0.1.0-field.5" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" // Consumed by react-native-app-auth's bundled RedirectUriReceiverActivity // to catch the org.ganesha.elebook://oauthredirect callback from the diff --git a/ios/OffgridMobile/AppDelegate.swift b/ios/OffgridMobile/AppDelegate.swift index c9940513b..160ab5f98 100644 --- a/ios/OffgridMobile/AppDelegate.swift +++ b/ios/OffgridMobile/AppDelegate.swift @@ -41,6 +41,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, RNAppAuthAuthorizationFlo _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { + guard Self.shouldStartReactNative( + environment: ProcessInfo.processInfo.environment, + testRuntimeLoaded: NSClassFromString("XCTestCase") != nil + ) else { + return true + } + let delegate = ReactNativeDelegate() let factory = RCTReactNativeFactory(delegate: delegate) delegate.dependencyProvider = RCTAppDependencyProvider() @@ -58,6 +65,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate, RNAppAuthAuthorizationFlo return true } + + static func shouldStartReactNative( + environment: [String: String], + testRuntimeLoaded: Bool + ) -> Bool { +#if DEBUG + return environment["XCTestConfigurationFilePath"] == nil && !testRuntimeLoaded +#else + return true +#endif + } } class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate { diff --git a/ios/OffgridMobileTests/OffgridMobileTests.swift b/ios/OffgridMobileTests/OffgridMobileTests.swift index 3737d0299..dc9398ed7 100644 --- a/ios/OffgridMobileTests/OffgridMobileTests.swift +++ b/ios/OffgridMobileTests/OffgridMobileTests.swift @@ -612,7 +612,9 @@ final class DownloadManagerModuleTests: XCTestCase { multiFileDestDir: nil, isMultiFile: false ) - module.downloads[100] = info + module.queue.sync(flags: .barrier) { + module.downloads[100] = info + } let exp = expectation(description: "getActiveDownloads returns completed entry") module.getActiveDownloads( @@ -657,7 +659,9 @@ final class DownloadManagerModuleTests: XCTestCase { multiFileDestDir: nil, isMultiFile: false ) - module.downloads[200] = info + module.queue.sync(flags: .barrier) { + module.downloads[200] = info + } let exp = expectation(description: "moveCompletedDownload moves file") module.moveCompletedDownload( @@ -707,7 +711,9 @@ final class DownloadManagerModuleTests: XCTestCase { multiFileDestDir: nil, isMultiFile: false ) - module.downloads[300] = info + module.queue.sync(flags: .barrier) { + module.downloads[300] = info + } let exp = expectation(description: "moveCompletedDownload rejects not-completed download") module.moveCompletedDownload( @@ -734,6 +740,29 @@ final class DownloadManagerModuleTests: XCTestCase { /// the build itself would fail — making this test a compile-time guard. final class AppDelegateBackgroundSessionTests: XCTestCase { + @MainActor + func testNormalLaunchStillStartsReactNative() { + XCTAssertTrue(AppDelegate.shouldStartReactNative(environment: [:], testRuntimeLoaded: false)) + } + + @MainActor + func testNativeTestMarkersSkipReactNativeStartup() { + XCTAssertFalse(AppDelegate.shouldStartReactNative( + environment: ["XCTestConfigurationFilePath": "/tmp/native.xctestconfiguration"], + testRuntimeLoaded: false + )) + XCTAssertFalse(AppDelegate.shouldStartReactNative(environment: [:], testRuntimeLoaded: true)) + } + + @MainActor + func testNativeTestHostDoesNotCreateAReactRuntime() { + let delegate = AppDelegate() + XCTAssertTrue(delegate.application(UIApplication.shared, didFinishLaunchingWithOptions: nil)) + XCTAssertNil(delegate.reactNativeFactory) + XCTAssertNil(delegate.reactNativeDelegate) + XCTAssertNil(delegate.window) + } + func testAppDelegateRespondsToBackgroundURLSessionSelector() { let appDelegate = AppDelegate() let responds = appDelegate.responds( diff --git a/package-lock.json b/package-lock.json index 7fddbe28f..f745563d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "offgrid-mobile", - "version": "0.1.0-field.4", + "version": "0.1.0-field.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "offgrid-mobile", - "version": "0.1.0-field.4", + "version": "0.1.0-field.5", "hasInstallScript": true, "dependencies": { "@noble/hashes": "1.8.0", diff --git a/package.json b/package.json index 16f333a4d..37c2329c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "offgrid-mobile", - "version": "0.1.0-field.4", + "version": "0.1.0-field.5", "private": true, "scripts": { "configure": "node scripts/configure-app.js", diff --git a/src/screens/DetectionResultsScreen/DetectionPhoto.tsx b/src/screens/DetectionResultsScreen/DetectionPhoto.tsx new file mode 100644 index 000000000..c4133683b --- /dev/null +++ b/src/screens/DetectionResultsScreen/DetectionPhoto.tsx @@ -0,0 +1,130 @@ +import React, { useState } from 'react'; +import { FlatList, Image, Text, TouchableOpacity, View } from 'react-native'; +import Icon from 'react-native-vector-icons/Feather'; +import type { Detection } from '../../types/wildlife'; +import { useThemedStyles, useTheme } from '../../theme'; +import { toDisplayUri } from '../../utils/imageUri'; +import { BoundingBoxOverlay } from './BoundingBoxOverlay'; +import { createStyles } from './styles'; + +interface ImageSize { + width: number; + height: number; +} + +interface DetectionPhotoProps { + photoUri: string; + detections: Detection[]; + onBoxPress: (detectionId: string) => void; + resolveName: (individualId: string | null) => string | null; +} + +function containedFrame(layout: ImageSize | null, image: ImageSize | null) { + if (!layout || !image || ![layout.width, layout.height, image.width, image.height] + .every(value => Number.isFinite(value) && value > 0)) { + return null; + } + const scale = Math.min(layout.width / image.width, layout.height / image.height); + const width = image.width * scale; + const height = image.height * scale; + return { + left: (layout.width - width) / 2, + top: (layout.height - height) / 2, + width, + height, + }; +} + +export const DetectionPhoto: React.FC = ({ + photoUri, detections, onBoxPress, resolveName, +}) => { + const styles = useThemedStyles(createStyles); + const { colors } = useTheme(); + const [layout, setLayout] = useState(null); + const [image, setImage] = useState<{ + attempt: number; + size: ImageSize | null; + failed: boolean; + }>({ attempt: 0, size: null, failed: false }); + const { attempt } = image; + const frame = containedFrame(layout, image.size); + + return ( + <> + {photoUri ? ( + setLayout(nativeEvent.layout)} + onLoad={({ nativeEvent }) => { + const { width, height } = nativeEvent.source; + const valid = [width, height].every(value => Number.isFinite(value) && value > 0); + setImage(previous => previous.attempt === attempt + ? { ...previous, size: valid ? { width, height } : null, failed: !valid } + : previous); + }} + onError={() => setImage(previous => previous.attempt === attempt + ? { ...previous, size: null, failed: true } + : previous)} + /> + ) : null} + {frame && ( + + {detections.map(detection => ( + onBoxPress(detection.id)} + resolveName={resolveName} + /> + ))} + + )} + {!frame && ( + + + {image.failed || !photoUri ? 'Photo unavailable' : 'Loading photo'} + + {photoUri && image.failed ? ( + setImage(previous => ({ + attempt: previous.attempt + 1, size: null, failed: false, + }))} + > + + Retry photo + + ) : null} + detection.id} + style={styles.photoReviewList} + renderItem={({ item, index }) => ( + onBoxPress(item.id)} + > + + {`Review detection ${index + 1}: ${item.species}`} + + + + )} + /> + + )} + + ); +}; diff --git a/src/screens/DetectionResultsScreen/index.tsx b/src/screens/DetectionResultsScreen/index.tsx index 70521c96a..aa28cd51b 100644 --- a/src/screens/DetectionResultsScreen/index.tsx +++ b/src/screens/DetectionResultsScreen/index.tsx @@ -2,7 +2,6 @@ import React, { useCallback, useMemo, useState } from 'react'; import { ActivityIndicator, Alert, - Image, KeyboardAvoidingView, Platform, Text, @@ -19,9 +18,8 @@ import { useThemedStyles, useTheme } from '../../theme'; import { useWildlifeStore } from '../../stores'; import { useIndividualNameResolver } from '../../hooks/useIndividualNameResolver'; import type { RootStackParamList } from '../../navigation/types'; -import { toDisplayUri } from '../../utils/imageUri'; import { SPACING } from '../../constants'; -import { BoundingBoxOverlay } from './BoundingBoxOverlay'; +import { DetectionPhoto } from './DetectionPhoto'; import { createStyles } from './styles'; type NavigationProp = NativeStackNavigationProp< @@ -155,24 +153,15 @@ export const DetectionResultsScreen: React.FC = () => { style={styles.photoContainer} behavior={Platform.OS === 'ios' ? 'padding' : undefined} > - {observation?.photoUri ? ( - ) : null} - - {detections.map(detection => ( - handleBoxPress(detection.id)} - resolveName={resolveName} - /> - ))} - ({ }, overlayContainer: { position: 'absolute' as const, + }, + photoFallback: { + position: 'absolute' as const, top: 0, - left: 0, right: 0, bottom: 0, + left: 0, + padding: SPACING.lg, + gap: SPACING.md, + backgroundColor: colors.background, + }, + photoStatus: { + ...TYPOGRAPHY.body, + color: colors.textSecondary, + }, + photoRetry: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + alignSelf: 'flex-start' as const, + paddingVertical: SPACING.md, + gap: SPACING.sm, + }, + photoActionText: { + ...TYPOGRAPHY.body, + color: colors.primary, + }, + photoReviewList: { + flex: 1, + }, + photoReviewRow: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + paddingVertical: SPACING.md, + gap: SPACING.sm, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + photoReviewText: { + ...TYPOGRAPHY.body, + color: colors.text, + flex: 1, }, footer: { paddingHorizontal: SPACING.lg,