382 lines
13 KiB
TypeScript
382 lines
13 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import {renderHook} from '@testing-library/react-native';
|
|
import {AppState, Platform} from 'react-native';
|
|
|
|
import {useScreenTransitionAnimation} from './screen_transition_animation';
|
|
|
|
const mockUseFocusEffect = jest.fn();
|
|
const mockUseReducedMotion = jest.fn();
|
|
const mockUseSharedValue = jest.fn();
|
|
const mockUseAnimatedStyle = jest.fn();
|
|
const mockWithTiming = jest.fn();
|
|
const mockCancelAnimation = jest.fn();
|
|
const mockUseWindowDimensions = jest.fn();
|
|
|
|
jest.mock('expo-router', () => ({
|
|
useFocusEffect: (callback: () => void) => mockUseFocusEffect(callback),
|
|
}));
|
|
|
|
jest.mock('react-native-reanimated', () => ({
|
|
cancelAnimation: (sv: {value: number}) => mockCancelAnimation(sv),
|
|
useReducedMotion: () => mockUseReducedMotion(),
|
|
useSharedValue: (value: number) => mockUseSharedValue(value),
|
|
useAnimatedStyle: (callback: () => any) => mockUseAnimatedStyle(callback),
|
|
withTiming: (value: number, config: any) => mockWithTiming(value, config),
|
|
}));
|
|
|
|
jest.mock('./device', () => ({
|
|
useWindowDimensions: () => mockUseWindowDimensions(),
|
|
}));
|
|
|
|
describe('useScreenTransitionAnimation', () => {
|
|
let sharedValue: {value: number};
|
|
let mockSubRemove: jest.Mock;
|
|
let capturedAppStateCallback: ((state: string) => void) | undefined;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
|
|
// Setup default mock implementations
|
|
sharedValue = {value: 0};
|
|
mockSubRemove = jest.fn();
|
|
capturedAppStateCallback = undefined;
|
|
|
|
mockUseSharedValue.mockReturnValue(sharedValue);
|
|
mockUseAnimatedStyle.mockImplementation((callback) => {
|
|
const style = callback();
|
|
return style;
|
|
});
|
|
mockWithTiming.mockImplementation((value) => value);
|
|
mockUseReducedMotion.mockReturnValue(false);
|
|
mockUseWindowDimensions.mockReturnValue({width: 375, height: 812});
|
|
|
|
jest.spyOn(AppState, 'addEventListener').mockImplementation((_event, callback) => {
|
|
capturedAppStateCallback = callback as (state: string) => void;
|
|
return {remove: mockSubRemove} as any;
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.restoreAllMocks();
|
|
});
|
|
|
|
describe('initialization', () => {
|
|
it('should initialize with translateX equal to width when animated is true', () => {
|
|
mockUseReducedMotion.mockReturnValue(false);
|
|
|
|
renderHook(() => useScreenTransitionAnimation(true));
|
|
|
|
expect(mockUseSharedValue).toHaveBeenCalledWith(375);
|
|
});
|
|
|
|
it('should initialize with translateX equal to 0 when animated is false', () => {
|
|
renderHook(() => useScreenTransitionAnimation(false));
|
|
|
|
expect(mockUseSharedValue).toHaveBeenCalledWith(0);
|
|
});
|
|
|
|
it('should initialize with translateX equal to 0 when reducedMotion is true', () => {
|
|
mockUseReducedMotion.mockReturnValue(true);
|
|
|
|
renderHook(() => useScreenTransitionAnimation(true));
|
|
|
|
expect(mockUseSharedValue).toHaveBeenCalledWith(0);
|
|
});
|
|
|
|
it('should default to animated when no parameter is provided', () => {
|
|
mockUseReducedMotion.mockReturnValue(false);
|
|
|
|
renderHook(() => useScreenTransitionAnimation());
|
|
|
|
expect(mockUseSharedValue).toHaveBeenCalledWith(375);
|
|
});
|
|
});
|
|
|
|
describe('animatedStyle', () => {
|
|
it('should return animated style with Android duration', () => {
|
|
Platform.OS = 'android';
|
|
|
|
renderHook(() => useScreenTransitionAnimation(true));
|
|
|
|
// Get the callback that was passed to useAnimatedStyle
|
|
const animatedStyleCallback = mockUseAnimatedStyle.mock.calls[0][0];
|
|
const style = animatedStyleCallback();
|
|
const focusEffectCallback = mockUseFocusEffect.mock.calls[0][0];
|
|
focusEffectCallback();
|
|
|
|
expect(mockWithTiming).toHaveBeenCalledWith(0, {duration: 250});
|
|
expect(style).toHaveProperty('transform');
|
|
});
|
|
|
|
it('should return animated style with iOS duration', () => {
|
|
Platform.OS = 'ios';
|
|
|
|
renderHook(() => useScreenTransitionAnimation(true));
|
|
|
|
// Get the callback that was passed to useAnimatedStyle
|
|
const animatedStyleCallback = mockUseAnimatedStyle.mock.calls[0][0];
|
|
const style = animatedStyleCallback();
|
|
const focusEffectCallback = mockUseFocusEffect.mock.calls[0][0];
|
|
focusEffectCallback();
|
|
|
|
expect(mockWithTiming).toHaveBeenCalledWith(0, {duration: 350});
|
|
expect(style).toHaveProperty('transform');
|
|
});
|
|
|
|
it('should return transform with translateX', () => {
|
|
renderHook(() => useScreenTransitionAnimation(true));
|
|
|
|
// Get the callback that was passed to useAnimatedStyle
|
|
const animatedStyleCallback = mockUseAnimatedStyle.mock.calls[0][0];
|
|
const style = animatedStyleCallback();
|
|
|
|
expect(style.transform).toEqual([{translateX: sharedValue.value}]);
|
|
});
|
|
});
|
|
|
|
describe('focus effect', () => {
|
|
it('should register focus effect callback', () => {
|
|
renderHook(() => useScreenTransitionAnimation(true));
|
|
|
|
expect(mockUseFocusEffect).toHaveBeenCalledWith(expect.any(Function));
|
|
});
|
|
|
|
it('should set translateX to 0 when screen is focused', () => {
|
|
renderHook(() => useScreenTransitionAnimation(true));
|
|
|
|
const focusEffectCallback = mockUseFocusEffect.mock.calls[0][0];
|
|
focusEffectCallback();
|
|
|
|
expect(sharedValue.value).toBe(0);
|
|
});
|
|
|
|
it('should set translateX to negative width when screen is blurred and animated', () => {
|
|
mockUseWindowDimensions.mockReturnValue({width: 400, height: 800});
|
|
|
|
renderHook(() => useScreenTransitionAnimation(true));
|
|
|
|
const focusEffectCallback = mockUseFocusEffect.mock.calls[0][0];
|
|
const cleanup = focusEffectCallback();
|
|
|
|
cleanup();
|
|
|
|
expect(sharedValue.value).toBe(-400);
|
|
});
|
|
|
|
it('should set translateX to 0 when screen is blurred and not animated', () => {
|
|
renderHook(() => useScreenTransitionAnimation(false));
|
|
|
|
const focusEffectCallback = mockUseFocusEffect.mock.calls[0][0];
|
|
const cleanup = focusEffectCallback();
|
|
|
|
cleanup();
|
|
|
|
expect(sharedValue.value).toBe(0);
|
|
});
|
|
|
|
it('should set translateX to 0 when screen is blurred and reducedMotion is true', () => {
|
|
mockUseReducedMotion.mockReturnValue(true);
|
|
|
|
renderHook(() => useScreenTransitionAnimation(true));
|
|
|
|
const focusEffectCallback = mockUseFocusEffect.mock.calls[0][0];
|
|
const cleanup = focusEffectCallback();
|
|
|
|
cleanup();
|
|
|
|
expect(sharedValue.value).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('AppState resume', () => {
|
|
it('cancels the in-flight animation when going to background so it does not race to completion', () => {
|
|
renderHook(() => useScreenTransitionAnimation(true));
|
|
const focusEffectCallback = mockUseFocusEffect.mock.calls[0][0];
|
|
focusEffectCallback();
|
|
|
|
sharedValue.value = 150;
|
|
|
|
expect(capturedAppStateCallback).toBeDefined();
|
|
capturedAppStateCallback?.('background');
|
|
|
|
expect(mockCancelAnimation).toHaveBeenCalledWith(sharedValue);
|
|
});
|
|
|
|
it('resumes the slide-in animation on active when the value is still mid-flight', () => {
|
|
renderHook(() => useScreenTransitionAnimation(true));
|
|
const focusEffectCallback = mockUseFocusEffect.mock.calls[0][0];
|
|
focusEffectCallback();
|
|
|
|
sharedValue.value = 150;
|
|
expect(capturedAppStateCallback).toBeDefined();
|
|
capturedAppStateCallback?.('background');
|
|
mockWithTiming.mockClear();
|
|
|
|
capturedAppStateCallback?.('active');
|
|
|
|
expect(mockWithTiming).toHaveBeenCalledWith(0, {duration: expect.any(Number)});
|
|
});
|
|
|
|
it('does restart animation even when app returns to active with the screen already at rest', () => {
|
|
renderHook(() => useScreenTransitionAnimation(true));
|
|
const focusEffectCallback = mockUseFocusEffect.mock.calls[0][0];
|
|
focusEffectCallback();
|
|
|
|
sharedValue.value = 0;
|
|
mockWithTiming.mockClear();
|
|
|
|
expect(capturedAppStateCallback).toBeDefined();
|
|
capturedAppStateCallback?.('active');
|
|
|
|
expect(mockWithTiming).toHaveBeenCalled();
|
|
});
|
|
|
|
it('removes AppState listener on blur', () => {
|
|
renderHook(() => useScreenTransitionAnimation(true));
|
|
const focusEffectCallback = mockUseFocusEffect.mock.calls[0][0];
|
|
const cleanup = focusEffectCallback();
|
|
|
|
cleanup();
|
|
|
|
expect(mockSubRemove).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('useEffect for shouldAnimate changes', () => {
|
|
it('should set translateX to 0 when shouldAnimate becomes false', () => {
|
|
const {rerender} = renderHook(
|
|
({animated}) => useScreenTransitionAnimation(animated),
|
|
{initialProps: {animated: true}},
|
|
);
|
|
|
|
// Initially should be animated
|
|
expect(sharedValue.value).toBe(0);
|
|
|
|
// Change to not animated
|
|
rerender({animated: false});
|
|
|
|
// Should reset to 0
|
|
expect(sharedValue.value).toBe(0);
|
|
});
|
|
|
|
it('should not modify translateX when shouldAnimate remains true', () => {
|
|
const {rerender} = renderHook(
|
|
({animated}) => useScreenTransitionAnimation(animated),
|
|
{initialProps: {animated: true}},
|
|
);
|
|
|
|
sharedValue.value = 100;
|
|
|
|
rerender({animated: true});
|
|
|
|
// Should not reset since shouldAnimate is still true
|
|
expect(sharedValue.value).toBe(100);
|
|
});
|
|
|
|
it('should reset translateX when reducedMotion becomes true', () => {
|
|
mockUseReducedMotion.mockReturnValue(false);
|
|
|
|
const {rerender} = renderHook(() => useScreenTransitionAnimation(true));
|
|
|
|
sharedValue.value = 100;
|
|
|
|
// Change reducedMotion to true
|
|
mockUseReducedMotion.mockReturnValue(true);
|
|
rerender(undefined);
|
|
|
|
expect(sharedValue.value).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('with different window dimensions', () => {
|
|
it('should use correct width for larger screens', () => {
|
|
mockUseWindowDimensions.mockReturnValue({width: 768, height: 1024});
|
|
|
|
renderHook(() => useScreenTransitionAnimation(true));
|
|
|
|
expect(mockUseSharedValue).toHaveBeenCalledWith(768);
|
|
});
|
|
|
|
it('should use correct width for smaller screens', () => {
|
|
mockUseWindowDimensions.mockReturnValue({width: 320, height: 568});
|
|
|
|
renderHook(() => useScreenTransitionAnimation(true));
|
|
|
|
expect(mockUseSharedValue).toHaveBeenCalledWith(320);
|
|
});
|
|
|
|
it('should update animation based on width changes', () => {
|
|
mockUseWindowDimensions.mockReturnValue({width: 375, height: 812});
|
|
|
|
const {rerender} = renderHook(() => useScreenTransitionAnimation(true));
|
|
|
|
const focusEffectCallback = mockUseFocusEffect.mock.calls[0][0];
|
|
focusEffectCallback();
|
|
|
|
// Change width
|
|
mockUseWindowDimensions.mockReturnValue({width: 414, height: 896});
|
|
rerender(undefined);
|
|
|
|
// Get the new focus effect callback
|
|
const newFocusEffectCallback = mockUseFocusEffect.mock.calls[1][0];
|
|
const newCleanup = newFocusEffectCallback();
|
|
|
|
newCleanup();
|
|
|
|
// Should use new width
|
|
expect(sharedValue.value).toBe(-414);
|
|
});
|
|
});
|
|
|
|
describe('return value', () => {
|
|
it('should return the animated style object', () => {
|
|
const mockStyle = {transform: [{translateX: 0}]};
|
|
mockUseAnimatedStyle.mockReturnValue(mockStyle);
|
|
|
|
const {result} = renderHook(() => useScreenTransitionAnimation(true));
|
|
|
|
expect(result.current).toBe(mockStyle);
|
|
});
|
|
|
|
it('should return style even when not animated', () => {
|
|
const mockStyle = {transform: [{translateX: 0}]};
|
|
mockUseAnimatedStyle.mockReturnValue(mockStyle);
|
|
|
|
const {result} = renderHook(() => useScreenTransitionAnimation(false));
|
|
|
|
expect(result.current).toBe(mockStyle);
|
|
});
|
|
});
|
|
|
|
describe('platform-specific behavior', () => {
|
|
it('should use 250ms duration on Android', () => {
|
|
Platform.OS = 'android';
|
|
|
|
renderHook(() => useScreenTransitionAnimation(true));
|
|
|
|
const focusEffectCallback = mockUseFocusEffect.mock.calls[0][0];
|
|
focusEffectCallback();
|
|
|
|
expect(mockWithTiming).toHaveBeenCalledWith(
|
|
expect.any(Number),
|
|
{duration: 250},
|
|
);
|
|
});
|
|
|
|
it('should use 350ms duration on iOS', () => {
|
|
Platform.OS = 'ios';
|
|
|
|
renderHook(() => useScreenTransitionAnimation(true));
|
|
|
|
const focusEffectCallback = mockUseFocusEffect.mock.calls[0][0];
|
|
focusEffectCallback();
|
|
|
|
expect(mockWithTiming).toHaveBeenCalledWith(
|
|
expect.any(Number),
|
|
{duration: 350},
|
|
);
|
|
});
|
|
});
|
|
});
|