diff --git a/packages/docs-gesture-handler/docs/guides/testing.mdx b/packages/docs-gesture-handler/docs/guides/testing.mdx index ecefa47ae5..0097362309 100644 --- a/packages/docs-gesture-handler/docs/guides/testing.mdx +++ b/packages/docs-gesture-handler/docs/guides/testing.mdx @@ -62,7 +62,119 @@ In order to load mocks provided by RNGH, add the following to your jest config: ## Testing Gestures' and Gesture handlers' callbacks -RNGH provides APIs, specifically [`fireGestureHandler`](#firegesturehandler) and [`getByGestureTestId`](#getbygesturetestid), to trigger selected handlers. +RNGH provides the following APIs for triggering selected handlers: + +- [`createGestureController`](#creategesturecontroller) to imperatively control a gesture lifecycle one step at a time. +- [`fireGestureHandler`](#firegesturehandler) to dispatch a complete event stream. +- [`getByGestureTestId`](#getbygesturetestid) to find a gesture by its test ID. + +### createGestureController + + GestureController; + +export interface GestureController< + TEventPayload extends Record = Record, +> { + begin: (event?: GestureControllerEvent) => void; + activate: (event?: GestureControllerEvent) => void; + update: (event?: GestureControllerEvent) => void; + end: (event?: GestureControllerEvent) => void; + fail: (event?: GestureControllerEvent) => void; + cancel: (event?: GestureControllerEvent) => void; +} + +export type GestureControllerEvent< + TEventPayload extends Record = Record, +> = Partial & { + handlerTag?: never; + nativeEvent?: never; + oldState?: never; + state?: never; +}; +`}/> + +Creates an imperative controller that dispatches gesture lifecycle events one step +at a time. This allows the test to assert application state between lifecycle steps +without manually supplying `state`, `oldState`, or `handlerTag`. + +`componentOrGesture` can be: + +- A Gesture Handler component found using a Jest query such as `getByTestId`. +- A gesture object. +- A gesture test ID string. + +When a gesture object is passed directly, the event payload type is inferred from +the gesture. Every lifecycle method accepts an optional partial event payload and +fills omitted handler-specific properties with defaults. + +The controller exposes the following methods: + +| Method | Behavior | +| ------------ | ---------------------------------------------------------------------------------------------------------------- | +| `begin()` | Starts a stream and calls `onBegin`. If the previous stream finished, the controller resets it before beginning. | +| `activate()` | Activates a begun stream and calls `onActivate`. | +| `update()` | Dispatches an update for an active stream and calls `onUpdate`. It can be called multiple times. | +| `end()` | Ends a begun or active stream. Calls `onDeactivate` if active, then `onFinalize` with `canceled: false`. | +| `fail()` | Fails a begun or active stream. Calls `onDeactivate` if active, then `onFinalize` with `canceled: true`. | +| `cancel()` | Cancels a begun or active stream. Calls `onDeactivate` if active, then `onFinalize` with `canceled: true`. | + +Calling `begin()` again after `end()`, `fail()`, or `cancel()` starts another stream with the same controller. +Calling methods in an invalid order throws an error. State-machine fields such as +`state`, `oldState`, `handlerTag`, and `nativeEvent` cannot be supplied in event +payloads because the controller manages them internally. + +```tsx +test('updates application state after each gesture step', () => { + const onBegin = jest.fn(); + const onActivate = jest.fn(); + const onUpdate = jest.fn(); + const onDeactivate = jest.fn(); + const onFinalize = jest.fn(); + + const panGesture = renderHook(() => + usePanGesture({ + disableReanimated: true, + onBegin, + onActivate, + onUpdate, + onDeactivate, + onFinalize, + }) + ).result.current; + + const controller = createGestureController(panGesture); + + controller.begin(); + expect(onBegin).toHaveBeenCalledTimes(1); + + controller.activate(); + expect(onActivate).toHaveBeenCalledTimes(1); + + controller.update({ translationX: 50 }); + expect(onUpdate).toHaveBeenCalledWith( + expect.objectContaining({ translationX: 50 }) + ); + + controller.end(); + expect(onDeactivate).toHaveBeenCalledTimes(1); + expect(onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ canceled: false }) + ); +}); +``` + +:::note +`createGestureController` controls lifecycle events directly. It does not generate +pointer input, run platform gesture recognizers, or evaluate relations between +gestures. +::: ### fireGestureHandler @@ -133,7 +245,7 @@ components. `testID` must be unique among components rendered in test. ::: -## Example +## fireGestureHandler example Extracted from RNGH tests, check [`api_v3.test.tsx`](https://github.com/software-mansion/react-native-gesture-handler/blob/main/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx) for full implementation. diff --git a/packages/react-native-gesture-handler/src/__tests__/gestureController.test.tsx b/packages/react-native-gesture-handler/src/__tests__/gestureController.test.tsx new file mode 100644 index 0000000000..d04a6e80f7 --- /dev/null +++ b/packages/react-native-gesture-handler/src/__tests__/gestureController.test.tsx @@ -0,0 +1,298 @@ +import { render, renderHook } from '@testing-library/react-native'; +import { View } from 'react-native'; + +import GestureHandlerRootView from '../components/GestureHandlerRootView'; +import { createGestureController } from '../jestUtils'; +import { State } from '../State'; +import { GestureDetector } from '../v3/detectors'; +import type { PanGesture, TapGesture } from '../v3/hooks/gestures'; +import { usePanGesture, useTapGesture } from '../v3/hooks/gestures'; + +function mockedContinuousCallbacks() { + const order: string[] = []; + + return { + order, + callbacks: { + onBegin: jest.fn(() => order.push('onBegin')), + onActivate: jest.fn(() => order.push('onActivate')), + onUpdate: jest.fn(() => order.push('onUpdate')), + onDeactivate: jest.fn(() => order.push('onDeactivate')), + onFinalize: jest.fn(() => order.push('onFinalize')), + }, + }; +} + +function mockedDiscreteCallbacks() { + const order: string[] = []; + + return { + order, + callbacks: { + onBegin: jest.fn(() => order.push('onBegin')), + onActivate: jest.fn(() => order.push('onActivate')), + onDeactivate: jest.fn(() => order.push('onDeactivate')), + onFinalize: jest.fn(() => order.push('onFinalize')), + }, + }; +} + +function assertGestureControllerTypes(pan: PanGesture, tap: TapGesture) { + const panController = createGestureController(pan); + panController.begin({ x: 1 }); + panController.update({ translationX: 1, velocityX: 2 }); + // @ts-expect-error unknown pan payload field + panController.update({ translationXX: 1 }); + // @ts-expect-error raw state-machine fields are managed internally + panController.begin({ state: State.BEGAN }); + + const tapController = createGestureController(tap); + tapController.begin({ x: 1 }); + // @ts-expect-error tap payloads do not include pan translation fields + tapController.update({ translationX: 1 }); + + const stringController = createGestureController<{ translationX: number }>( + 'pan-id' + ); + stringController.update({ translationX: 1 }); + // @ts-expect-error explicit payload type is respected for string targets + stringController.update({ translationXX: 1 }); +} + +void assertGestureControllerTypes; + +function getControllerState(gesture: object): State { + return (gesture as { state: State }).state; +} + +describe('createGestureController', () => { + test('transitions to the expected state after each gesture lifecycle step', () => { + const { order, callbacks } = mockedContinuousCallbacks(); + const pan = renderHook(() => usePanGesture(callbacks)).result.current; + const gesture = createGestureController(pan); + + gesture.begin({ translationX: 0 }); + + expect(order).toEqual(['onBegin']); + expect(getControllerState(gesture)).toBe(State.BEGAN); + expect(callbacks.onBegin).toHaveBeenCalledWith( + expect.objectContaining({ translationX: 0 }) + ); + + gesture.activate({ translationX: 10 }); + + expect(order).toEqual(['onBegin', 'onActivate']); + expect(getControllerState(gesture)).toBe(State.ACTIVE); + expect(callbacks.onActivate).toHaveBeenCalledWith( + expect.objectContaining({ translationX: 10 }) + ); + + gesture.update({ translationX: 50 }); + + expect(order).toEqual(['onBegin', 'onActivate', 'onUpdate']); + expect(getControllerState(gesture)).toBe(State.ACTIVE); + expect(callbacks.onUpdate).toHaveBeenCalledWith( + expect.objectContaining({ translationX: 50 }) + ); + + gesture.end({ translationX: 50 }); + + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onUpdate', + 'onDeactivate', + 'onFinalize', + ]); + expect(getControllerState(gesture)).toBe(State.END); + expect(callbacks.onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ canceled: false, translationX: 50 }) + ); + }); + + test('can fail a gesture before activation', () => { + const { order, callbacks } = mockedDiscreteCallbacks(); + const tap = renderHook(() => useTapGesture(callbacks)).result.current; + const gesture = createGestureController(tap); + + gesture.begin({ x: 10 }); + gesture.fail({ x: 10 }); + + expect(order).toEqual(['onBegin', 'onFinalize']); + expect(getControllerState(gesture)).toBe(State.FAILED); + expect(callbacks.onActivate).not.toHaveBeenCalled(); + expect(callbacks.onDeactivate).not.toHaveBeenCalled(); + expect(callbacks.onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ canceled: true, x: 10 }) + ); + }); + + test('resolves gesture test ID strings internally', () => { + const { order, callbacks } = mockedDiscreteCallbacks(); + renderHook(() => + useTapGesture({ + testID: 'controlled-tap', + ...callbacks, + }) + ); + const gesture = createGestureController('controlled-tap'); + + gesture.begin(); + gesture.activate(); + gesture.end(); + + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onDeactivate', + 'onFinalize', + ]); + }); + + test('controls a gesture created inside a component', () => { + const onBegin = jest.fn(); + + function ComponentWithGesture() { + const tap = useTapGesture({ + testID: 'component-tap', + onBegin, + }); + + return ( + + + + + + ); + } + + render(); + + const gesture = createGestureController('component-tap'); + gesture.begin(); + + expect(onBegin).toHaveBeenCalledTimes(1); + }); + + test('guards against invalid lifecycle order', () => { + const tap = renderHook(() => useTapGesture()).result.current; + const gesture = createGestureController(tap); + + expect(() => gesture.update()).toThrow( + 'Cannot update gesture from UNDETERMINED state.' + ); + + gesture.begin(); + + expect(() => gesture.begin()).toThrow( + 'Cannot begin gesture from BEGAN state.' + ); + }); + + test.each([ + ['end', State.END], + ['fail', State.FAILED], + ['cancel', State.CANCELLED], + ] as const)( + 'reuses the controller for streams ending with %s', + (terminalAction, terminalState) => { + const onBegin = jest.fn(); + const onFinalize = jest.fn(); + const tap = renderHook(() => useTapGesture({ onBegin, onFinalize })) + .result.current; + const gesture = createGestureController(tap); + + gesture.begin(); + gesture[terminalAction](); + + expect(getControllerState(gesture)).toBe(terminalState); + + gesture.begin(); + + expect(getControllerState(gesture)).toBe(State.BEGAN); + + gesture[terminalAction](); + + expect(getControllerState(gesture)).toBe(terminalState); + expect(onBegin).toHaveBeenCalledTimes(2); + expect(onFinalize).toHaveBeenCalledTimes(2); + } + ); + + test('rejects raw state-machine fields in event payloads', () => { + const onBegin = jest.fn(); + const tap = renderHook(() => useTapGesture({ onBegin })).result.current; + const gesture = createGestureController(tap); + + expect(() => gesture.begin({ state: State.BEGAN } as never)).toThrow( + "GestureController manages 'state' internally." + ); + expect(getControllerState(gesture)).toBe(State.UNDETERMINED); + expect(onBegin).not.toHaveBeenCalled(); + + gesture.begin(); + + expect(getControllerState(gesture)).toBe(State.BEGAN); + }); + + test('uses the latest callbacks after rerender', () => { + const calls: string[] = []; + const hook = renderHook( + ({ value }: { value: number }) => + usePanGesture({ + onBegin: () => calls.push(`begin-${value}`), + onActivate: () => calls.push(`activate-${value}`), + }), + { initialProps: { value: 1 } } + ); + const gesture = createGestureController(hook.result.current); + + gesture.begin(); + hook.rerender({ value: 2 }); + gesture.activate(); + + expect(calls).toEqual(['begin-1', 'activate-2']); + }); + + test('uses the latest enabled value after rerender', () => { + const onBegin = jest.fn(); + const hook = renderHook( + ({ enabled }: { enabled: boolean }) => + usePanGesture({ enabled, onBegin }), + { initialProps: { enabled: true } } + ); + const gesture = createGestureController(hook.result.current); + + hook.rerender({ enabled: false }); + gesture.begin(); + + expect(getControllerState(gesture)).toBe(State.UNDETERMINED); + expect(onBegin).not.toHaveBeenCalled(); + + hook.rerender({ enabled: true }); + gesture.begin(); + + expect(getControllerState(gesture)).toBe(State.BEGAN); + expect(onBegin).toHaveBeenCalledTimes(1); + }); + + test('disabled gestures are no-ops', () => { + const { order, callbacks } = mockedContinuousCallbacks(); + const pan = renderHook(() => + usePanGesture({ + enabled: false, + ...callbacks, + }) + ).result.current; + const gesture = createGestureController(pan); + + gesture.begin(); + gesture.activate(); + gesture.update({ translationX: 50 }); + gesture.end(); + + expect(getControllerState(gesture)).toBe(State.UNDETERMINED); + expect(order).toEqual([]); + }); +}); diff --git a/packages/react-native-gesture-handler/src/handlers/handlersRegistry.ts b/packages/react-native-gesture-handler/src/handlers/handlersRegistry.ts index 2c4426d974..2489478270 100644 --- a/packages/react-native-gesture-handler/src/handlers/handlersRegistry.ts +++ b/packages/react-native-gesture-handler/src/handlers/handlersRegistry.ts @@ -24,19 +24,29 @@ export function registerGesture< handlerTag: number, gesture: SingleGesture ) { - if (isTestEnv() && gesture.config.testID) { - hookGestures.set(handlerTag, gesture); + if (!isTestEnv()) { + return; + } + + hookGestures.set(handlerTag, gesture); + + if (gesture.config.testID) { testIDs.set(gesture.config.testID, handlerTag); } } export function unregisterGesture(handlerTag: number) { + if (!isTestEnv()) { + return; + } + const gesture = hookGestures.get(handlerTag); - if (gesture && isTestEnv() && gesture.config.testID) { + if (gesture?.config?.testID) { testIDs.delete(gesture.config.testID); - hookGestures.delete(handlerTag); } + + hookGestures.delete(handlerTag); } export function registerHandler( diff --git a/packages/react-native-gesture-handler/src/jestUtils/index.ts b/packages/react-native-gesture-handler/src/jestUtils/index.ts index 99351f2813..48db50fc32 100644 --- a/packages/react-native-gesture-handler/src/jestUtils/index.ts +++ b/packages/react-native-gesture-handler/src/jestUtils/index.ts @@ -1 +1,6 @@ -export { fireGestureHandler, getByGestureTestId } from './jestUtils'; +export type { GestureController, GestureControllerEvent } from './jestUtils'; +export { + createGestureController, + fireGestureHandler, + getByGestureTestId, +} from './jestUtils'; diff --git a/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts b/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts index d40060e43f..6591ba228d 100644 --- a/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts +++ b/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts @@ -31,7 +31,7 @@ import type { PanGesture } from '../handlers/gestures/panGesture'; import type { PinchGesture } from '../handlers/gestures/pinchGesture'; import type { RotationGesture } from '../handlers/gestures/rotationGesture'; import type { TapGesture } from '../handlers/gestures/tapGesture'; -import { findHandlerByTestID } from '../handlers/handlersRegistry'; +import { findGesture, findHandlerByTestID } from '../handlers/handlersRegistry'; import type { LongPressGestureHandler } from '../handlers/LongPressGestureHandler'; import { longPressHandlerName } from '../handlers/LongPressGestureHandler'; import type { NativeViewGestureHandler } from '../handlers/NativeViewGestureHandler'; @@ -47,7 +47,7 @@ import { tapHandlerName } from '../handlers/TapGestureHandler'; import { State } from '../State'; import { hasProperty, withPrevAndCurrent } from '../utils'; import { maybeUnpackValue } from '../v3/hooks/utils'; -import type { SingleGesture } from '../v3/types'; +import type { DetectorCallbacks, SingleGesture } from '../v3/types'; // Load fireEvent conditionally, so RNGH may be used in setups without testing-library let fireEvent = ( @@ -491,6 +491,272 @@ type ExtractConfig = ? ExtractPayloadFromProps : Record; +type ExtractHookGesturePayload = T extends { + detectorCallbacks: DetectorCallbacks; +} + ? TExtendedHandlerData + : never; + +type ExtractGestureControllerPayload = + T extends SingleGesture + ? ExtractHookGesturePayload + : T extends BaseGesture + ? ExtractConfig + : Record; + +export type GestureControllerEvent< + TEventPayload extends Record = Record, +> = Partial & { + handlerTag?: never; + nativeEvent?: never; + oldState?: never; + state?: never; +}; + +type GestureControllerTarget = + | ReactTestInstance + | GestureType + | SingleGesture + | string; + +export interface GestureController< + TEventPayload extends Record = Record, +> { + begin: (event?: GestureControllerEvent) => void; + activate: (event?: GestureControllerEvent) => void; + update: (event?: GestureControllerEvent) => void; + end: (event?: GestureControllerEvent) => void; + fail: (event?: GestureControllerEvent) => void; + cancel: (event?: GestureControllerEvent) => void; +} + +const FORBIDDEN_CONTROLLER_EVENT_FIELDS = [ + 'handlerTag', + 'nativeEvent', + 'oldState', + 'state', +]; + +const FINISHED_CONTROLLER_STATES: State[] = [ + State.END, + State.FAILED, + State.CANCELLED, +]; + +function getStateName(state: State): string { + return ( + Object.entries(State).find(([, value]) => value === state)?.[0] ?? + String(state) + ); +} + +function validateControllerEvent(event: Record) { + for (const field of FORBIDDEN_CONTROLLER_EVENT_FIELDS) { + invariant( + !hasProperty(event, field), + `GestureController manages '${field}' internally. Pass only gesture event payload fields.` + ); + } +} + +function resolveGestureControllerTarget(target: GestureControllerTarget) { + if (typeof target === 'string') { + return getByGestureTestId(target); + } + + if (isGesture(target)) { + return target; + } + + if (isHookGesture(target)) { + return findGesture(target.handlerTag) ?? target; + } + + return target; +} + +class GestureControllerImpl< + TEventPayload extends Record = Record, +> implements GestureController +{ + private state: State = State.UNDETERMINED; + + // eslint-disable-next-line no-useless-constructor + constructor(private resolveHandlerData: () => HandlerData) {} + + public begin(event: GestureControllerEvent = {}) { + const handlerData = this.resolveHandlerData(); + + if (!this.isEnabled(handlerData)) { + return; + } + + this.resetIfFinished(); + + this.transition( + 'begin', + State.BEGAN, + [State.UNDETERMINED], + event, + handlerData + ); + } + + public activate(event: GestureControllerEvent = {}) { + const handlerData = this.resolveHandlerData(); + + if (!this.isEnabled(handlerData)) { + return; + } + + this.transition( + 'activate', + State.ACTIVE, + [State.BEGAN], + event, + handlerData + ); + } + + public update(event: GestureControllerEvent = {}) { + const handlerData = this.resolveHandlerData(); + + if (!this.isEnabled(handlerData)) { + return; + } + + this.assertCurrentState('update', [State.ACTIVE]); + + const nativeEvent = this.buildEvent(State.ACTIVE, event, handlerData); + handlerData.emitEvent( + 'onGestureHandlerEvent', + wrapWithNativeEvent(nativeEvent as GestureHandlerTestEvent) + ); + } + + public end(event: GestureControllerEvent = {}) { + const handlerData = this.resolveHandlerData(); + + if (!this.isEnabled(handlerData)) { + return; + } + + this.transition( + 'end', + State.END, + [State.BEGAN, State.ACTIVE], + event, + handlerData + ); + } + + public fail(event: GestureControllerEvent = {}) { + const handlerData = this.resolveHandlerData(); + + if (!this.isEnabled(handlerData)) { + return; + } + + this.transition( + 'fail', + State.FAILED, + [State.BEGAN, State.ACTIVE], + event, + handlerData + ); + } + + public cancel(event: GestureControllerEvent = {}) { + const handlerData = this.resolveHandlerData(); + + if (!this.isEnabled(handlerData)) { + return; + } + + this.transition( + 'cancel', + State.CANCELLED, + [State.BEGAN, State.ACTIVE], + event, + handlerData + ); + } + + private transition( + action: string, + nextState: State, + allowedStates: State[], + event: GestureControllerEvent, + handlerData: HandlerData + ) { + this.assertCurrentState(action, allowedStates); + + const oldState = this.state; + const nativeEvent = { + oldState, + ...this.buildEvent(nextState, event, handlerData), + } as GestureHandlerTestEvent; + + this.state = nextState; + + handlerData.emitEvent( + 'onGestureHandlerStateChange', + wrapWithNativeEvent(nativeEvent) + ); + } + + private isEnabled(handlerData: HandlerData) { + return handlerData.enabled !== false; + } + + private resetIfFinished() { + if (FINISHED_CONTROLLER_STATES.includes(this.state)) { + this.state = State.UNDETERMINED; + } + } + + private assertCurrentState(action: string, allowedStates: State[]) { + invariant( + allowedStates.includes(this.state), + `Cannot ${action} gesture from ${getStateName(this.state)} state.` + ); + } + + private buildEvent( + state: State, + event: GestureControllerEvent, + handlerData: HandlerData + ): Omit { + validateControllerEvent(event); + + return fillMissingDefaultsFor(handlerData)({ + ...event, + state, + } as Partial) as Omit< + GestureHandlerTestEvent, + 'oldState' + >; + } +} + +export function createGestureController< + TTarget extends GestureType | SingleGesture, +>( + componentOrGesture: TTarget +): GestureController>; +export function createGestureController< + TEventPayload extends Record = Record, +>( + componentOrGesture: ReactTestInstance | string +): GestureController; +export function createGestureController( + componentOrGesture: GestureControllerTarget +): GestureController> { + return new GestureControllerImpl(() => + getHandlerData(resolveGestureControllerTarget(componentOrGesture)) + ); +} + export function fireGestureHandler( componentOrGesture: | ReactTestInstance diff --git a/packages/react-native-gesture-handler/src/v3/hooks/utils/configUtils.ts b/packages/react-native-gesture-handler/src/v3/hooks/utils/configUtils.ts index ba44a463bf..bd9bd16a6e 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/utils/configUtils.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/utils/configUtils.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { Reanimated } from '../../../handlers/gestures/reanimatedWrapper'; -import { tagMessage } from '../../../utils'; +import { isTestEnv, tagMessage } from '../../../utils'; import type { BaseGestureConfig, ExcludeInternalConfigProps, @@ -36,6 +36,10 @@ export function resolveInternalConfigProps< THandlerData, TExtendedHandlerData extends THandlerData, >(config: BaseGestureConfig) { + if (isTestEnv() && config.disableReanimated === undefined) { + config.disableReanimated = true; + } + if ( __DEV__ && isNativeAnimatedEvent(config.onUpdate) &&