import { useNetInfo, } from "@react-native-community/netinfo"; import { screen, userEvent, waitFor, within, } from "@testing-library/react-native"; import * as usePredictions from "components/Camera/AICamera/hooks/usePredictions"; import inatjs from "inaturalistjs"; import { Animated } from "react-native"; import * as useLocationPermission from "sharedHooks/useLocationPermission"; import { SCREEN_AFTER_PHOTO_EVIDENCE } from "stores/createLayoutSlice"; import useStore from "stores/useStore"; import factory, { makeResponse } from "tests/factory"; import { renderAppWithObservations } from "tests/helpers/render"; import setStoreStateLayout from "tests/helpers/setStoreStateLayout"; import setupUniqueRealm from "tests/helpers/uniqueRealm"; import { signIn, signOut } from "tests/helpers/user"; import { getPredictionsForImage } from "vision-camera-plugin-inatvision"; // Not my favorite code, but this patch is necessary to get tests passing right // now unless we can figure out why Animated.Value is being passed undefined, // which seems related to the AICamera const OriginalValue = Animated.Value; beforeEach( () => { // Patch the Value constructor to be safer with undefined values Animated.Value = function ( val ) { return new OriginalValue( val === undefined ? 0 : val ); }; } ); afterEach( () => { // Restore original implementation Animated.Value = OriginalValue; } ); const mockModelResult = { predictions: [ factory( "ModelPrediction", { rank_level: 30, combined_score: 86, } ), factory( "ModelPrediction", { rank_level: 20, combined_score: 96, } ), factory( "ModelPrediction", { rank_level: 10, combined_score: 40, } )], }; const mockModelResultNoConfidence = { predictions: [ factory( "ModelPrediction", { rank_level: 30, combined_score: 70, } ), factory( "ModelPrediction", { rank_level: 20, combined_score: 65, } ), ], }; const mockModelResultWithHuman = { predictions: [ factory( "ModelPrediction", { rank_level: 20, combined_score: 86, } ), factory( "ModelPrediction", { rank_level: 30, combined_score: 96, name: "Homo", } ), ], }; jest.mock( "react-native/Libraries/Utilities/Platform", () => ( { __esModule: true, default: { OS: "ios", select: jest.fn(), Version: 11, }, } ) ); const mockFetchUserLocation = jest.fn( () => ( { latitude: 56, longitude: 9, accuracy: 8 } ) ); jest.mock( "sharedHelpers/fetchAccurateUserLocation", () => ( { __esModule: true, default: () => mockFetchUserLocation(), } ) ); // We're explicitly testing navigation here so we want react-navigation // working normally jest.unmock( "@react-navigation/native" ); // UNIQUE REALM SETUP const mockRealmIdentifier = __filename; const { mockRealmModelsIndex, uniqueRealmBeforeAll, uniqueRealmAfterAll } = setupUniqueRealm( mockRealmIdentifier, ); jest.mock( "realmModels/index", ( ) => mockRealmModelsIndex ); jest.mock( "providers/contexts", ( ) => { const originalModule = jest.requireActual( "providers/contexts" ); return { __esModule: true, ...originalModule, RealmContext: { ...originalModule.RealmContext, useRealm: ( ) => global.mockRealms[mockRealmIdentifier], useQuery: ( ) => [], }, }; } ); beforeAll( uniqueRealmBeforeAll ); afterAll( uniqueRealmAfterAll ); // /UNIQUE REALM SETUP const initialStoreState = useStore.getState( ); beforeAll( async ( ) => { useStore.setState( initialStoreState, true ); // userEvent recommends fake timers jest.useFakeTimers( ); } ); // Mock the response from inatjs.computervision.score_image const topSuggestion = { taxon: factory.states( "genus" )( "RemoteTaxon", { name: "Primum" } ), combined_score: 90, }; const humanSuggestion = { taxon: factory( "RemoteTaxon", { name: "Homo sapiens", id: 43584 } ), combined_score: 86, }; const mockLocalTaxon = { id: 144351, name: "Poecile", rank_level: 20, default_photo: { url: "fake_image_url", }, }; const mockUser = factory( "LocalUser" ); const makeMockObservations = ( ) => ( [ factory( "RemoteObservation", { _synced_at: null, needsSync: jest.fn( ( ) => true ), wasSynced: jest.fn( ( ) => false ), // Suggestions won't load without a photo observationPhotos: [ factory( "RemoteObservationPhoto" ), ], user: mockUser, observed_on_string: "2020-01-01", } ), ] ); const makeMockObservationsWithLocation = ( ) => ( [ factory( "RemoteObservation", { _synced_at: null, needsSync: jest.fn( ( ) => true ), wasSynced: jest.fn( ( ) => false ), // Suggestions won't load without a photo observationPhotos: [ factory( "RemoteObservationPhoto" ), ], user: mockUser, observed_on_string: "2020-01-01", latitude: 4, longitude: 10, } ), ] ); const actor = userEvent.setup( ); const navigateToSuggestionsForObservationViaObsEdit = async observation => { const observationGridItem = await screen.findByTestId( `MyObservations.obsGridItem.${observation.uuid}`, ); await actor.press( observationGridItem ); const addIdButton = await screen.findByText( "IDENTIFY" ); await actor.press( addIdButton ); }; const navigateToSuggestionsViaAICamera = async ( ) => { const tabBar = await screen.findByTestId( "CustomTabBar" ); const addObsButton = await within( tabBar ).findByLabelText( "Add observations" ); await actor.press( addObsButton ); const cameraButton = await screen.findByLabelText( /AI Camera/ ); await actor.press( cameraButton ); const takePhotoButton = await screen.findByLabelText( /Take photo/ ); await actor.press( takePhotoButton ); const addIDButton = await screen.findByText( /ADD AN ID/ ); // We used toBeVisible here but the update to RN0.77 broke this expectation expect( addIDButton ).toBeOnTheScreen( ); }; const setupAppWithSignedInUser = async hasLocation => { const observations = hasLocation ? makeMockObservationsWithLocation( ) : makeMockObservations( ); useStore.setState( { observations, currentObservation: observations[0], } ); setStoreStateLayout( { isDefaultMode: false, screenAfterPhotoEvidence: SCREEN_AFTER_PHOTO_EVIDENCE.SUGGESTIONS, isAllAddObsOptionsMode: true, } ); await renderAppWithObservations( observations, __filename ); return { observations }; }; // TODO: fix this test. As of 20240627 we're bumping into issues with the // 2.13 new vision camera not loading offline suggestions, // so we may need this issue resolved before this test can be fixed: // https://github.com/inaturalist/iNaturalistReactNative/issues/1715 // it( // "should try offline suggestions if no online suggestions are found", // async ( ) => { // const { observations } = await setupAppWithSignedInUser( ); // await navigateToSuggestionsForObservationViaObsEdit( observations[0] ); // const offlineNotice = await screen.findByText( /You are offline. Tap to reload/ ); // await waitFor( ( ) => { // expect( offlineNotice ).toBeTruthy( ); // }, { timeout: 10000 } ); // const topOfflineTaxonResultButton = await screen.findByTestId( // `SuggestionsList.taxa.${mockModelResult.predictions[0].taxon_id}.checkmark` // ); // expect( topOfflineTaxonResultButton ).toBeTruthy( ); // await act( async ( ) => actor.press( topOfflineTaxonResultButton ) ); // const saveButton = await screen.findByText( /SAVE/ ); // expect( saveButton ).toBeTruthy( ); // await actor.press( saveButton ); // const savedObservation = global.mockRealms[__filename] // .objectForPrimaryKey( "Observation", observations[0].uuid ); // expect( savedObservation ).toHaveProperty( "owners_identification_from_vision", true ); // } // ); describe( "from ObsEdit with human observation", () => { beforeEach( async () => { await signIn( mockUser, { realm: global.mockRealms[__filename] } ); inatjs.computervision.score_image.mockResolvedValue( makeResponse( [humanSuggestion, topSuggestion] ), ); } ); afterEach( () => { signOut( { realm: global.mockRealms[__filename] } ); inatjs.computervision.score_image.mockReset(); } ); it( "should display only a single human observation" + "if human is found in suggestions", async () => { const { observations } = await setupAppWithSignedInUser(); await navigateToSuggestionsForObservationViaObsEdit( observations[0] ); const humanResultButton = await screen.findByTestId( `SuggestionsList.taxa.${humanSuggestion.taxon.id}.checkmark`, ); // We used toBeVisible here but the update to RN0.77 broke this expectation expect( humanResultButton ).toBeOnTheScreen(); const human = screen.getByText( /Homo sapiens/ ); // We used toBeVisible here but the update to RN0.77 broke this expectation expect( human ).toBeOnTheScreen(); const nonHumanSuggestion = screen.queryByText( /Primum/ ); expect( nonHumanSuggestion ).toBeFalsy(); } ); it( "should not show location permissions button", async () => { const { observations } = await setupAppWithSignedInUser(); await navigateToSuggestionsForObservationViaObsEdit( observations[0] ); const usePermissionsButton = screen.queryByText( /IMPROVE THESE SUGGESTIONS/, ); expect( usePermissionsButton ).toBeFalsy(); } ); it( "should not show use location button if unsynced obs has no location", async () => { const { observations } = await setupAppWithSignedInUser(); await navigateToSuggestionsForObservationViaObsEdit( observations[0] ); const useLocationButton = screen.queryByText( /USE LOCATION/ ); expect( useLocationButton ).toBeFalsy(); } ); it( "should show ignore location button if unsynced obs has location", async () => { const { observations } = await setupAppWithSignedInUser( true ); await navigateToSuggestionsForObservationViaObsEdit( observations[0] ); const ignoreLocationButton = await screen.findByText( /IGNORE LOCATION/ ); // We used toBeVisible here but the update to RN0.77 broke this expectation expect( ignoreLocationButton ).toBeOnTheScreen(); } ); } ); describe( "from AICamera directly", ( ) => { global.withAnimatedTimeTravelEnabled( { skipFakeTimers: true } ); beforeEach( async ( ) => { inatjs.computervision.score_image .mockResolvedValue( makeResponse( [topSuggestion] ) ); jest.spyOn( usePredictions, "default" ).mockImplementation( () => ( { handleTaxaDetected: jest.fn( ), modelLoaded: true, result: { taxon: mockLocalTaxon, }, setResult: jest.fn( ), } ) ); } ); afterEach( ( ) => { inatjs.computervision.score_image.mockReset( ); } ); describe( "suggestions with location", ( ) => { it( "should call score_image with location parameters on first render", async ( ) => { await setupAppWithSignedInUser( ); await navigateToSuggestionsViaAICamera( ); await waitFor( ( ) => { expect( inatjs.computervision.score_image ).toHaveBeenCalledWith( expect.objectContaining( { // Don't care about fields here fields: expect.any( Object ), image: expect.any( Object ), lat: 56, lng: 9, } ), expect.anything( ), ); } ); } ); } ); describe( "suggestions without location permissions", ( ) => { it( "should not call score_image with location parameters on first render" + " if location permission not given", async ( ) => { jest.spyOn( useLocationPermission, "default" ).mockImplementation( ( ) => ( { hasPermissions: false, renderPermissionsGate: jest.fn( ), } ) ); mockFetchUserLocation.mockReturnValue( null ); await setupAppWithSignedInUser( ); await navigateToSuggestionsViaAICamera( ); await waitFor( ( ) => { global.timeTravel( ); // We used toBeVisible here but the update to RN0.77 broke this expectation expect( screen.getByText( /IMPROVE THESE SUGGESTIONS/ ) ).toBeOnTheScreen( ); } ); const ignoreLocationButton = screen.queryByText( /IGNORE LOCATION/ ); expect( ignoreLocationButton ).toBeFalsy( ); const useLocationButton = screen.queryByText( /USE LOCATION/ ); expect( useLocationButton ).toBeFalsy( ); await waitFor( ( ) => { expect( inatjs.computervision.score_image ).toHaveBeenCalledWith( expect.not.objectContaining( { lat: 56, lng: 9, } ), expect.anything( ), ); } ); } ); } ); describe( "suggestions while offline", ( ) => { it( "should not call score_image and should not show any location buttons", async ( ) => { useNetInfo.mockImplementation( ( ) => ( { isConnected: false } ) ); await setupAppWithSignedInUser( ); await navigateToSuggestionsViaAICamera( ); expect( inatjs.computervision.score_image ).not.toHaveBeenCalled( ); const usePermissionsButton = screen.queryByText( /IMPROVE THESE SUGGESTIONS/ ); expect( usePermissionsButton ).toBeFalsy( ); const ignoreLocationButton = screen.queryByText( /IGNORE LOCATION/ ); expect( ignoreLocationButton ).toBeFalsy( ); const useLocationButton = screen.queryByText( /USE LOCATION/ ); expect( useLocationButton ).toBeFalsy( ); } ); it( "should show top suggestion with finest rank if a prediction" + " is above offline threshold", async ( ) => { getPredictionsForImage.mockImplementation( async ( ) => ( mockModelResult ), ); useNetInfo.mockImplementation( ( ) => ( { isConnected: false } ) ); await setupAppWithSignedInUser( ); await navigateToSuggestionsViaAICamera( ); const topTaxonSuggestion = await screen.findByLabelText( /Choose top taxon/ ); expect( topTaxonSuggestion ).toHaveProp( "testID", `SuggestionsList.taxa.${mockModelResult.predictions[1].taxon_id}.checkmark`, ); } ); it( "should show not confident message if no predictions" + " meet the offline threshold", async ( ) => { getPredictionsForImage.mockImplementation( async ( ) => ( mockModelResultNoConfidence ), ); useNetInfo.mockImplementation( ( ) => ( { isConnected: false } ) ); await setupAppWithSignedInUser( ); await navigateToSuggestionsViaAICamera( ); const notConfidentText = await screen.findByText( /not confident enough to make a top ID suggestion/ ); await waitFor( ( ) => { // We used toBeVisible here but the update to RN0.77 broke this expectation expect( notConfidentText ).toBeOnTheScreen( ); } ); const otherSuggestion = await screen.findByTestId( `SuggestionsList.taxa.${mockModelResultNoConfidence.predictions[1].taxon_id}.checkmark`, ); // We used toBeVisible here but the update to RN0.77 broke this expectation expect( otherSuggestion ).toBeOnTheScreen( ); } ); it( "should only show top human suggestion if human predicted offline", async ( ) => { getPredictionsForImage.mockImplementation( async ( ) => ( mockModelResultWithHuman ), ); useNetInfo.mockImplementation( ( ) => ( { isConnected: false } ) ); await setupAppWithSignedInUser( ); await navigateToSuggestionsViaAICamera( ); const topTaxonSuggestion = await screen.findByLabelText( /Choose top taxon/ ); const humanPrediction = mockModelResultWithHuman.predictions .find( p => p.name === "Homo" ); expect( topTaxonSuggestion ).toHaveProp( "testID", `SuggestionsList.taxa.${humanPrediction.taxon_id}.checkmark`, ); const otherSuggestionsText = screen.queryByText( /OTHER SUGGESTIONS/ ); expect( otherSuggestionsText ).toBeFalsy( ); } ); } ); describe( "suggestions not using location", () => { // 20240719 amanda - I keep bumping into an unmounted node error // here when ignoreLocationButton is pressed and I'm not sure what the root cause is. // I'm seeing the same type of error when trying to press Add an ID Later, so maybe // the same root cause? it.todo( "should call score_image without location parameters" ); // it( "should call score_image without location parameters if" // + " ignore location pressed", async ( ) => { // const { observations } = await setupAppWithSignedInUser( ); // await navigateToSuggestionsViaAICamera( ); // await waitFor( ( ) => { // expect( inatjs.computervision.score_image ).toHaveBeenCalled( ); // } ); // const ignoreLocationButton = screen.queryByText( /IGNORE LOCATION/ ); // We used toBeVisible here but the update to RN0.77 broke this expectation // expect( ignoreLocationButton ).toBeOnTheScreen( ); // await actor.press( ignoreLocationButton ); // await waitFor( ( ) => { // expect( inatjs.computervision.score_image ).toHaveBeenCalledWith( // expect.not.objectContaining( { // lat: observations[0].latitude, // lng: observations[0].longitude // } ), // expect.anything( ) // ); // } ); // const useLocationButton = await screen.findByText( /USE LOCATION/ ); // We used toBeVisible here but the update to RN0.77 broke this expectation // expect( useLocationButton ).toBeOnTheScreen( ); // } ); } ); } );