react-native-reanimated
Version:
More powerful alternative to Animated library for React Native.
357 lines (309 loc) • 9.77 kB
text/typescript
/* eslint-disable @typescript-eslint/no-namespace */
;
import type { ReactTestInstance } from 'react-test-renderer';
import type {
AnimatedComponentProps,
AnimatedProps,
IAnimatedComponentInternal,
InitialComponentProps,
} from './createAnimatedComponent/commonTypes';
import { ReanimatedError } from './errors';
import type { DefaultStyle } from './hook/commonTypes';
import { isJest } from './PlatformChecker';
declare global {
namespace jest {
interface Matchers<R> {
toHaveAnimatedStyle(
style: Record<string, unknown>[] | Record<string, unknown>,
config?: {
shouldMatchAllProps?: boolean;
}
): R;
toHaveAnimatedProps(props: Record<string, unknown>): R;
}
}
}
const defaultFramerateConfig = {
fps: 60,
};
const isEmpty = (obj: object | undefined) =>
!obj || Object.keys(obj).length === 0;
const getStylesFromObject = (obj: object) => {
return obj === undefined
? {}
: Object.fromEntries(
Object.entries(obj).map(([property, value]) => [
property,
value._isReanimatedSharedValue ? value.value : value,
])
);
};
type StyleValue = { value: unknown };
type JestInlineStyle =
| {
[s: string]: StyleValue;
}
| ArrayLike<StyleValue>;
const getCurrentProps = (
component: TestComponent
): Partial<AnimatedComponentProps<AnimatedProps>> => {
const propsObject = component.props.jestAnimatedProps?.value;
return propsObject ? { ...propsObject } : {};
};
const getCurrentStyle = (component: TestComponent): DefaultStyle => {
const styleObject = component.props.style;
let currentStyle = {};
if (Array.isArray(styleObject)) {
// It is possible that style may contain nested arrays. Currently, neither `StyleSheet.flatten` nor `flattenArray` solve this issue.
// Hence, we're not handling nested arrays at the moment - this is a known limitation of the current implementation.
styleObject.forEach((style) => {
currentStyle = {
...currentStyle,
...style,
};
});
}
const jestInlineStyles = component.props.jestInlineStyle as JestInlineStyle;
const jestAnimatedStyleValue = component.props.jestAnimatedStyle?.value;
if (Array.isArray(jestInlineStyles)) {
for (const obj of jestInlineStyles) {
if ('jestAnimatedValues' in obj) {
continue;
}
const inlineStyles = getStylesFromObject(obj);
currentStyle = {
...currentStyle,
...inlineStyles,
};
}
currentStyle = {
...currentStyle,
...jestAnimatedStyleValue,
};
return currentStyle;
}
const inlineStyles = getStylesFromObject(jestInlineStyles);
currentStyle = isEmpty(jestAnimatedStyleValue as object | undefined)
? { ...inlineStyles }
: { ...jestAnimatedStyleValue };
return currentStyle;
};
const checkEqual = <Value>(current: Value, expected: Value) => {
if (Array.isArray(expected)) {
if (!Array.isArray(current) || expected.length !== current.length) {
return false;
}
for (let i = 0; i < current.length; i++) {
if (!checkEqual(current[i], expected[i])) {
return false;
}
}
} else if (typeof current === 'object' && current) {
if (typeof expected !== 'object' || !expected) {
return false;
}
for (const property in expected) {
if (!checkEqual(current[property], expected[property])) {
return false;
}
}
} else {
return current === expected;
}
return true;
};
const findStyleDiff = (
current: DefaultStyle | Partial<AnimatedComponentProps<AnimatedProps>>,
expected: DefaultStyle | Partial<AnimatedComponentProps<AnimatedProps>>,
shouldMatchAllProps?: boolean
) => {
const diffs = [];
let isEqual = true;
let property: keyof DefaultStyle;
for (property in expected) {
if (!checkEqual(current[property], expected[property])) {
isEqual = false;
diffs.push({
property,
current: current[property],
expect: expected[property],
});
}
}
if (
shouldMatchAllProps &&
Object.keys(current).length !== Object.keys(expected).length
) {
isEqual = false;
// eslint-disable-next-line @typescript-eslint/no-shadow
let property: keyof DefaultStyle;
for (property in current) {
if (expected[property] === undefined) {
diffs.push({
property,
current: current[property],
expect: expected[property],
});
}
}
}
return { isEqual, diffs };
};
const compareAndFormatDifferences = (
currentValues: Partial<AnimatedComponentProps<AnimatedProps>> | DefaultStyle,
expectedValues: Partial<AnimatedComponentProps<AnimatedProps>> | DefaultStyle,
shouldMatchAllProps: boolean = false
): { message: () => string; pass: boolean } => {
const { isEqual, diffs } = findStyleDiff(
currentValues,
expectedValues,
shouldMatchAllProps
);
if (isEqual) {
return { message: () => 'ok', pass: true };
}
const currentValuesStr = JSON.stringify(currentValues);
const expectedValuesStr = JSON.stringify(expectedValues);
const differences = diffs
.map(
(diff) =>
`- '${diff.property}' should be ${JSON.stringify(diff.expect)}, but is ${JSON.stringify(diff.current)}`
)
.join('\n');
return {
message: () =>
`Expected: ${expectedValuesStr}\nReceived: ${currentValuesStr}\n\nDifferences:\n${differences}`,
pass: false,
};
};
const compareProps = (
component: TestComponent,
expectedProps: Partial<AnimatedComponentProps<AnimatedProps>>
) => {
if (
component.props.jestAnimatedProps &&
Object.keys(component.props.jestAnimatedProps.value).length === 0
) {
return { message: () => `Component doesn't have props.`, pass: false };
}
const currentProps = getCurrentProps(component);
return compareAndFormatDifferences(currentProps, expectedProps);
};
const compareStyle = (
component: TestComponent,
expectedStyle: DefaultStyle,
config: ToHaveAnimatedStyleConfig
) => {
if (!component.props.style) {
return { message: () => `Component doesn't have a style.`, pass: false };
}
const { shouldMatchAllProps } = config;
const currentStyle = getCurrentStyle(component);
return compareAndFormatDifferences(
currentStyle,
expectedStyle,
shouldMatchAllProps
);
};
let frameTime = Math.round(1000 / defaultFramerateConfig.fps);
const beforeTest = () => {
jest.useFakeTimers();
};
const afterTest = () => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
};
export const withReanimatedTimer = (animationTest: () => void) => {
console.warn(
'This method is deprecated, you should define your own before and after test hooks to enable jest.useFakeTimers(). Check out the documentation for details on testing'
);
beforeTest();
animationTest();
afterTest();
};
export const advanceAnimationByTime = (time = frameTime) => {
console.warn(
'This method is deprecated, use jest.advanceTimersByTime directly'
);
jest.advanceTimersByTime(time);
jest.runOnlyPendingTimers();
};
export const advanceAnimationByFrame = (count: number) => {
console.warn(
'This method is deprecated, use jest.advanceTimersByTime directly'
);
jest.advanceTimersByTime(count * frameTime);
jest.runOnlyPendingTimers();
};
const requireFunction = isJest()
? require
: () => {
throw new ReanimatedError(
'`setUpTests` is available only in Jest environment.'
);
};
type ToHaveAnimatedStyleConfig = {
shouldMatchAllProps?: boolean;
};
export const setUpTests = (userFramerateConfig = {}) => {
let expect: jest.Expect = (global as typeof global & { expect: jest.Expect })
.expect;
if (expect === undefined) {
const expectModule = requireFunction('expect');
expect = expectModule;
// Starting from Jest 28, "expect" package uses named exports instead of default export.
// So, requiring "expect" package doesn't give direct access to "expect" function anymore.
// It gives access to the module object instead.
// We use this info to detect if the project uses Jest 28 or higher.
if (typeof expect === 'object') {
const jestGlobals = requireFunction('@jest/globals');
expect = jestGlobals.expect;
}
if (expect === undefined || expect.extend === undefined) {
expect = expectModule.default;
}
}
const framerateConfig = {
...defaultFramerateConfig,
...userFramerateConfig,
};
frameTime = Math.round(1000 / framerateConfig.fps);
expect.extend({
toHaveAnimatedProps(
component: React.Component<
AnimatedComponentProps<InitialComponentProps>
> &
IAnimatedComponentInternal,
expectedProps: Partial<AnimatedComponentProps<AnimatedProps>>
) {
return compareProps(component, expectedProps);
},
});
expect.extend({
toHaveAnimatedStyle(
component: React.Component<
AnimatedComponentProps<InitialComponentProps>
> &
IAnimatedComponentInternal,
expectedStyle: DefaultStyle,
config: ToHaveAnimatedStyleConfig = {}
) {
return compareStyle(component, expectedStyle, config);
},
});
};
type TestComponent = React.Component<
AnimatedComponentProps<InitialComponentProps> & {
jestAnimatedStyle?: { value: DefaultStyle };
jestAnimatedProps?: {
value: Partial<AnimatedComponentProps<AnimatedProps>>;
};
}
>;
export const getAnimatedStyle = (component: ReactTestInstance) => {
return getCurrentStyle(
// This type assertion is needed to get type checking in the following
// functions since `ReactTestInstance` has its `props` defined as `any`.
component as unknown as TestComponent
);
};