test: add tests for some base components (#32356)

This commit is contained in:
Saumya Talwani
2026-02-24 12:05:23 +05:30
committed by GitHub
parent 657eeb65b8
commit 740d94c6ed
24 changed files with 2569 additions and 56 deletions

View File

@@ -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(<Alert {...defaultProps} />)
expect(screen.getByText(defaultProps.message)).toBeInTheDocument()
})
it('should render the info icon', () => {
render(<Alert {...defaultProps} />)
const icon = screen.getByTestId('info-icon')
expect(icon).toBeInTheDocument()
})
it('should render the close icon', () => {
render(<Alert {...defaultProps} />)
const closeIcon = screen.getByTestId('close-icon')
expect(closeIcon).toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(<Alert {...defaultProps} className="my-custom-class" />)
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(<Alert {...defaultProps} className="my-custom-class" />)
const outerDiv = container.firstChild as HTMLElement
expect(outerDiv).toHaveClass('pointer-events-none', 'w-full')
})
it('should default type to info', () => {
render(<Alert {...defaultProps} />)
const gradientDiv = screen.getByTestId('alert-gradient')
expect(gradientDiv).toHaveClass('from-components-badge-status-light-normal-halo')
})
it('should render with explicit type info', () => {
render(<Alert {...defaultProps} type="info" />)
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(<Alert {...defaultProps} message={msg} />)
expect(screen.getByText(msg)).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onHide when close button is clicked', () => {
const onHide = vi.fn()
render(<Alert {...defaultProps} onHide={onHide} />)
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(<Alert {...defaultProps} onHide={onHide} />)
fireEvent.click(screen.getByText(defaultProps.message))
expect(onHide).not.toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should render with an empty message string', () => {
render(<Alert {...defaultProps} message="" />)
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(<Alert {...defaultProps} message={longMessage} />)
expect(screen.getByText(longMessage)).toBeInTheDocument()
})
})
})

View File

@@ -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<Props> = ({
<div
className="relative flex space-x-1 overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg"
>
<div className={cn('pointer-events-none absolute inset-0 bg-gradient-to-r opacity-[0.4]', bgVariants({ type }))}>
<div className={cn('pointer-events-none absolute inset-0 bg-gradient-to-r opacity-[0.4]', bgVariants({ type }))} data-testid="alert-gradient">
</div>
<div className="flex h-6 w-6 items-center justify-center">
<RiInformation2Fill className="text-text-accent" />
<span className="i-ri-information-2-fill text-text-accent" data-testid="info-icon" />
</div>
<div className="p-1">
<div className="system-xs-regular text-text-secondary">
<div className="text-text-secondary system-xs-regular" data-testid="msg-container">
{message}
</div>
</div>
@@ -49,7 +45,7 @@ const Alert: React.FC<Props> = ({
className="pointer-events-auto flex h-6 w-6 cursor-pointer items-center justify-center"
onClick={onHide}
>
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" data-testid="close-icon" />
</div>
</div>
</div>

View File

@@ -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(<AppUnavailable />)
expect(screen.getByText(/404/)).toBeInTheDocument()
})
it('should render the error code in a heading', () => {
render(<AppUnavailable />)
const heading = screen.getByRole('heading', { level: 1 })
expect(heading).toHaveTextContent(/404/)
})
it('should render the default unavailable message', () => {
render(<AppUnavailable />)
expect(screen.getByText(/unavailable/i)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should display custom error code', () => {
render(<AppUnavailable code={500} />)
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('500')
})
it('should accept string error code', () => {
render(<AppUnavailable code="403" />)
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('403')
})
it('should apply custom className', () => {
const { container } = render(<AppUnavailable className="my-custom" />)
const outerDiv = container.firstChild as HTMLElement
expect(outerDiv).toHaveClass('my-custom')
})
it('should retain base classes when custom className is applied', () => {
const { container } = render(<AppUnavailable className="my-custom" />)
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(<AppUnavailable unknownReason="Custom error occurred" />)
expect(screen.getByText(/Custom error occurred/i)).toBeInTheDocument()
})
it('should display unknown error translation when isUnknownReason is true', () => {
render(<AppUnavailable isUnknownReason />)
expect(screen.getByText(/share.common.appUnknownError/i)).toBeInTheDocument()
})
it('should prioritize unknownReason over isUnknownReason', () => {
render(<AppUnavailable isUnknownReason unknownReason="My custom reason" />)
expect(screen.getByText(/My custom reason/i)).toBeInTheDocument()
})
it('should show appUnavailable translation when isUnknownReason is false', () => {
render(<AppUnavailable isUnknownReason={false} />)
expect(screen.getByText(/share.common.appUnavailable/i)).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should render with code 0', () => {
render(<AppUnavailable code={0} />)
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('0')
})
it('should render with an empty unknownReason and fall back to translation', () => {
render(<AppUnavailable unknownReason="" />)
expect(screen.getByText(/share.common.appUnavailable/i)).toBeInTheDocument()
})
})
})

