nexo/apps/mattermost/app/hooks/screen_transition_animation.test.ts

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},
);
});
});
});