Skip to content
Open
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
77 changes: 71 additions & 6 deletions app/components/UI/Predict/hooks/usePredictPlaceOrder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,17 @@ jest.mock('./usePredictBalance');
jest.mock('../../../../../locales/i18n', () => ({
strings: (key: string, options?: Record<string, unknown>) => {
const translations: Record<string, string> = {
'predict.prediction_placed': 'Prediction placed',
'predict.cashed_out': 'Cashed out',
'predict.cashed_out_subtitle': `You claimed $${options?.amount || '0'}`,
'predict.order.placing_prediction': 'Placing a prediction',
'predict.order.prediction_placed': 'Prediction placed',
'predict.order.cashed_out': 'Cashed out',
'predict.order.cashed_out_subtitle': `${
options?.amount || '0'
} added to your balance`,
'predict.order.cashing_out': `Cashing out ${options?.amount || '0'}`,
'predict.order.cashing_out_subtitle': `Estimated ${
options?.time || 5
} seconds`,
'predict.order.order_failed': 'Order failed',
};
return translations[key] || key;
},
Expand Down Expand Up @@ -147,7 +155,22 @@ describe('usePredictPlaceOrder', () => {
await result.current.placeOrder(mockOrderParams);
});

expect(mockToastRef.current?.showToast).toHaveBeenCalledWith(
expect(mockToastRef.current?.showToast).toHaveBeenCalledTimes(2);

// First call - loading toast
expect(mockToastRef.current?.showToast).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
variant: ToastVariants.Icon,
iconName: IconName.Loading,
labelOptions: [{ label: 'Placing a prediction' }],
hasNoTimeout: false,
}),
);

// Second call - success toast
expect(mockToastRef.current?.showToast).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
variant: ToastVariants.Icon,
iconName: IconName.Check,
Expand All @@ -163,6 +186,7 @@ describe('usePredictPlaceOrder', () => {
});

it('shows cashed out toast when SELL order is placed successfully', async () => {
jest.useFakeTimers();
mockPlaceOrder.mockResolvedValue(mockSuccessResult);

const sellOrderParams = {
Expand All @@ -179,7 +203,33 @@ describe('usePredictPlaceOrder', () => {
await result.current.placeOrder(sellOrderParams);
});

expect(mockToastRef.current?.showToast).toHaveBeenCalledWith(
expect(mockToastRef.current?.showToast).toHaveBeenCalledTimes(1);

// First call - loading toast
expect(mockToastRef.current?.showToast).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
variant: ToastVariants.Icon,
iconName: IconName.Loading,
labelOptions: expect.arrayContaining([
expect.objectContaining({
label: expect.stringContaining('Cashing out'),
isBold: true,
}),
]),
hasNoTimeout: false,
}),
);

await act(async () => {
jest.advanceTimersByTime(2000);
});

expect(mockToastRef.current?.showToast).toHaveBeenCalledTimes(2);

// Second call - success toast (after delay)
expect(mockToastRef.current?.showToast).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
variant: ToastVariants.Icon,
iconName: IconName.Check,
Expand All @@ -192,6 +242,8 @@ describe('usePredictPlaceOrder', () => {
hasNoTimeout: false,
}),
);

jest.useRealTimers();
});

