MOB-1339: tests

This commit is contained in:
sepeterson committed 2026-06-25 11:46:17 -05:00
1 parent 7a1760d2c1
commit 9e1376bcea
5 files changed
+435 -6

No files matched your search

@@ -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(
<UniversalSearchResult result={TAXON_WITH_PHOTO} onPress={jest.fn( )} />,
);
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(
<UniversalSearchResult result={TAXON_WITHOUT_PHOTO} onPress={jest.fn( )} />,
);
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(
<UniversalSearchResult result={USER_RESULT} onPress={jest.fn( )} />,
);
expect( screen.getByTestId( "UniversalSearchResult.user.7" ) ).toBeTruthy( );
expect( screen.getByText( "seth_msp" ) ).toBeTruthy( );
} );
it( "renders a project result with its title", ( ) => {
renderComponent(
<UniversalSearchResult result={PROJECT_RESULT} onPress={jest.fn( )} />,
);
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(
<UniversalSearchResult result={USER_RESULT} onPress={onPress} />,
);
fireEvent.press( screen.getByTestId( "UniversalSearchResult.user.7" ) );
expect( onPress ).toHaveBeenCalledTimes( 1 );
} );
} );
@@ -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" );
} );
} );
@@ -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( <UniversalSearch /> );
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( <UniversalSearch /> );
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( <UniversalSearch /> );
// 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( <UniversalSearch /> );
@@ -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( );
} );
} );
@@ -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( [] );
} );
} );