View File

@@ -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(<AutoHeightTextarea value="" onChange={vi.fn()} />)
const textarea = document.querySelector('textarea')
expect(textarea).toBeInTheDocument()
})
it('should render with placeholder when value is empty', () => {
render(<AutoHeightTextarea placeholder="Enter text" value="" onChange={vi.fn()} />)
expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument()
})
it('should render with value', () => {
render(<AutoHeightTextarea value="Hello World" onChange={vi.fn()} />)
const textarea = screen.getByDisplayValue('Hello World')
expect(textarea).toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className to textarea', () => {
render(<AutoHeightTextarea value="" onChange={vi.fn()} className="custom-class" />)
const textarea = document.querySelector('textarea')
expect(textarea).toHaveClass('custom-class')
})
it('should apply custom wrapperClassName to wrapper div', () => {
render(<AutoHeightTextarea value="" onChange={vi.fn()} wrapperClassName="wrapper-class" />)
const wrapper = document.querySelector('div.relative')
expect(wrapper).toHaveClass('wrapper-class')
})
it('should apply minHeight and maxHeight styles to hidden div', () => {
render(<AutoHeightTextarea value="" onChange={vi.fn()} minHeight={50} maxHeight={200} />)
const hiddenDiv = document.querySelector('div.invisible')
expect(hiddenDiv).toHaveStyle({ minHeight: '50px', maxHeight: '200px' })
})
it('should use default minHeight and maxHeight when not provided', () => {
render(<AutoHeightTextarea value="" onChange={vi.fn()} />)
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(<AutoHeightTextarea value="" onChange={vi.fn()} autoFocus />)
expect(focusSpy).toHaveBeenCalled()
})
})
describe('User Interactions', () => {
it('should call onChange when textarea value changes', () => {
const handleChange = vi.fn()
render(<AutoHeightTextarea value="" onChange={handleChange} />)
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(<AutoHeightTextarea value="" onChange={vi.fn()} onKeyDown={handleKeyDown} />)
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(<AutoHeightTextarea value="" onChange={vi.fn()} onKeyUp={handleKeyUp} />)
const textarea = screen.getByRole('textbox')
fireEvent.keyUp(textarea, { key: 'Enter' })
expect(handleKeyUp).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {
it('should handle empty string value', () => {
render(<AutoHeightTextarea value="" onChange={vi.fn()} />)
const textarea = screen.getByRole('textbox')
expect(textarea).toHaveValue('')
})
it('should handle whitespace-only value', () => {
render(<AutoHeightTextarea value=" " onChange={vi.fn()} />)
const textarea = screen.getByRole('textbox')
expect(textarea).toHaveValue(' ')
})
it('should handle very long text (>10000 chars)', () => {
const longText = 'a'.repeat(10001)
render(<AutoHeightTextarea value={longText} onChange={vi.fn()} />)
const textarea = screen.getByDisplayValue(longText)
expect(textarea).toBeInTheDocument()
})
it('should handle newlines in value', () => {
const textWithNewlines = 'line1\nline2\nline3'
render(<AutoHeightTextarea value={textWithNewlines} onChange={vi.fn()} />)
const textarea = document.querySelector('textarea')
expect(textarea).toHaveValue(textWithNewlines)
})
it('should handle special characters in value', () => {
const specialChars = '!@#$%^&*()_+-=[]{}|;:,.<>?'
render(<AutoHeightTextarea value={specialChars} onChange={vi.fn()} />)
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(<AutoHeightTextarea ref={ref as React.RefObject<HTMLTextAreaElement>} 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(<AutoHeightTextarea value="" onChange={vi.fn()} controlFocus={1} />)
expect(focusSpy).toHaveBeenCalledTimes(1)
rerender(<AutoHeightTextarea value="" onChange={vi.fn()} controlFocus={2} />)
expect(focusSpy).toHaveBeenCalledTimes(2)
focusSpy.mockRestore()
})
it('should retry focus recursively when ref is not ready during autoFocus', async () => {
const delayedRef = {} as React.RefObject<HTMLTextAreaElement>
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(<AutoHeightTextarea ref={delayedRef} value="" onChange={vi.fn()} autoFocus />)
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')
})
})
})

View File

@@ -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(<Badge text="beta" />)
expect(screen.getByText(/beta/i)).toBeInTheDocument()
})
it('should render with children instead of text', () => {
render(<Badge><span>child content</span></Badge>)
expect(screen.getByText(/child content/i)).toBeInTheDocument()
})
it('should render with no text or children', () => {
const { container } = render(<Badge />)
expect(container.firstChild).toBeInTheDocument()
expect(container.firstChild).toHaveTextContent('')
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(<Badge text="test" className="my-custom" />)
const badge = container.firstChild as HTMLElement
expect(badge).toHaveClass('my-custom')
})
it('should retain base classes when custom className is applied', () => {
const { container } = render(<Badge text="test" className="my-custom" />)
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(<Badge text="test" />)
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(<Badge text="test" uppercase={false} />)
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(<Badge text="test" hasRedCornerMark />)
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(<Badge text="test" />)
const mark = container.querySelector('.bg-components-badge-status-light-error-bg')
expect(mark).not.toBeInTheDocument()
})
it('should prioritize children over text', () => {
render(<Badge text="text content"><span>child wins</span></Badge>)
expect(screen.getByText(/child wins/i)).toBeInTheDocument()
expect(screen.queryByText(/text content/i)).not.toBeInTheDocument()
})
it('should render ReactNode as text prop', () => {
render(<Badge text={<strong>bold badge</strong>} />)
expect(screen.getByText(/bold badge/i)).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should render with empty string text', () => {
const { container } = render(<Badge text="" />)
expect(container.firstChild).toBeInTheDocument()
expect(container.firstChild).toHaveTextContent('')
})
it('should render with hasRedCornerMark false explicitly', () => {
const { container } = render(<Badge text="test" hasRedCornerMark={false} />)
const mark = container.querySelector('.bg-components-badge-status-light-error-bg')
expect(mark).not.toBeInTheDocument()
})
})
})

