From 31a6aabfe5b78641b864b6a14defed86962bad41 Mon Sep 17 00:00:00 2001 From: Joel Date: Thu, 3 Apr 2025 18:19:11 +0800 Subject: [PATCH] chore: add unit test to high frequency component (#17423) --- web/app/components/base/button/index.spec.tsx | 139 +++++++++---- .../components/base/divider/index.spec.tsx | 55 +++++ .../components/base/icons/IconBase.spec.tsx | 67 ++++++ web/app/components/base/icons/utils.spec.ts | 70 +++++++ web/app/components/base/input/index.spec.tsx | 124 ++++++++++++ web/app/components/base/input/index.tsx | 2 +- .../components/base/loading/index.spec.tsx | 29 +++ .../base/portal-to-follow-elem/index.spec.tsx | 121 +++++++++++ .../components/base/spinner/index.spec.tsx | 49 +++++ web/app/components/base/toast/index.spec.tsx | 191 ++++++++++++++++++ .../components/base/tooltip/index.spec.tsx | 116 +++++++++++ web/jest.config.ts | 2 +- 12 files changed, 924 insertions(+), 41 deletions(-) create mode 100644 web/app/components/base/divider/index.spec.tsx create mode 100644 web/app/components/base/icons/IconBase.spec.tsx create mode 100644 web/app/components/base/icons/utils.spec.ts create mode 100644 web/app/components/base/input/index.spec.tsx create mode 100644 web/app/components/base/loading/index.spec.tsx create mode 100644 web/app/components/base/portal-to-follow-elem/index.spec.tsx create mode 100644 web/app/components/base/spinner/index.spec.tsx create mode 100644 web/app/components/base/toast/index.spec.tsx create mode 100644 web/app/components/base/tooltip/index.spec.tsx diff --git a/web/app/components/base/button/index.spec.tsx b/web/app/components/base/button/index.spec.tsx index 308656c23..9da2620cd 100644 --- a/web/app/components/base/button/index.spec.tsx +++ b/web/app/components/base/button/index.spec.tsx @@ -4,46 +4,107 @@ import Button from './index' afterEach(cleanup) // https://testing-library.com/docs/queries/about -describe('Button text', () => { - test('Button text should be same as children', async () => { - const { getByRole, container } = render() - expect(getByRole('button').textContent).toBe('Click me') - expect(container.querySelector('button')?.textContent).toBe('Click me') +describe('Button', () => { + describe('Button text', () => { + test('Button text should be same as children', async () => { + const { getByRole, container } = render() + expect(getByRole('button').textContent).toBe('Click me') + expect(container.querySelector('button')?.textContent).toBe('Click me') + }) }) - test('Loading button text should include same as children', async () => { - const { getByRole } = render() - expect(getByRole('button').textContent?.includes('Loading')).toBe(true) - }) -}) - -describe('Button style', () => { - test('Button should have default variant', async () => { - const { getByRole } = render() - expect(getByRole('button').className).toContain('btn-secondary') - }) - - test('Button should have primary variant', async () => { - const { getByRole } = render() - expect(getByRole('button').className).toContain('btn-primary') - }) - - test('Button should have warning variant', async () => { - const { getByRole } = render() - expect(getByRole('button').className).toContain('btn-warning') - }) - - test('Button disabled should have disabled variant', async () => { - const { getByRole } = render() - expect(getByRole('button').className).toContain('btn-disabled') - }) -}) - -describe('Button events', () => { - test('onClick should been call after clicked', async () => { - const onClick = jest.fn() - const { getByRole } = render() - fireEvent.click(getByRole('button')) - expect(onClick).toHaveBeenCalled() + describe('Button loading', () => { + test('Loading button text should include same as children', async () => { + const { getByRole } = render() + expect(getByRole('button').textContent?.includes('Loading')).toBe(true) + }) + test('Not loading button text should include same as children', async () => { + const { getByRole } = render() + expect(getByRole('button').textContent?.includes('Loading')).toBe(false) + }) + + test('Loading button should have loading classname', async () => { + const animClassName = 'anim-breath' + const { getByRole } = render() + expect(getByRole('button').getElementsByClassName('animate-spin')[0]?.className).toContain(animClassName) + }) + }) + + describe('Button style', () => { + test('Button should have default variant', async () => { + const { getByRole } = render() + expect(getByRole('button').className).toContain('btn-secondary') + }) + + test('Button should have primary variant', async () => { + const { getByRole } = render() + expect(getByRole('button').className).toContain('btn-primary') + }) + + test('Button should have warning variant', async () => { + const { getByRole } = render() + expect(getByRole('button').className).toContain('btn-warning') + }) + + test('Button should have secondary variant', async () => { + const { getByRole } = render() + expect(getByRole('button').className).toContain('btn-secondary') + }) + + test('Button should have secondary-accent variant', async () => { + const { getByRole } = render() + expect(getByRole('button').className).toContain('btn-secondary-accent') + }) + test('Button should have ghost variant', async () => { + const { getByRole } = render() + expect(getByRole('button').className).toContain('btn-ghost') + }) + test('Button should have ghost-accent variant', async () => { + const { getByRole } = render() + expect(getByRole('button').className).toContain('btn-ghost-accent') + }) + + test('Button disabled should have disabled variant', async () => { + const { getByRole } = render() + expect(getByRole('button').className).toContain('btn-disabled') + }) + }) + + describe('Button size', () => { + test('Button should have default size', async () => { + const { getByRole } = render() + expect(getByRole('button').className).toContain('btn-medium') + }) + + test('Button should have small size', async () => { + const { getByRole } = render() + expect(getByRole('button').className).toContain('btn-small') + }) + + test('Button should have medium size', async () => { + const { getByRole } = render() + expect(getByRole('button').className).toContain('btn-medium') + }) + + test('Button should have large size', async () => { + const { getByRole } = render() + expect(getByRole('button').className).toContain('btn-large') + }) + }) + + describe('Button destructive', () => { + test('Button should have destructive classname', async () => { + const { getByRole } = render() + expect(getByRole('button').className).toContain('btn-destructive') + }) + }) + + describe('Button events', () => { + test('onClick should been call after clicked', async () => { + const onClick = jest.fn() + const { getByRole } = render() + fireEvent.click(getByRole('button')) + expect(onClick).toHaveBeenCalled() + }) }) }) diff --git a/web/app/components/base/divider/index.spec.tsx b/web/app/components/base/divider/index.spec.tsx new file mode 100644 index 000000000..d33bfeb87 --- /dev/null +++ b/web/app/components/base/divider/index.spec.tsx @@ -0,0 +1,55 @@ +import { render } from '@testing-library/react' +import '@testing-library/jest-dom' +import Divider from './index' + +describe('Divider', () => { + it('renders with default props', () => { + const { container } = render() + const divider = container.firstChild as HTMLElement + expect(divider).toHaveClass('w-full h-[0.5px] my-2') + expect(divider).toHaveClass('bg-divider-regular') + }) + + it('renders horizontal solid divider correctly', () => { + const { container } = render() + const divider = container.firstChild as HTMLElement + expect(divider).toHaveClass('w-full h-[0.5px] my-2') + expect(divider).toHaveClass('bg-divider-regular') + }) + + it('renders vertical solid divider correctly', () => { + const { container } = render() + const divider = container.firstChild as HTMLElement + expect(divider).toHaveClass('w-[1px] h-full mx-2') + expect(divider).toHaveClass('bg-divider-regular') + }) + + it('renders horizontal gradient divider correctly', () => { + const { container } = render() + const divider = container.firstChild as HTMLElement + expect(divider).toHaveClass('w-full h-[0.5px] my-2') + expect(divider).toHaveClass('bg-gradient-to-r from-divider-regular to-background-gradient-mask-transparent') + }) + + it('renders vertical gradient divider correctly', () => { + const { container } = render() + const divider = container.firstChild as HTMLElement + expect(divider).toHaveClass('w-[1px] h-full mx-2') + expect(divider).toHaveClass('bg-gradient-to-r from-divider-regular to-background-gradient-mask-transparent') + }) + + it('applies custom className correctly', () => { + const customClass = 'test-custom-class' + const { container } = render() + const divider = container.firstChild as HTMLElement + expect(divider).toHaveClass(customClass) + expect(divider).toHaveClass('w-full h-[0.5px] my-2') + }) + + it('applies custom style correctly', () => { + const customStyle = { margin: '10px' } + const { container } = render() + const divider = container.firstChild as HTMLElement + expect(divider).toHaveStyle('margin: 10px') + }) +}) diff --git a/web/app/components/base/icons/IconBase.spec.tsx b/web/app/components/base/icons/IconBase.spec.tsx new file mode 100644 index 000000000..e44004053 --- /dev/null +++ b/web/app/components/base/icons/IconBase.spec.tsx @@ -0,0 +1,67 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import React from 'react' +import type { IconData } from './IconBase' +import IconBase from './IconBase' +import * as utils from './utils' + +// Mock the utils module +jest.mock('./utils', () => ({ + generate: jest.fn((icon, key, props) => ( + + mocked svg content + + )), +})) + +describe('IconBase Component', () => { + const mockData: IconData = { + name: 'test-icon', + icon: { name: 'svg', attributes: {}, children: [] }, + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders properly with required props', () => { + render() + const svg = screen.getByTestId('mock-svg') + expect(svg).toBeInTheDocument() + expect(svg).toHaveAttribute('data-icon', mockData.name) + expect(svg).toHaveAttribute('aria-hidden', 'true') + }) + + it('passes className to the generated SVG', () => { + render() + const svg = screen.getByTestId('mock-svg') + expect(svg).toHaveAttribute('class', 'custom-class') + expect(utils.generate).toHaveBeenCalledWith( + mockData.icon, + 'svg-test-icon', + expect.objectContaining({ className: 'custom-class' }), + ) + }) + + it('handles onClick events', () => { + const handleClick = jest.fn() + render() + const svg = screen.getByTestId('mock-svg') + fireEvent.click(svg) + expect(handleClick).toHaveBeenCalledTimes(1) + }) + + it('applies custom styles', () => { + const customStyle = { color: 'red', fontSize: '24px' } + render() + expect(utils.generate).toHaveBeenCalledWith( + mockData.icon, + 'svg-test-icon', + expect.objectContaining({ style: customStyle }), + ) + }) +}) diff --git a/web/app/components/base/icons/utils.spec.ts b/web/app/components/base/icons/utils.spec.ts new file mode 100644 index 000000000..bfa8e394e --- /dev/null +++ b/web/app/components/base/icons/utils.spec.ts @@ -0,0 +1,70 @@ +import type { AbstractNode } from './utils' +import { generate, normalizeAttrs } from './utils' +import { render } from '@testing-library/react' +import '@testing-library/jest-dom' + +describe('generate icon base utils', () => { + describe('normalizeAttrs', () => { + it('should normalize class to className', () => { + const attrs = { class: 'test-class' } + const result = normalizeAttrs(attrs) + expect(result).toEqual({ className: 'test-class' }) + }) + + it('should normalize style string to style object', () => { + const attrs = { style: 'color:red;font-size:14px;' } + const result = normalizeAttrs(attrs) + expect(result).toEqual({ style: { color: 'red', fontSize: '14px' } }) + }) + + it('should handle attributes with dashes and colons', () => { + const attrs = { 'data-test': 'value', 'xlink:href': 'url' } + const result = normalizeAttrs(attrs) + expect(result).toEqual({ dataTest: 'value', xlinkHref: 'url' }) + }) + }) + + describe('generate', () => { + it('should generate React elements from AbstractNode', () => { + const node: AbstractNode = { + name: 'div', + attributes: { class: 'container' }, + children: [ + { + name: 'span', + attributes: { style: 'color:blue;' }, + children: [], + }, + ], + } + + const { container } = render(generate(node, 'key')) + // to svg element + expect(container.firstChild).toHaveClass('container') + expect(container.querySelector('span')).toHaveStyle({ color: 'blue' }) + }) + + // add not has children + it('should generate React elements without children', () => { + const node: AbstractNode = { + name: 'div', + attributes: { class: 'container' }, + } + const { container } = render(generate(node, 'key')) + // to svg element + expect(container.firstChild).toHaveClass('container') + }) + + it('should merge rootProps when provided', () => { + const node: AbstractNode = { + name: 'div', + attributes: { class: 'container' }, + children: [], + } + + const rootProps = { id: 'root' } + const { container } = render(generate(node, 'key', rootProps)) + expect(container.querySelector('div')).toHaveAttribute('id', 'root') + }) + }) +}) diff --git a/web/app/components/base/input/index.spec.tsx b/web/app/components/base/input/index.spec.tsx new file mode 100644 index 000000000..12dd9bc5f --- /dev/null +++ b/web/app/components/base/input/index.spec.tsx @@ -0,0 +1,124 @@ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import Input, { inputVariants } from './index' + +// Mock the i18n hook +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'common.operation.search': 'Search', + 'common.placeholder.input': 'Please input', + } + return translations[key] || '' + }, + }), +})) + +describe('Input component', () => { + describe('Variants', () => { + it('should return correct classes for regular size', () => { + const result = inputVariants({ size: 'regular' }) + expect(result).toContain('px-3') + expect(result).toContain('radius-md') + expect(result).toContain('system-sm-regular') + }) + + it('should return correct classes for large size', () => { + const result = inputVariants({ size: 'large' }) + expect(result).toContain('px-4') + expect(result).toContain('radius-lg') + expect(result).toContain('system-md-regular') + }) + + it('should use regular size as default', () => { + const result = inputVariants({}) + expect(result).toContain('px-3') + expect(result).toContain('radius-md') + expect(result).toContain('system-sm-regular') + }) + }) + + it('renders correctly with default props', () => { + render() + const input = screen.getByPlaceholderText('Please input') + expect(input).toBeInTheDocument() + expect(input).not.toBeDisabled() + expect(input).not.toHaveClass('cursor-not-allowed') + }) + + it('shows left icon when showLeftIcon is true', () => { + render() + const searchIcon = document.querySelector('svg') + expect(searchIcon).toBeInTheDocument() + const input = screen.getByPlaceholderText('Search') + expect(input).toHaveClass('pl-[26px]') + }) + + it('shows clear icon when showClearIcon is true and has value', () => { + render() + const clearIcon = document.querySelector('.group svg') + expect(clearIcon).toBeInTheDocument() + const input = screen.getByDisplayValue('test') + expect(input).toHaveClass('pr-[26px]') + }) + + it('does not show clear icon when disabled, even with value', () => { + render() + const clearIcon = document.querySelector('.group svg') + expect(clearIcon).not.toBeInTheDocument() + }) + + it('calls onClear when clear icon is clicked', () => { + const onClear = jest.fn() + render() + const clearIconContainer = document.querySelector('.group') + fireEvent.click(clearIconContainer!) + expect(onClear).toHaveBeenCalledTimes(1) + }) + + it('shows warning icon when destructive is true', () => { + render() + const warningIcon = document.querySelector('svg') + expect(warningIcon).toBeInTheDocument() + const input = screen.getByPlaceholderText('Please input') + expect(input).toHaveClass('border-components-input-border-destructive') + }) + + it('applies disabled styles when disabled', () => { + render() + const input = screen.getByPlaceholderText('Please input') + expect(input).toBeDisabled() + expect(input).toHaveClass('cursor-not-allowed') + expect(input).toHaveClass('bg-components-input-bg-disabled') + }) + + it('displays custom unit when provided', () => { + render() + const unitElement = screen.getByText('km') + expect(unitElement).toBeInTheDocument() + }) + + it('applies custom className and style', () => { + const customClass = 'test-class' + const customStyle = { color: 'red' } + render() + const input = screen.getByPlaceholderText('Please input') + expect(input).toHaveClass(customClass) + expect(input).toHaveStyle('color: red') + }) + + it('applies large size variant correctly', () => { + render() + const input = screen.getByPlaceholderText('Please input') + expect(input.className).toContain(inputVariants({ size: 'large' })) + }) + + it('uses custom placeholder when provided', () => { + const placeholder = 'Custom placeholder' + render() + const input = screen.getByPlaceholderText(placeholder) + expect(input).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/input/index.tsx b/web/app/components/base/input/index.tsx index e49cbf8ca..4d7ab4ed7 100644 --- a/web/app/components/base/input/index.tsx +++ b/web/app/components/base/input/index.tsx @@ -43,7 +43,7 @@ const Input = ({ styleCss, value, placeholder, - onChange, + onChange = () => { }, unit, ...props }: InputProps) => { diff --git a/web/app/components/base/loading/index.spec.tsx b/web/app/components/base/loading/index.spec.tsx new file mode 100644 index 000000000..03e2cfbc2 --- /dev/null +++ b/web/app/components/base/loading/index.spec.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import { render } from '@testing-library/react' +import '@testing-library/jest-dom' +import Loading from './index' + +describe('Loading Component', () => { + it('renders correctly with default props', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('flex w-full items-center justify-center') + expect(container.firstChild).not.toHaveClass('h-full') + }) + + it('renders correctly with area type', () => { + const { container } = render() + expect(container.firstChild).not.toHaveClass('h-full') + }) + + it('renders correctly with app type', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('h-full') + }) + + it('contains SVG with spin-animation class', () => { + const { container } = render() + + const svgElement = container.querySelector('svg') + expect(svgElement).toHaveClass('spin-animation') + }) +}) diff --git a/web/app/components/base/portal-to-follow-elem/index.spec.tsx b/web/app/components/base/portal-to-follow-elem/index.spec.tsx new file mode 100644 index 000000000..74790d784 --- /dev/null +++ b/web/app/components/base/portal-to-follow-elem/index.spec.tsx @@ -0,0 +1,121 @@ +import React from 'react' +import { cleanup, fireEvent, render } from '@testing-library/react' +import '@testing-library/jest-dom' +import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '.' + +afterEach(cleanup) + +describe('PortalToFollowElem', () => { + describe('Context and Provider', () => { + test('should throw error when using context outside provider', () => { + // Suppress console.error for this test + const originalError = console.error + console.error = jest.fn() + + expect(() => { + render( + Trigger , + ) + }).toThrow('PortalToFollowElem components must be wrapped in ') + + console.error = originalError + }) + + test('should not throw when used within provider', () => { + expect(() => { + render( + + Trigger + , + ) + }).not.toThrow() + }) + }) + + describe('PortalToFollowElemTrigger', () => { + test('should render children correctly', () => { + const { getByText } = render( + + Trigger Text + , + ) + expect(getByText('Trigger Text')).toBeInTheDocument() + }) + + test('should handle asChild prop correctly', () => { + const { getByRole } = render( + + + + + , + ) + + expect(getByRole('button')).toHaveTextContent('Button Trigger') + }) + }) + + describe('PortalToFollowElemContent', () => { + test('should not render content when closed', () => { + const { queryByText } = render( + + Trigger + Popup Content + , + ) + + expect(queryByText('Popup Content')).not.toBeInTheDocument() + }) + + test('should render content when open', () => { + const { getByText } = render( + + Trigger + Popup Content + , + ) + + expect(getByText('Popup Content')).toBeInTheDocument() + }) + }) + + describe('Controlled behavior', () => { + test('should call onOpenChange when interaction happens', () => { + const handleOpenChange = jest.fn() + + const { getByText } = render( + + Hover Me + Content + , + ) + + fireEvent.mouseEnter(getByText('Hover Me')) + expect(handleOpenChange).toHaveBeenCalled() + + fireEvent.mouseLeave(getByText('Hover Me')) + expect(handleOpenChange).toHaveBeenCalled() + }) + }) + + describe('Configuration options', () => { + test('should accept placement prop', () => { + // Since we can't easily test actual positioning, we'll check if the prop is passed correctly + const useFloatingMock = jest.spyOn(require('@floating-ui/react'), 'useFloating') + + render( + + Trigger + , + ) + + expect(useFloatingMock).toHaveBeenCalledWith( + expect.objectContaining({ + placement: 'top-start', + }), + ) + + useFloatingMock.mockRestore() + }) + }) +}) diff --git a/web/app/components/base/spinner/index.spec.tsx b/web/app/components/base/spinner/index.spec.tsx new file mode 100644 index 000000000..0c4f0f670 --- /dev/null +++ b/web/app/components/base/spinner/index.spec.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import { render } from '@testing-library/react' +import '@testing-library/jest-dom' +import Spinner from './index' + +describe('Spinner component', () => { + it('should render correctly when loading is true', () => { + const { container } = render() + const spinner = container.firstChild as HTMLElement + + expect(spinner).toHaveClass('animate-spin') + + // Check for accessibility text + const screenReaderText = spinner.querySelector('span') + expect(screenReaderText).toBeInTheDocument() + expect(screenReaderText).toHaveTextContent('Loading...') + }) + + it('should be hidden when loading is false', () => { + const { container } = render() + const spinner = container.firstChild as HTMLElement + + expect(spinner).toHaveClass('hidden') + }) + + it('should render with custom className', () => { + const customClass = 'text-blue-500' + const { container } = render() + const spinner = container.firstChild as HTMLElement + + expect(spinner).toHaveClass(customClass) + }) + + it('should render children correctly', () => { + const childText = 'Child content' + const { getByText } = render( + {childText}, + ) + + expect(getByText(childText)).toBeInTheDocument() + }) + + it('should use default loading value (false) when not provided', () => { + const { container } = render() + const spinner = container.firstChild as HTMLElement + + expect(spinner).toHaveClass('hidden') + }) +}) diff --git a/web/app/components/base/toast/index.spec.tsx b/web/app/components/base/toast/index.spec.tsx new file mode 100644 index 000000000..366fb4cb0 --- /dev/null +++ b/web/app/components/base/toast/index.spec.tsx @@ -0,0 +1,191 @@ +import React from 'react' +import { act, render, screen, waitFor } from '@testing-library/react' +import Toast, { ToastProvider, useToastContext } from '.' +import '@testing-library/jest-dom' + +// Mock timers for testing timeouts +jest.useFakeTimers() + +const TestComponent = () => { + const { notify, close } = useToastContext() + + return ( +
+ + +
+ ) +} + +describe('Toast', () => { + describe('Toast Component', () => { + test('renders toast with correct type and message', () => { + render( + + + , + ) + + expect(screen.getByText('Success message')).toBeInTheDocument() + }) + + test('renders with different types', () => { + const { rerender } = render( + + + , + ) + + expect(document.querySelector('.text-text-success')).toBeInTheDocument() + + rerender( + + + , + ) + + expect(document.querySelector('.text-text-destructive')).toBeInTheDocument() + }) + + test('renders with custom component', () => { + render( + + Custom} + /> + , + ) + + expect(screen.getByTestId('custom-component')).toBeInTheDocument() + }) + + test('renders children content', () => { + render( + + + Additional information + + , + ) + + expect(screen.getByText('Additional information')).toBeInTheDocument() + }) + + test('does not render close button when close is undefined', () => { + // Create a modified context where close is undefined + const CustomToastContext = React.createContext({ notify: () => { }, close: undefined }) + + // Create a wrapper component using the custom context + const Wrapper = ({ children }: any) => ( + { }, close: undefined }}> + {children} + + ) + + render( + + + , + ) + + expect(screen.getByText('No close button')).toBeInTheDocument() + // Ensure the close button is not rendered + expect(document.querySelector('.h-4.w-4.shrink-0.text-text-tertiary')).not.toBeInTheDocument() + }) + }) + + describe('ToastProvider and Context', () => { + test('shows and hides toast using context', async () => { + render( + + + , + ) + + // No toast initially + expect(screen.queryByText('Notification message')).not.toBeInTheDocument() + + // Show toast + act(() => { + screen.getByText('Show Toast').click() + }) + expect(screen.getByText('Notification message')).toBeInTheDocument() + + // Close toast + act(() => { + screen.getByText('Close Toast').click() + }) + expect(screen.queryByText('Notification message')).not.toBeInTheDocument() + }) + + test('automatically hides toast after duration', async () => { + render( + + + , + ) + + // Show toast + act(() => { + screen.getByText('Show Toast').click() + }) + expect(screen.getByText('Notification message')).toBeInTheDocument() + + // Fast-forward timer + act(() => { + jest.advanceTimersByTime(3000) // Default for info type is 3000ms + }) + + // Toast should be gone + await waitFor(() => { + expect(screen.queryByText('Notification message')).not.toBeInTheDocument() + }) + }) + }) + + describe('Toast.notify static method', () => { + test('creates and removes toast from DOM', async () => { + act(() => { + // Call the static method + Toast.notify({ message: 'Static notification', type: 'warning' }) + }) + + // Toast should be in document + expect(screen.getByText('Static notification')).toBeInTheDocument() + + // Fast-forward timer + act(() => { + jest.advanceTimersByTime(6000) // Default for warning type is 6000ms + }) + + // Toast should be removed + await waitFor(() => { + expect(screen.queryByText('Static notification')).not.toBeInTheDocument() + }) + }) + + test('calls onClose callback after duration', async () => { + const onCloseMock = jest.fn() + act(() => { + Toast.notify({ + message: 'Closing notification', + type: 'success', + onClose: onCloseMock, + }) + }) + + // Fast-forward timer + act(() => { + jest.advanceTimersByTime(3000) // Default for success type is 3000ms + }) + + // onClose should be called + await waitFor(() => { + expect(onCloseMock).toHaveBeenCalled() + }) + }) + }) +}) diff --git a/web/app/components/base/tooltip/index.spec.tsx b/web/app/components/base/tooltip/index.spec.tsx new file mode 100644 index 000000000..1b9b7a0ee --- /dev/null +++ b/web/app/components/base/tooltip/index.spec.tsx @@ -0,0 +1,116 @@ +import React from 'react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import Tooltip from './index' + +afterEach(cleanup) + +describe('Tooltip', () => { + describe('Rendering', () => { + test('should render default tooltip with question icon', () => { + const triggerClassName = 'custom-trigger' + const { container } = render() + const trigger = container.querySelector(`.${triggerClassName}`) + expect(trigger).not.toBeNull() + expect(trigger?.querySelector('svg')).not.toBeNull() // question icon + }) + + test('should render with custom children', () => { + const { getByText } = render( + + + , + ) + expect(getByText('Hover me').textContent).toBe('Hover me') + }) + }) + + describe('Disabled state', () => { + test('should not show tooltip when disabled', () => { + const triggerClassName = 'custom-trigger' + const { container } = render() + const trigger = container.querySelector(`.${triggerClassName}`) + act(() => { + fireEvent.mouseEnter(trigger!) + }) + expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument() + }) + }) + + describe('Trigger methods', () => { + test('should open on hover when triggerMethod is hover', () => { + const triggerClassName = 'custom-trigger' + const { container } = render() + const trigger = container.querySelector(`.${triggerClassName}`) + act(() => { + fireEvent.mouseEnter(trigger!) + }) + expect(screen.queryByText('Tooltip content')).toBeInTheDocument() + }) + + test('should close on mouse leave when triggerMethod is hover', () => { + const triggerClassName = 'custom-trigger' + const { container } = render() + const trigger = container.querySelector(`.${triggerClassName}`) + act(() => { + fireEvent.mouseEnter(trigger!) + fireEvent.mouseLeave(trigger!) + }) + expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument() + }) + + test('should toggle on click when triggerMethod is click', () => { + const triggerClassName = 'custom-trigger' + const { container } = render() + const trigger = container.querySelector(`.${triggerClassName}`) + act(() => { + fireEvent.click(trigger!) + }) + expect(screen.queryByText('Tooltip content')).toBeInTheDocument() + }) + + test('should not close immediately on mouse leave when needsDelay is true', () => { + const triggerClassName = 'custom-trigger' + const { container } = render() + const trigger = container.querySelector(`.${triggerClassName}`) + act(() => { + fireEvent.mouseEnter(trigger!) + fireEvent.mouseLeave(trigger!) + }) + expect(screen.queryByText('Tooltip content')).toBeInTheDocument() + }) + }) + + describe('Styling and positioning', () => { + test('should apply custom trigger className', () => { + const triggerClassName = 'custom-trigger' + const { container } = render() + const trigger = container.querySelector(`.${triggerClassName}`) + expect(trigger?.className).toContain('custom-trigger') + }) + + test('should apply custom popup className', async () => { + const triggerClassName = 'custom-trigger' + const { container } = render() + const trigger = container.querySelector(`.${triggerClassName}`) + act(() => { + fireEvent.mouseEnter(trigger!) + }) + expect((await screen.findByText('Tooltip content'))?.className).toContain('custom-popup') + }) + + test('should apply noDecoration when specified', async () => { + const triggerClassName = 'custom-trigger' + const { container } = render() + const trigger = container.querySelector(`.${triggerClassName}`) + act(() => { + fireEvent.mouseEnter(trigger!) + }) + expect((await screen.findByText('Tooltip content'))?.className).not.toContain('bg-components-panel-bg') + }) + }) +}) diff --git a/web/jest.config.ts b/web/jest.config.ts index 83b3db2f8..aa2f22bf8 100644 --- a/web/jest.config.ts +++ b/web/jest.config.ts @@ -26,7 +26,7 @@ const config: Config = { clearMocks: true, // Indicates whether the coverage information should be collected while executing the test - collectCoverage: false, + collectCoverage: true, // An array of glob patterns indicating a set of files for which coverage information should be collected // collectCoverageFrom: undefined,