Files
iNaturalistReactNative/tests/integration/SuggestionsWithUnsyncedObs.test.js
Ryan Stelly 277816b0e0 MOB-1458 use local observation ids (#3763)
* only return observations from useLocalObs

* remove unnecessary var

* export unsync'd filter

* remove obsoleted numUnuploadedObservations zustand state

* tests, typo

* fix tests

* add sync fields to keypaths & depp array

* consistent naming

* handle only ids downstream

* switch over to useIds

* restore ids return

* memoize realm mapping result

* extract realm hooks mocks into factory-available helper

* reapply to myobsresults

* reapply to myobsresults

* go back to wrapped ids for list parity

* fix test

* restrict mocked prop for more accurate MyObsSimple test

* comment cleanup

* fix fake timer flake w/ corrected setup

Claude:

1. withAnimatedTimeTravelEnabled({ skipFakeTimers: true }) does NOT call jest.useFakeTimers(), but still calls jest.setSystemTime(new Date(0)) in beforeEach — which requires fake timers.
2. Both SoundRecorder.test.js and PhotoDeletionExisting.test.js (and ObsEdit.test.js) call global.timeTravel(300) inside waitFor, which also requires fake timers.
3. Tests pass flakily when fake timers accidentally leak from another test file running in the same Jest worker. When they don't leak, timeTravel throws.
4. FadeInView is fully mocked to a plain View (instant) and Reanimated uses setUpTests(), so the 300ms timeTravel isn't actually advancing any real animation — it's just broken.

The fix: guard setSystemTime with the same check, and remove the timeTravel(300) calls from waitFor in tests that use skipFakeTimers: true.

* fix remaining time travel uses

* reset / restore timers to fix leaking fake timers
2026-06-30 11:48:47 -05:00

462 lines
16 KiB
JavaScript

import "tests/helpers/mockMortalForIntegration";
import {
useNetInfo,
} from "@react-native-community/netinfo";
import {
screen,
userEvent,
waitFor,
} 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 {
navigateToSuggestionsViaAICameraFromMyObs,
} from "tests/helpers/addObsBottomSheet";
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" );
const { makeRealmHooks } = jest.requireActual( "tests/helpers/uniqueRealm" );
return {
__esModule: true,
...originalModule,
RealmContext: {
...originalModule.RealmContext,
...makeRealmHooks( __filename ),
},
};
} );
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 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`,
);
expect( humanResultButton ).toBeVisible();
const human = screen.getByText( /Homo sapiens/ );
expect( human ).toBeVisible();
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/ );
expect( ignoreLocationButton ).toBeVisible();
} );
} );
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 navigateToSuggestionsViaAICameraFromMyObs( );
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 navigateToSuggestionsViaAICameraFromMyObs( );
await waitFor( ( ) => {
expect( screen.getByText( /IMPROVE THESE SUGGESTIONS/ ) ).toBeVisible( );
} );
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 navigateToSuggestionsViaAICameraFromMyObs( );
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 navigateToSuggestionsViaAICameraFromMyObs( );
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 navigateToSuggestionsViaAICameraFromMyObs( );
const notConfidentText = await screen.findByText( /not confident enough to make a top ID suggestion/ );
await waitFor( ( ) => {
expect( notConfidentText ).toBeVisible( );
} );
const otherSuggestion = await screen.findByTestId(
`SuggestionsList.taxa.${mockModelResultNoConfidence.predictions[1].taxon_id}.checkmark`,
);
expect( otherSuggestion ).toBeVisible( );
} );
it( "should only show top human suggestion if human predicted offline", async ( ) => {
getPredictionsForImage.mockImplementation(
async ( ) => ( mockModelResultWithHuman ),
);
useNetInfo.mockImplementation( ( ) => ( { isConnected: false } ) );
await setupAppWithSignedInUser( );
await navigateToSuggestionsViaAICameraFromMyObs( );
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 navigateToSuggestionsViaAICameraFromMyObs( );
// await waitFor( ( ) => {
// expect( inatjs.computervision.score_image ).toHaveBeenCalled( );
// } );
// const ignoreLocationButton = screen.queryByText( /IGNORE LOCATION/ );
// expect( ignoreLocationButton ).toBeVisible( );
// 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/ );
// expect( useLocationButton ).toBeVisible( );
// } );
} );
} );