Files
iNaturalistReactNative/tests/unit/components/PhotoSharing.test.js
Ryan Stelly b78be9243d lint rule & autofix for "trailing comma" (#3299)
* (lint) MOB-1063 enforce trailing commas

* autofix trailing commas

* manually fix newly introduced maxlen violations

* add trailing comma convention to i18n build
2025-12-22 20:17:13 -06:00

261 lines
7.8 KiB
JavaScript

import { CommonActions } from "@react-navigation/native";
import { act, waitFor } from "@testing-library/react-native";
import PhotoSharing from "components/PhotoSharing";
import React from "react";
import { Alert } from "react-native";
import Observation from "realmModels/Observation";
import { useLayoutPrefs } from "sharedHooks";
import useStore from "stores/useStore";
import { renderComponent } from "tests/helpers/render";
jest.mock( "realmModels/Observation" );
jest.mock( "stores/useStore" );
jest.mock( "sharedHooks" );
const JPEG = "image/jpeg";
const mockNavigate = jest.fn( );
const mockDispatch = jest.fn( );
const mockGoBack = jest.fn( );
const mockAddListener = jest.fn( ( ) => ( ) => {} );
const mockUseRoute = jest.fn( );
jest.mock( "@react-navigation/native", ( ) => {
const actualNav = jest.requireActual( "@react-navigation/native" );
return {
...actualNav,
useNavigation: ( ) => ( {
navigate: mockNavigate,
dispatch: mockDispatch,
goBack: mockGoBack,
addListener: mockAddListener,
} ),
useRoute: ( ) => mockUseRoute( ),
};
} );
const createMockRoute = item => ( { params: { item } } );
const setupMocks = ( overrides = {} ) => {
const defaults = {
resetObservationFlowSlice: jest.fn( ),
prepareObsEdit: jest.fn( ),
setPhotoImporterState: jest.fn( ),
isDefaultMode: true,
screenAfterPhotoEvidence: "ObsEdit",
};
const mocks = { ...defaults, ...overrides };
useStore.mockImplementation( selector => selector( {
resetObservationFlowSlice: mocks.resetObservationFlowSlice,
prepareObsEdit: mocks.prepareObsEdit,
setPhotoImporterState: mocks.setPhotoImporterState,
} ) );
useLayoutPrefs.mockReturnValue( {
screenAfterPhotoEvidence: mocks.screenAfterPhotoEvidence,
isDefaultMode: mocks.isDefaultMode,
} );
Observation.createObservationWithPhotos.mockResolvedValue( { description: "" } );
return {
...mocks,
navigate: mockNavigate,
dispatch: mockDispatch,
goBack: mockGoBack,
addListener: mockAddListener,
};
};
const mockShare = {
data: [{ data: "file://photo.jpg", mimeType: "image/jpeg" }],
};
const expectNavigationReset = ( mockDispatch, screenName, lastScreen = "PhotoSharing" ) => {
expect( mockDispatch ).toHaveBeenCalledWith(
CommonActions.reset( {
index: 0,
routes: [{
name: "NoBottomTabStackNavigator",
state: {
index: 0,
routes: [{ name: screenName, params: { lastScreen } }],
},
}],
} ),
);
};
describe( "PhotoSharing", ( ) => {
beforeEach( ( ) => {
jest.clearAllMocks( );
} );
describe( "Share Single Photo", ( ) => {
const singlePhotoData = {
...mockShare,
extraData: { userInput: "Share photo with description" },
};
it( "should handle single photo in default mode", async ( ) => {
const mocks = setupMocks( );
mockUseRoute.mockReturnValue( createMockRoute( singlePhotoData ) );
renderComponent( <PhotoSharing /> );
await waitFor( ( ) => {
expect( mocks.dispatch ).toHaveBeenCalled( );
} );
expect( mocks.resetObservationFlowSlice ).toHaveBeenCalled( );
expect( Observation.createObservationWithPhotos ).toHaveBeenCalledWith( [
{ image: { uri: "file://photo.jpg" } },
] );
expect( mocks.prepareObsEdit ).toHaveBeenCalledWith(
expect.objectContaining( { description: "Share photo with description" } ),
);
expectNavigationReset( mocks.dispatch, "Match" );
} );
it( "should handle single photo in advanced mode", async ( ) => {
const mocks = setupMocks( { isDefaultMode: false, screenAfterPhotoEvidence: "ObsEdit" } );
mockUseRoute.mockReturnValue( createMockRoute( singlePhotoData ) );
renderComponent( <PhotoSharing /> );
await waitFor( ( ) => {
expectNavigationReset( mocks.dispatch, "ObsEdit" );
} );
} );
it( "should handle observation creation error", async ( ) => {
const alertSpy = jest.spyOn( Alert, "alert" ).mockImplementation( ( ) => {} );
const mocks = setupMocks( );
const error = new Error( "Creation failed" );
Observation.createObservationWithPhotos.mockRejectedValue( error );
mockUseRoute.mockReturnValue( createMockRoute( singlePhotoData ) );
renderComponent( <PhotoSharing /> );
await waitFor( ( ) => {
expect( alertSpy ).toHaveBeenCalledWith(
"Photo sharing failed: couldn't create new observation:",
error,
);
} );
expect( mocks.dispatch ).not.toHaveBeenCalled( );
} );
it( "should handle photo with no description", async ( ) => {
const mocks = setupMocks( );
mockUseRoute.mockReturnValue( createMockRoute( mockShare ) );
renderComponent( <PhotoSharing /> );
await waitFor( ( ) => {
expect( mocks.prepareObsEdit ).toHaveBeenCalledWith(
expect.objectContaining( { description: undefined } ),
);
} );
} );
} );
describe( "Share Multiple Photos", ( ) => {
const multiplePhotosData = {
mimeType: JPEG,
data: [
{ data: "file://photo1.jpg", mimeType: JPEG },
{ data: "file://photo2.jpg", mimeType: JPEG },
],
extraData: { userInput: "Multiple photos" },
};
it( "should navigate to GroupPhotos for multiple photos", async ( ) => {
const mocks = setupMocks( );
mockUseRoute.mockReturnValue( createMockRoute( multiplePhotosData ) );
renderComponent( <PhotoSharing /> );
await waitFor( ( ) => {
expect( mocks.setPhotoImporterState ).toHaveBeenCalledWith( {
photoLibraryUris: ["file://photo1.jpg", "file://photo2.jpg"],
groupedPhotos: [
{ photos: [{ image: { uri: "file://photo1.jpg" } }] },
{ photos: [{ image: { uri: "file://photo2.jpg" } }] },
],
firstObservationDefaults: { description: "Multiple photos" },
} );
} );
expectNavigationReset( mocks.dispatch, "GroupPhotos" );
expect( Observation.createObservationWithPhotos ).not.toHaveBeenCalled( );
} );
it( "should filter out non-image files", async ( ) => {
const mocks = setupMocks( );
const mixedData = {
...multiplePhotosData,
data: [
{ data: "file://photo1.jpg", mimeType: JPEG },
{ data: "file://doc.pdf", mimeType: "application/pdf" },
{ data: "file://photo2.png", mimeType: "image/png" },
],
};
mockUseRoute.mockReturnValue( createMockRoute( mixedData ) );
renderComponent( <PhotoSharing /> );
await waitFor( ( ) => {
expect( mocks.setPhotoImporterState ).toHaveBeenCalledWith(
expect.objectContaining( {
photoLibraryUris: ["file://photo1.jpg", "file://photo2.png"],
} ),
);
} );
} );
} );
describe( "Back navigation", ( ) => {
it( "should handle expected behavior for blur/focus navigation", async ( ) => {
const mocks = setupMocks( );
const testItem = mockShare;
const testRoute = createMockRoute( testItem );
let blurCallback;
let focusCallback;
mocks.addListener.mockImplementation( ( event, callback ) => {
if ( event === "blur" ) blurCallback = callback;
if ( event === "focus" ) focusCallback = callback;
return ( ) => {};
} );
mockUseRoute.mockReturnValue( testRoute );
renderComponent( <PhotoSharing /> );
// first time on screen
act( ( ) => {
focusCallback( );
} );
// navigate forward in the direction of ObsEdit
act( ( ) => {
blurCallback( );
} );
// same item -- simulate user coming back to this screen by backing out
mockUseRoute.mockReturnValue( testRoute );
// return to PhotoSharing screen
act( ( ) => {
focusCallback( );
} );
expect( mocks.goBack ).toHaveBeenCalled( );
} );
} );
} );