!readonly && setIsEditing(true)}>
+
!readonly && setIsEditing(true)}>
{isEditing
? (
@@ -134,10 +134,10 @@ const BlockInput: FC = ({
onBlur={() => {
blur()
setIsEditing(false)
- // click confirm also make blur. Then outer value is change. So below code has problem.
- // setTimeout(() => {
- // handleCancel()
- // }, 1000)
+ // click confirm also make blur. Then outer value is change. So below code has problem.
+ // setTimeout(() => {
+ // handleCancel()
+ // }, 1000)
}}
/>
@@ -147,7 +147,7 @@ const BlockInput: FC
= ({
)
return (
-
+
{textAreaContent}
{/* footer */}
{!readonly && (
diff --git a/web/app/components/base/button/add-button.spec.tsx b/web/app/components/base/button/add-button.spec.tsx
new file mode 100644
index 0000000000..658c032bb7
--- /dev/null
+++ b/web/app/components/base/button/add-button.spec.tsx
@@ -0,0 +1,49 @@
+import { fireEvent, render } from '@testing-library/react'
+import AddButton from './add-button'
+
+describe('AddButton', () => {
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const { container } = render(
)
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render an add icon', () => {
+ const { container } = render(
)
+ const svg = container.querySelector('span')
+ expect(svg).toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should apply custom className', () => {
+ const { container } = render(
)
+ expect(container.firstChild).toHaveClass('my-custom')
+ })
+
+ it('should retain base classes when custom className is applied', () => {
+ const { container } = render(
)
+ expect(container.firstChild).toHaveClass('cursor-pointer')
+ expect(container.firstChild).toHaveClass('rounded-md')
+ expect(container.firstChild).toHaveClass('select-none')
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onClick when clicked', () => {
+ const onClick = vi.fn()
+ const { container } = render(
)
+ fireEvent.click(container.firstChild!)
+ expect(onClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onClick multiple times on repeated clicks', () => {
+ const onClick = vi.fn()
+ const { container } = render(
)
+ fireEvent.click(container.firstChild!)
+ fireEvent.click(container.firstChild!)
+ fireEvent.click(container.firstChild!)
+ expect(onClick).toHaveBeenCalledTimes(3)
+ })
+ })
+})
diff --git a/web/app/components/base/button/add-button.tsx b/web/app/components/base/button/add-button.tsx
index 332b52daca..50a39ffe7c 100644
--- a/web/app/components/base/button/add-button.tsx
+++ b/web/app/components/base/button/add-button.tsx
@@ -1,6 +1,5 @@
'use client'
import type { FC } from 'react'
-import { RiAddLine } from '@remixicon/react'
import * as React from 'react'
import { cn } from '@/utils/classnames'
@@ -15,7 +14,7 @@ const AddButton: FC
= ({
}) => {
return (
-
+
)
}
diff --git a/web/app/components/base/button/sync-button.spec.tsx b/web/app/components/base/button/sync-button.spec.tsx
new file mode 100644
index 0000000000..eeaf60d46e
--- /dev/null
+++ b/web/app/components/base/button/sync-button.spec.tsx
@@ -0,0 +1,56 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import SyncButton from './sync-button'
+
+vi.mock('ahooks', () => ({
+ useBoolean: () => [false, { setTrue: vi.fn(), setFalse: vi.fn() }],
+}))
+
+describe('SyncButton', () => {
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const { container } = render( )
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render a refresh icon', () => {
+ const { container } = render( )
+ const svg = container.querySelector('span')
+ expect(svg).toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should apply custom className', () => {
+ render( )
+ const clickableDiv = screen.getByTestId('sync-button')
+ expect(clickableDiv).toHaveClass('my-custom')
+ })
+
+ it('should retain base classes when custom className is applied', () => {
+ render( )
+ const clickableDiv = screen.getByTestId('sync-button')
+ expect(clickableDiv).toHaveClass('rounded-md')
+ expect(clickableDiv).toHaveClass('select-none')
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onClick when clicked', () => {
+ const onClick = vi.fn()
+ render( )
+ const clickableDiv = screen.getByTestId('sync-button')!
+ fireEvent.click(clickableDiv)
+ expect(onClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onClick multiple times on repeated clicks', () => {
+ const onClick = vi.fn()
+ render( )
+ const clickableDiv = screen.getByTestId('sync-button')!
+ fireEvent.click(clickableDiv)
+ fireEvent.click(clickableDiv)
+ fireEvent.click(clickableDiv)
+ expect(onClick).toHaveBeenCalledTimes(3)
+ })
+ })
+})
diff --git a/web/app/components/base/button/sync-button.tsx b/web/app/components/base/button/sync-button.tsx
index 12c34026cb..06c155fb1d 100644
--- a/web/app/components/base/button/sync-button.tsx
+++ b/web/app/components/base/button/sync-button.tsx
@@ -1,6 +1,5 @@
'use client'
import type { FC } from 'react'
-import { RiRefreshLine } from '@remixicon/react'
import * as React from 'react'
import TooltipPlus from '@/app/components/base/tooltip'
import { cn } from '@/utils/classnames'
@@ -18,8 +17,8 @@ const SyncButton: FC = ({
}) => {
return (
-
-
+
+
)
diff --git a/web/app/components/base/carousel/index.spec.tsx b/web/app/components/base/carousel/index.spec.tsx
new file mode 100644
index 0000000000..06434a51aa
--- /dev/null
+++ b/web/app/components/base/carousel/index.spec.tsx
@@ -0,0 +1,218 @@
+import type { Mock } from 'vitest'
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import useEmblaCarousel from 'embla-carousel-react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { Carousel, useCarousel } from './index'
+
+vi.mock('embla-carousel-react', () => ({
+ default: vi.fn(),
+}))
+
+type EmblaEventName = 'reInit' | 'select'
+type EmblaListener = (api: MockEmblaApi | undefined) => void
+
+type MockEmblaApi = {
+ scrollPrev: Mock
+ scrollNext: Mock
+ scrollTo: Mock
+ selectedScrollSnap: Mock
+ canScrollPrev: Mock
+ canScrollNext: Mock
+ slideNodes: Mock
+ on: Mock
+ off: Mock
+}
+
+let mockCanScrollPrev = false
+let mockCanScrollNext = false
+let mockSelectedIndex = 0
+let mockSlideCount = 3
+let listeners: Record
+let mockApi: MockEmblaApi
+const mockCarouselRef = vi.fn()
+
+const mockedUseEmblaCarousel = vi.mocked(useEmblaCarousel)
+
+const createMockEmblaApi = (): MockEmblaApi => ({
+ scrollPrev: vi.fn(),
+ scrollNext: vi.fn(),
+ scrollTo: vi.fn(),
+ selectedScrollSnap: vi.fn(() => mockSelectedIndex),
+ canScrollPrev: vi.fn(() => mockCanScrollPrev),
+ canScrollNext: vi.fn(() => mockCanScrollNext),
+ slideNodes: vi.fn(() =>
+ Array.from({ length: mockSlideCount }, () => document.createElement('div')),
+ ),
+ on: vi.fn((event: EmblaEventName, callback: EmblaListener) => {
+ listeners[event].push(callback)
+ }),
+ off: vi.fn((event: EmblaEventName, callback: EmblaListener) => {
+ listeners[event] = listeners[event].filter(listener => listener !== callback)
+ }),
+})
+
+const emitEmblaEvent = (event: EmblaEventName, api: MockEmblaApi | undefined = mockApi) => {
+ listeners[event].forEach(callback => callback(api))
+}
+
+const renderCarouselWithControls = (orientation: 'horizontal' | 'vertical' = 'horizontal') => {
+ return render(
+
+
+ Slide 1
+
+ Prev
+ Next
+ Dot
+ ,
+ )
+}
+
+describe('Carousel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCanScrollPrev = false
+ mockCanScrollNext = false
+ mockSelectedIndex = 0
+ mockSlideCount = 3
+ listeners = { reInit: [], select: [] }
+ mockApi = createMockEmblaApi()
+
+ mockedUseEmblaCarousel.mockReturnValue(
+ [mockCarouselRef, mockApi] as unknown as ReturnType,
+ )
+ })
+
+ // Rendering and basic semantic structure.
+ describe('Rendering', () => {
+ it('should render region and slides when used with content and items', () => {
+ renderCarouselWithControls()
+
+ expect(screen.getByRole('region')).toHaveAttribute('aria-roledescription', 'carousel')
+ expect(screen.getByTestId('carousel-content')).toHaveClass('flex')
+ expect(screen.getByRole('group')).toHaveAttribute('aria-roledescription', 'slide')
+ })
+ })
+
+ // Props should be translated into Embla options and visible layout.
+ describe('Props', () => {
+ it('should configure embla with horizontal axis when orientation is omitted', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(mockedUseEmblaCarousel).toHaveBeenCalledWith(
+ { loop: true, axis: 'x' },
+ ['plugin-marker'],
+ )
+ })
+
+ it('should configure embla with vertical axis and vertical content classes when orientation is vertical', () => {
+ renderCarouselWithControls('vertical')
+
+ expect(mockedUseEmblaCarousel).toHaveBeenCalledWith(
+ { axis: 'y' },
+ undefined,
+ )
+ expect(screen.getByTestId('carousel-content')).toHaveClass('flex-col')
+ })
+ })
+
+ // Users can move slides through previous and next controls.
+ describe('User interactions', () => {
+ it('should call scroll handlers when previous and next buttons are clicked', () => {
+ mockCanScrollPrev = true
+ mockCanScrollNext = true
+
+ renderCarouselWithControls()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Prev' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Next' }))
+
+ expect(mockApi.scrollPrev).toHaveBeenCalledTimes(1)
+ expect(mockApi.scrollNext).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call scrollTo with clicked index when a dot is clicked', () => {
+ renderCarouselWithControls()
+ const dots = screen.getAllByRole('button', { name: 'Dot' })
+
+ fireEvent.click(dots[2])
+
+ expect(mockApi.scrollTo).toHaveBeenCalledWith(2)
+ })
+ })
+
+ // Embla events should keep control states and selected index in sync.
+ describe('State synchronization', () => {
+ it('should update disabled states and active dot when select event is emitted', () => {
+ renderCarouselWithControls()
+
+ mockCanScrollPrev = true
+ mockCanScrollNext = true
+ mockSelectedIndex = 2
+
+ act(() => {
+ emitEmblaEvent('select')
+ })
+
+ const dots = screen.getAllByRole('button', { name: 'Dot' })
+ expect(screen.getByRole('button', { name: 'Prev' })).toBeEnabled()
+ expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled()
+ expect(dots[2]).toHaveAttribute('data-state', 'active')
+ })
+
+ it('should subscribe to embla events and unsubscribe from select on unmount', () => {
+ const { unmount } = renderCarouselWithControls()
+
+ const selectCallback = mockApi.on.mock.calls.find(
+ call => call[0] === 'select',
+ )?.[1] as EmblaListener
+
+ expect(mockApi.on).toHaveBeenCalledWith('reInit', expect.any(Function))
+ expect(mockApi.on).toHaveBeenCalledWith('select', expect.any(Function))
+
+ unmount()
+
+ expect(mockApi.off).toHaveBeenCalledWith('select', selectCallback)
+ })
+ })
+
+ // Edge-case behavior for missing providers or missing embla api values.
+ describe('Edge cases', () => {
+ it('should throw when useCarousel is used outside Carousel provider', () => {
+ const InvalidConsumer = () => {
+ useCarousel()
+ return null
+ }
+
+ expect(() => render( )).toThrowError(
+ 'useCarousel must be used within a ',
+ )
+ })
+
+ it('should render with disabled controls and no dots when embla api is undefined', () => {
+ mockedUseEmblaCarousel.mockReturnValue(
+ [mockCarouselRef, undefined] as unknown as ReturnType,
+ )
+
+ renderCarouselWithControls()
+
+ expect(screen.getByRole('button', { name: 'Prev' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled()
+ expect(screen.queryByRole('button', { name: 'Dot' })).not.toBeInTheDocument()
+ })
+
+ it('should ignore select callback when embla emits an undefined api', () => {
+ renderCarouselWithControls()
+
+ expect(() => {
+ act(() => {
+ emitEmblaEvent('select', undefined)
+ })
+ }).not.toThrow()
+ })
+ })
+})
diff --git a/web/app/components/base/checkbox/assets/indeterminate-icon.spec.tsx b/web/app/components/base/checkbox/assets/indeterminate-icon.spec.tsx
new file mode 100644
index 0000000000..3f39dd836f
--- /dev/null
+++ b/web/app/components/base/checkbox/assets/indeterminate-icon.spec.tsx
@@ -0,0 +1,17 @@
+import { render, screen } from '@testing-library/react'
+import IndeterminateIcon from './indeterminate-icon'
+
+describe('IndeterminateIcon', () => {
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render( )
+ expect(screen.getByTestId('indeterminate-icon')).toBeInTheDocument()
+ })
+
+ it('should render an svg element', () => {
+ const { container } = render( )
+ const svg = container.querySelector('svg')
+ expect(svg).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/base/drawer/index.tsx b/web/app/components/base/drawer/index.tsx
index 2f44ce75af..a145f9a64d 100644
--- a/web/app/components/base/drawer/index.tsx
+++ b/web/app/components/base/drawer/index.tsx
@@ -1,6 +1,5 @@
'use client'
import { Dialog, DialogBackdrop, DialogTitle } from '@headlessui/react'
-import { XMarkIcon } from '@heroicons/react/24/outline'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
import Button from '../button'
@@ -81,7 +80,7 @@ export default function Drawer({
)}
{showClose && (
-
+
)}
diff --git a/web/app/components/base/error-boundary/index.spec.tsx b/web/app/components/base/error-boundary/index.spec.tsx
new file mode 100644
index 0000000000..1caca84d79
--- /dev/null
+++ b/web/app/components/base/error-boundary/index.spec.tsx
@@ -0,0 +1,383 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import * as React from 'react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import ErrorBoundary, { ErrorFallback, useAsyncError, useErrorHandler, withErrorBoundary } from './index'
+
+const mockConfig = vi.hoisted(() => ({
+ isDev: false,
+}))
+
+vi.mock('@/config', () => ({
+ get IS_DEV() {
+ return mockConfig.isDev
+ },
+}))
+
+type ThrowOnRenderProps = {
+ message?: string
+ shouldThrow: boolean
+}
+
+const ThrowOnRender = ({ shouldThrow, message = 'render boom' }: ThrowOnRenderProps) => {
+ if (shouldThrow)
+ throw new Error(message)
+
+ return Child content rendered
+}
+
+let consoleErrorSpy: ReturnType
+
+describe('ErrorBoundary', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockConfig.isDev = false
+ consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ })
+
+ afterEach(() => {
+ consoleErrorSpy.mockRestore()
+ })
+
+ // Verify default render and default fallback behavior.
+ describe('Rendering', () => {
+ it('should render children when no error occurs', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText('Child content rendered')).toBeInTheDocument()
+ })
+
+ it('should render default fallback with title and message when child throws', async () => {
+ render(
+
+
+ ,
+ )
+
+ expect(await screen.findByText('Something went wrong')).toBeInTheDocument()
+ expect(screen.getByText('An unexpected error occurred while rendering this component.')).toBeInTheDocument()
+ })
+
+ it('should render custom title, message, and className in fallback', async () => {
+ render(
+
+
+ ,
+ )
+
+ expect(await screen.findByText('Custom crash title')).toBeInTheDocument()
+ expect(screen.getByText('Custom recovery message')).toBeInTheDocument()
+
+ const fallbackRoot = document.querySelector('.custom-boundary')
+ expect(fallbackRoot).toBeInTheDocument()
+ expect(fallbackRoot).not.toHaveClass('min-h-[200px]')
+ })
+ })
+
+ // Validate explicit fallback prop variants.
+ describe('Fallback props', () => {
+ it('should render node fallback when fallback prop is a React node', async () => {
+ render(
+ Node fallback content }>
+
+ ,
+ )
+
+ expect(await screen.findByText('Node fallback content')).toBeInTheDocument()
+ })
+
+ it('should render function fallback with error message when fallback prop is a function', async () => {
+ render(
+
(
+
+ Function fallback:
+ {' '}
+ {error.message}
+
+ )}
+ >
+
+ ,
+ )
+
+ expect(await screen.findByText('Function fallback: function fallback boom')).toBeInTheDocument()
+ })
+ })
+
+ // Validate error reporting and details panel behavior.
+ describe('Error reporting', () => {
+ it('should call onError with error and errorInfo when child throws', async () => {
+ const onError = vi.fn()
+
+ render(
+
+
+ ,
+ )
+
+ await screen.findByText('Something went wrong')
+
+ expect(onError).toHaveBeenCalledTimes(1)
+ expect(onError).toHaveBeenCalledWith(
+ expect.objectContaining({ message: 'render boom' }),
+ expect.objectContaining({ componentStack: expect.any(String) }),
+ )
+ })
+
+ it('should render details block when showDetails is true', async () => {
+ render(
+
+
+ ,
+ )
+
+ expect(await screen.findByText('Error Details (Development Only)')).toBeInTheDocument()
+ expect(screen.getByText('Error:')).toBeInTheDocument()
+ expect(screen.getByText(/details boom/i)).toBeInTheDocument()
+ })
+
+ it('should log boundary errors in development mode', async () => {
+ mockConfig.isDev = true
+
+ render(
+
+
+ ,
+ )
+
+ await screen.findByText('Something went wrong')
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'ErrorBoundary caught an error:',
+ expect.objectContaining({ message: 'dev boom' }),
+ )
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Error Info:',
+ expect.objectContaining({ componentStack: expect.any(String) }),
+ )
+ })
+ })
+
+ // Validate recovery controls and automatic reset triggers.
+ describe('Recovery', () => {
+ it('should hide recovery actions when enableRecovery is false', async () => {
+ render(
+
+
+ ,
+ )
+
+ await screen.findByText('Something went wrong')
+
+ expect(screen.queryByRole('button', { name: 'Try Again' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Reload Page' })).not.toBeInTheDocument()
+ })
+
+ it('should reset and render children when Try Again is clicked', async () => {
+ const onReset = vi.fn()
+
+ const RecoveryHarness = () => {
+ const [shouldThrow, setShouldThrow] = React.useState(true)
+ return (
+
{
+ onReset()
+ setShouldThrow(false)
+ }}
+ >
+
+
+ )
+ }
+
+ render(
)
+ fireEvent.click(await screen.findByRole('button', { name: 'Try Again' }))
+
+ await screen.findByText('Child content rendered')
+ expect(onReset).toHaveBeenCalledTimes(1)
+ })
+
+ it('should reset after resetKeys change when boundary is in error state', async () => {
+ const ResetKeysHarness = () => {
+ const [shouldThrow, setShouldThrow] = React.useState(true)
+ const [boundaryKey, setBoundaryKey] = React.useState(0)
+
+ return (
+ <>
+
{
+ setShouldThrow(false)
+ setBoundaryKey(1)
+ }}
+ >
+ Recover with keys
+
+
+
+
+ >
+ )
+ }
+
+ render(
)
+ await screen.findByText('Something went wrong')
+
+ fireEvent.click(screen.getByRole('button', { name: 'Recover with keys' }))
+
+ await waitFor(() => {
+ expect(screen.getByText('Child content rendered')).toBeInTheDocument()
+ })
+ })
+
+ it('should reset after children change when resetOnPropsChange is true', async () => {
+ const ResetOnPropsHarness = () => {
+ const [shouldThrow, setShouldThrow] = React.useState(true)
+ const [childLabel, setChildLabel] = React.useState('first child')
+
+ return (
+ <>
+
{
+ setShouldThrow(false)
+ setChildLabel('second child')
+ }}
+ >
+ Replace children
+
+
+ {shouldThrow ? : {childLabel}
}
+
+ >
+ )
+ }
+
+ render(
)
+ await screen.findByText('Something went wrong')
+
+ fireEvent.click(screen.getByRole('button', { name: 'Replace children' }))
+
+ await waitFor(() => {
+ expect(screen.getByText('second child')).toBeInTheDocument()
+ })
+ })
+ })
+})
+
+describe('ErrorBoundary utility exports', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ })
+
+ afterEach(() => {
+ consoleErrorSpy.mockRestore()
+ })
+
+ // Validate imperative error hook behavior.
+ describe('useErrorHandler', () => {
+ it('should trigger error boundary fallback when setError is called', async () => {
+ const HookConsumer = () => {
+ const setError = useErrorHandler()
+ return (
+
setError(new Error('handler boom'))}>
+ Trigger hook error
+
+ )
+ }
+
+ render(
+
Hook fallback shown }>
+
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Trigger hook error' }))
+
+ expect(await screen.findByText('Hook fallback shown')).toBeInTheDocument()
+ })
+ })
+
+ // Validate async error bridge hook behavior.
+ describe('useAsyncError', () => {
+ it('should trigger error boundary fallback when async error callback is called', async () => {
+ const AsyncHookConsumer = () => {
+ const throwAsyncError = useAsyncError()
+ return (
+ throwAsyncError(new Error('async hook boom'))}>
+ Trigger async hook error
+
+ )
+ }
+
+ render(
+ Async fallback shown }>
+
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Trigger async hook error' }))
+
+ expect(await screen.findByText('Async fallback shown')).toBeInTheDocument()
+ })
+ })
+
+ // Validate HOC wrapper behavior and metadata.
+ describe('withErrorBoundary', () => {
+ it('should wrap component and render custom title when wrapped component throws', async () => {
+ type WrappedProps = {
+ shouldThrow: boolean
+ }
+
+ const WrappedTarget = ({ shouldThrow }: WrappedProps) => {
+ if (shouldThrow)
+ throw new Error('wrapped boom')
+ return
Wrapped content
+ }
+
+ const Wrapped = withErrorBoundary(WrappedTarget, {
+ customTitle: 'Wrapped boundary title',
+ })
+
+ render(
)
+
+ expect(await screen.findByText('Wrapped boundary title')).toBeInTheDocument()
+ })
+
+ it('should set displayName using wrapped component name', () => {
+ const NamedComponent = () =>
named content
+ const Wrapped = withErrorBoundary(NamedComponent)
+
+ expect(Wrapped.displayName).toBe('withErrorBoundary(NamedComponent)')
+ })
+ })
+
+ // Validate simple fallback helper component.
+ describe('ErrorFallback', () => {
+ it('should render message and call reset action when button is clicked', () => {
+ const resetErrorBoundaryAction = vi.fn()
+
+ render(
+
,
+ )
+
+ expect(screen.getByText('Oops! Something went wrong')).toBeInTheDocument()
+ expect(screen.getByText('fallback helper message')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
+
+ expect(resetErrorBoundaryAction).toHaveBeenCalledTimes(1)
+ })
+ })
+})
diff --git a/web/app/components/base/float-right-container/index.spec.tsx b/web/app/components/base/float-right-container/index.spec.tsx
new file mode 100644
index 0000000000..51713cc527
--- /dev/null
+++ b/web/app/components/base/float-right-container/index.spec.tsx
@@ -0,0 +1,140 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import FloatRightContainer from './index'
+
+describe('FloatRightContainer', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // Rendering behavior across mobile and desktop branches.
+ describe('Rendering', () => {
+ it('should render content in drawer when isMobile is true and isOpen is true', async () => {
+ render(
+
+ Mobile content
+ ,
+ )
+
+ expect(await screen.findByRole('dialog')).toBeInTheDocument()
+ expect(screen.getByText('Mobile panel')).toBeInTheDocument()
+ expect(screen.getByText('Mobile content')).toBeInTheDocument()
+ })
+
+ it('should not render content when isMobile is true and isOpen is false', () => {
+ render(
+
+ Closed mobile content
+ ,
+ )
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ expect(screen.queryByText('Closed mobile content')).not.toBeInTheDocument()
+ })
+
+ it('should render content inline when isMobile is false and isOpen is true', () => {
+ render(
+
+ Desktop inline content
+ ,
+ )
+
+ expect(screen.getByText('Desktop inline content')).toBeInTheDocument()
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ expect(screen.queryByText('Desktop drawer title should not render')).not.toBeInTheDocument()
+ })
+
+ it('should render nothing when isMobile is false and isOpen is false', () => {
+ const { container } = render(
+
+ Hidden desktop content
+ ,
+ )
+
+ expect(container).toBeEmptyDOMElement()
+ expect(screen.queryByText('Hidden desktop content')).not.toBeInTheDocument()
+ })
+ })
+
+ // Validate that drawer-specific props are passed through in mobile mode.
+ describe('Props forwarding', () => {
+ it('should call onClose when close icon is clicked in mobile drawer mode', async () => {
+ const onClose = vi.fn()
+ render(
+
+ Closable mobile content
+ ,
+ )
+
+ await screen.findByRole('dialog')
+ const closeIcon = screen.getByTestId('close-icon')
+ expect(closeIcon).toBeInTheDocument()
+
+ fireEvent.click(closeIcon!)
+
+ expect(onClose).toHaveBeenCalledTimes(1)
+ })
+
+ it('should apply drawer className props in mobile drawer mode', async () => {
+ render(
+
+ Class forwarding content
+ ,
+ )
+
+ const dialog = await screen.findByRole('dialog')
+ expect(dialog).toHaveClass('custom-dialog-class')
+
+ const panel = document.querySelector('.custom-panel-class')
+ expect(panel).toBeInTheDocument()
+ })
+ })
+
+ // Edge-case behavior with optional children.
+ describe('Edge cases', () => {
+ it('should render without crashing when children is undefined in mobile mode', async () => {
+ render(
+
,
+ )
+
+ expect(await screen.findByRole('dialog')).toBeInTheDocument()
+ expect(screen.getByText('Empty mobile panel')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/base/pagination/hook.spec.ts b/web/app/components/base/pagination/hook.spec.ts
new file mode 100644
index 0000000000..284032df47
--- /dev/null
+++ b/web/app/components/base/pagination/hook.spec.ts
@@ -0,0 +1,155 @@
+import { renderHook } from '@testing-library/react'
+import usePagination from './hook'
+
+const defaultProps = {
+ currentPage: 0,
+ setCurrentPage: vi.fn(),
+ totalPages: 10,
+ edgePageCount: 2,
+ middlePagesSiblingCount: 1,
+ truncableText: '...',
+ truncableClassName: 'truncable',
+}
+
+describe('usePagination', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('pages', () => {
+ it('should generate correct pages array', () => {
+ const { result } = renderHook(() => usePagination(defaultProps))
+ expect(result.current.pages).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
+ })
+
+ it('should generate empty pages for totalPages 0', () => {
+ const { result } = renderHook(() => usePagination({ ...defaultProps, totalPages: 0 }))
+ expect(result.current.pages).toEqual([])
+ })
+
+ it('should generate single page', () => {
+ const { result } = renderHook(() => usePagination({ ...defaultProps, totalPages: 1 }))
+ expect(result.current.pages).toEqual([1])
+ })
+ })
+
+ describe('hasPreviousPage / hasNextPage', () => {
+ it('should have no previous page on first page', () => {
+ const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 1 }))
+ expect(result.current.hasPreviousPage).toBe(false)
+ })
+
+ it('should have previous page when not on first page', () => {
+ const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 3 }))
+ expect(result.current.hasPreviousPage).toBe(true)
+ })
+
+ it('should have next page when not on last page', () => {
+ const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 1 }))
+ expect(result.current.hasNextPage).toBe(true)
+ })
+
+ it('should have no next page on last page', () => {
+ const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 10 }))
+ expect(result.current.hasNextPage).toBe(false)
+ })
+ })
+
+ describe('middlePages', () => {
+ it('should return correct middle pages when at start', () => {
+ const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 0 }))
+ // isReachedToFirst: currentPage(0) <= middlePagesSiblingCount(1), so slice(0, 3)
+ expect(result.current.middlePages).toEqual([1, 2, 3])
+ })
+
+ it('should return correct middle pages when in the middle', () => {
+ const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
+ // Not at start or end, slice(5-1, 5+1+1) = slice(4, 7) = [5, 6, 7]
+ expect(result.current.middlePages).toEqual([5, 6, 7])
+ })
+
+ it('should return correct middle pages when at end', () => {
+ const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 9 }))
+ // isReachedToLast: currentPage(9) + middlePagesSiblingCount(1) >= totalPages(10), so slice(-3)
+ expect(result.current.middlePages).toEqual([8, 9, 10])
+ })
+ })
+
+ describe('previousPages and nextPages', () => {
+ it('should return empty previousPages when at start', () => {
+ const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 0 }))
+ expect(result.current.previousPages).toEqual([])
+ })
+
+ it('should return previousPages when in the middle', () => {
+ const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
+ // edgePageCount=2, so first 2 pages filtered by not in middlePages
+ expect(result.current.previousPages).toEqual([1, 2])
+ })
+
+ it('should return empty nextPages when at end', () => {
+ const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 9 }))
+ expect(result.current.nextPages).toEqual([])
+ })
+
+ it('should return nextPages when in the middle', () => {
+ const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
+ // Last 2 pages: [9, 10], filtered by not in middlePages [5,6,7]
+ expect(result.current.nextPages).toEqual([9, 10])
+ })
+ })
+
+ describe('truncation', () => {
+ it('should be previous truncable when middle pages are far from edge', () => {
+ const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
+ // previousPages=[1,2], middlePages=[5,6,7], 5 > 2+1 = true
+ expect(result.current.isPreviousTruncable).toBe(true)
+ })
+
+ it('should not be previous truncable when pages are contiguous', () => {
+ const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 2 }))
+ expect(result.current.isPreviousTruncable).toBe(false)
+ })
+
+ it('should be next truncable when middle pages are far from end edge', () => {
+ const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
+ // middlePages=[5,6,7], nextPages=[9,10], 7+1 < 9 = true
+ expect(result.current.isNextTruncable).toBe(true)
+ })
+
+ it('should not be next truncable when pages are contiguous', () => {
+ const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 7 }))
+ expect(result.current.isNextTruncable).toBe(false)
+ })
+ })
+
+ describe('passthrough values', () => {
+ it('should pass through currentPage', () => {
+ const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
+ expect(result.current.currentPage).toBe(5)
+ })
+
+ it('should pass through setCurrentPage', () => {
+ const setCurrentPage = vi.fn()
+ const { result } = renderHook(() => usePagination({ ...defaultProps, setCurrentPage }))
+ result.current.setCurrentPage(3)
+ expect(setCurrentPage).toHaveBeenCalledWith(3)
+ })
+
+ it('should pass through truncableText', () => {
+ const { result } = renderHook(() => usePagination({ ...defaultProps, truncableText: '…' }))
+ expect(result.current.truncableText).toBe('…')
+ })
+
+ it('should pass through truncableClassName', () => {
+ const { result } = renderHook(() => usePagination({ ...defaultProps, truncableClassName: 'custom-trunc' }))
+ expect(result.current.truncableClassName).toBe('custom-trunc')
+ })
+
+ it('should use default truncableText', () => {
+ const { currentPage, setCurrentPage, totalPages, edgePageCount, middlePagesSiblingCount } = defaultProps
+ const { result } = renderHook(() => usePagination({ currentPage, setCurrentPage, totalPages, edgePageCount, middlePagesSiblingCount }))
+ expect(result.current.truncableText).toBe('...')
+ })
+ })
+})
diff --git a/web/app/components/base/pagination/index.spec.tsx b/web/app/components/base/pagination/index.spec.tsx
new file mode 100644
index 0000000000..ef924c290b
--- /dev/null
+++ b/web/app/components/base/pagination/index.spec.tsx
@@ -0,0 +1,242 @@
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import CustomizedPagination from './index'
+
+describe('CustomizedPagination', () => {
+ const defaultProps = {
+ current: 0,
+ onChange: vi.fn(),
+ total: 100,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.useRealTimers()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const { container } = render(
)
+ expect(container).toBeInTheDocument()
+ })
+
+ it('should display current page and total pages', () => {
+ render(
)
+ // current + 1 = 1, totalPages = 10
+ // The page info display shows "1 / 10" and page buttons also show numbers
+ expect(screen.getByText('/')).toBeInTheDocument()
+ expect(screen.getAllByText('1').length).toBeGreaterThanOrEqual(1)
+ })
+
+ it('should render prev and next buttons', () => {
+ render(
)
+ const buttons = screen.getAllByRole('button')
+ expect(buttons.length).toBeGreaterThanOrEqual(2)
+ })
+
+ it('should render page number buttons', () => {
+ render(
)
+ // 5 pages total, should see page numbers
+ expect(screen.getByText('2')).toBeInTheDocument()
+ expect(screen.getByText('3')).toBeInTheDocument()
+ })
+
+ it('should display slash separator between current page and total', () => {
+ render(
)
+ expect(screen.getByText('/')).toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should apply custom className', () => {
+ const { container } = render(
)
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('my-custom')
+ })
+
+ it('should default limit to 10', () => {
+ render(
)
+ // totalPages = 100 / 10 = 10, displayed in the page info area
+ expect(screen.getAllByText('10').length).toBeGreaterThanOrEqual(1)
+ })
+
+ it('should calculate total pages based on custom limit', () => {
+ render(
)
+ // totalPages = 100 / 25 = 4, displayed in the page info area
+ expect(screen.getAllByText('4').length).toBeGreaterThanOrEqual(1)
+ })
+
+ it('should disable prev button on first page', () => {
+ render(
)
+ const buttons = screen.getAllByRole('button')
+ // First button is prev
+ expect(buttons[0]).toBeDisabled()
+ })
+
+ it('should disable next button on last page', () => {
+ render(
)
+ const buttons = screen.getAllByRole('button')
+ // Last button is next
+ expect(buttons[buttons.length - 1]).toBeDisabled()
+ })
+
+ it('should not render limit selector when onLimitChange is not provided', () => {
+ render(
)
+ expect(screen.queryByText(/common\.pagination\.perPage/i)).not.toBeInTheDocument()
+ })
+
+ it('should render limit selector when onLimitChange is provided', () => {
+ const onLimitChange = vi.fn()
+ render(
)
+ // Should show limit options 10, 25, 50
+ expect(screen.getByText('25')).toBeInTheDocument()
+ expect(screen.getByText('50')).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onChange when next button is clicked', () => {
+ const onChange = vi.fn()
+ render(
)
+ const buttons = screen.getAllByRole('button')
+ const nextButton = buttons[buttons.length - 1]
+ fireEvent.click(nextButton)
+ expect(onChange).toHaveBeenCalledWith(1)
+ })
+
+ it('should call onChange when prev button is clicked', () => {
+ const onChange = vi.fn()
+ render(
)
+ const buttons = screen.getAllByRole('button')
+ fireEvent.click(buttons[0])
+ expect(onChange).toHaveBeenCalledWith(4)
+ })
+
+ it('should show input when page display is clicked', () => {
+ render(
)
+ // Click the current page display (the div containing "1 / 10")
+ fireEvent.click(screen.getByText('/'))
+ // Input should appear
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ })
+
+ it('should navigate to entered page on Enter key', () => {
+ vi.useFakeTimers()
+ const onChange = vi.fn()
+ render(
)
+ fireEvent.click(screen.getByText('/'))
+ const input = screen.getByRole('textbox')
+ fireEvent.change(input, { target: { value: '5' } })
+ fireEvent.keyDown(input, { key: 'Enter' })
+ act(() => {
+ vi.advanceTimersByTime(500)
+ })
+ expect(onChange).toHaveBeenCalledWith(4) // 0-indexed
+ })
+
+ it('should cancel input on Escape key', () => {
+ render(
)
+ fireEvent.click(screen.getByText('/'))
+ const input = screen.getByRole('textbox')
+ fireEvent.keyDown(input, { key: 'Escape' })
+ // Input should be hidden and page display should return
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
+ expect(screen.getByText('/')).toBeInTheDocument()
+ })
+
+ it('should confirm input on blur', () => {
+ vi.useFakeTimers()
+ const onChange = vi.fn()
+ render(
)
+ fireEvent.click(screen.getByText('/'))
+ const input = screen.getByRole('textbox')
+ fireEvent.change(input, { target: { value: '3' } })
+ fireEvent.blur(input)
+ act(() => {
+ vi.advanceTimersByTime(500)
+ })
+ expect(onChange).toHaveBeenCalledWith(2) // 0-indexed
+ })
+
+ it('should clamp page to max when input exceeds total pages', () => {
+ vi.useFakeTimers()
+ const onChange = vi.fn()
+ render(
)
+ fireEvent.click(screen.getByText('/'))
+ const input = screen.getByRole('textbox')
+ fireEvent.change(input, { target: { value: '999' } })
+ fireEvent.keyDown(input, { key: 'Enter' })
+ act(() => {
+ vi.advanceTimersByTime(500)
+ })
+ expect(onChange).toHaveBeenCalledWith(9) // last page (0-indexed)
+ })
+
+ it('should clamp page to min when input is less than 1', () => {
+ vi.useFakeTimers()
+ const onChange = vi.fn()
+ render(
)
+ fireEvent.click(screen.getByText('/'))
+ const input = screen.getByRole('textbox')
+ fireEvent.change(input, { target: { value: '0' } })
+ fireEvent.keyDown(input, { key: 'Enter' })
+ act(() => {
+ vi.advanceTimersByTime(500)
+ })
+ expect(onChange).toHaveBeenCalledWith(0)
+ })
+
+ it('should ignore non-numeric input', () => {
+ render(
)
+ fireEvent.click(screen.getByText('/'))
+ const input = screen.getByRole('textbox')
+ fireEvent.change(input, { target: { value: 'abc' } })
+ expect(input).toHaveValue('')
+ })
+
+ it('should call onLimitChange when limit option is clicked', () => {
+ const onLimitChange = vi.fn()
+ render(
)
+ fireEvent.click(screen.getByText('25'))
+ expect(onLimitChange).toHaveBeenCalledWith(25)
+ })
+
+ it('should call onLimitChange with 50 when 50 option is clicked', () => {
+ const onLimitChange = vi.fn()
+ render(
)
+ fireEvent.click(screen.getByText('50'))
+ expect(onLimitChange).toHaveBeenCalledWith(50)
+ })
+
+ it('should call onChange when a page button is clicked', () => {
+ const onChange = vi.fn()
+ render(
)
+ fireEvent.click(screen.getByText('3'))
+ expect(onChange).toHaveBeenCalledWith(2) // 0-indexed
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle total of 0', () => {
+ const { container } = render(
)
+ expect(container).toBeInTheDocument()
+ })
+
+ it('should handle single page', () => {
+ render(
)
+ // totalPages = 1, both buttons should be disabled
+ const buttons = screen.getAllByRole('button')
+ expect(buttons[0]).toBeDisabled()
+ expect(buttons[buttons.length - 1]).toBeDisabled()
+ })
+
+ it('should restore input value when blurred with empty value', () => {
+ render(
)
+ fireEvent.click(screen.getByText('/'))
+ const input = screen.getByRole('textbox')
+ fireEvent.change(input, { target: { value: '' } })
+ fireEvent.blur(input)
+ // Should close input without calling onChange, restoring to current + 1
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/base/pagination/pagination.spec.tsx b/web/app/components/base/pagination/pagination.spec.tsx
new file mode 100644
index 0000000000..2374f8257a
--- /dev/null
+++ b/web/app/components/base/pagination/pagination.spec.tsx
@@ -0,0 +1,376 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { Pagination } from './pagination'
+
+// Helper to render Pagination with common defaults
+function renderPagination({
+ currentPage = 0,
+ totalPages = 10,
+ setCurrentPage = vi.fn(),
+ edgePageCount = 2,
+ middlePagesSiblingCount = 1,
+ truncableText = '...',
+ truncableClassName = 'truncable',
+ children,
+}: {
+ currentPage?: number
+ totalPages?: number
+ setCurrentPage?: (page: number) => void
+ edgePageCount?: number
+ middlePagesSiblingCount?: number
+ truncableText?: string
+ truncableClassName?: string
+ children?: React.ReactNode
+} = {}) {
+ return render(
+
+ {children}
+ ,
+ )
+}
+
+describe('Pagination', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const { container } = renderPagination()
+ expect(container).toBeInTheDocument()
+ })
+
+ it('should render children', () => {
+ renderPagination({ children:
child content })
+ expect(screen.getByText(/child content/i)).toBeInTheDocument()
+ })
+
+ it('should apply className to wrapper div', () => {
+ const { container } = render(
+
+ test
+ ,
+ )
+ expect(container.firstChild).toHaveClass('my-pagination')
+ })
+
+ it('should apply data-testid when provided', () => {
+ render(
+
+ test
+ ,
+ )
+ expect(screen.getByTestId('my-pagination')).toBeInTheDocument()
+ })
+ })
+
+ describe('PrevButton', () => {
+ it('should render prev button', () => {
+ renderPagination({
+ currentPage: 3,
+ children:
Prev ,
+ })
+ expect(screen.getByText(/prev/i)).toBeInTheDocument()
+ })
+
+ it('should call setCurrentPage with previous page when clicked', () => {
+ const setCurrentPage = vi.fn()
+ renderPagination({
+ currentPage: 3,
+ setCurrentPage,
+ children:
Prev ,
+ })
+ fireEvent.click(screen.getByText(/prev/i))
+ expect(setCurrentPage).toHaveBeenCalledWith(2)
+ })
+
+ it('should not navigate below page 0', () => {
+ const setCurrentPage = vi.fn()
+ renderPagination({
+ currentPage: 0,
+ setCurrentPage,
+ children:
Prev ,
+ })
+ fireEvent.click(screen.getByText(/prev/i))
+ expect(setCurrentPage).not.toHaveBeenCalled()
+ })
+
+ it('should be disabled on first page', () => {
+ renderPagination({
+ currentPage: 0,
+ children:
Prev ,
+ })
+ expect(screen.getByText(/prev/i).closest('button')).toBeDisabled()
+ })
+
+ it('should navigate on Enter key press', () => {
+ const setCurrentPage = vi.fn()
+ renderPagination({
+ currentPage: 3,
+ setCurrentPage,
+ children:
Prev ,
+ })
+ fireEvent.keyPress(screen.getByText(/prev/i), { key: 'Enter', charCode: 13 })
+ expect(setCurrentPage).toHaveBeenCalledWith(2)
+ })
+
+ it('should not navigate on Enter when disabled', () => {
+ const setCurrentPage = vi.fn()
+ renderPagination({
+ currentPage: 0,
+ setCurrentPage,
+ children:
Prev ,
+ })
+ fireEvent.keyPress(screen.getByText(/prev/i), { key: 'Enter', charCode: 13 })
+ expect(setCurrentPage).not.toHaveBeenCalled()
+ })
+
+ it('should render with custom as element', () => {
+ renderPagination({
+ currentPage: 3,
+ children:
}>Prev,
+ })
+ expect(screen.getByText(/prev/i)).toBeInTheDocument()
+ })
+
+ it('should apply dataTestId', () => {
+ renderPagination({
+ currentPage: 3,
+ children:
Prev ,
+ })
+ expect(screen.getByTestId('prev-btn')).toBeInTheDocument()
+ })
+ })
+
+ describe('NextButton', () => {
+ it('should render next button', () => {
+ renderPagination({
+ currentPage: 0,
+ children:
Next ,
+ })
+ expect(screen.getByText(/next/i)).toBeInTheDocument()
+ })
+
+ it('should call setCurrentPage with next page when clicked', () => {
+ const setCurrentPage = vi.fn()
+ renderPagination({
+ currentPage: 0,
+ totalPages: 10,
+ setCurrentPage,
+ children:
Next ,
+ })
+ fireEvent.click(screen.getByText(/next/i))
+ expect(setCurrentPage).toHaveBeenCalledWith(1)
+ })
+
+ it('should not navigate beyond last page', () => {
+ const setCurrentPage = vi.fn()
+ renderPagination({
+ currentPage: 9,
+ totalPages: 10,
+ setCurrentPage,
+ children:
Next ,
+ })
+ fireEvent.click(screen.getByText(/next/i))
+ expect(setCurrentPage).not.toHaveBeenCalled()
+ })
+
+ it('should be disabled on last page', () => {
+ renderPagination({
+ currentPage: 9,
+ totalPages: 10,
+ children:
Next ,
+ })
+ expect(screen.getByText(/next/i).closest('button')).toBeDisabled()
+ })
+
+ it('should navigate on Enter key press', () => {
+ const setCurrentPage = vi.fn()
+ renderPagination({
+ currentPage: 0,
+ totalPages: 10,
+ setCurrentPage,
+ children:
Next ,
+ })
+ fireEvent.keyPress(screen.getByText(/next/i), { key: 'Enter', charCode: 13 })
+ expect(setCurrentPage).toHaveBeenCalledWith(1)
+ })
+
+ it('should not navigate on Enter when disabled', () => {
+ const setCurrentPage = vi.fn()
+ renderPagination({
+ currentPage: 9,
+ totalPages: 10,
+ setCurrentPage,
+ children:
Next ,
+ })
+ fireEvent.keyPress(screen.getByText(/next/i), { key: 'Enter', charCode: 13 })
+ expect(setCurrentPage).not.toHaveBeenCalled()
+ })
+
+ it('should apply dataTestId', () => {
+ renderPagination({
+ currentPage: 0,
+ children:
Next ,
+ })
+ expect(screen.getByTestId('next-btn')).toBeInTheDocument()
+ })
+ })
+
+ describe('PageButton', () => {
+ it('should render page number buttons', () => {
+ renderPagination({
+ currentPage: 0,
+ totalPages: 5,
+ children: (
+
+ ),
+ })
+ expect(screen.getByText('1')).toBeInTheDocument()
+ expect(screen.getByText('5')).toBeInTheDocument()
+ })
+
+ it('should apply activeClassName to current page', () => {
+ renderPagination({
+ currentPage: 2,
+ totalPages: 5,
+ children: (
+
+ ),
+ })
+ // current page is 2, so page 3 (1-indexed) should be active
+ expect(screen.getByText('3').closest('a')).toHaveClass('active')
+ })
+
+ it('should apply inactiveClassName to non-current pages', () => {
+ renderPagination({
+ currentPage: 2,
+ totalPages: 5,
+ children: (
+
+ ),
+ })
+ expect(screen.getByText('1').closest('a')).toHaveClass('inactive')
+ })
+
+ it('should call setCurrentPage when a page button is clicked', () => {
+ const setCurrentPage = vi.fn()
+ renderPagination({
+ currentPage: 0,
+ totalPages: 5,
+ setCurrentPage,
+ children: (
+
+ ),
+ })
+ fireEvent.click(screen.getByText('3'))
+ expect(setCurrentPage).toHaveBeenCalledWith(2) // 0-indexed
+ })
+
+ it('should navigate on Enter key press on a page button', () => {
+ const setCurrentPage = vi.fn()
+ renderPagination({
+ currentPage: 0,
+ totalPages: 5,
+ setCurrentPage,
+ children: (
+
+ ),
+ })
+ fireEvent.keyPress(screen.getByText('4'), { key: 'Enter', charCode: 13 })
+ expect(setCurrentPage).toHaveBeenCalledWith(3) // 0-indexed
+ })
+
+ it('should render truncable text when pages are truncated', () => {
+ renderPagination({
+ currentPage: 5,
+ totalPages: 20,
+ edgePageCount: 2,
+ middlePagesSiblingCount: 1,
+ truncableText: '...',
+ children: (
+
+ ),
+ })
+ // With 20 pages and current at 5, there should be truncation
+ expect(screen.getAllByText('...').length).toBeGreaterThanOrEqual(1)
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle single page', () => {
+ const setCurrentPage = vi.fn()
+ renderPagination({
+ currentPage: 0,
+ totalPages: 1,
+ setCurrentPage,
+ children: (
+ <>
+
Prev
+
+
Next
+ >
+ ),
+ })
+ expect(screen.getByText(/prev/i).closest('button')).toBeDisabled()
+ expect(screen.getByText(/next/i).closest('button')).toBeDisabled()
+ expect(screen.getByText('1')).toBeInTheDocument()
+ })
+
+ it('should handle zero total pages', () => {
+ const { container } = renderPagination({
+ currentPage: 0,
+ totalPages: 0,
+ children: (
+
+ ),
+ })
+ expect(container).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/base/theme-selector.spec.tsx b/web/app/components/base/theme-selector.spec.tsx
new file mode 100644
index 0000000000..8cd0028acf
--- /dev/null
+++ b/web/app/components/base/theme-selector.spec.tsx
@@ -0,0 +1,103 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import ThemeSelector from './theme-selector'
+
+// Mock next-themes with controllable state
+let mockTheme = 'system'
+const mockSetTheme = vi.fn()
+vi.mock('next-themes', () => ({
+ useTheme: () => ({
+ theme: mockTheme,
+ setTheme: mockSetTheme,
+ }),
+}))
+
+describe('ThemeSelector', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockTheme = 'system'
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const { container } = render(
)
+ expect(container).toBeInTheDocument()
+ })
+
+ it('should render the trigger button', () => {
+ render(
)
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should not show dropdown content when closed', () => {
+ render(
)
+ expect(screen.queryByText(/common\.theme\.light/i)).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should show all theme options when dropdown is opened', () => {
+ render(
)
+ fireEvent.click(screen.getByRole('button'))
+ expect(screen.getByText(/light/i)).toBeInTheDocument()
+ expect(screen.getByText(/dark/i)).toBeInTheDocument()
+ expect(screen.getByText(/auto/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call setTheme with light when light option is clicked', () => {
+ render(
)
+ fireEvent.click(screen.getByRole('button'))
+ const lightButton = screen.getByText(/light/i).closest('button')!
+ fireEvent.click(lightButton)
+ expect(mockSetTheme).toHaveBeenCalledWith('light')
+ })
+
+ it('should call setTheme with dark when dark option is clicked', () => {
+ render(
)
+ fireEvent.click(screen.getByRole('button'))
+ const darkButton = screen.getByText(/dark/i).closest('button')!
+ fireEvent.click(darkButton)
+ expect(mockSetTheme).toHaveBeenCalledWith('dark')
+ })
+
+ it('should call setTheme with system when system option is clicked', () => {
+ render(
)
+ fireEvent.click(screen.getByRole('button'))
+ const systemButton = screen.getByText(/auto/i).closest('button')!
+ fireEvent.click(systemButton)
+ expect(mockSetTheme).toHaveBeenCalledWith('system')
+ })
+ })
+
+ describe('Theme-specific rendering', () => {
+ it('should show checkmark for the currently active light theme', () => {
+ mockTheme = 'light'
+ render(
)
+ fireEvent.click(screen.getByRole('button'))
+ expect(screen.getByTestId('light-icon')).toBeInTheDocument()
+ })
+
+ it('should show checkmark for the currently active dark theme', () => {
+ mockTheme = 'dark'
+ render(
)
+ fireEvent.click(screen.getByRole('button'))
+ expect(screen.getByTestId('dark-icon')).toBeInTheDocument()
+ })
+
+ it('should show checkmark for the currently active system theme', () => {
+ mockTheme = 'system'
+ render(
)
+ fireEvent.click(screen.getByRole('button'))
+ expect(screen.getByTestId('system-icon')).toBeInTheDocument()
+ })
+
+ it('should not show checkmark on non-active themes', () => {
+ mockTheme = 'light'
+ render(
)
+ fireEvent.click(screen.getByRole('button'))
+ expect(screen.queryByTestId('dark-icon')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('system-icon')).not.toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/base/theme-selector.tsx b/web/app/components/base/theme-selector.tsx
index 8869407057..49fdfb4390 100644
--- a/web/app/components/base/theme-selector.tsx
+++ b/web/app/components/base/theme-selector.tsx
@@ -1,11 +1,5 @@
'use client'
-import {
- RiCheckLine,
- RiComputerLine,
- RiMoonLine,
- RiSunLine,
-} from '@remixicon/react'
import { useTheme } from 'next-themes'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -30,9 +24,9 @@ export default function ThemeSelector() {
const getCurrentIcon = () => {
switch (theme) {
- case 'light': return
- case 'dark': return
- default: return
+ case 'light': return
+ case 'dark': return
+ default: return
}
}
@@ -59,13 +53,13 @@ export default function ThemeSelector() {
className="flex w-full items-center gap-1 rounded-lg px-2 py-1.5 text-text-secondary hover:bg-state-base-hover"
onClick={() => handleThemeChange('light')}
>
-
+
{t('theme.light', { ns: 'common' })}
{theme === 'light' && (
-
+
)}
@@ -74,13 +68,13 @@ export default function ThemeSelector() {
className="flex w-full items-center gap-1 rounded-lg px-2 py-1.5 text-text-secondary hover:bg-state-base-hover"
onClick={() => handleThemeChange('dark')}
>
-
+
{t('theme.dark', { ns: 'common' })}
{theme === 'dark' && (
-
+
)}
@@ -89,13 +83,13 @@ export default function ThemeSelector() {
className="flex w-full items-center gap-1 rounded-lg px-2 py-1.5 text-text-secondary hover:bg-state-base-hover"
onClick={() => handleThemeChange('system')}
>
-
+
{t('theme.auto', { ns: 'common' })}
{theme === 'system' && (
-
+
)}
diff --git a/web/app/components/base/theme-switcher.spec.tsx b/web/app/components/base/theme-switcher.spec.tsx
new file mode 100644
index 0000000000..e19fbd3835
--- /dev/null
+++ b/web/app/components/base/theme-switcher.spec.tsx
@@ -0,0 +1,106 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import ThemeSwitcher from './theme-switcher'
+
+let mockTheme = 'system'
+const mockSetTheme = vi.fn()
+vi.mock('next-themes', () => ({
+ useTheme: () => ({
+ theme: mockTheme,
+ setTheme: mockSetTheme,
+ }),
+}))
+
+describe('ThemeSwitcher', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockTheme = 'system'
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const { container } = render(
)
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render three theme option buttons', () => {
+ render(
)
+ expect(screen.getByTestId('system-theme-container')).toBeInTheDocument()
+ expect(screen.getByTestId('light-theme-container')).toBeInTheDocument()
+ expect(screen.getByTestId('dark-theme-container')).toBeInTheDocument()
+ })
+
+ it('should render two dividers between options', () => {
+ render(
)
+ const dividers = screen.getAllByTestId('divider')
+ expect(dividers).toHaveLength(2)
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call setTheme with system when system option is clicked', () => {
+ render(
)
+ fireEvent.click(screen.getByTestId('system-theme-container')) // system is first
+ expect(mockSetTheme).toHaveBeenCalledWith('system')
+ })
+
+ it('should call setTheme with light when light option is clicked', () => {
+ render(
)
+ fireEvent.click(screen.getByTestId('light-theme-container')) // light is second
+ expect(mockSetTheme).toHaveBeenCalledWith('light')
+ })
+
+ it('should call setTheme with dark when dark option is clicked', () => {
+ render(
)
+ fireEvent.click(screen.getByTestId('dark-theme-container')) // dark is third
+ expect(mockSetTheme).toHaveBeenCalledWith('dark')
+ })
+ })
+
+ describe('Theme-specific rendering', () => {
+ it('should highlight system option when theme is system', () => {
+ mockTheme = 'system'
+ render(
)
+ expect(screen.getByTestId('system-theme-container')).toHaveClass('bg-components-segmented-control-item-active-bg')
+ expect(screen.getByTestId('light-theme-container')).not.toHaveClass('bg-components-segmented-control-item-active-bg')
+ expect(screen.getByTestId('dark-theme-container')).not.toHaveClass('bg-components-segmented-control-item-active-bg')
+ })
+
+ it('should highlight light option when theme is light', () => {
+ mockTheme = 'light'
+ render(
)
+ expect(screen.getByTestId('light-theme-container')).toHaveClass('bg-components-segmented-control-item-active-bg')
+ expect(screen.getByTestId('system-theme-container')).not.toHaveClass('bg-components-segmented-control-item-active-bg')
+ expect(screen.getByTestId('dark-theme-container')).not.toHaveClass('bg-components-segmented-control-item-active-bg')
+ })
+
+ it('should highlight dark option when theme is dark', () => {
+ mockTheme = 'dark'
+ render(
)
+ expect(screen.getByTestId('dark-theme-container')).toHaveClass('bg-components-segmented-control-item-active-bg')
+ expect(screen.getByTestId('system-theme-container')).not.toHaveClass('bg-components-segmented-control-item-active-bg')
+ expect(screen.getByTestId('light-theme-container')).not.toHaveClass('bg-components-segmented-control-item-active-bg')
+ })
+
+ it('should show divider between system and light when dark is active', () => {
+ mockTheme = 'dark'
+ render(
)
+ const dividers = screen.getAllByTestId('divider')
+ expect(dividers[0]).toHaveClass('bg-divider-regular')
+ })
+
+ it('should show divider between light and dark when system is active', () => {
+ mockTheme = 'system'
+ render(
)
+ const dividers = screen.getAllByTestId('divider')
+ expect(dividers[1]).toHaveClass('bg-divider-regular')
+ })
+
+ it('should have transparent dividers when neither adjacent theme is active', () => {
+ mockTheme = 'light'
+ render(
)
+ const dividers = screen.getAllByTestId('divider')
+ expect(dividers[0]).not.toHaveClass('bg-divider-regular')
+ expect(dividers[1]).not.toHaveClass('bg-divider-regular')
+ })
+ })
+})
diff --git a/web/app/components/base/theme-switcher.tsx b/web/app/components/base/theme-switcher.tsx
index d223ff738e..86e24a443c 100644
--- a/web/app/components/base/theme-switcher.tsx
+++ b/web/app/components/base/theme-switcher.tsx
@@ -1,9 +1,4 @@
'use client'
-import {
- RiComputerLine,
- RiMoonLine,
- RiSunLine,
-} from '@remixicon/react'
import { useTheme } from 'next-themes'
import { cn } from '@/utils/classnames'
@@ -24,33 +19,36 @@ export default function ThemeSwitcher() {
theme === 'system' && 'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-sm hover:bg-components-segmented-control-item-active-bg hover:text-text-accent-light-mode-only',
)}
onClick={() => handleThemeChange('system')}
+ data-testid="system-theme-container"
>
-
+
-