View File

@@ -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(<BlockInput value="" />)
const wrapper = screen.getByTestId('block-input')
expect(wrapper).toBeInTheDocument()
})
it('should render with initial value', () => {
const { container } = render(<BlockInput value="Hello World" />)
expect(container.textContent).toContain('Hello World')
})
it('should render variable highlights', () => {
render(<BlockInput value="Hello {{name}}" />)
const nameElement = screen.getByText('name')
expect(nameElement).toBeInTheDocument()
expect(nameElement.parentElement).toHaveClass('text-primary-600')
})
it('should render multiple variable highlights', () => {
render(<BlockInput value="{{foo}} and {{bar}}" />)
expect(screen.getByText('foo')).toBeInTheDocument()
expect(screen.getByText('bar')).toBeInTheDocument()
})
it('should display character count in footer when not readonly', () => {
render(<BlockInput value="Hello" />)
expect(screen.getByText('5')).toBeInTheDocument()
})
it('should hide footer in readonly mode', () => {
render(<BlockInput value="Hello" readonly />)
expect(screen.queryByText('5')).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
render(<BlockInput value="test" className="custom-class" />)
const innerContent = screen.getByTestId('block-input-content')
expect(innerContent).toHaveClass('custom-class')
})
it('should apply readonly prop with max height', () => {
render(<BlockInput value="test" readonly />)
const contentDiv = screen.getByTestId('block-input').firstChild as Element
expect(contentDiv).toHaveClass('max-h-[180px]')
})
it('should have default empty value', () => {
render(<BlockInput value="" />)
const contentDiv = screen.getByTestId('block-input')
expect(contentDiv).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should enter edit mode when clicked', async () => {
render(<BlockInput value="Hello" />)
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<typeof vi.fn>).mockReturnValue({ isValid: true, errorMessageKey: '', errorKey: '' })
render(<BlockInput value="Hello" onConfirm={onConfirm} />)
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<typeof vi.fn>).mockReturnValue({ isValid: true, errorMessageKey: '', errorKey: '' })
render(<BlockInput value="initial" onConfirm={onConfirm} />)
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<typeof vi.fn>).mockReturnValue({
isValid: false,
errorMessageKey: 'invalidKey',
errorKey: 'test_key',
})
render(<BlockInput value="initial" onConfirm={onConfirm} />)
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(<BlockInput value="Hello" readonly />)
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(<BlockInput value="" />)
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(<BlockInput value="plain text" />)
expect(screen.getByText('plain text')).toBeInTheDocument()
})
it('should handle newlines in value', () => {
render(<BlockInput value="line1\nline2" />)
expect(screen.getByText(/line1/)).toBeInTheDocument()
})
it('should handle multiple same variables', () => {
render(<BlockInput value="{{name}} and {{name}}" />)
const highlights = screen.getAllByText('name')
expect(highlights).toHaveLength(2)
})
it('should handle value with only variables', () => {
render(<BlockInput value="{{foo}}{{bar}}{{baz}}" />)
expect(screen.getByText('foo')).toBeInTheDocument()
expect(screen.getByText('bar')).toBeInTheDocument()
expect(screen.getByText('baz')).toBeInTheDocument()
})
it('should handle text adjacent to variables', () => {
render(<BlockInput value="prefix {{var}} suffix" />)
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'])
})
})

View File