it('reloads balance after successful order placement', async () => {
Expand Down Expand Up @@ -300,7 +352,20 @@ describe('usePredictPlaceOrder', () => {
await result.current.placeOrder(mockOrderParams);
});

expect(mockToastRef.current?.showToast).toHaveBeenCalledWith({
expect(mockToastRef.current?.showToast).toHaveBeenCalledTimes(2);

// First call - loading toast
expect(mockToastRef.current?.showToast).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
variant: ToastVariants.Icon,
iconName: IconName.Loading,
hasNoTimeout: false,
}),
);

// Second call - failure toast
expect(mockToastRef.current?.showToast).toHaveBeenNthCalledWith(2, {
variant: ToastVariants.Icon,
iconName: IconName.Loading,
labelOptions: [{ label: 'Order failed' }],
Expand Down
93 changes: 66 additions & 27 deletions app/components/UI/Predict/hooks/usePredictPlaceOrder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,22 +48,27 @@ export function usePredictPlaceOrder(
const { toastRef } = useContext(ToastContext);

const showCashedOutToast = useCallback(
(amount: string) =>
toastRef?.current?.showToast({
variant: ToastVariants.Icon,
iconName: IconName.Check,
labelOptions: [
{ label: strings('predict.cashed_out'), isBold: true },
{ label: '\n', isBold: false },
{
label: strings('predict.cashed_out_subtitle', {
amount,
}),
isBold: false,
},
],
hasNoTimeout: false,
}),
(amount: string) => {
// NOTE: When cashing out happens fast, stacking toasts messes the UX.
// Figure out how toast behavior can be improved to avoid this.
setTimeout(() => {
toastRef?.current?.showToast({
variant: ToastVariants.Icon,
iconName: IconName.Check,
labelOptions: [
{ label: strings('predict.order.cashed_out'), isBold: true },
{ label: '\n', isBold: false },
{
label: strings('predict.order.cashed_out_subtitle', {
amount,
}),
isBold: false,
},
],
hasNoTimeout: false,
});
}, 2000);
},
[toastRef],
);

Expand All @@ -73,7 +78,7 @@ export function usePredictPlaceOrder(
variant: ToastVariants.Icon,
iconName: IconName.Check,
labelOptions: [
{ label: strings('predict.prediction_placed'), isBold: true },
{ label: strings('predict.order.prediction_placed'), isBold: true },
],
hasNoTimeout: false,
}),
Expand All @@ -82,22 +87,56 @@ export function usePredictPlaceOrder(

const placeOrder = useCallback(
async (orderParams: PlaceOrderParams) => {
const {
preview: { minAmountReceived, side },
} = orderParams;

try {
setIsLoading(true);

DevLogger.log('usePredictPlaceOrder: Placing order', orderParams);
if (side === Side.BUY) {
toastRef?.current?.showToast({
variant: ToastVariants.Icon,
iconName: IconName.Loading,
labelOptions: [
{ label: strings('predict.order.placing_prediction') },
],
hasNoTimeout: false,
});
} else {
toastRef?.current?.showToast({
variant: ToastVariants.Icon,
iconName: IconName.Loading,
labelOptions: [
{
label: strings('predict.order.cashing_out', {
amount: formatPrice(minAmountReceived, {
maximumDecimals: 2,
}),
}),
isBold: true,
},
{ label: '\n', isBold: false },
{
label: strings('predict.order.cashing_out_subtitle', {
time: 5,
}),
isBold: false,
},
],
hasNoTimeout: false,
});
}

// Place order using Predict controller
const orderResult = await controllerPlaceOrder(orderParams);
const {
preview: { minAmountReceived, side },
} = orderParams;

if (!orderResult.success) {
toastRef?.current?.showToast({
variant: ToastVariants.Icon,
iconName: IconName.Loading,
labelOptions: [{ label: 'Order failed' }],
labelOptions: [{ label: strings('predict.order.order_failed') }],
hasNoTimeout: false,
});
throw new Error(orderResult.error);
Expand All @@ -106,16 +145,16 @@ export function usePredictPlaceOrder(
// Clear any previous error state
setError(undefined);

onComplete?.(orderResult as Result);

setResult(orderResult as Result);
onComplete?.(orderResult);

if (side === Side.SELL) {
showCashedOutToast(formatPrice(minAmountReceived));
}
setResult(orderResult);

if (side === Side.BUY) {
showOrderPlacedToast();
} else {
showCashedOutToast(
formatPrice(minAmountReceived, { maximumDecimals: 2 }),
);
}

await loadBalance({ isRefresh: true });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -821,12 +821,16 @@ describe('PredictBuyPreview', () => {

const placeBetButton = getByText('Yes • 50¢');

// Even though navigation fails, placeOrder should still be called
// The dispatch now throws and is not caught, so expect the error
expect(() => {
fireEvent.press(placeBetButton);
}).not.toThrow();
}).toThrow('Navigation error');

// PlaceOrder should still be called before dispatch throws
expect(mockPlaceOrder).toHaveBeenCalled();

// Dispatch should have been attempted
expect(mockDispatch).toHaveBeenCalledWith(StackActions.pop());
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -77,19 +77,14 @@ const PredictBuyPreview = () => {
preview?.sharePrice ?? outcomeToken?.price ?? 0,
)}`;

const onPlaceBet = async () => {
const onPlaceBet = () => {
if (!preview) return;

await placeOrder({
placeOrder({
providerId: outcome.providerId,
preview,
});
try {
dispatch(StackActions.pop());
} catch (error) {
// Navigation errors should not prevent the bet from being placed
console.warn('Navigation error after placing bet:', error);
}
dispatch(StackActions.pop());
};

const renderHeader = () => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ const mockFormatPrice = jest.fn();
const mockFormatPercentage = jest.fn();

jest.mock('../../utils/format', () => ({
formatPrice: (value: number, options?: { minimumDecimals?: number }) =>
formatPrice: (value: number, options?: { maximumDecimals?: number }) =>
mockFormatPrice(value, options),
formatPercentage: (value: number) => mockFormatPercentage(value),
}));
Expand Down Expand Up @@ -314,7 +314,7 @@ describe('PredictSellPreview', () => {
});

mockFormatPrice.mockImplementation((value, options) => {
if (options?.minimumDecimals === 2) {
if (options?.maximumDecimals === 2) {
return `$${value.toFixed(2)}`;
}
return `$${value}`;
Expand Down Expand Up @@ -346,7 +346,7 @@ describe('PredictSellPreview', () => {
});

// Component uses preview.minAmountReceived for current value
expect(mockFormatPrice).toHaveBeenCalledWith(60, { minimumDecimals: 2 });
expect(mockFormatPrice).toHaveBeenCalledWith(60, { maximumDecimals: 2 });
// PnL is calculated from position data, not preview
expect(mockFormatPercentage).toHaveBeenCalledWith(20);
});
Expand Down Expand Up @@ -485,12 +485,16 @@ describe('PredictSellPreview', () => {

const cashOutButton = getByTestId('button-secondary');

// Even though navigation fails, placeOrder should still be called and no error should be thrown
// The dispatch now throws and is not caught, so expect the error
expect(() => {
fireEvent.press(cashOutButton);
}).not.toThrow();
}).toThrow('Navigation error');

// PlaceOrder should still be called before dispatch throws
expect(mockPlaceOrder).toHaveBeenCalled();

// Dispatch should have been attempted
expect(mockDispatch).toHaveBeenCalledWith(StackActions.pop());
});
});

Expand Down
Loading
Loading