mirror of
https://github.com/inaturalist/iNaturalistReactNative.git
synced 2026-05-07 23:26:56 -04:00
Taxon result updates (#1254)
* Only requests remote taxon if local is missing or hasn't been synced in a week * Returns a localized version of the remote taxon immediately without waiting to get a newly-created record from realm * Expands tests to use a unique realm, integrate more of our code, and check to ensure the API gets called * Show indicator while loading taxon on ARCamera * Show iconic taxon found in model results if we can't load a remote taxon * Show iconic taxon as backdrop for ObsImage whenever possible
This commit is contained in:
@@ -43,10 +43,10 @@ async function handleError( e: Object, options: Object = {} ): Object {
|
||||
if ( typeof ( options.onApiError ) === "function" ) {
|
||||
options.onApiError( error );
|
||||
}
|
||||
// Default to throw errors. We almost never want handle supress an error at
|
||||
// Default to throw errors. We almost never want supress an error at
|
||||
// this low level
|
||||
if ( options.throw === false ) {
|
||||
return null;
|
||||
return error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
useState
|
||||
} from "react";
|
||||
import { useIconicTaxa } from "sharedHooks";
|
||||
|
||||
const usePredictions = ( ): Object => {
|
||||
const [result, setResult] = useState( null );
|
||||
@@ -11,22 +12,27 @@ const usePredictions = ( ): Object => {
|
||||
const [fps, setFPS] = useState( 1 );
|
||||
const [numStoredResults, setNumStoredResults] = useState( 4 );
|
||||
const [cropRatio, setCropRatio] = useState( 1 );
|
||||
const iconicTaxa = useIconicTaxa( );
|
||||
|
||||
const handleTaxaDetected = cvResult => {
|
||||
console.log( "[DEBUG usePredictions.js] cvResult: ", cvResult );
|
||||
if ( cvResult && !modelLoaded ) {
|
||||
setModelLoaded( true );
|
||||
}
|
||||
let prediction = null;
|
||||
const { predictions } = cvResult;
|
||||
predictions.sort( ( a, b ) => a.rank_level - b.rank_level );
|
||||
if ( predictions.length > 0 ) {
|
||||
const finestPrediction = predictions[0];
|
||||
const { predictions: branch } = cvResult;
|
||||
branch.sort( ( a, b ) => a.rank_level - b.rank_level );
|
||||
const branchIDs = branch.map( t => t.taxon_id );
|
||||
if ( branch.length > 0 ) {
|
||||
const finestPrediction = branch[0];
|
||||
// Try to find a known iconic taxon in the model results so we can show
|
||||
// an icon if we can't show a photo
|
||||
const iconicTaxon = iconicTaxa?.find( t => branchIDs.indexOf( t.id ) >= 0 );
|
||||
prediction = {
|
||||
taxon: {
|
||||
rank_level: finestPrediction.rank_level,
|
||||
id: finestPrediction.taxon_id,
|
||||
name: finestPrediction.name
|
||||
name: finestPrediction.name,
|
||||
iconic_taxon_name: iconicTaxon?.name
|
||||
},
|
||||
score: finestPrediction.score
|
||||
};
|
||||
|
||||
@@ -137,7 +137,7 @@ const DisplayTaxonName = ( {
|
||||
return (
|
||||
<View
|
||||
testID="display-taxon-name"
|
||||
className={classNames( "flex", null, {
|
||||
className={classNames( "flex", {
|
||||
"flex-row items-end flex-wrap w-11/12": isHorizontal
|
||||
} )}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @flow
|
||||
import classNames from "classnames";
|
||||
import { IconicTaxonIcon } from "components/SharedComponents";
|
||||
import { Image } from "components/styledComponents";
|
||||
import { Image, View } from "components/styledComponents";
|
||||
import type { Node } from "react";
|
||||
import React from "react";
|
||||
|
||||
@@ -35,29 +35,36 @@ const ObsImage = ( {
|
||||
}: Props ): Node => {
|
||||
const noImg = !uri?.uri;
|
||||
|
||||
const iconicTaxon = (
|
||||
<IconicTaxonIcon
|
||||
imageClassName={[
|
||||
...CLASS_NAMES,
|
||||
imageClassName,
|
||||
{ "bg-darkGray": white }
|
||||
]}
|
||||
iconicTaxonName={iconicTaxonName}
|
||||
white={white}
|
||||
isBackground={isBackground}
|
||||
size={iconicTaxonIconSize}
|
||||
/>
|
||||
);
|
||||
|
||||
if ( noImg ) {
|
||||
return (
|
||||
<IconicTaxonIcon
|
||||
imageClassName={[
|
||||
...CLASS_NAMES,
|
||||
imageClassName,
|
||||
{ "bg-darkGray": white }
|
||||
]}
|
||||
iconicTaxonName={iconicTaxonName}
|
||||
white={white}
|
||||
isBackground={isBackground}
|
||||
size={iconicTaxonIconSize}
|
||||
/>
|
||||
);
|
||||
return iconicTaxon;
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
source={uri}
|
||||
className={classNames( ...CLASS_NAMES, { "opacity-50": opaque } )}
|
||||
testID="ObsList.photo"
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
<View className={classNames( CLASS_NAMES, "relative" )}>
|
||||
<View className="absolute w-full h-full">
|
||||
{iconicTaxon}
|
||||
</View>
|
||||
<Image
|
||||
source={uri}
|
||||
className={classNames( ...CLASS_NAMES, { "opacity-50": opaque } )}
|
||||
testID="ObsList.photo"
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import classnames from "classnames";
|
||||
import { DisplayTaxonName, INatIconButton } from "components/SharedComponents";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
DisplayTaxonName,
|
||||
INatIconButton
|
||||
} from "components/SharedComponents";
|
||||
import ObsImagePreview from "components/SharedComponents/ObservationsFlashList/ObsImagePreview";
|
||||
import { Pressable, View } from "components/styledComponents";
|
||||
import type { Node } from "react";
|
||||
@@ -39,7 +43,7 @@ const TaxonResult = ( {
|
||||
handleCheckmarkPress,
|
||||
handlePress,
|
||||
showCheckmark = true,
|
||||
taxon: taxonResult,
|
||||
taxon: taxonProp,
|
||||
testID,
|
||||
white = false
|
||||
}: Props ): Node => {
|
||||
@@ -50,13 +54,13 @@ const TaxonResult = ( {
|
||||
// network requests for useTaxon instead of making individual API calls.
|
||||
// right now, this fetches a single taxon at a time on AR camera &
|
||||
// a short list of taxa from offline Suggestions
|
||||
const localTaxon = useTaxon( taxonResult, fetchRemote );
|
||||
const taxon = fromLocal
|
||||
const { taxon: localTaxon, isLoading } = useTaxon( taxonProp, fetchRemote );
|
||||
const usableTaxon = fromLocal
|
||||
? localTaxon
|
||||
: taxonResult;
|
||||
const taxonImage = { uri: taxon?.default_photo?.url };
|
||||
: taxonProp;
|
||||
const taxonImage = { uri: usableTaxon.default_photo?.url };
|
||||
|
||||
const navToTaxonDetails = () => navigation.navigate( "TaxonDetails", { id: taxon.id } );
|
||||
const navToTaxonDetails = () => navigation.navigate( "TaxonDetails", { id: usableTaxon.id } );
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -65,7 +69,6 @@ const TaxonResult = ( {
|
||||
"flex-row items-center justify-between px-4 py-3",
|
||||
{
|
||||
"border-b-[1px] border-lightGray": !clearBackground,
|
||||
"mx-4": clearBackground,
|
||||
"border-t-[1px]": first
|
||||
}
|
||||
)
|
||||
@@ -73,33 +76,43 @@ const TaxonResult = ( {
|
||||
testID={testID}
|
||||
>
|
||||
<Pressable
|
||||
className="flex-row items-center w-16 grow"
|
||||
className="flex-row items-center shrink"
|
||||
onPress={handlePress || navToTaxonDetails}
|
||||
accessible
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={t( "Navigate-to-taxon-details" )}
|
||||
accessibilityValue={{ text: taxon.name }}
|
||||
accessibilityValue={{ text: usableTaxon.name }}
|
||||
accessibilityState={{ disabled: false }}
|
||||
>
|
||||
<ObsImagePreview
|
||||
source={taxonImage}
|
||||
testID={`${testID}.photo`}
|
||||
iconicTaxonName={taxon?.iconic_taxon_name}
|
||||
className="rounded-xl"
|
||||
isSmall
|
||||
white={white}
|
||||
/>
|
||||
{( confidence && confidencePosition === "photo" ) && (
|
||||
<View className="absolute -bottom-5 w-[62px]">
|
||||
<ConfidenceInterval
|
||||
confidence={confidence}
|
||||
activeColor={activeColor}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View className="w-[62px] h-[62px] justify-center relative">
|
||||
{
|
||||
isLoading
|
||||
? (
|
||||
<ActivityIndicator size={44} />
|
||||
)
|
||||
: (
|
||||
<ObsImagePreview
|
||||
source={taxonImage}
|
||||
testID={`${testID}.photo`}
|
||||
iconicTaxonName={usableTaxon?.iconic_taxon_name}
|
||||
className="rounded-xl"
|
||||
isSmall
|
||||
white={white}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{( confidence && confidencePosition === "photo" ) && (
|
||||
<View className="absolute -bottom-4 w-full items-center">
|
||||
<ConfidenceInterval
|
||||
confidence={confidence}
|
||||
activeColor={activeColor}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className="shrink ml-3">
|
||||
<DisplayTaxonName
|
||||
taxon={taxon}
|
||||
taxon={usableTaxon}
|
||||
color={clearBackground && "text-white"}
|
||||
/>
|
||||
{( confidence && confidencePosition === "text" ) && (
|
||||
@@ -136,7 +149,7 @@ const TaxonResult = ( {
|
||||
? theme.colors.onSecondary
|
||||
: theme.colors.secondary
|
||||
}
|
||||
onPress={() => handleCheckmarkPress( taxon )}
|
||||
onPress={() => handleCheckmarkPress( usableTaxon )}
|
||||
accessibilityLabel={t( "Checkmark" )}
|
||||
accessibilityHint={t( "Add-this-ID" )}
|
||||
testID={`${testID}.checkmark`}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Realm } from "@realm/react";
|
||||
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
|
||||
|
||||
import Photo from "./Photo";
|
||||
|
||||
@@ -107,15 +106,6 @@ class Taxon extends Realm.Object {
|
||||
|
||||
static uri = item => ( item && item.default_photo ) && { uri: item.default_photo.url };
|
||||
|
||||
static saveRemoteTaxon = async ( remoteTaxon, realm ) => {
|
||||
if ( remoteTaxon ) {
|
||||
const localTaxon = Taxon.mapApiToRealm( remoteTaxon, realm );
|
||||
safeRealmWrite( realm, ( ) => {
|
||||
realm.create( "Taxon", localTaxon, "modified" );
|
||||
}, "saving remote taxon in Taxon" );
|
||||
}
|
||||
};
|
||||
|
||||
static schema = {
|
||||
name: "Taxon",
|
||||
primaryKey: "id",
|
||||
@@ -136,7 +126,8 @@ class Taxon extends Realm.Object {
|
||||
rank: "string?",
|
||||
rank_level: "float?",
|
||||
isIconic: "bool?",
|
||||
iconic_taxon_name: "string?"
|
||||
iconic_taxon_name: "string?",
|
||||
_synced_at: "date?"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export default {
|
||||
User,
|
||||
Vote
|
||||
],
|
||||
schemaVersion: 46,
|
||||
schemaVersion: 47,
|
||||
path: `${RNFS.DocumentDirectoryPath}/db.realm`,
|
||||
// https://github.com/realm/realm-js/pull/6076 embedded constraints
|
||||
migrationOptions: {
|
||||
|
||||
@@ -7,7 +7,8 @@ import { useAuthenticatedQuery, useIsConnected } from "sharedHooks";
|
||||
|
||||
const { useRealm } = RealmContext;
|
||||
|
||||
const useIconicTaxa = ( { reload }: Object ): Object => {
|
||||
const useIconicTaxa = ( options: { reload: boolean } = { reload: false } ): Object => {
|
||||
const { reload } = options;
|
||||
const realm = useRealm( );
|
||||
const isConnected = useIsConnected( );
|
||||
const [isUpdatingRealm, setIsUpdatingRealm] = useState( );
|
||||
@@ -23,10 +24,11 @@ const useIconicTaxa = ( { reload }: Object ): Object => {
|
||||
if ( iconicTaxa?.length > 0 && !isUpdatingRealm ) {
|
||||
setIsUpdatingRealm( true );
|
||||
safeRealmWrite( realm, ( ) => {
|
||||
iconicTaxa.forEach( taxa => {
|
||||
iconicTaxa.forEach( taxon => {
|
||||
realm.create( "Taxon", {
|
||||
...taxa,
|
||||
isIconic: true
|
||||
...taxon,
|
||||
isIconic: true,
|
||||
_synced_at: new Date( )
|
||||
}, "modified" );
|
||||
} );
|
||||
}, "modifying iconic taxa in useIconicTaxa" );
|
||||
|
||||
@@ -3,30 +3,67 @@
|
||||
import { fetchTaxon } from "api/taxa";
|
||||
import { RealmContext } from "providers/contexts";
|
||||
import Taxon from "realmModels/Taxon";
|
||||
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
|
||||
import { useAuthenticatedQuery } from "sharedHooks";
|
||||
|
||||
const { useRealm } = RealmContext;
|
||||
|
||||
const ONE_WEEK_MS = (
|
||||
1000 // ms / s
|
||||
* 60 // s / min
|
||||
* 60 // min / hr
|
||||
* 24 // hr / day
|
||||
* 7 // day / wk
|
||||
);
|
||||
|
||||
const useTaxon = ( taxon: Object, fetchRemote: boolean = true ): Object => {
|
||||
const realm = useRealm( );
|
||||
|
||||
const existingTaxon = taxon.id && realm.objectForPrimaryKey( "Taxon", taxon?.id );
|
||||
const localTaxon = taxon.id && realm.objectForPrimaryKey( "Taxon", taxon.id );
|
||||
|
||||
const canFetchTaxon = !!taxon?.id;
|
||||
const localTaxonNeedsSync = (
|
||||
// Definitely sync if there's no local copy
|
||||
!localTaxon
|
||||
|| (
|
||||
// Sync if the local copy hasn't been synced in a week
|
||||
localTaxon._synced_at && ( Date.now( ) - localTaxon._synced_at > ONE_WEEK_MS )
|
||||
)
|
||||
);
|
||||
const enabled = canFetchTaxon && fetchRemote && localTaxonNeedsSync;
|
||||
|
||||
const {
|
||||
data: remoteTaxon
|
||||
data: remoteTaxon,
|
||||
isLoading
|
||||
} = useAuthenticatedQuery(
|
||||
["fetchTaxon", taxon?.id],
|
||||
optsWithAuth => fetchTaxon( taxon.id, { fields: Taxon.TAXON_FIELDS }, optsWithAuth ),
|
||||
{
|
||||
enabled: !!( taxon?.id && fetchRemote )
|
||||
enabled
|
||||
}
|
||||
);
|
||||
|
||||
if ( !existingTaxon && remoteTaxon ) {
|
||||
Taxon.saveRemoteTaxon( remoteTaxon, realm );
|
||||
const mappedRemoteTaxon = remoteTaxon
|
||||
? Taxon.mapApiToRealm( remoteTaxon, realm )
|
||||
: null;
|
||||
|
||||
if ( localTaxonNeedsSync && mappedRemoteTaxon ) {
|
||||
safeRealmWrite( realm, ( ) => {
|
||||
realm.create(
|
||||
"Taxon",
|
||||
{ ...mappedRemoteTaxon, _synced_at: new Date( ) },
|
||||
"modified"
|
||||
);
|
||||
}, "saving remote taxon in useTaxon" );
|
||||
}
|
||||
|
||||
return existingTaxon || taxon;
|
||||
// Local is best, local-ish version of remote will be available sooner, use
|
||||
// whatever was passed in as a last resort
|
||||
return {
|
||||
taxon: localTaxon || mappedRemoteTaxon || taxon,
|
||||
// Apparently useQuery isLoading is true if the query is disabled
|
||||
isLoading: enabled && isLoading
|
||||
};
|
||||
};
|
||||
|
||||
export default useTaxon;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { define } from "factoria";
|
||||
|
||||
export default define( "LocalTaxon", faker => ( {
|
||||
_synced_at: faker.date.past( ),
|
||||
id: faker.number.int( ),
|
||||
name: faker.person.fullName( ),
|
||||
preferred_common_name: faker.person.fullName( ),
|
||||
rank: "species",
|
||||
rank_level: 10,
|
||||
taxon_photos: []
|
||||
rank_level: 10
|
||||
} ) );
|
||||
|
||||
@@ -23,7 +23,7 @@ const queryClient = new QueryClient( {
|
||||
}
|
||||
} );
|
||||
|
||||
function renderComponent( component, update = null ) {
|
||||
function renderComponent( component, update = null, renderOptions = {} ) {
|
||||
const renderMethod = update || render;
|
||||
return renderMethod(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -36,7 +36,8 @@ function renderComponent( component, update = null ) {
|
||||
</BottomSheetModalProvider>
|
||||
</GestureHandlerRootView>
|
||||
</INatPaperProvider>
|
||||
</QueryClientProvider>
|
||||
</QueryClientProvider>,
|
||||
renderOptions
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,9 +83,47 @@ async function renderAppWithObservations(
|
||||
await screen.findByTestId( `MyObservations.obsListItem.${observations[0].uuid}` );
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a hook within a component
|
||||
*
|
||||
* Port of equivalent in react-testing-library
|
||||
* (https://github.com/testing-library/react-testing-library/blob/edb6344d578a8c224daf0cd6e2984f36cc6e8d86/src/pure.js#L264C1-L290C2),
|
||||
* but using our renderComponent
|
||||
*/
|
||||
function renderHook( renderCallback, options = {} ) {
|
||||
const { initialProps, ...renderOptions } = options;
|
||||
const result = React.createRef( );
|
||||
|
||||
const TestComponent = ( { renderCallbackProps } ) => {
|
||||
// eslint-disable-next-line testing-library/render-result-naming-convention
|
||||
const pendingResult = renderCallback( renderCallbackProps );
|
||||
|
||||
React.useEffect( ( ) => {
|
||||
result.current = pendingResult;
|
||||
} );
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const { rerender: baseRerender, unmount } = renderComponent(
|
||||
<TestComponent renderCallbackProps={initialProps} />,
|
||||
null,
|
||||
renderOptions
|
||||
);
|
||||
|
||||
function rerender( rerenderCallbackProps ) {
|
||||
return baseRerender(
|
||||
<TestComponent renderCallbackProps={rerenderCallbackProps} />
|
||||
);
|
||||
}
|
||||
|
||||
return { result, rerender, unmount };
|
||||
}
|
||||
|
||||
export {
|
||||
renderApp,
|
||||
renderAppWithComponent,
|
||||
renderAppWithObservations,
|
||||
renderComponent
|
||||
renderComponent,
|
||||
renderHook
|
||||
};
|
||||
|
||||
@@ -1,8 +1,32 @@
|
||||
import { renderHook } from "@testing-library/react-native";
|
||||
import { waitFor } from "@testing-library/react-native";
|
||||
import inatjs from "inaturalistjs";
|
||||
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
|
||||
import { useTaxon } from "sharedHooks";
|
||||
import factory from "tests/factory";
|
||||
import factory, { makeResponse } from "tests/factory";
|
||||
import faker from "tests/helpers/faker";
|
||||
import { renderHook } from "tests/helpers/render";
|
||||
import setupUniqueRealm from "tests/helpers/uniqueRealm";
|
||||
|
||||
// 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]
|
||||
}
|
||||
};
|
||||
} );
|
||||
beforeAll( uniqueRealmBeforeAll );
|
||||
afterAll( uniqueRealmAfterAll );
|
||||
// /UNIQUE REALM SETUP
|
||||
|
||||
const mockRemoteTaxon = factory( "RemoteTaxon", {
|
||||
default_photo: {
|
||||
@@ -10,25 +34,33 @@ const mockRemoteTaxon = factory( "RemoteTaxon", {
|
||||
}
|
||||
} );
|
||||
|
||||
jest.mock( "sharedHooks/useAuthenticatedQuery", () => ( {
|
||||
__esModule: true,
|
||||
default: ( ) => ( {
|
||||
data: mockRemoteTaxon
|
||||
} )
|
||||
} ) );
|
||||
|
||||
const mockTaxon = factory( "LocalTaxon", {
|
||||
_syncedAt: faker.date.recent( { days: 1 } ),
|
||||
default_photo: {
|
||||
url: faker.image.url( )
|
||||
}
|
||||
} );
|
||||
const mockOutdatedTaxon = factory( "LocalTaxon", {
|
||||
_syncedAt: faker.date.recent( { days: 1, refDate: "2024-01-01" } ),
|
||||
default_photo: {
|
||||
url: faker.image.url( )
|
||||
}
|
||||
} );
|
||||
|
||||
describe( "useTaxon", ( ) => {
|
||||
beforeAll( async () => {
|
||||
jest.useFakeTimers( );
|
||||
} );
|
||||
beforeEach( ( ) => {
|
||||
jest.restoreAllMocks( );
|
||||
inatjs.taxa.fetch.mockResolvedValue( makeResponse( [mockRemoteTaxon] ) );
|
||||
} );
|
||||
|
||||
describe( "with local taxon", ( ) => {
|
||||
beforeEach( async ( ) => {
|
||||
beforeEach( ( ) => {
|
||||
// Write mock taxon to realm
|
||||
safeRealmWrite( global.realm, ( ) => {
|
||||
global.realm.create( "Taxon", mockTaxon, "modified" );
|
||||
safeRealmWrite( global.mockRealms[mockRealmIdentifier], ( ) => {
|
||||
global.mockRealms[mockRealmIdentifier].create( "Taxon", mockTaxon, "modified" );
|
||||
}, "write mock taxon, useTaxon test" );
|
||||
} );
|
||||
|
||||
@@ -40,23 +72,73 @@ describe( "useTaxon", ( ) => {
|
||||
describe( "when there is a local taxon with taxon id", ( ) => {
|
||||
it( "should return local taxon with default photo", ( ) => {
|
||||
const { result } = renderHook( ( ) => useTaxon( mockTaxon ) );
|
||||
expect( result.current ).toHaveProperty( "default_photo" );
|
||||
expect( result.current.default_photo.url ).toEqual( mockTaxon.default_photo.url );
|
||||
expect( inatjs.taxa.fetch ).not.toHaveBeenCalled( );
|
||||
const { taxon: resultTaxon } = result.current;
|
||||
expect( resultTaxon ).toHaveProperty( "default_photo" );
|
||||
expect( resultTaxon.default_photo.url ).toEqual( mockTaxon.default_photo.url );
|
||||
} );
|
||||
|
||||
it( "should request a taxon from the API if the local copy is out of date", async ( ) => {
|
||||
safeRealmWrite( global.mockRealms[mockRealmIdentifier], ( ) => {
|
||||
global.mockRealms[mockRealmIdentifier].create( "Taxon", mockOutdatedTaxon, "modified" );
|
||||
}, "write mock outdated taxon, useTaxon test" );
|
||||
renderHook( ( ) => useTaxon( mockOutdatedTaxon ) );
|
||||
await waitFor( ( ) => expect( inatjs.taxa.fetch ).toHaveBeenCalled( ) );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( "when there is no local taxon with taxon id", ( ) => {
|
||||
beforeEach( async ( ) => {
|
||||
safeRealmWrite( global.realm, ( ) => {
|
||||
global.realm.deleteAll( );
|
||||
safeRealmWrite( global.mockRealms[mockRealmIdentifier], ( ) => {
|
||||
global.mockRealms[mockRealmIdentifier].deleteAll( );
|
||||
}, "delete all realm, useTaxon test" );
|
||||
} );
|
||||
|
||||
it( "should make an API call and return passed in taxon when fetchRemote is enabled", ( ) => {
|
||||
const taxonId = faker.number.int( );
|
||||
const { result } = renderHook( ( ) => useTaxon( { id: taxonId }, true ) );
|
||||
expect( result.current ).not.toHaveProperty( "default_photo" );
|
||||
describe( "with fetchRemote: true", ( ) => {
|
||||
it( "should request the taxon from the API", async ( ) => {
|
||||
expect(
|
||||
global.mockRealms[mockRealmIdentifier].objectForPrimaryKey( "Taxon", mockTaxon.id )
|
||||
).toBeNull( );
|
||||
renderHook( ( ) => useTaxon( mockTaxon ) );
|
||||
await waitFor( ( ) => expect( inatjs.taxa.fetch ).toHaveBeenCalled( ) );
|
||||
} );
|
||||
|
||||
it( "should return the argument taxon if request fails", ( ) => {
|
||||
// I don't love this. While it kind of mocks at the edge of the code
|
||||
// we need to integrate, it doesn't test out API error handling code.
|
||||
// I tried mocking inatjs to make it throw, but that always seems to
|
||||
// result in a failure in the test, even though useQuery should catch
|
||||
// those errors. ~~~kueda20240305
|
||||
jest.mock( "@tanstack/react-query", () => ( {
|
||||
useQuery: jest.fn( ( ) => ( {
|
||||
error: { }
|
||||
} ) )
|
||||
} ) );
|
||||
const partialTaxon = { id: faker.number.int( ), foo: "bar" };
|
||||
const { result } = renderHook( ( ) => useTaxon( partialTaxon ) );
|
||||
expect( result.current.taxon.foo ).toEqual( "bar" );
|
||||
jest.unmock( "@tanstack/react-query" );
|
||||
} );
|
||||
|
||||
it( "should return a taxon like a local taxon record if the request succeeds", async ( ) => {
|
||||
const { result } = renderHook( ( ) => useTaxon( { id: mockTaxon.id } ) );
|
||||
await waitFor( ( ) => expect( inatjs.taxa.fetch ).toHaveBeenCalled( ) );
|
||||
expect( result.current.taxon ).toHaveProperty( "default_photo" );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( "with fetchRemote: false", ( ) => {
|
||||
it( "should not call the API and return passed in taxon", ( ) => {
|
||||
const taxonId = faker.number.int( );
|
||||
expect(
|
||||
global.mockRealms[mockRealmIdentifier].objectForPrimaryKey( "Taxon", taxonId )
|
||||
).toBeNull( );
|
||||
const { result } = renderHook( ( ) => useTaxon( { id: taxonId, foo: "bar" }, false ) );
|
||||
expect( inatjs.taxa.fetch ).not.toHaveBeenCalled( );
|
||||
expect( result.current.taxon ).not.toHaveProperty( "default_photo" );
|
||||
expect( result.current.taxon.foo ).toEqual( "bar" );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
|
||||
@@ -35,7 +35,7 @@ jest.mock( "sharedHooks/useAuthenticatedQuery", () => ( {
|
||||
|
||||
jest.mock( "sharedHooks/useTaxon", () => ( {
|
||||
__esModule: true,
|
||||
default: () => mockLocalTaxon
|
||||
default: () => ( { taxon: mockLocalTaxon } )
|
||||
} ) );
|
||||
|
||||
const mockModelLoaded = {
|
||||
@@ -120,9 +120,11 @@ describe( "AR Camera", ( ) => {
|
||||
|
||||
it( "displays iconic taxon icon if taxon does not exist in realm", ( ) => {
|
||||
jest.spyOn( useTaxon, "default" ).mockImplementation( () => ( {
|
||||
...mockLocalTaxon,
|
||||
default_photo: {
|
||||
url: null
|
||||
taxon: {
|
||||
...mockLocalTaxon,
|
||||
default_photo: {
|
||||
url: null
|
||||
}
|
||||
}
|
||||
} ) );
|
||||
jest.spyOn( usePredictions, "default" ).mockImplementation( () => ( {
|
||||
|
||||
@@ -31,13 +31,7 @@ exports[`ObsGridItem for an observation with a photo should render 1`] = `
|
||||
}
|
||||
testID="MyObservations.gridItem.00000000-0000-0000-0000-000000000000"
|
||||
>
|
||||
<Image
|
||||
accessibilityIgnoresInvertColors={true}
|
||||
source={
|
||||
{
|
||||
"uri": "https://inaturalist-open-data.s3.amazonaws.com/photos/1/large.jpeg",
|
||||
}
|
||||
}
|
||||
<View
|
||||
style={
|
||||
[
|
||||
[
|
||||
@@ -47,11 +41,126 @@ exports[`ObsGridItem for an observation with a photo should render 1`] = `
|
||||
{
|
||||
"aspectRatio": 1,
|
||||
},
|
||||
{
|
||||
"position": "relative",
|
||||
},
|
||||
],
|
||||
]
|
||||
}
|
||||
testID="ObsList.photo"
|
||||
/>
|
||||
>
|
||||
<View
|
||||
style={
|
||||
[
|
||||
[
|
||||
{
|
||||
"position": "absolute",
|
||||
},
|
||||
{
|
||||
"width": "100%",
|
||||
},
|
||||
{
|
||||
"height": "100%",
|
||||
},
|
||||
],
|
||||
]
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
[
|
||||
[
|
||||
{
|
||||
"flexGrow": 1,
|
||||
},
|
||||
{
|
||||
"aspectRatio": 1,
|
||||
},
|
||||
{
|
||||
"maxHeight": 210,
|
||||
},
|
||||
{
|
||||
"overflow": "hidden",
|
||||
},
|
||||
{
|
||||
"position": "relative",
|
||||
},
|
||||
{
|
||||
"borderBottomLeftRadius": 15,
|
||||
"borderBottomRightRadius": 15,
|
||||
"borderTopLeftRadius": 15,
|
||||
"borderTopRightRadius": 15,
|
||||
},
|
||||
{
|
||||
"width": 200,
|
||||
},
|
||||
{
|
||||
"width": 200,
|
||||
},
|
||||
{
|
||||
"backgroundColor": "#454545",
|
||||
},
|
||||
{
|
||||
"flexShrink": 1,
|
||||
},
|
||||
{
|
||||
"justifyContent": "center",
|
||||
},
|
||||
{
|
||||
"alignItems": "center",
|
||||
},
|
||||
{
|
||||
"backgroundColor": "#454545",
|
||||
},
|
||||
],
|
||||
]
|
||||
}
|
||||
testID="IconicTaxonName.iconicTaxonIcon"
|
||||
>
|
||||
<Text
|
||||
allowFontScaling={false}
|
||||
selectable={false}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"color": "#BFBFBF33",
|
||||
"fontSize": 100,
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
"fontFamily": "INatIcon",
|
||||
"fontStyle": "normal",
|
||||
"fontWeight": "normal",
|
||||
},
|
||||
{},
|
||||
]
|
||||
}
|
||||
>
|
||||
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Image
|
||||
accessibilityIgnoresInvertColors={true}
|
||||
source={
|
||||
{
|
||||
"uri": "https://inaturalist-open-data.s3.amazonaws.com/photos/1/large.jpeg",
|
||||
}
|
||||
}
|
||||
style={
|
||||
[
|
||||
[
|
||||
{
|
||||
"flexGrow": 1,
|
||||
},
|
||||
{
|
||||
"aspectRatio": 1,
|
||||
},
|
||||
],
|
||||
]
|
||||
}
|
||||
testID="ObsList.photo"
|
||||
/>
|
||||
</View>
|
||||
<BVLinearGradient
|
||||
colors={
|
||||
[
|
||||
|
||||
@@ -12,7 +12,7 @@ const mockTaxon = {
|
||||
|
||||
jest.mock( "sharedHooks/useTaxon", () => ( {
|
||||
__esModule: true,
|
||||
default: () => mockTaxon
|
||||
default: () => ( { taxon: mockTaxon } )
|
||||
} ) );
|
||||
|
||||
describe( "TaxonResult", () => {
|
||||
|
||||
@@ -77,10 +77,7 @@ exports[`TaxonResult should render correctly 1`] = `
|
||||
"alignItems": "center",
|
||||
},
|
||||
{
|
||||
"width": 64,
|
||||
},
|
||||
{
|
||||
"flexGrow": 1,
|
||||
"flexShrink": 1,
|
||||
},
|
||||
],
|
||||
]
|
||||
@@ -91,49 +88,25 @@ exports[`TaxonResult should render correctly 1`] = `
|
||||
[
|
||||
[
|
||||
{
|
||||
"maxHeight": 210,
|
||||
},
|
||||
{
|
||||
"overflow": "hidden",
|
||||
},
|
||||
{
|
||||
"position": "relative",
|
||||
},
|
||||
{
|
||||
"borderBottomLeftRadius": 8,
|
||||
"borderBottomRightRadius": 8,
|
||||
"borderTopLeftRadius": 8,
|
||||
"borderTopRightRadius": 8,
|
||||
"width": 62,
|
||||
},
|
||||
{
|
||||
"height": 62,
|
||||
},
|
||||
{
|
||||
"width": 62,
|
||||
"justifyContent": "center",
|
||||
},
|
||||
{
|
||||
"position": "relative",
|
||||
},
|
||||
[
|
||||
{
|
||||
"borderBottomLeftRadius": 12,
|
||||
"borderBottomRightRadius": 12,
|
||||
"borderTopLeftRadius": 12,
|
||||
"borderTopRightRadius": 12,
|
||||
},
|
||||
],
|
||||
],
|
||||
]
|
||||
}
|
||||
testID="undefined.photo"
|
||||
>
|
||||
<View
|
||||
style={
|
||||
[
|
||||
[
|
||||
{
|
||||
"flexGrow": 1,
|
||||
},
|
||||
{
|
||||
"aspectRatio": 1,
|
||||
},
|
||||
{
|
||||
"maxHeight": 210,
|
||||
},
|
||||
@@ -155,77 +128,122 @@ exports[`TaxonResult should render correctly 1`] = `
|
||||
{
|
||||
"width": 62,
|
||||
},
|
||||
{
|
||||
"flexShrink": 1,
|
||||
},
|
||||
{
|
||||
"justifyContent": "center",
|
||||
},
|
||||
{
|
||||
"alignItems": "center",
|
||||
},
|
||||
{
|
||||
"borderBottomWidth": 1,
|
||||
"borderLeftWidth": 1,
|
||||
"borderRightWidth": 1,
|
||||
"borderTopWidth": 1,
|
||||
},
|
||||
{
|
||||
"borderBottomColor": "#E8E8E8",
|
||||
"borderLeftColor": "#E8E8E8",
|
||||
"borderRightColor": "#E8E8E8",
|
||||
"borderTopColor": "#E8E8E8",
|
||||
},
|
||||
[
|
||||
{
|
||||
"borderBottomLeftRadius": 12,
|
||||
"borderBottomRightRadius": 12,
|
||||
"borderTopLeftRadius": 12,
|
||||
"borderTopRightRadius": 12,
|
||||
},
|
||||
],
|
||||
],
|
||||
]
|
||||
}
|
||||
testID="IconicTaxonName.iconicTaxonIcon"
|
||||
testID="undefined.photo"
|
||||
>
|
||||
<Text
|
||||
allowFontScaling={false}
|
||||
selectable={false}
|
||||
<View
|
||||
style={
|
||||
[
|
||||
{
|
||||
"color": "rgba(103, 80, 164, 1)",
|
||||
"fontSize": 22,
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
"fontFamily": "INatIcon",
|
||||
"fontStyle": "normal",
|
||||
"fontWeight": "normal",
|
||||
},
|
||||
{},
|
||||
[
|
||||
{
|
||||
"flexGrow": 1,
|
||||
},
|
||||
{
|
||||
"aspectRatio": 1,
|
||||
},
|
||||
{
|
||||
"maxHeight": 210,
|
||||
},
|
||||
{
|
||||
"overflow": "hidden",
|
||||
},
|
||||
{
|
||||
"position": "relative",
|
||||
},
|
||||
{
|
||||
"borderBottomLeftRadius": 8,
|
||||
"borderBottomRightRadius": 8,
|
||||
"borderTopLeftRadius": 8,
|
||||
"borderTopRightRadius": 8,
|
||||
},
|
||||
{
|
||||
"height": 62,
|
||||
},
|
||||
{
|
||||
"width": 62,
|
||||
},
|
||||
{
|
||||
"flexShrink": 1,
|
||||
},
|
||||
{
|
||||
"justifyContent": "center",
|
||||
},
|
||||
{
|
||||
"alignItems": "center",
|
||||
},
|
||||
{
|
||||
"borderBottomWidth": 1,
|
||||
"borderLeftWidth": 1,
|
||||
"borderRightWidth": 1,
|
||||
"borderTopWidth": 1,
|
||||
},
|
||||
{
|
||||
"borderBottomColor": "#E8E8E8",
|
||||
"borderLeftColor": "#E8E8E8",
|
||||
"borderRightColor": "#E8E8E8",
|
||||
"borderTopColor": "#E8E8E8",
|
||||
},
|
||||
],
|
||||
]
|
||||
}
|
||||
testID="IconicTaxonName.iconicTaxonIcon"
|
||||
>
|
||||
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={
|
||||
[
|
||||
<Text
|
||||
allowFontScaling={false}
|
||||
selectable={false}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"color": "rgba(103, 80, 164, 1)",
|
||||
"fontSize": 22,
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
"fontFamily": "INatIcon",
|
||||
"fontStyle": "normal",
|
||||
"fontWeight": "normal",
|
||||
},
|
||||
{},
|
||||
]
|
||||
}
|
||||
>
|
||||
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={
|
||||
[
|
||||
{
|
||||
"position": "absolute",
|
||||
},
|
||||
{
|
||||
"right": 0,
|
||||
},
|
||||
{
|
||||
"paddingBottom": 4,
|
||||
"paddingLeft": 4,
|
||||
"paddingRight": 4,
|
||||
"paddingTop": 4,
|
||||
},
|
||||
{
|
||||
"bottom": 0,
|
||||
},
|
||||
],
|
||||
]
|
||||
}
|
||||
/>
|
||||
[
|
||||
{
|
||||
"position": "absolute",
|
||||
},
|
||||
{
|
||||
"right": 0,
|
||||
},
|
||||
{
|
||||
"paddingBottom": 4,
|
||||
"paddingLeft": 4,
|
||||
"paddingRight": 4,
|
||||
"paddingTop": 4,
|
||||
},
|
||||
{
|
||||
"bottom": 0,
|
||||
},
|
||||
],
|
||||
]
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
style={
|
||||
|
||||
@@ -18,7 +18,7 @@ const mockTaxon = factory( "RemoteTaxon" );
|
||||
|
||||
jest.mock( "sharedHooks/useTaxon", () => ( {
|
||||
__esModule: true,
|
||||
default: () => mockTaxon
|
||||
default: () => ( { taxon: mockTaxon } )
|
||||
} ) );
|
||||
|
||||
const mockSuggestionsList = [{
|
||||
|
||||
@@ -33,7 +33,7 @@ jest.mock( "sharedHooks/useTaxonSearch", () => ( {
|
||||
|
||||
jest.mock( "sharedHooks/useTaxon", () => ( {
|
||||
__esModule: true,
|
||||
default: () => mockTaxaList[0]
|
||||
default: () => ( { taxon: mockTaxaList[0] } )
|
||||
} ) );
|
||||
|
||||
// react-native-paper's TextInput does a bunch of async stuff that's hard to
|
||||
|
||||
Reference in New Issue
Block a user