@@ -63,7 +63,7 @@ const BlockInput: FC<IBlockInputProps> = ({
}, [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<IBlockInputProps> = ({
// Prevent rerendering caused cursor to jump to the start of the contentEditable element
const TextAreaContentView = () => {
return (
<div className={cn(style, className)}>
<div className={cn(style, className)} data-testid="block-input-content">
{renderSafeContent(currentValue || '')}
</div>
)
@@ -121,7 +121,7 @@ const BlockInput: FC<IBlockInputProps> = ({
const editAreaClassName = 'focus:outline-none bg-transparent text-sm'
const textAreaContent = (
<div className={cn(readonly ? 'max-h-[180px] pb-5' : 'h-[180px]', ' overflow-y-auto')} onClick={() => !readonly && setIsEditing(true)}>
<div className={cn(readonly ? 'max-h-[180px] pb-5' : 'h-[180px]', 'overflow-y-auto')} onClick={() => !readonly && setIsEditing(true)}>
{isEditing
? (
<div className="h-full px-4 py-2">
@@ -134,10 +134,10 @@ const BlockInput: FC<IBlockInputProps> = ({
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)
}}
/>
</div>
@@ -147,7 +147,7 @@ const BlockInput: FC<IBlockInputProps> = ({
)
return (
<div className={cn('block-input w-full overflow-y-auto rounded-xl border-none bg-white')}>
<div className={cn('block-input w-full overflow-y-auto rounded-xl border-none bg-white')} data-testid="block-input">
{textAreaContent}
{/* footer */}
{!readonly && (

View File

@@ -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(<AddButton onClick={vi.fn()} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render an add icon', () => {
const { container } = render(<AddButton onClick={vi.fn()} />)
const svg = container.querySelector('span')
expect(svg).toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(<AddButton onClick={vi.fn()} className="my-custom" />)
expect(container.firstChild).toHaveClass('my-custom')
})
it('should retain base classes when custom className is applied', () => {
const { container } = render(<AddButton onClick={vi.fn()} className="my-custom" />)
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(<AddButton onClick={onClick} />)
fireEvent.click(container.firstChild!)
expect(onClick).toHaveBeenCalledTimes(1)
})
it('should call onClick multiple times on repeated clicks', () => {
const onClick = vi.fn()
const { container } = render(<AddButton onClick={onClick} />)
fireEvent.click(container.firstChild!)
fireEvent.click(container.firstChild!)
fireEvent.click(container.firstChild!)
expect(onClick).toHaveBeenCalledTimes(3)
})
})
})

View File

@@ -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<Props> = ({
}) => {
return (
<div className={cn(className, 'cursor-pointer select-none rounded-md p-1 hover:bg-state-base-hover')} onClick={onClick}>
<RiAddLine className="h-4 w-4 text-text-tertiary" />
<span className="i-ri-add-line h-4 w-4 text-text-tertiary" />
</div>
)
}

View File

@@ -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(<SyncButton onClick={vi.fn()} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render a refresh icon', () => {
const { container } = render(<SyncButton onClick={vi.fn()} />)
const svg = container.querySelector('span')
expect(svg).toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
render(<SyncButton onClick={vi.fn()} className="my-custom" />)
const clickableDiv = screen.getByTestId('sync-button')
expect(clickableDiv).toHaveClass('my-custom')
})
it('should retain base classes when custom className is applied', () => {
render(<SyncButton onClick={vi.fn()} className="my-custom" />)
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(<SyncButton onClick={onClick} />)
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(<SyncButton onClick={onClick} />)
const clickableDiv = screen.getByTestId('sync-button')!
fireEvent.click(clickableDiv)
fireEvent.click(clickableDiv)
fireEvent.click(clickableDiv)
expect(onClick).toHaveBeenCalledTimes(3)
})
})
})

View File

@@ -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<Props> = ({
}) => {
return (
<TooltipPlus popupContent={popupContent}>
<div className={cn(className, 'cursor-pointer select-none rounded-md p-1 hover:bg-state-base-hover')} onClick={onClick}>
<RiRefreshLine className="h-4 w-4 text-text-tertiary" />
<div className={cn(className, 'cursor-pointer select-none rounded-md p-1 hover:bg-state-base-hover')} onClick={onClick} data-testid="sync-button">
<span className="i-ri-refresh-line h-4 w-4 text-text-tertiary" />
</div>
</TooltipPlus>
)

View File

@@ -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<EmblaEventName, EmblaListener[]>
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(
<Carousel orientation={orientation}>
<Carousel.Content data-testid="carousel-content">
<Carousel.Item>Slide 1</Carousel.Item>
</Carousel.Content>
<Carousel.Previous>Prev</Carousel.Previous>
<Carousel.Next>Next</Carousel.Next>
<Carousel.Dot>Dot</Carousel.Dot>
</Carousel>,
)
}
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<typeof useEmblaCarousel>,
)
})
// 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(
<Carousel opts={{ loop: true }} plugins={['plugin-marker' as unknown as never]}>
<Carousel.Content />
</Carousel>,
)
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(<InvalidConsumer />)).toThrowError(
'useCarousel must be used within a <Carousel />',
)
})
it('should render with disabled controls and no dots when embla api is undefined', () => {
mockedUseEmblaCarousel.mockReturnValue(
[mockCarouselRef, undefined] as unknown as ReturnType<typeof useEmblaCarousel>,
)
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()
})
})
})

