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..867ef8775d --- /dev/null +++ b/packages/react-native-gesture-handler/src/__tests__/gestureController.test.tsx @@ -0,0 +1,195 @@ +import { renderHook } from '@testing-library/react-native'; + +import { createGestureController } from '../jestUtils'; +import { State } from '../State'; +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; + +describe('createGestureController', () => { + test('allows assertions after each gesture lifecycle step', () => { + const { order, callbacks } = mockedContinuousCallbacks(); + const pan = renderHook(() => + usePanGesture({ disableReanimated: true, ...callbacks }) + ).result.current; + const gesture = createGestureController(pan); + + gesture.begin({ translationX: 0 }); + + expect(gesture.getState()).toBe(State.BEGAN); + expect(order).toEqual(['onBegin']); + expect(callbacks.onBegin).toHaveBeenCalledWith( + expect.objectContaining({ translationX: 0 }) + ); + + gesture.activate({ translationX: 10 }); + + expect(gesture.getState()).toBe(State.ACTIVE); + expect(order).toEqual(['onBegin', 'onActivate']); + expect(callbacks.onActivate).toHaveBeenCalledWith( + expect.objectContaining({ translationX: 10 }) + ); + + gesture.update({ translationX: 50 }); + + expect(gesture.getState()).toBe(State.ACTIVE); + expect(order).toEqual(['onBegin', 'onActivate', 'onUpdate']); + expect(callbacks.onUpdate).toHaveBeenCalledWith( + expect.objectContaining({ translationX: 50 }) + ); + + gesture.end({ translationX: 50 }); + + expect(gesture.getState()).toBe(State.END); + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onUpdate', + 'onDeactivate', + 'onFinalize', + ]); + 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({ disableReanimated: true, ...callbacks }) + ).result.current; + const gesture = createGestureController(tap); + + gesture.begin({ x: 10 }); + gesture.fail({ x: 10 }); + + expect(gesture.getState()).toBe(State.FAILED); + expect(order).toEqual(['onBegin', 'onFinalize']); + 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', + disableReanimated: true, + ...callbacks, + }) + ); + const gesture = createGestureController('controlled-tap'); + + gesture.begin(); + gesture.activate(); + gesture.end(); + + expect(order).toEqual([ + 'onBegin', + 'onActivate', + 'onDeactivate', + 'onFinalize', + ]); + }); + + test('guards against invalid lifecycle order', () => { + const tap = renderHook(() => useTapGesture({ disableReanimated: true })) + .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('rejects raw state-machine fields in event payloads', () => { + const tap = renderHook(() => useTapGesture({ disableReanimated: true })) + .result.current; + const gesture = createGestureController(tap); + + expect(() => gesture.begin({ state: State.BEGAN } as never)).toThrow( + "createGestureController manages 'state' internally." + ); + }); + + test('disabled gestures are no-ops', () => { + const { order, callbacks } = mockedContinuousCallbacks(); + const pan = renderHook(() => + usePanGesture({ + disableReanimated: true, + enabled: false, + ...callbacks, + }) + ).result.current; + const gesture = createGestureController(pan); + + gesture.begin(); + gesture.activate(); + gesture.update({ translationX: 50 }); + gesture.end(); + + expect(gesture.getState()).toBe(State.UNDETERMINED); + expect(order).toEqual([]); + }); +}); 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..0f02a0d532 100644 --- a/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts +++ b/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts @@ -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,214 @@ 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; + getState: () => State; +} + +const FORBIDDEN_CONTROLLER_EVENT_FIELDS = [ + 'handlerTag', + 'nativeEvent', + 'oldState', + 'state', +]; + +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), + `createGestureController manages '${field}' internally. Pass only gesture event payload fields.` + ); + } +} + +function resolveGestureControllerTarget(target: GestureControllerTarget) { + return typeof target === 'string' ? getByGestureTestId(target) : target; +} + +class GestureControllerImpl< + TEventPayload extends Record = Record, +> implements GestureController +{ + private state: State = State.UNDETERMINED; + + // eslint-disable-next-line no-useless-constructor + constructor(private handlerData: HandlerData) {} + + public getState() { + return this.state; + } + + public begin(event: GestureControllerEvent = {}) { + if (!this.isEnabled()) { + return; + } + + this.transition('begin', State.BEGAN, [State.UNDETERMINED], event); + } + + public activate(event: GestureControllerEvent = {}) { + if (!this.isEnabled()) { + return; + } + + this.transition('activate', State.ACTIVE, [State.BEGAN], event); + } + + public update(event: GestureControllerEvent = {}) { + if (!this.isEnabled()) { + return; + } + + this.assertCurrentState('update', [State.ACTIVE]); + + const nativeEvent = this.buildEvent(State.ACTIVE, event); + this.handlerData.emitEvent( + 'onGestureHandlerEvent', + wrapWithNativeEvent(nativeEvent as GestureHandlerTestEvent) + ); + } + + public end(event: GestureControllerEvent = {}) { + if (!this.isEnabled()) { + return; + } + + this.transition('end', State.END, [State.BEGAN, State.ACTIVE], event); + } + + public fail(event: GestureControllerEvent = {}) { + if (!this.isEnabled()) { + return; + } + + this.transition('fail', State.FAILED, [State.BEGAN, State.ACTIVE], event); + } + + public cancel(event: GestureControllerEvent = {}) { + if (!this.isEnabled()) { + return; + } + + this.transition( + 'cancel', + State.CANCELLED, + [State.BEGAN, State.ACTIVE], + event + ); + } + + private transition( + action: string, + nextState: State, + allowedStates: State[], + event: GestureControllerEvent + ) { + this.assertCurrentState(action, allowedStates); + + const oldState = this.state; + this.state = nextState; + + const nativeEvent = { + oldState, + ...this.buildEvent(nextState, event), + } as GestureHandlerTestEvent; + + this.handlerData.emitEvent( + 'onGestureHandlerStateChange', + wrapWithNativeEvent(nativeEvent) + ); + } + + private isEnabled() { + return this.handlerData.enabled !== false; + } + + 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 + ): Omit { + validateControllerEvent(event); + + return fillMissingDefaultsFor(this.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> { + const handlerData = getHandlerData( + resolveGestureControllerTarget(componentOrGesture) + ); + + return new GestureControllerImpl(handlerData); +} + export function fireGestureHandler( componentOrGesture: | ReactTestInstance