From dfe4fc8bc00b98dc49ed12496e938b061bb8140d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Tue, 14 Jul 2026 08:19:17 -0700 Subject: [PATCH] Add Fantom integration tests for core public APIs (#57466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Adds Fantom `-itest.js` coverage for maintained, public React Native APIs that were previously untested or thinly tested, driving both the JavaScript logic and the underlying cross-platform C++ through the public `react-native` package surface. To make JavaScript-driven animations deterministic under the test runner, this also introduces a small timing indirection in `Animated`: - New module `Animated/AnimationTimingUtils.js` exports `getCurrentAnimationTime` (returns `Date.now()` by default) and `setAnimationTimeProvider` (overrides the time source; pass `null` to restore the default). - `TimingAnimation`, `SpringAnimation` and `DecayAnimation` now read the current time from `getCurrentAnimationTime()` instead of calling `Date.now()` directly. The default behavior is unchanged. With this, a test can override the provider with a clock that advances one frame per read, which drives the JS-driver animation at the same cadence as the native frame clock. The spring and decay tests use this to verify the actual animation curve (overshoot for an underdamped spring, exponential deceleration for decay), not just the final value, and to run a single unified code path for both the JS and native drivers. New test files: - `Animated/__tests__/SpringAnimation-itest.js`, `DecayAnimation-itest.js`, `AnimatedColor-itest.js`, `AnimatedComposition-itest.js` — spring/decay curve shape (both drivers), `Animated.Color` value semantics, and the composition nodes (`add`/`subtract`/`multiply`/`divide`/`modulo`/`diffClamp`/tracking). `AnimatedFantomTestUtils.js` holds the shared trajectory helper. - `Components/View/__tests__/View-nativeCSSParsing-itest.js` — string-valued CSS (color functions, transform, gradients) parsed via native CSS parsing. - `Pressability/__tests__/Pressability-touch-itest.js` — press in/out/press, long press, press delay, and responder termination via touch events and the deterministic timer mock. - `Interaction/__tests__/PanResponder-itest.js` — grant/move/release/terminate and gesture state across touch sequences. - `LayoutAnimation/__tests__/LayoutAnimation-itest.js` — `configureNext`/`create`/presets applying the resulting layout. - `Network/__tests__/Network-itest.js` — synchronous `XMLHttpRequest` lifecycle and validation. Extended test files: - `Components/TextInput/__tests__/TextInput-itest.js` — `onSelectionChange`/`onSubmitEditing`/`onKeyPress`/`onEndEditing` events. - `Lists/__tests__/FlatList-itest.js` — `onViewableItemsChanged` across all `viewabilityConfig` options (`itemVisiblePercentThreshold`, `viewAreaCoveragePercentThreshold`, `minimumViewTime`, `waitForInteraction`). - `Text/__tests__/Text-itest.js` — `letterSpacing`, `lineHeight`, and `fontVariant` styles. The runtime behavior of `Animated` is unchanged; the new timing indirection defaults to `Date.now()`. Changelog: [Internal] Reviewed By: javache, zeyap Differential Revision: D110784436 --- .../Animated/AnimationTimingUtils.js | 39 ++ .../Animated/__tests__/AnimatedColor-itest.js | 195 +++++++++ .../__tests__/AnimatedComposition-itest.js | 381 ++++++++++++++++++ .../__tests__/AnimatedFantomTestUtils.js | 100 +++++ .../__tests__/DecayAnimation-itest.js | 106 +++++ .../__tests__/SpringAnimation-itest.js | 214 ++++++++++ .../Animated/animations/DecayAnimation.js | 5 +- .../Animated/animations/SpringAnimation.js | 5 +- .../Animated/animations/TimingAnimation.js | 5 +- .../TextInput/__tests__/TextInput-itest.js | 128 +++++- .../__tests__/View-nativeCSSParsing-itest.js | 75 ++++ .../__tests__/PanResponder-itest.js | 143 +++++++ .../__tests__/LayoutAnimation-itest.js | 109 +++++ .../Lists/__tests__/FlatList-itest.js | 213 +++++++++- .../Network/__tests__/Network-itest.js | 90 +++++ .../__tests__/Pressability-touch-itest.js | 167 ++++++++ .../Libraries/Text/__tests__/Text-itest.js | 49 ++- 17 files changed, 1996 insertions(+), 28 deletions(-) create mode 100644 packages/react-native/Libraries/Animated/AnimationTimingUtils.js create mode 100644 packages/react-native/Libraries/Animated/__tests__/AnimatedColor-itest.js create mode 100644 packages/react-native/Libraries/Animated/__tests__/AnimatedComposition-itest.js create mode 100644 packages/react-native/Libraries/Animated/__tests__/AnimatedFantomTestUtils.js create mode 100644 packages/react-native/Libraries/Animated/__tests__/DecayAnimation-itest.js create mode 100644 packages/react-native/Libraries/Animated/__tests__/SpringAnimation-itest.js create mode 100644 packages/react-native/Libraries/Components/View/__tests__/View-nativeCSSParsing-itest.js create mode 100644 packages/react-native/Libraries/Interaction/__tests__/PanResponder-itest.js create mode 100644 packages/react-native/Libraries/LayoutAnimation/__tests__/LayoutAnimation-itest.js create mode 100644 packages/react-native/Libraries/Network/__tests__/Network-itest.js create mode 100644 packages/react-native/Libraries/Pressability/__tests__/Pressability-touch-itest.js diff --git a/packages/react-native/Libraries/Animated/AnimationTimingUtils.js b/packages/react-native/Libraries/Animated/AnimationTimingUtils.js new file mode 100644 index 000000000000..d153a622dee7 --- /dev/null +++ b/packages/react-native/Libraries/Animated/AnimationTimingUtils.js @@ -0,0 +1,39 @@ +/** + * 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 + */ + +'use strict'; + +export type AnimationTimeProvider = () => number; + +const defaultAnimationTimeProvider: AnimationTimeProvider = () => Date.now(); + +let animationTimeProvider: AnimationTimeProvider = defaultAnimationTimeProvider; + +/** + * Returns the current time, in milliseconds, used to drive JavaScript-based + * animations (timing, spring and decay). + * + * Defaults to `Date.now()`. The value can be overridden with + * `setAnimationTimeProvider`, e.g. to drive animations from a controlled clock + * in tests. + */ +export function getCurrentAnimationTime(): number { + return animationTimeProvider(); +} + +/** + * Overrides the time source used by `getCurrentAnimationTime`. Pass `null` to restore + * the default `Date.now()`-based provider. + */ +export function setAnimationTimeProvider( + provider: ?AnimationTimeProvider, +): void { + animationTimeProvider = provider ?? defaultAnimationTimeProvider; +} diff --git a/packages/react-native/Libraries/Animated/__tests__/AnimatedColor-itest.js b/packages/react-native/Libraries/Animated/__tests__/AnimatedColor-itest.js new file mode 100644 index 000000000000..651811587b5b --- /dev/null +++ b/packages/react-native/Libraries/Animated/__tests__/AnimatedColor-itest.js @@ -0,0 +1,195 @@ +/** + * 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 {DRIVERS} from './AnimatedFantomTestUtils'; +import * as Fantom from '@react-native/fantom'; +import * as React from 'react'; +import {Animated} from 'react-native'; + +// Renders `color` as a View's backgroundColor so it can be observed through the +// public rendered output rather than private state. +function renderColor(color: Animated.Color): Fantom.Root { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + , + ); + }); + return root; +} + +function mountedBackgroundColor(color: Animated.Color): string { + return renderColor(color) + .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)'); + }); + + it('updates all channels via setValue', () => { + const color = new Animated.Color('rgba(0, 0, 0, 1)'); + const root = renderColor(color); + + Fantom.runTask(() => { + color.setValue({r: 5, g: 6, b: 7, a: 1}); + }); + + expect( + root.getRenderedOutput({props: ['backgroundColor']}).toJSX(), + ).toEqual(); + }); + + it('applies an offset on top of the base value', () => { + const color = new Animated.Color({r: 10, g: 10, b: 10, a: 1}); + const root = renderColor(color); + + // `setOffset` on its own does not flush to a connected view (unlike the + // native driver, which does); the following value update flushes the + // composed color, which includes the offset (base 20 + offset 5). + Fantom.runTask(() => { + color.setOffset({r: 5, g: 5, b: 5, a: 0}); + color.setValue({r: 20, g: 20, b: 20, a: 1}); + }); + + expect( + root.getRenderedOutput({props: ['backgroundColor']}).toJSX(), + ).toEqual(); + }); + + it('flattenOffset and extractOffset preserve the composed value', () => { + const color = new Animated.Color({r: 10, g: 10, b: 10, a: 1}); + renderColor(color); + + let afterFlatten: string = ''; + let afterExtract: string = ''; + Fantom.runTask(() => { + color.setOffset({r: 5, g: 5, b: 5, a: 0}); + // Merges the offset (5) into the base (10) -> base 15, offset 0. + color.flattenOffset(); + color.stopAnimation(value => { + afterFlatten = String(value); + }); + // Moves the base (15) into the offset -> base 0, offset 15. + color.extractOffset(); + color.stopAnimation(value => { + afterExtract = String(value); + }); + }); + + expect(afterFlatten).toBe('rgba(15, 15, 15, 1)'); + expect(afterExtract).toBe('rgba(15, 15, 15, 1)'); + }); + + it('resetAnimation restores the value and reports it to the callback', () => { + const color = new Animated.Color('rgba(1, 2, 3, 1)'); + renderColor(color); + + let reported: string = ''; + Fantom.runTask(() => { + color.resetAnimation(value => { + reported = String(value); + }); + }); + + expect(reported).toBe('rgba(1, 2, 3, 1)'); + }); + + it('updates a native-driven color via setValue', () => { + const color = new Animated.Color('rgba(0, 0, 0, 1)', { + useNativeDriver: true, + }); + renderColor(color); + + // A native-driven color applies via direct manipulation rather than the + // committed tree, so observe the value through the animation callback. + let reported: string = ''; + Fantom.runTask(() => { + color.setValue({r: 5, g: 6, b: 7, a: 1}); + color.stopAnimation(value => { + reported = String(value); + }); + }); + Fantom.unstable_produceFramesForDuration(16); + Fantom.runWorkLoop(); + + expect(reported).toBe('rgba(5, 6, 7, 1)'); + }); + + for (const {name, useNativeDriver} of DRIVERS) { + it(`animates to a target color (${name})`, () => { + const color = new Animated.Color('rgba(255, 0, 0, 1)'); + const root = renderColor(color); + + expect( + root.getRenderedOutput({props: ['backgroundColor']}).toJSX(), + ).toEqual(); + + let finished = false; + Fantom.runTask(() => { + Animated.timing(color, { + toValue: {r: 0, g: 0, b: 255, a: 1}, + duration: 100, + useNativeDriver, + }).start(result => { + finished = result.finished; + }); + }); + + Fantom.unstable_produceFramesForDuration(200); + Fantom.runWorkLoop(); + + // The final driven color is flushed to the committed tree and observed + // through the public rendered output. + expect(finished).toBe(true); + expect( + root.getRenderedOutput({props: ['backgroundColor']}).toJSX(), + ).toEqual(); + }); + } +}); diff --git a/packages/react-native/Libraries/Animated/__tests__/AnimatedComposition-itest.js b/packages/react-native/Libraries/Animated/__tests__/AnimatedComposition-itest.js new file mode 100644 index 000000000000..ba97eacec7f9 --- /dev/null +++ b/packages/react-native/Libraries/Animated/__tests__/AnimatedComposition-itest.js @@ -0,0 +1,381 @@ +/** + * 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 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, +): { + base: Animated.Value, + element: HostInstance, + root: Fantom.Root, +} { + let base: ?Animated.Value; + const viewRef = createRef(); + + function MyApp() { + const value = useAnimatedValue(0); + base = value; + return ( + + ); + } + + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + + const element = nullthrows(viewRef.current); + return {base: nullthrows(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(20); + }); + expect(getTranslateX(root)).toBe(10); + + Fantom.runTask(() => { + base.setValue(80); + }); + + expect(getTranslateX(root)).toBe(40); + }); + + it('returns 0 instead of Infinity when dividing by zero', () => { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + , + ); + }); + expect( + Number( + root.getRenderedOutput({props: ['width']}).toJSONObject().props.width, + ), + ).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', () => { + it('clamps the accumulated delta between min and max', () => { + const base = new Animated.Value(0); + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + , + ); + }); + + // `width` (unlike a zero `translateX`) is preserved in the rendered output, + // so the accumulated, clamped value is observed publicly. + const getWidth = () => + Number( + root.getRenderedOutput({props: ['width']}).toJSONObject().props.width, + ); + + expect(getWidth()).toBe(0); + + // Increase beyond max: 0 + 100 clamped to 20. + Fantom.runTask(() => base.setValue(100)); + expect(getWidth()).toBe(20); + + // Decrease by 30: 20 + (70 - 100) = -10, clamped to 0. + Fantom.runTask(() => base.setValue(70)); + expect(getWidth()).toBe(0); + + // Increase by 5: 0 + (75 - 70) = 5, within range. + Fantom.runTask(() => base.setValue(75)); + expect(getWidth()).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; + let leader: ?Animated.Value; + const viewRef = createRef(); + + function MyApp() { + const followerValue = useAnimatedValue(0); + const leaderValue = useAnimatedValue(0); + follower = followerValue; + leader = leaderValue; + return ( + + ); + } + + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + + Fantom.runTask(() => { + Animated.timing(nullthrows(follower), { + toValue: nullthrows(leader), + duration: 100, + useNativeDriver: false, + }).start(); + }); + + Fantom.runTask(() => { + nullthrows(leader).setValue(50); + }); + + Fantom.unstable_produceFramesForDuration(200); + Fantom.runWorkLoop(); + + expect(getTranslateX(root)).toBeCloseTo(50, 1); + }); + + it('follows the target value on the native driver', () => { + let follower: ?Animated.Value; + let leader: ?Animated.Value; + let animation: ?Animated.CompositeAnimation; + const viewRef = createRef(); + + function MyApp() { + const followerValue = useAnimatedValue(0); + const leaderValue = useAnimatedValue(0); + follower = followerValue; + leader = leaderValue; + return ( + + ); + } + + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + const element = nullthrows(viewRef.current); + + Fantom.runTask(() => { + animation = Animated.timing(nullthrows(follower), { + toValue: nullthrows(leader), + duration: 100, + useNativeDriver: true, + }); + animation.start(); + }); + + Fantom.runTask(() => { + nullthrows(leader).setValue(50); + }); + + Fantom.unstable_produceFramesForDuration(200); + Fantom.runWorkLoop(); + + expect(element.getBoundingClientRect().x).toBeCloseTo(50, 0); + + Fantom.runTask(() => { + nullthrows(animation).stop(); + }); + Fantom.runTask(() => { + root.render(); + }); + Fantom.unstable_produceFramesForDuration(16); + Fantom.runWorkLoop(); + }); +}); + +// Exercises the native-config, interpolation and detach paths of the +// composition nodes (the JS `__getValue` path is covered by the tests above). +describe('composition nodes: native driver, interpolation and detach', () => { + const factories = [ + { + name: 'add', + make: (base: Animated.Value) => Animated.add(base, 10), + expected: 60, + }, + { + name: 'subtract', + make: (base: Animated.Value) => Animated.subtract(base, 10), + expected: 40, + }, + { + name: 'multiply', + make: (base: Animated.Value) => Animated.multiply(base, 2), + expected: 100, + }, + { + name: 'divide', + make: (base: Animated.Value) => Animated.divide(base, 2), + expected: 25, + }, + { + name: 'modulo', + make: (base: Animated.Value) => Animated.modulo(base, 7), + expected: 50 % 7, + }, + { + name: 'diffClamp', + make: (base: Animated.Value) => Animated.diffClamp(base, 0, 100), + expected: 50, + }, + ]; + + for (const {name, make, expected} of factories) { + it(`${name} runs on the native driver, interpolates, and detaches`, () => { + let base: ?Animated.Value; + const viewRef = createRef(); + + function MyApp() { + const value = useAnimatedValue(0); + base = value; + const node = make(value); + return ( + + ); + } + + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + const element = nullthrows(viewRef.current); + + let animation: ?Animated.CompositeAnimation; + Fantom.runTask(() => { + animation = Animated.timing(nullthrows(base), { + toValue: 50, + duration: 100, + useNativeDriver: true, + }); + animation.start(); + }); + Fantom.unstable_produceFramesForDuration(200); + Fantom.runWorkLoop(); + + expect(element.getBoundingClientRect().x).toBeCloseTo(expected, 0); + + // Stop the animation and re-render without the node to detach the + // composition graph and drain pending work. + Fantom.runTask(() => { + nullthrows(animation).stop(); + }); + Fantom.runTask(() => { + root.render(); + }); + Fantom.unstable_produceFramesForDuration(16); + Fantom.runWorkLoop(); + }); + } +}); diff --git a/packages/react-native/Libraries/Animated/__tests__/AnimatedFantomTestUtils.js b/packages/react-native/Libraries/Animated/__tests__/AnimatedFantomTestUtils.js new file mode 100644 index 000000000000..0a94e454275f --- /dev/null +++ b/packages/react-native/Libraries/Animated/__tests__/AnimatedFantomTestUtils.js @@ -0,0 +1,100 @@ +/** + * 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 + * @oncall react_native + */ + +import * as Fantom from '@react-native/fantom'; +import {Animated} from 'react-native'; +import {setAnimationTimeProvider} from 'react-native/Libraries/Animated/AnimationTimingUtils'; + +export type Driver = {readonly name: string, readonly useNativeDriver: boolean}; + +// Every animation is exercised with both drivers. The JS driver runs the +// closed-form integration in JavaScript; the native driver hands the config to +// the C++ backend, which drives the view on each frame. +export const DRIVERS: ReadonlyArray = [ + {name: 'JS driver', useNativeDriver: false}, + {name: 'native driver', useNativeDriver: true}, +]; + +// Fantom's `produceFramesForDuration` advances its frame clock in fixed +// ~16.333ms (60fps) steps. We advance the JS animation clock by the same step +// so both drivers step at the same cadence. +export const FRAME_STEP_MS = 16333 / 1000; + +/** + * Runs `createAnimation(useNativeDriver)` on `value` to completion and returns + * the per-frame trajectory recorded from `value`'s listener. + * + * Both drivers step at the same frame cadence, driven by two clocks advanced + * together: + * - frame timing: `unstable_produceFramesForDuration` advances Fantom's frame + * clock (drives the native driver and flushes each frame's work), and + * - animation timing: `getCurrentAnimationTime` is overridden with a clock + * that advances one frame step per read, driving the JS driver + * deterministically (the native driver ignores it and reads the frame clock + * in C++). + * + * This makes the JS and native drivers produce the same trajectory, so tests + * can assert one curve for both. + */ +export function collectAnimationTrajectory( + value: Animated.Value, + createAnimation: (useNativeDriver: boolean) => Animated.CompositeAnimation, + useNativeDriver: boolean, + durationMs: number, +): {samples: Array, finished: boolean} { + const samples: Array = []; + const listenerId = value.addListener(state => { + samples.push(state.value); + }); + + let animationTime = 0; + setAnimationTimeProvider(() => { + const current = animationTime; + animationTime += FRAME_STEP_MS; + return current; + }); + + let finished = false; + try { + Fantom.runTask(() => { + createAnimation(useNativeDriver).start(result => { + finished = result.finished; + }); + }); + Fantom.unstable_produceFramesForDuration(durationMs); + Fantom.runWorkLoop(); + } finally { + setAnimationTimeProvider(null); + value.removeListener(listenerId); + } + + return {samples, finished}; +} + +/** Diffs between consecutive samples. */ +export function deltas(samples: ReadonlyArray): Array { + const result: Array = []; + for (let i = 1; i < samples.length; i++) { + result.push(samples[i] - samples[i - 1]); + } + return result; +} + +/** + * Asserts the samples rise monotonically to their peak (allowing tiny + * floating-point noise), i.e. no dips on the way up. + */ +export function expectMonotonicToPeak(samples: ReadonlyArray): void { + const peakIndex = samples.indexOf(Math.max(...samples)); + for (let i = 1; i <= peakIndex; i++) { + expect(samples[i]).toBeGreaterThanOrEqual(samples[i - 1] - 1e-6); + } +} diff --git a/packages/react-native/Libraries/Animated/__tests__/DecayAnimation-itest.js b/packages/react-native/Libraries/Animated/__tests__/DecayAnimation-itest.js new file mode 100644 index 000000000000..6863a40b66c3 --- /dev/null +++ b/packages/react-native/Libraries/Animated/__tests__/DecayAnimation-itest.js @@ -0,0 +1,106 @@ +/** + * 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 + * @oncall react_native + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {HostInstance} from 'react-native'; + +import { + DRIVERS, + collectAnimationTrajectory, + deltas, + expectMonotonicToPeak, +} from './AnimatedFantomTestUtils'; +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'; + +function renderTranslateX(): Animated.Value { + let value: ?Animated.Value; + const viewRef = createRef(); + + function MyApp() { + const translateX = useAnimatedValue(0); + value = translateX; + return ( + + ); + } + + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + + return nullthrows(value); +} + +const VELOCITY = 2; +const DECELERATION = 0.99; +// value(t) = v0 / (1 - deceleration) * (1 - deceleration^t): the asymptote is +// v0 / (1 - deceleration). +const ASYMPTOTE = VELOCITY / (1 - DECELERATION); + +for (const {name, useNativeDriver} of DRIVERS) { + describe(`Animated.decay (${name})`, () => { + it('decelerates exponentially to a resting position', () => { + const value = renderTranslateX(); + + const {samples, finished} = collectAnimationTrajectory( + value, + driver => + Animated.decay(value, { + velocity: VELOCITY, + deceleration: DECELERATION, + useNativeDriver: driver, + }), + useNativeDriver, + 3000, + ); + + expect(finished).toBe(true); + expect(samples.length).toBeGreaterThan(10); + + // Comes to rest just below the analytical asymptote. + const rest = samples[samples.length - 1]; + expect(rest).toBeGreaterThan(ASYMPTOTE * 0.97); + expect(rest).toBeLessThanOrEqual(ASYMPTOTE + 1e-6); + + // Monotonically increasing to its resting position (no dips), which for + // decay is also the peak. + expectMonotonicToPeak(samples); + + // Exponential deceleration has two signatures over the early frames + // (before it converges into floating-point noise): + // 1. each per-frame step is strictly smaller than the previous one, and + // 2. the ratio between consecutive steps is (nearly) constant — the + // defining property of geometric/exponential decay, which a linear + // ramp (constant deltas, ratio 1) or an accelerating curve (ratio + // > 1) would fail. + const frameDeltas = deltas(samples); + const ratios: Array = []; + for (let i = 1; i < 9; i++) { + expect(frameDeltas[i]).toBeLessThan(frameDeltas[i - 1]); + ratios.push(frameDeltas[i] / frameDeltas[i - 1]); + } + for (const ratio of ratios) { + expect(ratio).toBeGreaterThan(0.75); + expect(ratio).toBeLessThan(0.95); + } + expect(Math.max(...ratios) - Math.min(...ratios)).toBeLessThan(0.1); + }); + }); +} diff --git a/packages/react-native/Libraries/Animated/__tests__/SpringAnimation-itest.js b/packages/react-native/Libraries/Animated/__tests__/SpringAnimation-itest.js new file mode 100644 index 000000000000..5d26452130de --- /dev/null +++ b/packages/react-native/Libraries/Animated/__tests__/SpringAnimation-itest.js @@ -0,0 +1,214 @@ +/** + * 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 + * @oncall react_native + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {HostInstance} from 'react-native'; + +import { + DRIVERS, + collectAnimationTrajectory, + expectMonotonicToPeak, +} from './AnimatedFantomTestUtils'; +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'; + +// Springs are observed through an 's opacity. The animation is +// bound to a mounted view (required for the native driver) and its per-frame +// trajectory is recorded from the value's listener. +function renderOpacity(): Animated.Value { + let value: ?Animated.Value; + const viewRef = createRef(); + + function MyApp() { + const opacity = useAnimatedValue(0); + value = opacity; + return ( + + ); + } + + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + + return nullthrows(value); +} + +function expectSettledAtTarget(sample: number, target: number): void { + expect(Math.abs(sample - target)).toBeLessThan(0.02); +} + +for (const {name, useNativeDriver} of DRIVERS) { + describe(`Animated.spring (${name})`, () => { + it('follows an underdamped curve that overshoots then settles', () => { + const value = renderOpacity(); + + // zeta = c / (2 * sqrt(k * m)) = 20 / (2 * sqrt(200)) ~= 0.707, which + // overshoots the target by exp(-zeta * pi / sqrt(1 - zeta^2)) ~= 4.3%. + const {samples, finished} = collectAnimationTrajectory( + value, + driver => + Animated.spring(value, { + toValue: 1, + stiffness: 200, + damping: 20, + mass: 1, + useNativeDriver: driver, + }), + useNativeDriver, + 3000, + ); + + expect(finished).toBe(true); + expect(samples.length).toBeGreaterThan(3); + // Rises smoothly through the full range (not a snap to the end). + expect(samples.some(v => v > 0.2 && v < 0.4)).toBe(true); + expect(samples.some(v => v > 0.6 && v < 0.8)).toBe(true); + expectMonotonicToPeak(samples); + + // Overshoots the target by the amount the damping ratio predicts. + const peak = Math.max(...samples); + expect(peak).toBeGreaterThan(1.02); + expect(peak).toBeLessThan(1.07); + + // Oscillates back down from the peak and settles at the target. + const last = samples[samples.length - 1]; + expect(last).toBeLessThan(peak); + expectSettledAtTarget(last, 1); + }); + + it('follows an overdamped curve that approaches the target without overshoot', () => { + const value = renderOpacity(); + + // zeta = 30 / (2 * sqrt(100)) = 1.5 > 1 (overdamped): a smooth sigmoid to + // the target with no overshoot. + const {samples, finished} = collectAnimationTrajectory( + value, + driver => + Animated.spring(value, { + toValue: 1, + stiffness: 100, + damping: 30, + mass: 1, + useNativeDriver: driver, + }), + useNativeDriver, + 3000, + ); + + expect(finished).toBe(true); + expect(samples.some(v => v > 0.2 && v < 0.4)).toBe(true); + expect(samples.some(v => v > 0.6 && v < 0.8)).toBe(true); + expectMonotonicToPeak(samples); + + // No meaningful overshoot for an overdamped spring. + expect(Math.max(...samples)).toBeLessThan(1.005); + expectSettledAtTarget(samples[samples.length - 1], 1); + }); + + it('does not overshoot past toValue when overshootClamping is enabled', () => { + const value = renderOpacity(); + + // Same underdamped config as the overshoot test, but clamping must + // suppress the overshoot entirely. + const {samples} = collectAnimationTrajectory( + value, + driver => + Animated.spring(value, { + toValue: 1, + stiffness: 200, + damping: 20, + mass: 1, + overshootClamping: true, + useNativeDriver: driver, + }), + useNativeDriver, + 3000, + ); + + expectMonotonicToPeak(samples); + // Clamped: overshoot is suppressed to well under the ~4.3% the same + // unclamped config produces (peak ~1.043). + expect(Math.max(...samples)).toBeLessThan(1.02); + expectSettledAtTarget(samples[samples.length - 1], 1); + }); + + it('settles at the target when configured via tension/friction', () => { + const value = renderOpacity(); + + // Exercises the Origami tension/friction -> stiffness/damping conversion + // in SpringConfig (`fromOrigamiTensionAndFriction`). + const {samples, finished} = collectAnimationTrajectory( + value, + driver => + Animated.spring(value, { + toValue: 1, + tension: 40, + friction: 7, + useNativeDriver: driver, + }), + useNativeDriver, + 3000, + ); + + expect(finished).toBe(true); + expectMonotonicToPeak(samples); + expectSettledAtTarget(samples[samples.length - 1], 1); + }); + + // `speed` values chosen so the bounciness/speed -> stiffness/damping + // conversion exercises all three friction regimes in SpringConfig + // (bouncyTension <= 18, 18 < bouncyTension <= 44, and > 44). + for (const speed of [2, 5, 12]) { + it(`settles at the target when configured via bounciness/speed (speed ${speed})`, () => { + const value = renderOpacity(); + + const {samples, finished} = collectAnimationTrajectory( + value, + driver => + Animated.spring(value, { + toValue: 1, + bounciness: 12, + speed, + useNativeDriver: driver, + }), + useNativeDriver, + 3000, + ); + + expect(finished).toBe(true); + expectSettledAtTarget(samples[samples.length - 1], 1); + }); + } + }); +} + +describe('Animated.spring config validation', () => { + it('throws when combining mutually exclusive config groups', () => { + const value = renderOpacity(); + + expect(() => { + Fantom.runTask(() => { + Animated.spring(value, { + toValue: 1, + stiffness: 100, + bounciness: 10, + useNativeDriver: true, + }).start(); + }); + }).toThrow(); + }); +}); diff --git a/packages/react-native/Libraries/Animated/animations/DecayAnimation.js b/packages/react-native/Libraries/Animated/animations/DecayAnimation.js index d6b834b032ee..07dc60372058 100644 --- a/packages/react-native/Libraries/Animated/animations/DecayAnimation.js +++ b/packages/react-native/Libraries/Animated/animations/DecayAnimation.js @@ -12,6 +12,7 @@ import type {PlatformConfig} from '../AnimatedPlatformConfig'; import type AnimatedValue from '../nodes/AnimatedValue'; import type {AnimationConfig, EndCallback} from './Animation'; +import {getCurrentAnimationTime} from '../AnimationTimingUtils'; import Animation from './Animation'; export type DecayAnimationConfig = Readonly<{ @@ -82,7 +83,7 @@ export default class DecayAnimation extends Animation { this._lastValue = fromValue; this._fromValue = fromValue; this._onUpdate = onUpdate; - this._startTime = Date.now(); + this._startTime = getCurrentAnimationTime(); const useNativeDriver = this.__startAnimationIfNative(animatedValue); // TODO: T274006331 - Remove js-only animation once shared backend is fully rolled out @@ -92,7 +93,7 @@ export default class DecayAnimation extends Animation { } onUpdate(): void { - const now = Date.now(); + const now = getCurrentAnimationTime(); const value = this._fromValue + diff --git a/packages/react-native/Libraries/Animated/animations/SpringAnimation.js b/packages/react-native/Libraries/Animated/animations/SpringAnimation.js index f04a527469b3..62ede656adce 100644 --- a/packages/react-native/Libraries/Animated/animations/SpringAnimation.js +++ b/packages/react-native/Libraries/Animated/animations/SpringAnimation.js @@ -14,6 +14,7 @@ import type AnimatedValue from '../nodes/AnimatedValue'; import type AnimatedValueXY from '../nodes/AnimatedValueXY'; import type {AnimationConfig, EndCallback} from './Animation'; +import {getCurrentAnimationTime} from '../AnimationTimingUtils'; import AnimatedColor from '../nodes/AnimatedColor'; import * as SpringConfig from '../SpringConfig'; import Animation from './Animation'; @@ -211,7 +212,7 @@ export default class SpringAnimation extends Animation { this._lastPosition = this._startPosition; this._onUpdate = onUpdate; - this._lastTime = Date.now(); + this._lastTime = getCurrentAnimationTime(); this._frameTime = 0.0; if (previousAnimation instanceof SpringAnimation) { @@ -274,7 +275,7 @@ export default class SpringAnimation extends Animation { // computation and will continue on the next frame. It's better to have it // running at faster speed than jumping to the end. const MAX_STEPS = 64; - let now = Date.now(); + let now = getCurrentAnimationTime(); if (now > this._lastTime + MAX_STEPS) { now = this._lastTime + MAX_STEPS; } diff --git a/packages/react-native/Libraries/Animated/animations/TimingAnimation.js b/packages/react-native/Libraries/Animated/animations/TimingAnimation.js index dffb737a9882..e83d1a449a05 100644 --- a/packages/react-native/Libraries/Animated/animations/TimingAnimation.js +++ b/packages/react-native/Libraries/Animated/animations/TimingAnimation.js @@ -16,6 +16,7 @@ import type AnimatedValueXY from '../nodes/AnimatedValueXY'; import type {AnimationConfig, EndCallback} from './Animation'; import * as ReactNativeFeatureFlags from '../../../src/private/featureflags/ReactNativeFeatureFlags'; +import {getCurrentAnimationTime} from '../AnimationTimingUtils'; import AnimatedColor from '../nodes/AnimatedColor'; import Animation from './Animation'; @@ -126,7 +127,7 @@ export default class TimingAnimation extends Animation { } const start = () => { - this._startTime = Date.now(); + this._startTime = getCurrentAnimationTime(); const useNativeDriver = this.__startAnimationIfNative(animatedValue); // TODO: T274006331 - Remove js-only animation once shared backend is fully rolled out @@ -150,7 +151,7 @@ export default class TimingAnimation extends Animation { } onUpdate(): void { - const now = Date.now(); + const now = getCurrentAnimationTime(); if (now >= this._startTime + this._duration) { if (this._duration === 0) { this._onUpdate(this._toValue); diff --git a/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-itest.js b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-itest.js index 7a9e2a47bc3c..d7f1636c2cdb 100644 --- a/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-itest.js +++ b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-itest.js @@ -10,12 +10,13 @@ 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, useEffect, useLayoutEffect, useRef} from 'react'; import {TextInput} from 'react-native'; -import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; describe('', () => { describe('props', () => { @@ -43,7 +44,7 @@ describe('', () => { describe('onChange', () => { it('is called when the change native event is dispatched', () => { const root = Fantom.createRoot(); - const nodeRef = createRef>(); + const nodeRef = createRef(); const onChange = jest.fn(); Fantom.runTask(() => { @@ -74,7 +75,7 @@ describe('', () => { describe('onChangeText', () => { it('is called when the change native event is dispatched', () => { const root = Fantom.createRoot(); - const nodeRef = createRef>(); + const nodeRef = createRef(); const onChangeText = jest.fn(); Fantom.runTask(() => { @@ -98,7 +99,7 @@ describe('', () => { describe('onFocus', () => { it('is called when the focus native event is dispatched', () => { const root = Fantom.createRoot(); - const nodeRef = createRef>(); + const nodeRef = createRef(); let focusEvent = jest.fn(); @@ -124,7 +125,7 @@ describe('', () => { describe('onBlur', () => { it('is called when the blur native event is dispatched', () => { const root = Fantom.createRoot(); - const nodeRef = createRef>(); + const nodeRef = createRef(); let blurEvent = jest.fn(); @@ -147,6 +148,113 @@ describe('', () => { }); }); + describe('onSelectionChange', () => { + it('is called with the updated selection', () => { + const root = Fantom.createRoot(); + const nodeRef = createRef(); + const onSelectionChange = jest.fn(); + + Fantom.runTask(() => { + root.render( + { + onSelectionChange(event.nativeEvent); + }} + ref={nodeRef}> + hello world + , + ); + }); + + Fantom.dispatchNativeEvent(nodeRef, 'selectionChange', { + selection: {start: 2, end: 5}, + }); + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + const [entry] = onSelectionChange.mock.lastCall; + expect(entry.selection).toEqual({start: 2, end: 5}); + }); + }); + + describe('onSubmitEditing', () => { + it('is called when the submit native event is dispatched', () => { + const root = Fantom.createRoot(); + const nodeRef = createRef(); + const onSubmitEditing = jest.fn(); + + Fantom.runTask(() => { + root.render( + { + onSubmitEditing(event.nativeEvent); + }} + ref={nodeRef} + />, + ); + }); + + Fantom.dispatchNativeEvent(nodeRef, 'submitEditing', { + text: 'submitted text', + }); + + expect(onSubmitEditing).toHaveBeenCalledTimes(1); + const [entry] = onSubmitEditing.mock.lastCall; + expect(entry.text).toEqual('submitted text'); + }); + }); + + describe('onKeyPress', () => { + it('is called with the pressed key', () => { + const root = Fantom.createRoot(); + const nodeRef = createRef(); + const onKeyPress = jest.fn(); + + Fantom.runTask(() => { + root.render( + { + onKeyPress(event.nativeEvent); + }} + ref={nodeRef} + />, + ); + }); + + Fantom.dispatchNativeEvent(nodeRef, 'keyPress', {key: 'a'}); + + expect(onKeyPress).toHaveBeenCalledTimes(1); + const [entry] = onKeyPress.mock.lastCall; + expect(entry.key).toEqual('a'); + }); + }); + + describe('onEndEditing', () => { + it('is called when the end editing native event is dispatched', () => { + const root = Fantom.createRoot(); + const nodeRef = createRef(); + const onEndEditing = jest.fn(); + + Fantom.runTask(() => { + root.render( + { + onEndEditing(event.nativeEvent); + }} + ref={nodeRef} + />, + ); + }); + + Fantom.dispatchNativeEvent(nodeRef, 'endEditing', { + text: 'final text', + }); + + expect(onEndEditing).toHaveBeenCalledTimes(1); + const [entry] = onEndEditing.mock.lastCall; + expect(entry.text).toEqual('final text'); + }); + }); + describe('id and nativeID', () => { it(`has 'id' propagated as 'nativeID' to the mounting layer`, () => { const root = Fantom.createRoot(); @@ -282,7 +390,7 @@ describe('', () => { describe('ref', () => { it('is an element node', () => { - const ref = createRef>(); + const ref = createRef(); const root = Fantom.createRoot(); @@ -290,7 +398,7 @@ describe('', () => { root.render(); }); - expect(ref.current).toBeInstanceOf(ReactNativeElement); + expect(ref.current).toBeInstanceOf(HTMLElement); }); it('provides additional methods: clear, isFocused, getNativeRef, setSelection', () => { @@ -311,7 +419,7 @@ describe('', () => { describe('focus()', () => { it('dispatches the focus command', () => { const root = Fantom.createRoot(); - const ref = createRef>(); + const ref = createRef(); Fantom.runTask(() => { root.render(); @@ -448,7 +556,7 @@ describe('', () => { describe('blur()', () => { it('does NOT dispatch any commands if the input is NOT focused', () => { const root = Fantom.createRoot(); - const ref = createRef>(); + const ref = createRef(); Fantom.runTask(() => { root.render(); @@ -467,7 +575,7 @@ describe('', () => { it('does dispatches the blur command if the input is focused', () => { const root = Fantom.createRoot(); - const ref = createRef>(); + const ref = createRef(); Fantom.runTask(() => { root.render(); diff --git a/packages/react-native/Libraries/Components/View/__tests__/View-nativeCSSParsing-itest.js b/packages/react-native/Libraries/Components/View/__tests__/View-nativeCSSParsing-itest.js new file mode 100644 index 000000000000..8aefac7b4248 --- /dev/null +++ b/packages/react-native/Libraries/Components/View/__tests__/View-nativeCSSParsing-itest.js @@ -0,0 +1,75 @@ +/** + * 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 + * @fantom_flags enableNativeCSSParsing:true + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {ViewStyleProp} from 'react-native/Libraries/StyleSheet/StyleSheet'; + +import * as Fantom from '@react-native/fantom'; +import * as React from 'react'; +import {View} from 'react-native'; + +// These tests render with string-valued CSS properties. With +// `enableNativeCSSParsing` forced on, the strings are parsed by the C++ CSS +// parsers (color functions, transforms, filters, box shadows, gradients), and +// we assert the resulting mounted props. `collapsable={false}` keeps views that +// carry only a non-layout prop present in the mounting layer. +function mountedProp(style: ViewStyleProp, prop: string): string { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + return root.getRenderedOutput({props: [prop]}).toJSONObject().props[prop]; +} + +describe(' native CSS parsing', () => { + describe('color functions', () => { + it('parses hwb() into an rgba color', () => { + // hwb(0 0% 0%) is pure red. + expect( + mountedProp({backgroundColor: 'hwb(0 0% 0%)'}, 'backgroundColor'), + ).toBe('rgba(255, 0, 0, 1)'); + }); + + it('parses hsl() into an rgba color', () => { + // hsl(120, 100%, 50%) is pure green. + expect( + mountedProp( + {backgroundColor: 'hsl(120, 100%, 50%)'}, + 'backgroundColor', + ), + ).toBe('rgba(0, 255, 0, 1)'); + }); + }); + + describe('transform', () => { + it('parses string transform syntax', () => { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + expect(root.getRenderedOutput({props: ['transform']}).toJSX()).toEqual( + , + ); + }); + }); + + describe('backgroundImage', () => { + it('parses a linear-gradient()', () => { + const backgroundImage = mountedProp( + {backgroundImage: 'linear-gradient(#e66465, #9198e5)'}, + 'backgroundImage', + ); + expect(backgroundImage).toContain('linear-gradient'); + expect(backgroundImage).toContain('rgba(230, 100, 101, 1)'); + }); + }); +}); diff --git a/packages/react-native/Libraries/Interaction/__tests__/PanResponder-itest.js b/packages/react-native/Libraries/Interaction/__tests__/PanResponder-itest.js new file mode 100644 index 000000000000..f5846d4be360 --- /dev/null +++ b/packages/react-native/Libraries/Interaction/__tests__/PanResponder-itest.js @@ -0,0 +1,143 @@ +/** + * 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 + * @oncall react_native + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {PanResponderCallbacks} from '../PanResponder'; +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 {PanResponder, View} from 'react-native'; + +function touchAt( + pageX: number, + pageY: number, + timestamp: number, +): {touches: Array<{...}>, changedTouches: Array<{...}>} { + return { + touches: [{identifier: 0, pageX, pageY, timestamp}], + changedTouches: [{identifier: 0, pageX, pageY, timestamp}], + }; +} + +function endAt( + pageX: number, + pageY: number, + timestamp: number, +): {touches: Array<{...}>, changedTouches: Array<{...}>} { + return { + touches: [], + changedTouches: [{identifier: 0, pageX, pageY, timestamp}], + }; +} + +describe('PanResponder', () => { + function renderWithHandlers(config: PanResponderCallbacks): HostInstance { + const panResponder = PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onMoveShouldSetPanResponder: () => true, + ...config, + }); + const ref = createRef(); + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + , + ); + }); + return nullthrows(ref.current); + } + + function dispatch( + element: HostInstance, + type: string, + payload: Readonly<{[key: string]: unknown}>, + ): void { + Fantom.dispatchNativeEvent(element, type, payload, { + category: Fantom.NativeEventCategory.Discrete, + }); + } + + it('grants the responder on touch start', () => { + const onPanResponderGrant = jest.fn(); + const element = renderWithHandlers({onPanResponderGrant}); + + dispatch(element, 'onTouchStart', touchAt(5, 5, 0)); + expect(onPanResponderGrant).toHaveBeenCalledTimes(1); + + dispatch(element, 'onTouchEnd', endAt(5, 5, 100)); + }); + + it('reports gesture displacement on move', () => { + let capturedDx = null; + let capturedDy = null; + const onPanResponderMove = jest.fn((event, gestureState) => { + capturedDx = gestureState.dx; + capturedDy = gestureState.dy; + }); + const element = renderWithHandlers({onPanResponderMove}); + + dispatch(element, 'onTouchStart', touchAt(0, 0, 0)); + dispatch(element, 'onTouchMove', touchAt(10, 20, 100)); + + expect(onPanResponderMove).toHaveBeenCalled(); + expect(capturedDx).toBe(10); + expect(capturedDy).toBe(20); + + dispatch(element, 'onTouchEnd', endAt(10, 20, 200)); + }); + + it('releases the responder on touch end', () => { + const onPanResponderRelease = jest.fn(); + const element = renderWithHandlers({onPanResponderRelease}); + + dispatch(element, 'onTouchStart', touchAt(5, 5, 0)); + dispatch(element, 'onTouchEnd', endAt(5, 5, 100)); + + expect(onPanResponderRelease).toHaveBeenCalledTimes(1); + }); + + it('terminates the responder on touch cancel', () => { + const onPanResponderTerminate = jest.fn(); + const element = renderWithHandlers({onPanResponderTerminate}); + + dispatch(element, 'onTouchStart', touchAt(5, 5, 0)); + dispatch(element, 'onTouchCancel', endAt(5, 5, 100)); + + expect(onPanResponderTerminate).toHaveBeenCalledTimes(1); + }); + + it('tracks the number of active touches across a gesture', () => { + let grantTouches = null; + let releaseTouches = null; + const element = renderWithHandlers({ + onPanResponderGrant: (event, gestureState) => { + grantTouches = gestureState.numberActiveTouches; + }, + onPanResponderRelease: (event, gestureState) => { + releaseTouches = gestureState.numberActiveTouches; + }, + }); + + dispatch(element, 'onTouchStart', touchAt(5, 5, 0)); + expect(grantTouches).toBe(1); + + dispatch(element, 'onTouchEnd', endAt(5, 5, 100)); + expect(releaseTouches).toBe(0); + }); +}); diff --git a/packages/react-native/Libraries/LayoutAnimation/__tests__/LayoutAnimation-itest.js b/packages/react-native/Libraries/LayoutAnimation/__tests__/LayoutAnimation-itest.js new file mode 100644 index 000000000000..8c5f9b76febd --- /dev/null +++ b/packages/react-native/Libraries/LayoutAnimation/__tests__/LayoutAnimation-itest.js @@ -0,0 +1,109 @@ +/** + * 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 + * @oncall react_native + */ + +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 {LayoutAnimation, View} from 'react-native'; + +// Note: Fantom's mock mounting layer commits the post-animation layout in a +// single mounting instruction — it does not replay the C++ +// LayoutAnimationKeyFrameManager's per-frame interpolation, so stepping the +// virtual clock exposes only the final layout (verified: the box jumps straight +// to its end size and `takeMountingManagerLogs` shows a single Update, with no +// intermediate frames). These tests therefore exercise the JS LayoutAnimation +// config/creation paths and assert that the configured layout change is +// applied; intermediate-frame verification would require frame-by-frame +// interpolation support in Fantom's mounting layer. +function renderBox( + viewRef: {current: HostInstance | null}, + width: number, +): React.MixedElement { + return ( + + ); +} + +describe('LayoutAnimation', () => { + it('applies the layout change scheduled with a preset', () => { + const viewRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(renderBox(viewRef, 100)); + }); + + const element = nullthrows(viewRef.current); + expect(element.getBoundingClientRect().width).toBe(100); + + Fantom.runTask(() => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + root.render(renderBox(viewRef, 300)); + }); + + Fantom.unstable_produceFramesForDuration(500); + Fantom.runWorkLoop(); + + expect(element.getBoundingClientRect().width).toBe(300); + }); + + it('supports LayoutAnimation.create() as the config', () => { + const viewRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(renderBox(viewRef, 100)); + }); + const element = nullthrows(viewRef.current); + + Fantom.runTask(() => { + LayoutAnimation.configureNext( + LayoutAnimation.create(200, 'linear', 'scaleXY'), + ); + root.render(renderBox(viewRef, 250)); + }); + + Fantom.unstable_produceFramesForDuration(200); + Fantom.runWorkLoop(); + + expect(element.getBoundingClientRect().width).toBe(250); + }); + + it('exposes the easeInEaseOut() shortcut', () => { + const viewRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(renderBox(viewRef, 100)); + }); + const element = nullthrows(viewRef.current); + + Fantom.runTask(() => { + LayoutAnimation.easeInEaseOut(); + root.render(renderBox(viewRef, 150)); + }); + + Fantom.unstable_produceFramesForDuration(500); + Fantom.runWorkLoop(); + + expect(element.getBoundingClientRect().width).toBe(150); + }); +}); diff --git a/packages/react-native/Libraries/Lists/__tests__/FlatList-itest.js b/packages/react-native/Libraries/Lists/__tests__/FlatList-itest.js index f45861320c83..15afb533d503 100644 --- a/packages/react-native/Libraries/Lists/__tests__/FlatList-itest.js +++ b/packages/react-native/Libraries/Lists/__tests__/FlatList-itest.js @@ -11,13 +11,11 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; import type {FlatListProps} from 'react-native/Libraries/Lists/FlatList'; -import ensureInstance from '../../../src/private/__tests__/utilities/ensureInstance'; import * as Fantom from '@react-native/fantom'; import nullthrows from 'nullthrows'; import * as React from 'react'; import {createRef} from 'react'; import {FlatList, Text, View} from 'react-native'; -import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; function testPropPropagatedToMountingLayer({ propName, @@ -482,9 +480,8 @@ describe('', () => { ); // Scroll down to trigger rendering of additional items. - const scrollView = ensureInstance( + const scrollView = nullthrows( nullthrows(flatListRef.current).getNativeScrollRef(), - ReactNativeElement, ); Fantom.scrollTo(scrollView, {x: 0, y: 100}); @@ -527,9 +524,8 @@ describe('', () => { ); }); - const scrollView = ensureInstance( + const scrollView = nullthrows( nullthrows(flatListRef.current).getNativeScrollRef(), - ReactNativeElement, ); Fantom.scrollTo(scrollView, {x: 0, y: 800}); @@ -538,6 +534,211 @@ describe('', () => { }); }); + describe('onViewableItemsChanged', () => { + it('reports viewable items after scrolling', () => { + const root = Fantom.createRoot({ + viewportWidth: 400, + viewportHeight: 200, + }); + const onViewableItemsChanged = jest.fn(); + const flatListRef = createRef>(); + Fantom.runTask(() => { + root.render( + ({key: String(i)}))} + renderItem={() => ( + + )} + getItemLayout={( + _data: ?Readonly<$ArrayLike<{key: string}>>, + index: number, + ) => ({ + length: 100, + offset: 100 * index, + index, + })} + viewabilityConfig={{itemVisiblePercentThreshold: 50}} + onViewableItemsChanged={onViewableItemsChanged} + initialNumToRender={4} + windowSize={5} + />, + ); + }); + + const scrollView = nullthrows( + nullthrows(flatListRef.current).getNativeScrollRef(), + ); + + // Scroll so items around index 3 become the viewable window. + Fantom.scrollTo(scrollView, {x: 0, y: 300}); + + expect(onViewableItemsChanged).toHaveBeenCalled(); + const lastCall = + onViewableItemsChanged.mock.calls[ + onViewableItemsChanged.mock.calls.length - 1 + ][0]; + const viewableKeys = lastCall.viewableItems.map( + (item: {key: string, ...}) => item.key, + ); + expect(viewableKeys).toContain('3'); + }); + + it('supports viewabilityConfigCallbackPairs', () => { + const root = Fantom.createRoot({ + viewportWidth: 400, + viewportHeight: 200, + }); + const onViewableItemsChanged = jest.fn(); + const flatListRef = createRef>(); + Fantom.runTask(() => { + root.render( + ({key: String(i)}))} + renderItem={() => ( + + )} + getItemLayout={( + _data: ?Readonly<$ArrayLike<{key: string}>>, + index: number, + ) => ({ + length: 100, + offset: 100 * index, + index, + })} + viewabilityConfigCallbackPairs={[ + { + viewabilityConfig: {viewAreaCoveragePercentThreshold: 50}, + onViewableItemsChanged, + }, + ]} + initialNumToRender={4} + windowSize={5} + />, + ); + }); + + const scrollView = nullthrows( + nullthrows(flatListRef.current).getNativeScrollRef(), + ); + + Fantom.scrollTo(scrollView, {x: 0, y: 300}); + + expect(onViewableItemsChanged).toHaveBeenCalled(); + const lastCall = + onViewableItemsChanged.mock.calls[ + onViewableItemsChanged.mock.calls.length - 1 + ][0]; + expect(lastCall.viewableItems.length).toBeGreaterThan(0); + }); + + it('respects minimumViewTime before reporting items', () => { + const root = Fantom.createRoot({ + viewportWidth: 400, + viewportHeight: 200, + }); + const onViewableItemsChanged = jest.fn(); + const flatListRef = createRef>(); + const timers = Fantom.installTimerMock(); + try { + Fantom.runTask(() => { + root.render( + ({key: String(i)}))} + renderItem={() => ( + + )} + getItemLayout={( + _data: ?Readonly<$ArrayLike<{key: string}>>, + index: number, + ) => ({ + length: 100, + offset: 100 * index, + index, + })} + viewabilityConfig={{ + itemVisiblePercentThreshold: 50, + minimumViewTime: 200, + }} + onViewableItemsChanged={onViewableItemsChanged} + initialNumToRender={4} + windowSize={5} + />, + ); + }); + + const scrollView = nullthrows( + nullthrows(flatListRef.current).getNativeScrollRef(), + ); + Fantom.scrollTo(scrollView, {x: 0, y: 300}); + + // Items must remain viewable for `minimumViewTime` before being + // reported, so nothing has fired yet. + expect(onViewableItemsChanged).not.toHaveBeenCalled(); + + timers.advanceTimersByTime(200); + + expect(onViewableItemsChanged).toHaveBeenCalled(); + } finally { + timers.uninstall(); + } + }); + + it('waits for interaction before reporting items when waitForInteraction is set', () => { + const root = Fantom.createRoot({ + viewportWidth: 400, + viewportHeight: 200, + }); + const onViewableItemsChanged = jest.fn(); + const flatListRef = createRef>(); + Fantom.runTask(() => { + root.render( + ({key: String(i)}))} + renderItem={() => ( + + )} + getItemLayout={( + _data: ?Readonly<$ArrayLike<{key: string}>>, + index: number, + ) => ({ + length: 100, + offset: 100 * index, + index, + })} + viewabilityConfig={{ + itemVisiblePercentThreshold: 50, + waitForInteraction: true, + }} + onViewableItemsChanged={onViewableItemsChanged} + initialNumToRender={4} + windowSize={5} + />, + ); + }); + + // Initially visible items are withheld until the user interacts. + expect(onViewableItemsChanged).not.toHaveBeenCalled(); + + // Signal an interaction (scrolling records this via onScrollBeginDrag; the + // public method is the deterministic equivalent), then a viewability + // re-evaluation reports the visible items. + Fantom.runTask(() => { + nullthrows(flatListRef.current).recordInteraction(); + }); + + const scrollView = nullthrows( + nullthrows(flatListRef.current).getNativeScrollRef(), + ); + Fantom.scrollTo(scrollView, {x: 0, y: 100}); + + expect(onViewableItemsChanged).toHaveBeenCalled(); + }); + }); + describe('extraData', () => { it('triggers re-render when extraData changes', () => { const root = Fantom.createRoot(); diff --git a/packages/react-native/Libraries/Network/__tests__/Network-itest.js b/packages/react-native/Libraries/Network/__tests__/Network-itest.js new file mode 100644 index 000000000000..5e9d748d799b --- /dev/null +++ b/packages/react-native/Libraries/Network/__tests__/Network-itest.js @@ -0,0 +1,90 @@ +/** + * 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 + * @oncall react_native + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +// `XMLHttpRequest` is set up as a global by the default environment (via +// `setUpXHR`), so these tests use the public global rather than importing the +// internal module. Fantom's HTTP client stub does not deliver responses, so +// they cover the synchronous XMLHttpRequest lifecycle and validation logic +// rather than response/progress/load/error events. +describe('XMLHttpRequest', () => { + it('transitions to OPENED after open()', () => { + const xhr = new XMLHttpRequest(); + expect(xhr.readyState).toBe(XMLHttpRequest.UNSENT); + + xhr.open('GET', 'https://example.com/data'); + expect(xhr.readyState).toBe(XMLHttpRequest.OPENED); + }); + + it('throws for synchronous requests', () => { + const xhr = new XMLHttpRequest(); + expect(() => { + xhr.open('GET', 'https://example.com', false); + }).toThrow('Synchronous http requests are not supported'); + }); + + it('throws when opening an empty url', () => { + const xhr = new XMLHttpRequest(); + expect(() => { + xhr.open('GET', ''); + }).toThrow('Cannot load an empty url'); + }); + + it('throws when setting a request header before open()', () => { + const xhr = new XMLHttpRequest(); + expect(() => { + xhr.setRequestHeader('Content-Type', 'application/json'); + }).toThrow('Request has not been opened'); + }); + + it('accepts request headers after open()', () => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', 'https://example.com'); + expect(() => { + xhr.setRequestHeader('Content-Type', 'application/json'); + }).not.toThrow(); + }); + + it('throws when sending before open()', () => { + const xhr = new XMLHttpRequest(); + expect(() => { + xhr.send(); + }).toThrow('Request has not been opened'); + }); + + it('exposes an empty responseText before loading', () => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', 'https://example.com'); + expect(xhr.responseText).toBe(''); + expect(xhr.status).toBe(0); + expect(xhr.getAllResponseHeaders()).toBe(null); + }); + + it('supports setting a valid responseType before send', () => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', 'https://example.com'); + xhr.responseType = 'json'; + expect(xhr.responseType).toBe('json'); + }); + + it('exposes the upload object and fires onreadystatechange when opened', () => { + const xhr = new XMLHttpRequest(); + const onReadyStateChange = jest.fn(); + xhr.onreadystatechange = onReadyStateChange; + + xhr.open('GET', 'https://example.com'); + + // Opening advances readyState to OPENED, firing readystatechange. + expect(onReadyStateChange).toHaveBeenCalled(); + expect(xhr.upload).toBeDefined(); + }); +}); diff --git a/packages/react-native/Libraries/Pressability/__tests__/Pressability-touch-itest.js b/packages/react-native/Libraries/Pressability/__tests__/Pressability-touch-itest.js new file mode 100644 index 000000000000..7b31bc57a0c6 --- /dev/null +++ b/packages/react-native/Libraries/Pressability/__tests__/Pressability-touch-itest.js @@ -0,0 +1,167 @@ +/** + * 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 + * @oncall react_native + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {PressabilityConfig} from '../Pressability'; +import type {HostInstance} from 'react-native'; + +import usePressability from '../usePressability'; +import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; +import * as React from 'react'; +import {createRef} from 'react'; +import {View} from 'react-native'; + +function touchPayload( + identifier: number = 0, + pageX: number = 5, + pageY: number = 5, +): {touches: Array<{...}>, changedTouches: Array<{...}>} { + return { + touches: [{identifier, pageX, pageY, timestamp: 0}], + changedTouches: [{identifier, pageX, pageY, timestamp: 0}], + }; +} + +function touchEndPayload(identifier: number = 0): { + touches: Array<{...}>, + changedTouches: Array<{...}>, +} { + return { + touches: [], + changedTouches: [{identifier, pageX: 5, pageY: 5, timestamp: 200}], + }; +} + +function PressabilityTestView({ + config, + ...viewProps +}: { + config: PressabilityConfig, + ref?: React.RefSetter, + style?: {height: number, width: number}, +}) { + const eventHandlers = usePressability(config); + return ; +} + +function renderPressability(config: PressabilityConfig): HostInstance { + const ref = createRef(); + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + , + ); + }); + return nullthrows(ref.current); +} + +function touchStart(element: HostInstance): void { + Fantom.dispatchNativeEvent(element, 'onTouchStart', touchPayload(), { + category: Fantom.NativeEventCategory.Discrete, + }); +} + +function touchEnd(element: HostInstance): void { + Fantom.dispatchNativeEvent(element, 'onTouchEnd', touchEndPayload(), { + category: Fantom.NativeEventCategory.Discrete, + }); +} + +describe('Pressability (touch)', () => { + let timers: Fantom.TimerMock; + + beforeEach(() => { + timers = Fantom.installTimerMock(); + }); + + afterEach(() => { + timers.uninstall(); + }); + + it('fires onPressIn on touch start and onPressOut/onPress on touch end', () => { + const onPressIn = jest.fn(); + const onPressOut = jest.fn(); + const onPress = jest.fn(); + + const element = renderPressability({onPressIn, onPressOut, onPress}); + + touchStart(element); + expect(onPressIn).toHaveBeenCalledTimes(1); + expect(onPressOut).toHaveBeenCalledTimes(0); + + touchEnd(element); + // Flush the minimum-press-duration timer that gates onPressOut. + timers.runAllTimers(); + + expect(onPress).toHaveBeenCalledTimes(1); + expect(onPressOut).toHaveBeenCalledTimes(1); + }); + + it('fires onLongPress after the long press delay and suppresses onPress', () => { + const onLongPress = jest.fn(); + const onPress = jest.fn(); + + const element = renderPressability({onLongPress, onPress}); + + touchStart(element); + // Default long press delay is 500ms. + timers.advanceTimersByTime(500); + expect(onLongPress).toHaveBeenCalledTimes(1); + + touchEnd(element); + timers.runAllTimers(); + + // A long press does not additionally fire onPress. + expect(onPress).toHaveBeenCalledTimes(0); + }); + + it('delays onPressIn when delayPressIn is set', () => { + const onPressIn = jest.fn(); + + const element = renderPressability({ + onPressIn, + delayPressIn: 200, + }); + + touchStart(element); + expect(onPressIn).toHaveBeenCalledTimes(0); + + timers.advanceTimersByTime(200); + expect(onPressIn).toHaveBeenCalledTimes(1); + + // Release the responder to clean up global responder state. + touchEnd(element); + timers.runAllTimers(); + }); + + it('does not fire onPress when the responder is terminated', () => { + const onPress = jest.fn(); + const onPressIn = jest.fn(); + + const element = renderPressability({onPress, onPressIn}); + + touchStart(element); + expect(onPressIn).toHaveBeenCalledTimes(1); + + Fantom.dispatchNativeEvent(element, 'onTouchCancel', touchEndPayload(), { + category: Fantom.NativeEventCategory.Discrete, + }); + timers.runAllTimers(); + + expect(onPress).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/react-native/Libraries/Text/__tests__/Text-itest.js b/packages/react-native/Libraries/Text/__tests__/Text-itest.js index a902d3d528a0..a7a50140cd5a 100644 --- a/packages/react-native/Libraries/Text/__tests__/Text-itest.js +++ b/packages/react-native/Libraries/Text/__tests__/Text-itest.js @@ -10,10 +10,12 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; -import type {AccessibilityProps, HostInstance} from 'react-native'; +import type {HostInstance} from 'react-native'; +import type {AccessibilityProps} from 'react-native'; import ensureInstance from '../../../src/private/__tests__/utilities/ensureInstance'; import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; import * as React from 'react'; import {createRef} from 'react'; import {Text} from 'react-native'; @@ -21,7 +23,7 @@ import accessibilityPropsSuite, { rolePropSuite, } from 'react-native/src/private/__tests__/utilities/accessibilityPropsSuite'; import {testIDPropSuite} from 'react-native/src/private/__tests__/utilities/commonPropsSuite'; -import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; +import ReadOnlyElement from 'react-native/src/private/webapis/dom/nodes/ReadOnlyElement'; import ReadOnlyText from 'react-native/src/private/webapis/dom/nodes/ReadOnlyText'; const TEST_TEXT = 'the text'; @@ -579,7 +581,7 @@ describe('', () => { root.render({TEST_TEXT}); }); - const element = ensureInstance(elementRef.current, ReactNativeElement); + const element = nullthrows(elementRef.current); expect(element.tagName).toBe('RN:Paragraph'); }); @@ -592,7 +594,7 @@ describe('', () => { root.render({TEST_TEXT}); }); - const element = ensureInstance(elementRef.current, ReactNativeElement); + const element = nullthrows(elementRef.current); expect(element.childNodes.length).toBe(1); const textChild = ensureInstance(element.childNodes[0], ReadOnlyText); @@ -612,7 +614,7 @@ describe('', () => { ); }); - const element = ensureInstance(elementRef.current, ReactNativeElement); + const element = nullthrows(elementRef.current); expect(element.childNodes.length).toBe(2); const firstChild = ensureInstance(element.childNodes[0], ReadOnlyText); @@ -620,7 +622,7 @@ describe('', () => { const secondChild = ensureInstance( element.childNodes[1], - ReactNativeElement, + ReadOnlyElement, ); expect(secondChild.tagName).toBe('RN:Text'); expect(secondChild.childNodes.length).toBe(1); @@ -816,6 +818,41 @@ describe('', () => { }); }); + describe('text style props', () => { + it('propagates letterSpacing to the mounting layer', () => { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render({TEST_TEXT}); + }); + expect( + root.getRenderedOutput({props: ['letterSpacing']}).toJSX(), + ).toEqual({TEST_TEXT}); + }); + + it('propagates lineHeight to the mounting layer', () => { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render({TEST_TEXT}); + }); + expect(root.getRenderedOutput({props: ['lineHeight']}).toJSX()).toEqual( + {TEST_TEXT}, + ); + }); + + it('propagates fontVariant to the mounting layer', () => { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + {TEST_TEXT}, + ); + }); + const fontVariant = root + .getRenderedOutput({props: ['fontVariant']}) + .toJSONObject().props.fontVariant; + expect(fontVariant).toContain('small-caps'); + }); + }); + component TestComponent(testID?: ?string, ...props: AccessibilityProps) { return (