View File

@@ -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(<IndeterminateIcon />)
expect(screen.getByTestId('indeterminate-icon')).toBeInTheDocument()
})
it('should render an svg element', () => {
const { container } = render(<IndeterminateIcon />)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
})
})

View File

@@ -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 && (
<DialogTitle className="mb-4 flex cursor-pointer items-center" as="div">
<XMarkIcon className="h-4 w-4 text-text-tertiary" onClick={onClose} />
<span className="i-heroicons-x-mark h-4 w-4 text-text-tertiary" onClick={onClose} data-testid="close-icon" />
</DialogTitle>
)}
</div>

View File

@@ -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 <div>Child content rendered</div>
}
let consoleErrorSpy: ReturnType<typeof vi.spyOn>
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(
<ErrorBoundary>
<ThrowOnRender shouldThrow={false} />
</ErrorBoundary>,
)
expect(screen.getByText('Child content rendered')).toBeInTheDocument()
})
it('should render default fallback with title and message when child throws', async () => {
render(
<ErrorBoundary>
<ThrowOnRender shouldThrow={true} />
</ErrorBoundary>,
)
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(
<ErrorBoundary
className="custom-boundary"
customMessage="Custom recovery message"
customTitle="Custom crash title"
isolate={false}
>
<ThrowOnRender shouldThrow={true} />
</ErrorBoundary>,
)
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(
<ErrorBoundary fallback={<div>Node fallback content</div>}>
<ThrowOnRender shouldThrow={true} />
</ErrorBoundary>,
)
expect(await screen.findByText('Node fallback content')).toBeInTheDocument()
})
it('should render function fallback with error message when fallback prop is a function', async () => {
render(
<ErrorBoundary
fallback={error => (
<div>
Function fallback:
{' '}
{error.message}
</div>
)}
>
<ThrowOnRender message="function fallback boom" shouldThrow={true} />
</ErrorBoundary>,
)
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(
<ErrorBoundary onError={onError}>
<ThrowOnRender shouldThrow={true} />
</ErrorBoundary>,
)
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(
<ErrorBoundary showDetails={true}>
<ThrowOnRender message="details boom" shouldThrow={true} />
</ErrorBoundary>,
)
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(
<ErrorBoundary>
<ThrowOnRender message="dev boom" shouldThrow={true} />
</ErrorBoundary>,
)
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(
<ErrorBoundary enableRecovery={false}>
<ThrowOnRender shouldThrow={true} />
</ErrorBoundary>,
)
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 (
<ErrorBoundary
onReset={() => {
onReset()
setShouldThrow(false)
}}
>
<ThrowOnRender shouldThrow={shouldThrow} />
</ErrorBoundary>
)
}
render(<RecoveryHarness />)
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 (
<>
<button
onClick={() => {
setShouldThrow(false)
setBoundaryKey(1)
}}
>
Recover with keys
</button>
<ErrorBoundary resetKeys={[boundaryKey]}>
<ThrowOnRender shouldThrow={shouldThrow} />
</ErrorBoundary>
</>
)
}
render(<ResetKeysHarness />)
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 (
<>
<button
onClick={() => {
setShouldThrow(false)
setChildLabel('second child')
}}
>
Replace children
</button>
<ErrorBoundary resetOnPropsChange={true}>
{shouldThrow ? <ThrowOnRender shouldThrow={true} /> : <div>{childLabel}</div>}
</ErrorBoundary>
</>
)
}
render(<ResetOnPropsHarness />)
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 (
<button onClick={() => setError(new Error('handler boom'))}>
Trigger hook error
</button>
)
}
render(
<ErrorBoundary fallback={<div>Hook fallback shown</div>}>
<HookConsumer />
</ErrorBoundary>,
)
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 (
<button onClick={() => throwAsyncError(new Error('async hook boom'))}>
Trigger async hook error
</button>
)
}
render(
<ErrorBoundary fallback={<div>Async fallback shown</div>}>
<AsyncHookConsumer />
</ErrorBoundary>,
)
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 <div>Wrapped content</div>
}
const Wrapped = withErrorBoundary(WrappedTarget, {
customTitle: 'Wrapped boundary title',
})
render(<Wrapped shouldThrow={true} />)
expect(await screen.findByText('Wrapped boundary title')).toBeInTheDocument()
})
it('should set displayName using wrapped component name', () => {
const NamedComponent = () => <div>named content</div>
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(
<ErrorFallback
error={new Error('fallback helper message')}
resetErrorBoundaryAction={resetErrorBoundaryAction}
/>,
)
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)
})
})
})

View File

