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( [] ); + } ); +} );