1- import { render } from '@testing-library/react-native' ;
1+ import { fireEvent , render , screen } from '@testing-library/react-native' ;
22import React from 'react' ;
3- import { Text } from 'react-native' ;
3+ import type { StyleProp , ViewStyle } from 'react-native' ;
4+ import { StyleSheet , Text } from 'react-native' ;
5+ import { withSpring } from 'react-native-reanimated' ;
46import { scheduleOnRN } from 'react-native-worklets' ;
57
68import GestureHandlerRootView from '../components/GestureHandlerRootView' ;
@@ -9,7 +11,25 @@ import type {
911 SwipeableProps ,
1012} from '../components/ReanimatedSwipeable' ;
1113import ReanimatedSwipeable from '../components/ReanimatedSwipeable' ;
14+ import { createGestureController } from '../jestUtils' ;
1215import RNGestureHandlerModule from '../RNGestureHandlerModule' ;
16+ import type * as Gestures from '../v3/hooks/gestures' ;
17+
18+ let mockLastPanGesture : Gestures . PanGesture | undefined ;
19+
20+ jest . mock ( '../v3/hooks/gestures' , ( ) => {
21+ const actual = jest . requireActual < typeof Gestures > ( '../v3/hooks/gestures' ) ;
22+
23+ return {
24+ ...actual ,
25+ usePanGesture : jest . fn (
26+ ( ...args : Parameters < typeof actual . usePanGesture > ) => {
27+ mockLastPanGesture = actual . usePanGesture ( ...args ) ;
28+ return mockLastPanGesture ;
29+ }
30+ ) ,
31+ } ;
32+ } ) ;
1333
1434jest . mock ( 'react-native-reanimated' , ( ) => {
1535 // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
@@ -29,18 +49,27 @@ jest.mock('react-native-reanimated', () => {
2949 createAnimatedComponent : ( component : unknown ) => component ,
3050 interpolate : ( value : number ) => value ,
3151 isSharedValue : ( ) => false ,
32- measure : ( ) => null ,
52+ measure : ( ) => ( { pageX : 0 } ) ,
3353 ReduceMotion : { System : 'system' } ,
3454 // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
3555 useAnimatedRef : ( ) => ReactActual . useRef ( null ) ,
36- useAnimatedStyle : ( ) => ( { } ) ,
56+ useAnimatedStyle : ( updater : ( ) => object ) => updater ( ) ,
3757 useSharedValue : ( init : unknown ) => {
3858 // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
3959 const ref = ReactActual . useRef ( { value : init } ) ;
4060 // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access
4161 return ref . current ;
4262 } ,
43- withSpring : ( to : unknown ) => to ,
63+ withSpring : jest . fn (
64+ (
65+ to : unknown ,
66+ _config : unknown ,
67+ callback ?: ( finished : boolean ) => void
68+ ) => {
69+ callback ?.( true ) ;
70+ return to ;
71+ }
72+ ) ,
4473 } ;
4574} ) ;
4675
@@ -193,3 +222,145 @@ describe('ReanimatedSwipeable callback identity', () => {
193222 expect ( scheduleOnRN ) . not . toHaveBeenCalled ( ) ;
194223 } ) ;
195224} ) ;
225+
226+ const ROW_WIDTH = 300 ;
227+
228+ function getHostStyle ( text : string ) {
229+ let node = screen . getByText ( text ) . parent ;
230+ while ( node && typeof node . type !== 'string' ) {
231+ node = node . parent ;
232+ }
233+ return StyleSheet . flatten ( node ?. props . style as StyleProp < ViewStyle > ) ?? { } ;
234+ }
235+
236+ describe ( 'ReanimatedSwipeable release and action panes' , ( ) => {
237+ const callbacks = {
238+ onSwipeableOpen : jest . fn ( ) ,
239+ onSwipeableWillOpen : jest . fn ( ) ,
240+ onSwipeableClose : jest . fn ( ) ,
241+ onSwipeableWillClose : jest . fn ( ) ,
242+ } ;
243+
244+ function expectNoSwipeableCallbacks ( ) {
245+ for ( const callback of Object . values ( callbacks ) ) {
246+ expect ( callback ) . not . toHaveBeenCalled ( ) ;
247+ }
248+ }
249+
250+ function renderRow ( ) {
251+ const swipeableRef = React . createRef < SwipeableMethods > ( ) ;
252+ const row = ( ) => (
253+ < GestureHandlerRootView >
254+ < ReanimatedSwipeable
255+ ref = { swipeableRef }
256+ renderLeftActions = { ( ) => < Text > Archive</ Text > }
257+ renderRightActions = { ( ) => < Text > Delete</ Text > }
258+ { ...callbacks } >
259+ < Text style = { { backgroundColor : 'rgba(255, 255, 255, 0.35)' } } >
260+ Row
261+ </ Text >
262+ </ ReanimatedSwipeable >
263+ </ GestureHandlerRootView >
264+ ) ;
265+ const { rerender } = render ( row ( ) ) ;
266+ let container = screen . getByText ( 'Row' ) . parent ;
267+ while ( container && ! container . props . onLayout ) {
268+ container = container . parent ;
269+ }
270+ if ( ! container || ! mockLastPanGesture ) {
271+ throw new Error ( 'ReanimatedSwipeable did not render' ) ;
272+ }
273+ fireEvent ( container , 'layout' , {
274+ nativeEvent : { layout : { width : ROW_WIDTH } } ,
275+ } ) ;
276+
277+ return {
278+ pan : createGestureController ( mockLastPanGesture ) ,
279+ refreshStyles : ( ) => rerender ( row ( ) ) ,
280+ swipeableRef,
281+ } ;
282+ }
283+
284+ beforeEach ( ( ) => {
285+ jest . clearAllMocks ( ) ;
286+ } ) ;
287+
288+ test ( 'opens the row when the pan ends past the threshold' , async ( ) => {
289+ const { pan, refreshStyles } = renderRow ( ) ;
290+
291+ pan . begin ( ) ;
292+ pan . activate ( { translationX : - 20 } ) ;
293+ pan . update ( { translationX : - 200 } ) ;
294+ pan . end ( { translationX : - 200 } ) ;
295+ await flushNativeOps ( ) ;
296+ refreshStyles ( ) ;
297+
298+ expect ( callbacks . onSwipeableOpen ) . toHaveBeenCalledTimes ( 1 ) ;
299+ expect ( getHostStyle ( 'Row' ) . transform ) . toEqual ( [ { translateX : - ROW_WIDTH } ] ) ;
300+ } ) ;
301+
302+ test . each ( [ 'cancel' , 'fail' ] as const ) (
303+ 'keeps a closed row closed without callbacks when the pan is %sed past the threshold' ,
304+ async ( action ) => {
305+ const { pan, refreshStyles } = renderRow ( ) ;
306+
307+ pan . begin ( ) ;
308+ pan . activate ( { translationX : - 20 } ) ;
309+ pan . update ( { translationX : - 200 } ) ;
310+ pan [ action ] ( { translationX : - 200 } ) ;
311+ await flushNativeOps ( ) ;
312+ refreshStyles ( ) ;
313+
314+ expectNoSwipeableCallbacks ( ) ;
315+ expect ( getHostStyle ( 'Row' ) . transform ) . toEqual ( [ { translateX : 0 } ] ) ;
316+ }
317+ ) ;
318+
319+ test . each ( [ 'cancel' , 'fail' ] as const ) (
320+ 'keeps an open row open without callbacks when the pan is %sed past the threshold' ,
321+ async ( action ) => {
322+ const { pan, refreshStyles } = renderRow ( ) ;
323+
324+ pan . begin ( ) ;
325+ pan . activate ( { translationX : - 20 } ) ;
326+ pan . update ( { translationX : - 200 } ) ;
327+ pan . end ( { translationX : - 200 } ) ;
328+ await flushNativeOps ( ) ;
329+ Object . values ( callbacks ) . forEach ( ( callback ) => callback . mockClear ( ) ) ;
330+
331+ pan . begin ( ) ;
332+ pan . activate ( { translationX : 20 } ) ;
333+ pan . update ( { translationX : 200 } ) ;
334+ pan [ action ] ( { translationX : 200 } ) ;
335+ await flushNativeOps ( ) ;
336+ refreshStyles ( ) ;
337+
338+ expectNoSwipeableCallbacks ( ) ;
339+ expect ( getHostStyle ( 'Row' ) . transform ) . toEqual ( [
340+ { translateX : - ROW_WIDTH } ,
341+ ] ) ;
342+ }
343+ ) ;
344+
345+ test ( 'finishes an open animation that a cancelled pan interrupted' , async ( ) => {
346+ const { pan, swipeableRef } = renderRow ( ) ;
347+
348+ // The open animation is still running when the pan starts.
349+ jest . mocked ( withSpring ) . mockImplementationOnce ( ( to ) => to ) ;
350+ swipeableRef . current ?. openRight ( ) ;
351+ await flushNativeOps ( ) ;
352+ expect ( callbacks . onSwipeableWillOpen ) . toHaveBeenCalledTimes ( 1 ) ;
353+ expect ( callbacks . onSwipeableOpen ) . not . toHaveBeenCalled ( ) ;
354+
355+ pan . begin ( ) ;
356+ pan . activate ( { translationX : 20 } ) ;
357+ pan . update ( { translationX : 50 } ) ;
358+ pan . cancel ( { translationX : 50 } ) ;
359+ await flushNativeOps ( ) ;
360+
361+ expect ( callbacks . onSwipeableWillOpen ) . toHaveBeenCalledTimes ( 1 ) ;
362+ expect ( callbacks . onSwipeableOpen ) . toHaveBeenCalledTimes ( 1 ) ;
363+ expect ( callbacks . onSwipeableWillClose ) . not . toHaveBeenCalled ( ) ;
364+ expect ( callbacks . onSwipeableClose ) . not . toHaveBeenCalled ( ) ;
365+ } ) ;
366+ } ) ;
0 commit comments