@@ -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(
<FloatRightContainer
isMobile={true}
isOpen={true}
onClose={vi.fn()}
title="Mobile panel"
>
<div>Mobile content</div>
</FloatRightContainer>,
)
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(
<FloatRightContainer
isMobile={true}
isOpen={false}
onClose={vi.fn()}
unmount={true}
>
<div>Closed mobile content</div>
</FloatRightContainer>,
)
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(
<FloatRightContainer
isMobile={false}
isOpen={true}
onClose={vi.fn()}
title="Desktop drawer title should not render"
>
<div>Desktop inline content</div>
</FloatRightContainer>,
)
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(
<FloatRightContainer
isMobile={false}
isOpen={false}
onClose={vi.fn()}
>
<div>Hidden desktop content</div>
</FloatRightContainer>,
)
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(
<FloatRightContainer
isMobile={true}
isOpen={true}
onClose={onClose}
showClose={true}
>
<div>Closable mobile content</div>
</FloatRightContainer>,
)
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(
<FloatRightContainer
isMobile={true}
isOpen={true}
onClose={vi.fn()}
dialogClassName="custom-dialog-class"
panelClassName="custom-panel-class"
>
<div>Class forwarding content</div>
</FloatRightContainer>,
)
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(
<FloatRightContainer
isMobile={true}
isOpen={true}
onClose={vi.fn()}
title="Empty mobile panel"
children={undefined}
/>,
)
expect(await screen.findByRole('dialog')).toBeInTheDocument()
expect(screen.getByText('Empty mobile panel')).toBeInTheDocument()
})
})
})

View File

@@ -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('...')
})
})
})

View File

@@ -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(<CustomizedPagination {...defaultProps} />)
expect(container).toBeInTheDocument()
})
it('should display current page and total pages', () => {
render(<CustomizedPagination {...defaultProps} current={0} total={100} limit={10} />)
// 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(<CustomizedPagination {...defaultProps} />)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThanOrEqual(2)
})
it('should render page number buttons', () => {
render(<CustomizedPagination {...defaultProps} total={50} limit={10} />)
// 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(<CustomizedPagination {...defaultProps} />)
expect(screen.getByText('/')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(<CustomizedPagination {...defaultProps} className="my-custom" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('my-custom')
})
it('should default limit to 10', () => {
render(<CustomizedPagination {...defaultProps} total={100} />)
// 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(<CustomizedPagination {...defaultProps} total={100} limit={25} />)
// 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(<CustomizedPagination {...defaultProps} current={0} />)
const buttons = screen.getAllByRole('button')
// First button is prev
expect(buttons[0]).toBeDisabled()
})
it('should disable next button on last page', () => {
render(<CustomizedPagination {...defaultProps} current={9} total={100} limit={10} />)
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(<CustomizedPagination {...defaultProps} />)
expect(screen.queryByText(/common\.pagination\.perPage/i)).not.toBeInTheDocument()
})
it('should render limit selector when onLimitChange is provided', () => {
const onLimitChange = vi.fn()
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
// 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(<CustomizedPagination {...defaultProps} current={0} onChange={onChange} />)
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(<CustomizedPagination {...defaultProps} current={5} onChange={onChange} />)
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[0])
expect(onChange).toHaveBeenCalledWith(4)
})
it('should show input when page display is clicked', () => {
render(<CustomizedPagination {...defaultProps} />)
// 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(<CustomizedPagination {...defaultProps} current={0} onChange={onChange} />)
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(<CustomizedPagination {...defaultProps} current={0} />)
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(<CustomizedPagination {...defaultProps} current={0} onChange={onChange} />)
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(<CustomizedPagination {...defaultProps} current={0} total={100} limit={10} onChange={onChange} />)
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(<CustomizedPagination {...defaultProps} current={5} onChange={onChange} />)
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(<CustomizedPagination {...defaultProps} />)
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(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
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(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
fireEvent.click(screen.getByText('50'))
expect(onLimitChange).toHaveBeenCalledWith(50)
})
it('should call onChange when a page button is clicked', () => {
const onChange = vi.fn()
render(<CustomizedPagination {...defaultProps} current={0} total={50} limit={10} onChange={onChange} />)
fireEvent.click(screen.getByText('3'))
expect(onChange).toHaveBeenCalledWith(2) // 0-indexed
})
})
describe('Edge Cases', () => {
it('should handle total of 0', () => {
const { container } = render(<CustomizedPagination {...defaultProps} total={0} />)
expect(container).toBeInTheDocument()
})
it('should handle single page', () => {
render(<CustomizedPagination {...defaultProps} total={5} limit={10} />)
// 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(<CustomizedPagination {...defaultProps} current={4} />)
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()
})
})
})

View File

