Skip to content

Commit 5e8c845

Browse files
authored
fix(predict): Market details outcomes tab now displays correctly when market is resolved (MetaMask#22358)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** - Now shows the resolved outcomes under the outcomes tab (on the market details screen) when the market is resolved. - Sidenote the `app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts` view is increasingly in need of a refactor, but holding off while we prioritize backlog, etc <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: null ## **Related issues** Fixes: NA ## **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** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <img width="420" alt="image" src="https://github.com/user-attachments/assets/7a16c912-b905-4499-bb99-09dba3e224a7" /> <img width="420" alt="image" src="https://github.com/user-attachments/assets/62e45d3b-253c-47b7-9ca2-965b2f4f6f52" /> ## **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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds `PredictMarketOutcomeResolved` and integrates it to display resolved outcomes in the Outcomes tab for closed and partially resolved markets, with tests and i18n updates. > > - **UI/Logic (Predict Market Details)**: > - **Resolved Outcomes Rendering**: Introduces `renderOutcomesContent` to show: > - Closed binary markets: winning/losing outcomes. > - Closed multi-outcome markets: list of resolved outcomes via `PredictMarketOutcomeResolved`. > - Open markets with partial resolution: expandable "Resolved outcomes" section using `PredictMarketOutcomeResolved` plus open outcomes. > - Integrates `PredictMarketOutcomeResolved` into `PredictMarketDetails` and updates tab defaulting for closed markets. > - **New Component**: > - `components/PredictMarketOutcomeResolved`: Displays group title, formatted volume, winner/draw, and confirmation icon. > - **Tests**: > - Adds comprehensive tests for `PredictMarketOutcomeResolved` (winner/draw, formatting, styling). > - Updates `PredictMarketDetails` tests to assert new outcomes rendering and collapsed/expanded behavior. > - **i18n**: > - Adds `predict.outcome_draw` string. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a7b83c0. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent b3e0a64 commit 5e8c845

6 files changed

Lines changed: 548 additions & 165 deletions

File tree

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
import React from 'react';
2+
import { render } from '@testing-library/react-native';
3+
import PredictMarketOutcomeResolved from './PredictMarketOutcomeResolved';
4+
import { PredictOutcome } from '../../types';
5+
import { formatVolume } from '../../utils/format';
6+
7+
jest.mock('../../../../../../locales/i18n', () => ({
8+
strings: (key: string) => {
9+
const translations: Record<string, string> = {
10+
'predict.volume_abbreviated': 'Vol.',
11+
'predict.outcome_draw': 'Draw',
12+
};
13+
return translations[key] || key;
14+
},
15+
}));
16+
17+
jest.mock('../../utils/format');
18+
19+
const createMockOutcome = (
20+
overrides?: Partial<PredictOutcome>,
21+
): PredictOutcome => ({
22+
id: 'test-outcome-1',
23+
marketId: 'test-market-1',
24+
providerId: 'test-provider',
25+
title: 'Will Bitcoin reach $150,000 by end of year?',
26+
description: 'Bitcoin price prediction market',
27+
image: 'https://example.com/bitcoin.png',
28+
status: 'open',
29+
tokens: [
30+
{
31+
id: 'token-yes',
32+
title: 'Yes',
33+
price: 0.65,
34+
},
35+
{
36+
id: 'token-no',
37+
title: 'No',
38+
price: 0.35,
39+
},
40+
],
41+
volume: 1000000,
42+
groupItemTitle: 'Crypto Markets',
43+
negRisk: false,
44+
tickSize: '0.01',
45+
...overrides,
46+
});
47+
48+
describe('PredictMarketOutcomeResolved', () => {
49+
const mockFormatVolume = formatVolume as jest.MockedFunction<
50+
typeof formatVolume
51+
>;
52+
53+
beforeEach(() => {
54+
jest.clearAllMocks();
55+
mockFormatVolume.mockImplementation((volume: string | number) => {
56+
const numVolume =
57+
typeof volume === 'string' ? parseFloat(volume) : volume;
58+
if (numVolume >= 1000000) {
59+
return `${(numVolume / 1000000).toFixed(1)}M`;
60+
}
61+
if (numVolume >= 1000) {
62+
return `${(numVolume / 1000).toFixed(1)}K`;
63+
}
64+
return numVolume.toString();
65+
});
66+
});
67+
68+
afterEach(() => {
69+
jest.resetAllMocks();
70+
});
71+
72+
it('renders outcome title and volume information', () => {
73+
const outcome = createMockOutcome();
74+
75+
const { getByText } = render(
76+
<PredictMarketOutcomeResolved outcome={outcome} />,
77+
);
78+
79+
expect(getByText('Crypto Markets')).toBeOnTheScreen();
80+
expect(getByText(/\$1\.0M.*Vol\./)).toBeOnTheScreen();
81+
});
82+
83+
it('displays token one as winner when token one price is higher', () => {
84+
const outcome = createMockOutcome({
85+
tokens: [
86+
{ id: 'token-yes', title: 'Yes', price: 0.75 },
87+
{ id: 'token-no', title: 'No', price: 0.25 },
88+
],
89+
});
90+
91+
const { getByText } = render(
92+
<PredictMarketOutcomeResolved outcome={outcome} />,
93+
);
94+
95+
expect(getByText('Yes')).toBeOnTheScreen();
96+
});
97+
98+
it('displays token two as winner when token two price is higher', () => {
99+
const outcome = createMockOutcome({
100+
tokens: [
101+
{ id: 'token-yes', title: 'Yes', price: 0.25 },
102+
{ id: 'token-no', title: 'No', price: 0.75 },
103+
],
104+
});
105+
106+
const { getByText } = render(
107+
<PredictMarketOutcomeResolved outcome={outcome} />,
108+
);
109+
110+
expect(getByText('No')).toBeOnTheScreen();
111+
});
112+
113+
it('displays draw when token prices are equal', () => {
114+
const outcome = createMockOutcome({
115+
tokens: [
116+
{ id: 'token-yes', title: 'Yes', price: 0.5 },
117+
{ id: 'token-no', title: 'No', price: 0.5 },
118+
],
119+
});
120+
121+
const { getByText } = render(
122+
<PredictMarketOutcomeResolved outcome={outcome} />,
123+
);
124+
125+
expect(getByText('Draw')).toBeOnTheScreen();
126+
});
127+
128+
it('renders confirmation icon when token one wins', () => {
129+
const outcome = createMockOutcome({
130+
tokens: [
131+
{ id: 'token-yes', title: 'Yes', price: 0.75 },
132+
{ id: 'token-no', title: 'No', price: 0.25 },
133+
],
134+
});
135+
136+
const { getByTestId } = render(
137+
<PredictMarketOutcomeResolved outcome={outcome} />,
138+
);
139+
140+
// Component renders without errors and shows winner
141+
expect(getByTestId).toBeDefined();
142+
});
143+
144+
it('renders without confirmation icon when token two wins', () => {
145+
const outcome = createMockOutcome({
146+
tokens: [
147+
{ id: 'token-yes', title: 'Yes', price: 0.25 },
148+
{ id: 'token-no', title: 'No', price: 0.75 },
149+
],
150+
});
151+
152+
const { getByText } = render(
153+
<PredictMarketOutcomeResolved outcome={outcome} />,
154+
);
155+
156+
// Verify token two is displayed as winner
157+
expect(getByText('No')).toBeOnTheScreen();
158+
});
159+
160+
it('renders without confirmation icon when prices are equal', () => {
161+
const outcome = createMockOutcome({
162+
tokens: [
163+
{ id: 'token-yes', title: 'Yes', price: 0.5 },
164+
{ id: 'token-no', title: 'No', price: 0.5 },
165+
],
166+
});
167+
168+
const { getByText } = render(
169+
<PredictMarketOutcomeResolved outcome={outcome} />,
170+
);
171+
172+
// Verify draw is displayed
173+
expect(getByText('Draw')).toBeOnTheScreen();
174+
});
175+
176+
it('renders with container styling when noContainer is false', () => {
177+
const outcome = createMockOutcome();
178+
179+
const { getByText } = render(
180+
<PredictMarketOutcomeResolved outcome={outcome} noContainer={false} />,
181+
);
182+
183+
// Component renders successfully
184+
expect(getByText('Crypto Markets')).toBeOnTheScreen();
185+
});
186+
187+
it('renders with minimal styling when noContainer is true', () => {
188+
const outcome = createMockOutcome();
189+
190+
const { getByText } = render(
191+
<PredictMarketOutcomeResolved outcome={outcome} noContainer />,
192+
);
193+
194+
// Component renders successfully
195+
expect(getByText('Crypto Markets')).toBeOnTheScreen();
196+
});
197+
198+
it('handles zero volume', () => {
199+
const outcome = createMockOutcome({ volume: 0 });
200+
201+
const { getByText } = render(
202+
<PredictMarketOutcomeResolved outcome={outcome} />,
203+
);
204+
205+
expect(getByText(/\$0.*Vol\./)).toBeOnTheScreen();
206+
});
207+
208+
it('formats large volume values correctly', () => {
209+
const outcome = createMockOutcome({ volume: 5000000 });
210+
211+
const { getByText } = render(
212+
<PredictMarketOutcomeResolved outcome={outcome} />,
213+
);
214+
215+
expect(getByText(/\$5\.0M.*Vol\./)).toBeOnTheScreen();
216+
});
217+
218+
it('formats small volume values correctly', () => {
219+
const outcome = createMockOutcome({ volume: 5000 });
220+
221+
const { getByText } = render(
222+
<PredictMarketOutcomeResolved outcome={outcome} />,
223+
);
224+
225+
expect(getByText(/\$5\.0K.*Vol\./)).toBeOnTheScreen();
226+
});
227+
228+
it('truncates long outcome titles with ellipsis', () => {
229+
const outcome = createMockOutcome({
230+
groupItemTitle:
231+
'This is a very long title that should be truncated with ellipsis',
232+
});
233+
234+
const { getByText } = render(
235+
<PredictMarketOutcomeResolved outcome={outcome} />,
236+
);
237+
238+
const titleElement = getByText(
239+
'This is a very long title that should be truncated with ellipsis',
240+
);
241+
expect(titleElement).toBeOnTheScreen();
242+
expect(titleElement.props.numberOfLines).toBe(1);
243+
expect(titleElement.props.ellipsizeMode).toBe('tail');
244+
});
245+
246+
it('handles very small price differences between tokens', () => {
247+
const outcome = createMockOutcome({
248+
tokens: [
249+
{ id: 'token-yes', title: 'Yes', price: 0.501 },
250+
{ id: 'token-no', title: 'No', price: 0.499 },
251+
],
252+
});
253+
254+
const { getByText } = render(
255+
<PredictMarketOutcomeResolved outcome={outcome} />,
256+
);
257+
258+
expect(getByText('Yes')).toBeOnTheScreen();
259+
});
260+
261+
it('handles price of 1.0 for winning token', () => {
262+
const outcome = createMockOutcome({
263+
tokens: [
264+
{ id: 'token-yes', title: 'Yes', price: 1.0 },
265+
{ id: 'token-no', title: 'No', price: 0.0 },
266+
],
267+
});
268+
269+
const { getByText } = render(
270+
<PredictMarketOutcomeResolved outcome={outcome} />,
271+
);
272+
273+
expect(getByText('Yes')).toBeOnTheScreen();
274+
});
275+
276+
it('handles price of 0.0 for both tokens', () => {
277+
const outcome = createMockOutcome({
278+
tokens: [
279+
{ id: 'token-yes', title: 'Yes', price: 0.0 },
280+
{ id: 'token-no', title: 'No', price: 0.0 },
281+
],
282+
});
283+
284+
const { getByText } = render(
285+
<PredictMarketOutcomeResolved outcome={outcome} />,
286+
);
287+
288+
expect(getByText('Draw')).toBeOnTheScreen();
289+
});
290+
291+
it('calls formatVolume with outcome volume', () => {
292+
const outcome = createMockOutcome({ volume: 1000000 });
293+
294+
render(<PredictMarketOutcomeResolved outcome={outcome} />);
295+
296+
expect(mockFormatVolume).toHaveBeenCalledWith(1000000);
297+
});
298+
299+
it('displays formatted volume in UI', () => {
300+
mockFormatVolume.mockReturnValue('1.5M');
301+
const outcome = createMockOutcome({ volume: 1500000 });
302+
303+
const { getByText } = render(
304+
<PredictMarketOutcomeResolved outcome={outcome} />,
305+
);
306+
307+
expect(getByText(/\$1\.5M.*Vol\./)).toBeOnTheScreen();
308+
});
309+
});
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import {
2+
Box,
3+
BoxAlignItems,
4+
BoxFlexDirection,
5+
BoxJustifyContent,
6+
} from '@metamask/design-system-react-native';
7+
import React from 'react';
8+
import { strings } from '../../../../../../locales/i18n';
9+
import Text, {
10+
TextColor,
11+
TextVariant,
12+
} from '../../../../../component-library/components/Texts/Text';
13+
import Icon, {
14+
IconName,
15+
IconSize,
16+
} from '../../../../../component-library/components/Icons/Icon';
17+
import { PredictOutcome } from '../../types';
18+
import { formatVolume } from '../../utils/format';
19+
20+
interface PredictMarketOutcomeResolvedProps {
21+
outcome: PredictOutcome;
22+
noContainer?: boolean;
23+
}
24+
25+
const PredictMarketOutcomeResolved: React.FC<
26+
PredictMarketOutcomeResolvedProps
27+
> = ({ outcome, noContainer = false }) => {
28+
const tokenOnePrice = outcome.tokens[0].price;
29+
const tokenTwoPrice = outcome.tokens[1].price;
30+
const tokenOneIsWinner = tokenOnePrice > tokenTwoPrice;
31+
const tokenTwoIsWinner = tokenTwoPrice > tokenOnePrice;
32+
33+
const winnerTitle = tokenOneIsWinner
34+
? outcome.tokens[0].title
35+
: tokenTwoIsWinner
36+
? outcome.tokens[1].title
37+
: strings('predict.outcome_draw');
38+
39+
const textColor = tokenOneIsWinner
40+
? TextColor.Default
41+
: TextColor.Alternative;
42+
43+
return (
44+
<Box twClassName={noContainer ? 'pt-2' : ' bg-muted rounded-xl p-4 mb-4'}>
45+
<Box
46+
flexDirection={BoxFlexDirection.Row}
47+
justifyContent={BoxJustifyContent.Between}
48+
alignItems={BoxAlignItems.Center}
49+
twClassName="gap-2"
50+
>
51+
<Box flexDirection={BoxFlexDirection.Column} twClassName="gap-1">
52+
<Text
53+
variant={TextVariant.BodyMDMedium}
54+
color={TextColor.Default}
55+
numberOfLines={1}
56+
ellipsizeMode="tail"
57+
>
58+
{outcome.groupItemTitle}
59+
</Text>
60+
<Text
61+
variant={TextVariant.BodySMMedium}
62+
color={TextColor.Alternative}
63+
>
64+
${formatVolume(outcome.volume)}{' '}
65+
{strings('predict.volume_abbreviated')}
66+
</Text>
67+
</Box>
68+
<Box
69+
flexDirection={BoxFlexDirection.Row}
70+
alignItems={BoxAlignItems.Center}
71+
twClassName="gap-1"
72+
>
73+
<Text variant={TextVariant.BodyMDMedium} color={textColor}>
74+
{winnerTitle}
75+
</Text>
76+
{tokenOneIsWinner && (
77+
<Icon
78+
name={IconName.Confirmation}
79+
size={IconSize.Md}
80+
color={TextColor.Success}
81+
/>
82+
)}
83+
</Box>
84+
</Box>
85+
</Box>
86+
);
87+
};
88+
89+
export default PredictMarketOutcomeResolved;

0 commit comments

Comments
 (0)