Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,80 @@ describe('PredictActionButtons', () => {
expect(screen.getAllByText('35¢')).toHaveLength(1);
});

it('uses token-matched teams when a game moneyline returns home-away tokens', () => {
const outcome = createMockOutcome({
sportsMarketType: 'moneyline',
tokens: [
{
id: 'token-ivashka',
title: 'Ilya Ivashka',
shortTitle: 'IVASHKA',
price: 0.63,
},
{
id: 'token-stewart',
title: 'Hamish Stewart',
shortTitle: 'STEWART',
price: 0.38,
},
],
});
const market = createMockMarket({
outcomes: [outcome],
game: {
id: 'game-atp-1',
startTime: '2026-05-22T07:30:00Z',
status: 'scheduled',
league: 'atp',
elapsed: null,
period: null,
score: null,
awayTeam: {
id: 'stewart',
name: 'Hamish Stewart',
logo: 'https://example.com/stewart.png',
abbreviation: 'STEWART',
color: TEST_HEX_COLORS.TEAM_SEA,
alias: 'H. Stewart',
},
homeTeam: {
id: 'ivashka',
name: 'Ilya Ivashka',
logo: 'https://example.com/ivashka.png',
abbreviation: 'IVASHKA',
color: TEST_HEX_COLORS.TEAM_DEN,
alias: 'I. Ivashka',
},
},
});

const mockOnBetPress = jest.fn();
const props = createDefaultProps({
market,
outcome,
onBetPress: mockOnBetPress,
});

renderWithProvider(<PredictActionButtons {...props} />);

expect(screen.getByText('IVASHKA')).toBeOnTheScreen();
expect(screen.getByText('STEWART')).toBeOnTheScreen();
expect(screen.getAllByText('63¢')).toHaveLength(1);
expect(screen.getAllByText('38¢')).toHaveLength(1);

fireEvent.press(screen.getByTestId('action-buttons-bet-yes'));
fireEvent.press(screen.getByTestId('action-buttons-bet-no'));

expect(mockOnBetPress).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ id: 'token-ivashka' }),
);
expect(mockOnBetPress).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ id: 'token-stewart' }),
);
});

it('calls onBetPress with correct token for away team', () => {
const mockOnBetPress = jest.fn();
const outcome = createMockOutcome();
Expand Down Expand Up @@ -379,6 +453,49 @@ describe('PredictActionButtons', () => {
expect.objectContaining({ id: 'token-draw' }),
);
});

it('ignores extended non-moneyline outcomes for draw-capable leagues', () => {
const market = createMockDrawCapableGameMarket();
const [awayOutcome, drawOutcome, homeOutcome] = market.outcomes;
const extendedMarket = {
...market,
outcomes: [
createMockOutcome({
id: 'outcome-spread',
sportsMarketType: 'spreads',
groupItemThreshold: -2.5,
tokens: [{ id: 'token-spread', title: 'Spread', price: 0.16 }],
}),
{ ...awayOutcome, sportsMarketType: 'moneyline' },
createMockOutcome({
id: 'outcome-halftime',
sportsMarketType: 'soccer_halftime_result',
groupItemThreshold: 1,
tokens: [{ id: 'token-halftime', title: 'Draw', price: 0.2 }],
}),
{ ...drawOutcome, sportsMarketType: 'moneyline' },
{ ...homeOutcome, sportsMarketType: 'moneyline' },
],
};

const props = createDefaultProps({
market: extendedMarket,
outcome: extendedMarket.outcomes[0],
});

renderWithProvider(<PredictActionButtons {...props} />);

expect(screen.getByText('ARS')).toBeOnTheScreen();
expect(screen.getByText('DRAW')).toBeOnTheScreen();
expect(screen.getByText('PSG')).toBeOnTheScreen();
expect(screen.getAllByText('42¢')).toHaveLength(1);
expect(screen.getAllByText('30¢')).toHaveLength(1);
expect(screen.getAllByText('28¢')).toHaveLength(1);
expect(mockUseLiveMarketPrices).toHaveBeenCalledWith(
['token-home', 'token-draw', 'token-away'],
{ enabled: true },
);
});
});