@@ -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(
<Pagination
currentPage={currentPage}
totalPages={totalPages}
setCurrentPage={setCurrentPage}
edgePageCount={edgePageCount}
middlePagesSiblingCount={middlePagesSiblingCount}
truncableText={truncableText}
truncableClassName={truncableClassName}
>
{children}
</Pagination>,
)
}
describe('Pagination', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = renderPagination()
expect(container).toBeInTheDocument()
})
it('should render children', () => {
renderPagination({ children: <span>child content</span> })
expect(screen.getByText(/child content/i)).toBeInTheDocument()
})
it('should apply className to wrapper div', () => {
const { container } = render(
<Pagination
currentPage={0}
totalPages={5}
setCurrentPage={vi.fn()}
edgePageCount={2}
middlePagesSiblingCount={1}
className="my-pagination"
>
<span>test</span>
</Pagination>,
)
expect(container.firstChild).toHaveClass('my-pagination')
})
it('should apply data-testid when provided', () => {
render(
<Pagination
currentPage={0}
totalPages={5}
setCurrentPage={vi.fn()}
edgePageCount={2}
middlePagesSiblingCount={1}
dataTestId="my-pagination"
>
<span>test</span>
</Pagination>,
)
expect(screen.getByTestId('my-pagination')).toBeInTheDocument()
})
})
describe('PrevButton', () => {
it('should render prev button', () => {
renderPagination({
currentPage: 3,
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
})
expect(screen.getByText(/prev/i)).toBeInTheDocument()
})
it('should call setCurrentPage with previous page when clicked', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 3,
setCurrentPage,
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
})
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: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
})
fireEvent.click(screen.getByText(/prev/i))
expect(setCurrentPage).not.toHaveBeenCalled()
})
it('should be disabled on first page', () => {
renderPagination({
currentPage: 0,
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
})
expect(screen.getByText(/prev/i).closest('button')).toBeDisabled()
})
it('should navigate on Enter key press', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 3,
setCurrentPage,
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
})
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: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
})
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: <Pagination.PrevButton as={<div />}>Prev</Pagination.PrevButton>,
})
expect(screen.getByText(/prev/i)).toBeInTheDocument()
})
it('should apply dataTestId', () => {
renderPagination({
currentPage: 3,
children: <Pagination.PrevButton dataTestId="prev-btn">Prev</Pagination.PrevButton>,
})
expect(screen.getByTestId('prev-btn')).toBeInTheDocument()
})
})
describe('NextButton', () => {
it('should render next button', () => {
renderPagination({
currentPage: 0,
children: <Pagination.NextButton>Next</Pagination.NextButton>,
})
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: <Pagination.NextButton>Next</Pagination.NextButton>,
})
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: <Pagination.NextButton>Next</Pagination.NextButton>,
})
fireEvent.click(screen.getByText(/next/i))
expect(setCurrentPage).not.toHaveBeenCalled()
})
it('should be disabled on last page', () => {
renderPagination({
currentPage: 9,
totalPages: 10,
children: <Pagination.NextButton>Next</Pagination.NextButton>,
})
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: <Pagination.NextButton>Next</Pagination.NextButton>,
})
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: <Pagination.NextButton>Next</Pagination.NextButton>,
})
fireEvent.keyPress(screen.getByText(/next/i), { key: 'Enter', charCode: 13 })
expect(setCurrentPage).not.toHaveBeenCalled()
})
it('should apply dataTestId', () => {
renderPagination({
currentPage: 0,
children: <Pagination.NextButton dataTestId="next-btn">Next</Pagination.NextButton>,
})
expect(screen.getByTestId('next-btn')).toBeInTheDocument()
})
})
describe('PageButton', () => {
it('should render page number buttons', () => {
renderPagination({
currentPage: 0,
totalPages: 5,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
/>
),
})
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('5')).toBeInTheDocument()
})
it('should apply activeClassName to current page', () => {
renderPagination({
currentPage: 2,
totalPages: 5,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
/>
),
})
// 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: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
/>
),
})
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: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
/>
),
})
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: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
/>
),
})
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: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
/>
),
})
// 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: (
<>
<Pagination.PrevButton>Prev</Pagination.PrevButton>
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
<Pagination.NextButton>Next</Pagination.NextButton>
</>
),
})
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: (
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
),
})
expect(container).toBeInTheDocument()
})
})
})

View File

@@ -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(<ThemeSelector />)
expect(container).toBeInTheDocument()
})
it('should render the trigger button', () => {
render(<ThemeSelector />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should not show dropdown content when closed', () => {
render(<ThemeSelector />)
expect(screen.queryByText(/common\.theme\.light/i)).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should show all theme options when dropdown is opened', () => {
render(<ThemeSelector />)
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(<ThemeSelector />)
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(<ThemeSelector />)
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(<ThemeSelector />)
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(<ThemeSelector />)
fireEvent.click(screen.getByRole('button'))
expect(screen.getByTestId('light-icon')).toBeInTheDocument()
})
it('should show checkmark for the currently active dark theme', () => {
mockTheme = 'dark'
render(<ThemeSelector />)
fireEvent.click(screen.getByRole('button'))
expect(screen.getByTestId('dark-icon')).toBeInTheDocument()
})
it('should show checkmark for the currently active system theme', () => {
mockTheme = 'system'
render(<ThemeSelector />)
fireEvent.click(screen.getByRole('button'))
expect(screen.getByTestId('system-icon')).toBeInTheDocument()
})
it('should not show checkmark on non-active themes', () => {
mockTheme = 'light'
render(<ThemeSelector />)
fireEvent.click(screen.getByRole('button'))
expect(screen.queryByTestId('dark-icon')).not.toBeInTheDocument()
expect(screen.queryByTestId('system-icon')).not.toBeInTheDocument()
})
})
})

