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 },
+ );
+ } );
+} );