Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
7 changes: 7 additions & 0 deletions app/components/UI/Rewards/Views/MusdCalculatorView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ describe('MusdCalculatorView', () => {
expect(getByTestId('musd-calculator-tab')).toBeOnTheScreen();
});

it('wraps the calculator in a keyboard avoiding view', () => {
const { getByTestId } = render(<MusdCalculatorView />);
expect(
getByTestId('musd-calculator-keyboard-avoiding-view'),
).toBeOnTheScreen();
});

it('tracks REWARDS_PAGE_VIEWED on mount with page_type musd_calculator', () => {
render(<MusdCalculatorView />);

Expand Down
10 changes: 9 additions & 1 deletion app/components/UI/Rewards/Views/MusdCalculatorView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import { HeaderStandard } from '@metamask/design-system-react-native';
import { useNavigation } from '@react-navigation/native';
import { KeyboardAvoidingView, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import ErrorBoundary from '../../../Views/ErrorBoundary';
Expand All @@ -25,7 +26,14 @@ const MusdCalculatorView: React.FC = () => {
onBack={() => navigation.goBack()}
backButtonProps={{ testID: 'header-back-button' }}
/>
<MusdCalculatorTab />
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
style={tw.style('flex-1')}
testID="musd-calculator-keyboard-avoiding-view"
>
<MusdCalculatorTab />
</KeyboardAvoidingView>
</SafeAreaView>
</ErrorBoundary>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,47 @@
import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react-native';
import { render, fireEvent, waitFor, act } from '@testing-library/react-native';
import MusdCalculatorTab from './MusdCalculatorTab';
import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics';
import { createMockUseAnalyticsHook } from '../../../../../../util/test/analyticsMock';
import { MetaMetricsEvents } from '../../../../../../core/Analytics';
import { amountToPercent } from '../../../utils/musdCalculatorSlider';

const mockPanGestureHandlers: {
onBegin?: (event: { x: number }) => void;
onUpdate?: (event: { x: number }) => void;
onFinalize?: (event: { x: number }) => void;
} = {};

jest.mock('react-native-gesture-handler', () => ({
GestureHandlerRootView: jest.requireActual('react-native').View,
GestureDetector: ({ children }: { children: React.ReactNode }) => children,
Gesture: {
Pan: jest.fn(() => ({
minDistance: jest.fn().mockReturnThis(),
onBegin: jest.fn(function (
this: unknown,
handler: (event: { x: number }) => void,
) {
mockPanGestureHandlers.onBegin = handler;
return this;
}),
onUpdate: jest.fn(function (
this: unknown,
handler: (event: { x: number }) => void,
) {
mockPanGestureHandlers.onUpdate = handler;
return this;
}),
onFinalize: jest.fn(function (
this: unknown,
handler: (event: { x: number }) => void,
) {
mockPanGestureHandlers.onFinalize = handler;
return this;
}),
})),
},
}));

const mockGoToSwaps = jest.fn();
const mockTrackEvent = jest.fn();
Expand All @@ -22,6 +60,13 @@ jest.mock('../../../../../../../locales/i18n', () => ({
strings: jest.fn((key: string) => key),
}));

jest.mock('../../../../SimulationDetails/FiatDisplay/useFiatFormatter', () =>
jest.fn(
() => (value: { toNumber: () => number }) =>
`$${value.toNumber().toLocaleString('en-US')}`,
),
);

jest.mock('../../../../../../core/DeeplinkManager', () => ({
handleDeeplink: jest.fn(),
}));
Expand All @@ -43,6 +88,9 @@ jest.mock('../../../../../../util/theme', () => {
describe('MusdCalculatorTab', () => {
beforeEach(() => {
jest.clearAllMocks();
mockPanGestureHandlers.onBegin = undefined;
mockPanGestureHandlers.onUpdate = undefined;
mockPanGestureHandlers.onFinalize = undefined;
jest.mocked(useAnalytics).mockReturnValue(
createMockUseAnalyticsHook({
trackEvent: mockTrackEvent,
Expand All @@ -51,28 +99,28 @@ describe('MusdCalculatorTab', () => {
);
});

it('renders all calculator UI elements', () => {
const { getByText } = render(<MusdCalculatorTab />);

expect(getByText('rewards.musd.title')).toBeTruthy();
expect(getByText('rewards.musd.description')).toBeTruthy();
expect(getByText('rewards.musd.amount_label')).toBeTruthy();
expect(getByText('rewards.musd.estimated_bonus')).toBeTruthy();
expect(getByText('rewards.musd.initial_amount')).toBeTruthy();
expect(getByText('rewards.musd.daily_bonus')).toBeTruthy();
expect(getByText('rewards.musd.annualized_bonus')).toBeTruthy();
expect(getByText('rewards.musd.disclaimer_brief')).toBeTruthy();
expect(getByText('rewards.musd.buy_button')).toBeTruthy();
expect(getByText('rewards.musd.swap_button')).toBeTruthy();
it('renders calculator layout and controls', () => {
const { getByText, getByTestId } = render(<MusdCalculatorTab />);

expect(getByText('rewards.musd.hero_hold')).toBeOnTheScreen();
expect(getByText('rewards.musd.hero_earn')).toBeOnTheScreen();
expect(getByText('rewards.musd.slider_amount_label')).toBeOnTheScreen();
expect(getByText('rewards.musd.disclaimer_calculator')).toBeOnTheScreen();
expect(getByTestId('musd-slider-scale-min')).toHaveTextContent('$100');
expect(getByTestId('musd-slider-scale-mid')).toHaveTextContent('$1,000');
expect(getByTestId('musd-slider-scale-max')).toHaveTextContent('$10,000');
expect(getByText('rewards.musd.buy_button')).toBeOnTheScreen();
expect(getByText('rewards.musd.swap_button')).toBeOnTheScreen();
expect(getByTestId('musd-slider-track')).toBeOnTheScreen();
});

it('calls handleDeeplink and tracks buy_musd event when Buy button is pressed', () => {
const { handleDeeplink } = jest.requireMock(
'../../../../../../core/DeeplinkManager',
);

const { getByText } = render(<MusdCalculatorTab />);
fireEvent.press(getByText('rewards.musd.buy_button'));
const { getByTestId } = render(<MusdCalculatorTab />);
fireEvent.press(getByTestId('musd-buy-button'));

expect(handleDeeplink).toHaveBeenCalledWith({
uri: expect.stringContaining('link.metamask.io/buy'),
Expand All @@ -87,8 +135,8 @@ describe('MusdCalculatorTab', () => {
});

it('navigates to swap screen and tracks swap_to_musd event when Swap button is pressed', () => {
const { getByText } = render(<MusdCalculatorTab />);
fireEvent.press(getByText('rewards.musd.swap_button'));
const { getByTestId } = render(<MusdCalculatorTab />);
fireEvent.press(getByTestId('musd-swap-button'));

expect(mockGoToSwaps).toHaveBeenCalled();
expect(mockCreateEventBuilder).toHaveBeenCalledWith(
Expand All @@ -100,56 +148,141 @@ describe('MusdCalculatorTab', () => {
expect(mockTrackEvent).toHaveBeenCalled();
});

it('updates input value when amount changes', () => {
const { getByTestId } = render(<MusdCalculatorTab />);
it('updates amount and earnings when the track is pressed', async () => {
const { getByTestId, getByText, queryByText } = render(
<MusdCalculatorTab />,
);
const track = getByTestId('musd-slider-track');

fireEvent(track, 'layout', {
nativeEvent: { layout: { width: 300, height: 32, x: 0, y: 0 } },
});

const input = getByTestId('musd-amount-input');
fireEvent.changeText(input, '5000');
const locationX = (amountToPercent(5000) / 100) * 300;
fireEvent(track, 'pressIn', {
nativeEvent: { locationX },
});

expect(input.props.value).toBe('5000');
await waitFor(() => {
expect(getByTestId('musd-slider-amount-display')).toHaveProp(
'value',
'$5,000',
);
expect(
getByText(/rewards\.musd\.earnings_per_day_suffix/),
).toBeOnTheScreen();
expect(
queryByText(/rewards\.musd\.earnings_per_month_suffix/),
).toBeNull();
expect(getByText(/\$0\.41/)).toBeOnTheScreen();
expect(getByText(/\$150/)).toBeOnTheScreen();
});
});

it('sanitizes non-numeric input', () => {
const { getByTestId } = render(<MusdCalculatorTab />);
it('allows editing the amount and updates earnings from typed values', async () => {
const { getByTestId, getByText } = render(<MusdCalculatorTab />);
const amountInput = getByTestId('musd-slider-amount-display');

fireEvent(amountInput, 'focus');
fireEvent.changeText(amountInput, '5000');

await waitFor(() => {
expect(amountInput).toHaveProp('value', '5000');
expect(getByText(/\$150/)).toBeOnTheScreen();
});

const input = getByTestId('musd-amount-input');
fireEvent.changeText(input, 'abc123def');
fireEvent(amountInput, 'endEditing');

expect(input.props.value).toBe('123');
await waitFor(() => {
expect(amountInput).toHaveProp('value', '$5,000');
});
});

it('allows decimal input', () => {
const { getByTestId } = render(<MusdCalculatorTab />);
it('accepts amounts over the slider maximum', async () => {
const { getByTestId, getByText } = render(<MusdCalculatorTab />);
const amountInput = getByTestId('musd-slider-amount-display');

fireEvent(amountInput, 'focus');
fireEvent.changeText(amountInput, '12000');

const input = getByTestId('musd-amount-input');
fireEvent.changeText(input, '1000.50');
await waitFor(() => {
expect(amountInput).toHaveProp('value', '12000');
expect(getByText(/\$360/)).toBeOnTheScreen();
});
});

it('normalizes decorated decimal input while editing', async () => {
const { getByTestId, getByText } = render(<MusdCalculatorTab />);
const amountInput = getByTestId('musd-slider-amount-display');

fireEvent(amountInput, 'focus');
fireEvent.changeText(amountInput, '$1,234.56.78');

await waitFor(() => {
expect(amountInput).toHaveProp('value', '1234.5678');
expect(getByText(/\$37\.037/)).toBeOnTheScreen();
});
});

it('treats invalid numeric input as zero', async () => {
const { getByTestId, getAllByText } = render(<MusdCalculatorTab />);
const amountInput = getByTestId('musd-slider-amount-display');

expect(input.props.value).toBe('1000.50');
fireEvent(amountInput, 'focus');
fireEvent.changeText(amountInput, '.');

await waitFor(() => {
expect(amountInput).toHaveProp('value', '.');
expect(getAllByText(/\$0/).length).toBeGreaterThan(0);
});
});

it('rejects multiple decimal points', () => {
it('ignores slider presses before the track is measured', () => {
const { getByTestId } = render(<MusdCalculatorTab />);

const input = getByTestId('musd-amount-input');
fireEvent.changeText(input, '1000.50');
fireEvent.changeText(input, '1000.50.25');
fireEvent(getByTestId('musd-slider-track'), 'pressIn', {
nativeEvent: { locationX: 200 },
});

expect(input.props.value).toBe('1000.50');
expect(getByTestId('musd-slider-amount-display')).toHaveProp(
'value',
'$1,000',
);
});

it('calculates correct bonus values for $5000 input', async () => {
const { getByTestId, getByText } = render(<MusdCalculatorTab />);
it('updates amount through pan gesture handlers', async () => {
const { getByTestId } = render(<MusdCalculatorTab />);
const track = getByTestId('musd-slider-track');

const input = getByTestId('musd-amount-input');
fireEvent.changeText(input, '5000');
fireEvent(track, 'layout', {
nativeEvent: { layout: { width: 300, height: 32, x: 0, y: 0 } },
});

act(() => {
mockPanGestureHandlers.onBegin?.({ x: 150 });
mockPanGestureHandlers.onUpdate?.({ x: 300 });
mockPanGestureHandlers.onFinalize?.({ x: 300 });
});

// Initial amount: $5,000.00
// Daily bonus: $5000 * 0.03 / 365 = $0.41
// Annualized bonus: $5000 * 0.03 = $150.00
await waitFor(() => {
expect(getByText('$5,000')).toBeOnTheScreen();
expect(getByText('$0.41')).toBeOnTheScreen();
expect(getByText('$150')).toBeOnTheScreen();
expect(getByTestId('musd-slider-amount-display')).toHaveProp(
'value',
'$10,000',
);
});
});

it('ignores pan gesture handlers before the track is measured', () => {
const { getByTestId } = render(<MusdCalculatorTab />);

act(() => {
mockPanGestureHandlers.onBegin?.({ x: 150 });
mockPanGestureHandlers.onUpdate?.({ x: 300 });
});

expect(getByTestId('musd-slider-amount-display')).toHaveProp(
'value',
'$1,000',
);
});
});
Loading
Loading