From bfec7984dfd1acbcda73e08d38baa9f76119ca5d Mon Sep 17 00:00:00 2001 From: Seth Peterson <10458078+sepeterson@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:27:54 -0500 Subject: [PATCH] MOB-1347: tests --- src/components/SharedComponents/Tabs/Tabs.tsx | 2 +- .../components/ExploreV2Tabs.test.js | 53 ++++++++ .../ExploreV2/hooks/useUserTabCounts.test.js | 51 +++++++ .../ExploreV2/screens/ExploreResults.test.js | 96 ++++++++++++- .../screens/ExploreV2UsersView.test.js | 126 ++++++++++++++++++ .../SharedComponents/Tabs/Tabs.test.js | 37 ++++- 6 files changed, 362 insertions(+), 3 deletions(-) create mode 100644 tests/unit/components/Explore/ExploreV2/hooks/useUserTabCounts.test.js create mode 100644 tests/unit/components/Explore/ExploreV2/screens/ExploreV2UsersView.test.js diff --git a/src/components/SharedComponents/Tabs/Tabs.tsx b/src/components/SharedComponents/Tabs/Tabs.tsx index 9e1d36f88..868e98c74 100644 --- a/src/components/SharedComponents/Tabs/Tabs.tsx +++ b/src/components/SharedComponents/Tabs/Tabs.tsx @@ -36,7 +36,7 @@ const EMPTY_TABS: Tab[] = []; // first two tabs need to occupy less than 100% of the screen width // so the user can be cued by a third option peeking out -const SCROLLABLE_TAB_WIDTH_RATIO = 0.42; +export const SCROLLABLE_TAB_WIDTH_RATIO = 0.42; const SCROLL_STYLE = { flexGrow: 0 }; diff --git a/tests/unit/components/Explore/ExploreV2/components/ExploreV2Tabs.test.js b/tests/unit/components/Explore/ExploreV2/components/ExploreV2Tabs.test.js index f0506a1c1..c4ce08aeb 100644 --- a/tests/unit/components/Explore/ExploreV2/components/ExploreV2Tabs.test.js +++ b/tests/unit/components/Explore/ExploreV2/components/ExploreV2Tabs.test.js @@ -2,6 +2,7 @@ import { screen, userEvent } from "@testing-library/react-native"; import ExploreV2Tabs from "components/Explore/ExploreV2/components/ExploreV2Tabs"; import { ExploreV2Provider, initialExploreV2State } from "providers/ExploreV2Context"; import React from "react"; +import useStore from "stores/useStore"; import { renderComponent } from "tests/helpers/render"; const actor = userEvent.setup( ); @@ -12,7 +13,14 @@ const renderTabs = props => renderComponent( , ); +const setAdvancedSearchMode = advancedSearchMode => useStore + .getState( ).exploreV2AdvancedSearch.setAdvancedSearchMode( advancedSearchMode ); + describe( "ExploreV2Tabs", () => { + beforeEach( () => { + setAdvancedSearchMode( false ); + } ); + it( "renders both the observations and species tabs", () => { renderTabs(); @@ -62,4 +70,49 @@ describe( "ExploreV2Tabs", () => { expect( screen.getAllByText( "--" ) ).toHaveLength( 2 ); } ); + + it( "does not render the observers and identifiers tabs in default search mode", () => { + renderTabs(); + + expect( screen.queryByTestId( "ExploreV2Tabs.observers" ) ).toBeNull(); + expect( screen.queryByTestId( "ExploreV2Tabs.identifiers" ) ).toBeNull(); + } ); + + describe( "in advanced search mode", () => { + beforeEach( () => { + setAdvancedSearchMode( true ); + } ); + + it( "renders all four tabs", () => { + renderTabs(); + + expect( screen.getByTestId( "ExploreV2Tabs.observations" ) ).toBeTruthy(); + expect( screen.getByTestId( "ExploreV2Tabs.species" ) ).toBeTruthy(); + expect( screen.getByTestId( "ExploreV2Tabs.observers" ) ).toBeTruthy(); + expect( screen.getByTestId( "ExploreV2Tabs.identifiers" ) ).toBeTruthy(); + } ); + + it( "renders the provided observer and identifier counts", () => { + renderTabs( { observersCount: 12, identifiersCount: 3 } ); + + expect( screen.getByText( "12" ) ).toBeTruthy(); + expect( screen.getByText( "3" ) ).toBeTruthy(); + } ); + + it.each( ["observers", "identifiers"] )( + "switches the active tab when the %s tab is pressed", + async tab => { + renderTabs(); + + await actor.press( screen.getByTestId( `ExploreV2Tabs.${tab}` ) ); + + expect( + screen.getByTestId( `ExploreV2Tabs.${tab}` ).props.accessibilityState.selected, + ).toBe( true ); + expect( + screen.getByTestId( "ExploreV2Tabs.observations" ).props.accessibilityState.selected, + ).toBe( false ); + }, + ); + } ); } ); diff --git a/tests/unit/components/Explore/ExploreV2/hooks/useUserTabCounts.test.js b/tests/unit/components/Explore/ExploreV2/hooks/useUserTabCounts.test.js new file mode 100644 index 000000000..725677169 --- /dev/null +++ b/tests/unit/components/Explore/ExploreV2/hooks/useUserTabCounts.test.js @@ -0,0 +1,51 @@ +import { act, waitFor } from "@testing-library/react-native"; +import useUserTabCounts from "components/Explore/ExploreV2/hooks/useUserTabCounts"; +import { renderHookInApp } from "tests/helpers/render"; + +jest.mock( "api/observations" ); +const { fetchIdentifiers, fetchObservers } = require( "api/observations" ); + +const PARAMS = { taxon_id: 12, place_id: 1 }; + +beforeEach( ( ) => { + fetchObservers.mockReset( ); + fetchIdentifiers.mockReset( ); + fetchObservers.mockResolvedValue( { total_results: 8, results: [] } ); + fetchIdentifiers.mockResolvedValue( { total_results: 5, results: [] } ); +} ); + +describe( "useUserTabCounts", ( ) => { + it( "returns the observer and identifier totals", async ( ) => { + const { result } = renderHookInApp( ( ) => useUserTabCounts( PARAMS ) ); + + await waitFor( ( ) => expect( result.current.observersCount ).toBe( 8 ) ); + expect( result.current.identifiersCount ).toBe( 5 ); + } ); + + it( "asks for no results, only the totals", async ( ) => { + renderHookInApp( ( ) => useUserTabCounts( PARAMS ) ); + + await waitFor( ( ) => expect( fetchObservers ).toHaveBeenCalled( ) ); + const [observersParams] = fetchObservers.mock.calls.at( -1 ); + expect( observersParams.per_page ).toBe( 0 ); + expect( observersParams.taxon_id ).toBe( 12 ); + const [identifiersParams] = fetchIdentifiers.mock.calls.at( -1 ); + expect( identifiersParams.per_page ).toBe( 0 ); + } ); + + it( "returns null counts and does not fetch when disabled", async ( ) => { + const { result } = renderHookInApp( + ( ) => useUserTabCounts( { taxon_id: 999 }, { enabled: false } ), + ); + + await act( async ( ) => { + await new Promise( resolve => { + setTimeout( resolve, 0 ); + } ); + } ); + expect( fetchObservers ).not.toHaveBeenCalled( ); + expect( fetchIdentifiers ).not.toHaveBeenCalled( ); + expect( result.current.observersCount ).toBeNull( ); + expect( result.current.identifiersCount ).toBeNull( ); + } ); +} ); diff --git a/tests/unit/components/Explore/ExploreV2/screens/ExploreResults.test.js b/tests/unit/components/Explore/ExploreV2/screens/ExploreResults.test.js index d642db22b..d63e04c07 100644 --- a/tests/unit/components/Explore/ExploreV2/screens/ExploreResults.test.js +++ b/tests/unit/components/Explore/ExploreV2/screens/ExploreResults.test.js @@ -2,7 +2,9 @@ import { refresh, useNetInfo } from "@react-native-community/netinfo"; import { act, screen, userEvent, waitFor, } from "@testing-library/react-native"; -import { SPECIES_TAB } from "appConstants/tabs"; +import { + IDENTIFIERS_TAB, OBSERVATIONS_TAB, OBSERVERS_TAB, SPECIES_TAB, +} from "appConstants/tabs"; import ExploreResults from "components/Explore/ExploreV2/screens/ExploreResults"; import initI18next from "i18n/initI18next"; import { @@ -12,6 +14,7 @@ import { } from "providers/ExploreV2Context"; import React from "react"; import { SPECIES_SORT } from "sharedHelpers/speciesSort"; +import useStore from "stores/useStore"; import { renderComponent } from "tests/helpers/render"; jest.mock( "@react-navigation/native", ( ) => { @@ -62,6 +65,24 @@ jest.mock( "components/Explore/ExploreV2/screens/ExploreV2SpeciesView", ( ) => ( default: props => mockExploreV2SpeciesView( props ), } ) ); +const mockExploreV2UsersView = jest.fn( ( ) => null ); +jest.mock( "components/Explore/ExploreV2/screens/ExploreV2UsersView", ( ) => ( { + __esModule: true, + default: props => mockExploreV2UsersView( props ), +} ) ); + +const lastUsersViewTab = ( ) => mockExploreV2UsersView.mock.calls.at( -1 )?.[0]?.tab; + +let mockUserTabCounts = { observersCount: null, identifiersCount: null }; +const mockUseUserTabCounts = jest.fn( ); +jest.mock( "components/Explore/ExploreV2/hooks/useUserTabCounts", ( ) => ( { + __esModule: true, + default: ( ...args ) => { + mockUseUserTabCounts( ...args ); + return mockUserTabCounts; + }, +} ) ); + let mockLayout = "map"; const mockWriteLayoutToStorage = jest.fn( ); jest.mock( "sharedHooks/useStoredLayout", ( ) => ( { @@ -91,6 +112,10 @@ beforeEach( ( ) => { mockDispatch.mockClear( ); mockRequestPermissions.mockClear( ); mockExploreV2SpeciesView.mockClear( ); + mockExploreV2UsersView.mockClear( ); + mockUseUserTabCounts.mockClear( ); + mockUserTabCounts = { observersCount: null, identifiersCount: null }; + useStore.getState( ).exploreV2AdvancedSearch.setAdvancedSearchMode( false ); fetchCoarseUserLocation.mockReset( ); mockUseInfiniteExploreScroll.mockReset( ); mockUseInfiniteExploreScroll.mockReturnValue( { @@ -448,3 +473,72 @@ describe( "ExploreResults observations view", ( ) => { } ); } ); } ); + +describe( "ExploreResults observers and identifiers tabs", ( ) => { + const advancedState = tab => ( { + ...mockState( { placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE } ), + activeTab: tab, + } ); + + beforeEach( ( ) => { + useStore.getState( ).exploreV2AdvancedSearch.setAdvancedSearchMode( true ); + } ); + + it.each( [OBSERVERS_TAB, IDENTIFIERS_TAB] )( + "shows the users list on the %s tab", + async tab => { + useExploreV2.mockReturnValue( { + state: advancedState( tab ), + dispatch: mockDispatch, + } ); + + renderComponent( ); + + await waitFor( ( ) => { + expect( lastUsersViewTab( ) ).toBe( tab ); + } ); + }, + ); + + it( "shows the observer and identifier counts in the tabs", async ( ) => { + mockUserTabCounts = { observersCount: 8, identifiersCount: 5 }; + useExploreV2.mockReturnValue( { + state: advancedState( OBSERVERS_TAB ), + dispatch: mockDispatch, + } ); + + renderComponent( ); + + expect( await screen.findByText( "8" ) ).toBeVisible( ); + expect( screen.getByText( "5" ) ).toBeVisible( ); + } ); + + it( "offers no sort on the observers tab", async ( ) => { + useExploreV2.mockReturnValue( { + state: advancedState( OBSERVERS_TAB ), + dispatch: mockDispatch, + } ); + + renderComponent( ); + + await waitFor( ( ) => { + expect( mockExploreV2UsersView ).toHaveBeenCalled( ); + } ); + expect( screen.queryByLabelText( "Change observations sort order" ) ).toBeNull( ); + } ); + + it( "does not count observers or identifiers outside of advanced search mode", async ( ) => { + useStore.getState( ).exploreV2AdvancedSearch.setAdvancedSearchMode( false ); + useExploreV2.mockReturnValue( { + state: advancedState( OBSERVATIONS_TAB ), + dispatch: mockDispatch, + } ); + + renderComponent( ); + + await waitFor( ( ) => { + expect( mockUseUserTabCounts ).toHaveBeenCalled( ); + } ); + expect( mockUseUserTabCounts.mock.calls.at( -1 )[1] ).toEqual( { enabled: false } ); + } ); +} ); diff --git a/tests/unit/components/Explore/ExploreV2/screens/ExploreV2UsersView.test.js b/tests/unit/components/Explore/ExploreV2/screens/ExploreV2UsersView.test.js new file mode 100644 index 000000000..1bb6237ed --- /dev/null +++ b/tests/unit/components/Explore/ExploreV2/screens/ExploreV2UsersView.test.js @@ -0,0 +1,126 @@ +import { screen, userEvent } from "@testing-library/react-native"; +import { fetchIdentifiers, fetchObservers } from "api/observations"; +import { IDENTIFIERS_TAB, OBSERVERS_TAB } from "appConstants/tabs"; +import ExploreV2UsersView + from "components/Explore/ExploreV2/screens/ExploreV2UsersView"; +import initI18next from "i18n/initI18next"; +import React from "react"; +import factory from "tests/factory"; +import { renderComponent } from "tests/helpers/render"; + +const mockObservers = [ + { user: factory( "RemoteUser", { id: 1, login: "carrieseltzer" } ), observation_count: 22 }, + { user: factory( "RemoteUser", { id: 2, login: "kueda" } ), observation_count: 11 }, +]; + +const mockIdentifiers = [ + { user: factory( "RemoteUser", { id: 3, login: "loarie" } ), count: 9 }, + { user: factory( "RemoteUser", { id: 4, login: "tiwane" } ), count: 4 }, +]; + +const mockedNavigate = jest.fn( ); +jest.mock( "@react-navigation/native", () => { + const actualNav = jest.requireActual( "@react-navigation/native" ); + return { + ...actualNav, + useNavigation: () => ( { navigate: mockedNavigate } ), + }; +} ); + +const mockUseInfiniteScroll = jest.fn( ); +jest.mock( "sharedHooks/useInfiniteScroll", () => ( { + __esModule: true, + default: ( ...args ) => mockUseInfiniteScroll( ...args ), +} ) ); + +const PARAMS = { taxon_id: 12, place_id: 1 }; + +const renderUsers = ( props = {} ) => renderComponent( + , +); + +const mockResults = results => mockUseInfiniteScroll.mockReturnValue( { + data: results, + fetchNextPage: jest.fn( ), + isFetchingNextPage: false, + totalResults: results.length, +} ); + +beforeAll( async ( ) => { + await initI18next( ); +} ); + +beforeEach( ( ) => { + mockedNavigate.mockClear( ); + mockUseInfiniteScroll.mockReset( ); + mockResults( mockObservers ); +} ); + +describe( "ExploreV2UsersView", ( ) => { + describe( "observers tab", ( ) => { + it( "lists observers with their observation counts", ( ) => { + renderUsers( ); + + expect( screen.getByText( "carrieseltzer" ) ).toBeVisible( ); + expect( screen.getByText( "22 Observations" ) ).toBeVisible( ); + expect( screen.getByText( "kueda" ) ).toBeVisible( ); + } ); + + it( "navigates to a user profile when an observer is pressed", async ( ) => { + const actor = userEvent.setup( ); + renderUsers( ); + + await actor.press( screen.getByTestId( "UserProfile.1" ) ); + + expect( mockedNavigate ).toHaveBeenCalledWith( "UserProfile", { userId: 1 } ); + } ); + + it( "queries observers, sorted by observation count, with the search params", ( ) => { + renderUsers( ); + + const [queryKey, fetchUsers, params] = mockUseInfiniteScroll.mock.calls.at( -1 ); + expect( queryKey ).toBe( "exploreV2Observers" ); + expect( fetchUsers ).toBe( fetchObservers ); + expect( params.order_by ).toBe( "observation_count" ); + expect( params.taxon_id ).toBe( 12 ); + expect( params.place_id ).toBe( 1 ); + } ); + } ); + + describe( "identifiers tab", ( ) => { + beforeEach( ( ) => { + mockResults( mockIdentifiers ); + } ); + + it( "lists identifiers with their identification counts", ( ) => { + renderUsers( { tab: IDENTIFIERS_TAB } ); + + expect( screen.getByText( "loarie" ) ).toBeVisible( ); + expect( screen.getByText( "9 Identifications" ) ).toBeVisible( ); + expect( screen.getByText( "tiwane" ) ).toBeVisible( ); + } ); + + it( "queries identifiers, without an observers sort, with the search params", ( ) => { + renderUsers( { tab: IDENTIFIERS_TAB } ); + + const [queryKey, fetchUsers, params] = mockUseInfiniteScroll.mock.calls.at( -1 ); + expect( queryKey ).toBe( "exploreV2Identifiers" ); + expect( fetchUsers ).toBe( fetchIdentifiers ); + expect( params.order_by ).toBeUndefined( ); + expect( params.taxon_id ).toBe( 12 ); + expect( params.place_id ).toBe( 1 ); + } ); + } ); + + it( "passes the disabled flag through to the query", ( ) => { + renderUsers( { enabled: false } ); + + expect( mockUseInfiniteScroll.mock.calls.at( -1 )[3] ).toEqual( { enabled: false } ); + } ); +} ); diff --git a/tests/unit/components/SharedComponents/Tabs/Tabs.test.js b/tests/unit/components/SharedComponents/Tabs/Tabs.test.js index d051ec010..0a8a87884 100644 --- a/tests/unit/components/SharedComponents/Tabs/Tabs.test.js +++ b/tests/unit/components/SharedComponents/Tabs/Tabs.test.js @@ -1,9 +1,12 @@ import { fireEvent, render, screen } from "@testing-library/react-native"; -import { Tabs } from "components/SharedComponents"; +import Tabs, { SCROLLABLE_TAB_WIDTH_RATIO } from "components/SharedComponents/Tabs/Tabs"; import React from "react"; +import { Dimensions, ScrollView } from "react-native"; const TAB_1 = "TAB_1"; const TAB_2 = "TAB_2"; +const TAB_3 = "TAB_3"; +const TAB_4 = "TAB_4"; const tab1Click = jest.fn(); const tab2Click = jest.fn(); @@ -63,3 +66,35 @@ describe( "Tabs", () => { expect( tab2Click ).toHaveBeenCalled(); } ); } ); + +describe( "scrollable Tabs", () => { + const { width } = Dimensions.get( "window" ); + const tabWidth = width * SCROLLABLE_TAB_WIDTH_RATIO; + const scrollableTabs = [TAB_1, TAB_2, TAB_3, TAB_4].map( id => ( { + id, + text: id, + onPress: jest.fn( ), + } ) ); + + beforeEach( () => { + ScrollView.prototype.scrollTo.mockClear( ); + } ); + + it( "should keep the strip at the start when the first tab is active", () => { + render( ); + + expect( ScrollView.prototype.scrollTo ).toHaveBeenCalledWith( + { x: 0, animated: false }, + ); + } ); + + it( "should scroll the newly active tab into view when the user changes tabs", () => { + render( ); + ScrollView.prototype.scrollTo.mockClear( ); + screen.update( ); + + expect( ScrollView.prototype.scrollTo ).toHaveBeenCalledWith( + { x: 2.5 * tabWidth - width / 2, animated: true }, + ); + } ); +} );