diff --git a/app/hooks/android_back_handler.test.ts b/app/hooks/android_back_handler.test.ts new file mode 100644 index 0000000000..a2dc79e8aa --- /dev/null +++ b/app/hooks/android_back_handler.test.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {renderHook} from '@testing-library/react-hooks'; +import {BackHandler} from 'react-native'; + +import NavigationStore from '@store/navigation_store'; + +import useAndroidHardwareBackHandler from './android_back_handler'; + +jest.mock('react-native', () => ({ + BackHandler: { + addEventListener: jest.fn(), + }, +})); + +jest.mock('@store/navigation_store', () => ({ + getVisibleScreen: jest.fn(), +})); + +test('useAndroidHardwareBackHandler - calls callback when visible screen matches componentId', () => { + const componentId = 'About'; + const callback = jest.fn(); + + (NavigationStore.getVisibleScreen as jest.Mock).mockReturnValue(componentId); + + renderHook(() => useAndroidHardwareBackHandler(componentId, callback)); + + const hardwareBackPressHandler = (BackHandler.addEventListener as jest.Mock).mock.calls[0][1]; + hardwareBackPressHandler(); + + expect(callback).toHaveBeenCalled(); +}); + +test('useAndroidHardwareBackHandler - does not call callback when visible screen does not match componentId', () => { + const componentId = 'About'; + const callback = jest.fn(); + + (NavigationStore.getVisibleScreen as jest.Mock).mockReturnValue('otherScreen'); + + renderHook(() => useAndroidHardwareBackHandler(componentId, callback)); + + const hardwareBackPressHandler = (BackHandler.addEventListener as jest.Mock).mock.calls[0][1]; + hardwareBackPressHandler(); + + expect(callback).not.toHaveBeenCalled(); +}); + +test('useAndroidHardwareBackHandler - removes event listener on unmount', () => { + const componentId = 'About'; + const callback = jest.fn(); + const remove = jest.fn(); + (BackHandler.addEventListener as jest.Mock).mockReturnValue({remove}); + + const {unmount} = renderHook(() => useAndroidHardwareBackHandler(componentId, callback)); + + unmount(); + + expect(remove).toHaveBeenCalled(); +}); diff --git a/app/hooks/emoji_category_bar.test.ts b/app/hooks/emoji_category_bar.test.ts new file mode 100644 index 0000000000..8b599083b6 --- /dev/null +++ b/app/hooks/emoji_category_bar.test.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {renderHook, act} from '@testing-library/react-hooks'; +import {BehaviorSubject} from 'rxjs'; + +import { + selectEmojiCategoryBarSection, + setEmojiCategoryBarSection, + setEmojiCategoryBarIcons, + setEmojiSkinTone, + useEmojiCategoryBar, + useEmojiSkinTone, +} from './emoji_category_bar'; + +describe('EmojiCategoryBar', () => { + const mockSubject = { + next: jest.fn(), + value: { + icons: undefined, + currentIndex: 0, + selectedIndex: undefined, + skinTone: 'default', + }, + subscribe: jest.fn().mockImplementation((callback) => { + callback(mockSubject.value); + return {unsubscribe: jest.fn()}; + }), + }; + + beforeEach(() => { + jest.spyOn(BehaviorSubject.prototype, 'next').mockImplementation(mockSubject.next); + jest.spyOn(BehaviorSubject.prototype, 'subscribe').mockImplementation(mockSubject.subscribe); + jest.spyOn(BehaviorSubject.prototype, 'value', 'get').mockReturnValue(mockSubject.value); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('setEmojiSkinTone', () => { + it('should update the skin tone in the state', () => { + setEmojiSkinTone('light'); + + expect(mockSubject.next).toHaveBeenCalledWith({ + ...mockSubject.value, + skinTone: 'light', + }); + }); + }); + + describe('useEmojiSkinTone', () => { + it('should return the current skin tone', () => { + const {result} = renderHook(() => useEmojiSkinTone()); + + expect(result.current).toBe('default'); + }); + + it('should update the skin tone when it changes', () => { + const {result} = renderHook(() => useEmojiSkinTone()); + + act(() => { + mockSubject.subscribe.mock.calls[0][0]({skinTone: 'dark'}); + }); + + expect(result.current).toBe('dark'); + }); + }); + + describe('selectEmojiCategoryBarSection', () => { + it('should update the selected index in the state', () => { + selectEmojiCategoryBarSection(1); + + expect(mockSubject.next).toHaveBeenCalledWith({ + ...mockSubject.value, + selectedIndex: 1, + }); + }); + }); + + describe('setEmojiCategoryBarSection', () => { + it('should update the current index in the state', () => { + setEmojiCategoryBarSection(1); + + expect(mockSubject.next).toHaveBeenCalledWith({ + ...mockSubject.value, + currentIndex: 1, + }); + }); + }); + + describe('setEmojiCategoryBarIcons', () => { + it('should update the icons in the state', () => { + const icons = [{key: 'smile', name: 'smile', icon: 'smile'}]; + setEmojiCategoryBarIcons(icons); + + expect(mockSubject.next).toHaveBeenCalledWith({ + ...mockSubject.value, + icons, + }); + }); + }); + + describe('useEmojiCategoryBar', () => { + it('should return the current state', () => { + const {result} = renderHook(() => useEmojiCategoryBar()); + + expect(result.current).toEqual(mockSubject.value); + }); + + it('should update the state when it changes', () => { + const {result} = renderHook(() => useEmojiCategoryBar()); + + const newState = { + icons: [{name: 'smile', icon: 'smile'}], + currentIndex: 1, + selectedIndex: 2, + skinTone: 'dark', + }; + + act(() => { + mockSubject.subscribe.mock.calls[0][0](newState); + }); + + expect(result.current).toEqual(newState); + }); + }); +}); diff --git a/app/hooks/files.test.ts b/app/hooks/files.test.ts index 980a3b3716..f4b2e0c9d3 100644 --- a/app/hooks/files.test.ts +++ b/app/hooks/files.test.ts @@ -3,12 +3,16 @@ import {renderHook} from '@testing-library/react-hooks'; +import {getLocalFileInfo} from '@actions/local/file'; import {buildFilePreviewUrl, buildFileUrl} from '@actions/remote/file'; import {useServerUrl} from '@context/server'; import {mockFileInfo} from '@test/api_mocks/file'; import {isGif, isImage, isVideo} from '@utils/file'; +import {getImageSize} from '@utils/gallery'; -import {useImageAttachments} from './files'; +import {useChannelBookmarkFiles, useImageAttachments} from './files'; + +import type ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark'; jest.mock('@actions/remote/file', () => ({ buildFilePreviewUrl: jest.fn(), @@ -25,6 +29,9 @@ jest.mock('@context/server', () => ({ useServerUrl: jest.fn(), })); +jest.mock('@utils/gallery'); +jest.mock('@actions/local/file'); + describe('useImageAttachments', () => { const serverUrl = 'https://example.com'; @@ -143,3 +150,42 @@ describe('useImageAttachments', () => { ]); }); }); + +describe('useChannelBookmarkFiles', () => { + it('should fetch and set file info correctly', async () => { + const serverUrl = 'https://example.com'; + (useServerUrl as jest.Mock).mockReturnValue(serverUrl); + + const bookmarks = [ + {fileId: '1', ownerId: 'user1'}, + {fileId: '2', ownerId: 'user2'}, + ] as ChannelBookmarkModel[]; + + const file1 = {id: '1', localPath: 'path/to/image1', has_preview_image: true, toFileInfo: jest.fn().mockReturnValue({id: '1'})}; + const file2 = {id: '2', localPath: 'path/to/video1', has_preview_image: false, toFileInfo: jest.fn().mockReturnValue({id: '2'})}; + + (getLocalFileInfo as jest.Mock).mockImplementation((url, id) => { + if (id === '1') { + return {file: file1}; + } else if (id === '2') { + return {file: file2}; + } + return {file: null}; + }); + + (isImage as jest.Mock).mockImplementation((file) => file.id === '1'); + (isVideo as jest.Mock).mockImplementation((file) => file.id === '2'); + (isGif as jest.Mock).mockImplementation(() => false); + (buildFileUrl as jest.Mock).mockImplementation((url, id) => `${url}/files/${id}`); + (buildFilePreviewUrl as jest.Mock).mockImplementation((url, id) => `${url}/files/${id}/preview`); + (getImageSize as jest.Mock).mockImplementation(() => ({width: 100, height: 100})); + + const {result, waitForNextUpdate} = renderHook(() => useChannelBookmarkFiles(bookmarks, true)); + + await waitForNextUpdate(); + + expect(result.current).toHaveLength(2); + expect(result.current[0].id).toBe('1'); + expect(result.current[1].id).toBe('2'); + }); +}); diff --git a/app/hooks/gallery.test.ts b/app/hooks/gallery.test.ts new file mode 100644 index 0000000000..a2a0c2c73a --- /dev/null +++ b/app/hooks/gallery.test.ts @@ -0,0 +1,398 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {renderHook, act} from '@testing-library/react-hooks'; +import {useSharedValue} from 'react-native-reanimated'; + +import {useGallery} from '@context/gallery'; + +import {useGalleryControls, useGalleryItem, diff, useCreateAnimatedGestureHandler} from './gallery'; + +jest.mock('react-native-reanimated', () => ({ + Easing: { + bezier: jest.fn(), + }, + runOnJS: jest.fn((fn) => fn), + useAnimatedRef: jest.fn(() => ({})), + useAnimatedStyle: jest.fn((fn) => fn()), + useEvent: jest.fn(), + useSharedValue: jest.fn(), + withTiming: jest.fn(), +})); + +jest.mock('@context/gallery', () => ({ + useGallery: jest.fn(), +})); + +jest.mock('react-native', () => ({ + Platform: { + OS: 'ios', + }, +})); + +describe('gallery hooks', () => { + test('diff', () => { + const context = {} as {___diffs?: any}; + const name = 'test'; + const value = 10; + + const result = diff(context, name, value); + + expect(result).toBe(0); + expect(context.___diffs[name].stash).toBe(0); + expect(context.___diffs[name].prev).toBe(value); + }); + + test('diff should return 0 for the same value', () => { + const context = {} as {___diffs?: any}; + const name = 'test'; + const value = 10; + + diff(context, name, value); + const result = diff(context, name, value); + + expect(result).toBe(0); + expect(context.___diffs[name].stash).toBe(0); + expect(context.___diffs[name].prev).toBe(value); + }); + + describe('useCreateAnimatedGestureHandler', () => { + const handlers = { + onInit: jest.fn(), + onGesture: jest.fn(), + shouldHandleEvent: jest.fn(), + onEvent: jest.fn(), + shouldCancel: jest.fn(), + onEnd: jest.fn(), + onStart: jest.fn(), + onActive: jest.fn(), + onFail: jest.fn(), + onCancel: jest.fn(), + onFinish: jest.fn(), + beforeEach: jest.fn(), + afterEach: jest.fn(), + }; + + test('iOS and state is active', () => { + const context = {__initialized: false, _shouldSkip: false} as any; + + (useSharedValue as jest.Mock).mockImplementationOnce(() => ({value: context})); + + const {result} = renderHook(() => useCreateAnimatedGestureHandler(handlers)); + const handler = result.current; + + expect(handler).toBeInstanceOf(Function); + + const event = { + nativeEvent: { + state: 4, + oldState: 1, + velocityX: 0, + velocityY: 0, + }, + }; + + act(() => { + handler(event.nativeEvent as any); + }); + + expect(handlers.onInit).toHaveBeenCalled(); + expect(handlers.onGesture).toHaveBeenCalled(); + expect(handlers.shouldHandleEvent).not.toHaveBeenCalled(); + expect(handlers.onEvent).toHaveBeenCalled(); + expect(handlers.shouldCancel).toHaveBeenCalled(); + expect(handlers.onEnd).not.toHaveBeenCalled(); + expect(handlers.onStart).not.toHaveBeenCalled(); + expect(handlers.onActive).toHaveBeenCalled(); + expect(handlers.onFail).not.toHaveBeenCalled(); + expect(handlers.onCancel).not.toHaveBeenCalled(); + expect(handlers.onFinish).not.toHaveBeenCalled(); + expect(handlers.beforeEach).toHaveBeenCalled(); + expect(handlers.afterEach).toHaveBeenCalled(); + + // Check the expected values of context + expect(context.__initialized).toBe(true); + expect(context._shouldSkip).toBe(false); + }); + + test('State is end, oldState is active', () => { + const context = {__initialized: false, _shouldSkip: false} as any; + + (useSharedValue as jest.Mock).mockImplementationOnce(() => ({value: context})); + + const {result} = renderHook(() => useCreateAnimatedGestureHandler(handlers)); + const handler = result.current; + + expect(handler).toBeInstanceOf(Function); + + const event = { + nativeEvent: { + state: 5, + oldState: 4, + velocityX: 0, + velocityY: 0, + }, + }; + + act(() => { + handler(event.nativeEvent as any); + }); + + expect(handlers.onEnd).toHaveBeenCalled(); + + // Check the expected values of context + expect(context.__initialized).toBe(true); + expect(context._shouldCancel).toBeUndefined(); + expect(context._shouldSkip).toBeUndefined(); + }); + + test('State is began, oldState is active, velocity non-zero', () => { + const context = {__initialized: false, _shouldSkip: false} as any; + + (useSharedValue as jest.Mock).mockImplementationOnce(() => ({value: context})); + + const {result} = renderHook(() => useCreateAnimatedGestureHandler(handlers)); + const handler = result.current; + + expect(handler).toBeInstanceOf(Function); + + const event = { + nativeEvent: { + state: 2, + oldState: 4, + velocityX: 1, + velocityY: 1, + }, + }; + + act(() => { + handler(event.nativeEvent as any); + }); + + expect(handlers.shouldHandleEvent).toHaveBeenCalled(); + + // Check the expected values of context + expect(context.__initialized).toBe(true); + expect(context._shouldCancel).toBeUndefined(); + expect(context._shouldSkip).toBeUndefined(); + }); + + test('should skip handling event if shouldHandleEvent returns false', () => { + const context = {__initialized: false, _shouldSkip: undefined} as any; + + handlers.shouldHandleEvent.mockReturnValueOnce(false); + (useSharedValue as jest.Mock).mockImplementationOnce(() => ({value: context})); + + const {result} = renderHook(() => useCreateAnimatedGestureHandler(handlers)); + const handler = result.current; + expect(handler).toBeInstanceOf(Function); + + const event = { + nativeEvent: { + state: 2, + velocityX: 1, + velocityY: 1, + }, + }; + + act(() => { + handler(event.nativeEvent as any); + }); + + expect(handlers.shouldHandleEvent).toHaveBeenCalled(); + expect(context._shouldSkip).toBe(true); + }); + + test('handles shouldCancel returning true', () => { + const context = {__initialized: true, _shouldSkip: false} as any; + handlers.shouldCancel.mockReturnValueOnce(true); + (useSharedValue as jest.Mock).mockImplementationOnce(() => ({value: context})); + + const {result} = renderHook(() => useCreateAnimatedGestureHandler(handlers)); + const handler = result.current; + + const event = { + nativeEvent: { + state: 4, // ACTIVE + oldState: 2, // BEGAN + velocityX: 0, + velocityY: 0, + }, + }; + + act(() => { + handler(event.nativeEvent as any); + }); + + expect(handlers.shouldCancel).toHaveBeenCalled(); + expect(handlers.onEnd).toHaveBeenCalledWith( + event.nativeEvent, + context, + true, // cancelled = true + ); + expect(handlers.onActive).not.toHaveBeenCalled(); + }); + + test('skips event handling when context._shouldSkip is undefined', () => { + const context = {__initialized: true} as any; + (useSharedValue as jest.Mock).mockImplementationOnce(() => ({value: context})); + + const {result} = renderHook(() => useCreateAnimatedGestureHandler(handlers)); + const handler = result.current; + + const event = { + nativeEvent: { + state: 4, // ACTIVE + oldState: 3, // CANCELLED + velocityX: 0, + velocityY: 0, + }, + }; + + act(() => { + handler(event.nativeEvent as any); + }); + + expect(handlers.onEvent).not.toHaveBeenCalled(); + expect(handlers.onActive).not.toHaveBeenCalled(); + }); + + test('handles CANCELLED state when oldState is ACTIVE', () => { + const context = {__initialized: true, _shouldSkip: false} as any; + (useSharedValue as jest.Mock).mockImplementationOnce(() => ({value: context})); + + const {result} = renderHook(() => useCreateAnimatedGestureHandler(handlers)); + const handler = result.current; + + const event = { + nativeEvent: { + state: 3, // CANCELLED + oldState: 4, // ACTIVE + velocityX: 0, + velocityY: 0, + }, + }; + + act(() => { + handler(event.nativeEvent as any); + }); + + expect(handlers.onCancel).toHaveBeenCalled(); + expect(handlers.onFinish).toHaveBeenCalledWith( + event.nativeEvent, + context, + true, // cancelled = true + ); + }); + + test('handles FAILED state when oldState is ACTIVE', () => { + const context = {__initialized: true, _shouldSkip: false} as any; + (useSharedValue as jest.Mock).mockImplementationOnce(() => ({value: context})); + + const {result} = renderHook(() => useCreateAnimatedGestureHandler(handlers)); + const handler = result.current; + + const event = { + nativeEvent: { + state: 1, // FAILED + oldState: 4, // ACTIVE + velocityX: 0, + velocityY: 0, + }, + }; + + act(() => { + handler(event.nativeEvent as any); + }); + + expect(handlers.onFail).toHaveBeenCalled(); + expect(handlers.onFinish).toHaveBeenCalledWith( + event.nativeEvent, + context, + true, // failed = true + ); + }); + }); + + test('useGalleryControls', () => { + (useSharedValue as jest.Mock).mockImplementationOnce(() => ({value: false})); + const {result} = renderHook(() => useGalleryControls()); + + act(() => { + result.current.setControlsHidden(true); + }); + + expect(result.current.controlsHidden.value).toBe(true); + + act(() => { + result.current.setControlsHidden(false); + }); + + expect(result.current.controlsHidden.value).toBe(false); + }); + + test('useGalleryControls does not update controlsHidden when same value is set', () => { + const mockWithTiming = jest.fn(); + (useSharedValue as jest.Mock).mockImplementationOnce(() => ({value: true})); + + const {result} = renderHook(() => useGalleryControls()); + + act(() => { + result.current.setControlsHidden(true); + }); + + expect(mockWithTiming).not.toHaveBeenCalled(); + }); + + test('useGalleryItem', () => { + const identifier = 'test'; + const index = 0; + const onPress = jest.fn(); + const gallery = { + sharedValues: { + opacity: {value: 1}, + activeIndex: {value: 0}, + }, + registerItem: jest.fn(), + }; + (useGallery as jest.Mock).mockReturnValue(gallery); + + const {result} = renderHook(() => useGalleryItem(identifier, index, onPress)); + + expect(result.current.ref).toBeDefined(); + expect(result.current.styles).toBeDefined(); + expect(result.current.onGestureEvent).toBeInstanceOf(Function); + + act(() => { + result.current.onGestureEvent(); + }); + + expect(gallery.sharedValues.activeIndex.value).toBe(index); + expect(onPress).toHaveBeenCalledWith(identifier, index); + }); + + test('useGalleryItem updates activeIndex when onGestureEvent is triggered', () => { + const identifier = 'test'; + const index = 0; + const onPress = jest.fn(); + const gallery = { + sharedValues: { + opacity: {value: 1}, + activeIndex: {value: 1}, // Initial value different from index + }, + registerItem: jest.fn(), + }; + (useGallery as jest.Mock).mockReturnValue(gallery); + + const {result} = renderHook(() => useGalleryItem(identifier, index, onPress)); + + expect(result.current.styles.opacity).toBe(1); + + act(() => { + result.current.onGestureEvent(); + }); + + expect(gallery.sharedValues.activeIndex.value).toBe(index); + expect(onPress).toHaveBeenCalledWith(identifier, index); + }); +}); diff --git a/app/hooks/gallery.ts b/app/hooks/gallery.ts index 13e07db0b8..5d2ede247b 100644 --- a/app/hooks/gallery.ts +++ b/app/hooks/gallery.ts @@ -14,7 +14,7 @@ import {useGallery} from '@context/gallery'; import type {Context, GestureHandlers, OnGestureEvent} from '@typings/screens/gallery'; import type {GestureHandlerGestureEvent} from 'react-native-gesture-handler'; -function diff(context: any, name: string, value: any) { +export function diff(context: any, name: string, value: any) { 'worklet'; if (!context.___diffs) { diff --git a/app/hooks/markdown.test.ts b/app/hooks/markdown.test.ts new file mode 100644 index 0000000000..a22569c0a8 --- /dev/null +++ b/app/hooks/markdown.test.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {renderHook} from '@testing-library/react-hooks'; + +import {getUsersByUsername} from '@utils/user'; + +import {useMemoMentionedUser, useMemoMentionedGroup} from './markdown'; + +import type {GroupModel, UserModel} from '@database/models/server'; + +jest.mock('@utils/user', () => ({ + getUsersByUsername: jest.fn(), +})); + +const mockGetUsersByUsername = jest.mocked(getUsersByUsername); + +describe('useMemoMentionedUser', () => { + it('should return the mentioned user if found', () => { + const users = [{username: 'john_doe'} as UserModel]; + const mentionName = 'john_doe'; + const usersByUsername = {john_doe: users[0]}; + mockGetUsersByUsername.mockReturnValue(usersByUsername); + + const {result} = renderHook(() => useMemoMentionedUser(users, mentionName)); + + expect(result.current).toBe(users[0]); + }); + + it('should return undefined if the mentioned user is not found', () => { + const users = [{username: 'john_doe'} as UserModel]; + const mentionName = 'jane_doe'; + const usersByUsername = {john_doe: users[0]}; + mockGetUsersByUsername.mockReturnValue(usersByUsername); + + const {result} = renderHook(() => useMemoMentionedUser(users, mentionName)); + + expect(result.current).toBeUndefined(); + }); + + it('should trim trailing punctuation from the mention name', () => { + const users = [{username: 'john_doe'} as UserModel]; + const mentionName = 'john_doe.'; + const usersByUsername = {john_doe: users[0]}; + mockGetUsersByUsername.mockReturnValue(usersByUsername); + + const {result} = renderHook(() => useMemoMentionedUser(users, mentionName)); + + expect(result.current).toBe(users[0]); + }); +}); + +describe('useMemoMentionedGroup', () => { + it('should return the mentioned group if found', () => { + const groups = [{name: 'developers'} as GroupModel]; + const user = undefined; + const mentionName = 'developers'; + + const {result} = renderHook(() => useMemoMentionedGroup(groups, user, mentionName)); + + expect(result.current).toBe(groups[0]); + }); + + it('should return undefined if the mentioned group is not found', () => { + const groups = [{name: 'developers'} as GroupModel]; + const user = undefined; + const mentionName = 'designers'; + + const {result} = renderHook(() => useMemoMentionedGroup(groups, user, mentionName)); + + expect(result.current).toBeUndefined(); + }); + + it('should return undefined if the user is defined', () => { + const groups = [{name: 'developers'} as GroupModel]; + const user = {username: 'john_doe'} as UserModel; + const mentionName = 'developers'; + + const {result} = renderHook(() => useMemoMentionedGroup(groups, user, mentionName)); + + expect(result.current).toBeUndefined(); + }); + + it('should trim trailing punctuation from the mention name', () => { + const groups = [{name: 'developers'} as GroupModel]; + const user = undefined; + const mentionName = 'developers.'; + + const {result} = renderHook(() => useMemoMentionedGroup(groups, user, mentionName)); + + expect(result.current).toBe(groups[0]); + }); +});