Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
Binary file added app/animations/next_best_action_module_v1.riv
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,13 @@ const StepperCard = ({
{/* Image */}
<Box twClassName="p-4">
<Box testID={getTestId('step-image')} twClassName="w-full aspect-video">
<Image
source={step.image}
style={tw.style('w-full h-full')}
resizeMode="contain"
/>
{step.media ?? (
<Image
source={step.image}
style={tw.style('w-full h-full')}
resizeMode="contain"
/>
)}
</Box>
</Box>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ReactNode } from 'react';
import { ImageSourcePropType } from 'react-native';

export interface StepperCardCta {
Expand All @@ -20,6 +21,11 @@ export interface StepperCardStep {
*/
descriptionTooltipAccessibilityLabel?: string;
image: ImageSourcePropType;
/**
* Optional custom media rendered in the image slot in place of `image`
* (e.g. an animated graphic). Falls back to `image` when not provided.
*/
media?: ReactNode;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see the media being rendered in the StepperCard. It's only being used as a condition. Is that intended?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StepperCard.tsx:60

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not as a condition, but as a ??, so if media exists, it will use it, otherwise fallback to Image

primaryCta: StepperCardCta;
secondaryCta?: StepperCardCta;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@ jest.mock('../../hooks/useMoneyEarnableTokens', () => ({
useMoneyEarnableTokens: () => mockUseMoneyEarnableTokens(),
}));

// Animated Rive graphic pulls in device sensors; not exercised by these tests.
jest.mock('../../components/MoneyNextBestActionParallax', () => ({
__esModule: true,
default: () => null,
PARALLAX_ARTBOARD_FUND: 'Parallax Block 1',
PARALLAX_ARTBOARD_CARD: 'Parallax Block 2',
}));

jest.mock('../../hooks/useMoneyAccountTransactions', () => ({
useMoneyAccountTransactions: jest.fn(),
}));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { StyleSheet } from 'react-native';

const styles = StyleSheet.create({
media: {
width: '100%',
height: '100%',
},
});

export default styles;
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import React from 'react';
import { render, act } from '@testing-library/react-native';
import MoneyNextBestActionParallax from './MoneyNextBestActionParallax';
import { MoneyNextBestActionParallaxTestIds } from './MoneyNextBestActionParallax.testIds';
import { useReduceMotion } from '../../hooks/useReduceMotion';
import { useDeviceOrientation } from '../../hooks/useDeviceOrientation';
import fallbackImage from '../../../../../images/money-onboarding-stepper-step-1.png';

const mockSetXValue = jest.fn();
const mockSetYValue = jest.fn();
const mockRefCallback = jest.fn();
const mockRiveInstance = {};
const mockOnErrorRef: { current?: (error: { message: string }) => void } = {};
const mockRiveProps: { current?: { artboardName?: string } } = {};

jest.mock('rive-react-native', () => {
const ReactActual = jest.requireActual('react');
const { View: RNView } = jest.requireActual('react-native');
return {
__esModule: true,
AutoBind: jest.fn(() => ({})),
Fit: { Contain: 'contain' },
useRive: () => [mockRefCallback, mockRiveInstance],
useRiveNumber: (_instance: unknown, path: string) => [
undefined,
path === 'xValue' ? mockSetXValue : mockSetYValue,
],
default: (props: {
testID?: string;
artboardName?: string;
onError?: (error: { message: string }) => void;
}) => {
mockOnErrorRef.current = props.onError;
mockRiveProps.current = { artboardName: props.artboardName };
return ReactActual.createElement(RNView, { testID: props.testID });
},
};
});

const mockUseSelector = jest.fn();
jest.mock('react-redux', () => ({
useSelector: (selector: unknown) => mockUseSelector(selector),
}));

jest.mock('../../hooks/useReduceMotion', () => ({
useReduceMotion: jest.fn(),
}));

jest.mock('../../hooks/useDeviceOrientation', () => ({
useDeviceOrientation: jest.fn(),
}));

const mockUseReduceMotion = useReduceMotion as jest.Mock;
const mockUseDeviceOrientation = useDeviceOrientation as jest.Mock;

describe('MoneyNextBestActionParallax', () => {
beforeEach(() => {
jest.clearAllMocks();
mockOnErrorRef.current = undefined;
mockRiveProps.current = undefined;
mockUseSelector.mockReturnValue(true);
mockUseReduceMotion.mockReturnValue(false);
});

it('renders the Rive animation when enabled and reduce motion is off', () => {
const { getByTestId, queryByTestId } = render(
<MoneyNextBestActionParallax
artboardName="Parallax Block 1"
fallbackImage={fallbackImage}
/>,
);

expect(
getByTestId(MoneyNextBestActionParallaxTestIds.RIVE),
).toBeOnTheScreen();
expect(
queryByTestId(MoneyNextBestActionParallaxTestIds.STATIC_IMAGE),
).toBeNull();
});

it('renders the gradient background behind the Rive when animating', () => {
const { getByTestId } = render(
<MoneyNextBestActionParallax
artboardName="Parallax Block 1"
fallbackImage={fallbackImage}
/>,
);

expect(
getByTestId(MoneyNextBestActionParallaxTestIds.BACKGROUND),
).toBeOnTheScreen();
});

it('passes the given artboard name through to Rive', () => {
render(
<MoneyNextBestActionParallax
artboardName="Parallax Block 2"
fallbackImage={fallbackImage}
/>,
);

expect(mockRiveProps.current?.artboardName).toBe('Parallax Block 2');
});

it('does not render the gradient background behind the fallback image', () => {
mockUseReduceMotion.mockReturnValue(true);

const { queryByTestId } = render(
<MoneyNextBestActionParallax
artboardName="Parallax Block 1"
fallbackImage={fallbackImage}
/>,
);

expect(
queryByTestId(MoneyNextBestActionParallaxTestIds.BACKGROUND),
).toBeNull();
});

it('renders the fallback image when the feature flag is disabled', () => {
mockUseSelector.mockReturnValue(false);

const { getByTestId, queryByTestId } = render(
<MoneyNextBestActionParallax
artboardName="Parallax Block 1"
fallbackImage={fallbackImage}
/>,
);

expect(
getByTestId(MoneyNextBestActionParallaxTestIds.STATIC_IMAGE),
).toBeOnTheScreen();
expect(queryByTestId(MoneyNextBestActionParallaxTestIds.RIVE)).toBeNull();
});

it('renders the fallback image (with the provided source) when reduce motion is enabled', () => {
mockUseReduceMotion.mockReturnValue(true);

const { getByTestId, queryByTestId } = render(
<MoneyNextBestActionParallax
artboardName="Parallax Block 1"
fallbackImage={fallbackImage}
/>,
);

expect(
getByTestId(MoneyNextBestActionParallaxTestIds.STATIC_IMAGE).props.source,
).toBe(fallbackImage);
expect(queryByTestId(MoneyNextBestActionParallaxTestIds.RIVE)).toBeNull();
});

it('enables the device tilt callback when animating', () => {
render(
<MoneyNextBestActionParallax
artboardName="Parallax Block 1"
fallbackImage={fallbackImage}
/>,
);

expect(mockUseDeviceOrientation).toHaveBeenCalledWith(
expect.any(Function),
{
enabled: true,
},
);
});

it('disables the device tilt callback when not animating', () => {
mockUseReduceMotion.mockReturnValue(true);

render(
<MoneyNextBestActionParallax
artboardName="Parallax Block 1"
fallbackImage={fallbackImage}
/>,
);

expect(mockUseDeviceOrientation).toHaveBeenCalledWith(
expect.any(Function),
{
enabled: false,
},
);
});

it('drives the bound Rive number properties from mapped tilt values', () => {
render(
<MoneyNextBestActionParallax
artboardName="Parallax Block 1"
fallbackImage={fallbackImage}
/>,
);

const applyTilt = mockUseDeviceOrientation.mock.calls[0][0] as (
x: number,
y: number,
) => void;

act(() => applyTilt(0.5, -0.5));

expect(mockSetXValue).toHaveBeenCalledWith(75);
expect(mockSetYValue).toHaveBeenCalledWith(25);
});

it('falls back to the static image when Rive reports an error', () => {
const { getByTestId, queryByTestId } = render(
<MoneyNextBestActionParallax
artboardName="Parallax Block 1"
fallbackImage={fallbackImage}
/>,
);

act(() => mockOnErrorRef.current?.({ message: 'boom' }));

expect(
getByTestId(MoneyNextBestActionParallaxTestIds.STATIC_IMAGE),
).toBeOnTheScreen();
expect(queryByTestId(MoneyNextBestActionParallaxTestIds.RIVE)).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const MoneyNextBestActionParallaxTestIds = {
CONTAINER: 'money-next-best-action-parallax-container',
RIVE: 'money-next-best-action-parallax-rive',
BACKGROUND: 'money-next-best-action-parallax-background',
STATIC_IMAGE: 'money-next-best-action-parallax-static-image',
} as const;
Loading
Loading