Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
213 changes: 211 additions & 2 deletions __tests__/rntl/screens/DetectionResultsScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<typeof renderComponent>,
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
Expand Down Expand Up @@ -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(<DetectionResultsScreen />);
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(<DetectionResultsScreen />);
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(<DetectionResultsScreen />);
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(<DetectionResultsScreen />);
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(<DetectionResultsScreen />);
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(<DetectionResultsScreen />);
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(<DetectionResultsScreen />);
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(<DetectionResultsScreen />);
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(<DetectionResultsScreen />);
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(<DetectionResultsScreen />);
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(<DetectionResultsScreen />);
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(<DetectionResultsScreen />);
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
// ==========================================================================
Expand Down
4 changes: 2 additions & 2 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions ios/OffgridMobile/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 {
Expand Down
35 changes: 32 additions & 3 deletions ios/OffgridMobileTests/OffgridMobileTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading
Loading