View File

@@ -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 <RiSunLine className="h-4 w-4 text-text-tertiary" />
case 'dark': return <RiMoonLine className="h-4 w-4 text-text-tertiary" />
default: return <RiComputerLine className="h-4 w-4 text-text-tertiary" />
case 'light': return <span className="i-ri-sun-line h-4 w-4 text-text-tertiary" />
case 'dark': return <span className="i-ri-moon-line h-4 w-4 text-text-tertiary" />
default: return <span className="i-ri-computer-line h-4 w-4 text-text-tertiary" />
}
}
@@ -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')}
>
<RiSunLine className="h-4 w-4 text-text-tertiary" />
<span className="i-ri-sun-line h-4 w-4 text-text-tertiary" />
<div className="flex grow items-center justify-start px-1">
<span className="system-md-regular">{t('theme.light', { ns: 'common' })}</span>
</div>
{theme === 'light' && (
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
<RiCheckLine className="h-4 w-4 text-text-accent" />
<span className="i-ri-check-line h-4 w-4 text-text-accent" data-testid="light-icon" />
</div>
)}
</button>
@@ -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')}
>
<RiMoonLine className="h-4 w-4 text-text-tertiary" />
<span className="i-ri-moon-line h-4 w-4 text-text-tertiary" />
<div className="flex grow items-center justify-start px-1">
<span className="system-md-regular">{t('theme.dark', { ns: 'common' })}</span>
</div>
{theme === 'dark' && (
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
<RiCheckLine className="h-4 w-4 text-text-accent" />
<span className="i-ri-check-line h-4 w-4 text-text-accent" data-testid="dark-icon" />
</div>
)}
</button>
@@ -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')}
>
<RiComputerLine className="h-4 w-4 text-text-tertiary" />
<span className="i-ri-computer-line h-4 w-4 text-text-tertiary" />
<div className="flex grow items-center justify-start px-1">
<span className="system-md-regular">{t('theme.auto', { ns: 'common' })}</span>
</div>
{theme === 'system' && (
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
<RiCheckLine className="h-4 w-4 text-text-accent" />
<span className="i-ri-check-line h-4 w-4 text-text-accent" data-testid="system-icon" />
</div>
)}
</button>

View File

@@ -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(<ThemeSwitcher />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render three theme option buttons', () => {
render(<ThemeSwitcher />)
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(<ThemeSwitcher />)
const dividers = screen.getAllByTestId('divider')
expect(dividers).toHaveLength(2)
})
})
describe('User Interactions', () => {
it('should call setTheme with system when system option is clicked', () => {
render(<ThemeSwitcher />)
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(<ThemeSwitcher />)
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(<ThemeSwitcher />)
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(<ThemeSwitcher />)
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(<ThemeSwitcher />)
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(<ThemeSwitcher />)
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(<ThemeSwitcher />)
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(<ThemeSwitcher />)
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(<ThemeSwitcher />)
const dividers = screen.getAllByTestId('divider')
expect(dividers[0]).not.toHaveClass('bg-divider-regular')
expect(dividers[1]).not.toHaveClass('bg-divider-regular')
})
})
})

View File

@@ -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"
>
<div className="p-0.5">
<RiComputerLine className="h-4 w-4" />
<span className="i-ri-computer-line h-4 w-4" />
</div>
</div>
<div className={cn('h-[14px] w-px bg-transparent', theme === 'dark' && 'bg-divider-regular')}></div>
<div className={cn('h-[14px] w-px bg-transparent', theme === 'dark' && 'bg-divider-regular')} data-testid="divider"></div>
<div
className={cn(
'rounded-lg px-2 py-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
theme === 'light' && '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('light')}
data-testid="light-theme-container"
>
<div className="p-0.5">
<RiSunLine className="h-4 w-4" />
<span className="i-ri-sun-line h-4 w-4" />
</div>
</div>
<div className={cn('h-[14px] w-px bg-transparent', theme === 'system' && 'bg-divider-regular')}></div>
<div className={cn('h-[14px] w-px bg-transparent', theme === 'system' && 'bg-divider-regular')} data-testid="divider"></div>
<div
className={cn(
'rounded-lg px-2 py-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
theme === 'dark' && '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('dark')}
data-testid="dark-theme-container"
>
<div className="p-0.5">
<RiMoonLine className="h-4 w-4" />
<span className="i-ri-moon-line h-4 w-4" />
</div>
</div>
</div>

View File

@@ -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