From e7fe7c23ffcf6844a35630594864b1548a2fbdda Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Tue, 6 Aug 2024 13:17:36 +0300 Subject: [PATCH] add unit tests to utils/gallery (#8117) --- app/utils/gallery/index.test.ts | 283 ++++++++++++++++++++++++++++++ app/utils/gallery/vectors.test.ts | 166 ++++++++++++++++++ app/utils/gallery/vectors.ts | 1 + test/setup.ts | 2 + 4 files changed, 452 insertions(+) create mode 100644 app/utils/gallery/index.test.ts create mode 100644 app/utils/gallery/vectors.test.ts diff --git a/app/utils/gallery/index.test.ts b/app/utils/gallery/index.test.ts new file mode 100644 index 000000000..9c9c660c7 --- /dev/null +++ b/app/utils/gallery/index.test.ts @@ -0,0 +1,283 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {DeviceEventEmitter, Image, Keyboard} from 'react-native'; +import {Navigation} from 'react-native-navigation'; +import {measure, type AnimatedRef} from 'react-native-reanimated'; + +import {clamp, clampVelocity, fileToGalleryItem, freezeOtherScreens, friction, galleryItemToFileInfo, getImageSize, getShouldRender, measureItem, openGalleryAtIndex, typedMemo, workletNoop, workletNoopTrue} from '.'; + +import type {GalleryItemType, GalleryManagerSharedValues} from '@typings/screens/gallery'; + +jest.mock('@screens/navigation', () => ({ + showOverlay: jest.fn(), +})); + +jest.mock('react-native', () => { + const ReactNative = jest.requireActual('react-native'); + const { + NativeModules: RNNativeModules, + } = ReactNative; + + const NativeModules = { + ...RNNativeModules, + RNUtils: { + getConstants: () => ({ + appGroupIdentifier: 'group.mattermost.rnbeta', + appGroupSharedDirectory: { + sharedDirectory: '', + databasePath: '', + }, + }), + addListener: jest.fn(), + removeListeners: jest.fn(), + isRunningInSplitView: jest.fn().mockReturnValue({isSplit: false, isTablet: false}), + + getDeliveredNotifications: jest.fn().mockResolvedValue([]), + removeChannelNotifications: jest.fn().mockImplementation(), + removeThreadNotifications: jest.fn().mockImplementation(), + removeServerNotifications: jest.fn().mockImplementation(), + + unlockOrientation: jest.fn(), + }, + }; + + return Object.setPrototypeOf({ + NativeModules, + DeviceEventEmitter: { + emit: jest.fn(), + }, + Keyboard: { + dismiss: jest.fn(), + }, + }, ReactNative); +}); + +// Mock react-native-reanimated measure function +jest.mock('react-native-reanimated', () => ({ + measure: jest.fn(() => ({ + pageX: 100, + pageY: 200, + width: 300, + height: 400, + })), +})); + +describe('Gallery utils', () => { + afterAll(() => { + jest.clearAllMocks(); + }); + + describe('clamp', () => { + it('should clamp a value within the given bounds', () => { + expect(clamp(5, 1, 10)).toBe(5); + expect(clamp(-5, 0, 10)).toBe(0); + expect(clamp(15, 0, 10)).toBe(10); + }); + }); + + describe('clampVelocity', () => { + it('should clamp positive velocity within the given bounds', () => { + expect(clampVelocity(5, 1, 10)).toBe(5); + expect(clampVelocity(0.5, 1, 10)).toBe(1); + expect(clampVelocity(15, 1, 10)).toBe(10); + }); + + it('should clamp negative velocity within the given bounds', () => { + expect(clampVelocity(-5, 1, 10)).toBe(-5); + expect(clampVelocity(-0.5, 1, 10)).toBe(-1); + expect(clampVelocity(-15, 1, 10)).toBe(-10); + }); + }); + + describe('fileToGalleryItem', () => { + it('should convert file info to gallery item with image type', () => { + const file = { + extension: 'jpg', + height: 600, + id: '123', + mime_type: 'image/jpeg', + name: 'test-image', + post_id: 'post123', + size: 5000, + localPath: '/path/to/image.jpg', + width: 800, + } as FileInfo; + const result = fileToGalleryItem(file); + expect(result.type).toBe('image'); + expect(result.uri).toBe(file.localPath); + }); + + it('should convert file info to gallery item with video type', () => { + const file = { + extension: 'mp4', + height: 600, + id: '123', + mime_type: 'video/mp4', + name: 'test-video', + post_id: 'post123', + size: 10000, + localPath: '/path/to/video.mp4', + mini_preview: '/path/to/preview.jpg', + width: 800, + } as FileInfo; + const result = fileToGalleryItem(file); + expect(result.type).toBe('video'); + expect(result.posterUri).toBe(file.mini_preview); + }); + + it('should convert file info to gallery item with video type and assign a file id that starts with uid', () => { + const file = { + extension: 'mp4', + height: 600, + mime_type: 'video/mp4', + name: 'test-video', + post_id: 'post123', + size: 10000, + localPath: '/path/to/video.mp4', + mini_preview: '/path/to/preview.jpg', + width: 800, + } as FileInfo; + const result = fileToGalleryItem(file); + expect(result.id.startsWith('uid')).toBeTruthy(); + }); + }); + + describe('freezeOtherScreens', () => { + it('should emit freeze screen event', () => { + freezeOtherScreens(true); + expect(DeviceEventEmitter.emit).toHaveBeenCalledWith('FREEZE_SCREEN', true); + }); + }); + + describe('friction', () => { + it('should calculate friction based on value', () => { + expect(friction(100)).toBeGreaterThan(1); + expect(friction(-100)).toBeLessThan(0); + }); + }); + + describe('galleryItemToFileInfo', () => { + it('should convert gallery item to file info', () => { + const item = { + id: '123', + name: 'test-image', + width: 800, + height: 600, + extension: 'jpg', + mime_type: 'image/jpeg', + postId: 'post123', + authorId: 'user123', + } as GalleryItemType; + const result = galleryItemToFileInfo(item); + expect(result.id).toBe(item.id); + expect(result.name).toBe(item.name); + }); + }); + + describe('getShouldRender', () => { + it('should return true if index is within range of active index', () => { + expect(getShouldRender(5, 5)).toBe(true); + expect(getShouldRender(6, 5)).toBe(true); + expect(getShouldRender(9, 5)).toBe(false); + }); + }); + + describe('measureItem', () => { + it('should measure and set shared values', () => { + const ref = jest.fn() as unknown as AnimatedRef; + const sharedValues = { + x: {value: 0}, + y: {value: 0}, + width: {value: 0}, + height: {value: 0}, + } as GalleryManagerSharedValues; + measureItem(ref, sharedValues); + expect(sharedValues.x.value).toBe(100); + expect(sharedValues.y.value).toBe(200); + }); + + it('should measure and set shared values', () => { + const ref = jest.fn() as unknown as AnimatedRef; + const sharedValues = { + x: {value: 0}, + y: {value: 0}, + width: {value: 0}, + height: {value: 0}, + } as GalleryManagerSharedValues; + measureItem(ref, sharedValues); + expect(sharedValues.x.value).toBe(100); + expect(sharedValues.y.value).toBe(200); + }); + + it('should handle measure exception and set shared values out of the viewport', () => { + const ref = jest.fn() as unknown as AnimatedRef; + const sharedValues = { + x: {value: 0}, + y: {value: 0}, + width: {value: 0}, + height: {value: 0}, + } as GalleryManagerSharedValues; + (measure as jest.Mock).mockImplementationOnce(() => { + throw new Error('error'); + }); + measureItem(ref, sharedValues); + expect(sharedValues.x.value).toBe(999999); + expect(sharedValues.y.value).toBe(999999); + }); + }); + + describe('openGalleryAtIndex', () => { + it('should open gallery and freeze other screens', () => { + const galleryIdentifier = 'gallery1'; + const initialIndex = 0; + const items = [{id: '1', name: 'item1'}, {id: '2', name: 'item2'}] as GalleryItemType[]; + + openGalleryAtIndex(galleryIdentifier, initialIndex, items); + expect(Keyboard.dismiss).toHaveBeenCalled(); + expect(Navigation.setDefaultOptions).toHaveBeenCalled(); + }); + }); + + describe('typedMemo', () => { + it('should memoize component', () => { + const component = jest.fn(); + const memoizedComponent = typedMemo(component); + + // @ts-expect-error type in typedef + expect(memoizedComponent.type).toBe(component); + }); + }); + + describe('workletNoop', () => { + it('should execute without doing anything', () => { + expect(workletNoop()).toBeUndefined(); + }); + }); + + describe('workletNoopTrue', () => { + it('should always return true', () => { + expect(workletNoopTrue()).toBe(true); + }); + }); + + describe('getImageSize', () => { + it('should resolve with image size', async () => { + jest.spyOn(Image, 'getSize').mockImplementationOnce((uri, success) => { + success(800, 600); + }); + + const result = await getImageSize('test-uri'); + expect(result).toEqual({width: 800, height: 600}); + }); + + it('should reject on error', async () => { + jest.spyOn(Image, 'getSize').mockImplementationOnce((uri, success, failure) => { + // @ts-expect-error param + failure(new Error('Failed to get size')); + }); + + await expect(getImageSize('test-uri')).rejects.toThrow('Failed to get size'); + }); + }); +}); diff --git a/app/utils/gallery/vectors.test.ts b/app/utils/gallery/vectors.test.ts new file mode 100644 index 000000000..d5025dbf6 --- /dev/null +++ b/app/utils/gallery/vectors.test.ts @@ -0,0 +1,166 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import { + create, + add, + sub, + divide, + multiply, + invert, + set, + min, + max, + clamp, + eq, + useSharedVector, +} from './vectors'; + +// Mock the useSharedValue from react-native-reanimated +jest.mock('react-native-reanimated', () => ({ + useSharedValue: jest.fn((initialValue) => ({ + value: initialValue, + })), +})); + +describe('Vector operations', () => { + describe('create', () => { + it('should create a vector with given x and y', () => { + const vector = create(10, 20); + expect(vector).toEqual({x: 10, y: 20}); + }); + }); + + describe('useSharedVector', () => { + it('should create a shared vector with given x and y', () => { + const vector = useSharedVector(10, 20); + expect(vector.x.value).toBe(10); + expect(vector.y.value).toBe(20); + }); + + it('should use the same value for x and y if y is not provided', () => { + const vector = useSharedVector(15); + expect(vector.x.value).toBe(15); + expect(vector.y.value).toBe(15); + }); + }); + + describe('add', () => { + it('should add multiple vectors together', () => { + const v1 = create(10, 20); + const v2 = create(30, 40); + const result = add(v1, v2); + expect(result).toEqual({x: 40, y: 60}); + }); + }); + + describe('sub', () => { + it('should subtract multiple vectors', () => { + const v1 = create(30, 40); + const v2 = create(10, 20); + const result = sub(v1, v2); + expect(result).toEqual({x: 20, y: 20}); + }); + }); + + describe('multiply', () => { + it('should multiply multiple vectors', () => { + const v1 = create(10, 20); + const v2 = create(2, 3); + const result = multiply(v1, v2); + expect(result).toEqual({x: 20, y: 60}); + }); + }); + + describe('divide', () => { + it('should divide multiple vectors', () => { + const v1 = create(20, 40); + const v2 = create(2, 4); + const result = divide(v1, v2); + expect(result).toEqual({x: 10, y: 10}); + }); + + it('should return 0 when dividing by zero', () => { + const v1 = create(20, 40); + const v2 = create(0, 0); + const result = divide(v1, v2); + expect(result).toEqual({x: 0, y: 0}); + }); + }); + + describe('invert', () => { + it('should invert the vector', () => { + const v = create(10, -20); + const result = invert(v); + expect(result).toEqual({x: -10, y: 20}); + }); + }); + + describe('set', () => { + it('should set vector values using another vector', () => { + const vector = useSharedVector(0, 0); + const newValue = create(10, 20); + set(vector, newValue); + expect(vector.x.value).toBe(10); + expect(vector.y.value).toBe(20); + }); + + it('should set vector values using a callback function', () => { + const vector = useSharedVector(0, 0); + const callback = () => 5; + set(vector, callback); + expect(vector.x.value).toBe(5); + expect(vector.y.value).toBe(5); + }); + + it('should set vector values using shared values', () => { + const vector = useSharedVector(0, 0); + const sharedValue = 10; + set(vector, sharedValue); + expect(vector.x.value).toBe(10); + expect(vector.y.value).toBe(10); + }); + }); + + describe('min', () => { + it('should return the minimum of multiple vectors', () => { + const v1 = create(10, 20); + const v2 = create(30, 5); + const result = min(v1, v2); + expect(result).toEqual({x: 10, y: 5}); + }); + }); + + describe('max', () => { + it('should return the maximum of multiple vectors', () => { + const v1 = create(10, 20); + const v2 = create(30, 5); + const result = max(v1, v2); + expect(result).toEqual({x: 30, y: 20}); + }); + }); + + describe('clamp', () => { + it('should clamp vector within given bounds', () => { + const value = create(15, 5); + const lower = create(10, 10); + const upper = create(20, 20); + const result = clamp(value, lower, upper); + expect(result).toEqual({x: 15, y: 10}); + }); + }); + + describe('eq', () => { + it('should return true if vectors are equal', () => { + const v1 = create(10, 20); + const v2 = create(10, 20); + expect(eq(v1, v2)).toBe(true); + }); + + it('should return false if vectors are not equal', () => { + const v1 = create(10, 20); + const v2 = create(20, 10); + expect(eq(v1, v2)).toBe(false); + }); + }); +}); diff --git a/app/utils/gallery/vectors.ts b/app/utils/gallery/vectors.ts index a1508f549..06db95f6e 100644 --- a/app/utils/gallery/vectors.ts +++ b/app/utils/gallery/vectors.ts @@ -160,6 +160,7 @@ export const set = ( if (typeof value === 'function') { vector.x.value = value(); vector.y.value = value(); + return; } const x = get(isVector(value) ? value.x : value); diff --git a/test/setup.ts b/test/setup.ts index 0ff7995a2..409753cc8 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -150,6 +150,8 @@ jest.doMock('react-native', () => { removeChannelNotifications: jest.fn().mockImplementation(), removeThreadNotifications: jest.fn().mockImplementation(), removeServerNotifications: jest.fn().mockImplementation(), + + unlockOrientation: jest.fn(), }, APIClient: { getConstants: () => ({