From 740d94c6edae2eae5220c2150fa783ded856d103 Mon Sep 17 00:00:00 2001 From: Saumya Talwani <68903741+saumyatalwani@users.noreply.github.com> Date: Tue, 24 Feb 2026 12:05:23 +0530 Subject: [PATCH] test: add tests for some base components (#32356) --- web/app/components/base/alert.spec.tsx | 96 +++++ web/app/components/base/alert.tsx | 12 +- .../components/base/app-unavailable.spec.tsx | 82 ++++ .../base/auto-height-textarea/index.spec.tsx | 201 +++++++++ web/app/components/base/badge.spec.tsx | 86 ++++ .../base/block-input/index.spec.tsx | 226 +++++++++++ web/app/components/base/block-input/index.tsx | 16 +- .../base/button/add-button.spec.tsx | 49 +++ web/app/components/base/button/add-button.tsx | 3 +- .../base/button/sync-button.spec.tsx | 56 +++ .../components/base/button/sync-button.tsx | 5 +- .../components/base/carousel/index.spec.tsx | 218 ++++++++++ .../assets/indeterminate-icon.spec.tsx | 17 + web/app/components/base/drawer/index.tsx | 3 +- .../base/error-boundary/index.spec.tsx | 383 ++++++++++++++++++ .../base/float-right-container/index.spec.tsx | 140 +++++++ .../components/base/pagination/hook.spec.ts | 155 +++++++ .../components/base/pagination/index.spec.tsx | 242 +++++++++++ .../base/pagination/pagination.spec.tsx | 376 +++++++++++++++++ .../components/base/theme-selector.spec.tsx | 103 +++++ web/app/components/base/theme-selector.tsx | 24 +- .../components/base/theme-switcher.spec.tsx | 106 +++++ web/app/components/base/theme-switcher.tsx | 18 +- web/eslint-suppressions.json | 8 - 24 files changed, 2569 insertions(+), 56 deletions(-) create mode 100644 web/app/components/base/alert.spec.tsx create mode 100644 web/app/components/base/app-unavailable.spec.tsx create mode 100644 web/app/components/base/auto-height-textarea/index.spec.tsx create mode 100644 web/app/components/base/badge.spec.tsx create mode 100644 web/app/components/base/block-input/index.spec.tsx create mode 100644 web/app/components/base/button/add-button.spec.tsx create mode 100644 web/app/components/base/button/sync-button.spec.tsx create mode 100644 web/app/components/base/carousel/index.spec.tsx create mode 100644 web/app/components/base/checkbox/assets/indeterminate-icon.spec.tsx create mode 100644 web/app/components/base/error-boundary/index.spec.tsx create mode 100644 web/app/components/base/float-right-container/index.spec.tsx create mode 100644 web/app/components/base/pagination/hook.spec.ts create mode 100644 web/app/components/base/pagination/index.spec.tsx create mode 100644 web/app/components/base/pagination/pagination.spec.tsx create mode 100644 web/app/components/base/theme-selector.spec.tsx create mode 100644 web/app/components/base/theme-switcher.spec.tsx diff --git a/web/app/components/base/alert.spec.tsx b/web/app/components/base/alert.spec.tsx new file mode 100644 index 0000000000..1ad52ea201 --- /dev/null +++ b/web/app/components/base/alert.spec.tsx @@ -0,0 +1,96 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import Alert from './alert' + +describe('Alert', () => { + const defaultProps = { + message: 'This is an alert message', + onHide: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText(defaultProps.message)).toBeInTheDocument() + }) + + it('should render the info icon', () => { + render() + const icon = screen.getByTestId('info-icon') + expect(icon).toBeInTheDocument() + }) + + it('should render the close icon', () => { + render() + const closeIcon = screen.getByTestId('close-icon') + expect(closeIcon).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + const { container } = render() + const outerDiv = container.firstChild as HTMLElement + expect(outerDiv).toHaveClass('my-custom-class') + }) + + it('should retain base classes when custom className is applied', () => { + const { container } = render() + const outerDiv = container.firstChild as HTMLElement + expect(outerDiv).toHaveClass('pointer-events-none', 'w-full') + }) + + it('should default type to info', () => { + render() + const gradientDiv = screen.getByTestId('alert-gradient') + expect(gradientDiv).toHaveClass('from-components-badge-status-light-normal-halo') + }) + + it('should render with explicit type info', () => { + render() + const gradientDiv = screen.getByTestId('alert-gradient') + expect(gradientDiv).toHaveClass('from-components-badge-status-light-normal-halo') + }) + + it('should display the provided message text', () => { + const msg = 'A different alert message' + render() + expect(screen.getByText(msg)).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onHide when close button is clicked', () => { + const onHide = vi.fn() + render() + const closeButton = screen.getByTestId('close-icon') + fireEvent.click(closeButton) + expect(onHide).toHaveBeenCalledTimes(1) + }) + + it('should not call onHide when other parts of the alert are clicked', () => { + const onHide = vi.fn() + render() + fireEvent.click(screen.getByText(defaultProps.message)) + expect(onHide).not.toHaveBeenCalled() + }) + }) + + describe('Edge Cases', () => { + it('should render with an empty message string', () => { + render() + const messageDiv = screen.getByTestId('msg-container') + expect(messageDiv).toBeInTheDocument() + expect(messageDiv).toHaveTextContent('') + }) + + it('should render with a very long message', () => { + const longMessage = 'A'.repeat(1000) + render() + expect(screen.getByText(longMessage)).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/alert.tsx b/web/app/components/base/alert.tsx index cf602b541a..3c1671bb2c 100644 --- a/web/app/components/base/alert.tsx +++ b/web/app/components/base/alert.tsx @@ -1,7 +1,3 @@ -import { - RiCloseLine, - RiInformation2Fill, -} from '@remixicon/react' import { cva } from 'class-variance-authority' import { memo, @@ -35,13 +31,13 @@ const Alert: React.FC = ({
-
+
- +
-
+
{message}
@@ -49,7 +45,7 @@ const Alert: React.FC = ({ className="pointer-events-auto flex h-6 w-6 cursor-pointer items-center justify-center" onClick={onHide} > - +
diff --git a/web/app/components/base/app-unavailable.spec.tsx b/web/app/components/base/app-unavailable.spec.tsx new file mode 100644 index 0000000000..27fb359781 --- /dev/null +++ b/web/app/components/base/app-unavailable.spec.tsx @@ -0,0 +1,82 @@ +import { render, screen } from '@testing-library/react' +import AppUnavailable from './app-unavailable' + +describe('AppUnavailable', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText(/404/)).toBeInTheDocument() + }) + + it('should render the error code in a heading', () => { + render() + const heading = screen.getByRole('heading', { level: 1 }) + expect(heading).toHaveTextContent(/404/) + }) + + it('should render the default unavailable message', () => { + render() + expect(screen.getByText(/unavailable/i)).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should display custom error code', () => { + render() + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('500') + }) + + it('should accept string error code', () => { + render() + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('403') + }) + + it('should apply custom className', () => { + const { container } = render() + const outerDiv = container.firstChild as HTMLElement + expect(outerDiv).toHaveClass('my-custom') + }) + + it('should retain base classes when custom className is applied', () => { + const { container } = render() + const outerDiv = container.firstChild as HTMLElement + expect(outerDiv).toHaveClass('flex', 'h-screen', 'w-screen', 'items-center', 'justify-center') + }) + + it('should display unknownReason when provided', () => { + render() + expect(screen.getByText(/Custom error occurred/i)).toBeInTheDocument() + }) + + it('should display unknown error translation when isUnknownReason is true', () => { + render() + expect(screen.getByText(/share.common.appUnknownError/i)).toBeInTheDocument() + }) + + it('should prioritize unknownReason over isUnknownReason', () => { + render() + expect(screen.getByText(/My custom reason/i)).toBeInTheDocument() + }) + + it('should show appUnavailable translation when isUnknownReason is false', () => { + render() + expect(screen.getByText(/share.common.appUnavailable/i)).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should render with code 0', () => { + render() + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('0') + }) + + it('should render with an empty unknownReason and fall back to translation', () => { + render() + expect(screen.getByText(/share.common.appUnavailable/i)).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/auto-height-textarea/index.spec.tsx b/web/app/components/base/auto-height-textarea/index.spec.tsx new file mode 100644 index 0000000000..2eab1ba82e --- /dev/null +++ b/web/app/components/base/auto-height-textarea/index.spec.tsx @@ -0,0 +1,201 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { sleep } from '@/utils' +import AutoHeightTextarea from './index' + +vi.mock('@/utils', async () => { + const actual = await vi.importActual('@/utils') + return { + ...actual, + sleep: vi.fn(), + } +}) + +describe('AutoHeightTextarea', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + const textarea = document.querySelector('textarea') + expect(textarea).toBeInTheDocument() + }) + + it('should render with placeholder when value is empty', () => { + render() + expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument() + }) + + it('should render with value', () => { + render() + const textarea = screen.getByDisplayValue('Hello World') + expect(textarea).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply custom className to textarea', () => { + render() + const textarea = document.querySelector('textarea') + expect(textarea).toHaveClass('custom-class') + }) + + it('should apply custom wrapperClassName to wrapper div', () => { + render() + const wrapper = document.querySelector('div.relative') + expect(wrapper).toHaveClass('wrapper-class') + }) + + it('should apply minHeight and maxHeight styles to hidden div', () => { + render() + const hiddenDiv = document.querySelector('div.invisible') + expect(hiddenDiv).toHaveStyle({ minHeight: '50px', maxHeight: '200px' }) + }) + + it('should use default minHeight and maxHeight when not provided', () => { + render() + const hiddenDiv = document.querySelector('div.invisible') + expect(hiddenDiv).toHaveStyle({ minHeight: '36px', maxHeight: '96px' }) + }) + + it('should set autoFocus on textarea', () => { + const focusSpy = vi.spyOn(HTMLTextAreaElement.prototype, 'focus') + render() + expect(focusSpy).toHaveBeenCalled() + }) + }) + + describe('User Interactions', () => { + it('should call onChange when textarea value changes', () => { + const handleChange = vi.fn() + render() + const textarea = screen.getByRole('textbox') + + fireEvent.change(textarea, { target: { value: 'new value' } }) + + expect(handleChange).toHaveBeenCalledTimes(1) + }) + + it('should call onKeyDown when key is pressed', () => { + const handleKeyDown = vi.fn() + render() + const textarea = screen.getByRole('textbox') + + fireEvent.keyDown(textarea, { key: 'Enter' }) + + expect(handleKeyDown).toHaveBeenCalledTimes(1) + }) + + it('should call onKeyUp when key is released', () => { + const handleKeyUp = vi.fn() + render() + const textarea = screen.getByRole('textbox') + + fireEvent.keyUp(textarea, { key: 'Enter' }) + + expect(handleKeyUp).toHaveBeenCalledTimes(1) + }) + }) + + describe('Edge Cases', () => { + it('should handle empty string value', () => { + render() + const textarea = screen.getByRole('textbox') + expect(textarea).toHaveValue('') + }) + + it('should handle whitespace-only value', () => { + render() + const textarea = screen.getByRole('textbox') + expect(textarea).toHaveValue(' ') + }) + + it('should handle very long text (>10000 chars)', () => { + const longText = 'a'.repeat(10001) + render() + const textarea = screen.getByDisplayValue(longText) + expect(textarea).toBeInTheDocument() + }) + + it('should handle newlines in value', () => { + const textWithNewlines = 'line1\nline2\nline3' + render() + const textarea = document.querySelector('textarea') + expect(textarea).toHaveValue(textWithNewlines) + }) + + it('should handle special characters in value', () => { + const specialChars = '!@#$%^&*()_+-=[]{}|;:,.<>?' + render() + const textarea = screen.getByDisplayValue(specialChars) + expect(textarea).toBeInTheDocument() + }) + }) + + describe('Ref forwarding', () => { + it('should accept ref and allow focusing', () => { + const ref = { current: null as HTMLTextAreaElement | null } + render(} value="" onChange={vi.fn()} />) + + expect(ref.current).not.toBeNull() + expect(ref.current?.tagName).toBe('TEXTAREA') + }) + }) + + describe('controlFocus prop', () => { + it('should call focus when controlFocus changes', () => { + const focusSpy = vi.spyOn(HTMLTextAreaElement.prototype, 'focus') + const { rerender } = render() + + expect(focusSpy).toHaveBeenCalledTimes(1) + + rerender() + + expect(focusSpy).toHaveBeenCalledTimes(2) + focusSpy.mockRestore() + }) + + it('should retry focus recursively when ref is not ready during autoFocus', async () => { + const delayedRef = {} as React.RefObject + let assignedNode: HTMLTextAreaElement | null = null + let exposedNode: HTMLTextAreaElement | null = null + + Object.defineProperty(delayedRef, 'current', { + get: () => exposedNode, + set: (value: HTMLTextAreaElement | null) => { + assignedNode = value + }, + }) + + const sleepMock = vi.mocked(sleep) + let sleepCalls = 0 + sleepMock.mockImplementation(async () => { + sleepCalls += 1 + if (sleepCalls === 2) + exposedNode = assignedNode + }) + + const focusSpy = vi.spyOn(HTMLTextAreaElement.prototype, 'focus') + const setSelectionRangeSpy = vi.spyOn(HTMLTextAreaElement.prototype, 'setSelectionRange') + + render() + + await waitFor(() => { + expect(sleepMock).toHaveBeenCalledTimes(2) + expect(focusSpy).toHaveBeenCalled() + expect(setSelectionRangeSpy).toHaveBeenCalledTimes(1) + }) + + focusSpy.mockRestore() + setSelectionRangeSpy.mockRestore() + }) + }) + + describe('displayName', () => { + it('should have displayName set', () => { + expect(AutoHeightTextarea.displayName).toBe('AutoHeightTextarea') + }) + }) +}) diff --git a/web/app/components/base/badge.spec.tsx b/web/app/components/base/badge.spec.tsx new file mode 100644 index 0000000000..5ca5cfe789 --- /dev/null +++ b/web/app/components/base/badge.spec.tsx @@ -0,0 +1,86 @@ +import { render, screen } from '@testing-library/react' +import Badge from './badge' + +describe('Badge', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText(/beta/i)).toBeInTheDocument() + }) + + it('should render with children instead of text', () => { + render(child content) + expect(screen.getByText(/child content/i)).toBeInTheDocument() + }) + + it('should render with no text or children', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + expect(container.firstChild).toHaveTextContent('') + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + const { container } = render() + const badge = container.firstChild as HTMLElement + expect(badge).toHaveClass('my-custom') + }) + + it('should retain base classes when custom className is applied', () => { + const { container } = render() + const badge = container.firstChild as HTMLElement + expect(badge).toHaveClass('relative', 'inline-flex', 'h-5', 'items-center') + }) + + it('should apply uppercase class by default', () => { + const { container } = render() + const badge = container.firstChild as HTMLElement + expect(badge).toHaveClass('system-2xs-medium-uppercase') + }) + + it('should apply non-uppercase class when uppercase is false', () => { + const { container } = render() + const badge = container.firstChild as HTMLElement + expect(badge).toHaveClass('system-xs-medium') + expect(badge).not.toHaveClass('system-2xs-medium-uppercase') + }) + + it('should render red corner mark when hasRedCornerMark is true', () => { + const { container } = render() + const mark = container.querySelector('.bg-components-badge-status-light-error-bg') + expect(mark).toBeInTheDocument() + }) + + it('should not render red corner mark by default', () => { + const { container } = render() + const mark = container.querySelector('.bg-components-badge-status-light-error-bg') + expect(mark).not.toBeInTheDocument() + }) + + it('should prioritize children over text', () => { + render(child wins) + expect(screen.getByText(/child wins/i)).toBeInTheDocument() + expect(screen.queryByText(/text content/i)).not.toBeInTheDocument() + }) + + it('should render ReactNode as text prop', () => { + render(bold badge} />) + expect(screen.getByText(/bold badge/i)).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should render with empty string text', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + expect(container.firstChild).toHaveTextContent('') + }) + + it('should render with hasRedCornerMark false explicitly', () => { + const { container } = render() + const mark = container.querySelector('.bg-components-badge-status-light-error-bg') + expect(mark).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/block-input/index.spec.tsx b/web/app/components/base/block-input/index.spec.tsx new file mode 100644 index 0000000000..8d8729287d --- /dev/null +++ b/web/app/components/base/block-input/index.spec.tsx @@ -0,0 +1,226 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' +import Toast from '@/app/components/base/toast' +import BlockInput, { getInputKeys } from './index' + +vi.mock('@/utils/var', () => ({ + checkKeys: vi.fn((_keys: string[]) => ({ + isValid: true, + errorMessageKey: '', + errorKey: '', + })), +})) + +describe('BlockInput', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(Toast, 'notify') + cleanup() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + const wrapper = screen.getByTestId('block-input') + expect(wrapper).toBeInTheDocument() + }) + + it('should render with initial value', () => { + const { container } = render() + expect(container.textContent).toContain('Hello World') + }) + + it('should render variable highlights', () => { + render() + const nameElement = screen.getByText('name') + expect(nameElement).toBeInTheDocument() + expect(nameElement.parentElement).toHaveClass('text-primary-600') + }) + + it('should render multiple variable highlights', () => { + render() + expect(screen.getByText('foo')).toBeInTheDocument() + expect(screen.getByText('bar')).toBeInTheDocument() + }) + + it('should display character count in footer when not readonly', () => { + render() + expect(screen.getByText('5')).toBeInTheDocument() + }) + + it('should hide footer in readonly mode', () => { + render() + expect(screen.queryByText('5')).not.toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + render() + const innerContent = screen.getByTestId('block-input-content') + expect(innerContent).toHaveClass('custom-class') + }) + + it('should apply readonly prop with max height', () => { + render() + const contentDiv = screen.getByTestId('block-input').firstChild as Element + expect(contentDiv).toHaveClass('max-h-[180px]') + }) + + it('should have default empty value', () => { + render() + const contentDiv = screen.getByTestId('block-input') + expect(contentDiv).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should enter edit mode when clicked', async () => { + render() + + const contentArea = screen.getByText('Hello') + fireEvent.click(contentArea) + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + }) + + it('should update value when typing in edit mode', async () => { + const onConfirm = vi.fn() + const { checkKeys } = await import('@/utils/var') + ; (checkKeys as ReturnType).mockReturnValue({ isValid: true, errorMessageKey: '', errorKey: '' }) + + render() + + const contentArea = screen.getByText('Hello') + fireEvent.click(contentArea) + + const textarea = await screen.findByRole('textbox') + fireEvent.change(textarea, { target: { value: 'Hello World' } }) + + expect(textarea).toHaveValue('Hello World') + }) + + it('should call onConfirm on value change with valid keys', async () => { + const onConfirm = vi.fn() + const { checkKeys } = await import('@/utils/var') + ; (checkKeys as ReturnType).mockReturnValue({ isValid: true, errorMessageKey: '', errorKey: '' }) + + render() + + const contentArea = screen.getByText('initial') + fireEvent.click(contentArea) + + const textarea = await screen.findByRole('textbox') + fireEvent.change(textarea, { target: { value: '{{name}}' } }) + + await waitFor(() => { + expect(onConfirm).toHaveBeenCalledWith('{{name}}', ['name']) + }) + }) + + it('should show error toast on value change with invalid keys', async () => { + const onConfirm = vi.fn() + const { checkKeys } = await import('@/utils/var'); + (checkKeys as ReturnType).mockReturnValue({ + isValid: false, + errorMessageKey: 'invalidKey', + errorKey: 'test_key', + }) + + render() + + const contentArea = screen.getByText('initial') + fireEvent.click(contentArea) + + const textarea = await screen.findByRole('textbox') + fireEvent.change(textarea, { target: { value: '{{invalid}}' } }) + + await waitFor(() => { + expect(Toast.notify).toHaveBeenCalled() + }) + expect(onConfirm).not.toHaveBeenCalled() + }) + + it('should not enter edit mode when readonly is true', () => { + render() + + const contentArea = screen.getByText('Hello') + fireEvent.click(contentArea) + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should handle empty string value', () => { + const { container } = render() + expect(container.textContent).toBe('0') + const span = screen.getByTestId('block-input').querySelector('span') + expect(span).toBeInTheDocument() + expect(span).toBeEmptyDOMElement() + }) + + it('should handle value without variables', () => { + render() + expect(screen.getByText('plain text')).toBeInTheDocument() + }) + + it('should handle newlines in value', () => { + render() + expect(screen.getByText(/line1/)).toBeInTheDocument() + }) + + it('should handle multiple same variables', () => { + render() + const highlights = screen.getAllByText('name') + expect(highlights).toHaveLength(2) + }) + + it('should handle value with only variables', () => { + render() + expect(screen.getByText('foo')).toBeInTheDocument() + expect(screen.getByText('bar')).toBeInTheDocument() + expect(screen.getByText('baz')).toBeInTheDocument() + }) + + it('should handle text adjacent to variables', () => { + render() + expect(screen.getByText(/prefix/)).toBeInTheDocument() + expect(screen.getByText(/suffix/)).toBeInTheDocument() + }) + }) +}) + +describe('getInputKeys', () => { + it('should extract keys from {{}} syntax', () => { + const keys = getInputKeys('Hello {{name}}') + expect(keys).toEqual(['name']) + }) + + it('should extract multiple keys', () => { + const keys = getInputKeys('{{foo}} and {{bar}}') + expect(keys).toEqual(['foo', 'bar']) + }) + + it('should remove duplicate keys', () => { + const keys = getInputKeys('{{name}} and {{name}}') + expect(keys).toEqual(['name']) + }) + + it('should return empty array for no variables', () => { + const keys = getInputKeys('plain text') + expect(keys).toEqual([]) + }) + + it('should return empty array for empty string', () => { + const keys = getInputKeys('') + expect(keys).toEqual([]) + }) + + it('should handle keys with underscores and numbers', () => { + const keys = getInputKeys('{{user_1}} and {{user_2}}') + expect(keys).toEqual(['user_1', 'user_2']) + }) +}) diff --git a/web/app/components/base/block-input/index.tsx b/web/app/components/base/block-input/index.tsx index d9057eb737..05bb95e10b 100644 --- a/web/app/components/base/block-input/index.tsx +++ b/web/app/components/base/block-input/index.tsx @@ -63,7 +63,7 @@ const BlockInput: FC = ({ }, [isEditing]) const style = cn({ - 'block px-4 py-2 w-full h-full text-sm text-gray-900 outline-0 border-0 break-all': true, + 'block h-full w-full break-all border-0 px-4 py-2 text-sm text-gray-900 outline-0': true, 'block-input--editing': isEditing, }) @@ -111,7 +111,7 @@ const BlockInput: FC = ({ // Prevent rerendering caused cursor to jump to the start of the contentEditable element const TextAreaContentView = () => { return ( -
+
{renderSafeContent(currentValue || '')}
) @@ -121,7 +121,7 @@ const BlockInput: FC = ({ const editAreaClassName = 'focus:outline-none bg-transparent text-sm' const textAreaContent = ( -
!readonly && setIsEditing(true)}> +
!readonly && setIsEditing(true)}> {isEditing ? (
@@ -134,10 +134,10 @@ const BlockInput: FC = ({ onBlur={() => { blur() setIsEditing(false) - // click confirm also make blur. Then outer value is change. So below code has problem. - // setTimeout(() => { - // handleCancel() - // }, 1000) + // click confirm also make blur. Then outer value is change. So below code has problem. + // setTimeout(() => { + // handleCancel() + // }, 1000) }} />
@@ -147,7 +147,7 @@ const BlockInput: FC = ({ ) return ( -
+
{textAreaContent} {/* footer */} {!readonly && ( diff --git a/web/app/components/base/button/add-button.spec.tsx b/web/app/components/base/button/add-button.spec.tsx new file mode 100644 index 0000000000..658c032bb7 --- /dev/null +++ b/web/app/components/base/button/add-button.spec.tsx @@ -0,0 +1,49 @@ +import { fireEvent, render } from '@testing-library/react' +import AddButton from './add-button' + +describe('AddButton', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render an add icon', () => { + const { container } = render() + const svg = container.querySelector('span') + expect(svg).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('my-custom') + }) + + it('should retain base classes when custom className is applied', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('cursor-pointer') + expect(container.firstChild).toHaveClass('rounded-md') + expect(container.firstChild).toHaveClass('select-none') + }) + }) + + describe('User Interactions', () => { + it('should call onClick when clicked', () => { + const onClick = vi.fn() + const { container } = render() + fireEvent.click(container.firstChild!) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('should call onClick multiple times on repeated clicks', () => { + const onClick = vi.fn() + const { container } = render() + fireEvent.click(container.firstChild!) + fireEvent.click(container.firstChild!) + fireEvent.click(container.firstChild!) + expect(onClick).toHaveBeenCalledTimes(3) + }) + }) +}) diff --git a/web/app/components/base/button/add-button.tsx b/web/app/components/base/button/add-button.tsx index 332b52daca..50a39ffe7c 100644 --- a/web/app/components/base/button/add-button.tsx +++ b/web/app/components/base/button/add-button.tsx @@ -1,6 +1,5 @@ 'use client' import type { FC } from 'react' -import { RiAddLine } from '@remixicon/react' import * as React from 'react' import { cn } from '@/utils/classnames' @@ -15,7 +14,7 @@ const AddButton: FC = ({ }) => { return (
- +
) } diff --git a/web/app/components/base/button/sync-button.spec.tsx b/web/app/components/base/button/sync-button.spec.tsx new file mode 100644 index 0000000000..eeaf60d46e --- /dev/null +++ b/web/app/components/base/button/sync-button.spec.tsx @@ -0,0 +1,56 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import SyncButton from './sync-button' + +vi.mock('ahooks', () => ({ + useBoolean: () => [false, { setTrue: vi.fn(), setFalse: vi.fn() }], +})) + +describe('SyncButton', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render a refresh icon', () => { + const { container } = render() + const svg = container.querySelector('span') + expect(svg).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + render() + const clickableDiv = screen.getByTestId('sync-button') + expect(clickableDiv).toHaveClass('my-custom') + }) + + it('should retain base classes when custom className is applied', () => { + render() + const clickableDiv = screen.getByTestId('sync-button') + expect(clickableDiv).toHaveClass('rounded-md') + expect(clickableDiv).toHaveClass('select-none') + }) + }) + + describe('User Interactions', () => { + it('should call onClick when clicked', () => { + const onClick = vi.fn() + render() + const clickableDiv = screen.getByTestId('sync-button')! + fireEvent.click(clickableDiv) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('should call onClick multiple times on repeated clicks', () => { + const onClick = vi.fn() + render() + const clickableDiv = screen.getByTestId('sync-button')! + fireEvent.click(clickableDiv) + fireEvent.click(clickableDiv) + fireEvent.click(clickableDiv) + expect(onClick).toHaveBeenCalledTimes(3) + }) + }) +}) diff --git a/web/app/components/base/button/sync-button.tsx b/web/app/components/base/button/sync-button.tsx index 12c34026cb..06c155fb1d 100644 --- a/web/app/components/base/button/sync-button.tsx +++ b/web/app/components/base/button/sync-button.tsx @@ -1,6 +1,5 @@ 'use client' import type { FC } from 'react' -import { RiRefreshLine } from '@remixicon/react' import * as React from 'react' import TooltipPlus from '@/app/components/base/tooltip' import { cn } from '@/utils/classnames' @@ -18,8 +17,8 @@ const SyncButton: FC = ({ }) => { return ( -
- +
+
) diff --git a/web/app/components/base/carousel/index.spec.tsx b/web/app/components/base/carousel/index.spec.tsx new file mode 100644 index 0000000000..06434a51aa --- /dev/null +++ b/web/app/components/base/carousel/index.spec.tsx @@ -0,0 +1,218 @@ +import type { Mock } from 'vitest' +import { act, fireEvent, render, screen } from '@testing-library/react' +import useEmblaCarousel from 'embla-carousel-react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Carousel, useCarousel } from './index' + +vi.mock('embla-carousel-react', () => ({ + default: vi.fn(), +})) + +type EmblaEventName = 'reInit' | 'select' +type EmblaListener = (api: MockEmblaApi | undefined) => void + +type MockEmblaApi = { + scrollPrev: Mock + scrollNext: Mock + scrollTo: Mock + selectedScrollSnap: Mock + canScrollPrev: Mock + canScrollNext: Mock + slideNodes: Mock + on: Mock + off: Mock +} + +let mockCanScrollPrev = false +let mockCanScrollNext = false +let mockSelectedIndex = 0 +let mockSlideCount = 3 +let listeners: Record +let mockApi: MockEmblaApi +const mockCarouselRef = vi.fn() + +const mockedUseEmblaCarousel = vi.mocked(useEmblaCarousel) + +const createMockEmblaApi = (): MockEmblaApi => ({ + scrollPrev: vi.fn(), + scrollNext: vi.fn(), + scrollTo: vi.fn(), + selectedScrollSnap: vi.fn(() => mockSelectedIndex), + canScrollPrev: vi.fn(() => mockCanScrollPrev), + canScrollNext: vi.fn(() => mockCanScrollNext), + slideNodes: vi.fn(() => + Array.from({ length: mockSlideCount }, () => document.createElement('div')), + ), + on: vi.fn((event: EmblaEventName, callback: EmblaListener) => { + listeners[event].push(callback) + }), + off: vi.fn((event: EmblaEventName, callback: EmblaListener) => { + listeners[event] = listeners[event].filter(listener => listener !== callback) + }), +}) + +const emitEmblaEvent = (event: EmblaEventName, api: MockEmblaApi | undefined = mockApi) => { + listeners[event].forEach(callback => callback(api)) +} + +const renderCarouselWithControls = (orientation: 'horizontal' | 'vertical' = 'horizontal') => { + return render( + + + Slide 1 + + Prev + Next + Dot + , + ) +} + +describe('Carousel', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCanScrollPrev = false + mockCanScrollNext = false + mockSelectedIndex = 0 + mockSlideCount = 3 + listeners = { reInit: [], select: [] } + mockApi = createMockEmblaApi() + + mockedUseEmblaCarousel.mockReturnValue( + [mockCarouselRef, mockApi] as unknown as ReturnType, + ) + }) + + // Rendering and basic semantic structure. + describe('Rendering', () => { + it('should render region and slides when used with content and items', () => { + renderCarouselWithControls() + + expect(screen.getByRole('region')).toHaveAttribute('aria-roledescription', 'carousel') + expect(screen.getByTestId('carousel-content')).toHaveClass('flex') + expect(screen.getByRole('group')).toHaveAttribute('aria-roledescription', 'slide') + }) + }) + + // Props should be translated into Embla options and visible layout. + describe('Props', () => { + it('should configure embla with horizontal axis when orientation is omitted', () => { + render( + + + , + ) + + expect(mockedUseEmblaCarousel).toHaveBeenCalledWith( + { loop: true, axis: 'x' }, + ['plugin-marker'], + ) + }) + + it('should configure embla with vertical axis and vertical content classes when orientation is vertical', () => { + renderCarouselWithControls('vertical') + + expect(mockedUseEmblaCarousel).toHaveBeenCalledWith( + { axis: 'y' }, + undefined, + ) + expect(screen.getByTestId('carousel-content')).toHaveClass('flex-col') + }) + }) + + // Users can move slides through previous and next controls. + describe('User interactions', () => { + it('should call scroll handlers when previous and next buttons are clicked', () => { + mockCanScrollPrev = true + mockCanScrollNext = true + + renderCarouselWithControls() + + fireEvent.click(screen.getByRole('button', { name: 'Prev' })) + fireEvent.click(screen.getByRole('button', { name: 'Next' })) + + expect(mockApi.scrollPrev).toHaveBeenCalledTimes(1) + expect(mockApi.scrollNext).toHaveBeenCalledTimes(1) + }) + + it('should call scrollTo with clicked index when a dot is clicked', () => { + renderCarouselWithControls() + const dots = screen.getAllByRole('button', { name: 'Dot' }) + + fireEvent.click(dots[2]) + + expect(mockApi.scrollTo).toHaveBeenCalledWith(2) + }) + }) + + // Embla events should keep control states and selected index in sync. + describe('State synchronization', () => { + it('should update disabled states and active dot when select event is emitted', () => { + renderCarouselWithControls() + + mockCanScrollPrev = true + mockCanScrollNext = true + mockSelectedIndex = 2 + + act(() => { + emitEmblaEvent('select') + }) + + const dots = screen.getAllByRole('button', { name: 'Dot' }) + expect(screen.getByRole('button', { name: 'Prev' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled() + expect(dots[2]).toHaveAttribute('data-state', 'active') + }) + + it('should subscribe to embla events and unsubscribe from select on unmount', () => { + const { unmount } = renderCarouselWithControls() + + const selectCallback = mockApi.on.mock.calls.find( + call => call[0] === 'select', + )?.[1] as EmblaListener + + expect(mockApi.on).toHaveBeenCalledWith('reInit', expect.any(Function)) + expect(mockApi.on).toHaveBeenCalledWith('select', expect.any(Function)) + + unmount() + + expect(mockApi.off).toHaveBeenCalledWith('select', selectCallback) + }) + }) + + // Edge-case behavior for missing providers or missing embla api values. + describe('Edge cases', () => { + it('should throw when useCarousel is used outside Carousel provider', () => { + const InvalidConsumer = () => { + useCarousel() + return null + } + + expect(() => render()).toThrowError( + 'useCarousel must be used within a ', + ) + }) + + it('should render with disabled controls and no dots when embla api is undefined', () => { + mockedUseEmblaCarousel.mockReturnValue( + [mockCarouselRef, undefined] as unknown as ReturnType, + ) + + renderCarouselWithControls() + + expect(screen.getByRole('button', { name: 'Prev' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled() + expect(screen.queryByRole('button', { name: 'Dot' })).not.toBeInTheDocument() + }) + + it('should ignore select callback when embla emits an undefined api', () => { + renderCarouselWithControls() + + expect(() => { + act(() => { + emitEmblaEvent('select', undefined) + }) + }).not.toThrow() + }) + }) +}) diff --git a/web/app/components/base/checkbox/assets/indeterminate-icon.spec.tsx b/web/app/components/base/checkbox/assets/indeterminate-icon.spec.tsx new file mode 100644 index 0000000000..3f39dd836f --- /dev/null +++ b/web/app/components/base/checkbox/assets/indeterminate-icon.spec.tsx @@ -0,0 +1,17 @@ +import { render, screen } from '@testing-library/react' +import IndeterminateIcon from './indeterminate-icon' + +describe('IndeterminateIcon', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByTestId('indeterminate-icon')).toBeInTheDocument() + }) + + it('should render an svg element', () => { + const { container } = render() + const svg = container.querySelector('svg') + expect(svg).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/drawer/index.tsx b/web/app/components/base/drawer/index.tsx index 2f44ce75af..a145f9a64d 100644 --- a/web/app/components/base/drawer/index.tsx +++ b/web/app/components/base/drawer/index.tsx @@ -1,6 +1,5 @@ 'use client' import { Dialog, DialogBackdrop, DialogTitle } from '@headlessui/react' -import { XMarkIcon } from '@heroicons/react/24/outline' import { useTranslation } from 'react-i18next' import { cn } from '@/utils/classnames' import Button from '../button' @@ -81,7 +80,7 @@ export default function Drawer({ )} {showClose && ( - + )}
diff --git a/web/app/components/base/error-boundary/index.spec.tsx b/web/app/components/base/error-boundary/index.spec.tsx new file mode 100644 index 0000000000..1caca84d79 --- /dev/null +++ b/web/app/components/base/error-boundary/index.spec.tsx @@ -0,0 +1,383 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import ErrorBoundary, { ErrorFallback, useAsyncError, useErrorHandler, withErrorBoundary } from './index' + +const mockConfig = vi.hoisted(() => ({ + isDev: false, +})) + +vi.mock('@/config', () => ({ + get IS_DEV() { + return mockConfig.isDev + }, +})) + +type ThrowOnRenderProps = { + message?: string + shouldThrow: boolean +} + +const ThrowOnRender = ({ shouldThrow, message = 'render boom' }: ThrowOnRenderProps) => { + if (shouldThrow) + throw new Error(message) + + return
Child content rendered
+} + +let consoleErrorSpy: ReturnType + +describe('ErrorBoundary', () => { + beforeEach(() => { + vi.clearAllMocks() + mockConfig.isDev = false + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + }) + + afterEach(() => { + consoleErrorSpy.mockRestore() + }) + + // Verify default render and default fallback behavior. + describe('Rendering', () => { + it('should render children when no error occurs', () => { + render( + + + , + ) + + expect(screen.getByText('Child content rendered')).toBeInTheDocument() + }) + + it('should render default fallback with title and message when child throws', async () => { + render( + + + , + ) + + expect(await screen.findByText('Something went wrong')).toBeInTheDocument() + expect(screen.getByText('An unexpected error occurred while rendering this component.')).toBeInTheDocument() + }) + + it('should render custom title, message, and className in fallback', async () => { + render( + + + , + ) + + expect(await screen.findByText('Custom crash title')).toBeInTheDocument() + expect(screen.getByText('Custom recovery message')).toBeInTheDocument() + + const fallbackRoot = document.querySelector('.custom-boundary') + expect(fallbackRoot).toBeInTheDocument() + expect(fallbackRoot).not.toHaveClass('min-h-[200px]') + }) + }) + + // Validate explicit fallback prop variants. + describe('Fallback props', () => { + it('should render node fallback when fallback prop is a React node', async () => { + render( + Node fallback content
}> + + , + ) + + expect(await screen.findByText('Node fallback content')).toBeInTheDocument() + }) + + it('should render function fallback with error message when fallback prop is a function', async () => { + render( + ( +
+ Function fallback: + {' '} + {error.message} +
+ )} + > + +
, + ) + + expect(await screen.findByText('Function fallback: function fallback boom')).toBeInTheDocument() + }) + }) + + // Validate error reporting and details panel behavior. + describe('Error reporting', () => { + it('should call onError with error and errorInfo when child throws', async () => { + const onError = vi.fn() + + render( + + + , + ) + + await screen.findByText('Something went wrong') + + expect(onError).toHaveBeenCalledTimes(1) + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'render boom' }), + expect.objectContaining({ componentStack: expect.any(String) }), + ) + }) + + it('should render details block when showDetails is true', async () => { + render( + + + , + ) + + expect(await screen.findByText('Error Details (Development Only)')).toBeInTheDocument() + expect(screen.getByText('Error:')).toBeInTheDocument() + expect(screen.getByText(/details boom/i)).toBeInTheDocument() + }) + + it('should log boundary errors in development mode', async () => { + mockConfig.isDev = true + + render( + + + , + ) + + await screen.findByText('Something went wrong') + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'ErrorBoundary caught an error:', + expect.objectContaining({ message: 'dev boom' }), + ) + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error Info:', + expect.objectContaining({ componentStack: expect.any(String) }), + ) + }) + }) + + // Validate recovery controls and automatic reset triggers. + describe('Recovery', () => { + it('should hide recovery actions when enableRecovery is false', async () => { + render( + + + , + ) + + await screen.findByText('Something went wrong') + + expect(screen.queryByRole('button', { name: 'Try Again' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Reload Page' })).not.toBeInTheDocument() + }) + + it('should reset and render children when Try Again is clicked', async () => { + const onReset = vi.fn() + + const RecoveryHarness = () => { + const [shouldThrow, setShouldThrow] = React.useState(true) + return ( + { + onReset() + setShouldThrow(false) + }} + > + + + ) + } + + render() + fireEvent.click(await screen.findByRole('button', { name: 'Try Again' })) + + await screen.findByText('Child content rendered') + expect(onReset).toHaveBeenCalledTimes(1) + }) + + it('should reset after resetKeys change when boundary is in error state', async () => { + const ResetKeysHarness = () => { + const [shouldThrow, setShouldThrow] = React.useState(true) + const [boundaryKey, setBoundaryKey] = React.useState(0) + + return ( + <> + + + + + + ) + } + + render() + await screen.findByText('Something went wrong') + + fireEvent.click(screen.getByRole('button', { name: 'Recover with keys' })) + + await waitFor(() => { + expect(screen.getByText('Child content rendered')).toBeInTheDocument() + }) + }) + + it('should reset after children change when resetOnPropsChange is true', async () => { + const ResetOnPropsHarness = () => { + const [shouldThrow, setShouldThrow] = React.useState(true) + const [childLabel, setChildLabel] = React.useState('first child') + + return ( + <> + + + {shouldThrow ? :
{childLabel}
} +
+ + ) + } + + render() + await screen.findByText('Something went wrong') + + fireEvent.click(screen.getByRole('button', { name: 'Replace children' })) + + await waitFor(() => { + expect(screen.getByText('second child')).toBeInTheDocument() + }) + }) + }) +}) + +describe('ErrorBoundary utility exports', () => { + beforeEach(() => { + vi.clearAllMocks() + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + }) + + afterEach(() => { + consoleErrorSpy.mockRestore() + }) + + // Validate imperative error hook behavior. + describe('useErrorHandler', () => { + it('should trigger error boundary fallback when setError is called', async () => { + const HookConsumer = () => { + const setError = useErrorHandler() + return ( + + ) + } + + render( + Hook fallback shown
}> + + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Trigger hook error' })) + + expect(await screen.findByText('Hook fallback shown')).toBeInTheDocument() + }) + }) + + // Validate async error bridge hook behavior. + describe('useAsyncError', () => { + it('should trigger error boundary fallback when async error callback is called', async () => { + const AsyncHookConsumer = () => { + const throwAsyncError = useAsyncError() + return ( + + ) + } + + render( + Async fallback shown
}> + + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Trigger async hook error' })) + + expect(await screen.findByText('Async fallback shown')).toBeInTheDocument() + }) + }) + + // Validate HOC wrapper behavior and metadata. + describe('withErrorBoundary', () => { + it('should wrap component and render custom title when wrapped component throws', async () => { + type WrappedProps = { + shouldThrow: boolean + } + + const WrappedTarget = ({ shouldThrow }: WrappedProps) => { + if (shouldThrow) + throw new Error('wrapped boom') + return
Wrapped content
+ } + + const Wrapped = withErrorBoundary(WrappedTarget, { + customTitle: 'Wrapped boundary title', + }) + + render() + + expect(await screen.findByText('Wrapped boundary title')).toBeInTheDocument() + }) + + it('should set displayName using wrapped component name', () => { + const NamedComponent = () =>
named content
+ const Wrapped = withErrorBoundary(NamedComponent) + + expect(Wrapped.displayName).toBe('withErrorBoundary(NamedComponent)') + }) + }) + + // Validate simple fallback helper component. + describe('ErrorFallback', () => { + it('should render message and call reset action when button is clicked', () => { + const resetErrorBoundaryAction = vi.fn() + + render( + , + ) + + expect(screen.getByText('Oops! Something went wrong')).toBeInTheDocument() + expect(screen.getByText('fallback helper message')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + + expect(resetErrorBoundaryAction).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/web/app/components/base/float-right-container/index.spec.tsx b/web/app/components/base/float-right-container/index.spec.tsx new file mode 100644 index 0000000000..51713cc527 --- /dev/null +++ b/web/app/components/base/float-right-container/index.spec.tsx @@ -0,0 +1,140 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import FloatRightContainer from './index' + +describe('FloatRightContainer', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering behavior across mobile and desktop branches. + describe('Rendering', () => { + it('should render content in drawer when isMobile is true and isOpen is true', async () => { + render( + +
Mobile content
+
, + ) + + expect(await screen.findByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('Mobile panel')).toBeInTheDocument() + expect(screen.getByText('Mobile content')).toBeInTheDocument() + }) + + it('should not render content when isMobile is true and isOpen is false', () => { + render( + +
Closed mobile content
+
, + ) + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.queryByText('Closed mobile content')).not.toBeInTheDocument() + }) + + it('should render content inline when isMobile is false and isOpen is true', () => { + render( + +
Desktop inline content
+
, + ) + + expect(screen.getByText('Desktop inline content')).toBeInTheDocument() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.queryByText('Desktop drawer title should not render')).not.toBeInTheDocument() + }) + + it('should render nothing when isMobile is false and isOpen is false', () => { + const { container } = render( + +
Hidden desktop content
+
, + ) + + expect(container).toBeEmptyDOMElement() + expect(screen.queryByText('Hidden desktop content')).not.toBeInTheDocument() + }) + }) + + // Validate that drawer-specific props are passed through in mobile mode. + describe('Props forwarding', () => { + it('should call onClose when close icon is clicked in mobile drawer mode', async () => { + const onClose = vi.fn() + render( + +
Closable mobile content
+
, + ) + + await screen.findByRole('dialog') + const closeIcon = screen.getByTestId('close-icon') + expect(closeIcon).toBeInTheDocument() + + fireEvent.click(closeIcon!) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should apply drawer className props in mobile drawer mode', async () => { + render( + +
Class forwarding content
+
, + ) + + const dialog = await screen.findByRole('dialog') + expect(dialog).toHaveClass('custom-dialog-class') + + const panel = document.querySelector('.custom-panel-class') + expect(panel).toBeInTheDocument() + }) + }) + + // Edge-case behavior with optional children. + describe('Edge cases', () => { + it('should render without crashing when children is undefined in mobile mode', async () => { + render( + , + ) + + expect(await screen.findByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('Empty mobile panel')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/pagination/hook.spec.ts b/web/app/components/base/pagination/hook.spec.ts new file mode 100644 index 0000000000..284032df47 --- /dev/null +++ b/web/app/components/base/pagination/hook.spec.ts @@ -0,0 +1,155 @@ +import { renderHook } from '@testing-library/react' +import usePagination from './hook' + +const defaultProps = { + currentPage: 0, + setCurrentPage: vi.fn(), + totalPages: 10, + edgePageCount: 2, + middlePagesSiblingCount: 1, + truncableText: '...', + truncableClassName: 'truncable', +} + +describe('usePagination', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('pages', () => { + it('should generate correct pages array', () => { + const { result } = renderHook(() => usePagination(defaultProps)) + expect(result.current.pages).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) + }) + + it('should generate empty pages for totalPages 0', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, totalPages: 0 })) + expect(result.current.pages).toEqual([]) + }) + + it('should generate single page', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, totalPages: 1 })) + expect(result.current.pages).toEqual([1]) + }) + }) + + describe('hasPreviousPage / hasNextPage', () => { + it('should have no previous page on first page', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 1 })) + expect(result.current.hasPreviousPage).toBe(false) + }) + + it('should have previous page when not on first page', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 3 })) + expect(result.current.hasPreviousPage).toBe(true) + }) + + it('should have next page when not on last page', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 1 })) + expect(result.current.hasNextPage).toBe(true) + }) + + it('should have no next page on last page', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 10 })) + expect(result.current.hasNextPage).toBe(false) + }) + }) + + describe('middlePages', () => { + it('should return correct middle pages when at start', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 0 })) + // isReachedToFirst: currentPage(0) <= middlePagesSiblingCount(1), so slice(0, 3) + expect(result.current.middlePages).toEqual([1, 2, 3]) + }) + + it('should return correct middle pages when in the middle', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 })) + // Not at start or end, slice(5-1, 5+1+1) = slice(4, 7) = [5, 6, 7] + expect(result.current.middlePages).toEqual([5, 6, 7]) + }) + + it('should return correct middle pages when at end', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 9 })) + // isReachedToLast: currentPage(9) + middlePagesSiblingCount(1) >= totalPages(10), so slice(-3) + expect(result.current.middlePages).toEqual([8, 9, 10]) + }) + }) + + describe('previousPages and nextPages', () => { + it('should return empty previousPages when at start', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 0 })) + expect(result.current.previousPages).toEqual([]) + }) + + it('should return previousPages when in the middle', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 })) + // edgePageCount=2, so first 2 pages filtered by not in middlePages + expect(result.current.previousPages).toEqual([1, 2]) + }) + + it('should return empty nextPages when at end', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 9 })) + expect(result.current.nextPages).toEqual([]) + }) + + it('should return nextPages when in the middle', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 })) + // Last 2 pages: [9, 10], filtered by not in middlePages [5,6,7] + expect(result.current.nextPages).toEqual([9, 10]) + }) + }) + + describe('truncation', () => { + it('should be previous truncable when middle pages are far from edge', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 })) + // previousPages=[1,2], middlePages=[5,6,7], 5 > 2+1 = true + expect(result.current.isPreviousTruncable).toBe(true) + }) + + it('should not be previous truncable when pages are contiguous', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 2 })) + expect(result.current.isPreviousTruncable).toBe(false) + }) + + it('should be next truncable when middle pages are far from end edge', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 })) + // middlePages=[5,6,7], nextPages=[9,10], 7+1 < 9 = true + expect(result.current.isNextTruncable).toBe(true) + }) + + it('should not be next truncable when pages are contiguous', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 7 })) + expect(result.current.isNextTruncable).toBe(false) + }) + }) + + describe('passthrough values', () => { + it('should pass through currentPage', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 })) + expect(result.current.currentPage).toBe(5) + }) + + it('should pass through setCurrentPage', () => { + const setCurrentPage = vi.fn() + const { result } = renderHook(() => usePagination({ ...defaultProps, setCurrentPage })) + result.current.setCurrentPage(3) + expect(setCurrentPage).toHaveBeenCalledWith(3) + }) + + it('should pass through truncableText', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, truncableText: '…' })) + expect(result.current.truncableText).toBe('…') + }) + + it('should pass through truncableClassName', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, truncableClassName: 'custom-trunc' })) + expect(result.current.truncableClassName).toBe('custom-trunc') + }) + + it('should use default truncableText', () => { + const { currentPage, setCurrentPage, totalPages, edgePageCount, middlePagesSiblingCount } = defaultProps + const { result } = renderHook(() => usePagination({ currentPage, setCurrentPage, totalPages, edgePageCount, middlePagesSiblingCount })) + expect(result.current.truncableText).toBe('...') + }) + }) +}) diff --git a/web/app/components/base/pagination/index.spec.tsx b/web/app/components/base/pagination/index.spec.tsx new file mode 100644 index 0000000000..ef924c290b --- /dev/null +++ b/web/app/components/base/pagination/index.spec.tsx @@ -0,0 +1,242 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import CustomizedPagination from './index' + +describe('CustomizedPagination', () => { + const defaultProps = { + current: 0, + onChange: vi.fn(), + total: 100, + } + + beforeEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = render() + expect(container).toBeInTheDocument() + }) + + it('should display current page and total pages', () => { + render() + // current + 1 = 1, totalPages = 10 + // The page info display shows "1 / 10" and page buttons also show numbers + expect(screen.getByText('/')).toBeInTheDocument() + expect(screen.getAllByText('1').length).toBeGreaterThanOrEqual(1) + }) + + it('should render prev and next buttons', () => { + render() + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThanOrEqual(2) + }) + + it('should render page number buttons', () => { + render() + // 5 pages total, should see page numbers + expect(screen.getByText('2')).toBeInTheDocument() + expect(screen.getByText('3')).toBeInTheDocument() + }) + + it('should display slash separator between current page and total', () => { + render() + expect(screen.getByText('/')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('my-custom') + }) + + it('should default limit to 10', () => { + render() + // totalPages = 100 / 10 = 10, displayed in the page info area + expect(screen.getAllByText('10').length).toBeGreaterThanOrEqual(1) + }) + + it('should calculate total pages based on custom limit', () => { + render() + // totalPages = 100 / 25 = 4, displayed in the page info area + expect(screen.getAllByText('4').length).toBeGreaterThanOrEqual(1) + }) + + it('should disable prev button on first page', () => { + render() + const buttons = screen.getAllByRole('button') + // First button is prev + expect(buttons[0]).toBeDisabled() + }) + + it('should disable next button on last page', () => { + render() + const buttons = screen.getAllByRole('button') + // Last button is next + expect(buttons[buttons.length - 1]).toBeDisabled() + }) + + it('should not render limit selector when onLimitChange is not provided', () => { + render() + expect(screen.queryByText(/common\.pagination\.perPage/i)).not.toBeInTheDocument() + }) + + it('should render limit selector when onLimitChange is provided', () => { + const onLimitChange = vi.fn() + render() + // Should show limit options 10, 25, 50 + expect(screen.getByText('25')).toBeInTheDocument() + expect(screen.getByText('50')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onChange when next button is clicked', () => { + const onChange = vi.fn() + render() + const buttons = screen.getAllByRole('button') + const nextButton = buttons[buttons.length - 1] + fireEvent.click(nextButton) + expect(onChange).toHaveBeenCalledWith(1) + }) + + it('should call onChange when prev button is clicked', () => { + const onChange = vi.fn() + render() + const buttons = screen.getAllByRole('button') + fireEvent.click(buttons[0]) + expect(onChange).toHaveBeenCalledWith(4) + }) + + it('should show input when page display is clicked', () => { + render() + // Click the current page display (the div containing "1 / 10") + fireEvent.click(screen.getByText('/')) + // Input should appear + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + it('should navigate to entered page on Enter key', () => { + vi.useFakeTimers() + const onChange = vi.fn() + render() + fireEvent.click(screen.getByText('/')) + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: '5' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + act(() => { + vi.advanceTimersByTime(500) + }) + expect(onChange).toHaveBeenCalledWith(4) // 0-indexed + }) + + it('should cancel input on Escape key', () => { + render() + fireEvent.click(screen.getByText('/')) + const input = screen.getByRole('textbox') + fireEvent.keyDown(input, { key: 'Escape' }) + // Input should be hidden and page display should return + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + expect(screen.getByText('/')).toBeInTheDocument() + }) + + it('should confirm input on blur', () => { + vi.useFakeTimers() + const onChange = vi.fn() + render() + fireEvent.click(screen.getByText('/')) + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: '3' } }) + fireEvent.blur(input) + act(() => { + vi.advanceTimersByTime(500) + }) + expect(onChange).toHaveBeenCalledWith(2) // 0-indexed + }) + + it('should clamp page to max when input exceeds total pages', () => { + vi.useFakeTimers() + const onChange = vi.fn() + render() + fireEvent.click(screen.getByText('/')) + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: '999' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + act(() => { + vi.advanceTimersByTime(500) + }) + expect(onChange).toHaveBeenCalledWith(9) // last page (0-indexed) + }) + + it('should clamp page to min when input is less than 1', () => { + vi.useFakeTimers() + const onChange = vi.fn() + render() + fireEvent.click(screen.getByText('/')) + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: '0' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + act(() => { + vi.advanceTimersByTime(500) + }) + expect(onChange).toHaveBeenCalledWith(0) + }) + + it('should ignore non-numeric input', () => { + render() + fireEvent.click(screen.getByText('/')) + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'abc' } }) + expect(input).toHaveValue('') + }) + + it('should call onLimitChange when limit option is clicked', () => { + const onLimitChange = vi.fn() + render() + fireEvent.click(screen.getByText('25')) + expect(onLimitChange).toHaveBeenCalledWith(25) + }) + + it('should call onLimitChange with 50 when 50 option is clicked', () => { + const onLimitChange = vi.fn() + render() + fireEvent.click(screen.getByText('50')) + expect(onLimitChange).toHaveBeenCalledWith(50) + }) + + it('should call onChange when a page button is clicked', () => { + const onChange = vi.fn() + render() + fireEvent.click(screen.getByText('3')) + expect(onChange).toHaveBeenCalledWith(2) // 0-indexed + }) + }) + + describe('Edge Cases', () => { + it('should handle total of 0', () => { + const { container } = render() + expect(container).toBeInTheDocument() + }) + + it('should handle single page', () => { + render() + // totalPages = 1, both buttons should be disabled + const buttons = screen.getAllByRole('button') + expect(buttons[0]).toBeDisabled() + expect(buttons[buttons.length - 1]).toBeDisabled() + }) + + it('should restore input value when blurred with empty value', () => { + render() + fireEvent.click(screen.getByText('/')) + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: '' } }) + fireEvent.blur(input) + // Should close input without calling onChange, restoring to current + 1 + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/pagination/pagination.spec.tsx b/web/app/components/base/pagination/pagination.spec.tsx new file mode 100644 index 0000000000..2374f8257a --- /dev/null +++ b/web/app/components/base/pagination/pagination.spec.tsx @@ -0,0 +1,376 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { Pagination } from './pagination' + +// Helper to render Pagination with common defaults +function renderPagination({ + currentPage = 0, + totalPages = 10, + setCurrentPage = vi.fn(), + edgePageCount = 2, + middlePagesSiblingCount = 1, + truncableText = '...', + truncableClassName = 'truncable', + children, +}: { + currentPage?: number + totalPages?: number + setCurrentPage?: (page: number) => void + edgePageCount?: number + middlePagesSiblingCount?: number + truncableText?: string + truncableClassName?: string + children?: React.ReactNode +} = {}) { + return render( + + {children} + , + ) +} + +describe('Pagination', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = renderPagination() + expect(container).toBeInTheDocument() + }) + + it('should render children', () => { + renderPagination({ children: child content }) + expect(screen.getByText(/child content/i)).toBeInTheDocument() + }) + + it('should apply className to wrapper div', () => { + const { container } = render( + + test + , + ) + expect(container.firstChild).toHaveClass('my-pagination') + }) + + it('should apply data-testid when provided', () => { + render( + + test + , + ) + expect(screen.getByTestId('my-pagination')).toBeInTheDocument() + }) + }) + + describe('PrevButton', () => { + it('should render prev button', () => { + renderPagination({ + currentPage: 3, + children: Prev, + }) + expect(screen.getByText(/prev/i)).toBeInTheDocument() + }) + + it('should call setCurrentPage with previous page when clicked', () => { + const setCurrentPage = vi.fn() + renderPagination({ + currentPage: 3, + setCurrentPage, + children: Prev, + }) + fireEvent.click(screen.getByText(/prev/i)) + expect(setCurrentPage).toHaveBeenCalledWith(2) + }) + + it('should not navigate below page 0', () => { + const setCurrentPage = vi.fn() + renderPagination({ + currentPage: 0, + setCurrentPage, + children: Prev, + }) + fireEvent.click(screen.getByText(/prev/i)) + expect(setCurrentPage).not.toHaveBeenCalled() + }) + + it('should be disabled on first page', () => { + renderPagination({ + currentPage: 0, + children: Prev, + }) + expect(screen.getByText(/prev/i).closest('button')).toBeDisabled() + }) + + it('should navigate on Enter key press', () => { + const setCurrentPage = vi.fn() + renderPagination({ + currentPage: 3, + setCurrentPage, + children: Prev, + }) + fireEvent.keyPress(screen.getByText(/prev/i), { key: 'Enter', charCode: 13 }) + expect(setCurrentPage).toHaveBeenCalledWith(2) + }) + + it('should not navigate on Enter when disabled', () => { + const setCurrentPage = vi.fn() + renderPagination({ + currentPage: 0, + setCurrentPage, + children: Prev, + }) + fireEvent.keyPress(screen.getByText(/prev/i), { key: 'Enter', charCode: 13 }) + expect(setCurrentPage).not.toHaveBeenCalled() + }) + + it('should render with custom as element', () => { + renderPagination({ + currentPage: 3, + children: }>Prev, + }) + expect(screen.getByText(/prev/i)).toBeInTheDocument() + }) + + it('should apply dataTestId', () => { + renderPagination({ + currentPage: 3, + children: Prev, + }) + expect(screen.getByTestId('prev-btn')).toBeInTheDocument() + }) + }) + + describe('NextButton', () => { + it('should render next button', () => { + renderPagination({ + currentPage: 0, + children: Next, + }) + expect(screen.getByText(/next/i)).toBeInTheDocument() + }) + + it('should call setCurrentPage with next page when clicked', () => { + const setCurrentPage = vi.fn() + renderPagination({ + currentPage: 0, + totalPages: 10, + setCurrentPage, + children: Next, + }) + fireEvent.click(screen.getByText(/next/i)) + expect(setCurrentPage).toHaveBeenCalledWith(1) + }) + + it('should not navigate beyond last page', () => { + const setCurrentPage = vi.fn() + renderPagination({ + currentPage: 9, + totalPages: 10, + setCurrentPage, + children: Next, + }) + fireEvent.click(screen.getByText(/next/i)) + expect(setCurrentPage).not.toHaveBeenCalled() + }) + + it('should be disabled on last page', () => { + renderPagination({ + currentPage: 9, + totalPages: 10, + children: Next, + }) + expect(screen.getByText(/next/i).closest('button')).toBeDisabled() + }) + + it('should navigate on Enter key press', () => { + const setCurrentPage = vi.fn() + renderPagination({ + currentPage: 0, + totalPages: 10, + setCurrentPage, + children: Next, + }) + fireEvent.keyPress(screen.getByText(/next/i), { key: 'Enter', charCode: 13 }) + expect(setCurrentPage).toHaveBeenCalledWith(1) + }) + + it('should not navigate on Enter when disabled', () => { + const setCurrentPage = vi.fn() + renderPagination({ + currentPage: 9, + totalPages: 10, + setCurrentPage, + children: Next, + }) + fireEvent.keyPress(screen.getByText(/next/i), { key: 'Enter', charCode: 13 }) + expect(setCurrentPage).not.toHaveBeenCalled() + }) + + it('should apply dataTestId', () => { + renderPagination({ + currentPage: 0, + children: Next, + }) + expect(screen.getByTestId('next-btn')).toBeInTheDocument() + }) + }) + + describe('PageButton', () => { + it('should render page number buttons', () => { + renderPagination({ + currentPage: 0, + totalPages: 5, + children: ( + + ), + }) + expect(screen.getByText('1')).toBeInTheDocument() + expect(screen.getByText('5')).toBeInTheDocument() + }) + + it('should apply activeClassName to current page', () => { + renderPagination({ + currentPage: 2, + totalPages: 5, + children: ( + + ), + }) + // current page is 2, so page 3 (1-indexed) should be active + expect(screen.getByText('3').closest('a')).toHaveClass('active') + }) + + it('should apply inactiveClassName to non-current pages', () => { + renderPagination({ + currentPage: 2, + totalPages: 5, + children: ( + + ), + }) + expect(screen.getByText('1').closest('a')).toHaveClass('inactive') + }) + + it('should call setCurrentPage when a page button is clicked', () => { + const setCurrentPage = vi.fn() + renderPagination({ + currentPage: 0, + totalPages: 5, + setCurrentPage, + children: ( + + ), + }) + fireEvent.click(screen.getByText('3')) + expect(setCurrentPage).toHaveBeenCalledWith(2) // 0-indexed + }) + + it('should navigate on Enter key press on a page button', () => { + const setCurrentPage = vi.fn() + renderPagination({ + currentPage: 0, + totalPages: 5, + setCurrentPage, + children: ( + + ), + }) + fireEvent.keyPress(screen.getByText('4'), { key: 'Enter', charCode: 13 }) + expect(setCurrentPage).toHaveBeenCalledWith(3) // 0-indexed + }) + + it('should render truncable text when pages are truncated', () => { + renderPagination({ + currentPage: 5, + totalPages: 20, + edgePageCount: 2, + middlePagesSiblingCount: 1, + truncableText: '...', + children: ( + + ), + }) + // With 20 pages and current at 5, there should be truncation + expect(screen.getAllByText('...').length).toBeGreaterThanOrEqual(1) + }) + }) + + describe('Edge Cases', () => { + it('should handle single page', () => { + const setCurrentPage = vi.fn() + renderPagination({ + currentPage: 0, + totalPages: 1, + setCurrentPage, + children: ( + <> + Prev + + Next + + ), + }) + expect(screen.getByText(/prev/i).closest('button')).toBeDisabled() + expect(screen.getByText(/next/i).closest('button')).toBeDisabled() + expect(screen.getByText('1')).toBeInTheDocument() + }) + + it('should handle zero total pages', () => { + const { container } = renderPagination({ + currentPage: 0, + totalPages: 0, + children: ( + + ), + }) + expect(container).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/theme-selector.spec.tsx b/web/app/components/base/theme-selector.spec.tsx new file mode 100644 index 0000000000..8cd0028acf --- /dev/null +++ b/web/app/components/base/theme-selector.spec.tsx @@ -0,0 +1,103 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import ThemeSelector from './theme-selector' + +// Mock next-themes with controllable state +let mockTheme = 'system' +const mockSetTheme = vi.fn() +vi.mock('next-themes', () => ({ + useTheme: () => ({ + theme: mockTheme, + setTheme: mockSetTheme, + }), +})) + +describe('ThemeSelector', () => { + beforeEach(() => { + vi.clearAllMocks() + mockTheme = 'system' + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = render() + expect(container).toBeInTheDocument() + }) + + it('should render the trigger button', () => { + render() + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should not show dropdown content when closed', () => { + render() + expect(screen.queryByText(/common\.theme\.light/i)).not.toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should show all theme options when dropdown is opened', () => { + render() + fireEvent.click(screen.getByRole('button')) + expect(screen.getByText(/light/i)).toBeInTheDocument() + expect(screen.getByText(/dark/i)).toBeInTheDocument() + expect(screen.getByText(/auto/i)).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call setTheme with light when light option is clicked', () => { + render() + fireEvent.click(screen.getByRole('button')) + const lightButton = screen.getByText(/light/i).closest('button')! + fireEvent.click(lightButton) + expect(mockSetTheme).toHaveBeenCalledWith('light') + }) + + it('should call setTheme with dark when dark option is clicked', () => { + render() + fireEvent.click(screen.getByRole('button')) + const darkButton = screen.getByText(/dark/i).closest('button')! + fireEvent.click(darkButton) + expect(mockSetTheme).toHaveBeenCalledWith('dark') + }) + + it('should call setTheme with system when system option is clicked', () => { + render() + fireEvent.click(screen.getByRole('button')) + const systemButton = screen.getByText(/auto/i).closest('button')! + fireEvent.click(systemButton) + expect(mockSetTheme).toHaveBeenCalledWith('system') + }) + }) + + describe('Theme-specific rendering', () => { + it('should show checkmark for the currently active light theme', () => { + mockTheme = 'light' + render() + fireEvent.click(screen.getByRole('button')) + expect(screen.getByTestId('light-icon')).toBeInTheDocument() + }) + + it('should show checkmark for the currently active dark theme', () => { + mockTheme = 'dark' + render() + fireEvent.click(screen.getByRole('button')) + expect(screen.getByTestId('dark-icon')).toBeInTheDocument() + }) + + it('should show checkmark for the currently active system theme', () => { + mockTheme = 'system' + render() + fireEvent.click(screen.getByRole('button')) + expect(screen.getByTestId('system-icon')).toBeInTheDocument() + }) + + it('should not show checkmark on non-active themes', () => { + mockTheme = 'light' + render() + fireEvent.click(screen.getByRole('button')) + expect(screen.queryByTestId('dark-icon')).not.toBeInTheDocument() + expect(screen.queryByTestId('system-icon')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/theme-selector.tsx b/web/app/components/base/theme-selector.tsx index 8869407057..49fdfb4390 100644 --- a/web/app/components/base/theme-selector.tsx +++ b/web/app/components/base/theme-selector.tsx @@ -1,11 +1,5 @@ 'use client' -import { - RiCheckLine, - RiComputerLine, - RiMoonLine, - RiSunLine, -} from '@remixicon/react' import { useTheme } from 'next-themes' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -30,9 +24,9 @@ export default function ThemeSelector() { const getCurrentIcon = () => { switch (theme) { - case 'light': return - case 'dark': return - default: return + case 'light': return + case 'dark': return + default: return } } @@ -59,13 +53,13 @@ export default function ThemeSelector() { className="flex w-full items-center gap-1 rounded-lg px-2 py-1.5 text-text-secondary hover:bg-state-base-hover" onClick={() => handleThemeChange('light')} > - +
{t('theme.light', { ns: 'common' })}
{theme === 'light' && (
- +
)} @@ -74,13 +68,13 @@ export default function ThemeSelector() { className="flex w-full items-center gap-1 rounded-lg px-2 py-1.5 text-text-secondary hover:bg-state-base-hover" onClick={() => handleThemeChange('dark')} > - +
{t('theme.dark', { ns: 'common' })}
{theme === 'dark' && (
- +
)} @@ -89,13 +83,13 @@ export default function ThemeSelector() { className="flex w-full items-center gap-1 rounded-lg px-2 py-1.5 text-text-secondary hover:bg-state-base-hover" onClick={() => handleThemeChange('system')} > - +
{t('theme.auto', { ns: 'common' })}
{theme === 'system' && (
- +
)} diff --git a/web/app/components/base/theme-switcher.spec.tsx b/web/app/components/base/theme-switcher.spec.tsx new file mode 100644 index 0000000000..e19fbd3835 --- /dev/null +++ b/web/app/components/base/theme-switcher.spec.tsx @@ -0,0 +1,106 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import ThemeSwitcher from './theme-switcher' + +let mockTheme = 'system' +const mockSetTheme = vi.fn() +vi.mock('next-themes', () => ({ + useTheme: () => ({ + theme: mockTheme, + setTheme: mockSetTheme, + }), +})) + +describe('ThemeSwitcher', () => { + beforeEach(() => { + vi.clearAllMocks() + mockTheme = 'system' + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render three theme option buttons', () => { + render() + expect(screen.getByTestId('system-theme-container')).toBeInTheDocument() + expect(screen.getByTestId('light-theme-container')).toBeInTheDocument() + expect(screen.getByTestId('dark-theme-container')).toBeInTheDocument() + }) + + it('should render two dividers between options', () => { + render() + const dividers = screen.getAllByTestId('divider') + expect(dividers).toHaveLength(2) + }) + }) + + describe('User Interactions', () => { + it('should call setTheme with system when system option is clicked', () => { + render() + fireEvent.click(screen.getByTestId('system-theme-container')) // system is first + expect(mockSetTheme).toHaveBeenCalledWith('system') + }) + + it('should call setTheme with light when light option is clicked', () => { + render() + fireEvent.click(screen.getByTestId('light-theme-container')) // light is second + expect(mockSetTheme).toHaveBeenCalledWith('light') + }) + + it('should call setTheme with dark when dark option is clicked', () => { + render() + fireEvent.click(screen.getByTestId('dark-theme-container')) // dark is third + expect(mockSetTheme).toHaveBeenCalledWith('dark') + }) + }) + + describe('Theme-specific rendering', () => { + it('should highlight system option when theme is system', () => { + mockTheme = 'system' + render() + expect(screen.getByTestId('system-theme-container')).toHaveClass('bg-components-segmented-control-item-active-bg') + expect(screen.getByTestId('light-theme-container')).not.toHaveClass('bg-components-segmented-control-item-active-bg') + expect(screen.getByTestId('dark-theme-container')).not.toHaveClass('bg-components-segmented-control-item-active-bg') + }) + + it('should highlight light option when theme is light', () => { + mockTheme = 'light' + render() + expect(screen.getByTestId('light-theme-container')).toHaveClass('bg-components-segmented-control-item-active-bg') + expect(screen.getByTestId('system-theme-container')).not.toHaveClass('bg-components-segmented-control-item-active-bg') + expect(screen.getByTestId('dark-theme-container')).not.toHaveClass('bg-components-segmented-control-item-active-bg') + }) + + it('should highlight dark option when theme is dark', () => { + mockTheme = 'dark' + render() + expect(screen.getByTestId('dark-theme-container')).toHaveClass('bg-components-segmented-control-item-active-bg') + expect(screen.getByTestId('system-theme-container')).not.toHaveClass('bg-components-segmented-control-item-active-bg') + expect(screen.getByTestId('light-theme-container')).not.toHaveClass('bg-components-segmented-control-item-active-bg') + }) + + it('should show divider between system and light when dark is active', () => { + mockTheme = 'dark' + render() + const dividers = screen.getAllByTestId('divider') + expect(dividers[0]).toHaveClass('bg-divider-regular') + }) + + it('should show divider between light and dark when system is active', () => { + mockTheme = 'system' + render() + const dividers = screen.getAllByTestId('divider') + expect(dividers[1]).toHaveClass('bg-divider-regular') + }) + + it('should have transparent dividers when neither adjacent theme is active', () => { + mockTheme = 'light' + render() + const dividers = screen.getAllByTestId('divider') + expect(dividers[0]).not.toHaveClass('bg-divider-regular') + expect(dividers[1]).not.toHaveClass('bg-divider-regular') + }) + }) +}) diff --git a/web/app/components/base/theme-switcher.tsx b/web/app/components/base/theme-switcher.tsx index d223ff738e..86e24a443c 100644 --- a/web/app/components/base/theme-switcher.tsx +++ b/web/app/components/base/theme-switcher.tsx @@ -1,9 +1,4 @@ 'use client' -import { - RiComputerLine, - RiMoonLine, - RiSunLine, -} from '@remixicon/react' import { useTheme } from 'next-themes' import { cn } from '@/utils/classnames' @@ -24,33 +19,36 @@ export default function ThemeSwitcher() { theme === 'system' && 'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-sm hover:bg-components-segmented-control-item-active-bg hover:text-text-accent-light-mode-only', )} onClick={() => handleThemeChange('system')} + data-testid="system-theme-container" >
- +
-
+
handleThemeChange('light')} + data-testid="light-theme-container" >
- +
-
+
handleThemeChange('dark')} + data-testid="dark-theme-container" >
- +
diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 9f0104333f..b5c02271b9 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -1266,14 +1266,6 @@ "count": 2 } }, - "app/components/base/alert.tsx": { - "tailwindcss/enforce-consistent-class-order": { - "count": 1 - }, - "tailwindcss/no-unnecessary-whitespace": { - "count": 1 - } - }, "app/components/base/amplitude/AmplitudeProvider.tsx": { "react-refresh/only-export-components": { "count": 1