Skip to content

Commit

Permalink
Tests: cancelAnimation, useFrameCallback, measure, withDecay (#6016)
Browse files Browse the repository at this point in the history
## Summary
**New tests take approx 20 seconds to run ⏰**

Add tests for the following functionalities:
* cancelAnimation
* useFrameCallback
* measure
* withDecay

## Test plan


https://github.com/software-mansion/react-native-reanimated/assets/56199675/2e5285a2-c203-48fa-adbd-2d528932a68a
  • Loading branch information
Latropos committed May 27, 2024
1 parent 49e6924 commit af6ebb3
Show file tree
Hide file tree
Showing 7 changed files with 598 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@ import { getComparator } from './Comparators';
import { appendWhiteSpaceToMatchLength, color } from './stringFormatUtils';
import { ComparisonMode, OperationUpdate, TestCase, TestValue, NullableTestValue, TrackerCallCount } from './types';

type MatcherFunction = (
type ToBeArgs = [TestValue, ComparisonMode?];
type ToBeWithinRangeArgs = [number, number];
type ToBeCalledArgs = [number];

type MatcherArguments = ToBeArgs | ToBeCalledArgs | ToBeWithinRangeArgs;

type Matcher<Args extends MatcherArguments> = (
currentValue: TestValue,
expectedValue: TestValue,
...additionalArgs: Array<unknown>
...args: Args
) => {
pass: boolean;
message: string;
Expand All @@ -21,11 +26,7 @@ export class Matchers {
}
}

private _toBeMatcher: MatcherFunction = (
currentValue: TestValue,
expectedValue: TestValue,
comparisonModeUnknown: unknown,
) => {
private _toBeMatcher: Matcher<ToBeArgs> = (currentValue, expectedValue, comparisonModeUnknown) => {
const comparisonMode: ComparisonMode =
typeof comparisonModeUnknown === 'string' && comparisonModeUnknown in ComparisonMode
? (comparisonModeUnknown as ComparisonMode)
Expand All @@ -45,7 +46,23 @@ export class Matchers {
};
};

private _toBeCalledMatcher: MatcherFunction = (currentValue: TestValue, times = 1) => {
private _toBeWithinRangeMatcher: Matcher<ToBeWithinRangeArgs> = (currentValue, minimumValue, maximumValue) => {
const currentValueAsNumber = Number(Number(currentValue));
const validInputTypes = typeof minimumValue === 'number' && typeof maximumValue === 'number';
const isWithinRange = Number(minimumValue) <= currentValueAsNumber && currentValueAsNumber <= Number(maximumValue);

const coloredExpected = color(`[${minimumValue}, ${maximumValue}]`, 'green');
const coloredReceived = color(currentValue, 'red');

return {
pass: isWithinRange && validInputTypes,
message: `Expected the value ${
this._negation ? ' NOT' : ''
}to be in range ${coloredExpected} received ${coloredReceived}`,
};
};

private _toBeCalledMatcher: Matcher<ToBeCalledArgs> = (currentValue, times) => {
Matchers._assertValueIsCallTracker(currentValue);
const callsCount = currentValue.onUI + currentValue.onJS;
const name = color(currentValue.name, 'green');
Expand All @@ -59,7 +76,7 @@ export class Matchers {
};
};

private _toBeCalledUIMatcher: MatcherFunction = (currentValue: TestValue, times = 1) => {
private _toBeCalledUIMatcher: Matcher<ToBeCalledArgs> = (currentValue, times) => {
Matchers._assertValueIsCallTracker(currentValue);
const callsCount = currentValue.onUI;
const name = color(currentValue.name, 'green');
Expand All @@ -75,7 +92,7 @@ export class Matchers {
};
};

private _toBeCalledJSMatcher: MatcherFunction = (currentValue: TestValue, times = 1) => {
private _toBeCalledJSMatcher: Matcher<ToBeCalledArgs> = (currentValue, times) => {
Matchers._assertValueIsCallTracker(currentValue);
const callsCount = currentValue.onJS;
const name = color(currentValue.name, 'green');
Expand All @@ -91,16 +108,17 @@ export class Matchers {
};
};

private decorateMatcher(matcher: MatcherFunction) {
return (expectedValue: TestValue, ...args: Array<unknown>) => {
const { pass, message } = matcher(this._currentValue, expectedValue, ...args);
private decorateMatcher<MatcherArgs extends MatcherArguments>(matcher: Matcher<MatcherArgs>) {
return (...args: MatcherArgs) => {
const { pass, message } = matcher(this._currentValue, ...args);
if ((!pass && !this._negation) || (pass && this._negation)) {
this._testCase.errors.push(message);
}
};
}

public toBe = this.decorateMatcher(this._toBeMatcher);
public toBeWithinRange = this.decorateMatcher(this._toBeWithinRangeMatcher);
public toBeCalled = this.decorateMatcher(this._toBeCalledMatcher);
public toBeCalledUI = this.decorateMatcher(this._toBeCalledUIMatcher);
public toBeCalledJS = this.decorateMatcher(this._toBeCalledJSMatcher);
Expand Down
11 changes: 8 additions & 3 deletions app/src/examples/RuntimeTests/RuntimeTestsExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,25 @@ import React from 'react';
import RuntimeTestsRunner from './ReanimatedRuntimeTestsRunner/RuntimeTestsRunner';

// load tests
import './tests/Animations.test';
// import './tests/Animations.test';

import './tests/animations/withTiming/arrays.test';
import './tests/animations/withTiming/basic.test';
import './tests/animations/withTiming/colors.test';
import './tests/animations/withTiming/easing.test';
import './tests/animations/withTiming/transformMatrices.test';

import './tests/animations/withDecay/basic.test';
import './tests/animations/withSpring/variousConfig.test';

import './tests/core/cancelAnimation.test';

import './tests/utilities/relativeCoords.test';

import './tests/layoutAnimations/entering/enteringColors.test';
import './tests/layoutAnimations/entering/predefinedEntering.test';

import './tests/utilities/relativeCoords.test';
import './tests/advancedAPI/useFrameCallback.test';
import './tests/advancedAPI/measure.test';

import './tests/core/useSharedValue.test';
import './tests/core/useAnimatedStyle/reuseAnimatedStyle.test';
Expand Down
191 changes: 191 additions & 0 deletions app/src/examples/RuntimeTests/tests/advancedAPI/measure.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import React, { useEffect } from 'react';
import { StyleSheet, View } from 'react-native';
import Animated, {
runOnUI,
measure,
useAnimatedRef,
useSharedValue,
withDelay,
withTiming,
useAnimatedStyle,
AnimatableValueObject,
Easing,
MeasuredDimensions,
useFrameCallback,
} from 'react-native-reanimated';
import {
describe,
expect,
test,
render,
wait,
registerValue,
getRegisteredValue,
} from '../../ReanimatedRuntimeTestsRunner/RuntimeTestsApi';
import { ComparisonMode } from '../../ReanimatedRuntimeTestsRunner/types';

describe('Test measuring component before nad after animation', () => {
const INITIAL_MEASURE = 'INITIAL_MEASURE';
const FINAL_MEASURE = 'FINAL_MEASURE';
const MeasuredComponent = ({
initialStyle,
finalStyle,
}: {
initialStyle: AnimatableValueObject;
finalStyle: AnimatableValueObject;
}) => {
const measuredInitial = useSharedValue<MeasuredDimensions | null>(null);
const measuredFinal = useSharedValue<MeasuredDimensions | null>(null);

const styleSV = useSharedValue(initialStyle);

registerValue(INITIAL_MEASURE, measuredInitial);
registerValue(FINAL_MEASURE, measuredFinal);

const ref = useAnimatedRef();

const animatedStyle = useAnimatedStyle(() => {
return styleSV.value;
});

useEffect(() => {
runOnUI(() => {
measuredInitial.value = measure(ref);
})();
});

useEffect(() => {
styleSV.value = withDelay(
50,
withTiming(finalStyle, { duration: 300 }, () => {
measuredFinal.value = measure(ref);
}),
);
});

return (
<View style={styles.container}>
<Animated.View ref={ref} style={[styles.smallBox, animatedStyle]} />
</View>
);
};

test.each([
[{ width: 40 }, { width: 100 }],
[{ height: 40 }, { height: 100 }],
[
{ height: 80, width: 20 },
{ height: 25, width: 85 },
],
[{ margin: 40 }, { margin: 60 }],
[
{ height: 40, width: 40, margin: 40 },
{ height: 100, width: 100, margin: 60 },
],
[
{ height: 40, width: 40, margin: 40, top: 0 },
{ height: 100, width: 100, margin: 60, top: 40 },
],
[
{ height: 40, width: 40, margin: 40, left: 0 },
{ height: 100, width: 100, margin: 60, left: 40 },
],
[
{ height: 40, width: 40, margin: 40, left: 0, top: 50 },
{ height: 100, width: 100, margin: 60, left: 40, top: 0 },
],
])('Measure component animating from ${0} to ${1}', async ([initialStyle, finalStyle]) => {
await render(<MeasuredComponent initialStyle={initialStyle} finalStyle={finalStyle} />);
await wait(450);
const measuredInitial = (await getRegisteredValue(INITIAL_MEASURE)).onJS as unknown as MeasuredDimensions;
const measuredFinal = (await getRegisteredValue(FINAL_MEASURE)).onJS as unknown as MeasuredDimensions;

// Check the distance from the top
const finalStyleFull = { width: 100, height: 100, margin: 0, top: 0, left: 0, ...finalStyle };
const initialStyleFull = { width: 100, height: 100, margin: 0, top: 0, left: 0, ...initialStyle };

if ('height' in finalStyle && 'height' in initialStyle) {
expect(measuredFinal.height).toBeWithinRange(finalStyle.height - 2, finalStyle.height + 2);
expect(measuredInitial.height).toBeWithinRange(initialStyle.height - 2, initialStyle.height + 2);
}

if ('width' in finalStyle && 'width' in initialStyle) {
expect(measuredFinal.width).toBeWithinRange(finalStyle.width - 2, finalStyle.width + 2);
expect(measuredInitial.width).toBeWithinRange(initialStyle.width - 2, initialStyle.width + 2);
}

// Absolute translation equals relative translation
expect(measuredFinal.pageX - measuredInitial.pageX).toBe(measuredFinal.x - measuredInitial.x);
expect(measuredFinal.pageY - measuredInitial.pageY).toBe(measuredFinal.y - measuredInitial.y);

// Check distance from top and from left
const expectedInitialDistanceFromTop = initialStyleFull.margin + initialStyleFull.top;
expect(measuredInitial.y).toBeWithinRange(expectedInitialDistanceFromTop - 2, expectedInitialDistanceFromTop + 2);
const expectedFinalDistanceFromTop = finalStyleFull.margin + finalStyleFull.top;
expect(measuredFinal.y).toBeWithinRange(expectedFinalDistanceFromTop - 2, expectedFinalDistanceFromTop + 2);

const expectedInitialDistanceFromLeft = initialStyleFull.margin + initialStyleFull.left;
expect(measuredInitial.x).toBeWithinRange(expectedInitialDistanceFromLeft - 2, expectedInitialDistanceFromLeft + 2);
const expectedFinalDistanceFromLeft = finalStyleFull.margin + finalStyleFull.left;
expect(measuredFinal.x).toBeWithinRange(expectedFinalDistanceFromLeft - 2, expectedFinalDistanceFromLeft + 2);
});
});

describe('Test measuring component during the animation', () => {
const DURATION = 500;
const FINAL_WIDTH = 300;
const OBSERVED_WIDTHS_REF = 'OBSERVED_WIDTHS_REF';
const TestComponent = () => {
const width = useSharedValue(0);
const observedWidths = useSharedValue<Array<[number, number]>>([]);
registerValue(OBSERVED_WIDTHS_REF, observedWidths);
const ref = useAnimatedRef();

useFrameCallback(({ timeSinceFirstFrame, timeSincePreviousFrame }) => {
if (timeSinceFirstFrame === 0) {
width.value = withTiming(FINAL_WIDTH, { easing: Easing.linear, duration: DURATION });
} else if (timeSinceFirstFrame !== timeSincePreviousFrame) {
const observedWidth = measure(ref)?.width;
observedWidths.value = [
...observedWidths.value,
[observedWidth || 0, timeSinceFirstFrame - (timeSincePreviousFrame || 0)],
];
}
});

const animatedStyle = useAnimatedStyle(() => {
return { width: width.value };
});

return (
<View style={styles.container}>
<Animated.View ref={ref} style={[styles.smallBox, animatedStyle]} />
</View>
);
};

test('Test that measurements of withTiming match the expectations', async () => {
await render(<TestComponent />);
await wait(650);
const observedWidths = (await getRegisteredValue(OBSERVED_WIDTHS_REF)).onJS as Array<[number, number]>;
observedWidths.forEach(([width, timeSinceFirstFrame]) => {
const expectedWidth = Math.min(FINAL_WIDTH, (timeSinceFirstFrame * FINAL_WIDTH) / DURATION);
expect(width).toBe(expectedWidth, ComparisonMode.DISTANCE);
});
});
});

const styles = StyleSheet.create({
container: {
flex: 1,
flexDirection: 'column',
},
smallBox: {
width: 100,
height: 50,
backgroundColor: 'mediumseagreen',
borderColor: 'seagreen',
borderWidth: 2,
borderRadius: 10,
},
});
Loading

0 comments on commit af6ebb3

Please sign in to comment.