diff --git a/tests/unit/components/Explore/ExploreV2/components/UniversalSearchResult.test.js b/tests/unit/components/Explore/ExploreV2/components/UniversalSearchResult.test.js
new file mode 100644
index 000000000..a3d8a8eca
--- /dev/null
+++ b/tests/unit/components/Explore/ExploreV2/components/UniversalSearchResult.test.js
@@ -0,0 +1,115 @@
+import { fireEvent, screen } from "@testing-library/react-native";
+import UniversalSearchResult
+ from "components/Explore/ExploreV2/components/UniversalSearchResult";
+import initI18next from "i18n/initI18next";
+import React from "react";
+import { renderComponent } from "tests/helpers/render";
+
+jest.mock( "sharedHooks/useCurrentUser", ( ) => ( {
+ __esModule: true,
+ default: jest.fn( ),
+} ) );
+const useCurrentUser = require( "sharedHooks/useCurrentUser" ).default;
+
+const TAXON_WITH_PHOTO = {
+ type: "taxon",
+ taxon: {
+ id: 12,
+ name: "Eumyias thalassinus",
+ preferred_common_name: "Verditer Flycatcher",
+ iconic_taxon_name: "Aves",
+ default_photo: { url: "https://example.com/t.jpg" },
+ },
+};
+
+const TAXON_WITHOUT_PHOTO = {
+ type: "taxon",
+ taxon: {
+ id: 13,
+ name: "Plantae",
+ iconic_taxon_name: "Plantae",
+ },
+};
+
+const USER_RESULT = {
+ type: "user",
+ user: {
+ id: 7,
+ login: "seth_msp",
+ icon_url: "https://example.com/u.jpg",
+ observations_count: 5,
+ },
+};
+
+const PROJECT_RESULT = {
+ type: "project",
+ project: {
+ id: 9,
+ title: "InverteFest",
+ project_type: "collection",
+ rule_preferences: [],
+ icon: "https://example.com/p.jpg",
+ },
+};
+
+beforeAll( async ( ) => {
+ await initI18next( );
+} );
+
+beforeEach( ( ) => {
+ useCurrentUser.mockReturnValue( {
+ prefers_common_names: true,
+ prefers_scientific_name_first: false,
+ } );
+} );
+
+describe( "UniversalSearchResult", ( ) => {
+ it( "renders a taxon result with its photo", ( ) => {
+ renderComponent(
+ ,
+ );
+
+ expect( screen.getByTestId( "UniversalSearchResult.taxon.12" ) ).toBeTruthy( );
+ expect( screen.getByTestId( "UniversalSearchResult.taxonImage" ) ).toBeTruthy( );
+ expect( screen.getByText( "Verditer Flycatcher" ) ).toBeTruthy( );
+ } );
+
+ it( "falls back to the iconic taxon icon when a taxon has no photo", ( ) => {
+ renderComponent(
+ ,
+ );
+
+ expect( screen.getByTestId( "UniversalSearchResult.taxon.13" ) ).toBeTruthy( );
+ expect( screen.queryByTestId( "UniversalSearchResult.taxonImage" ) ).toBeNull( );
+ expect( screen.getByTestId( "IconicTaxonName.iconicTaxonIcon" ) ).toBeTruthy( );
+ } );
+
+ it( "renders a user result with its login and observation count", ( ) => {
+ renderComponent(
+ ,
+ );
+
+ expect( screen.getByTestId( "UniversalSearchResult.user.7" ) ).toBeTruthy( );
+ expect( screen.getByText( "seth_msp" ) ).toBeTruthy( );
+ } );
+
+ it( "renders a project result with its title", ( ) => {
+ renderComponent(
+ ,
+ );
+
+ expect( screen.getByTestId( "UniversalSearchResult.project.9" ) ).toBeTruthy( );
+ expect( screen.getByText( "InverteFest" ) ).toBeTruthy( );
+ } );
+
+ it( "calls onPress when the row is tapped", ( ) => {
+ const onPress = jest.fn( );
+ renderComponent(
+ ,
+ );
+
+ fireEvent.press( screen.getByTestId( "UniversalSearchResult.user.7" ) );
+
+ expect( onPress ).toHaveBeenCalledTimes( 1 );
+ } );
+} );
diff --git a/tests/unit/components/Explore/ExploreV2/helpers/universalSearchSubject.test.js b/tests/unit/components/Explore/ExploreV2/helpers/universalSearchSubject.test.js
new file mode 100644
index 000000000..9c5b08aaf
--- /dev/null
+++ b/tests/unit/components/Explore/ExploreV2/helpers/universalSearchSubject.test.js
@@ -0,0 +1,113 @@
+import {
+ resultToSubject,
+ subjectToText,
+} from "components/Explore/ExploreV2/helpers/universalSearchSubject";
+
+describe( "resultToSubject", ( ) => {
+ it( "maps a user result to a user subject", ( ) => {
+ const result = {
+ type: "user",
+ user: {
+ id: 7,
+ login: "seth_msp",
+ icon_url: "https://example.com/u.jpg",
+ observations_count: 5,
+ },
+ };
+
+ expect( resultToSubject( result ) ).toEqual( {
+ type: "user",
+ user: {
+ id: 7,
+ login: "seth_msp",
+ icon_url: "https://example.com/u.jpg",
+ },
+ } );
+ } );
+
+ it( "maps a project result to a project subject", ( ) => {
+ const result = {
+ type: "project",
+ project: {
+ id: 9,
+ title: "InverteFest",
+ project_type: "collection",
+ icon: "https://example.com/p.jpg",
+ },
+ };
+
+ expect( resultToSubject( result ) ).toEqual( {
+ type: "project",
+ project: {
+ id: 9,
+ title: "InverteFest",
+ icon: "https://example.com/p.jpg",
+ },
+ } );
+ } );
+
+ it( "maps a taxon result to a taxon subject", ( ) => {
+ const result = {
+ type: "taxon",
+ taxon: {
+ id: 12,
+ name: "Eumyias thalassinus",
+ preferred_common_name: "Verditer Flycatcher",
+ iconic_taxon_name: "Aves",
+ default_photo: { url: "https://example.com/t.jpg" },
+ },
+ };
+
+ expect( resultToSubject( result ) ).toEqual( {
+ type: "taxon",
+ taxon: {
+ id: 12,
+ name: "Eumyias thalassinus",
+ preferred_common_name: "Verditer Flycatcher",
+ iconic_taxon_name: "Aves",
+ default_photo: { url: "https://example.com/t.jpg" },
+ },
+ } );
+ } );
+} );
+
+describe( "subjectToText", ( ) => {
+ it( "returns the login for a user subject", ( ) => {
+ const subject = { type: "user", user: { id: 7, login: "seth_msp" } };
+
+ expect( subjectToText( subject, true ) ).toEqual( "seth_msp" );
+ } );
+
+ it( "returns the title for a project subject", ( ) => {
+ const subject = { type: "project", project: { id: 9, title: "InverteFest" } };
+
+ expect( subjectToText( subject, true ) ).toEqual( "InverteFest" );
+ } );
+
+ it( "returns the common name for a taxon when the common name is primary", ( ) => {
+ const subject = {
+ type: "taxon",
+ taxon: { id: 2, name: "Aves", preferred_common_name: "Birds" },
+ };
+
+ expect( subjectToText( subject, true ) ).toEqual( "Birds" );
+ } );
+
+ it( "returns the scientific name for a taxon when the scientific name is primary", ( ) => {
+ const subject = {
+ type: "taxon",
+ taxon: { id: 2, name: "Aves", preferred_common_name: "Birds" },
+ };
+
+ expect( subjectToText( subject, false ) ).toEqual( "Aves" );
+ } );
+
+ it( "falls back to the scientific name when there is no common name", ( ) => {
+ const subject = {
+ type: "taxon",
+ taxon: { id: 2, name: "Aves" },
+ };
+
+ expect( subjectToText( subject, true ) ).toEqual( "Aves" );
+ } );
+} );
diff --git a/tests/unit/components/Explore/ExploreV2/screens/UniversalSearch.test.js b/tests/unit/components/Explore/ExploreV2/screens/UniversalSearch.test.js
index f157a9349..3c7edcae4 100644
--- a/tests/unit/components/Explore/ExploreV2/screens/UniversalSearch.test.js
+++ b/tests/unit/components/Explore/ExploreV2/screens/UniversalSearch.test.js
@@ -51,7 +51,7 @@ const MIXED_RESULTS = [
type: "user",
user: {
id: 7,
- login: "carrieseltzer",
+ login: "seth_msp",
icon_url: "https://example.com/u.jpg",
observations_count: 5,
},
@@ -147,7 +147,7 @@ describe( "UniversalSearch screen", ( ) => {
typeQuery( "ver" );
- expect( screen.getByText( "carrieseltzer" ) ).toBeTruthy( );
+ expect( screen.getByText( "seth_msp" ) ).toBeTruthy( );
expect( screen.getByText( "InverteFest" ) ).toBeTruthy( );
expect( screen.getByTestId( "UniversalSearchResult.taxon.12" ) ).toBeTruthy( );
// each row has a (no-op) info icon
@@ -171,12 +171,12 @@ describe( "UniversalSearch screen", ( ) => {
type: "SET_SUBJECT",
subject: expect.objectContaining( {
type: "user",
- user: expect.objectContaining( { id: 7, login: "carrieseltzer" } ),
+ user: expect.objectContaining( { id: 7, login: "seth_msp" } ),
} ),
} ),
);
// the search field is filled with the selected suggestion
- expect( screen.getByDisplayValue( "carrieseltzer" ) ).toBeTruthy( );
+ expect( screen.getByDisplayValue( "seth_msp" ) ).toBeTruthy( );
} );
it( "clears the field but keeps the subject when tapping back in after a selection", ( ) => {
@@ -189,17 +189,55 @@ describe( "UniversalSearch screen", ( ) => {
typeQuery( "ver" );
fireEvent.press( screen.getByTestId( "UniversalSearchResult.user.7" ) );
- expect( screen.getByDisplayValue( "carrieseltzer" ) ).toBeTruthy( );
+ expect( screen.getByDisplayValue( "seth_msp" ) ).toBeTruthy( );
mockDispatch.mockClear( );
fireEvent( screen.getByTestId( "UniversalSearch.subjectInput" ), "focus" );
// the field is cleared for a fresh search...
- expect( screen.queryByDisplayValue( "carrieseltzer" ) ).toBeNull( );
+ expect( screen.queryByDisplayValue( "seth_msp" ) ).toBeNull( );
// ...but the committed subject persists (only Reset / a new selection clears it)
expect( mockDispatch ).not.toHaveBeenCalledWith( { type: "CLEAR_SUBJECT" } );
} );
+ it( "shows an empty-state message when a query returns no results", ( ) => {
+ useUniversalSearch.mockReturnValue( { results: [], isLoading: false, refetch: jest.fn( ) } );
+ renderComponent( );
+
+ typeQuery( "zzzzz" );
+
+ expect(
+ screen.getByText( i18next.t( "No-results-found-for-that-search" ) ),
+ ).toBeTruthy( );
+ } );
+
+ it( "does not show the empty-state message while results are loading", ( ) => {
+ useUniversalSearch.mockReturnValue( { results: [], isLoading: true, refetch: jest.fn( ) } );
+ renderComponent( );
+
+ typeQuery( "ver" );
+
+ expect(
+ screen.queryByText( i18next.t( "No-results-found-for-that-search" ) ),
+ ).toBeNull( );
+ } );
+
+ it( "does not show results until the user has typed a query", ( ) => {
+ useUniversalSearch.mockReturnValue( {
+ results: MIXED_RESULTS,
+ isLoading: false,
+ refetch: jest.fn( ),
+ } );
+ renderComponent( );
+
+ // No query typed yet, so the result list is not rendered even though the
+ // hook would return data.
+ expect( screen.queryByText( "seth_msp" ) ).toBeNull( );
+ expect(
+ screen.queryByText( i18next.t( "No-results-found-for-that-search" ) ),
+ ).toBeNull( );
+ } );
+
it( "navigates to Advanced Search", ( ) => {
renderComponent( );
diff --git a/tests/unit/sharedHooks/useDebouncedValue.test.js b/tests/unit/sharedHooks/useDebouncedValue.test.js
new file mode 100644
index 000000000..b93161707
--- /dev/null
+++ b/tests/unit/sharedHooks/useDebouncedValue.test.js
@@ -0,0 +1,90 @@
+import { act, renderHook } from "@testing-library/react-native";
+import useDebouncedValue from "sharedHooks/useDebouncedValue";
+
+const DELAY = 400;
+
+beforeEach( ( ) => {
+ jest.useFakeTimers( );
+} );
+
+afterEach( ( ) => {
+ jest.useRealTimers( );
+} );
+
+describe( "useDebouncedValue", ( ) => {
+ it( "returns the initial value immediately", ( ) => {
+ const { result } = renderHook( ( ) => useDebouncedValue( "start", DELAY ) );
+
+ expect( result.current.debouncedValue ).toEqual( "start" );
+ } );
+
+ it( "does not update the value until the delay elapses", ( ) => {
+ const { result } = renderHook( ( ) => useDebouncedValue( "", DELAY ) );
+
+ act( ( ) => {
+ result.current.debounce( "later" );
+ jest.advanceTimersByTime( DELAY - 1 );
+ } );
+
+ expect( result.current.debouncedValue ).toEqual( "" );
+
+ act( ( ) => {
+ jest.advanceTimersByTime( 1 );
+ } );
+
+ expect( result.current.debouncedValue ).toEqual( "later" );
+ } );
+
+ it( "coalesces rapid calls so only the last value lands", ( ) => {
+ const { result } = renderHook( ( ) => useDebouncedValue( "", DELAY ) );
+
+ act( ( ) => {
+ result.current.debounce( "a" );
+ jest.advanceTimersByTime( DELAY - 100 );
+ // A second call before the first fired resets the timer.
+ result.current.debounce( "b" );
+ jest.advanceTimersByTime( DELAY - 100 );
+ } );
+
+ // The pending "a" was cancelled, and "b" hasn't reached its full delay yet.
+ expect( result.current.debouncedValue ).toEqual( "" );
+
+ act( ( ) => {
+ jest.advanceTimersByTime( 100 );
+ } );
+
+ expect( result.current.debouncedValue ).toEqual( "b" );
+ } );
+
+ it( "setImmediately applies the value now and cancels a pending debounce", ( ) => {
+ const { result } = renderHook( ( ) => useDebouncedValue( "", DELAY ) );
+
+ act( ( ) => {
+ result.current.debounce( "pending" );
+ result.current.setImmediately( "now" );
+ } );
+
+ expect( result.current.debouncedValue ).toEqual( "now" );
+
+ // The pending debounce must not overwrite the immediate value later.
+ act( ( ) => {
+ jest.advanceTimersByTime( DELAY );
+ } );
+
+ expect( result.current.debouncedValue ).toEqual( "now" );
+ } );
+
+ it( "cancels a pending debounce on unmount", ( ) => {
+ const clearTimeoutSpy = jest.spyOn( global, "clearTimeout" );
+ const { result, unmount } = renderHook( ( ) => useDebouncedValue( "", DELAY ) );
+
+ act( ( ) => {
+ result.current.debounce( "never" );
+ } );
+
+ unmount( );
+
+ expect( clearTimeoutSpy ).toHaveBeenCalled( );
+ clearTimeoutSpy.mockRestore( );
+ } );
+} );
diff --git a/tests/unit/sharedHooks/useUniversalSearch.test.js b/tests/unit/sharedHooks/useUniversalSearch.test.js
new file mode 100644
index 000000000..0ed3f1d1e
--- /dev/null
+++ b/tests/unit/sharedHooks/useUniversalSearch.test.js
@@ -0,0 +1,73 @@
+import { waitFor } from "@testing-library/react-native";
+import useUniversalSearch from "sharedHooks/useUniversalSearch";
+import { renderHookInApp } from "tests/helpers/render";
+
+jest.mock( "api/search" );
+const { search } = require( "api/search" );
+
+const TAXON_RESULT = {
+ taxon: {
+ id: 12,
+ name: "Eumyias thalassinus",
+ preferred_common_name: "Verditer Flycatcher",
+ },
+};
+const USER_RESULT = {
+ user: { id: 7, login: "seth_msp" },
+};
+const PROJECT_RESULT = {
+ project: { id: 9, title: "InverteFest" },
+};
+
+beforeEach( ( ) => {
+ search.mockReset( );
+} );
+
+describe( "useUniversalSearch", ( ) => {
+ it( "does not query and returns no results for a blank query", async ( ) => {
+ const { result } = renderHookInApp( ( ) => useUniversalSearch( " " ) );
+
+ // Give the (disabled) query a chance to fire if it were going to.
+ await waitFor( ( ) => expect( result.current.isLoading ).toBe( false ) );
+
+ expect( search ).not.toHaveBeenCalled( );
+ expect( result.current.results ).toEqual( [] );
+ } );
+
+ it( "trims the query before sending it to the API", async ( ) => {
+ search.mockResolvedValue( { results: [] } );
+
+ renderHookInApp( ( ) => useUniversalSearch( " ver " ) );
+
+ await waitFor( ( ) => expect( search ).toHaveBeenCalled( ) );
+ expect( search ).toHaveBeenCalledWith(
+ expect.objectContaining( { q: "ver", sources: ["taxa", "users", "projects"] } ),
+ expect.anything( ),
+ );
+ } );
+
+ it( "maps a mixed response to a tagged union, preserving order", async ( ) => {
+ search.mockResolvedValue( {
+ results: [USER_RESULT, TAXON_RESULT, PROJECT_RESULT],
+ } );
+
+ const { result } = renderHookInApp( ( ) => useUniversalSearch( "ver" ) );
+
+ await waitFor( ( ) => expect( result.current.results.length ).toEqual( 3 ) );
+
+ expect( result.current.results ).toEqual( [
+ { type: "user", user: USER_RESULT.user },
+ { type: "taxon", taxon: TAXON_RESULT.taxon },
+ { type: "project", project: PROJECT_RESULT.project },
+ ] );
+ } );
+
+ it( "returns an empty list when the API responds with null", async ( ) => {
+ search.mockResolvedValue( null );
+
+ const { result } = renderHookInApp( ( ) => useUniversalSearch( "ver" ) );
+
+ await waitFor( ( ) => expect( search ).toHaveBeenCalled( ) );
+ expect( result.current.results ).toEqual( [] );
+ } );
+} );