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
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/

import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';

import type {HostInstance} from 'react-native';

import * as Fantom from '@react-native/fantom';
import nullthrows from 'nullthrows';
import * as React from 'react';
import {createRef} from 'react';
import {Animated} from 'react-native';

// Renders `color` as a View's backgroundColor so the parsed/composed value can
// be observed through the public rendered output rather than private state.
function mountedBackgroundColor(color: Animated.Color): string {
const root = Fantom.createRoot();
Fantom.runTask(() => {
root.render(
<Animated.View style={{width: 10, height: 10, backgroundColor: color}} />,
);
});
return root.getRenderedOutput({props: ['backgroundColor']}).toJSONObject()
.props.backgroundColor;
}

describe('Animated.Color', () => {
it('defaults to opaque black', () => {
expect(mountedBackgroundColor(new Animated.Color())).toBe(
'rgba(0, 0, 0, 1)',
);
});

it('parses an rgba() string', () => {
expect(
mountedBackgroundColor(new Animated.Color('rgba(255, 128, 0, 1)')),
).toBe('rgba(255, 128, 0, 1)');
});

it('parses a hex string', () => {
expect(mountedBackgroundColor(new Animated.Color('#ff0000'))).toBe(
'rgba(255, 0, 0, 1)',
);
});

it('accepts an rgba object', () => {
// The rendered color quantizes alpha to 8 bits (0.5 -> 128/255).
expect(
mountedBackgroundColor(new Animated.Color({r: 10, g: 20, b: 30, a: 0.5})),
).toBe('rgba(10, 20, 30, 0.501961)');
});

it('accepts individual AnimatedValues for each channel', () => {
expect(
mountedBackgroundColor(
new Animated.Color({
r: new Animated.Value(1),
g: new Animated.Value(2),
b: new Animated.Value(3),
a: new Animated.Value(1),
}),
),
).toBe('rgba(1, 2, 3, 1)');
});

// Runtime color updates (setValue/offsets/JS-driven animation) are not exposed
// through Fantom's rendered output or direct-manipulation props, so these
// assert the computed value via `__getValue()`.
it('updates all channels via setValue', () => {
const color = new Animated.Color('rgba(0, 0, 0, 1)');
color.setValue({r: 5, g: 6, b: 7, a: 1});
expect(color.__getValue()).toBe('rgba(5, 6, 7, 1)');
});

it('applies an offset on top of the base value', () => {
const color = new Animated.Color({r: 10, g: 10, b: 10, a: 1});
color.setOffset({r: 5, g: 5, b: 5, a: 0});
expect(color.__getValue()).toBe('rgba(15, 15, 15, 1)');
});

it('animates to a target color', () => {
const color = new Animated.Color('rgba(255, 0, 0, 1)');
const viewRef = createRef<HostInstance>();
const root = Fantom.createRoot();
Fantom.runTask(() => {
root.render(
<Animated.View
ref={viewRef}
style={{width: 100, height: 100, backgroundColor: color}}
/>,
);
});
nullthrows(viewRef.current);

// Initial value is observed publicly through the rendered output.
expect(
root.getRenderedOutput({props: ['backgroundColor']}).toJSX(),
).toEqual(<rn-view backgroundColor="rgba(255, 0, 0, 1)" />);

let finished = false;
Fantom.runTask(() => {
Animated.timing(color, {
toValue: {r: 0, g: 0, b: 255, a: 1},
duration: 100,
useNativeDriver: false,
}).start(result => {
finished = result.finished;
});
});

Fantom.unstable_produceFramesForDuration(200);
Fantom.runWorkLoop();

expect(finished).toBe(true);
// The driven value isn't exposed publicly; assert the computed result.
expect(color.__getValue()).toBe('rgba(0, 0, 255, 1)');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/

import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';

import type {HostInstance} from 'react-native';

import * as Fantom from '@react-native/fantom';
import nullthrows from 'nullthrows';
import * as React from 'react';
import {createRef} from 'react';
import {Animated, useAnimatedValue} from 'react-native';

// Renders an <Animated.View> whose translateX is driven by `makeNode(base)` and
// returns the base AnimatedValue plus the mounted element so tests can mutate the
// base and observe the derived value on the shadow tree.
function renderWithDerivedTranslateX(
makeNode: (base: Animated.Value) => Animated.WithAnimatedValue<number>,
): {
base: Animated.Value,
element: HostInstance,
root: Fantom.Root,
} {
let base: Animated.Value = new Animated.Value(0);
const viewRef = createRef<HostInstance>();

function MyApp() {
const value = useAnimatedValue(0);
base = value;
return (
<Animated.View
ref={viewRef}
style={[
{width: 100, height: 100},
{transform: [{translateX: makeNode(value)}]},
]}
/>
);
}

const root = Fantom.createRoot();
Fantom.runTask(() => {
root.render(<MyApp />);
});

const element = nullthrows(viewRef.current);
return {base, element, root};
}

function getTranslateX(root: Fantom.Root): number {
const output = root.getRenderedOutput({props: ['transform']}).toJSONObject();
const transform = JSON.parse(output.props.transform);
return transform[0].translateX;
}

describe('Animated.subtract', () => {
it('computes the difference of two values and reacts to updates', () => {
const {base, root} = renderWithDerivedTranslateX(value =>
Animated.subtract(100, value),
);

expect(getTranslateX(root)).toBe(100);

Fantom.runTask(() => {
base.setValue(30);
});

expect(getTranslateX(root)).toBe(70);
});
});

describe('Animated.divide', () => {
it('computes the quotient of two values and reacts to updates', () => {
const {base, root} = renderWithDerivedTranslateX(value =>
Animated.divide(value, 2),
);

Fantom.runTask(() => {
base.setValue(80);
});

expect(getTranslateX(root)).toBe(40);
});

it('returns 0 instead of Infinity when dividing by zero', () => {
// A zero transform value is dropped from the rendered output, so the result
// isn't observable publicly; assert the computed value directly.
const node = Animated.divide(10, 0);
expect(node.__getValue()).toBe(0);
});
});

describe('Animated.modulo', () => {
it('wraps values into the [0, modulus) range for positive and negative inputs', () => {
const {base, root} = renderWithDerivedTranslateX(value =>
Animated.modulo(value, 10),
);

Fantom.runTask(() => {
base.setValue(25);
});
expect(getTranslateX(root)).toBe(5);

Fantom.runTask(() => {
base.setValue(-3);
});
// ((-3 % 10) + 10) % 10 === 7
expect(getTranslateX(root)).toBe(7);
});
});

describe('Animated.diffClamp', () => {
// `AnimatedDiffClamp.__getValue()` mutates internal accumulator state on every
// call, so it is driven directly here (rather than through the render/commit
// pipeline, which may read the node an unpredictable number of times).
it('clamps the accumulated delta between min and max', () => {
const base = new Animated.Value(0);
const clamped = Animated.diffClamp(base, 0, 20);

expect(clamped.__getValue()).toBe(0);

// Increase beyond max: 0 + 100 clamped to 20.
base.setValue(100);
expect(clamped.__getValue()).toBe(20);

// Decrease by 30: 20 + (70 - 100) = -10, clamped to 0.
base.setValue(70);
expect(clamped.__getValue()).toBe(0);

// Increase by 5: 0 + (75 - 70) = 5, within range.
base.setValue(75);
expect(clamped.__getValue()).toBe(5);
});
});

describe('Animated.add and Animated.multiply', () => {
it('compose additions and multiplications', () => {
const {base, root} = renderWithDerivedTranslateX(value =>
Animated.add(Animated.multiply(value, 2), 10),
);

Fantom.runTask(() => {
base.setValue(20);
});

// 20 * 2 + 10 = 50
expect(getTranslateX(root)).toBe(50);
});
});

describe('Animated tracking (toValue is an AnimatedNode)', () => {
it('follows the target value when animating toward another AnimatedValue', () => {
let follower: Animated.Value = new Animated.Value(0);
let leader: Animated.Value = new Animated.Value(0);
const viewRef = createRef<HostInstance>();

function MyApp() {
const followerValue = useAnimatedValue(0);
const leaderValue = useAnimatedValue(0);
follower = followerValue;
leader = leaderValue;
return (
<Animated.View
ref={viewRef}
style={[
{width: 100, height: 100},
{transform: [{translateX: followerValue}]},
]}
/>
);
}

const root = Fantom.createRoot();
Fantom.runTask(() => {
root.render(<MyApp />);
});

Fantom.runTask(() => {
Animated.timing(follower, {
toValue: leader,
duration: 100,
useNativeDriver: true,
}).start();
});

Fantom.runTask(() => {
leader.setValue(50);
});

Fantom.unstable_produceFramesForDuration(200);
Fantom.runWorkLoop();

expect(getTranslateX(root)).toBeCloseTo(50, 1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/

import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';

import * as Fantom from '@react-native/fantom';
import {Animated} from 'react-native';

describe('Animated.decay', () => {
it('runs the JS decay animation to completion', () => {
const value = new Animated.Value(0);

let finished = false;
Fantom.runTask(() => {
Animated.decay(value, {
velocity: 1,
useNativeDriver: false,
}).start(result => {
finished = result.finished;
});
});

// Drive the `requestAnimationFrame`-based JS decay to rest. Fantom's virtual
// clock does not deterministically expose intermediate frame values for the
// JS driver, so we assert that the animation completes (which exercises
// `onUpdate`) rather than asserting per-frame movement.
Fantom.unstable_produceFramesForDuration(3000);

expect(finished).toBe(true);
});
});
Loading
Loading