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..a5a06d9bb22d --- /dev/null +++ b/packages/react-native/Libraries/Animated/__tests__/AnimatedColor-itest.js @@ -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( + , + ); + }); + 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(); + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + , + ); + }); + nullthrows(viewRef.current); + + // Initial value is observed publicly through the rendered output. + 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: 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)'); + }); +}); 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..d58eee52e3e8 --- /dev/null +++ b/packages/react-native/Libraries/Animated/__tests__/AnimatedComposition-itest.js @@ -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 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 = new Animated.Value(0); + 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, 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(); + + 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(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); + }); +}); 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..40aa96557194 --- /dev/null +++ b/packages/react-native/Libraries/Animated/__tests__/DecayAnimation-itest.js @@ -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); + }); +}); 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..e53e0cc4dcee --- /dev/null +++ b/packages/react-native/Libraries/Animated/__tests__/SpringAnimation-itest.js @@ -0,0 +1,181 @@ +/** + * 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 bound to an AnimatedValue and +// returns the value plus the mounted element. Spring/decay configs are all +// exercised through the public `Animated.spring`/`Animated.decay` API with the +// native driver, which builds the JS animation instance (running its config +// conversion and invariants) and hands the config to the C++ backend. +function renderTranslateX(): { + value: Animated.Value, + element: HostInstance, + root: Fantom.Root, +} { + let value: Animated.Value = new Animated.Value(0); + const viewRef = createRef(); + + function MyApp() { + const translateX = useAnimatedValue(0); + value = translateX; + return ( + + ); + } + + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + + const element = nullthrows(viewRef.current); + return {value, element, root}; +} + +function getTranslateX(root: Fantom.Root): number { + const output = root.getRenderedOutput({props: ['transform']}).toJSONObject(); + return JSON.parse(output.props.transform)[0].translateX; +} + +describe('Animated.spring', () => { + // Runs first so it drives frames from a fresh virtual clock. Uses the JS + // driver so the closed-form spring integration (`onUpdate`) runs in JS; the + // settled value is observed through the rendered transform. + it('settles exactly at toValue with the JS driver', () => { + const {value, root} = renderTranslateX(); + + let finished = false; + Fantom.runTask(() => { + Animated.spring(value, { + toValue: 100, + useNativeDriver: false, + }).start(result => { + finished = result.finished; + }); + }); + + Fantom.unstable_produceFramesForDuration(3000); + + expect(finished).toBe(true); + expect(getTranslateX(root)).toBe(100); + }); + + it('settles at toValue with the default tension/friction config', () => { + const {value, element} = renderTranslateX(); + + let finished = false; + Fantom.runTask(() => { + Animated.spring(value, { + toValue: 100, + useNativeDriver: true, + }).start(result => { + finished = result.finished; + }); + }); + + Fantom.unstable_produceFramesForDuration(3000); + Fantom.runWorkLoop(); + + expect(finished).toBe(true); + expect(element.getBoundingClientRect().x).toBe(100); + }); + + it('settles at toValue when configured via bounciness/speed', () => { + const {value, element} = renderTranslateX(); + + Fantom.runTask(() => { + Animated.spring(value, { + toValue: 80, + bounciness: 12, + speed: 12, + useNativeDriver: true, + }).start(); + }); + + Fantom.unstable_produceFramesForDuration(3000); + Fantom.runWorkLoop(); + + expect(element.getBoundingClientRect().x).toBe(80); + }); + + it('settles at toValue when configured via stiffness/damping/mass', () => { + const {value, element} = renderTranslateX(); + + Fantom.runTask(() => { + Animated.spring(value, { + toValue: 60, + stiffness: 200, + damping: 20, + mass: 1, + useNativeDriver: true, + }).start(); + }); + + Fantom.unstable_produceFramesForDuration(3000); + Fantom.runWorkLoop(); + + expect(element.getBoundingClientRect().x).toBe(60); + }); + + it('does not overshoot past toValue when overshootClamping is enabled', () => { + const {value, element} = renderTranslateX(); + + let maxObserved = 0; + Fantom.runTask(() => { + value.addListener(({value: v}) => { + maxObserved = Math.max(maxObserved, v); + }); + }); + + Fantom.runTask(() => { + Animated.spring(value, { + toValue: 100, + overshootClamping: true, + // A low-damping spring would overshoot without clamping. + stiffness: 500, + damping: 5, + mass: 1, + useNativeDriver: true, + }).start(); + }); + + Fantom.unstable_produceFramesForDuration(3000); + Fantom.runWorkLoop(); + + expect(element.getBoundingClientRect().x).toBe(100); + }); + + it('throws when combining mutually exclusive config groups', () => { + const {value} = renderTranslateX(); + + expect(() => { + Fantom.runTask(() => { + Animated.spring(value, { + toValue: 1, + stiffness: 100, + bounciness: 10, + useNativeDriver: true, + }).start(); + }); + }).toThrow(); + }); +}); 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..0209c2967828 100644 --- a/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-itest.js +++ b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-itest.js @@ -15,7 +15,6 @@ 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', () => { @@ -147,6 +146,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(); @@ -290,7 +396,7 @@ describe('', () => { root.render(); }); - expect(ref.current).toBeInstanceOf(ReactNativeElement); + expect(ref.current).toBeInstanceOf(HTMLElement); }); it('provides additional methods: clear, isFocused, getNativeRef, setSelection', () => { 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..27245766b251 --- /dev/null +++ b/packages/react-native/Libraries/LayoutAnimation/__tests__/LayoutAnimation-itest.js @@ -0,0 +1,104 @@ +/** + * 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 applies the post-animation layout without +// exposing frame-by-frame interpolation, so these tests exercise the JS +// LayoutAnimation config/creation paths and assert the resulting layout instead +// of intermediate frames. +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..817d4e4f4043 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,106 @@ 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); + }); + }); + 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..1a1a6bfac2a0 --- /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('supports the upload object and event listeners', () => { + 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 (