From 2ed7bb0c3d7acd2ef4a587961936aa9a018946a9 Mon Sep 17 00:00:00 2001 From: tommasini <46944231+tommasini@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:36:25 +0000 Subject: [PATCH 01/11] chore: increase js bundle to 53 (#27135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Increase JS bundle 1 MB ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Low risk: workflow-only change that just relaxes the CI bundle-size gate by 1 unit and doesn’t affect runtime code. > > **Overview** > **CI bundle-size gating has been relaxed slightly.** The `js-bundle-size-check` step in `.github/workflows/ci.yml` now allows an iOS `main.jsbundle` size threshold of `53` instead of `52` when running `./scripts/js-bundle-stats.sh`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 3c0f0a4e861f1666572af8610c74ccc441c68421. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43478944fd2..5031bc205a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -183,7 +183,7 @@ jobs: NODE_OPTIONS: --max_old_space_size=12288 - name: Check bundle size - run: ./scripts/js-bundle-stats.sh ios/main.jsbundle 52 + run: ./scripts/js-bundle-stats.sh ios/main.jsbundle 53 - name: Upload iOS bundle uses: actions/upload-artifact@v4 From b7aab19201a6de6233773cad31413aac887683f1 Mon Sep 17 00:00:00 2001 From: Ramon AC <36987446+racitores@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:40:18 +0100 Subject: [PATCH 02/11] test: add component view tests for Predict feature (PredictFeed + PredictMarketDetails) (#27012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary First component view tests for the Predict feature area (MMQA-1529). Tests use Engine spies and real user interactions — no mocked hooks or selectors — following the integration-test doctrine: each test models a complete user journey, not an isolated unit behavior. ### Infrastructure added | File | Purpose | |---|---| | `tests/component-view/presets/predict.ts` | State preset with `predictTradingEnabled` remote feature flag, `PredictController` state, `PreferencesController.privacyMode`, and `TransactionController` | | `tests/component-view/renderers/predict.tsx` | `renderPredictFeedView` and `renderPredictFeedViewWithRoutes`, wrapped with `QueryClientProvider` (required by `PredictBalance` which uses `@tanstack/react-query`) | | `tests/component-view/renderers/predictMarketDetails.tsx` | `renderPredictMarketDetailsView` and `renderPredictMarketDetailsViewWithRoutes` with `initialParams` support for route params | | `tests/component-view/fixtures/predict.ts` | Shared `MOCK_PREDICT_MARKET` fixture used across both test files | | `app/components/UI/Predict/Predict.testIds.ts` | Added `PredictSearchSelectorsIDs` (`SEARCH_BUTTON`, `CLEAR_BUTTON`, `ERROR_STATE`) and `getPredictSearchSelector.resultCard(index)` helper; all raw strings replaced with constants | | `tests/component-view/mocks.ts` | Updated to support Predict engine context | ### PredictFeed tests (12) - Search overlay opens when the user presses the search icon - `getMarkets` called with the debounced typed query - Search overlay closes when the user presses Cancel - Clear button hides after user clears the input - No-results message includes the typed query - **Data completeness**: result card shows title + Yes/No tokens after `getMarkets` resolves - Tapping a result card navigates to market details - Back button navigates to wallet - Balance card renders and `getBalance` is called on mount - Add Funds triggers `trackGeoBlockTriggered` with `attemptedAction: deposit` - Error state shown when all `getMarkets` retries fail - Retry press calls `getMarkets` again after an error ### PredictMarketDetails tests (5) - `getMarket` called with `marketId` from route params on mount - **Data completeness**: title + Yes/No bet buttons visible after `getMarket` resolves - Pressing a bet button triggers `trackGeoBlockTriggered` while ineligible - `trackMarketDetailsOpened` called after market and positions load - Back button navigates to Predict root ### Key implementation constraints - The main feed (`PagerView` + `FlashList`) never renders in the test environment — it is gated by `{layoutReady && }` and `layoutReady` stays false without native layout events. Tests focus on the search overlay which does render. - Market card navigation targets `Routes.PREDICT.ROOT` (nested navigator), not `MARKET_DETAILS` directly. - `PredictBalance` requires `QueryClientProvider`; renderer wraps with `{ retry: false }` to surface errors immediately. ## Test plan ```bash yarn jest -c jest.config.view.js PredictFeed.view.test PredictMarketDetails.view.test --runInBand --silent --coverage=false ``` - [x] All 17 tests pass - [x] No ESLint errors (`yarn eslint app/components/UI/Predict/views/**/*.view.test.tsx`) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- > [!NOTE] > **Low Risk** > Low risk: changes are primarily test-only infrastructure plus refactors of `testID` strings in Predict UI components/tests. Main risk is breaking existing E2E/unit tests that rely on previous hard-coded selector strings. > > **Overview** > **Adds component view tests for Predict.** Introduces new Predict component-view test suites for `PredictFeed` and `PredictMarketDetails` that validate real user flows (search, navigation, balance loading, error/retry) via `Engine.context.PredictController` spies. > > **Builds supporting test infrastructure and normalizes selectors.** Adds Predict-specific component-view renderers, Redux state preset, and a shared `MOCK_PREDICT_MARKET` fixture; extends component-view `Engine` mocks with a stubbed `PredictController`. Updates `PredictFeed` and multiple unit tests to replace hard-coded `testID` strings with new constants/helpers in `Predict.testIds.ts` (feed/search/market-details selectors, skeleton/empty-state IDs). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8ba8bca58d99e605dc30ff31b68ac7b2e071a820. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Claude Sonnet 4.6 --- app/components/UI/Predict/Predict.testIds.ts | 66 +++- .../views/PredictFeed/PredictFeed.test.tsx | 267 +++++++++++----- .../Predict/views/PredictFeed/PredictFeed.tsx | 61 ++-- .../PredictFeed/PredictFeed.view.test.tsx | 300 ++++++++++++++++++ .../PredictMarketDetails.test.tsx | 249 ++++++++++----- .../PredictMarketDetails.view.test.tsx | 144 +++++++++ tests/component-view/fixtures/predict.ts | 36 +++ tests/component-view/mocks.ts | 12 + tests/component-view/presets/predict.ts | 51 +++ tests/component-view/renderers/predict.tsx | 85 +++++ .../renderers/predictMarketDetails.tsx | 84 +++++ 11 files changed, 1190 insertions(+), 165 deletions(-) create mode 100644 app/components/UI/Predict/views/PredictFeed/PredictFeed.view.test.tsx create mode 100644 app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.view.test.tsx create mode 100644 tests/component-view/fixtures/predict.ts create mode 100644 tests/component-view/presets/predict.ts create mode 100644 tests/component-view/renderers/predict.tsx create mode 100644 tests/component-view/renderers/predictMarketDetails.tsx diff --git a/app/components/UI/Predict/Predict.testIds.ts b/app/components/UI/Predict/Predict.testIds.ts index 8fbba72d606..6d66ce1b27d 100644 --- a/app/components/UI/Predict/Predict.testIds.ts +++ b/app/components/UI/Predict/Predict.testIds.ts @@ -42,6 +42,40 @@ export const getPredictMarketListSelector = { emptyState: () => 'predict-market-list-empty-state', }; +// ======================================== +// PREDICT FEED SELECTORS +// ======================================== + +export const PredictFeedSelectorsIDs = { + TABS: 'predict-feed-tabs', + PAGER: 'predict-feed-pager', + SEARCH_ICON: 'search-icon', +} as const; + +export const getPredictFeedSelector = { + tabPage: (key: string) => `predict-feed-tab-page-${key}`, + emptyState: (category: string) => `predict-empty-state-${category}`, + skeletonLoading: (category: string, index: number) => + `skeleton-loading-${category}-${index}`, + skeletonFooter: (category: string, index: number) => + `skeleton-footer-${category}-${index}`, + searchSkeleton: (index: number) => `search-skeleton-${index}`, + marketList: (category: string) => `predict-market-list-${category}`, +}; + +// PredictFeed unit test mock selectors (used by PredictFeed.test.tsx mocks) +export const PredictFeedMockSelectorsIDs = { + PAGER_VIEW: 'pager-view-mock', + BALANCE_MOCK: 'predict-balance-mock', + OFFLINE_MOCK: 'predict-offline-mock', +} as const; + +export const getPredictFeedMockSelector = { + tabKey: (key: string) => `tab-${key}`, + activeTab: (index: number) => `active-tab-${index}`, + pagerPage: (index: number) => `pager-page-${index}`, +}; + // ======================================== // PREDICT MARKET DETAILS SELECTORS // ======================================== @@ -61,7 +95,7 @@ export const PredictMarketDetailsSelectorsIDs = { POSITIONS_TAB: 'predict-market-details-positions-tab', OUTCOMES_TAB: 'predict-market-details-outcomes-tab', - //Tab labels + // Tab labels POSITIONS_TAB_LABEL: 'predict-market-details-tab-bar-tab-0', OUTCOMES_TAB_LABEL: 'predict-market-details-tab-bar-tab-1', ABOUT_TAB_LABEL: 'predict-market-details-tab-bar-tab-2', @@ -72,6 +106,22 @@ export const PredictMarketDetailsSelectorsIDs = { OUTCOMES_TAB_CONTENT: 'outcomes-tab-content', MARKET_DETAILS_CASH_OUT_BUTTON: 'predict-market-details-cash-out-button', CLAIM_WINNINGS_BUTTON: 'predict-market-details-claim-winnings-button', + + // Chart and content (used by component and tests) + DETAILS_CHART: 'predict-details-chart', + GAME_DETAILS_CONTENT: 'predict-game-details-content', + + // Skeleton loaders + DETAILS_HEADER_SKELETON_BACK_BUTTON: + 'predict-details-header-skeleton-back-button', + DETAILS_CONTENT_SKELETON_LINE_1: 'predict-details-content-skeleton-line-1', + DETAILS_BUTTONS_SKELETON_BUTTON_1: + 'predict-details-buttons-skeleton-button-1', +} as const; + +export const getPredictMarketDetailsSelector = { + tabBarTab: (index: number) => `predict-market-details-tab-bar-tab-${index}`, + icon: (name: string) => `icon-${name}`, } as const; export const PredictMarketDetailsSelectorsText = { @@ -170,6 +220,20 @@ export const PredictActivityDetailsSelectorsIDs = { AMOUNT_DISPLAY: 'predict-activity-details-amount', } as const; +// ======================================== +// PREDICT SEARCH SELECTORS +// ======================================== + +export const PredictSearchSelectorsIDs = { + SEARCH_BUTTON: 'predict-search-button', + CLEAR_BUTTON: 'predict-clear-button', + ERROR_STATE: 'predict-error-state', +} as const; + +export const getPredictSearchSelector = { + resultCard: (index: number) => `predict-search-result-${index}`, +}; + // ======================================== // PREDICT BALANCE SELECTORS // ======================================== diff --git a/app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx b/app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx index ed8a48f829e..da0f3b9d170 100644 --- a/app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx +++ b/app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx @@ -1,20 +1,33 @@ import { fireEvent, render } from '@testing-library/react-native'; import React from 'react'; -import { PredictMarketListSelectorsIDs } from '../../Predict.testIds'; +import { + PredictMarketListSelectorsIDs, + PredictSearchSelectorsIDs, + PredictFeedSelectorsIDs, + PredictFeedMockSelectorsIDs, + getPredictMarketListSelector, + getPredictSearchSelector, + getPredictFeedSelector, + getPredictFeedMockSelector, +} from '../../Predict.testIds'; import PredictFeed from './PredictFeed'; jest.mock('react-native-pager-view', () => { const MockReact = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); + // Jest mock factory runs before module imports; require() needed for testIds + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires + const PredictTestIds = require('../../Predict.testIds'); return { __esModule: true, default: jest.fn(({ children, onPageSelected }) => ( - + {MockReact.Children.map( children, (child: React.ReactElement, index: number) => MockReact.cloneElement(child, { - testID: `pager-page-${index}`, + testID: + PredictTestIds.getPredictFeedMockSelector.pagerPage(index), onTouchEnd: () => onPageSelected?.({ nativeEvent: { position: index } }), }), @@ -26,9 +39,11 @@ jest.mock('react-native-pager-view', () => { jest.mock('../../components/PredictBalance', () => { const { View, Text } = jest.requireActual('react-native'); + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires + const PredictTestIds = require('../../Predict.testIds'); return { PredictBalance: jest.fn(() => ( - + Balance Component )), @@ -95,9 +110,13 @@ jest.mock('../../components/PredictMarketSkeleton', () => { jest.mock('../../components/PredictOffline', () => { const { View } = jest.requireActual('react-native'); + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires + const PredictTestIds = require('../../Predict.testIds'); return { __esModule: true, - default: jest.fn(() => ), + default: jest.fn(() => ( + + )), }; }); @@ -201,19 +220,25 @@ jest.mock('../../hooks/usePredictMeasurement', () => ({ jest.mock('../../../../../component-library/components-temp/Tabs', () => { const { View, Pressable, Text } = jest.requireActual('react-native'); + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires + const PredictTestIds = require('../../Predict.testIds'); return { TabsBar: jest.fn(({ tabs, activeIndex, onTabPress, testID }) => ( {tabs.map((tab: { key: string; label: string }, index: number) => ( onTabPress(index)} > {tab.label} ))} - + )), TabItem: {}, @@ -276,16 +301,22 @@ describe('PredictFeed', () => { expect( getByTestId(PredictMarketListSelectorsIDs.BACK_BUTTON), ).toBeOnTheScreen(); - expect(getByTestId('predict-search-button')).toBeOnTheScreen(); - expect(getByTestId('predict-balance-mock')).toBeOnTheScreen(); - expect(getByTestId('predict-feed-tabs')).toBeOnTheScreen(); - expect(getByTestId('pager-view-mock')).toBeOnTheScreen(); + expect( + getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON), + ).toBeOnTheScreen(); + expect( + getByTestId(PredictFeedMockSelectorsIDs.BALANCE_MOCK), + ).toBeOnTheScreen(); + expect(getByTestId(PredictFeedSelectorsIDs.TABS)).toBeOnTheScreen(); + expect( + getByTestId(PredictFeedMockSelectorsIDs.PAGER_VIEW), + ).toBeOnTheScreen(); }); it('hides search overlay on initial render', () => { const { queryByTestId } = render(); - expect(queryByTestId('search-icon')).toBeNull(); + expect(queryByTestId(PredictFeedSelectorsIDs.SEARCH_ICON)).toBeNull(); }); }); @@ -293,18 +324,20 @@ describe('PredictFeed', () => { it('opens search overlay when search button pressed', () => { const { getByTestId } = render(); - fireEvent.press(getByTestId('predict-search-button')); + fireEvent.press(getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON)); - expect(getByTestId('search-icon')).toBeOnTheScreen(); + expect( + getByTestId(PredictFeedSelectorsIDs.SEARCH_ICON), + ).toBeOnTheScreen(); }); it('closes search overlay when cancel button pressed', () => { const { getByTestId, getByText, queryByTestId } = render(); - fireEvent.press(getByTestId('predict-search-button')); + fireEvent.press(getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON)); fireEvent.press(getByText('Cancel')); - expect(queryByTestId('search-icon')).toBeNull(); + expect(queryByTestId(PredictFeedSelectorsIDs.SEARCH_ICON)).toBeNull(); }); }); @@ -312,18 +345,30 @@ describe('PredictFeed', () => { it('renders all six category tabs', () => { const { getByTestId } = render(); - expect(getByTestId('tab-trending')).toBeOnTheScreen(); - expect(getByTestId('tab-ending-soon')).toBeOnTheScreen(); - expect(getByTestId('tab-new')).toBeOnTheScreen(); - expect(getByTestId('tab-sports')).toBeOnTheScreen(); - expect(getByTestId('tab-crypto')).toBeOnTheScreen(); - expect(getByTestId('tab-politics')).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedMockSelector.tabKey('trending')), + ).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedMockSelector.tabKey('ending-soon')), + ).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedMockSelector.tabKey('new')), + ).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedMockSelector.tabKey('sports')), + ).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedMockSelector.tabKey('crypto')), + ).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedMockSelector.tabKey('politics')), + ).toBeOnTheScreen(); }); it('does not track analytics when tab pressed', () => { const { getByTestId } = render(); - fireEvent.press(getByTestId('tab-sports')); + fireEvent.press(getByTestId(getPredictFeedMockSelector.tabKey('sports'))); expect(mockSessionManager.trackTabChange).not.toHaveBeenCalled(); }); @@ -394,8 +439,12 @@ describe('PredictFeed', () => { const { getByTestId } = render(); - expect(getByTestId('skeleton-loading-trending-1')).toBeOnTheScreen(); - expect(getByTestId('skeleton-loading-trending-2')).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedSelector.skeletonLoading('trending', 1)), + ).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedSelector.skeletonLoading('trending', 2)), + ).toBeOnTheScreen(); }); }); @@ -413,7 +462,9 @@ describe('PredictFeed', () => { const { getByTestId } = render(); - expect(getByTestId('predict-offline-mock')).toBeOnTheScreen(); + expect( + getByTestId(PredictFeedMockSelectorsIDs.OFFLINE_MOCK), + ).toBeOnTheScreen(); }); }); @@ -431,7 +482,9 @@ describe('PredictFeed', () => { const { getByTestId } = render(); - expect(getByTestId('predict-empty-state-trending')).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedSelector.emptyState('trending')), + ).toBeOnTheScreen(); }); }); @@ -439,12 +492,16 @@ describe('PredictFeed', () => { it('displays search results when query is entered', () => { const { getByTestId, getByPlaceholderText } = render(); - fireEvent.press(getByTestId('predict-search-button')); + fireEvent.press(getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON)); const searchInput = getByPlaceholderText('Search prediction markets'); fireEvent.changeText(searchInput, 'bitcoin'); - expect(getByTestId('predict-search-result-0')).toBeOnTheScreen(); - expect(getByTestId('predict-search-result-1')).toBeOnTheScreen(); + expect( + getByTestId(getPredictSearchSelector.resultCard(0)), + ).toBeOnTheScreen(); + expect( + getByTestId(getPredictSearchSelector.resultCard(1)), + ).toBeOnTheScreen(); }); it('displays skeleton loaders while search is fetching', () => { @@ -460,11 +517,13 @@ describe('PredictFeed', () => { const { getByTestId, getByPlaceholderText } = render(); - fireEvent.press(getByTestId('predict-search-button')); + fireEvent.press(getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON)); const searchInput = getByPlaceholderText('Search prediction markets'); fireEvent.changeText(searchInput, 'bitcoin'); - expect(getByTestId('search-skeleton-1')).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedSelector.searchSkeleton(1)), + ).toBeOnTheScreen(); }); it('clears search query when clear button is pressed', () => { @@ -472,16 +531,20 @@ describe('PredictFeed', () => { , ); - fireEvent.press(getByTestId('predict-search-button')); + fireEvent.press(getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON)); const searchInput = getByPlaceholderText('Search prediction markets'); fireEvent.changeText(searchInput, 'test query'); - fireEvent.press(getByTestId('clear-button')); + fireEvent.press(getByTestId(PredictSearchSelectorsIDs.CLEAR_BUTTON)); // After clearing search, the clear button should no longer be visible // (only shows when searchQuery.length > 0) - expect(queryByTestId('clear-button')).not.toBeOnTheScreen(); + expect( + queryByTestId(PredictSearchSelectorsIDs.CLEAR_BUTTON), + ).not.toBeOnTheScreen(); // Trending results visible when no search query is empty - expect(getByTestId('predict-search-result-0')).toBeOnTheScreen(); + expect( + getByTestId(getPredictSearchSelector.resultCard(0)), + ).toBeOnTheScreen(); }); }); @@ -501,7 +564,7 @@ describe('PredictFeed', () => { }); const { getByTestId } = render(); - const page1 = getByTestId('pager-page-1'); + const page1 = getByTestId(getPredictFeedMockSelector.pagerPage(1)); fireEvent(page1, 'onTouchEnd'); @@ -528,7 +591,7 @@ describe('PredictFeed', () => { const { queryByTestId } = render(); - expect(queryByTestId('pager-view-mock')).toBeNull(); + expect(queryByTestId(PredictFeedMockSelectorsIDs.PAGER_VIEW)).toBeNull(); }); }); @@ -537,10 +600,14 @@ describe('PredictFeed', () => { const { getByTestId } = render(); expect( - getByTestId('predict-market-list-trending-card-1'), + getByTestId( + getPredictMarketListSelector.marketCardByCategory('trending', 1), + ), ).toBeOnTheScreen(); expect( - getByTestId('predict-market-list-trending-card-2'), + getByTestId( + getPredictMarketListSelector.marketCardByCategory('trending', 2), + ), ).toBeOnTheScreen(); }); }); @@ -561,7 +628,7 @@ describe('PredictFeed', () => { , ); - fireEvent.press(getByTestId('predict-search-button')); + fireEvent.press(getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON)); const searchInput = getByPlaceholderText('Search prediction markets'); fireEvent.changeText(searchInput, 'nonexistent'); @@ -583,11 +650,13 @@ describe('PredictFeed', () => { , ); - fireEvent.press(getByTestId('predict-search-button')); + fireEvent.press(getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON)); const searchInput = getByPlaceholderText('Search prediction markets'); fireEvent.changeText(searchInput, 'test'); - const offlineElements = getAllByTestId('predict-offline-mock'); + const offlineElements = getAllByTestId( + PredictFeedMockSelectorsIDs.OFFLINE_MOCK, + ); expect(offlineElements.length).toBeGreaterThan(0); }); }); @@ -609,8 +678,12 @@ describe('PredictFeed', () => { const { getByTestId } = render(); - expect(getByTestId('skeleton-footer-trending-1')).toBeOnTheScreen(); - expect(getByTestId('skeleton-footer-trending-2')).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedSelector.skeletonFooter('trending', 1)), + ).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedSelector.skeletonFooter('trending', 2)), + ).toBeOnTheScreen(); }); }); @@ -649,7 +722,7 @@ describe('PredictFeed', () => { mockUseDebouncedValue.mockReturnValue('debounced-query'); const { getByTestId, getByPlaceholderText } = render(); - fireEvent.press(getByTestId('predict-search-button')); + fireEvent.press(getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON)); const searchInput = getByPlaceholderText('Search prediction markets'); fireEvent.changeText(searchInput, 'bitcoin'); @@ -672,11 +745,13 @@ describe('PredictFeed', () => { }); const { getByTestId, getByPlaceholderText } = render(); - fireEvent.press(getByTestId('predict-search-button')); + fireEvent.press(getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON)); const searchInput = getByPlaceholderText('Search prediction markets'); fireEvent.changeText(searchInput, 'bitcoin'); - expect(getByTestId('search-skeleton-1')).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedSelector.searchSkeleton(1)), + ).toBeOnTheScreen(); }); it('displays search results after debounce completes', () => { @@ -695,18 +770,22 @@ describe('PredictFeed', () => { }); const { getByTestId, getByPlaceholderText } = render(); - fireEvent.press(getByTestId('predict-search-button')); + fireEvent.press(getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON)); const searchInput = getByPlaceholderText('Search prediction markets'); fireEvent.changeText(searchInput, 'bitcoin'); - expect(getByTestId('predict-search-result-0')).toBeOnTheScreen(); - expect(getByTestId('predict-search-result-1')).toBeOnTheScreen(); + expect( + getByTestId(getPredictSearchSelector.resultCard(0)), + ).toBeOnTheScreen(); + expect( + getByTestId(getPredictSearchSelector.resultCard(1)), + ).toBeOnTheScreen(); }); it('invokes useDebouncedValue with 200ms delay', () => { const { getByTestId, getByPlaceholderText } = render(); - fireEvent.press(getByTestId('predict-search-button')); + fireEvent.press(getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON)); const searchInput = getByPlaceholderText('Search prediction markets'); fireEvent.changeText(searchInput, 'test'); @@ -723,8 +802,12 @@ describe('PredictFeed', () => { const { getByTestId } = render(); - expect(getByTestId('tab-hot')).toBeOnTheScreen(); - expect(getByTestId('tab-trending')).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedMockSelector.tabKey('hot')), + ).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedMockSelector.tabKey('trending')), + ).toBeOnTheScreen(); }); it('does not render Hot tab when flag is disabled', () => { @@ -735,8 +818,12 @@ describe('PredictFeed', () => { const { queryByTestId, getByTestId } = render(); - expect(queryByTestId('tab-hot')).toBeNull(); - expect(getByTestId('tab-trending')).toBeOnTheScreen(); + expect( + queryByTestId(getPredictFeedMockSelector.tabKey('hot')), + ).toBeNull(); + expect( + getByTestId(getPredictFeedMockSelector.tabKey('trending')), + ).toBeOnTheScreen(); }); it('renders seven category tabs when hot tab is enabled', () => { @@ -747,13 +834,27 @@ describe('PredictFeed', () => { const { getByTestId } = render(); - expect(getByTestId('tab-hot')).toBeOnTheScreen(); - expect(getByTestId('tab-trending')).toBeOnTheScreen(); - expect(getByTestId('tab-ending-soon')).toBeOnTheScreen(); - expect(getByTestId('tab-new')).toBeOnTheScreen(); - expect(getByTestId('tab-sports')).toBeOnTheScreen(); - expect(getByTestId('tab-crypto')).toBeOnTheScreen(); - expect(getByTestId('tab-politics')).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedMockSelector.tabKey('hot')), + ).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedMockSelector.tabKey('trending')), + ).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedMockSelector.tabKey('ending-soon')), + ).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedMockSelector.tabKey('new')), + ).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedMockSelector.tabKey('sports')), + ).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedMockSelector.tabKey('crypto')), + ).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedMockSelector.tabKey('politics')), + ).toBeOnTheScreen(); }); it('renders seven pager pages when hot tab is enabled', () => { @@ -764,13 +865,27 @@ describe('PredictFeed', () => { const { getByTestId } = render(); - expect(getByTestId('pager-page-0')).toBeOnTheScreen(); - expect(getByTestId('pager-page-1')).toBeOnTheScreen(); - expect(getByTestId('pager-page-2')).toBeOnTheScreen(); - expect(getByTestId('pager-page-3')).toBeOnTheScreen(); - expect(getByTestId('pager-page-4')).toBeOnTheScreen(); - expect(getByTestId('pager-page-5')).toBeOnTheScreen(); - expect(getByTestId('pager-page-6')).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedMockSelector.pagerPage(0)), + ).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedMockSelector.pagerPage(1)), + ).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedMockSelector.pagerPage(2)), + ).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedMockSelector.pagerPage(3)), + ).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedMockSelector.pagerPage(4)), + ).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedMockSelector.pagerPage(5)), + ).toBeOnTheScreen(); + expect( + getByTestId(getPredictFeedMockSelector.pagerPage(6)), + ).toBeOnTheScreen(); }); it('tracks tab change for hot tab when swiped to', () => { @@ -793,7 +908,7 @@ describe('PredictFeed', () => { }); const { getByTestId } = render(); - const hotTabPage = getByTestId('pager-page-0'); + const hotTabPage = getByTestId(getPredictFeedMockSelector.pagerPage(0)); fireEvent(hotTabPage, 'onTouchEnd'); @@ -835,7 +950,9 @@ describe('PredictFeed', () => { const { getByTestId } = render(); - expect(getByTestId('search-icon')).toBeOnTheScreen(); + expect( + getByTestId(PredictFeedSelectorsIDs.SEARCH_ICON), + ).toBeOnTheScreen(); }, ); @@ -866,10 +983,12 @@ describe('PredictFeed', () => { const { getByText, getByTestId, queryByTestId } = render(); - expect(getByTestId('search-icon')).toBeOnTheScreen(); + expect( + getByTestId(PredictFeedSelectorsIDs.SEARCH_ICON), + ).toBeOnTheScreen(); fireEvent.press(getByText('Cancel')); - expect(queryByTestId('search-icon')).toBeNull(); + expect(queryByTestId(PredictFeedSelectorsIDs.SEARCH_ICON)).toBeNull(); }); }); }); diff --git a/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx b/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx index 6a3db326fc7..51615f6a308 100644 --- a/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx +++ b/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx @@ -47,7 +47,11 @@ import { } from '@react-navigation/native'; import { PredictMarketListSelectorsIDs, + PredictSearchSelectorsIDs, + PredictFeedSelectorsIDs, getPredictMarketListSelector, + getPredictFeedSelector, + getPredictSearchSelector, } from '../../Predict.testIds'; import { usePredictMarketData } from '../../hooks/usePredictMarketData'; import { useDebouncedValue } from '../../../../hooks/useDebouncedValue'; @@ -120,7 +124,7 @@ const PredictFeedTabBar: React.FC = ({ tabs={tabItems} activeIndex={activeIndex} onTabPress={onTabPress} - testID="predict-feed-tabs" + testID={PredictFeedSelectorsIDs.TABS} /> ); }; @@ -294,8 +298,12 @@ const PredictTabContent: React.FC = ({ if (!isFetchingMore) return null; return ( - - + + ); }, [isFetchingMore, category]); @@ -318,10 +326,18 @@ const PredictTabContent: React.FC = ({ if (!hasEverBeenActive || (isFetching && !isRefreshing && !isFetchingMore)) { return ( - - - - + + + + ); } @@ -337,7 +353,7 @@ const PredictTabContent: React.FC = ({ if (!marketData || marketData.length === 0) { return ( @@ -351,7 +367,7 @@ const PredictTabContent: React.FC = ({ return ( = ({ style={tw.style('flex-1')} initialPage={initialPage} onPageSelected={handlePageSelected} - testID="predict-feed-pager" + testID={PredictFeedSelectorsIDs.PAGER} > {tabs.map((tab, index) => ( = ({ ), [], @@ -513,7 +529,7 @@ const PredictSearchOverlay: React.FC = ({ twClassName="flex-1 bg-muted rounded-lg px-3 py-2" > = ({ autoFocus /> {searchQuery.length > 0 && ( - onSearchChange('')}> + onSearchChange('')} + > = ({ {isSearchLoading ? ( - - - + + + ) : error ? ( @@ -705,7 +730,7 @@ const PredictFeed: React.FC = () => { { iconName: IconName.Search, onPress: showSearch, - testID: 'predict-search-button', + testID: PredictSearchSelectorsIDs.SEARCH_BUTTON, }, ]} /> diff --git a/app/components/UI/Predict/views/PredictFeed/PredictFeed.view.test.tsx b/app/components/UI/Predict/views/PredictFeed/PredictFeed.view.test.tsx new file mode 100644 index 00000000000..3766759ad3a --- /dev/null +++ b/app/components/UI/Predict/views/PredictFeed/PredictFeed.view.test.tsx @@ -0,0 +1,300 @@ +/** + * Component view tests for PredictFeed. + * + * These are the first component view tests for the Predict area. + * They test user-oriented behaviour via Engine spies and real interactions — + * not static render checks. + * + * Run with: yarn jest -c jest.config.view.js PredictFeed.view.test --runInBand --silent --coverage=false + */ +import '../../../../../../tests/component-view/mocks'; +import Engine from '../../../../../../app/core/Engine'; +import { + renderPredictFeedView, + renderPredictFeedViewWithRoutes, +} from '../../../../../../tests/component-view/renderers/predict'; +import { fireEvent, waitFor, within } from '@testing-library/react-native'; +import { + PredictMarketListSelectorsIDs, + PredictSearchSelectorsIDs, + PredictBalanceSelectorsIDs, + getPredictSearchSelector, +} from '../../Predict.testIds'; +import Routes from '../../../../../constants/navigation/Routes'; +import { MOCK_PREDICT_MARKET } from '../../../../../../tests/component-view/fixtures/predict'; + +const SEARCH_PLACEHOLDER = 'Search prediction markets'; +const CANCEL_TEXT = 'Cancel'; + +describe('PredictFeed', () => { + describe('search interaction', () => { + it('opens the search overlay when the user presses the search icon', async () => { + const { getByTestId, findByPlaceholderText } = renderPredictFeedView(); + + fireEvent.press(getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON)); + + expect(await findByPlaceholderText(SEARCH_PLACEHOLDER)).toBeOnTheScreen(); + }); + + it('calls PredictController.getMarkets with the typed query after the user searches', async () => { + const getMarketsSpy = jest.spyOn( + Engine.context.PredictController, + 'getMarkets', + ); + + const { getByTestId, findByPlaceholderText } = renderPredictFeedView(); + + fireEvent.press(getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON)); + + const searchInput = await findByPlaceholderText(SEARCH_PLACEHOLDER); + fireEvent.changeText(searchInput, 'bitcoin'); + + await waitFor( + () => { + expect(getMarketsSpy).toHaveBeenCalledWith( + expect.objectContaining({ q: 'bitcoin' }), + ); + }, + { timeout: 2000 }, + ); + + getMarketsSpy.mockRestore(); + }); + + it('closes the search overlay when the user presses Cancel', async () => { + const { + getByTestId, + findByText, + findByPlaceholderText, + queryByPlaceholderText, + } = renderPredictFeedView(); + + fireEvent.press(getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON)); + await findByPlaceholderText(SEARCH_PLACEHOLDER); + + fireEvent.press(await findByText(CANCEL_TEXT)); + + await waitFor(() => { + expect( + queryByPlaceholderText(SEARCH_PLACEHOLDER), + ).not.toBeOnTheScreen(); + }); + }); + + it('hides the clear button after the user clears the typed query', async () => { + const { getByTestId, findByPlaceholderText, queryByTestId } = + renderPredictFeedView(); + + fireEvent.press(getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON)); + + const searchInput = await findByPlaceholderText(SEARCH_PLACEHOLDER); + fireEvent.changeText(searchInput, 'ethereum'); + + await waitFor(() => { + expect( + getByTestId(PredictSearchSelectorsIDs.CLEAR_BUTTON), + ).toBeOnTheScreen(); + }); + + fireEvent.press(getByTestId(PredictSearchSelectorsIDs.CLEAR_BUTTON)); + + await waitFor(() => { + expect( + queryByTestId(PredictSearchSelectorsIDs.CLEAR_BUTTON), + ).not.toBeOnTheScreen(); + }); + }); + + it('shows a "no results" message that includes the typed query when getMarkets returns empty', async () => { + const { getByTestId, findByPlaceholderText, findByText } = + renderPredictFeedView(); + + fireEvent.press(getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON)); + + const searchInput = await findByPlaceholderText(SEARCH_PLACEHOLDER); + fireEvent.changeText(searchInput, 'xyznotfound'); + + expect( + await findByText('No results found for "xyznotfound"'), + ).toBeOnTheScreen(); + }); + + it('shows complete market data in the search result card after getMarkets resolves', async () => { + // Arrange + const getMarketsSpy = jest.spyOn( + Engine.context.PredictController, + 'getMarkets', + ); + getMarketsSpy.mockResolvedValue([MOCK_PREDICT_MARKET]); + const { getByTestId, findByPlaceholderText, findByTestId } = + renderPredictFeedView(); + + // Act — user opens search and types a query + fireEvent.press(getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON)); + const searchInput = await findByPlaceholderText(SEARCH_PLACEHOLDER); + fireEvent.changeText(searchInput, 'bitcoin'); + + // Assert — result card contains all significant market fields + const resultCard = await findByTestId( + getPredictSearchSelector.resultCard(0), + {}, + { timeout: 3000 }, + ); + expect( + within(resultCard).getByText(MOCK_PREDICT_MARKET.title), + ).toBeOnTheScreen(); + expect(within(resultCard).getByText(/Yes/)).toBeOnTheScreen(); + expect(within(resultCard).getByText(/No/)).toBeOnTheScreen(); + + getMarketsSpy.mockRestore(); + }); + + it('navigates to market details when the user taps a search result card', async () => { + const getMarketsSpy = jest.spyOn( + Engine.context.PredictController, + 'getMarkets', + ); + getMarketsSpy.mockResolvedValue([MOCK_PREDICT_MARKET]); + + const { getByTestId, findByPlaceholderText, findByTestId } = + renderPredictFeedViewWithRoutes({ + extraRoutes: [{ name: Routes.PREDICT.ROOT }], + }); + + fireEvent.press(getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON)); + + const searchInput = await findByPlaceholderText(SEARCH_PLACEHOLDER); + fireEvent.changeText(searchInput, 'bitcoin'); + + const resultCard = await findByTestId( + getPredictSearchSelector.resultCard(0), + {}, + { timeout: 3000 }, + ); + fireEvent.press(resultCard); + + expect( + await findByTestId(`route-${Routes.PREDICT.ROOT}`), + ).toBeOnTheScreen(); + + getMarketsSpy.mockRestore(); + }); + }); + + describe('back navigation', () => { + it('navigates to the wallet when the user presses back from the root feed', async () => { + const { getByTestId, findByTestId } = renderPredictFeedViewWithRoutes({ + extraRoutes: [{ name: Routes.WALLET.HOME }], + }); + + await findByTestId(PredictMarketListSelectorsIDs.CONTAINER); + + fireEvent.press(getByTestId(PredictMarketListSelectorsIDs.BACK_BUTTON)); + + expect( + await findByTestId(`route-${Routes.WALLET.HOME}`), + ).toBeOnTheScreen(); + }); + }); + + describe('balance card', () => { + it('calls getBalance and displays the balance card once the balance resolves', async () => { + const getBalanceSpy = jest.spyOn( + Engine.context.PredictController, + 'getBalance', + ); + + const { findByTestId } = renderPredictFeedView(); + + expect( + await findByTestId(PredictBalanceSelectorsIDs.BALANCE_CARD), + ).toBeOnTheScreen(); + expect(getBalanceSpy).toHaveBeenCalled(); + + getBalanceSpy.mockRestore(); + }); + + it('calls trackGeoBlockTriggered when the user presses Add Funds while ineligible', async () => { + const trackGeoBlockSpy = jest.spyOn( + Engine.context.PredictController, + 'trackGeoBlockTriggered', + ); + + const { findByTestId, findByText } = renderPredictFeedView(); + + await findByTestId(PredictBalanceSelectorsIDs.BALANCE_CARD); + fireEvent.press(await findByText('Add funds')); + + await waitFor(() => { + expect(trackGeoBlockSpy).toHaveBeenCalledWith( + expect.objectContaining({ attemptedAction: 'deposit' }), + ); + }); + + trackGeoBlockSpy.mockRestore(); + }); + }); + + describe('search error recovery', () => { + it('shows the offline error state in the search overlay when all market fetch retries fail', async () => { + const getMarketsSpy = jest.spyOn( + Engine.context.PredictController, + 'getMarkets', + ); + getMarketsSpy.mockRejectedValue(new Error('Network error')); + + const { getByTestId, findByPlaceholderText, findByTestId } = + renderPredictFeedView(); + + fireEvent.press(getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON)); + await findByPlaceholderText(SEARCH_PLACEHOLDER); + + // The hook retries up to 3 times with exponential backoff (~3-5 s total). + // findByTestId waits until the error state appears after all retries exhaust. + expect( + await findByTestId( + PredictSearchSelectorsIDs.ERROR_STATE, + {}, + { timeout: 10000 }, + ), + ).toBeOnTheScreen(); + + getMarketsSpy.mockRestore(); + }); + + it('calls getMarkets again when the user presses Retry after an error', async () => { + const getMarketsSpy = jest.spyOn( + Engine.context.PredictController, + 'getMarkets', + ); + getMarketsSpy.mockRejectedValue(new Error('Network error')); + + const { getByTestId, findByPlaceholderText, findByTestId, findByText } = + renderPredictFeedView(); + + fireEvent.press(getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON)); + await findByPlaceholderText(SEARCH_PLACEHOLDER); + + await findByTestId( + PredictSearchSelectorsIDs.ERROR_STATE, + {}, + { timeout: 10000 }, + ); + + const callCountBeforeRetry = getMarketsSpy.mock.calls.length; + + // Make subsequent calls succeed so the retry completes quickly. + getMarketsSpy.mockResolvedValue([]); + + fireEvent.press(await findByText('Retry')); + + await waitFor(() => { + expect(getMarketsSpy.mock.calls.length).toBeGreaterThan( + callCountBeforeRetry, + ); + }); + + getMarketsSpy.mockRestore(); + }); + }); +}); diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx index 8c9a3974b61..5549ef80007 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx @@ -9,6 +9,10 @@ import { useRoute, } from '@react-navigation/native'; import PredictMarketDetails from './PredictMarketDetails'; +import { + PredictMarketDetailsSelectorsIDs, + getPredictMarketDetailsSelector, +} from '../../Predict.testIds'; import { PredictPriceHistoryInterval } from '../../types'; import type { UsePredictPriceHistoryOptions } from '../../hooks/usePredictPriceHistory'; import { strings } from '../../../../../../locales/i18n'; @@ -79,6 +83,9 @@ jest.mock('../../../../../component-library/components/Icons/Icon', () => { const ActualIcon = jest.requireActual( '../../../../../component-library/components/Icons/Icon', ); + // Jest mock factory runs before module imports; require() needed for testIds + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires + const PredictTestIds = require('../../Predict.testIds'); return { ...ActualIcon, __esModule: true, @@ -92,7 +99,15 @@ jest.mock('../../../../../component-library/components/Icons/Icon', () => { [key: string]: unknown; }) => { const Icon = ActualIcon.default; - return ; + return ( + + ); }, }; }); @@ -234,9 +249,13 @@ jest.mock('../../hooks/usePredictOrderPreview', () => ({ jest.mock('../../components/PredictDetailsChart/PredictDetailsChart', () => { const { View, Text } = jest.requireActual('react-native'); + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires + const PredictTestIds = require('../../Predict.testIds'); return function MockPredictDetailsChart() { return ( - + Chart Component ); @@ -260,10 +279,12 @@ jest.mock('../../components/PredictMarketOutcome', () => { jest.mock('../../components/PredictShareButton/PredictShareButton', () => { const { View } = jest.requireActual('react-native'); + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires + const PredictTestIds = require('../../Predict.testIds'); return function MockPredictShareButton({ marketId }: { marketId?: string }) { return ( ); @@ -272,13 +293,19 @@ jest.mock('../../components/PredictShareButton/PredictShareButton', () => { jest.mock('../../components/PredictGameDetailsContent', () => { const { View, Text } = jest.requireActual('react-native'); + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires + const PredictTestIds = require('../../Predict.testIds'); return function MockPredictGameDetailsContent({ market, }: { market: { title?: string }; }) { return ( - + {market?.title || 'Game Details'} ); @@ -669,7 +696,7 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(); expect( - screen.getByTestId('predict-market-details-screen'), + screen.getByTestId(PredictMarketDetailsSelectorsIDs.SCREEN), ).toBeOnTheScreen(); }); @@ -688,13 +715,19 @@ describe('PredictMarketDetails', () => { // Check that skeleton loaders appear expect( - screen.getByTestId('predict-details-header-skeleton-back-button'), + screen.getByTestId( + PredictMarketDetailsSelectorsIDs.DETAILS_HEADER_SKELETON_BACK_BUTTON, + ), ).toBeOnTheScreen(); expect( - screen.getByTestId('predict-details-content-skeleton-line-1'), + screen.getByTestId( + PredictMarketDetailsSelectorsIDs.DETAILS_CONTENT_SKELETON_LINE_1, + ), ).toBeOnTheScreen(); expect( - screen.getByTestId('predict-details-buttons-skeleton-button-1'), + screen.getByTestId( + PredictMarketDetailsSelectorsIDs.DETAILS_BUTTONS_SKELETON_BUTTON_1, + ), ).toBeOnTheScreen(); }); @@ -703,26 +736,32 @@ describe('PredictMarketDetails', () => { // Screen renders without a title; other sections may still show loading keys expect( - screen.getByTestId('predict-market-details-screen'), + screen.getByTestId(PredictMarketDetailsSelectorsIDs.SCREEN), ).toBeOnTheScreen(); }); it('renders back button with correct accessibility', () => { setupPredictMarketDetailsTest(); - expect(screen.getByTestId('icon-ArrowLeft')).toBeOnTheScreen(); + expect( + screen.getByTestId(getPredictMarketDetailsSelector.icon('ArrowLeft')), + ).toBeOnTheScreen(); }); it('renders share button in header when market data is loaded', () => { setupPredictMarketDetailsTest(); - expect(screen.getByTestId('predict-share-button')).toBeOnTheScreen(); + expect( + screen.getByTestId(PredictMarketDetailsSelectorsIDs.SHARE_BUTTON), + ).toBeOnTheScreen(); }); it('passes market.id to share button', () => { setupPredictMarketDetailsTest({ id: 'test-market-id' }); - const shareButton = screen.getByTestId('predict-share-button'); + const shareButton = screen.getByTestId( + PredictMarketDetailsSelectorsIDs.SHARE_BUTTON, + ); expect(shareButton.props.accessibilityHint).toBe( 'marketId:test-market-id', @@ -737,10 +776,12 @@ describe('PredictMarketDetails', () => { ); expect( - screen.queryByTestId('predict-share-button'), + screen.queryByTestId(PredictMarketDetailsSelectorsIDs.SHARE_BUTTON), ).not.toBeOnTheScreen(); expect( - screen.getByTestId('predict-details-header-skeleton-back-button'), + screen.getByTestId( + PredictMarketDetailsSelectorsIDs.DETAILS_HEADER_SKELETON_BACK_BUTTON, + ), ).toBeOnTheScreen(); }); }); @@ -750,7 +791,7 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(); const aboutTab = screen.getByTestId( - 'predict-market-details-tab-bar-tab-1', + getPredictMarketDetailsSelector.tabBarTab(1), ); fireEvent.press(aboutTab); @@ -763,7 +804,7 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(); const aboutTab = screen.getByTestId( - 'predict-market-details-tab-bar-tab-1', + getPredictMarketDetailsSelector.tabBarTab(1), ); fireEvent.press(aboutTab); @@ -777,7 +818,7 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(); const aboutTab = screen.getByTestId( - 'predict-market-details-tab-bar-tab-1', + getPredictMarketDetailsSelector.tabBarTab(1), ); fireEvent.press(aboutTab); @@ -791,7 +832,7 @@ describe('PredictMarketDetails', () => { const { mockNavigate } = setupPredictMarketDetailsTest(); const aboutTab = screen.getByTestId( - 'predict-market-details-tab-bar-tab-1', + getPredictMarketDetailsSelector.tabBarTab(1), ); fireEvent.press(aboutTab); @@ -839,13 +880,17 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(singleOutcomeMarket); - expect(screen.getByTestId('predict-details-chart')).toBeOnTheScreen(); + expect( + screen.getByTestId(PredictMarketDetailsSelectorsIDs.DETAILS_CHART), + ).toBeOnTheScreen(); }); it('renders multiple outcome chart for binary markets with two outcomes', () => { setupPredictMarketDetailsTest(); - expect(screen.getByTestId('predict-details-chart')).toBeOnTheScreen(); + expect( + screen.getByTestId(PredictMarketDetailsSelectorsIDs.DETAILS_CHART), + ).toBeOnTheScreen(); }); it('renders multiple outcome chart for multi-outcome markets', () => { @@ -877,7 +922,9 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(multiOutcomeMarket); - expect(screen.getByTestId('predict-details-chart')).toBeOnTheScreen(); + expect( + screen.getByTestId(PredictMarketDetailsSelectorsIDs.DETAILS_CHART), + ).toBeOnTheScreen(); }); it('does not render chart when all outcomes are closed', () => { @@ -910,7 +957,7 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(closedOutcomesMarket); expect( - screen.queryByTestId('predict-details-chart'), + screen.queryByTestId(PredictMarketDetailsSelectorsIDs.DETAILS_CHART), ).not.toBeOnTheScreen(); }); @@ -930,7 +977,7 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(noOpenOutcomesMarket); expect( - screen.queryByTestId('predict-details-chart'), + screen.queryByTestId(PredictMarketDetailsSelectorsIDs.DETAILS_CHART), ).not.toBeOnTheScreen(); }); @@ -956,7 +1003,9 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(mixedStatusMarket); - expect(screen.getByTestId('predict-details-chart')).toBeOnTheScreen(); + expect( + screen.getByTestId(PredictMarketDetailsSelectorsIDs.DETAILS_CHART), + ).toBeOnTheScreen(); }); it('limits chart data to first 3 open outcomes when more are available', () => { @@ -1044,7 +1093,9 @@ describe('PredictMarketDetails', () => { marketIds: ['token-2', 'token-3'], }), ); - expect(screen.getByTestId('predict-details-chart')).toBeOnTheScreen(); + expect( + screen.getByTestId(PredictMarketDetailsSelectorsIDs.DETAILS_CHART), + ).toBeOnTheScreen(); }); it('removes chart when closed market lacks open outcomes', () => { @@ -1067,7 +1118,7 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(emptyOutcomesMarket); expect( - screen.queryByTestId('predict-details-chart'), + screen.queryByTestId(PredictMarketDetailsSelectorsIDs.DETAILS_CHART), ).not.toBeOnTheScreen(); }); @@ -1086,7 +1137,9 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(noTokensMarket); - expect(screen.getByTestId('predict-details-chart')).toBeOnTheScreen(); + expect( + screen.getByTestId(PredictMarketDetailsSelectorsIDs.DETAILS_CHART), + ).toBeOnTheScreen(); }); }); @@ -1095,10 +1148,12 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(); expect( - screen.getByTestId('predict-market-details-tab-bar'), + screen.getByTestId(PredictMarketDetailsSelectorsIDs.TAB_BAR), ).toBeOnTheScreen(); expect( - screen.getByTestId('predict-market-details-scrollable-tab-view'), + screen.getByTestId( + PredictMarketDetailsSelectorsIDs.SCROLLABLE_TAB_VIEW, + ), ).toBeOnTheScreen(); }); @@ -1106,7 +1161,7 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(); const aboutTab = screen.getByTestId( - 'predict-market-details-tab-bar-tab-1', + getPredictMarketDetailsSelector.tabBarTab(1), ); fireEvent.press(aboutTab); @@ -1185,7 +1240,9 @@ describe('PredictMarketDetails', () => { it('handles back button press correctly', () => { const { mockGoBack, mockCanGoBack } = setupPredictMarketDetailsTest(); - const backButton = screen.getByTestId('icon-ArrowLeft'); + const backButton = screen.getByTestId( + getPredictMarketDetailsSelector.icon('ArrowLeft'), + ); fireEvent.press(backButton); expect(mockCanGoBack).toHaveBeenCalled(); @@ -1196,7 +1253,9 @@ describe('PredictMarketDetails', () => { const { mockCanGoBack, mockNavigate } = setupPredictMarketDetailsTest(); mockCanGoBack.mockReturnValue(false); - const backButton = screen.getByTestId('icon-ArrowLeft'); + const backButton = screen.getByTestId( + getPredictMarketDetailsSelector.icon('ArrowLeft'), + ); fireEvent.press(backButton); expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT); @@ -1270,7 +1329,7 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(marketWithoutEndDate); const aboutTab = screen.getByTestId( - 'predict-market-details-tab-bar-tab-1', + getPredictMarketDetailsSelector.tabBarTab(1), ); fireEvent.press(aboutTab); @@ -1311,7 +1370,9 @@ describe('PredictMarketDetails', () => { const { mockMarket } = setupPredictMarketDetailsTest(); // Find the chart component and trigger timeframe change - const chartComponent = screen.getByTestId('predict-details-chart'); + const chartComponent = screen.getByTestId( + PredictMarketDetailsSelectorsIDs.DETAILS_CHART, + ); expect(chartComponent).toBeOnTheScreen(); // The timeframe change is handled internally by the component @@ -1340,7 +1401,7 @@ describe('PredictMarketDetails', () => { // Switch to Positions tab (index 0 when positions exist) const positionsTab = screen.getByTestId( - 'predict-market-details-tab-bar-tab-0', + getPredictMarketDetailsSelector.tabBarTab(0), ); fireEvent.press(positionsTab); @@ -1498,7 +1559,7 @@ describe('PredictMarketDetails', () => { // Switch to Positions tab (index 0 when positions exist) const positionsTab = screen.getByTestId( - 'predict-market-details-tab-bar-tab-0', + getPredictMarketDetailsSelector.tabBarTab(0), ); fireEvent.press(positionsTab); @@ -1533,7 +1594,7 @@ describe('PredictMarketDetails', () => { // Switch to Positions tab (index 0 when positions exist) const positionsTab = screen.getByTestId( - 'predict-market-details-tab-bar-tab-0', + getPredictMarketDetailsSelector.tabBarTab(0), ); fireEvent.press(positionsTab); @@ -1572,7 +1633,7 @@ describe('PredictMarketDetails', () => { // Outcomes is the default tab when there are no positions expect( - screen.getByTestId('predict-market-details-outcomes-tab'), + screen.getByTestId(PredictMarketDetailsSelectorsIDs.OUTCOMES_TAB), ).toBeOnTheScreen(); }); @@ -1678,7 +1739,7 @@ describe('PredictMarketDetails', () => { // Switch to Positions tab (index 0 when positions exist) const positionsTab = screen.getByTestId( - 'predict-market-details-tab-bar-tab-0', + getPredictMarketDetailsSelector.tabBarTab(0), ); fireEvent.press(positionsTab); @@ -1711,7 +1772,7 @@ describe('PredictMarketDetails', () => { // Switch to Positions tab (index 0 when positions exist) const positionsTab = screen.getByTestId( - 'predict-market-details-tab-bar-tab-0', + getPredictMarketDetailsSelector.tabBarTab(0), ); fireEvent.press(positionsTab); @@ -1744,7 +1805,7 @@ describe('PredictMarketDetails', () => { // Switch to Positions tab (index 0 when positions exist) const positionsTab = screen.getByTestId( - 'predict-market-details-tab-bar-tab-0', + getPredictMarketDetailsSelector.tabBarTab(0), ); fireEvent.press(positionsTab); @@ -1758,7 +1819,7 @@ describe('PredictMarketDetails', () => { // Component should render without errors even with invalid data expect( - screen.getByTestId('predict-market-details-screen'), + screen.getByTestId(PredictMarketDetailsSelectorsIDs.SCREEN), ).toBeOnTheScreen(); }); @@ -1766,7 +1827,7 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest({}, { params: undefined }); expect( - screen.getByTestId('predict-market-details-screen'), + screen.getByTestId(PredictMarketDetailsSelectorsIDs.SCREEN), ).toBeOnTheScreen(); }); @@ -1982,7 +2043,7 @@ describe('PredictMarketDetails', () => { // Verify the component renders without errors expect( - screen.getByTestId('predict-market-details-screen'), + screen.getByTestId(PredictMarketDetailsSelectorsIDs.SCREEN), ).toBeOnTheScreen(); }); @@ -2111,7 +2172,7 @@ describe('PredictMarketDetails', () => { // Switch to Positions tab (index 0 when positions exist) const positionsTab = screen.getByTestId( - 'predict-market-details-tab-bar-tab-0', + getPredictMarketDetailsSelector.tabBarTab(0), ); fireEvent.press(positionsTab); @@ -2139,7 +2200,9 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(singleOutcomeMarket); // Verify chart renders for single outcome - expect(screen.getByTestId('predict-details-chart')).toBeOnTheScreen(); + expect( + screen.getByTestId(PredictMarketDetailsSelectorsIDs.DETAILS_CHART), + ).toBeOnTheScreen(); }); it('handles chart color selection for multiple outcomes', () => { @@ -2166,7 +2229,9 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(multiOutcomeMarket); // Verify chart renders for multiple outcomes - expect(screen.getByTestId('predict-details-chart')).toBeOnTheScreen(); + expect( + screen.getByTestId(PredictMarketDetailsSelectorsIDs.DETAILS_CHART), + ).toBeOnTheScreen(); }); it('handles outcome without tokens correctly', () => { @@ -2192,7 +2257,9 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(); // Component should handle different fidelity settings based on timeframe - expect(screen.getByTestId('predict-details-chart')).toBeOnTheScreen(); + expect( + screen.getByTestId(PredictMarketDetailsSelectorsIDs.DETAILS_CHART), + ).toBeOnTheScreen(); }); it('handles empty price histories array', () => { @@ -2202,7 +2269,9 @@ describe('PredictMarketDetails', () => { { priceHistory: { priceHistories: [] } }, ); - expect(screen.getByTestId('predict-details-chart')).toBeOnTheScreen(); + expect( + screen.getByTestId(PredictMarketDetailsSelectorsIDs.DETAILS_CHART), + ).toBeOnTheScreen(); }); it('handles errors in price history', () => { @@ -2212,7 +2281,9 @@ describe('PredictMarketDetails', () => { { priceHistory: { errors: ['Network error'] } }, ); - expect(screen.getByTestId('predict-details-chart')).toBeOnTheScreen(); + expect( + screen.getByTestId(PredictMarketDetailsSelectorsIDs.DETAILS_CHART), + ).toBeOnTheScreen(); }); describe('Price history fidelity adjustments', () => { @@ -2527,7 +2598,7 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(closedMarket); expect( - screen.getByTestId('predict-market-details-outcomes-tab'), + screen.getByTestId(PredictMarketDetailsSelectorsIDs.OUTCOMES_TAB), ).toBeOnTheScreen(); expect(screen.getByText('Yes Outcome')).toBeOnTheScreen(); expect(screen.getByText('No Outcome')).toBeOnTheScreen(); @@ -2541,7 +2612,7 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(closedMarket); const aboutTab = screen.getByTestId( - 'predict-market-details-tab-bar-tab-1', + getPredictMarketDetailsSelector.tabBarTab(1), ); fireEvent.press(aboutTab); @@ -2577,7 +2648,7 @@ describe('PredictMarketDetails', () => { ); const aboutTabWithPositions = screen.getByTestId( - 'predict-market-details-tab-bar-tab-2', + getPredictMarketDetailsSelector.tabBarTab(2), ); fireEvent.press(aboutTabWithPositions); @@ -2599,7 +2670,7 @@ describe('PredictMarketDetails', () => { screen.queryByText('predict.market_details.volume'), ).not.toBeOnTheScreen(); expect( - screen.getByTestId('predict-market-details-outcomes-tab'), + screen.getByTestId(PredictMarketDetailsSelectorsIDs.OUTCOMES_TAB), ).toBeOnTheScreen(); }); }); @@ -2667,7 +2738,9 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(marketWithPartialResolution); - expect(screen.getByTestId('predict-details-chart')).toBeOnTheScreen(); + expect( + screen.getByTestId(PredictMarketDetailsSelectorsIDs.DETAILS_CHART), + ).toBeOnTheScreen(); }); it('displays resolved outcomes count badge', () => { @@ -2731,7 +2804,9 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(marketWithPartialResolution); - const arrowDownIcon = screen.getByTestId('icon-ArrowDown'); + const arrowDownIcon = screen.getByTestId( + getPredictMarketDetailsSelector.icon('ArrowDown'), + ); const pressable = arrowDownIcon.parent?.parent; if (pressable) { @@ -2773,7 +2848,9 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(marketWithPartialResolution); - const arrowDownIcon = screen.getByTestId('icon-ArrowDown'); + const arrowDownIcon = screen.getByTestId( + getPredictMarketDetailsSelector.icon('ArrowDown'), + ); const pressable = arrowDownIcon.parent?.parent; if (pressable) { @@ -2817,7 +2894,9 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(marketWithPartialResolution); - const arrowDownIcon = screen.getByTestId('icon-ArrowDown'); + const arrowDownIcon = screen.getByTestId( + getPredictMarketDetailsSelector.icon('ArrowDown'), + ); const pressable = arrowDownIcon.parent?.parent; if (pressable) { @@ -2859,7 +2938,9 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(marketWithPartialResolution); - const arrowDownIcon = screen.getByTestId('icon-ArrowDown'); + const arrowDownIcon = screen.getByTestId( + getPredictMarketDetailsSelector.icon('ArrowDown'), + ); const pressable = arrowDownIcon.parent?.parent; if (pressable) { @@ -2898,7 +2979,9 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(marketWithPartialResolution); - const arrowDownIcon = screen.getByTestId('icon-ArrowDown'); + const arrowDownIcon = screen.getByTestId( + getPredictMarketDetailsSelector.icon('ArrowDown'), + ); const pressable = arrowDownIcon.parent?.parent; if (pressable) { @@ -2937,7 +3020,9 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(marketWithPartialResolution); - expect(screen.getByTestId('icon-ArrowDown')).toBeOnTheScreen(); + expect( + screen.getByTestId(getPredictMarketDetailsSelector.icon('ArrowDown')), + ).toBeOnTheScreen(); }); it('displays ArrowUp icon when expanded', () => { @@ -2969,13 +3054,17 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(marketWithPartialResolution); - const arrowDownIcon = screen.getByTestId('icon-ArrowDown'); + const arrowDownIcon = screen.getByTestId( + getPredictMarketDetailsSelector.icon('ArrowDown'), + ); const pressable = arrowDownIcon.parent?.parent; if (pressable) { fireEvent.press(pressable); - expect(screen.getByTestId('icon-ArrowUp')).toBeOnTheScreen(); + expect( + screen.getByTestId(getPredictMarketDetailsSelector.icon('ArrowUp')), + ).toBeOnTheScreen(); } }); @@ -3008,19 +3097,27 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(marketWithPartialResolution); - const arrowDownIcon = screen.getByTestId('icon-ArrowDown'); + const arrowDownIcon = screen.getByTestId( + getPredictMarketDetailsSelector.icon('ArrowDown'), + ); const pressable = arrowDownIcon.parent?.parent; if (pressable) { fireEvent.press(pressable); expect(screen.getByText('Option A')).toBeOnTheScreen(); - const arrowUpIcon = screen.getByTestId('icon-ArrowUp'); + const arrowUpIcon = screen.getByTestId( + getPredictMarketDetailsSelector.icon('ArrowUp'), + ); const pressableAgain = arrowUpIcon.parent?.parent; if (pressableAgain) { fireEvent.press(pressableAgain); expect(screen.queryByText('Option A')).not.toBeOnTheScreen(); - expect(screen.getByTestId('icon-ArrowDown')).toBeOnTheScreen(); + expect( + screen.getByTestId( + getPredictMarketDetailsSelector.icon('ArrowDown'), + ), + ).toBeOnTheScreen(); } } }); @@ -3100,7 +3197,9 @@ describe('PredictMarketDetails', () => { expect(screen.getByText('2')).toBeOnTheScreen(); - const arrowDownIcon = screen.getByTestId('icon-ArrowDown'); + const arrowDownIcon = screen.getByTestId( + getPredictMarketDetailsSelector.icon('ArrowDown'), + ); const pressable = arrowDownIcon.parent?.parent; if (pressable) { @@ -3139,7 +3238,9 @@ describe('PredictMarketDetails', () => { expect( screen.queryByText('predict.resolved_outcomes'), ).not.toBeOnTheScreen(); - expect(screen.getByTestId('predict-details-chart')).toBeOnTheScreen(); + expect( + screen.getByTestId(PredictMarketDetailsSelectorsIDs.DETAILS_CHART), + ).toBeOnTheScreen(); }); }); @@ -3205,7 +3306,7 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(); expect( - screen.getByTestId('predict-market-details-screen'), + screen.getByTestId(PredictMarketDetailsSelectorsIDs.SCREEN), ).toBeOnTheScreen(); }); }); @@ -3428,7 +3529,9 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(gameMarket); expect( - screen.getByTestId('predict-game-details-content'), + screen.getByTestId( + PredictMarketDetailsSelectorsIDs.GAME_DETAILS_CONTENT, + ), ).toBeOnTheScreen(); expect(screen.getByText('NFL: Team A vs Team B')).toBeOnTheScreen(); }); @@ -3442,10 +3545,12 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(regularMarket); expect( - screen.queryByTestId('predict-game-details-content'), + screen.queryByTestId( + PredictMarketDetailsSelectorsIDs.GAME_DETAILS_CONTENT, + ), ).not.toBeOnTheScreen(); expect( - screen.getByTestId('predict-market-details-screen'), + screen.getByTestId(PredictMarketDetailsSelectorsIDs.SCREEN), ).toBeOnTheScreen(); }); }); diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.view.test.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.view.test.tsx new file mode 100644 index 00000000000..272da826523 --- /dev/null +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.view.test.tsx @@ -0,0 +1,144 @@ +/** + * Component view tests for PredictMarketDetails. + * + * Run with: yarn jest -c jest.config.view.js PredictMarketDetails.view.test --runInBand --silent --coverage=false + */ +import '../../../../../../tests/component-view/mocks'; +import Engine from '../../../../../../app/core/Engine'; +import { + renderPredictMarketDetailsView, + renderPredictMarketDetailsViewWithRoutes, +} from '../../../../../../tests/component-view/renderers/predictMarketDetails'; +import { fireEvent, waitFor, within } from '@testing-library/react-native'; +import { PredictMarketDetailsSelectorsIDs } from '../../Predict.testIds'; +import Routes from '../../../../../constants/navigation/Routes'; +import { MOCK_PREDICT_MARKET } from '../../../../../../tests/component-view/fixtures/predict'; + +describe('PredictMarketDetails', () => { + describe('initial load', () => { + it('calls getMarket with the marketId from route params when the screen mounts', async () => { + const getMarketSpy = jest.spyOn( + Engine.context.PredictController, + 'getMarket', + ); + getMarketSpy.mockResolvedValue(MOCK_PREDICT_MARKET); + + renderPredictMarketDetailsView({ + initialParams: { marketId: 'market-btc-1' }, + }); + + await waitFor(() => { + expect(getMarketSpy).toHaveBeenCalledWith( + expect.objectContaining({ marketId: 'market-btc-1' }), + ); + }); + + getMarketSpy.mockRestore(); + }); + + it('shows complete market data in the details screen after getMarket resolves', async () => { + // Arrange + const getMarketSpy = jest.spyOn( + Engine.context.PredictController, + 'getMarket', + ); + getMarketSpy.mockResolvedValue(MOCK_PREDICT_MARKET); + const { findByTestId, findByText } = renderPredictMarketDetailsView({ + initialParams: { marketId: 'market-btc-1' }, + }); + + // Assert — all significant fields of the loaded market are visible on screen. + // The async resolution of getMarket is the event under test (not a render scenario). + const screen = await findByTestId( + PredictMarketDetailsSelectorsIDs.SCREEN, + ); + expect( + within(screen).getByText(MOCK_PREDICT_MARKET.title), + ).toBeOnTheScreen(); + expect(await findByText(/Yes.*¢/)).toBeOnTheScreen(); + expect(await findByText(/No.*¢/)).toBeOnTheScreen(); + + getMarketSpy.mockRestore(); + }); + + it('calls trackGeoBlockTriggered when the user presses a bet button while ineligible', async () => { + const trackGeoBlockSpy = jest.spyOn( + Engine.context.PredictController, + 'trackGeoBlockTriggered', + ); + const getMarketSpy = jest.spyOn( + Engine.context.PredictController, + 'getMarket', + ); + getMarketSpy.mockResolvedValue(MOCK_PREDICT_MARKET); + + const { findByText } = renderPredictMarketDetailsView({ + initialParams: { marketId: 'market-btc-1' }, + }); + + // Bet button label is "Yes • {yesPercentage}¢"; press it while ineligible + fireEvent.press(await findByText(/Yes.*¢/)); + + await waitFor(() => { + expect(trackGeoBlockSpy).toHaveBeenCalledWith( + expect.objectContaining({ attemptedAction: 'predict_action' }), + ); + }); + + getMarketSpy.mockRestore(); + trackGeoBlockSpy.mockRestore(); + }); + + it('calls trackMarketDetailsOpened when the market and positions finish loading', async () => { + const trackSpy = jest.spyOn( + Engine.context.PredictController, + 'trackMarketDetailsOpened', + ); + const getMarketSpy = jest.spyOn( + Engine.context.PredictController, + 'getMarket', + ); + getMarketSpy.mockResolvedValue(MOCK_PREDICT_MARKET); + + renderPredictMarketDetailsView({ + initialParams: { marketId: 'market-btc-1' }, + }); + + await waitFor(() => { + expect(trackSpy).toHaveBeenCalledWith( + expect.objectContaining({ marketId: 'market-btc-1' }), + ); + }); + + getMarketSpy.mockRestore(); + trackSpy.mockRestore(); + }); + }); + + describe('back navigation', () => { + it('navigates to the Predict root when the user presses back from the details screen', async () => { + const getMarketSpy = jest.spyOn( + Engine.context.PredictController, + 'getMarket', + ); + getMarketSpy.mockResolvedValue(MOCK_PREDICT_MARKET); + + const { findByTestId } = renderPredictMarketDetailsViewWithRoutes({ + initialParams: { marketId: 'market-btc-1' }, + extraRoutes: [{ name: Routes.PREDICT.ROOT }], + }); + + await findByTestId(PredictMarketDetailsSelectorsIDs.SCREEN); + + fireEvent.press( + await findByTestId(PredictMarketDetailsSelectorsIDs.BACK_BUTTON), + ); + + expect( + await findByTestId(`route-${Routes.PREDICT.ROOT}`), + ).toBeOnTheScreen(); + + getMarketSpy.mockRestore(); + }); + }); +}); diff --git a/tests/component-view/fixtures/predict.ts b/tests/component-view/fixtures/predict.ts new file mode 100644 index 00000000000..972600f7f7e --- /dev/null +++ b/tests/component-view/fixtures/predict.ts @@ -0,0 +1,36 @@ +import { + Recurrence, + type PredictMarket, +} from '../../../app/components/UI/Predict/types'; + +export const MOCK_PREDICT_MARKET: PredictMarket = { + id: 'market-btc-1', + providerId: 'polymarket', + slug: 'will-btc-reach-100k', + title: 'Will Bitcoin reach $100k?', + description: 'Will Bitcoin reach $100k by end of year?', + image: '', + status: 'open', + recurrence: Recurrence.NONE, + category: 'trending', + tags: [], + outcomes: [ + { + id: 'outcome-yes', + providerId: 'polymarket', + marketId: 'market-btc-1', + title: 'Will Bitcoin reach $100k?', + description: '', + image: '', + status: 'open', + tokens: [ + { id: 'token-yes', title: 'Yes', price: 0.65 }, + { id: 'token-no', title: 'No', price: 0.35 }, + ], + volume: 1_000_000, + groupItemTitle: 'Yes', + }, + ], + liquidity: 500_000, + volume: 1_000_000, +}; diff --git a/tests/component-view/mocks.ts b/tests/component-view/mocks.ts index 75c887d9fef..8eba1c004a0 100644 --- a/tests/component-view/mocks.ts +++ b/tests/component-view/mocks.ts @@ -163,6 +163,18 @@ jest.mock('../../app/core/Engine', () => { setLocation: jest.fn(), trackUnifiedSwapBridgeEvent: jest.fn(), }, + PredictController: { + getMarkets: jest.fn().mockResolvedValue([]), + getMarket: jest.fn().mockResolvedValue(null), + getBalance: jest.fn().mockResolvedValue(0), + getPositions: jest.fn().mockResolvedValue([]), + getPrices: jest.fn().mockResolvedValue({ providerId: '', results: [] }), + trackFeedViewed: jest.fn(), + trackTabChanged: jest.fn(), + trackMarketDetailsOpened: jest.fn(), + trackGeoBlockTriggered: jest.fn(), + refreshEligibility: jest.fn().mockResolvedValue(undefined), + }, // Perps: stub so hooks (usePerpsClosePosition, usePerpsMarkets, etc.) do not throw // getMarkets returns one market so PerpsTabView explore section renders "See all perps" PerpsController: { diff --git a/tests/component-view/presets/predict.ts b/tests/component-view/presets/predict.ts new file mode 100644 index 00000000000..7b2c412734f --- /dev/null +++ b/tests/component-view/presets/predict.ts @@ -0,0 +1,51 @@ +import { createStateFixture } from '../stateFixture'; +import type { DeepPartial } from '../../../app/util/test/renderWithProvider'; +import type { RootState } from '../../../app/reducers'; + +/** + * Default PredictController state for component view tests. + * Selectors read from state.engine.backgroundState.PredictController. + */ +const defaultPredictControllerState = { + balances: {}, + pendingDeposits: {}, + claimablePositions: {}, + accountMeta: {}, + withdrawTransaction: null, +}; + +/** + * Returns a StateFixtureBuilder with minimal state for Predict views. + * Use .withOverrides() to set PredictController fields, feature flags, etc. + */ +export const initialStatePredict = () => + createStateFixture() + .withMinimalAccounts() + .withMinimalMainnetNetwork() + .withMinimalKeyringController() + .withRemoteFeatureFlags({ + predictTradingEnabled: { + enabled: true, + featureVersion: '1.0.0', + minimumVersion: '0.0.1', + }, + }) + .withOverrides({ + engine: { + backgroundState: { + PredictController: defaultPredictControllerState, + NetworkController: { + selectedNetworkClientId: 'mainnet', + }, + PreferencesController: { + privacyMode: false, + selectedAddress: '0x1234567890abcdef', + }, + // usePredictDeposit -> useConfirmNavigation reads TransactionController + TransactionController: { + transactions: [], + transactionBatches: [], + }, + }, + }, + } as unknown as DeepPartial); diff --git a/tests/component-view/renderers/predict.tsx b/tests/component-view/renderers/predict.tsx new file mode 100644 index 00000000000..e892e949cd3 --- /dev/null +++ b/tests/component-view/renderers/predict.tsx @@ -0,0 +1,85 @@ +import '../mocks'; +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { DeepPartial } from '../../../app/util/test/renderWithProvider'; +import type { RootState } from '../../../app/reducers'; +import { renderComponentViewScreen, renderScreenWithRoutes } from '../render'; +import Routes from '../../../app/constants/navigation/Routes'; +import PredictFeed from '../../../app/components/UI/Predict/views/PredictFeed'; +import { initialStatePredict } from '../presets/predict'; + +interface RenderPredictFeedOptions { + overrides?: DeepPartial; +} + +/** + * Creates a PredictFeed component wrapped with QueryClientProvider. + * + * A fresh QueryClient (retry: false) is created per call so that query state + * does not leak between tests. PredictBalance uses @tanstack/react-query to + * fetch the balance via Engine.context.PredictController.getBalance. + */ +function createWrappedPredictFeed(): React.ComponentType { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return (props: Record) => ( + + + + ); +} + +/** + * Renders PredictFeed for component view tests. + * + * State is driven by Redux + preset; use overrides for per-test deltas. + */ +export function renderPredictFeedView( + options: RenderPredictFeedOptions = {}, +): ReturnType { + const { overrides } = options; + + const builder = initialStatePredict(); + if (overrides) { + builder.withOverrides(overrides); + } + const state = builder.build(); + + return renderComponentViewScreen( + createWrappedPredictFeed(), + { name: Routes.PREDICT.MARKET_LIST }, + { state }, + ); +} + +interface RenderPredictFeedWithRoutesOptions extends RenderPredictFeedOptions { + extraRoutes?: { name: string; Component?: React.ComponentType }[]; +} + +/** + * Renders PredictFeed with additional registered routes for navigation assertions. + * + * Each extra route auto-generates a probe component that renders + * ``, so tests can assert navigation with + * `findByTestId(`route-${Routes.WALLET.HOME}`)`. + */ +export function renderPredictFeedViewWithRoutes( + options: RenderPredictFeedWithRoutesOptions = {}, +): ReturnType { + const { overrides, extraRoutes = [] } = options; + + const builder = initialStatePredict(); + if (overrides) { + builder.withOverrides(overrides); + } + const state = builder.build(); + + return renderScreenWithRoutes( + createWrappedPredictFeed(), + { name: Routes.PREDICT.MARKET_LIST }, + extraRoutes, + { state }, + ); +} diff --git a/tests/component-view/renderers/predictMarketDetails.tsx b/tests/component-view/renderers/predictMarketDetails.tsx new file mode 100644 index 00000000000..1f164615fc1 --- /dev/null +++ b/tests/component-view/renderers/predictMarketDetails.tsx @@ -0,0 +1,84 @@ +import '../mocks'; +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { DeepPartial } from '../../../app/util/test/renderWithProvider'; +import type { RootState } from '../../../app/reducers'; +import { renderComponentViewScreen, renderScreenWithRoutes } from '../render'; +import Routes from '../../../app/constants/navigation/Routes'; +import PredictMarketDetails from '../../../app/components/UI/Predict/views/PredictMarketDetails'; +import { initialStatePredict } from '../presets/predict'; + +interface RenderPredictMarketDetailsOptions { + overrides?: DeepPartial; + initialParams?: Record; +} + +/** + * Creates a PredictMarketDetails component wrapped with QueryClientProvider. + * + * A fresh QueryClient (retry: false) is created per call so that query state + * does not leak between tests. usePredictPositions uses @tanstack/react-query. + */ +function createWrappedPredictMarketDetails(): React.ComponentType { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return (props: Record) => ( + + + + ); +} + +/** + * Renders PredictMarketDetails for component view tests. + * + * Pass `initialParams` to provide route params (marketId, title, image, etc.). + */ +export function renderPredictMarketDetailsView( + options: RenderPredictMarketDetailsOptions = {}, +): ReturnType { + const { overrides, initialParams } = options; + + const builder = initialStatePredict(); + if (overrides) { + builder.withOverrides(overrides); + } + const state = builder.build(); + + return renderComponentViewScreen( + createWrappedPredictMarketDetails(), + { name: Routes.PREDICT.MARKET_DETAILS }, + { state }, + initialParams, + ); +} + +interface RenderPredictMarketDetailsWithRoutesOptions + extends RenderPredictMarketDetailsOptions { + extraRoutes?: { name: string; Component?: React.ComponentType }[]; +} + +/** + * Renders PredictMarketDetails with additional registered routes for navigation assertions. + */ +export function renderPredictMarketDetailsViewWithRoutes( + options: RenderPredictMarketDetailsWithRoutesOptions = {}, +): ReturnType { + const { overrides, initialParams, extraRoutes = [] } = options; + + const builder = initialStatePredict(); + if (overrides) { + builder.withOverrides(overrides); + } + const state = builder.build(); + + return renderScreenWithRoutes( + createWrappedPredictMarketDetails(), + { name: Routes.PREDICT.MARKET_DETAILS }, + extraRoutes, + { state }, + initialParams, + ); +} From 88ebfedd0fd88b5045a9ac97d69d4fe46e6e3248 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Fri, 6 Mar 2026 18:51:35 +0100 Subject: [PATCH 03/11] fix: charting library url (#26969) ## **Description** Fix charting library url config ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Low-risk configuration-only change, but a missing/incorrect `MM_CHARTING_LIBRARY_URL` secret/value could cause builds or OTA updates to use the wrong charting assets. > > **Overview** > Fixes charting library URL configuration by introducing `MM_CHARTING_LIBRARY_URL` as a first-class env var across the build system. > > The OTA push workflow (`push-eas-update.yml`) now injects `MM_CHARTING_LIBRARY_URL` from GitHub secrets, `builds.yml` defines the default URL, and `scripts/build.sh` exports it into the generated `.env` for Expo update/build steps. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 18c053a8eff6ba3eadcc684bbc019fef5444b186. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .github/workflows/push-eas-update.yml | 1 + builds.yml | 1 + scripts/build.sh | 1 + 3 files changed, 3 insertions(+) diff --git a/.github/workflows/push-eas-update.yml b/.github/workflows/push-eas-update.yml index efac957c8c4..17126d1a23a 100644 --- a/.github/workflows/push-eas-update.yml +++ b/.github/workflows/push-eas-update.yml @@ -332,6 +332,7 @@ jobs: QUICKNODE_POLYGON_URL: ${{ secrets.QUICKNODE_POLYGON_URL }} QUICKNODE_BSC_URL: ${{ secrets.QUICKNODE_BSC_URL }} QUICKNODE_SEI_URL: ${{ secrets.QUICKNODE_SEI_URL }} + MM_CHARTING_LIBRARY_URL: ${{ secrets.MM_CHARTING_LIBRARY_URL }} steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/builds.yml b/builds.yml index 127d5d22a73..8c81995d392 100644 --- a/builds.yml +++ b/builds.yml @@ -42,6 +42,7 @@ _public_envs: &public_envs # Servers (production) MM_PERPS_HIP3_ENABLED: 'true' # Temporary flag to enable builds with GitHub Actions, remove it when deprecating bitrise BUILDS_ENABLED_WITH_GH_ACTIONS_TEMPORARY: 'true' + MM_CHARTING_LIBRARY_URL: 'https://va-mmcx-terminal.s3.us-east-2.amazonaws.com/charting_library/' # Common secrets (shared across ALL builds - same names, GitHub Environment determines values) _secrets: &secrets # Infrastructure diff --git a/scripts/build.sh b/scripts/build.sh index ec121ce68f7..82a2e26a80e 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -712,6 +712,7 @@ createEnvFile() { "QUICKNODE_OPTIMISM_URL" "QUICKNODE_POLYGON_URL" "QUICKNODE_HYPEREVM_URL" + "MM_CHARTING_LIBRARY_URL" ) # Create .env file and export to GITHUB_ENV From 070c029e7d0d97544923cdb41cd3828e95dfa022 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Fri, 6 Mar 2026 19:52:57 +0100 Subject: [PATCH 04/11] feat(perps): default pay token when no balance and Add funds CTA on market details (#26281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** 1. **Why**: Users with no perps balance saw an unclear flow (e.g. no preselected pay token, or Long/Short with no way to fund). 2. **What**: (a) When the user has no perps balance and the pay-with-any-token allowlist is enabled, we preselect the allowlist token with the highest USD balance in the order Pay row. (b) When they have no perps balance and no such token can be preselected, we show a single "Add funds" CTA on the market details screen instead of Long/Short; tapping it navigates to the perps confirmation stack and opens the deposit flow. - **New hook** `useDefaultPayWithTokenWhenNoPerpsBalance`: returns the allowlist token with highest balance when `availableBalance <= PERPS_MIN_BALANCE_THRESHOLD`, otherwise `null`. Respects `perpsPayWithAnyTokenAllowlistAssets`. - **Constant** `PERPS_MIN_BALANCE_THRESHOLD` (0.01) in `perpsConfig.ts` for the "no perps balance" threshold and minimum token balance for preselection. - **PerpsPayRow**: uses the hook; when pending config has no selected token, either preselects that token (via `setPayToken` + `setSelectedPaymentToken`) or sets selected payment to Perps balance (`null`). - **PerpsMarketDetailsView**: uses `usePerpsLiveAccount`, the new hook, and `useConfirmNavigation`. When `showAddFundsCTA` (no position, not at OI cap, balance < 0.01, and hook returns `null`), footer shows "Add funds" only; `handleAddFunds` calls `navigateToConfirmation({ stack: Routes.PERPS.ROOT })` then `depositWithConfirmation()`. Otherwise Long/Short buttons are shown as before. ## **Changelog** CHANGELOG entry: When users have no perps balance, the app now preselects the allowlist token with the highest balance for payment when available, and shows an "Add funds" button on the market details screen when no token can be preselected. ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TAT-2569 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** simulator_screenshot_EB73FD6D-B607-4611-8B08-3C6B63737730 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Changes the perps funding/payment-token selection and deposit entrypoint from `PerpsMarketDetailsView`, which can affect how users land in confirmations and which token is preselected. Logic is gated by balance thresholds/allowlists but still touches trading UX flows and error handling. > > **Overview** > Improves the *zero/low perps balance* onboarding flow by adding `useDefaultPayWithTokenWhenNoPerpsBalance`, which selects the allowlisted pay-with-any-token asset with the highest fiat balance (above `PERPS_MIN_BALANCE_THRESHOLD`) while excluding the current provider’s native chain. > > `PerpsPayRow` now uses this hook to auto-preselect that token when pending trade config has no selected token; otherwise it keeps defaulting to Perps balance (`null`). `PerpsMarketDetailsView` conditionally replaces Long/Short with a single **Add funds** CTA when balance is below threshold and no default token exists; pressing it navigates to the Perps confirmation stack and triggers `depositWithConfirmation()`, logging any deposit errors. > > Adds supporting config (`PERPS_MIN_BALANCE_THRESHOLD`, provider chain-id mapping + `getPerpsProviderChainId`), expands Perps view state fixtures for component tests, and updates/adds unit tests covering the new behaviors. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 13930fc3e6f3f9d1c31cb5a52c4c27ec72091747. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Cursor --- .../PerpsMarketDetailsView.test.tsx | 161 ++++++++++++- .../PerpsMarketDetailsView.tsx | 74 +++++- .../Views/PerpsOrderView/PerpsPayRow.test.tsx | 38 +++ .../Views/PerpsOrderView/PerpsPayRow.tsx | 36 ++- .../UI/Perps/constants/perpsConfig.ts | 50 ++++ ...aultPayWithTokenWhenNoPerpsBalance.test.ts | 216 ++++++++++++++++++ ...seDefaultPayWithTokenWhenNoPerpsBalance.ts | 91 ++++++++ .../presets/perpsStatePreset.test.ts | 38 +++ .../presets/perpsStatePreset.ts | 13 ++ 9 files changed, 710 insertions(+), 7 deletions(-) create mode 100644 app/components/UI/Perps/hooks/useDefaultPayWithTokenWhenNoPerpsBalance.test.ts create mode 100644 app/components/UI/Perps/hooks/useDefaultPayWithTokenWhenNoPerpsBalance.ts create mode 100644 tests/component-view/presets/perpsStatePreset.test.ts diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx index 9e7facc3996..558dbd0a670 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx @@ -8,6 +8,7 @@ import { PerpsOrderViewSelectorsIDs, } from '../../Perps.testIds'; import { PerpsConnectionProvider } from '../../providers/PerpsConnectionProvider'; +import { useDefaultPayWithTokenWhenNoPerpsBalance } from '../../hooks/useDefaultPayWithTokenWhenNoPerpsBalance'; import { Linking } from 'react-native'; // Mock Linking @@ -95,6 +96,8 @@ jest.mock('../../../../../util/Logger', () => ({ const mockUsePerpsAccount = jest.fn(); const mockUsePerpsLiveAccount = jest.fn(); const mockUseHasExistingPosition = jest.fn(); +const mockNavigateToConfirmation = jest.fn(); +const mockDepositWithConfirmation = jest.fn(() => Promise.resolve()); const mockUsePerpsLiveOrders = jest.fn(); const mockUsePerpsLivePrices = jest.fn(); @@ -103,6 +106,21 @@ jest.mock('../../hooks/stream/usePerpsLiveAccount', () => ({ usePerpsLiveAccount: mockUsePerpsLiveAccount, })); +jest.mock('../../hooks/useDefaultPayWithTokenWhenNoPerpsBalance', () => ({ + useDefaultPayWithTokenWhenNoPerpsBalance: jest.fn(() => null), +})); + +const mockUseDefaultPayWithTokenWhenNoPerpsBalance = + useDefaultPayWithTokenWhenNoPerpsBalance as jest.MockedFunction< + typeof useDefaultPayWithTokenWhenNoPerpsBalance + >; + +jest.mock('../../../../Views/confirmations/hooks/useConfirmNavigation', () => ({ + useConfirmNavigation: () => ({ + navigateToConfirmation: mockNavigateToConfirmation, + }), +})); + // Mock usePerpsMarketFills to avoid Redux selector issues jest.mock('../../hooks/usePerpsMarketFills', () => ({ usePerpsMarketFills: jest.fn(() => ({ @@ -425,7 +443,7 @@ jest.mock('../../hooks', () => ({ placeOrder: jest.fn(), cancelOrder: jest.fn(), getAccountState: jest.fn(), - depositWithConfirmation: jest.fn(() => Promise.resolve()), + depositWithConfirmation: mockDepositWithConfirmation, withdrawWithConfirmation: jest.fn(), })), usePerpsNetworkManagement: jest.fn(() => ({ @@ -681,6 +699,8 @@ describe('PerpsMarketDetailsView', () => { isInitialLoading: false, }); + mockUseDefaultPayWithTokenWhenNoPerpsBalance.mockReturnValue(null); + mockUseHasExistingPosition.mockReturnValue({ hasPosition: false, isLoading: false, @@ -884,7 +904,13 @@ describe('PerpsMarketDetailsView', () => { describe('Button rendering scenarios', () => { it('shows long/short buttons when user balance is zero so user can trade', () => { - // Override with zero balance + // Override with zero balance; return a default pay token so Add funds CTA is not shown + // (when user has allowlist token they can pay with, we show Long/Short) + mockUseDefaultPayWithTokenWhenNoPerpsBalance.mockReturnValue({ + address: '0xUSDC' as const, + chainId: '0xa4b1' as const, + description: 'USDC', + }); mockUsePerpsAccount.mockReturnValue({ account: { availableBalance: '0.00', @@ -930,6 +956,137 @@ describe('PerpsMarketDetailsView', () => { ).toBeNull(); }); + it('shows add funds CTA when user balance is below threshold and no allowlist token', () => { + mockUseDefaultPayWithTokenWhenNoPerpsBalance.mockReturnValue(null); + mockUsePerpsAccount.mockReturnValue({ + account: { + availableBalance: '0.00', + marginUsed: '0.00', + unrealizedPnl: '0.00', + returnOnEquity: '0.00', + totalBalance: '0.00', + }, + isInitialLoading: false, + }); + mockUsePerpsLiveAccount.mockReturnValue({ + account: { + availableBalance: '0', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + totalBalance: '0', + }, + isInitialLoading: false, + }); + + const { getByTestId, queryByTestId } = renderWithProvider( + + + , + { state: initialState }, + ); + + expect( + getByTestId(PerpsMarketDetailsViewSelectorsIDs.ADD_FUNDS_BUTTON), + ).toBeTruthy(); + expect( + queryByTestId(PerpsMarketDetailsViewSelectorsIDs.LONG_BUTTON), + ).toBeNull(); + expect( + queryByTestId(PerpsMarketDetailsViewSelectorsIDs.SHORT_BUTTON), + ).toBeNull(); + }); + + it('calls navigateToConfirmation and depositWithConfirmation when add funds is pressed', async () => { + mockUseDefaultPayWithTokenWhenNoPerpsBalance.mockReturnValue(null); + mockUsePerpsAccount.mockReturnValue({ + account: { + availableBalance: '0.00', + marginUsed: '0.00', + unrealizedPnl: '0.00', + returnOnEquity: '0.00', + totalBalance: '0.00', + }, + isInitialLoading: false, + }); + mockUsePerpsLiveAccount.mockReturnValue({ + account: { + availableBalance: '0', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + totalBalance: '0', + }, + isInitialLoading: false, + }); + mockNavigateToConfirmation.mockClear(); + mockDepositWithConfirmation.mockClear(); + mockDepositWithConfirmation.mockResolvedValue(undefined); + + const { getByTestId } = renderWithProvider( + + + , + { state: initialState }, + ); + + const addFundsButton = getByTestId( + PerpsMarketDetailsViewSelectorsIDs.ADD_FUNDS_BUTTON, + ); + await act(async () => { + fireEvent.press(addFundsButton); + }); + + expect(mockNavigateToConfirmation).toHaveBeenCalledWith({ + stack: 'Perps', + }); + expect(mockDepositWithConfirmation).toHaveBeenCalled(); + }); + + it('handles depositWithConfirmation rejection without throwing', async () => { + mockUseDefaultPayWithTokenWhenNoPerpsBalance.mockReturnValue(null); + mockUsePerpsAccount.mockReturnValue({ + account: { + availableBalance: '0.00', + marginUsed: '0.00', + unrealizedPnl: '0.00', + returnOnEquity: '0.00', + totalBalance: '0.00', + }, + isInitialLoading: false, + }); + mockUsePerpsLiveAccount.mockReturnValue({ + account: { + availableBalance: '0', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + totalBalance: '0', + }, + isInitialLoading: false, + }); + mockDepositWithConfirmation.mockRejectedValueOnce( + new Error('Deposit failed'), + ); + + const { getByTestId } = renderWithProvider( + + + , + { state: initialState }, + ); + + const addFundsButton = getByTestId( + PerpsMarketDetailsViewSelectorsIDs.ADD_FUNDS_BUTTON, + ); + await act(async () => { + fireEvent.press(addFundsButton); + }); + await waitFor(() => { + expect(mockDepositWithConfirmation).toHaveBeenCalled(); + }); + }); + it('renders modify/close buttons when user has balance and existing position', () => { // Override with non-zero balance and existing position mockUsePerpsAccount.mockReturnValue({ diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index 27392b8c326..0f79b08c7ea 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -78,12 +78,20 @@ import TradingViewChart, { type TradingViewChartRef, } from '../../components/TradingViewChart'; import { PERPS_CHART_CONFIG } from '../../constants/chartConfig'; +import { PERPS_MIN_BALANCE_THRESHOLD } from '../../constants/perpsConfig'; import { usePerpsConnection, usePerpsNavigation, usePositionManagement, + usePerpsTrading, } from '../../hooks'; -import { usePerpsLiveOrders, usePerpsLivePrices } from '../../hooks/stream'; +import { useConfirmNavigation } from '../../../../Views/confirmations/hooks/useConfirmNavigation'; +import { useDefaultPayWithTokenWhenNoPerpsBalance } from '../../hooks/useDefaultPayWithTokenWhenNoPerpsBalance'; +import { + usePerpsLiveAccount, + usePerpsLiveOrders, + usePerpsLivePrices, +} from '../../hooks/stream'; import { usePerpsLiveCandles } from '../../hooks/stream/usePerpsLiveCandles'; import { useHasExistingPosition } from '../../hooks/useHasExistingPosition'; import { useIsPriceDeviatedAboveThreshold } from '../../hooks/useIsPriceDeviatedAboveThreshold'; @@ -389,6 +397,44 @@ const PerpsMarketDetailsView: React.FC = () => { loadOnMount: true, }); + const { account, isInitialLoading: isLoadingAccount } = usePerpsLiveAccount(); + const defaultPayTokenWhenNoPerpsBalance = + useDefaultPayWithTokenWhenNoPerpsBalance(); + const { depositWithConfirmation } = usePerpsTrading(); + const { navigateToConfirmation } = useConfirmNavigation(); + const availableBalance = Number.parseFloat( + account?.availableBalance?.toString() ?? '0', + ); + const showAddFundsCTA = + isEligible && + !isLoadingPosition && + !existingPosition && + !isAtOICap && + !isLoadingAccount && + availableBalance < PERPS_MIN_BALANCE_THRESHOLD && + defaultPayTokenWhenNoPerpsBalance === null; + + const handleAddFunds = useCallback(async () => { + if (!isEligible) { + track(MetaMetricsEvents.PERPS_SCREEN_VIEWED, { + [PERPS_EVENT_PROPERTY.SCREEN_TYPE]: + PERPS_EVENT_VALUE.SCREEN_TYPE.GEO_BLOCK_NOTIF, + [PERPS_EVENT_PROPERTY.SOURCE]: + PERPS_EVENT_VALUE.SOURCE.ADD_FUNDS_ACTION, + }); + setIsEligibilityModalVisible(true); + return; + } + navigateToConfirmation({ stack: Routes.PERPS.ROOT }); + try { + await depositWithConfirmation(); + } catch (err) { + Logger.error(ensureError(err, 'PerpsMarketDetailsView.handleAddFunds'), { + tags: { feature: PERPS_CONSTANTS.FeatureName }, + }); + } + }, [isEligible, track, navigateToConfirmation, depositWithConfirmation]); + // Keep current position ref in sync for callbacks stored in route params // This must be after useHasExistingPosition since it depends on existingPosition useEffect(() => { @@ -1031,6 +1077,13 @@ const PerpsMarketDetailsView: React.FC = () => { ); } + const shouldShowNewPositionActions = + hasLongShortButtons && !existingPosition && !isAtOICap; + const shouldShowAddFundsCTASection = + shouldShowNewPositionActions && showAddFundsCTA; + const shouldShowLongShortButtonsOnly = + shouldShowNewPositionActions && !showAddFundsCTA; + return ( = () => { )} - {/* Show Long/Short buttons when no position exists */} - {hasLongShortButtons && !existingPosition && !isAtOICap && ( + {/* Show Add funds CTA when no perps balance and no allowlist token to preselect */} + {shouldShowAddFundsCTASection && ( + + +