mirror of
https://github.com/langgenius/dify.git
synced 2026-02-24 18:05:11 +00:00
test: add tests for some base components (#32356)
This commit is contained in:
96
web/app/components/base/alert.spec.tsx
Normal file
96
web/app/components/base/alert.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
82
web/app/components/base/app-unavailable.spec.tsx
Normal file
82
web/app/components/base/app-unavailable.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
201
web/app/components/base/auto-height-textarea/index.spec.tsx
Normal file
201
web/app/components/base/auto-height-textarea/index.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
86
web/app/components/base/badge.spec.tsx
Normal file
86
web/app/components/base/badge.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
226
web/app/components/base/block-input/index.spec.tsx
Normal file
226
web/app/components/base/block-input/index.spec.tsx
Normal 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'])
|
||||
})
|
||||
})
|
||||
@@ -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 && (
|
||||
|
||||
49
web/app/components/base/button/add-button.spec.tsx
Normal file
49
web/app/components/base/button/add-button.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
56
web/app/components/base/button/sync-button.spec.tsx
Normal file
56
web/app/components/base/button/sync-button.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
218
web/app/components/base/carousel/index.spec.tsx
Normal file
218
web/app/components/base/carousel/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
383
web/app/components/base/error-boundary/index.spec.tsx
Normal file
383
web/app/components/base/error-boundary/index.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
140
web/app/components/base/float-right-container/index.spec.tsx
Normal file
140
web/app/components/base/float-right-container/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
155
web/app/components/base/pagination/hook.spec.ts
Normal file
155
web/app/components/base/pagination/hook.spec.ts
Normal 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('...')
|
||||
})
|
||||
})
|
||||
})
|
||||
242
web/app/components/base/pagination/index.spec.tsx
Normal file
242
web/app/components/base/pagination/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
376
web/app/components/base/pagination/pagination.spec.tsx
Normal file
376
web/app/components/base/pagination/pagination.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
103
web/app/components/base/theme-selector.spec.tsx
Normal file
103
web/app/components/base/theme-selector.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
106
web/app/components/base/theme-switcher.spec.tsx
Normal file
106
web/app/components/base/theme-switcher.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user