From ffe0a18828704dc5d030a90da1594e550f77adbb Mon Sep 17 00:00:00 2001 From: sergeyteleshev Date: Fri, 18 Oct 2024 07:34:19 +0200 Subject: [PATCH] Cb 5757 unit tests for core utils and core blocks (#2961) * CB-5757 adds unit tests for getDomainFromUrl helper * CB-5757 adds unit tests for getTextFileReadingProcess helper * CB-5757 adds unit tests for TextTools helper * CB-5757 adds unit tests for downloadFromURL helper * CB-5757 adds unit tests for Deferred and PromiseExecutor * CB-5757 adds unit tests for core-blocks hooks * CB-5757 fixes the tests --- webapp/packages/core-blocks/src/index.ts | 1 - .../src/useActivationDelay.test.ts | 159 ++++++++++++++++++ .../src/useCombinedHandler.test.ts | 69 ++++++++ .../core-blocks/src/useCombinedRef.test.ts | 69 ++++++++ webapp/packages/core-blocks/src/useFn.ts | 17 -- .../core-utils/src/Promises/Deferred.ts | 19 --- .../src/Promises/PromiseExecutor.test.ts | 46 +++++ .../packages/core-utils/src/TextTools.test.ts | 117 +++++++++++++ .../core-utils/src/downloadFromURL.test.ts | 78 +++++++++ .../src/getTextFileReadingProcess.test.ts | 83 +++++++++ 10 files changed, 621 insertions(+), 37 deletions(-) create mode 100644 webapp/packages/core-blocks/src/useActivationDelay.test.ts create mode 100644 webapp/packages/core-blocks/src/useCombinedHandler.test.ts create mode 100644 webapp/packages/core-blocks/src/useCombinedRef.test.ts delete mode 100644 webapp/packages/core-blocks/src/useFn.ts create mode 100644 webapp/packages/core-utils/src/Promises/PromiseExecutor.test.ts create mode 100644 webapp/packages/core-utils/src/TextTools.test.ts create mode 100644 webapp/packages/core-utils/src/downloadFromURL.test.ts create mode 100644 webapp/packages/core-utils/src/getTextFileReadingProcess.test.ts diff --git a/webapp/packages/core-blocks/src/index.ts b/webapp/packages/core-blocks/src/index.ts index 78e9c60fad..dbd05604ba 100644 --- a/webapp/packages/core-blocks/src/index.ts +++ b/webapp/packages/core-blocks/src/index.ts @@ -219,7 +219,6 @@ export * from './useClipboard.js'; export * from './useCombinedHandler.js'; export * from './useCombinedRef.js'; export * from './useExecutor.js'; -export * from './useFn.js'; export * from './useFocus.js'; export * from './useFormValidator.js'; export * from './ResourcesHooks/useOffsetPagination.js'; diff --git a/webapp/packages/core-blocks/src/useActivationDelay.test.ts b/webapp/packages/core-blocks/src/useActivationDelay.test.ts new file mode 100644 index 0000000000..b19006135f --- /dev/null +++ b/webapp/packages/core-blocks/src/useActivationDelay.test.ts @@ -0,0 +1,159 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { act, renderHook } from '@testing-library/react'; + +import { useActivationDelay } from './useActivationDelay.js'; + +describe('useActivationDelay', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('should initially return false', () => { + const { result } = renderHook(() => useActivationDelay(false, 1000)); + expect(result.current).toBe(false); + }); + + it('should not change state before delay when activated', () => { + const { result } = renderHook(() => useActivationDelay(true, 1000)); + + expect(result.current).toBe(false); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(result.current).toBe(false); + }); + + it('should change state after delay when activated', () => { + const { result } = renderHook(() => useActivationDelay(true, 1000)); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(result.current).toBe(true); + }); + + it('should fire callback after delay when activated', () => { + const callback = jest.fn(); + renderHook(() => useActivationDelay(true, 1000, callback)); + + expect(callback).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should handle callback changes', () => { + const initialCallback = jest.fn(); + const newCallback = jest.fn(); + + const { rerender } = renderHook(({ callback }) => useActivationDelay(true, 1000, callback), { initialProps: { callback: initialCallback } }); + + rerender({ callback: newCallback }); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(initialCallback).not.toHaveBeenCalled(); + expect(newCallback).toHaveBeenCalledTimes(1); + }); + + it('should clear timeout on unmount', () => { + const callback = jest.fn(); + const { unmount } = renderHook(() => useActivationDelay(true, 1000, callback)); + + unmount(); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should not fire callback if delay changed', () => { + const callback = jest.fn(); + const { rerender } = renderHook(({ delay }) => useActivationDelay(true, delay, callback), { initialProps: { delay: 1000 } }); + + act(() => { + jest.advanceTimersByTime(500); + }); + + rerender({ delay: 2000 }); + + act(() => { + jest.advanceTimersByTime(1500); + }); + + expect(callback).toHaveBeenCalledTimes(0); + }); + + it('should not fire callback when state changed to false', () => { + const callback = jest.fn(); + const { rerender } = renderHook(({ state }) => useActivationDelay(state, 1000, callback), { initialProps: { state: true } }); + + act(() => { + jest.advanceTimersByTime(500); + }); + + rerender({ state: false }); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(callback).toHaveBeenCalledTimes(0); + }); + + it('should handle rapid state changes', () => { + const callback = jest.fn(); + const { rerender } = renderHook(({ state }) => useActivationDelay(state, 1000, callback), { initialProps: { state: true } }); + + act(() => { + jest.advanceTimersByTime(500); + }); + + rerender({ state: false }); + + act(() => { + jest.advanceTimersByTime(100); + }); + + rerender({ state: true }); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should work with zero delay', () => { + const callback = jest.fn(); + renderHook(() => useActivationDelay(true, 0, callback)); + + act(() => { + jest.advanceTimersByTime(0); + }); + + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/webapp/packages/core-blocks/src/useCombinedHandler.test.ts b/webapp/packages/core-blocks/src/useCombinedHandler.test.ts new file mode 100644 index 0000000000..59ba656e01 --- /dev/null +++ b/webapp/packages/core-blocks/src/useCombinedHandler.test.ts @@ -0,0 +1,69 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { describe, expect, jest, test } from '@jest/globals'; +import { renderHook } from '@testing-library/react'; + +import { useCombinedHandler } from './useCombinedHandler.js'; + +jest.mock('./useObjectRef', () => ({ + useObjectRef: jest.fn(value => value), +})); + +describe('useCombinedHandler', () => { + test('should call all provided handlers', () => { + const handler1 = jest.fn(); + const handler2 = jest.fn(); + + const { result } = renderHook(() => useCombinedHandler(handler1, handler2)); + + result.current('arg1', 'arg2'); + + expect(handler1).toHaveBeenCalledWith('arg1', 'arg2'); + expect(handler2).toHaveBeenCalledWith('arg1', 'arg2'); + }); + + test('should handle null and undefined handlers', () => { + const { result } = renderHook(() => useCombinedHandler(null, undefined)); + + expect(() => result.current('testArg')).not.toThrow(); + }); + + test('should not fail when no handlers are provided', () => { + const { result } = renderHook(() => useCombinedHandler()); + + expect(() => result.current()).not.toThrow(); + }); + + test('should allow the combined handler to be called multiple times', () => { + const handler1 = jest.fn(); + const handler2 = jest.fn(); + + const { result } = renderHook(() => useCombinedHandler(handler1, handler2)); + + result.current('firstCall'); + result.current('secondCall'); + + expect(handler1).toHaveBeenNthCalledWith(1, 'firstCall'); + expect(handler1).toHaveBeenNthCalledWith(2, 'secondCall'); + + expect(handler2).toHaveBeenNthCalledWith(1, 'firstCall'); + expect(handler2).toHaveBeenNthCalledWith(2, 'secondCall'); + }); + + test('should work with asynchronous handlers', async () => { + const handler1 = jest.fn(async arg => await Promise.resolve(arg)); + const handler2 = jest.fn(async arg => await Promise.resolve(arg)); + + const { result } = renderHook(() => useCombinedHandler(handler1, handler2)); + + result.current('asyncArg'); + + expect(handler1).toHaveBeenCalledWith('asyncArg'); + expect(handler2).toHaveBeenCalledWith('asyncArg'); + }); +}); diff --git a/webapp/packages/core-blocks/src/useCombinedRef.test.ts b/webapp/packages/core-blocks/src/useCombinedRef.test.ts new file mode 100644 index 0000000000..b543b4e0ee --- /dev/null +++ b/webapp/packages/core-blocks/src/useCombinedRef.test.ts @@ -0,0 +1,69 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { describe, expect, it, jest } from '@jest/globals'; +import { act, renderHook } from '@testing-library/react'; +import React from 'react'; + +import { useCombinedRef } from './useCombinedRef.js'; + +describe('useCombinedRef', () => { + it('should handle ref as function', () => { + const callbackRef = jest.fn(); + const { result } = renderHook(() => useCombinedRef(callbackRef)); + + const testInstance = { test: true }; + + act(() => { + result.current(testInstance); + }); + + expect(callbackRef).toHaveBeenCalledWith(testInstance); + }); + + it('should handle ref as RefObject', () => { + const mutableRef = React.createRef(); + const { result } = renderHook(() => useCombinedRef(mutableRef)); + + const testInstance = { test: true }; + + act(() => { + result.current(testInstance); + }); + + expect(mutableRef.current).toBe(testInstance); + }); + + it('should handle ref as null', () => { + const { result } = renderHook(() => useCombinedRef(null)); + + const testInstance = { test: true }; + + expect(() => { + act(() => { + result.current(testInstance); + }); + }).not.toThrow(); + }); + + it('should handle multiple refs', () => { + const callbackRef = jest.fn(); + const mutableRef = React.createRef(); + const anotherMutableRef = React.createRef(); + const { result } = renderHook(() => useCombinedRef(callbackRef, mutableRef, anotherMutableRef)); + + const testInstance = { test: true }; + + act(() => { + result.current(testInstance); + }); + + expect(callbackRef).toHaveBeenCalledWith(testInstance); + expect(mutableRef.current).toBe(testInstance); + expect(anotherMutableRef.current).toBe(testInstance); + }); +}); diff --git a/webapp/packages/core-blocks/src/useFn.ts b/webapp/packages/core-blocks/src/useFn.ts deleted file mode 100644 index c6ce35cc3e..0000000000 --- a/webapp/packages/core-blocks/src/useFn.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others - * - * Licensed under the Apache License, Version 2.0. - * you may not use this file except in compliance with the License. - */ -import { useCallback, useRef } from 'react'; - -const noop: any[] = []; - -export function useFn(fn: (...args: TArgs) => TValue): (...args: TArgs) => TValue { - const ref = useRef(fn); - ref.current = fn; - - return useCallback((...args: TArgs) => ref.current(...args), noop); -} diff --git a/webapp/packages/core-utils/src/Promises/Deferred.ts b/webapp/packages/core-utils/src/Promises/Deferred.ts index 8b0fe1ac6f..bd234d54a9 100644 --- a/webapp/packages/core-utils/src/Promises/Deferred.ts +++ b/webapp/packages/core-utils/src/Promises/Deferred.ts @@ -7,8 +7,6 @@ */ import { action, computed, makeObservable, observable } from 'mobx'; -import { errorOf } from '../errorOf.js'; -import { PromiseCancelledError } from './PromiseCancelledError.js'; import { PromiseExecutor } from './PromiseExecutor.js'; export enum EDeferredState { @@ -111,20 +109,3 @@ export class Deferred { } } } - -export class DeferredFromPromise extends Deferred { - constructor(promise: Promise) { - super(); - promise.then( - value => this.toResolved(value), - err => { - const promiseCancelledError = errorOf(err, PromiseCancelledError); - if (promiseCancelledError) { - this.toCancelled(promiseCancelledError.cause); - } else { - this.toRejected(err); - } - }, - ); - } -} diff --git a/webapp/packages/core-utils/src/Promises/PromiseExecutor.test.ts b/webapp/packages/core-utils/src/Promises/PromiseExecutor.test.ts new file mode 100644 index 0000000000..5246510ac0 --- /dev/null +++ b/webapp/packages/core-utils/src/Promises/PromiseExecutor.test.ts @@ -0,0 +1,46 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { beforeEach, describe, expect, it } from '@jest/globals'; + +import { PromiseExecutor } from './PromiseExecutor.js'; + +describe('PromiseExecutor', () => { + let promiseExecutor: PromiseExecutor; + + beforeEach(() => { + promiseExecutor = new PromiseExecutor(); + }); + + it('should initialize with a promise', () => { + expect(promiseExecutor.promise).toBeInstanceOf(Promise); + }); + + it('should resolve the promise with the correct value', async () => { + const testValue = 'resolved value'; + + promiseExecutor.resolve(testValue); + + await expect(promiseExecutor.promise).resolves.toBe(testValue); + }); + + it('should reject the promise with the correct reason', async () => { + const testError = new Error('test error'); + + promiseExecutor.reject(testError); + + await expect(promiseExecutor.promise).rejects.toBe(testError); + }); + + it('should resolve with a PromiseLike object', async () => { + const testPromiseLike = Promise.resolve('promise-like value'); + + promiseExecutor.resolve(testPromiseLike); + + await expect(promiseExecutor.promise).resolves.toBe('promise-like value'); + }); +}); diff --git a/webapp/packages/core-utils/src/TextTools.test.ts b/webapp/packages/core-utils/src/TextTools.test.ts new file mode 100644 index 0000000000..09ddb141f0 --- /dev/null +++ b/webapp/packages/core-utils/src/TextTools.test.ts @@ -0,0 +1,117 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; + +import { TextTools } from './TextTools.js'; + +const TEXT_WIDTH = 100; +const LETTER_SPACING = 0.4; + +function getLength(value: string): number { + return TEXT_WIDTH + (value.length - 1) * LETTER_SPACING; +} + +describe('TextTools', () => { + let mockContext: jest.Mocked; + + beforeEach(() => { + mockContext = { + measureText: jest.fn().mockReturnValue({ width: TEXT_WIDTH }), + font: '', + } as unknown as jest.Mocked; + + const mockCanvas = { + getContext: jest.fn().mockReturnValue(mockContext), + }; + + jest.spyOn(document, 'createElement').mockImplementation(() => mockCanvas as unknown as HTMLCanvasElement); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('getWidth', () => { + it('should calculate width without container', () => { + const options = { + font: 'bold 16px Arial', + text: ['Hello', 'World'], + }; + + const result = TextTools.getWidth(options); + + expect(mockContext.font).toBe('bold 16px Arial'); + expect(result).toEqual(options.text.map(getLength)); + expect(mockContext.measureText).toHaveBeenCalledTimes(options.text.length); + }); + + it('should use container styles when provided', () => { + const mockContainer = document.createElement('div'); + const mockStyles = { + getPropertyValue: jest.fn().mockReturnValueOnce('bold').mockReturnValueOnce('16px').mockReturnValueOnce('Arial, sans'), + }; + + jest.spyOn(window, 'getComputedStyle').mockReturnValue(mockStyles as unknown as CSSStyleDeclaration); + + const options = { + font: 'default', + container: mockContainer, + text: ['Test'], + }; + + const result = TextTools.getWidth(options); + + expect(mockContext.font).toBe('bold 16px Arial'); + expect(result).toEqual(options.text.map(getLength)); + }); + + it('should use provided font when container styles are incomplete', () => { + const mockContainer = document.createElement('div'); + const mockStyles = { + getPropertyValue: jest.fn().mockReturnValue(''), + }; + + jest.spyOn(window, 'getComputedStyle').mockReturnValue(mockStyles as unknown as CSSStyleDeclaration); + + const options = { + font: 'italic 14px Times', + container: mockContainer, + text: ['Test'], + }; + + const result = TextTools.getWidth(options); + + expect(mockContext.font).toBe('italic 14px Times'); + expect(result).toEqual(options.text.map(getLength)); + }); + + it('should handle empty text array', () => { + const options = { + font: 'bold 16px Arial', + text: [], + }; + + const result = TextTools.getWidth(options); + + expect(result).toEqual([]); + expect(mockContext.measureText).not.toHaveBeenCalled(); + }); + + it('should handle single-character strings', () => { + const options = { + font: 'bold 16px Arial', + text: ['A', 'B'], + }; + + const result = TextTools.getWidth(options); + + expect(result).toEqual(options.text.map(getLength)); + expect(mockContext.measureText).toHaveBeenCalledTimes(options.text.length); + }); + }); +}); diff --git a/webapp/packages/core-utils/src/downloadFromURL.test.ts b/webapp/packages/core-utils/src/downloadFromURL.test.ts new file mode 100644 index 0000000000..a349021f83 --- /dev/null +++ b/webapp/packages/core-utils/src/downloadFromURL.test.ts @@ -0,0 +1,78 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; + +import { downloadFromURL } from './downloadFromURL.js'; + +type MockXHR = { + open: jest.Mock; + send: jest.Mock; + setRequestHeader: jest.Mock; + responseType: string; + onload: jest.Mock; + onerror: jest.Mock; + response: Blob | null; +}; + +describe('downloadFromURL', () => { + let mockXHR: MockXHR; + + beforeEach(() => { + mockXHR = { + open: jest.fn(), + send: jest.fn(), + setRequestHeader: jest.fn(), + responseType: '', + onload: jest.fn(), + onerror: jest.fn(), + response: null, + }; + + (global as any).XMLHttpRequest = jest.fn(() => mockXHR); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should open and send request to the specified URL', async () => { + const url = 'http://example.com/test'; + downloadFromURL(url); + + expect(mockXHR.open).toHaveBeenCalledWith('GET', url, true); + expect(mockXHR.send).toHaveBeenCalled(); + }); + + it('should resolve with a Blob when the request is successful', async () => { + const mockBlob = new Blob(['test'], { type: 'text/plain' }); + mockXHR.response = mockBlob; + + setTimeout(() => { + mockXHR.onload?.(); + }, 0); + + const url = 'http://example.com/test'; + const result = await downloadFromURL(url); + + expect(mockXHR.responseType).toBe('blob'); + expect(result).toBe(mockBlob); + }); + + it('should reject with an error when the request fails', async () => { + const mockError = new Error('Network error'); + + setTimeout(() => { + mockXHR.onerror?.(mockError); + }, 0); + + const url = 'http://example.com/test'; + + await expect(downloadFromURL(url)).rejects.toThrow('Network error'); + expect(mockXHR.responseType).toBe('blob'); + }); +}); diff --git a/webapp/packages/core-utils/src/getTextFileReadingProcess.test.ts b/webapp/packages/core-utils/src/getTextFileReadingProcess.test.ts new file mode 100644 index 0000000000..b1a1974bad --- /dev/null +++ b/webapp/packages/core-utils/src/getTextFileReadingProcess.test.ts @@ -0,0 +1,83 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; + +import { getTextFileReadingProcess } from './getTextFileReadingProcess.js'; + +describe('getTextFileReadingProcess', () => { + let file: File; + let mockFileReader: Partial; + + beforeEach(() => { + file = new File(['file content'], 'test.txt', { type: 'text/plain' }); + + mockFileReader = { + readAsText: jest.fn(), + onload: null, + onerror: null, + onabort: null, + }; + + jest.spyOn(window, 'FileReader').mockImplementation(() => mockFileReader as FileReader); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should return correct instances', () => { + const { promise, reader } = getTextFileReadingProcess(file); + + expect(promise).toBeInstanceOf(Promise); + expect(reader).toBe(mockFileReader); + }); + + it('should resolve with file content on successful read', async () => { + const { promise } = getTextFileReadingProcess(file); + + (mockFileReader.onload as any)({ + target: { result: 'file content' }, + }); + + await expect(promise).resolves.toBe('file content'); + expect(mockFileReader.readAsText).toHaveBeenCalledWith(file); + }); + + it('should reject with an error if file has no data', async () => { + const { promise } = getTextFileReadingProcess(file); + + const results = [null, undefined, '']; + + for (const result of results) { + (mockFileReader.onload as any)({ + target: { result: result }, + }); + + await expect(promise).rejects.toThrow(`No data for the file: "${file.name}"`); + } + }); + + it('should reject with an error if reading fails', async () => { + const { promise } = getTextFileReadingProcess(file); + const error = new Error('Read error'); + + (mockFileReader.onerror as any)({ + target: { error: error }, + }); + + await expect(promise).rejects.toThrow(`Error occurred reading file: "${file.name}"`); + }); + + it('should reject if the read is aborted', async () => { + const { promise } = getTextFileReadingProcess(file); + + (mockFileReader.onabort as any)(); + + await expect(promise).rejects.toThrow(`Reading "${file.name}" is aborted`); + }); +});