describe('priority order', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,16 @@ import {
PredictActionButtonsProps,
PredictBetButtonLayout,
} from './PredictActionButtons.types';
import { PredictMarketStatus, PredictOutcomeToken } from '../../types';
import {
PredictMarketGame,
PredictMarketStatus,
PredictOutcomeToken,
} from '../../types';
import { useLiveMarketPrices } from '../../hooks/useLiveMarketPrices';
import { isDrawCapableLeague } from '../../constants/sports';
import {
getPrimaryMoneylineOutcomes,
isDrawCapableLeague,
} from '../../constants/sports';
import {
BASE_PREDICT_ACTION_BUTTONS_TEST_IDS,
PREDICT_ACTION_BUTTONS_TEST_IDS,
Expand All @@ -29,6 +36,38 @@ interface ButtonConfig {
drawToken?: PredictOutcomeToken;
}

type GameTeam = PredictMarketGame['homeTeam'];

const normalizeLabel = (value?: string): string | undefined =>
value?.trim().toLowerCase();

const teamMatchesToken = (
team: GameTeam,
token: PredictOutcomeToken,
): boolean => {
const tokenLabels = [token.shortTitle, token.title]
.map(normalizeLabel)
.filter((label): label is string => Boolean(label));
const teamLabels = [team.abbreviation, team.name, team.alias]
.map(normalizeLabel)
.filter((label): label is string => Boolean(label));

return tokenLabels.some((tokenLabel) => teamLabels.includes(tokenLabel));
};

const getTokenTeam = (
token: PredictOutcomeToken,
game: PredictMarketGame,
): GameTeam | undefined => {
if (teamMatchesToken(game.homeTeam, token)) {
return game.homeTeam;
}
if (teamMatchesToken(game.awayTeam, token)) {
return game.awayTeam;
}
return undefined;
};

const PredictActionButtons: React.FC<PredictActionButtonsProps> = ({
market,
outcome,
Expand All @@ -45,21 +84,33 @@ const PredictActionButtons: React.FC<PredictActionButtonsProps> = ({
}) => {
const isGameMarket = Boolean(market.game);
const isMarketOpen = market.status === PredictMarketStatus.OPEN;
const moneylineOutcomes = useMemo(
() => getPrimaryMoneylineOutcomes(market.outcomes),
[market.outcomes],
);
const hasMainMoneylineOutcomes = moneylineOutcomes.some(
(marketOutcome) =>
marketOutcome.sportsMarketType?.toLowerCase() === 'moneyline',
);
const primaryOutcome =
hasMainMoneylineOutcomes && !moneylineOutcomes.includes(outcome)
? (moneylineOutcomes[0] ?? outcome)
: outcome;

const isDrawCapable =
isGameMarket &&
market.game &&
isDrawCapableLeague(market.game.league) &&
market.outcomes.length >= 3;
moneylineOutcomes.length >= 3;

const sortedOutcomes = useMemo(() => {
if (!isDrawCapable) {
return null;
}
return [...market.outcomes].sort(
return [...moneylineOutcomes].sort(
(a, b) => (a.groupItemThreshold ?? 0) - (b.groupItemThreshold ?? 0),
);
}, [isDrawCapable, market.outcomes]);
}, [isDrawCapable, moneylineOutcomes]);

const tokenIds = useMemo(() => {
if (sortedOutcomes) {
Expand All @@ -68,8 +119,8 @@ const PredictActionButtons: React.FC<PredictActionButtonsProps> = ({
.filter((tokenId): tokenId is string => Boolean(tokenId));
}

return outcome.tokens.map((token) => token.id);
}, [sortedOutcomes, outcome.tokens]);
return primaryOutcome.tokens.map((token) => token.id);
}, [sortedOutcomes, primaryOutcome.tokens]);

const { getPrice } = useLiveMarketPrices(tokenIds, {
enabled: isMarketOpen && !isLoading,
Expand Down Expand Up @@ -110,7 +161,7 @@ const PredictActionButtons: React.FC<PredictActionButtonsProps> = ({
};
}

const tokens = outcome.tokens;
const tokens = primaryOutcome.tokens;
if (tokens.length < 2) {
return null;
}
Expand All @@ -126,14 +177,17 @@ const PredictActionButtons: React.FC<PredictActionButtonsProps> = ({

if (isGameMarket && market.game) {
const { awayTeam, homeTeam } = market.game;
const yesTeam = getTokenTeam(yesToken, market.game) ?? awayTeam;
const noTeam = getTokenTeam(noToken, market.game) ?? homeTeam;

return {
yesLabel: awayTeam.abbreviation,
yesLabel: yesTeam.abbreviation,
yesPrice: Math.round(yesPrice * 100),
yesTeamColor: awayTeam.color,
yesTeamColor: yesTeam.color,
yesToken,
noLabel: homeTeam.abbreviation,
noLabel: noTeam.abbreviation,
noPrice: Math.round(noPrice * 100),
noTeamColor: homeTeam.color,
noTeamColor: noTeam.color,
noToken,
};
}
Expand All @@ -148,7 +202,13 @@ const PredictActionButtons: React.FC<PredictActionButtonsProps> = ({
noTeamColor: undefined,
noToken,
};
}, [outcome.tokens, isGameMarket, market.game, sortedOutcomes, getPrice]);
}, [
primaryOutcome.tokens,
isGameMarket,
market.game,
sortedOutcomes,
getPrice,
]);

if (isLoading) {
return (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
export const CHART_HEIGHT = 200;
export const TIMEFRAME_SELECTOR_RESERVED_HEIGHT = 56;
export const CHART_WITH_TIMEFRAME_SELECTOR_HEIGHT =
CHART_HEIGHT + TIMEFRAME_SELECTOR_RESERVED_HEIGHT;
export const FONT_SIZE_LABEL = 14;
export const FONT_SIZE_VALUE = 24;
export const LABEL_HEIGHT = 40;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import renderWithProvider from '../../../../../util/test/renderWithProvider';
import PredictGameChartContent from './PredictGameChartContent';
import { GameChartSeries } from './PredictGameChart.types';
import { TEST_HEX_COLORS } from '../../testUtils/mockColors';
import { CHART_WITH_TIMEFRAME_SELECTOR_HEIGHT } from './PredictGameChart.constants';

jest.mock('react-native-svg-charts', () => {
const { View, Text } = jest.requireActual('react-native');
Expand Down Expand Up @@ -313,6 +314,35 @@ describe('PredictGameChartContent (Chart UI)', () => {

expect(getByText('Live')).toBeOnTheScreen();
});

it('reserves chart height only while loading', () => {
const onTimeframeChange = jest.fn();

const { getByTestId, rerender } = renderWithProvider(
<PredictGameChartContent
data={[]}
isLoading
onTimeframeChange={onTimeframeChange}
testID="chart"
/>,
);

expect(getByTestId('chart')).toHaveStyle({
minHeight: CHART_WITH_TIMEFRAME_SELECTOR_HEIGHT,
});

rerender(
<PredictGameChartContent
data={mockDualSeriesData}
onTimeframeChange={onTimeframeChange}
testID="chart"
/>,
);

expect(getByTestId('chart')).not.toHaveStyle({
minHeight: CHART_WITH_TIMEFRAME_SELECTOR_HEIGHT,
});
});
});

describe('Data Processing', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import React, {
import { PredictGameStatus, PredictPriceHistoryInterval } from '../../types';
import { usePredictPriceHistory } from '../../hooks/usePredictPriceHistory';
import { useLiveMarketPrices } from '../../hooks/useLiveMarketPrices';
import { isDrawCapableLeague } from '../../constants/sports';
import {
getPrimaryMoneylineOutcomes,
isDrawCapableLeague,
} from '../../constants/sports';
import { useTheme } from '../../../../../util/theme';
import PredictGameChartContent from './PredictGameChartContent';
import {
Expand Down Expand Up @@ -72,27 +75,31 @@ const PredictGameChart: React.FC<PredictGameChartProps> = ({
const gameStatus = game?.status;
const isGameEnded = gameStatus === 'ended';
const isGameOngoing = gameStatus === 'ongoing';
const moneylineOutcomes = useMemo(
() => getPrimaryMoneylineOutcomes(market.outcomes),
[market.outcomes],
);

const tokenIds = useMemo(() => {
if (
game?.league &&
isDrawCapableLeague(game.league) &&
market.outcomes.length >= 3
moneylineOutcomes.length >= 3
) {
return [...market.outcomes]
return [...moneylineOutcomes]
.sort(
(a, b) => (a.groupItemThreshold ?? 0) - (b.groupItemThreshold ?? 0),
)
.map((o) => o.tokens[0]?.id)
.filter((id): id is string => Boolean(id));
}
const tokens = market.outcomes[0]?.tokens ?? [];
const tokens = moneylineOutcomes[0]?.tokens ?? [];
return tokens.map((t) => t.id);
}, [market.outcomes, game?.league]);
}, [moneylineOutcomes, game?.league]);

const seriesConfig: GameChartSeriesConfig[] | null = useMemo(() => {
if (!game) return null;
if (isDrawCapableLeague(game.league) && market.outcomes.length >= 3) {
if (isDrawCapableLeague(game.league) && moneylineOutcomes.length >= 3) {
return [
{ label: game.homeTeam.abbreviation, color: game.homeTeam.color },
{ label: 'DRAW', color: colors.icon.muted },
Expand All @@ -103,7 +110,7 @@ const PredictGameChart: React.FC<PredictGameChartProps> = ({
{ label: game.awayTeam.abbreviation, color: game.awayTeam.color },
{ label: game.homeTeam.abbreviation, color: game.homeTeam.color },
];
}, [game, market.outcomes.length, colors.icon.muted]);
}, [game, moneylineOutcomes.length, colors.icon.muted]);

const [timeframe, setTimeframe] = useState<ChartTimeframe>(() =>
getDefaultTimeframe(gameStatus),
Expand Down
Loading
Loading