test: Adding missing tests or correcting existing tests (#29937)

Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
This commit is contained in:
Coding On Star
2025-12-19 17:49:51 +08:00
committed by GitHub
parent 079620714e
commit 39ad9d1569
15 changed files with 17955 additions and 0 deletions

View File

@@ -0,0 +1,659 @@
import { fireEvent, render, screen } from '@testing-library/react'
import React from 'react'
import Header from './header'
import type { DataSourceCredential } from '@/types/pipeline'
// Mock CredentialTypeEnum to avoid deep import chain issues
enum MockCredentialTypeEnum {
OAUTH2 = 'oauth2',
API_KEY = 'api_key',
}
// Mock plugin-auth module to avoid deep import chain issues
jest.mock('@/app/components/plugins/plugin-auth', () => ({
CredentialTypeEnum: {
OAUTH2: 'oauth2',
API_KEY: 'api_key',
},
}))
// Mock portal-to-follow-elem - required for CredentialSelector
jest.mock('@/app/components/base/portal-to-follow-elem', () => {
const MockPortalToFollowElem = ({ children, open }: any) => {
return (
<div data-testid="portal-root" data-open={open}>
{React.Children.map(children, (child: any) => {
if (!child)
return null
return React.cloneElement(child, { __portalOpen: open })
})}
</div>
)
}
const MockPortalToFollowElemTrigger = ({ children, onClick, className, __portalOpen }: any) => (
<div data-testid="portal-trigger" onClick={onClick} className={className} data-open={__portalOpen}>
{children}
</div>
)
const MockPortalToFollowElemContent = ({ children, className, __portalOpen }: any) => {
if (!__portalOpen)
return null
return (
<div data-testid="portal-content" className={className}>
{children}
</div>
)
}
return {
PortalToFollowElem: MockPortalToFollowElem,
PortalToFollowElemTrigger: MockPortalToFollowElemTrigger,
PortalToFollowElemContent: MockPortalToFollowElemContent,
}
})
// ==========================================
// Test Data Builders
// ==========================================
const createMockCredential = (overrides?: Partial<DataSourceCredential>): DataSourceCredential => ({
id: 'cred-1',
name: 'Test Credential',
avatar_url: 'https://example.com/avatar.png',
credential: { key: 'value' },
is_default: false,
type: MockCredentialTypeEnum.OAUTH2 as unknown as DataSourceCredential['type'],
...overrides,
})
const createMockCredentials = (count: number = 3): DataSourceCredential[] =>
Array.from({ length: count }, (_, i) =>
createMockCredential({
id: `cred-${i + 1}`,
name: `Credential ${i + 1}`,
avatar_url: `https://example.com/avatar-${i + 1}.png`,
is_default: i === 0,
}),
)
type HeaderProps = React.ComponentProps<typeof Header>
const createDefaultProps = (overrides?: Partial<HeaderProps>): HeaderProps => ({
docTitle: 'Documentation',
docLink: 'https://docs.example.com',
pluginName: 'Test Plugin',
currentCredentialId: 'cred-1',
onCredentialChange: jest.fn(),
credentials: createMockCredentials(),
...overrides,
})
describe('Header', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// ==========================================
// Rendering Tests
// ==========================================
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText('Documentation')).toBeInTheDocument()
})
it('should render documentation link with correct attributes', () => {
// Arrange
const props = createDefaultProps({
docTitle: 'API Docs',
docLink: 'https://api.example.com/docs',
})
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link', { name: /API Docs/i })
expect(link).toHaveAttribute('href', 'https://api.example.com/docs')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
it('should render document title with title attribute', () => {
// Arrange
const props = createDefaultProps({ docTitle: 'My Documentation' })
// Act
render(<Header {...props} />)
// Assert
const titleSpan = screen.getByText('My Documentation')
expect(titleSpan).toHaveAttribute('title', 'My Documentation')
})
it('should render CredentialSelector with correct props', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert - CredentialSelector should render current credential name
expect(screen.getByText('Credential 1')).toBeInTheDocument()
})
it('should render configuration button', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render book icon in documentation link', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert - RiBookOpenLine renders as SVG
const link = screen.getByRole('link')
const svg = link.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should render divider between credential selector and configuration button', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Header {...props} />)
// Assert - Divider component should be rendered
// Divider typically renders as a div with specific styling
const divider = container.querySelector('[class*="divider"]') || container.querySelector('.mx-1.h-3\\.5')
expect(divider).toBeInTheDocument()
})
})
// ==========================================
// Props Testing
// ==========================================
describe('Props', () => {
describe('docTitle prop', () => {
it('should display the document title', () => {
// Arrange
const props = createDefaultProps({ docTitle: 'Getting Started Guide' })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText('Getting Started Guide')).toBeInTheDocument()
})
it.each([
'Quick Start',
'API Reference',
'Configuration Guide',
'Plugin Documentation',
])('should display "%s" as document title', (title) => {
// Arrange
const props = createDefaultProps({ docTitle: title })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText(title)).toBeInTheDocument()
})
})
describe('docLink prop', () => {
it('should set correct href on documentation link', () => {
// Arrange
const props = createDefaultProps({ docLink: 'https://custom.docs.com/guide' })
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link')
expect(link).toHaveAttribute('href', 'https://custom.docs.com/guide')
})
it.each([
'https://docs.dify.ai',
'https://example.com/api',
'/local/docs',
])('should accept "%s" as docLink', (link) => {
// Arrange
const props = createDefaultProps({ docLink: link })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByRole('link')).toHaveAttribute('href', link)
})
})
describe('pluginName prop', () => {
it('should pass pluginName to translation function', () => {
// Arrange
const props = createDefaultProps({ pluginName: 'MyPlugin' })
// Act
render(<Header {...props} />)
// Assert - The translation mock returns the key with options
// Tooltip uses the translated content
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('onClickConfiguration prop', () => {
it('should call onClickConfiguration when configuration icon is clicked', () => {
// Arrange
const mockOnClick = jest.fn()
const props = createDefaultProps({ onClickConfiguration: mockOnClick })
render(<Header {...props} />)
// Act - Find the configuration button and click the icon inside
// The button contains the RiEqualizer2Line icon with onClick handler
const configButton = screen.getByRole('button')
const configIcon = configButton.querySelector('svg')
expect(configIcon).toBeInTheDocument()
fireEvent.click(configIcon!)
// Assert
expect(mockOnClick).toHaveBeenCalledTimes(1)
})
it('should not crash when onClickConfiguration is undefined', () => {
// Arrange
const props = createDefaultProps({ onClickConfiguration: undefined })
render(<Header {...props} />)
// Act - Find the configuration button and click the icon inside
const configButton = screen.getByRole('button')
const configIcon = configButton.querySelector('svg')
expect(configIcon).toBeInTheDocument()
fireEvent.click(configIcon!)
// Assert - Component should still be rendered (no crash)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('CredentialSelector props passthrough', () => {
it('should pass currentCredentialId to CredentialSelector', () => {
// Arrange
const props = createDefaultProps({ currentCredentialId: 'cred-2' })
// Act
render(<Header {...props} />)
// Assert - Should display the second credential
expect(screen.getByText('Credential 2')).toBeInTheDocument()
})
it('should pass credentials to CredentialSelector', () => {
// Arrange
const customCredentials = [
createMockCredential({ id: 'custom-1', name: 'Custom Credential' }),
]
const props = createDefaultProps({
credentials: customCredentials,
currentCredentialId: 'custom-1',
})
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText('Custom Credential')).toBeInTheDocument()
})
it('should pass onCredentialChange to CredentialSelector', () => {
// Arrange
const mockOnChange = jest.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange })
render(<Header {...props} />)
// Act - Open dropdown and select a credential
// Use getAllByTestId and select the first one (CredentialSelector's trigger)
const triggers = screen.getAllByTestId('portal-trigger')
fireEvent.click(triggers[0])
const credential2 = screen.getByText('Credential 2')
fireEvent.click(credential2)
// Assert
expect(mockOnChange).toHaveBeenCalledWith('cred-2')
})
})
})
// ==========================================
// User Interactions
// ==========================================
describe('User Interactions', () => {
it('should open external link in new tab when clicking documentation link', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert - Link has target="_blank" for new tab
const link = screen.getByRole('link')
expect(link).toHaveAttribute('target', '_blank')
})
it('should allow credential selection through CredentialSelector', () => {
// Arrange
const mockOnChange = jest.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange })
render(<Header {...props} />)
// Act - Open dropdown (use first trigger which is CredentialSelector's)
const triggers = screen.getAllByTestId('portal-trigger')
fireEvent.click(triggers[0])
// Assert - Dropdown should be open
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
})
it('should trigger configuration callback when clicking config icon', () => {
// Arrange
const mockOnConfig = jest.fn()
const props = createDefaultProps({ onClickConfiguration: mockOnConfig })
const { container } = render(<Header {...props} />)
// Act
const configIcon = container.querySelector('.h-4.w-4')
fireEvent.click(configIcon!)
// Assert
expect(mockOnConfig).toHaveBeenCalled()
})
})
// ==========================================
// Component Memoization
// ==========================================
describe('Component Memoization', () => {
it('should be wrapped with React.memo', () => {
// Assert
expect(Header.$$typeof).toBe(Symbol.for('react.memo'))
})
it('should not re-render when props remain the same', () => {
// Arrange
const props = createDefaultProps()
const renderSpy = jest.fn()
const TrackedHeader: React.FC<HeaderProps> = (trackedProps) => {
renderSpy()
return <Header {...trackedProps} />
}
const MemoizedTracked = React.memo(TrackedHeader)
// Act
const { rerender } = render(<MemoizedTracked {...props} />)
rerender(<MemoizedTracked {...props} />)
// Assert - Should only render once due to same props
expect(renderSpy).toHaveBeenCalledTimes(1)
})
it('should re-render when docTitle changes', () => {
// Arrange
const props = createDefaultProps({ docTitle: 'Original Title' })
const { rerender } = render(<Header {...props} />)
// Assert initial
expect(screen.getByText('Original Title')).toBeInTheDocument()
// Act
rerender(<Header {...props} docTitle="Updated Title" />)
// Assert
expect(screen.getByText('Updated Title')).toBeInTheDocument()
})
it('should re-render when currentCredentialId changes', () => {
// Arrange
const props = createDefaultProps({ currentCredentialId: 'cred-1' })
const { rerender } = render(<Header {...props} />)
// Assert initial
expect(screen.getByText('Credential 1')).toBeInTheDocument()
// Act
rerender(<Header {...props} currentCredentialId="cred-2" />)
// Assert
expect(screen.getByText('Credential 2')).toBeInTheDocument()
})
})
// ==========================================
// Edge Cases
// ==========================================
describe('Edge Cases', () => {
it('should handle empty docTitle', () => {
// Arrange
const props = createDefaultProps({ docTitle: '' })
// Act
render(<Header {...props} />)
// Assert - Should render without crashing
const link = screen.getByRole('link')
expect(link).toBeInTheDocument()
})
it('should handle very long docTitle', () => {
// Arrange
const longTitle = 'A'.repeat(200)
const props = createDefaultProps({ docTitle: longTitle })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText(longTitle)).toBeInTheDocument()
})
it('should handle special characters in docTitle', () => {
// Arrange
const specialTitle = 'Docs & Guide <v2> "Special"'
const props = createDefaultProps({ docTitle: specialTitle })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText(specialTitle)).toBeInTheDocument()
})
it('should handle empty credentials array', () => {
// Arrange
const props = createDefaultProps({
credentials: [],
currentCredentialId: '',
})
// Act
render(<Header {...props} />)
// Assert - Should render without crashing
expect(screen.getByRole('link')).toBeInTheDocument()
})
it('should handle special characters in pluginName', () => {
// Arrange
const props = createDefaultProps({ pluginName: 'Plugin & Tool <v1>' })
// Act
render(<Header {...props} />)
// Assert - Should render without crashing
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle unicode characters in docTitle', () => {
// Arrange
const props = createDefaultProps({ docTitle: '文档说明 📚' })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText('文档说明 📚')).toBeInTheDocument()
})
})
// ==========================================
// Styling
// ==========================================
describe('Styling', () => {
it('should apply correct classes to container', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Header {...props} />)
// Assert
const rootDiv = container.firstChild as HTMLElement
expect(rootDiv).toHaveClass('flex', 'items-center', 'justify-between', 'gap-x-2')
})
it('should apply correct classes to documentation link', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link')
expect(link).toHaveClass('system-xs-medium', 'text-text-accent')
})
it('should apply shrink-0 to documentation link', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link')
expect(link).toHaveClass('shrink-0')
})
})
// ==========================================
// Integration Tests
// ==========================================
describe('Integration', () => {
it('should work with full credential workflow', () => {
// Arrange
const mockOnCredentialChange = jest.fn()
const props = createDefaultProps({
onCredentialChange: mockOnCredentialChange,
currentCredentialId: 'cred-1',
})
render(<Header {...props} />)
// Assert initial state
expect(screen.getByText('Credential 1')).toBeInTheDocument()
// Act - Open dropdown and select different credential
// Use first trigger which is CredentialSelector's
const triggers = screen.getAllByTestId('portal-trigger')
fireEvent.click(triggers[0])
const credential3 = screen.getByText('Credential 3')
fireEvent.click(credential3)
// Assert
expect(mockOnCredentialChange).toHaveBeenCalledWith('cred-3')
})
it('should display all components together correctly', () => {
// Arrange
const mockOnConfig = jest.fn()
const props = createDefaultProps({
docTitle: 'Integration Test Docs',
docLink: 'https://test.com/docs',
pluginName: 'TestPlugin',
onClickConfiguration: mockOnConfig,
})
// Act
render(<Header {...props} />)
// Assert - All main elements present
expect(screen.getByText('Credential 1')).toBeInTheDocument() // CredentialSelector
expect(screen.getByRole('button')).toBeInTheDocument() // Config button
expect(screen.getByText('Integration Test Docs')).toBeInTheDocument() // Doc link
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://test.com/docs')
})
})
// ==========================================
// Accessibility
// ==========================================
describe('Accessibility', () => {
it('should have accessible link', () => {
// Arrange
const props = createDefaultProps({ docTitle: 'Accessible Docs' })
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link', { name: /Accessible Docs/i })
expect(link).toBeInTheDocument()
})
it('should have accessible button for configuration', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})
it('should have noopener noreferrer for security on external links', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
})
})

View File

@@ -0,0 +1,622 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Connect from './index'
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
// ==========================================
// Mock Modules
// ==========================================
// Note: react-i18next uses global mock from web/__mocks__/react-i18next.ts
// Mock useToolIcon - hook has complex dependencies (API calls, stores)
const mockUseToolIcon = jest.fn()
jest.mock('@/app/components/workflow/hooks', () => ({
useToolIcon: (data: any) => mockUseToolIcon(data),
}))
// ==========================================
// Test Data Builders
// ==========================================
const createMockNodeData = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => ({
title: 'Test Node',
plugin_id: 'plugin-123',
provider_type: 'online_drive',
provider_name: 'online-drive-provider',
datasource_name: 'online-drive-ds',
datasource_label: 'Online Drive',
datasource_parameters: {},
datasource_configurations: {},
...overrides,
} as DataSourceNodeType)
type ConnectProps = React.ComponentProps<typeof Connect>
const createDefaultProps = (overrides?: Partial<ConnectProps>): ConnectProps => ({
nodeData: createMockNodeData(),
onSetting: jest.fn(),
...overrides,
})
// ==========================================
// Test Suites
// ==========================================
describe('Connect', () => {
beforeEach(() => {
jest.clearAllMocks()
// Default mock return values
mockUseToolIcon.mockReturnValue('https://example.com/icon.png')
})
// ==========================================
// Rendering Tests
// ==========================================
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert - Component should render with connect button
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render the BlockIcon component', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Connect {...props} />)
// Assert - BlockIcon container should exist
const iconContainer = container.querySelector('.size-12')
expect(iconContainer).toBeInTheDocument()
})
it('should render the not connected message with node title', () => {
// Arrange
const props = createDefaultProps({
nodeData: createMockNodeData({ title: 'My Google Drive' }),
})
// Act
render(<Connect {...props} />)
// Assert - Should show translation key with interpolated name (use getAllBy since both messages contain similar text)
const messages = screen.getAllByText(/datasetPipeline\.onlineDrive\.notConnected/)
expect(messages.length).toBeGreaterThanOrEqual(1)
})
it('should render the not connected tip message', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert - Should show tip translation key
expect(screen.getByText(/datasetPipeline\.onlineDrive\.notConnectedTip/)).toBeInTheDocument()
})
it('should render the connect button with correct text', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert - Button should have connect text
const button = screen.getByRole('button')
expect(button).toHaveTextContent('datasetCreation.stepOne.connect')
})
it('should render with primary button variant', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert - Button should be primary variant
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})
it('should render Icon3Dots component', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Connect {...props} />)
// Assert - Icon3Dots should be rendered (it's an SVG element)
const iconElement = container.querySelector('svg')
expect(iconElement).toBeInTheDocument()
})
it('should apply correct container styling', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Connect {...props} />)
// Assert - Container should have expected classes
const mainContainer = container.firstChild
expect(mainContainer).toHaveClass('flex', 'flex-col', 'items-start', 'gap-y-2', 'rounded-xl', 'p-6')
})
})
// ==========================================
// Props Testing
// ==========================================
describe('Props', () => {
describe('nodeData prop', () => {
it('should pass nodeData to useToolIcon hook', () => {
// Arrange
const nodeData = createMockNodeData({ plugin_id: 'my-plugin' })
const props = createDefaultProps({ nodeData })
// Act
render(<Connect {...props} />)
// Assert
expect(mockUseToolIcon).toHaveBeenCalledWith(nodeData)
})
it('should display node title in not connected message', () => {
// Arrange
const props = createDefaultProps({
nodeData: createMockNodeData({ title: 'Dropbox Storage' }),
})
// Act
render(<Connect {...props} />)
// Assert - Translation key should be in document (mock returns key)
const messages = screen.getAllByText(/datasetPipeline\.onlineDrive\.notConnected/)
expect(messages.length).toBeGreaterThanOrEqual(1)
})
it('should display node title in tip message', () => {
// Arrange
const props = createDefaultProps({
nodeData: createMockNodeData({ title: 'OneDrive Connector' }),
})
// Act
render(<Connect {...props} />)
// Assert - Translation key should be in document
expect(screen.getByText(/datasetPipeline\.onlineDrive\.notConnectedTip/)).toBeInTheDocument()
})
it.each([
{ title: 'Google Drive' },
{ title: 'Dropbox' },
{ title: 'OneDrive' },
{ title: 'Amazon S3' },
{ title: '' },
])('should handle nodeData with title=$title', ({ title }) => {
// Arrange
const props = createDefaultProps({
nodeData: createMockNodeData({ title }),
})
// Act
render(<Connect {...props} />)
// Assert - Should render without error
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('onSetting prop', () => {
it('should call onSetting when connect button is clicked', () => {
// Arrange
const mockOnSetting = jest.fn()
const props = createDefaultProps({ onSetting: mockOnSetting })
// Act
render(<Connect {...props} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(mockOnSetting).toHaveBeenCalledTimes(1)
})
it('should call onSetting when button clicked', () => {
// Arrange
const mockOnSetting = jest.fn()
const props = createDefaultProps({ onSetting: mockOnSetting })
// Act
render(<Connect {...props} />)
fireEvent.click(screen.getByRole('button'))
// Assert - onClick handler receives the click event from React
expect(mockOnSetting).toHaveBeenCalled()
expect(mockOnSetting.mock.calls[0]).toBeDefined()
})
it('should call onSetting on each button click', () => {
// Arrange
const mockOnSetting = jest.fn()
const props = createDefaultProps({ onSetting: mockOnSetting })
// Act
render(<Connect {...props} />)
const button = screen.getByRole('button')
fireEvent.click(button)
fireEvent.click(button)
fireEvent.click(button)
// Assert
expect(mockOnSetting).toHaveBeenCalledTimes(3)
})
})
})
// ==========================================
// User Interactions and Event Handlers
// ==========================================
describe('User Interactions', () => {
describe('Connect Button', () => {
it('should trigger onSetting callback on click', () => {
// Arrange
const mockOnSetting = jest.fn()
const props = createDefaultProps({ onSetting: mockOnSetting })
render(<Connect {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
expect(mockOnSetting).toHaveBeenCalled()
})
it('should be interactive and focusable', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
const button = screen.getByRole('button')
// Assert
expect(button).not.toBeDisabled()
})
it('should handle keyboard interaction (Enter key)', () => {
// Arrange
const mockOnSetting = jest.fn()
const props = createDefaultProps({ onSetting: mockOnSetting })
render(<Connect {...props} />)
// Act
const button = screen.getByRole('button')
fireEvent.keyDown(button, { key: 'Enter' })
// Assert - Button should be present and interactive
expect(button).toBeInTheDocument()
})
})
})
// ==========================================
// Hook Integration Tests
// ==========================================
describe('Hook Integration', () => {
describe('useToolIcon', () => {
it('should call useToolIcon with nodeData', () => {
// Arrange
const nodeData = createMockNodeData()
const props = createDefaultProps({ nodeData })
// Act
render(<Connect {...props} />)
// Assert
expect(mockUseToolIcon).toHaveBeenCalledWith(nodeData)
})
it('should use toolIcon result from useToolIcon', () => {
// Arrange
mockUseToolIcon.mockReturnValue('custom-icon-url')
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert - The hook should be called and its return value used
expect(mockUseToolIcon).toHaveBeenCalled()
})
it('should handle empty string icon', () => {
// Arrange
mockUseToolIcon.mockReturnValue('')
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert - Should still render without crashing
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle undefined icon', () => {
// Arrange
mockUseToolIcon.mockReturnValue(undefined)
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert - Should still render without crashing
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('useTranslation', () => {
it('should use correct translation keys for not connected message', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert - Should use the correct translation key (both notConnected and notConnectedTip contain similar pattern)
const messages = screen.getAllByText(/datasetPipeline\.onlineDrive\.notConnected/)
expect(messages.length).toBeGreaterThanOrEqual(1)
})
it('should use correct translation key for tip message', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert
expect(screen.getByText(/datasetPipeline\.onlineDrive\.notConnectedTip/)).toBeInTheDocument()
})
it('should use correct translation key for connect button', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert
expect(screen.getByRole('button')).toHaveTextContent('datasetCreation.stepOne.connect')
})
})
})
// ==========================================
// Edge Cases and Error Handling
// ==========================================
describe('Edge Cases and Error Handling', () => {
describe('Empty/Null Values', () => {
it('should handle empty title in nodeData', () => {
// Arrange
const props = createDefaultProps({
nodeData: createMockNodeData({ title: '' }),
})
// Act
render(<Connect {...props} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle undefined optional fields in nodeData', () => {
// Arrange
const minimalNodeData = {
title: 'Test',
plugin_id: 'test',
provider_type: 'online_drive',
provider_name: 'provider',
datasource_name: 'ds',
datasource_label: 'Label',
datasource_parameters: {},
datasource_configurations: {},
} as DataSourceNodeType
const props = createDefaultProps({ nodeData: minimalNodeData })
// Act
render(<Connect {...props} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle empty plugin_id', () => {
// Arrange
const props = createDefaultProps({
nodeData: createMockNodeData({ plugin_id: '' }),
})
// Act
render(<Connect {...props} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('Special Characters', () => {
it('should handle special characters in title', () => {
// Arrange
const props = createDefaultProps({
nodeData: createMockNodeData({ title: 'Drive <script>alert("xss")</script>' }),
})
// Act
render(<Connect {...props} />)
// Assert - Should render safely without executing script
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle unicode characters in title', () => {
// Arrange
const props = createDefaultProps({
nodeData: createMockNodeData({ title: '云盘存储 🌐' }),
})
// Act
render(<Connect {...props} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle very long title', () => {
// Arrange
const longTitle = 'A'.repeat(500)
const props = createDefaultProps({
nodeData: createMockNodeData({ title: longTitle }),
})
// Act
render(<Connect {...props} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('Icon Variations', () => {
it('should handle string icon URL', () => {
// Arrange
mockUseToolIcon.mockReturnValue('https://cdn.example.com/icon.png')
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle object icon with url property', () => {
// Arrange
mockUseToolIcon.mockReturnValue({ url: 'https://cdn.example.com/icon.png' })
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle null icon', () => {
// Arrange
mockUseToolIcon.mockReturnValue(null)
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
})
// ==========================================
// All Prop Variations Tests
// ==========================================
describe('Prop Variations', () => {
it.each([
{ title: 'Google Drive', plugin_id: 'google-drive' },
{ title: 'Dropbox', plugin_id: 'dropbox' },
{ title: 'OneDrive', plugin_id: 'onedrive' },
{ title: 'Amazon S3', plugin_id: 's3' },
{ title: 'Box', plugin_id: 'box' },
])('should render correctly with title=$title and plugin_id=$plugin_id', ({ title, plugin_id }) => {
// Arrange
const props = createDefaultProps({
nodeData: createMockNodeData({ title, plugin_id }),
})
// Act
render(<Connect {...props} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
expect(mockUseToolIcon).toHaveBeenCalledWith(
expect.objectContaining({ title, plugin_id }),
)
})
it.each([
{ provider_type: 'online_drive' },
{ provider_type: 'cloud_storage' },
{ provider_type: 'file_system' },
])('should render correctly with provider_type=$provider_type', ({ provider_type }) => {
// Arrange
const props = createDefaultProps({
nodeData: createMockNodeData({ provider_type }),
})
// Act
render(<Connect {...props} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
it.each([
{ datasource_label: 'Google Drive Storage' },
{ datasource_label: 'Dropbox Files' },
{ datasource_label: '' },
{ datasource_label: 'S3 Bucket' },
])('should render correctly with datasource_label=$datasource_label', ({ datasource_label }) => {
// Arrange
const props = createDefaultProps({
nodeData: createMockNodeData({ datasource_label }),
})
// Act
render(<Connect {...props} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
// ==========================================
// Accessibility Tests
// ==========================================
describe('Accessibility', () => {
it('should have an accessible button', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert - Button should be accessible by role
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should have proper text content for screen readers', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert - Text content should be present
const messages = screen.getAllByText(/datasetPipeline\.onlineDrive\.notConnected/)
expect(messages.length).toBe(2) // Both notConnected and notConnectedTip
})
})
})

View File

@@ -0,0 +1,865 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import React from 'react'
import Dropdown from './index'
// ==========================================
// Note: react-i18next uses global mock from web/__mocks__/react-i18next.ts
// ==========================================
// ==========================================
// Test Data Builders
// ==========================================
type DropdownProps = React.ComponentProps<typeof Dropdown>
const createDefaultProps = (overrides?: Partial<DropdownProps>): DropdownProps => ({
startIndex: 0,
breadcrumbs: ['folder1', 'folder2'],
onBreadcrumbClick: jest.fn(),
...overrides,
})
// ==========================================
// Test Suites
// ==========================================
describe('Dropdown', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// ==========================================
// Rendering Tests
// ==========================================
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Dropdown {...props} />)
// Assert - Trigger button should be visible
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render trigger button with more icon', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Dropdown {...props} />)
// Assert - Button should have RiMoreFill icon (rendered as svg)
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should render separator after dropdown', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Dropdown {...props} />)
// Assert - Separator "/" should be visible
expect(screen.getByText('/')).toBeInTheDocument()
})
it('should render trigger button with correct default styles', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Dropdown {...props} />)
// Assert
const button = screen.getByRole('button')
expect(button).toHaveClass('flex')
expect(button).toHaveClass('size-6')
expect(button).toHaveClass('items-center')
expect(button).toHaveClass('justify-center')
expect(button).toHaveClass('rounded-md')
})
it('should not render menu content when closed', () => {
// Arrange
const props = createDefaultProps({ breadcrumbs: ['visible-folder'] })
// Act
render(<Dropdown {...props} />)
// Assert - Menu content should not be visible when dropdown is closed
expect(screen.queryByText('visible-folder')).not.toBeInTheDocument()
})
it('should render menu content when opened', async () => {
// Arrange
const props = createDefaultProps({ breadcrumbs: ['test-folder1', 'test-folder2'] })
render(<Dropdown {...props} />)
// Act - Open dropdown
fireEvent.click(screen.getByRole('button'))
// Assert - Menu items should be visible
await waitFor(() => {
expect(screen.getByText('test-folder1')).toBeInTheDocument()
expect(screen.getByText('test-folder2')).toBeInTheDocument()
})
})
})
// ==========================================
// Props Testing
// ==========================================
describe('Props', () => {
describe('startIndex prop', () => {
it('should pass startIndex to Menu component', async () => {
// Arrange
const mockOnBreadcrumbClick = jest.fn()
const props = createDefaultProps({
startIndex: 5,
breadcrumbs: ['folder1'],
onBreadcrumbClick: mockOnBreadcrumbClick,
})
render(<Dropdown {...props} />)
// Act - Open dropdown and click on item
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText('folder1')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('folder1'))
// Assert - Should be called with startIndex (5) + item index (0) = 5
expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(5)
})
it('should calculate correct index for second item', async () => {
// Arrange
const mockOnBreadcrumbClick = jest.fn()
const props = createDefaultProps({
startIndex: 3,
breadcrumbs: ['folder1', 'folder2'],
onBreadcrumbClick: mockOnBreadcrumbClick,
})
render(<Dropdown {...props} />)
// Act - Open dropdown and click on second item
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText('folder2')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('folder2'))
// Assert - Should be called with startIndex (3) + item index (1) = 4
expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(4)
})
})
describe('breadcrumbs prop', () => {
it('should render all breadcrumbs in menu', async () => {
// Arrange
const props = createDefaultProps({
breadcrumbs: ['folder-a', 'folder-b', 'folder-c'],
})
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
await waitFor(() => {
expect(screen.getByText('folder-a')).toBeInTheDocument()
expect(screen.getByText('folder-b')).toBeInTheDocument()
expect(screen.getByText('folder-c')).toBeInTheDocument()
})
})
it('should handle single breadcrumb', async () => {
// Arrange
const props = createDefaultProps({
breadcrumbs: ['single-folder'],
})
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
await waitFor(() => {
expect(screen.getByText('single-folder')).toBeInTheDocument()
})
})
it('should handle empty breadcrumbs array', async () => {
// Arrange
const props = createDefaultProps({
breadcrumbs: [],
})
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert - Menu should be rendered but with no items
await waitFor(() => {
// The menu container should exist but be empty
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
it('should handle breadcrumbs with special characters', async () => {
// Arrange
const props = createDefaultProps({
breadcrumbs: ['folder [1]', 'folder (copy)', 'folder-v2.0'],
})
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
await waitFor(() => {
expect(screen.getByText('folder [1]')).toBeInTheDocument()
expect(screen.getByText('folder (copy)')).toBeInTheDocument()
expect(screen.getByText('folder-v2.0')).toBeInTheDocument()
})
})
it('should handle breadcrumbs with unicode characters', async () => {
// Arrange
const props = createDefaultProps({
breadcrumbs: ['文件夹', 'フォルダ', 'Папка'],
})
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
await waitFor(() => {
expect(screen.getByText('文件夹')).toBeInTheDocument()
expect(screen.getByText('フォルダ')).toBeInTheDocument()
expect(screen.getByText('Папка')).toBeInTheDocument()
})
})
})
describe('onBreadcrumbClick prop', () => {
it('should call onBreadcrumbClick with correct index when item clicked', async () => {
// Arrange
const mockOnBreadcrumbClick = jest.fn()
const props = createDefaultProps({
startIndex: 0,
breadcrumbs: ['folder1'],
onBreadcrumbClick: mockOnBreadcrumbClick,
})
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText('folder1')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('folder1'))
// Assert
expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(0)
expect(mockOnBreadcrumbClick).toHaveBeenCalledTimes(1)
})
})
})
// ==========================================
// State Management Tests
// ==========================================
describe('State Management', () => {
describe('open state', () => {
it('should initialize with closed state', () => {
// Arrange
const props = createDefaultProps({ breadcrumbs: ['test-folder'] })
// Act
render(<Dropdown {...props} />)
// Assert - Menu content should not be visible
expect(screen.queryByText('test-folder')).not.toBeInTheDocument()
})
it('should toggle to open state when trigger is clicked', async () => {
// Arrange
const props = createDefaultProps({ breadcrumbs: ['test-folder'] })
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
await waitFor(() => {
expect(screen.getByText('test-folder')).toBeInTheDocument()
})
})
it('should toggle to closed state when trigger is clicked again', async () => {
// Arrange
const props = createDefaultProps({ breadcrumbs: ['test-folder'] })
render(<Dropdown {...props} />)
// Act - Open and then close
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText('test-folder')).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button'))
// Assert
await waitFor(() => {
expect(screen.queryByText('test-folder')).not.toBeInTheDocument()
})
})
it('should close when breadcrumb item is clicked', async () => {
// Arrange
const mockOnBreadcrumbClick = jest.fn()
const props = createDefaultProps({
breadcrumbs: ['test-folder'],
onBreadcrumbClick: mockOnBreadcrumbClick,
})
render(<Dropdown {...props} />)
// Act - Open dropdown
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText('test-folder')).toBeInTheDocument()
})
// Click on breadcrumb item
fireEvent.click(screen.getByText('test-folder'))
// Assert - Menu should close
await waitFor(() => {
expect(screen.queryByText('test-folder')).not.toBeInTheDocument()
})
})
it('should apply correct button styles based on open state', async () => {
// Arrange
const props = createDefaultProps({ breadcrumbs: ['test-folder'] })
render(<Dropdown {...props} />)
const button = screen.getByRole('button')
// Assert - Initial state (closed): should have hover:bg-state-base-hover
expect(button).toHaveClass('hover:bg-state-base-hover')
// Act - Open dropdown
fireEvent.click(button)
// Assert - Open state: should have bg-state-base-hover
await waitFor(() => {
expect(button).toHaveClass('bg-state-base-hover')
})
})
})
})
// ==========================================
// Event Handlers Tests
// ==========================================
describe('Event Handlers', () => {
describe('handleTrigger', () => {
it('should toggle open state when trigger is clicked', async () => {
// Arrange
const props = createDefaultProps({ breadcrumbs: ['folder'] })
render(<Dropdown {...props} />)
// Act & Assert - Initially closed
expect(screen.queryByText('folder')).not.toBeInTheDocument()
// Act - Click to open
fireEvent.click(screen.getByRole('button'))
// Assert - Now open
await waitFor(() => {
expect(screen.getByText('folder')).toBeInTheDocument()
})
})
it('should toggle multiple times correctly', async () => {
// Arrange
const props = createDefaultProps({ breadcrumbs: ['folder'] })
render(<Dropdown {...props} />)
const button = screen.getByRole('button')
// Act & Assert - Toggle multiple times
// 1st click - open
fireEvent.click(button)
await waitFor(() => {
expect(screen.getByText('folder')).toBeInTheDocument()
})
// 2nd click - close
fireEvent.click(button)
await waitFor(() => {
expect(screen.queryByText('folder')).not.toBeInTheDocument()
})
// 3rd click - open again
fireEvent.click(button)
await waitFor(() => {
expect(screen.getByText('folder')).toBeInTheDocument()
})
})
})
describe('handleBreadCrumbClick', () => {
it('should call onBreadcrumbClick and close menu', async () => {
// Arrange
const mockOnBreadcrumbClick = jest.fn()
const props = createDefaultProps({
breadcrumbs: ['folder1'],
onBreadcrumbClick: mockOnBreadcrumbClick,
})
render(<Dropdown {...props} />)
// Act - Open dropdown
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText('folder1')).toBeInTheDocument()
})
// Click on breadcrumb
fireEvent.click(screen.getByText('folder1'))
// Assert
expect(mockOnBreadcrumbClick).toHaveBeenCalledTimes(1)
// Menu should close
await waitFor(() => {
expect(screen.queryByText('folder1')).not.toBeInTheDocument()
})
})
it('should pass correct index to onBreadcrumbClick for each item', async () => {
// Arrange
const mockOnBreadcrumbClick = jest.fn()
const props = createDefaultProps({
startIndex: 2,
breadcrumbs: ['folder1', 'folder2', 'folder3'],
onBreadcrumbClick: mockOnBreadcrumbClick,
})
render(<Dropdown {...props} />)
// Act - Open dropdown and click first item
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText('folder1')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('folder1'))
// Assert - Index should be startIndex (2) + item index (0) = 2
expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(2)
})
})
})
// ==========================================
// Callback Stability and Memoization Tests
// ==========================================
describe('Callback Stability and Memoization', () => {
it('should be wrapped with React.memo', () => {
// Assert - Dropdown component should be memoized
expect(Dropdown).toHaveProperty('$$typeof', Symbol.for('react.memo'))
})
it('should maintain stable callback after rerender with same props', async () => {
// Arrange
const mockOnBreadcrumbClick = jest.fn()
const props = createDefaultProps({
breadcrumbs: ['folder'],
onBreadcrumbClick: mockOnBreadcrumbClick,
})
const { rerender } = render(<Dropdown {...props} />)
// Act - Open and click
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText('folder')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('folder'))
// Rerender with same props and click again
rerender(<Dropdown {...props} />)
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText('folder')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('folder'))
// Assert
expect(mockOnBreadcrumbClick).toHaveBeenCalledTimes(2)
})
it('should update callback when onBreadcrumbClick prop changes', async () => {
// Arrange
const mockOnBreadcrumbClick1 = jest.fn()
const mockOnBreadcrumbClick2 = jest.fn()
const props = createDefaultProps({
breadcrumbs: ['folder'],
onBreadcrumbClick: mockOnBreadcrumbClick1,
})
const { rerender } = render(<Dropdown {...props} />)
// Act - Open and click with first callback
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText('folder')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('folder'))
// Rerender with different callback
rerender(<Dropdown {...createDefaultProps({
breadcrumbs: ['folder'],
onBreadcrumbClick: mockOnBreadcrumbClick2,
})} />)
// Open and click with second callback
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText('folder')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('folder'))
// Assert
expect(mockOnBreadcrumbClick1).toHaveBeenCalledTimes(1)
expect(mockOnBreadcrumbClick2).toHaveBeenCalledTimes(1)
})
it('should not re-render when props are the same', () => {
// Arrange
const props = createDefaultProps()
const { rerender } = render(<Dropdown {...props} />)
// Act - Rerender with same props
rerender(<Dropdown {...props} />)
// Assert - Component should render without errors
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
// ==========================================
// Edge Cases and Error Handling
// ==========================================
describe('Edge Cases and Error Handling', () => {
it('should handle rapid toggle clicks', async () => {
// Arrange
const props = createDefaultProps({ breadcrumbs: ['folder'] })
render(<Dropdown {...props} />)
const button = screen.getByRole('button')
// Act - Rapid clicks
fireEvent.click(button)
fireEvent.click(button)
fireEvent.click(button)
// Assert - Should handle gracefully (open after odd number of clicks)
await waitFor(() => {
expect(screen.getByText('folder')).toBeInTheDocument()
})
})
it('should handle very long folder names', async () => {
// Arrange
const longName = 'a'.repeat(100)
const props = createDefaultProps({
breadcrumbs: [longName],
})
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
await waitFor(() => {
expect(screen.getByText(longName)).toBeInTheDocument()
})
})
it('should handle many breadcrumbs', async () => {
// Arrange
const manyBreadcrumbs = Array.from({ length: 20 }, (_, i) => `folder-${i}`)
const props = createDefaultProps({
breadcrumbs: manyBreadcrumbs,
})
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert - First and last items should be visible
await waitFor(() => {
expect(screen.getByText('folder-0')).toBeInTheDocument()
expect(screen.getByText('folder-19')).toBeInTheDocument()
})
})
it('should handle startIndex of 0', async () => {
// Arrange
const mockOnBreadcrumbClick = jest.fn()
const props = createDefaultProps({
startIndex: 0,
breadcrumbs: ['folder'],
onBreadcrumbClick: mockOnBreadcrumbClick,
})
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText('folder')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('folder'))
// Assert
expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(0)
})
it('should handle large startIndex values', async () => {
// Arrange
const mockOnBreadcrumbClick = jest.fn()
const props = createDefaultProps({
startIndex: 999,
breadcrumbs: ['folder'],
onBreadcrumbClick: mockOnBreadcrumbClick,
})
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText('folder')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('folder'))
// Assert
expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(999)
})
it('should handle breadcrumbs with whitespace-only names', async () => {
// Arrange
const props = createDefaultProps({
breadcrumbs: [' ', 'normal-folder'],
})
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
await waitFor(() => {
expect(screen.getByText('normal-folder')).toBeInTheDocument()
})
})
it('should handle breadcrumbs with empty string', async () => {
// Arrange
const props = createDefaultProps({
breadcrumbs: ['', 'folder'],
})
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
await waitFor(() => {
expect(screen.getByText('folder')).toBeInTheDocument()
})
})
})
// ==========================================
// All Prop Variations Tests
// ==========================================
describe('Prop Variations', () => {
it.each([
{ startIndex: 0, breadcrumbs: ['a'], expectedIndex: 0 },
{ startIndex: 1, breadcrumbs: ['a'], expectedIndex: 1 },
{ startIndex: 5, breadcrumbs: ['a'], expectedIndex: 5 },
{ startIndex: 10, breadcrumbs: ['a', 'b'], expectedIndex: 10 },
])('should handle startIndex=$startIndex correctly', async ({ startIndex, breadcrumbs, expectedIndex }) => {
// Arrange
const mockOnBreadcrumbClick = jest.fn()
const props = createDefaultProps({
startIndex,
breadcrumbs,
onBreadcrumbClick: mockOnBreadcrumbClick,
})
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText(breadcrumbs[0])).toBeInTheDocument()
})
fireEvent.click(screen.getByText(breadcrumbs[0]))
// Assert
expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(expectedIndex)
})
it.each([
{ breadcrumbs: [], description: 'empty array' },
{ breadcrumbs: ['single'], description: 'single item' },
{ breadcrumbs: ['a', 'b'], description: 'two items' },
{ breadcrumbs: ['a', 'b', 'c', 'd', 'e'], description: 'five items' },
])('should render correctly with $description breadcrumbs', async ({ breadcrumbs }) => {
// Arrange
const props = createDefaultProps({ breadcrumbs })
// Act
render(<Dropdown {...props} />)
fireEvent.click(screen.getByRole('button'))
// Assert - Should render without errors
await waitFor(() => {
if (breadcrumbs.length > 0)
expect(screen.getByText(breadcrumbs[0])).toBeInTheDocument()
})
})
})
// ==========================================
// Integration Tests (Menu and Item)
// ==========================================
describe('Integration with Menu and Item', () => {
it('should render all menu items with correct content', async () => {
// Arrange
const props = createDefaultProps({
breadcrumbs: ['Documents', 'Projects', 'Archive'],
})
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
await waitFor(() => {
expect(screen.getByText('Documents')).toBeInTheDocument()
expect(screen.getByText('Projects')).toBeInTheDocument()
expect(screen.getByText('Archive')).toBeInTheDocument()
})
})
it('should handle click on any menu item', async () => {
// Arrange
const mockOnBreadcrumbClick = jest.fn()
const props = createDefaultProps({
startIndex: 0,
breadcrumbs: ['first', 'second', 'third'],
onBreadcrumbClick: mockOnBreadcrumbClick,
})
render(<Dropdown {...props} />)
// Act - Open and click on second item
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText('second')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('second'))
// Assert - Index should be 1 (second item)
expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(1)
})
it('should close menu after any item click', async () => {
// Arrange
const mockOnBreadcrumbClick = jest.fn()
const props = createDefaultProps({
breadcrumbs: ['item1', 'item2', 'item3'],
onBreadcrumbClick: mockOnBreadcrumbClick,
})
render(<Dropdown {...props} />)
// Act - Open and click on middle item
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText('item2')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('item2'))
// Assert - Menu should close
await waitFor(() => {
expect(screen.queryByText('item1')).not.toBeInTheDocument()
expect(screen.queryByText('item2')).not.toBeInTheDocument()
expect(screen.queryByText('item3')).not.toBeInTheDocument()
})
})
it('should correctly calculate index for each item based on startIndex', async () => {
// Arrange
const mockOnBreadcrumbClick = jest.fn()
const props = createDefaultProps({
startIndex: 3,
breadcrumbs: ['folder-a', 'folder-b', 'folder-c'],
onBreadcrumbClick: mockOnBreadcrumbClick,
})
// Test clicking each item
for (let i = 0; i < 3; i++) {
mockOnBreadcrumbClick.mockClear()
const { unmount } = render(<Dropdown {...props} />)
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText(`folder-${String.fromCharCode(97 + i)}`)).toBeInTheDocument()
})
fireEvent.click(screen.getByText(`folder-${String.fromCharCode(97 + i)}`))
expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(3 + i)
unmount()
}
})
})
// ==========================================
// Accessibility Tests
// ==========================================
describe('Accessibility', () => {
it('should render trigger as button element', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Dropdown {...props} />)
// Assert
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
expect(button.tagName).toBe('BUTTON')
})
it('should have type="button" attribute', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Dropdown {...props} />)
// Assert
const button = screen.getByRole('button')
expect(button).toHaveAttribute('type', 'button')
})
})
})

View File

@@ -0,0 +1,727 @@
import { fireEvent, render, screen } from '@testing-library/react'
import React from 'react'
import Header from './index'
// ==========================================
// Mock Modules
// ==========================================
// Note: react-i18next uses global mock from web/__mocks__/react-i18next.ts
// Mock store - required by Breadcrumbs component
const mockStoreState = {
hasBucket: false,
setOnlineDriveFileList: jest.fn(),
setSelectedFileIds: jest.fn(),
setBreadcrumbs: jest.fn(),
setPrefix: jest.fn(),
setBucket: jest.fn(),
breadcrumbs: [],
prefix: [],
}
const mockGetState = jest.fn(() => mockStoreState)
const mockDataSourceStore = { getState: mockGetState }
jest.mock('../../../store', () => ({
useDataSourceStore: () => mockDataSourceStore,
useDataSourceStoreWithSelector: (selector: (s: typeof mockStoreState) => unknown) => selector(mockStoreState),
}))
// ==========================================
// Test Data Builders
// ==========================================
type HeaderProps = React.ComponentProps<typeof Header>
const createDefaultProps = (overrides?: Partial<HeaderProps>): HeaderProps => ({
breadcrumbs: [],
inputValue: '',
keywords: '',
bucket: '',
searchResultsLength: 0,
handleInputChange: jest.fn(),
handleResetKeywords: jest.fn(),
isInPipeline: false,
...overrides,
})
// ==========================================
// Helper Functions
// ==========================================
const resetMockStoreState = () => {
mockStoreState.hasBucket = false
mockStoreState.setOnlineDriveFileList = jest.fn()
mockStoreState.setSelectedFileIds = jest.fn()
mockStoreState.setBreadcrumbs = jest.fn()
mockStoreState.setPrefix = jest.fn()
mockStoreState.setBucket = jest.fn()
mockStoreState.breadcrumbs = []
mockStoreState.prefix = []
}
// ==========================================
// Test Suites
// ==========================================
describe('Header', () => {
beforeEach(() => {
jest.clearAllMocks()
resetMockStoreState()
})
// ==========================================
// Rendering Tests
// ==========================================
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert - search input should be visible
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
it('should render with correct container styles', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Header {...props} />)
// Assert - container should have correct class names
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex')
expect(wrapper).toHaveClass('items-center')
expect(wrapper).toHaveClass('gap-x-2')
expect(wrapper).toHaveClass('bg-components-panel-bg')
expect(wrapper).toHaveClass('p-1')
expect(wrapper).toHaveClass('pl-3')
})
it('should render Input component with correct props', () => {
// Arrange
const props = createDefaultProps({ inputValue: 'test-value' })
// Act
render(<Header {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toBeInTheDocument()
expect(input).toHaveValue('test-value')
})
it('should render Input with search icon', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Header {...props} />)
// Assert - Input should have search icon (RiSearchLine is rendered as svg)
const searchIcon = container.querySelector('svg.h-4.w-4')
expect(searchIcon).toBeInTheDocument()
})
it('should render Input with correct wrapper width', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Header {...props} />)
// Assert - Input wrapper should have w-[200px] class
const inputWrapper = container.querySelector('.w-\\[200px\\]')
expect(inputWrapper).toBeInTheDocument()
})
})
// ==========================================
// Props Testing
// ==========================================
describe('Props', () => {
describe('inputValue prop', () => {
it('should display empty input when inputValue is empty string', () => {
// Arrange
const props = createDefaultProps({ inputValue: '' })
// Act
render(<Header {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toHaveValue('')
})
it('should display input value correctly', () => {
// Arrange
const props = createDefaultProps({ inputValue: 'search-query' })
// Act
render(<Header {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toHaveValue('search-query')
})
it('should handle special characters in inputValue', () => {
// Arrange
const specialChars = 'test[file].txt (copy)'
const props = createDefaultProps({ inputValue: specialChars })
// Act
render(<Header {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toHaveValue(specialChars)
})
it('should handle unicode characters in inputValue', () => {
// Arrange
const unicodeValue = '文件搜索 日本語'
const props = createDefaultProps({ inputValue: unicodeValue })
// Act
render(<Header {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toHaveValue(unicodeValue)
})
})
describe('breadcrumbs prop', () => {
it('should render with empty breadcrumbs', () => {
// Arrange
const props = createDefaultProps({ breadcrumbs: [] })
// Act
render(<Header {...props} />)
// Assert - Component should render without errors
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
it('should render with single breadcrumb', () => {
// Arrange
const props = createDefaultProps({ breadcrumbs: ['folder1'] })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
it('should render with multiple breadcrumbs', () => {
// Arrange
const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2', 'folder3'] })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
})
describe('keywords prop', () => {
it('should pass keywords to Breadcrumbs', () => {
// Arrange
const props = createDefaultProps({ keywords: 'search-keyword' })
// Act
render(<Header {...props} />)
// Assert - keywords are passed through, component renders
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
})
describe('bucket prop', () => {
it('should render with empty bucket', () => {
// Arrange
const props = createDefaultProps({ bucket: '' })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
it('should render with bucket value', () => {
// Arrange
const props = createDefaultProps({ bucket: 'my-bucket' })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
})
describe('searchResultsLength prop', () => {
it('should handle zero search results', () => {
// Arrange
const props = createDefaultProps({ searchResultsLength: 0 })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
it('should handle positive search results', () => {
// Arrange
const props = createDefaultProps({ searchResultsLength: 10, keywords: 'test' })
// Act
render(<Header {...props} />)
// Assert - Breadcrumbs will show search results text when keywords exist and results > 0
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
it('should handle large search results count', () => {
// Arrange
const props = createDefaultProps({ searchResultsLength: 1000, keywords: 'test' })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
})
describe('isInPipeline prop', () => {
it('should render correctly when isInPipeline is false', () => {
// Arrange
const props = createDefaultProps({ isInPipeline: false })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
it('should render correctly when isInPipeline is true', () => {
// Arrange
const props = createDefaultProps({ isInPipeline: true })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
})
})
// ==========================================
// Event Handlers Tests
// ==========================================
describe('Event Handlers', () => {
describe('handleInputChange', () => {
it('should call handleInputChange when input value changes', () => {
// Arrange
const mockHandleInputChange = jest.fn()
const props = createDefaultProps({ handleInputChange: mockHandleInputChange })
render(<Header {...props} />)
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
// Act
fireEvent.change(input, { target: { value: 'new-value' } })
// Assert
expect(mockHandleInputChange).toHaveBeenCalledTimes(1)
// Verify that onChange event was triggered (React's synthetic event structure)
expect(mockHandleInputChange.mock.calls[0][0]).toHaveProperty('type', 'change')
})
it('should call handleInputChange on each keystroke', () => {
// Arrange
const mockHandleInputChange = jest.fn()
const props = createDefaultProps({ handleInputChange: mockHandleInputChange })
render(<Header {...props} />)
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
// Act
fireEvent.change(input, { target: { value: 'a' } })
fireEvent.change(input, { target: { value: 'ab' } })
fireEvent.change(input, { target: { value: 'abc' } })
// Assert
expect(mockHandleInputChange).toHaveBeenCalledTimes(3)
})
it('should handle empty string input', () => {
// Arrange
const mockHandleInputChange = jest.fn()
const props = createDefaultProps({ inputValue: 'existing', handleInputChange: mockHandleInputChange })
render(<Header {...props} />)
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
// Act
fireEvent.change(input, { target: { value: '' } })
// Assert
expect(mockHandleInputChange).toHaveBeenCalledTimes(1)
expect(mockHandleInputChange.mock.calls[0][0]).toHaveProperty('type', 'change')
})
it('should handle whitespace-only input', () => {
// Arrange
const mockHandleInputChange = jest.fn()
const props = createDefaultProps({ handleInputChange: mockHandleInputChange })
render(<Header {...props} />)
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
// Act
fireEvent.change(input, { target: { value: ' ' } })
// Assert
expect(mockHandleInputChange).toHaveBeenCalledTimes(1)
expect(mockHandleInputChange.mock.calls[0][0]).toHaveProperty('type', 'change')
})
})
describe('handleResetKeywords', () => {
it('should call handleResetKeywords when clear icon is clicked', () => {
// Arrange
const mockHandleResetKeywords = jest.fn()
const props = createDefaultProps({
inputValue: 'to-clear',
handleResetKeywords: mockHandleResetKeywords,
})
const { container } = render(<Header {...props} />)
// Act - Find and click the clear icon container
const clearButton = container.querySelector('[class*="cursor-pointer"] svg[class*="h-3.5"]')?.parentElement
expect(clearButton).toBeInTheDocument()
fireEvent.click(clearButton!)
// Assert
expect(mockHandleResetKeywords).toHaveBeenCalledTimes(1)
})
it('should not show clear icon when inputValue is empty', () => {
// Arrange
const props = createDefaultProps({ inputValue: '' })
const { container } = render(<Header {...props} />)
// Act & Assert - Clear icon should not be visible
const clearIcon = container.querySelector('[class*="cursor-pointer"] svg[class*="h-3.5"]')
expect(clearIcon).not.toBeInTheDocument()
})
it('should show clear icon when inputValue is not empty', () => {
// Arrange
const props = createDefaultProps({ inputValue: 'some-value' })
const { container } = render(<Header {...props} />)
// Act & Assert - Clear icon should be visible
const clearIcon = container.querySelector('[class*="cursor-pointer"] svg[class*="h-3.5"]')
expect(clearIcon).toBeInTheDocument()
})
})
})
// ==========================================
// Component Memoization Tests
// ==========================================
describe('Memoization', () => {
it('should be wrapped with React.memo', () => {
// Assert - Header component should be memoized
expect(Header).toHaveProperty('$$typeof', Symbol.for('react.memo'))
})
it('should not re-render when props are the same', () => {
// Arrange
const mockHandleInputChange = jest.fn()
const mockHandleResetKeywords = jest.fn()
const props = createDefaultProps({
handleInputChange: mockHandleInputChange,
handleResetKeywords: mockHandleResetKeywords,
})
// Act - Initial render
const { rerender } = render(<Header {...props} />)
// Rerender with same props
rerender(<Header {...props} />)
// Assert - Component renders without errors
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
it('should re-render when inputValue changes', () => {
// Arrange
const props = createDefaultProps({ inputValue: 'initial' })
const { rerender } = render(<Header {...props} />)
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toHaveValue('initial')
// Act - Rerender with different inputValue
const newProps = createDefaultProps({ inputValue: 'changed' })
rerender(<Header {...newProps} />)
// Assert - Input value should be updated
expect(input).toHaveValue('changed')
})
it('should re-render when breadcrumbs change', () => {
// Arrange
const props = createDefaultProps({ breadcrumbs: [] })
const { rerender } = render(<Header {...props} />)
// Act - Rerender with different breadcrumbs
const newProps = createDefaultProps({ breadcrumbs: ['folder1', 'folder2'] })
rerender(<Header {...newProps} />)
// Assert - Component renders without errors
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
it('should re-render when keywords change', () => {
// Arrange
const props = createDefaultProps({ keywords: '' })
const { rerender } = render(<Header {...props} />)
// Act - Rerender with different keywords
const newProps = createDefaultProps({ keywords: 'search-term' })
rerender(<Header {...newProps} />)
// Assert - Component renders without errors
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
})
// ==========================================
// Edge Cases and Error Handling
// ==========================================
describe('Edge Cases and Error Handling', () => {
it('should handle very long inputValue', () => {
// Arrange
const longValue = 'a'.repeat(500)
const props = createDefaultProps({ inputValue: longValue })
// Act
render(<Header {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toHaveValue(longValue)
})
it('should handle very long breadcrumb paths', () => {
// Arrange
const longBreadcrumbs = Array.from({ length: 20 }, (_, i) => `folder-${i}`)
const props = createDefaultProps({ breadcrumbs: longBreadcrumbs })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
it('should handle breadcrumbs with special characters', () => {
// Arrange
const specialBreadcrumbs = ['folder [1]', 'folder (2)', 'folder-3.backup']
const props = createDefaultProps({ breadcrumbs: specialBreadcrumbs })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
it('should handle breadcrumbs with unicode names', () => {
// Arrange
const unicodeBreadcrumbs = ['文件夹', 'フォルダ', 'Папка']
const props = createDefaultProps({ breadcrumbs: unicodeBreadcrumbs })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
it('should handle bucket with special characters', () => {
// Arrange
const props = createDefaultProps({ bucket: 'my-bucket_2024.backup' })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
it('should pass the event object to handleInputChange callback', () => {
// Arrange
const mockHandleInputChange = jest.fn()
const props = createDefaultProps({ handleInputChange: mockHandleInputChange })
render(<Header {...props} />)
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
// Act
fireEvent.change(input, { target: { value: 'test-value' } })
// Assert - Verify the event object is passed correctly
expect(mockHandleInputChange).toHaveBeenCalledTimes(1)
const eventArg = mockHandleInputChange.mock.calls[0][0]
expect(eventArg).toHaveProperty('type', 'change')
expect(eventArg).toHaveProperty('target')
})
})
// ==========================================
// All Prop Variations Tests
// ==========================================
describe('Prop Variations', () => {
it.each([
{ isInPipeline: true, bucket: '' },
{ isInPipeline: true, bucket: 'my-bucket' },
{ isInPipeline: false, bucket: '' },
{ isInPipeline: false, bucket: 'my-bucket' },
])('should render correctly with isInPipeline=$isInPipeline and bucket=$bucket', (propVariation) => {
// Arrange
const props = createDefaultProps(propVariation)
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
it.each([
{ keywords: '', searchResultsLength: 0, description: 'no search' },
{ keywords: 'test', searchResultsLength: 0, description: 'search with no results' },
{ keywords: 'test', searchResultsLength: 5, description: 'search with results' },
{ keywords: '', searchResultsLength: 5, description: 'no keywords but has results count' },
])('should render correctly with $description', ({ keywords, searchResultsLength }) => {
// Arrange
const props = createDefaultProps({ keywords, searchResultsLength })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
it.each([
{ breadcrumbs: [], inputValue: '', expected: 'empty state' },
{ breadcrumbs: ['root'], inputValue: 'search', expected: 'single breadcrumb with search' },
{ breadcrumbs: ['a', 'b', 'c'], inputValue: '', expected: 'multiple breadcrumbs no search' },
{ breadcrumbs: ['a', 'b', 'c', 'd', 'e'], inputValue: 'query', expected: 'many breadcrumbs with search' },
])('should handle $expected correctly', ({ breadcrumbs, inputValue }) => {
// Arrange
const props = createDefaultProps({ breadcrumbs, inputValue })
// Act
render(<Header {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toHaveValue(inputValue)
})
})
// ==========================================
// Integration with Child Components
// ==========================================
describe('Integration with Child Components', () => {
it('should pass all required props to Breadcrumbs', () => {
// Arrange
const props = createDefaultProps({
breadcrumbs: ['folder1', 'folder2'],
keywords: 'test-keyword',
bucket: 'test-bucket',
searchResultsLength: 10,
isInPipeline: true,
})
// Act
render(<Header {...props} />)
// Assert - Component should render successfully, meaning props are passed correctly
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
it('should pass correct props to Input component', () => {
// Arrange
const mockHandleInputChange = jest.fn()
const mockHandleResetKeywords = jest.fn()
const props = createDefaultProps({
inputValue: 'test-input',
handleInputChange: mockHandleInputChange,
handleResetKeywords: mockHandleResetKeywords,
})
// Act
render(<Header {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toHaveValue('test-input')
// Test onChange handler
fireEvent.change(input, { target: { value: 'new-value' } })
expect(mockHandleInputChange).toHaveBeenCalled()
})
})
// ==========================================
// Callback Stability Tests
// ==========================================
describe('Callback Stability', () => {
it('should maintain stable handleInputChange callback after rerender', () => {
// Arrange
const mockHandleInputChange = jest.fn()
const props = createDefaultProps({ handleInputChange: mockHandleInputChange })
const { rerender } = render(<Header {...props} />)
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
// Act - Fire change event, rerender, fire again
fireEvent.change(input, { target: { value: 'first' } })
rerender(<Header {...props} />)
fireEvent.change(input, { target: { value: 'second' } })
// Assert
expect(mockHandleInputChange).toHaveBeenCalledTimes(2)
})
it('should maintain stable handleResetKeywords callback after rerender', () => {
// Arrange
const mockHandleResetKeywords = jest.fn()
const props = createDefaultProps({
inputValue: 'to-clear',
handleResetKeywords: mockHandleResetKeywords,
})
const { container, rerender } = render(<Header {...props} />)
// Act - Click clear, rerender, click again
const clearButton = container.querySelector('[class*="cursor-pointer"] svg[class*="h-3.5"]')?.parentElement
fireEvent.click(clearButton!)
rerender(<Header {...props} />)
fireEvent.click(clearButton!)
// Assert
expect(mockHandleResetKeywords).toHaveBeenCalledTimes(2)
})
})
})

View File

@@ -0,0 +1,757 @@
import { fireEvent, render, screen } from '@testing-library/react'
import React from 'react'
import FileList from './index'
import type { OnlineDriveFile } from '@/models/pipeline'
import { OnlineDriveFileType } from '@/models/pipeline'
// ==========================================
// Mock Modules
// ==========================================
// Note: react-i18next uses global mock from web/__mocks__/react-i18next.ts
// Mock ahooks useDebounceFn - third-party library requires mocking
const mockDebounceFnRun = jest.fn()
jest.mock('ahooks', () => ({
useDebounceFn: (fn: (...args: any[]) => void) => {
mockDebounceFnRun.mockImplementation(fn)
return { run: mockDebounceFnRun }
},
}))
// Mock store - context provider requires mocking
const mockStoreState = {
setNextPageParameters: jest.fn(),
currentNextPageParametersRef: { current: {} },
isTruncated: { current: false },
hasBucket: false,
setOnlineDriveFileList: jest.fn(),
setSelectedFileIds: jest.fn(),
setBreadcrumbs: jest.fn(),
setPrefix: jest.fn(),
setBucket: jest.fn(),
}
const mockGetState = jest.fn(() => mockStoreState)
const mockDataSourceStore = { getState: mockGetState }
jest.mock('../../store', () => ({
useDataSourceStore: () => mockDataSourceStore,
useDataSourceStoreWithSelector: (selector: (s: any) => any) => selector(mockStoreState),
}))
// ==========================================
// Test Data Builders
// ==========================================
const createMockOnlineDriveFile = (overrides?: Partial<OnlineDriveFile>): OnlineDriveFile => ({
id: 'file-1',
name: 'test-file.txt',
size: 1024,
type: OnlineDriveFileType.file,
...overrides,
})
type FileListProps = React.ComponentProps<typeof FileList>
const createDefaultProps = (overrides?: Partial<FileListProps>): FileListProps => ({
fileList: [],
selectedFileIds: [],
breadcrumbs: [],
keywords: '',
bucket: '',
isInPipeline: false,
resetKeywords: jest.fn(),
updateKeywords: jest.fn(),
searchResultsLength: 0,
handleSelectFile: jest.fn(),
handleOpenFolder: jest.fn(),
isLoading: false,
supportBatchUpload: true,
...overrides,
})
// ==========================================
// Helper Functions
// ==========================================
const resetMockStoreState = () => {
mockStoreState.setNextPageParameters = jest.fn()
mockStoreState.currentNextPageParametersRef = { current: {} }
mockStoreState.isTruncated = { current: false }
mockStoreState.hasBucket = false
mockStoreState.setOnlineDriveFileList = jest.fn()
mockStoreState.setSelectedFileIds = jest.fn()
mockStoreState.setBreadcrumbs = jest.fn()
mockStoreState.setPrefix = jest.fn()
mockStoreState.setBucket = jest.fn()
}
// ==========================================
// Test Suites
// ==========================================
describe('FileList', () => {
beforeEach(() => {
jest.clearAllMocks()
resetMockStoreState()
mockDebounceFnRun.mockClear()
})
// ==========================================
// Rendering Tests
// ==========================================
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<FileList {...props} />)
// Assert - search input should be visible
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
it('should render with correct container styles', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<FileList {...props} />)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex')
expect(wrapper).toHaveClass('h-[400px]')
expect(wrapper).toHaveClass('flex-col')
expect(wrapper).toHaveClass('overflow-hidden')
expect(wrapper).toHaveClass('rounded-xl')
})
it('should render Header component with search input', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<FileList {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toBeInTheDocument()
})
it('should render files when fileList has items', () => {
// Arrange
const fileList = [
createMockOnlineDriveFile({ id: 'file-1', name: 'file1.txt' }),
createMockOnlineDriveFile({ id: 'file-2', name: 'file2.txt' }),
]
const props = createDefaultProps({ fileList })
// Act
render(<FileList {...props} />)
// Assert
expect(screen.getByText('file1.txt')).toBeInTheDocument()
expect(screen.getByText('file2.txt')).toBeInTheDocument()
})
it('should show loading state when isLoading is true and fileList is empty', () => {
// Arrange
const props = createDefaultProps({ isLoading: true, fileList: [] })
// Act
const { container } = render(<FileList {...props} />)
// Assert - Loading component should be rendered with spin-animation class
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
})
it('should show empty folder state when not loading and fileList is empty', () => {
// Arrange
const props = createDefaultProps({ isLoading: false, fileList: [], keywords: '' })
// Act
render(<FileList {...props} />)
// Assert
expect(screen.getByText('datasetPipeline.onlineDrive.emptyFolder')).toBeInTheDocument()
})
it('should show empty search result when not loading, fileList is empty, and keywords exist', () => {
// Arrange
const props = createDefaultProps({ isLoading: false, fileList: [], keywords: 'search-term' })
// Act
render(<FileList {...props} />)
// Assert
expect(screen.getByText('datasetPipeline.onlineDrive.emptySearchResult')).toBeInTheDocument()
})
})
// ==========================================
// Props Testing
// ==========================================
describe('Props', () => {
describe('fileList prop', () => {
it('should render all files from fileList', () => {
// Arrange
const fileList = [
createMockOnlineDriveFile({ id: '1', name: 'a.txt' }),
createMockOnlineDriveFile({ id: '2', name: 'b.txt' }),
createMockOnlineDriveFile({ id: '3', name: 'c.txt' }),
]
const props = createDefaultProps({ fileList })
// Act
render(<FileList {...props} />)
// Assert
expect(screen.getByText('a.txt')).toBeInTheDocument()
expect(screen.getByText('b.txt')).toBeInTheDocument()
expect(screen.getByText('c.txt')).toBeInTheDocument()
})
it('should handle empty fileList', () => {
// Arrange
const props = createDefaultProps({ fileList: [] })
// Act
render(<FileList {...props} />)
// Assert - Should show empty folder state
expect(screen.getByText('datasetPipeline.onlineDrive.emptyFolder')).toBeInTheDocument()
})
})
describe('selectedFileIds prop', () => {
it('should mark files as selected based on selectedFileIds', () => {
// Arrange
const fileList = [
createMockOnlineDriveFile({ id: 'file-1', name: 'file1.txt' }),
createMockOnlineDriveFile({ id: 'file-2', name: 'file2.txt' }),
]
const props = createDefaultProps({ fileList, selectedFileIds: ['file-1'] })
// Act
render(<FileList {...props} />)
// Assert - The checkbox for file-1 should be checked (check icon present)
expect(screen.getByTestId('checkbox-file-1')).toBeInTheDocument()
expect(screen.getByTestId('check-icon-file-1')).toBeInTheDocument()
expect(screen.getByTestId('checkbox-file-2')).toBeInTheDocument()
expect(screen.queryByTestId('check-icon-file-2')).not.toBeInTheDocument()
})
})
describe('keywords prop', () => {
it('should initialize input with keywords value', () => {
// Arrange
const props = createDefaultProps({ keywords: 'my-search' })
// Act
render(<FileList {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toHaveValue('my-search')
})
})
describe('isLoading prop', () => {
it('should show loading when isLoading is true with empty list', () => {
// Arrange
const props = createDefaultProps({ isLoading: true, fileList: [] })
// Act
const { container } = render(<FileList {...props} />)
// Assert - Loading component with spin-animation class
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
})
it('should show loading indicator at bottom when isLoading is true with files', () => {
// Arrange
const fileList = [createMockOnlineDriveFile()]
const props = createDefaultProps({ isLoading: true, fileList })
// Act
const { container } = render(<FileList {...props} />)
// Assert - Should show spinner icon at the bottom
expect(container.querySelector('.animation-spin')).toBeInTheDocument()
})
})
describe('supportBatchUpload prop', () => {
it('should render checkboxes when supportBatchUpload is true', () => {
// Arrange
const fileList = [createMockOnlineDriveFile({ id: 'file-1', name: 'file1.txt' })]
const props = createDefaultProps({ fileList, supportBatchUpload: true })
// Act
render(<FileList {...props} />)
// Assert - Checkbox component has data-testid="checkbox-{id}"
expect(screen.getByTestId('checkbox-file-1')).toBeInTheDocument()
})
it('should render radio buttons when supportBatchUpload is false', () => {
// Arrange
const fileList = [createMockOnlineDriveFile({ id: 'file-1', name: 'file1.txt' })]
const props = createDefaultProps({ fileList, supportBatchUpload: false })
// Act
const { container } = render(<FileList {...props} />)
// Assert - Radio is rendered as a div with rounded-full class
expect(container.querySelector('.rounded-full')).toBeInTheDocument()
// And checkbox should not be present
expect(screen.queryByTestId('checkbox-file-1')).not.toBeInTheDocument()
})
})
})
// ==========================================
// State Management Tests
// ==========================================
describe('State Management', () => {
describe('inputValue state', () => {
it('should initialize inputValue with keywords prop', () => {
// Arrange
const props = createDefaultProps({ keywords: 'initial-keyword' })
// Act
render(<FileList {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toHaveValue('initial-keyword')
})
it('should update inputValue when input changes', () => {
// Arrange
const props = createDefaultProps({ keywords: '' })
render(<FileList {...props} />)
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
// Act
fireEvent.change(input, { target: { value: 'new-value' } })
// Assert
expect(input).toHaveValue('new-value')
})
})
describe('debounced keywords update', () => {
it('should call updateKeywords with debounce when input changes', () => {
// Arrange
const mockUpdateKeywords = jest.fn()
const props = createDefaultProps({ updateKeywords: mockUpdateKeywords })
render(<FileList {...props} />)
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
// Act
fireEvent.change(input, { target: { value: 'debounced-value' } })
// Assert
expect(mockDebounceFnRun).toHaveBeenCalledWith('debounced-value')
})
})
})
// ==========================================
// Event Handlers Tests
// ==========================================
describe('Event Handlers', () => {
describe('handleInputChange', () => {
it('should update inputValue on input change', () => {
// Arrange
const props = createDefaultProps()
render(<FileList {...props} />)
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
// Act
fireEvent.change(input, { target: { value: 'typed-text' } })
// Assert
expect(input).toHaveValue('typed-text')
})
it('should trigger debounced updateKeywords on input change', () => {
// Arrange
const mockUpdateKeywords = jest.fn()
const props = createDefaultProps({ updateKeywords: mockUpdateKeywords })
render(<FileList {...props} />)
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
// Act
fireEvent.change(input, { target: { value: 'search-term' } })
// Assert
expect(mockDebounceFnRun).toHaveBeenCalledWith('search-term')
})
it('should handle multiple sequential input changes', () => {
// Arrange
const mockUpdateKeywords = jest.fn()
const props = createDefaultProps({ updateKeywords: mockUpdateKeywords })
render(<FileList {...props} />)
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
// Act
fireEvent.change(input, { target: { value: 'a' } })
fireEvent.change(input, { target: { value: 'ab' } })
fireEvent.change(input, { target: { value: 'abc' } })
// Assert
expect(mockDebounceFnRun).toHaveBeenCalledTimes(3)
expect(mockDebounceFnRun).toHaveBeenLastCalledWith('abc')
expect(input).toHaveValue('abc')
})
})
describe('handleResetKeywords', () => {
it('should call resetKeywords prop when clear button is clicked', () => {
// Arrange
const mockResetKeywords = jest.fn()
const props = createDefaultProps({ resetKeywords: mockResetKeywords, keywords: 'to-reset' })
const { container } = render(<FileList {...props} />)
// Act - Click the clear icon div (it contains RiCloseCircleFill icon)
const clearButton = container.querySelector('[class*="cursor-pointer"] svg[class*="h-3.5"]')?.parentElement
expect(clearButton).toBeInTheDocument()
fireEvent.click(clearButton!)
// Assert
expect(mockResetKeywords).toHaveBeenCalledTimes(1)
})
it('should reset inputValue to empty string when clear is clicked', () => {
// Arrange
const props = createDefaultProps({ keywords: 'to-be-reset' })
const { container } = render(<FileList {...props} />)
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
fireEvent.change(input, { target: { value: 'some-search' } })
// Act - Find and click the clear icon
const clearButton = container.querySelector('[class*="cursor-pointer"] svg[class*="h-3.5"]')?.parentElement
expect(clearButton).toBeInTheDocument()
fireEvent.click(clearButton!)
// Assert
expect(input).toHaveValue('')
})
})
describe('handleSelectFile', () => {
it('should call handleSelectFile when file item is clicked', () => {
// Arrange
const mockHandleSelectFile = jest.fn()
const fileList = [createMockOnlineDriveFile({ id: 'file-1', name: 'test.txt' })]
const props = createDefaultProps({ handleSelectFile: mockHandleSelectFile, fileList })
render(<FileList {...props} />)
// Act - Click on the file item
const fileItem = screen.getByText('test.txt')
fireEvent.click(fileItem.closest('[class*="cursor-pointer"]')!)
// Assert
expect(mockHandleSelectFile).toHaveBeenCalledWith(expect.objectContaining({
id: 'file-1',
name: 'test.txt',
type: OnlineDriveFileType.file,
}))
})
})
describe('handleOpenFolder', () => {
it('should call handleOpenFolder when folder item is clicked', () => {
// Arrange
const mockHandleOpenFolder = jest.fn()
const fileList = [createMockOnlineDriveFile({ id: 'folder-1', name: 'my-folder', type: OnlineDriveFileType.folder })]
const props = createDefaultProps({ handleOpenFolder: mockHandleOpenFolder, fileList })
render(<FileList {...props} />)
// Act - Click on the folder item
const folderItem = screen.getByText('my-folder')
fireEvent.click(folderItem.closest('[class*="cursor-pointer"]')!)
// Assert
expect(mockHandleOpenFolder).toHaveBeenCalledWith(expect.objectContaining({
id: 'folder-1',
name: 'my-folder',
type: OnlineDriveFileType.folder,
}))
})
})
})
// ==========================================
// Edge Cases and Error Handling
// ==========================================
describe('Edge Cases and Error Handling', () => {
it('should handle empty string keywords', () => {
// Arrange
const props = createDefaultProps({ keywords: '' })
// Act
render(<FileList {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toHaveValue('')
})
it('should handle special characters in keywords', () => {
// Arrange
const specialChars = 'test[file].txt (copy)'
const props = createDefaultProps({ keywords: specialChars })
// Act
render(<FileList {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toHaveValue(specialChars)
})
it('should handle unicode characters in keywords', () => {
// Arrange
const unicodeKeywords = '文件搜索 日本語'
const props = createDefaultProps({ keywords: unicodeKeywords })
// Act
render(<FileList {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toHaveValue(unicodeKeywords)
})
it('should handle very long file names in fileList', () => {
// Arrange
const longName = `${'a'.repeat(100)}.txt`
const fileList = [createMockOnlineDriveFile({ id: '1', name: longName })]
const props = createDefaultProps({ fileList })
// Act
render(<FileList {...props} />)
// Assert
expect(screen.getByText(longName)).toBeInTheDocument()
})
it('should handle large number of files', () => {
// Arrange
const fileList = Array.from({ length: 50 }, (_, i) =>
createMockOnlineDriveFile({ id: `file-${i}`, name: `file-${i}.txt` }),
)
const props = createDefaultProps({ fileList })
// Act
render(<FileList {...props} />)
// Assert - Check a few files exist
expect(screen.getByText('file-0.txt')).toBeInTheDocument()
expect(screen.getByText('file-49.txt')).toBeInTheDocument()
})
it('should handle whitespace-only keywords input', () => {
// Arrange
const props = createDefaultProps()
render(<FileList {...props} />)
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
// Act
fireEvent.change(input, { target: { value: ' ' } })
// Assert
expect(input).toHaveValue(' ')
expect(mockDebounceFnRun).toHaveBeenCalledWith(' ')
})
})
// ==========================================
// All Prop Variations Tests
// ==========================================
describe('Prop Variations', () => {
it.each([
{ isInPipeline: true, supportBatchUpload: true },
{ isInPipeline: true, supportBatchUpload: false },
{ isInPipeline: false, supportBatchUpload: true },
{ isInPipeline: false, supportBatchUpload: false },
])('should render correctly with isInPipeline=$isInPipeline and supportBatchUpload=$supportBatchUpload', (propVariation) => {
// Arrange
const props = createDefaultProps(propVariation)
// Act
render(<FileList {...props} />)
// Assert - Component should render without crashing
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
it.each([
{ isLoading: true, fileCount: 0, description: 'loading state with no files' },
{ isLoading: false, fileCount: 0, description: 'not loading with no files' },
{ isLoading: false, fileCount: 3, description: 'not loading with files' },
])('should handle $description correctly', ({ isLoading, fileCount }) => {
// Arrange
const fileList = Array.from({ length: fileCount }, (_, i) =>
createMockOnlineDriveFile({ id: `file-${i}`, name: `file-${i}.txt` }),
)
const props = createDefaultProps({ isLoading, fileList })
// Act
const { container } = render(<FileList {...props} />)
// Assert
if (isLoading && fileCount === 0)
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
else if (!isLoading && fileCount === 0)
expect(screen.getByText('datasetPipeline.onlineDrive.emptyFolder')).toBeInTheDocument()
else
expect(screen.getByText('file-0.txt')).toBeInTheDocument()
})
it.each([
{ keywords: '', searchResultsLength: 0 },
{ keywords: 'test', searchResultsLength: 5 },
{ keywords: 'not-found', searchResultsLength: 0 },
])('should render correctly with keywords="$keywords" and searchResultsLength=$searchResultsLength', ({ keywords, searchResultsLength }) => {
// Arrange
const props = createDefaultProps({ keywords, searchResultsLength })
// Act
render(<FileList {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toHaveValue(keywords)
})
})
// ==========================================
// File Type Variations
// ==========================================
describe('File Type Variations', () => {
it('should render folder type correctly', () => {
// Arrange
const fileList = [createMockOnlineDriveFile({ id: 'folder-1', name: 'my-folder', type: OnlineDriveFileType.folder })]
const props = createDefaultProps({ fileList })
// Act
render(<FileList {...props} />)
// Assert
expect(screen.getByText('my-folder')).toBeInTheDocument()
})
it('should render bucket type correctly', () => {
// Arrange
const fileList = [createMockOnlineDriveFile({ id: 'bucket-1', name: 'my-bucket', type: OnlineDriveFileType.bucket })]
const props = createDefaultProps({ fileList })
// Act
render(<FileList {...props} />)
// Assert
expect(screen.getByText('my-bucket')).toBeInTheDocument()
})
it('should render file with size', () => {
// Arrange
const fileList = [createMockOnlineDriveFile({ id: 'file-1', name: 'test.txt', size: 1024 })]
const props = createDefaultProps({ fileList })
// Act
render(<FileList {...props} />)
// Assert
expect(screen.getByText('test.txt')).toBeInTheDocument()
// formatFileSize returns '1.00 KB' for 1024 bytes
expect(screen.getByText('1.00 KB')).toBeInTheDocument()
})
it('should not show checkbox for bucket type', () => {
// Arrange
const fileList = [createMockOnlineDriveFile({ id: 'bucket-1', name: 'my-bucket', type: OnlineDriveFileType.bucket })]
const props = createDefaultProps({ fileList, supportBatchUpload: true })
// Act
render(<FileList {...props} />)
// Assert - No checkbox should be rendered for bucket
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
})
})
// ==========================================
// Search Results Display
// ==========================================
describe('Search Results Display', () => {
it('should show search results count when keywords and results exist', () => {
// Arrange
const props = createDefaultProps({
keywords: 'test',
searchResultsLength: 5,
breadcrumbs: ['folder1'],
})
// Act
render(<FileList {...props} />)
// Assert
expect(screen.getByText(/datasetPipeline\.onlineDrive\.breadcrumbs\.searchResult/)).toBeInTheDocument()
})
})
// ==========================================
// Callback Stability
// ==========================================
describe('Callback Stability', () => {
it('should maintain stable handleSelectFile callback', () => {
// Arrange
const mockHandleSelectFile = jest.fn()
const fileList = [createMockOnlineDriveFile({ id: 'file-1', name: 'test.txt' })]
const props = createDefaultProps({ handleSelectFile: mockHandleSelectFile, fileList })
const { rerender } = render(<FileList {...props} />)
// Act - Click once
const fileItem = screen.getByText('test.txt')
fireEvent.click(fileItem.closest('[class*="cursor-pointer"]')!)
// Rerender with same props
rerender(<FileList {...props} />)
// Click again
fireEvent.click(fileItem.closest('[class*="cursor-pointer"]')!)
// Assert
expect(mockHandleSelectFile).toHaveBeenCalledTimes(2)
})
it('should maintain stable handleOpenFolder callback', () => {
// Arrange
const mockHandleOpenFolder = jest.fn()
const fileList = [createMockOnlineDriveFile({ id: 'folder-1', name: 'my-folder', type: OnlineDriveFileType.folder })]
const props = createDefaultProps({ handleOpenFolder: mockHandleOpenFolder, fileList })
const { rerender } = render(<FileList {...props} />)
// Act - Click once
const folderItem = screen.getByText('my-folder')
fireEvent.click(folderItem.closest('[class*="cursor-pointer"]')!)
// Rerender with same props
rerender(<FileList {...props} />)
// Click again
fireEvent.click(folderItem.closest('[class*="cursor-pointer"]')!)
// Assert
expect(mockHandleOpenFolder).toHaveBeenCalledTimes(2)
})
})
})

View File

@@ -0,0 +1,947 @@
import { fireEvent, render, screen } from '@testing-library/react'
import React from 'react'
import CheckboxWithLabel from './checkbox-with-label'
import CrawledResultItem from './crawled-result-item'
import CrawledResult from './crawled-result'
import Crawling from './crawling'
import ErrorMessage from './error-message'
import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets'
// ==========================================
// Test Data Builders
// ==========================================
const createMockCrawlResultItem = (overrides?: Partial<CrawlResultItemType>): CrawlResultItemType => ({
source_url: 'https://example.com/page1',
title: 'Test Page Title',
markdown: '# Test content',
description: 'Test description',
...overrides,
})
const createMockCrawlResultItems = (count = 3): CrawlResultItemType[] => {
return Array.from({ length: count }, (_, i) =>
createMockCrawlResultItem({
source_url: `https://example.com/page${i + 1}`,
title: `Page ${i + 1}`,
}),
)
}
// ==========================================
// CheckboxWithLabel Tests
// ==========================================
describe('CheckboxWithLabel', () => {
const defaultProps = {
isChecked: false,
onChange: jest.fn(),
label: 'Test Label',
}
beforeEach(() => {
jest.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
render(<CheckboxWithLabel {...defaultProps} />)
// Assert
expect(screen.getByText('Test Label')).toBeInTheDocument()
})
it('should render checkbox in unchecked state', () => {
// Arrange & Act
const { container } = render(<CheckboxWithLabel {...defaultProps} isChecked={false} />)
// Assert - Custom checkbox component uses div with data-testid
const checkbox = container.querySelector('[data-testid^="checkbox"]')
expect(checkbox).toBeInTheDocument()
expect(checkbox).not.toHaveClass('bg-components-checkbox-bg')
})
it('should render checkbox in checked state', () => {
// Arrange & Act
const { container } = render(<CheckboxWithLabel {...defaultProps} isChecked={true} />)
// Assert - Checked state has check icon
const checkIcon = container.querySelector('[data-testid^="check-icon"]')
expect(checkIcon).toBeInTheDocument()
})
it('should render tooltip when provided', () => {
// Arrange & Act
render(<CheckboxWithLabel {...defaultProps} tooltip="Helpful tooltip text" />)
// Assert - Tooltip trigger should be present
const tooltipTrigger = document.querySelector('[class*="ml-0.5"]')
expect(tooltipTrigger).toBeInTheDocument()
})
it('should not render tooltip when not provided', () => {
// Arrange & Act
render(<CheckboxWithLabel {...defaultProps} />)
// Assert
const tooltipTrigger = document.querySelector('[class*="ml-0.5"]')
expect(tooltipTrigger).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
// Arrange & Act
const { container } = render(
<CheckboxWithLabel {...defaultProps} className="custom-class" />,
)
// Assert
const label = container.querySelector('label')
expect(label).toHaveClass('custom-class')
})
it('should apply custom labelClassName', () => {
// Arrange & Act
render(<CheckboxWithLabel {...defaultProps} labelClassName="custom-label-class" />)
// Assert
const labelText = screen.getByText('Test Label')
expect(labelText).toHaveClass('custom-label-class')
})
})
describe('User Interactions', () => {
it('should call onChange with true when clicking unchecked checkbox', () => {
// Arrange
const mockOnChange = jest.fn()
const { container } = render(<CheckboxWithLabel {...defaultProps} isChecked={false} onChange={mockOnChange} />)
// Act
const checkbox = container.querySelector('[data-testid^="checkbox"]')!
fireEvent.click(checkbox)
// Assert
expect(mockOnChange).toHaveBeenCalledWith(true)
})
it('should call onChange with false when clicking checked checkbox', () => {
// Arrange
const mockOnChange = jest.fn()
const { container } = render(<CheckboxWithLabel {...defaultProps} isChecked={true} onChange={mockOnChange} />)
// Act
const checkbox = container.querySelector('[data-testid^="checkbox"]')!
fireEvent.click(checkbox)
// Assert
expect(mockOnChange).toHaveBeenCalledWith(false)
})
it('should not trigger onChange when clicking label text due to custom checkbox', () => {
// Arrange
const mockOnChange = jest.fn()
render(<CheckboxWithLabel {...defaultProps} onChange={mockOnChange} />)
// Act - Click on the label text element
const labelText = screen.getByText('Test Label')
fireEvent.click(labelText)
// Assert - Custom checkbox does not support native label-input click forwarding
expect(mockOnChange).not.toHaveBeenCalled()
})
})
})
// ==========================================
// CrawledResultItem Tests
// ==========================================
describe('CrawledResultItem', () => {
const defaultProps = {
payload: createMockCrawlResultItem(),
isChecked: false,
onCheckChange: jest.fn(),
isPreview: false,
showPreview: true,
onPreview: jest.fn(),
}
beforeEach(() => {
jest.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
render(<CrawledResultItem {...defaultProps} />)
// Assert
expect(screen.getByText('Test Page Title')).toBeInTheDocument()
expect(screen.getByText('https://example.com/page1')).toBeInTheDocument()
})
it('should render checkbox when isMultipleChoice is true', () => {
// Arrange & Act
const { container } = render(<CrawledResultItem {...defaultProps} isMultipleChoice={true} />)
// Assert - Custom checkbox uses data-testid
const checkbox = container.querySelector('[data-testid^="checkbox"]')
expect(checkbox).toBeInTheDocument()
})
it('should render radio when isMultipleChoice is false', () => {
// Arrange & Act
const { container } = render(<CrawledResultItem {...defaultProps} isMultipleChoice={false} />)
// Assert - Radio component has size-4 rounded-full classes
const radio = container.querySelector('.size-4.rounded-full')
expect(radio).toBeInTheDocument()
})
it('should render checkbox as checked when isChecked is true', () => {
// Arrange & Act
const { container } = render(<CrawledResultItem {...defaultProps} isChecked={true} />)
// Assert - Checked state shows check icon
const checkIcon = container.querySelector('[data-testid^="check-icon"]')
expect(checkIcon).toBeInTheDocument()
})
it('should render preview button when showPreview is true', () => {
// Arrange & Act
render(<CrawledResultItem {...defaultProps} showPreview={true} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should not render preview button when showPreview is false', () => {
// Arrange & Act
render(<CrawledResultItem {...defaultProps} showPreview={false} />)
// Assert
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('should apply active background when isPreview is true', () => {
// Arrange & Act
const { container } = render(<CrawledResultItem {...defaultProps} isPreview={true} />)
// Assert
const item = container.firstChild
expect(item).toHaveClass('bg-state-base-active')
})
it('should apply hover styles when isPreview is false', () => {
// Arrange & Act
const { container } = render(<CrawledResultItem {...defaultProps} isPreview={false} />)
// Assert
const item = container.firstChild
expect(item).toHaveClass('group')
expect(item).toHaveClass('hover:bg-state-base-hover')
})
})
describe('Props', () => {
it('should display payload title', () => {
// Arrange
const payload = createMockCrawlResultItem({ title: 'Custom Title' })
// Act
render(<CrawledResultItem {...defaultProps} payload={payload} />)
// Assert
expect(screen.getByText('Custom Title')).toBeInTheDocument()
})
it('should display payload source_url', () => {
// Arrange
const payload = createMockCrawlResultItem({ source_url: 'https://custom.url/path' })
// Act
render(<CrawledResultItem {...defaultProps} payload={payload} />)
// Assert
expect(screen.getByText('https://custom.url/path')).toBeInTheDocument()
})
it('should set title attribute for truncation tooltip', () => {
// Arrange
const payload = createMockCrawlResultItem({ title: 'Very Long Title' })
// Act
render(<CrawledResultItem {...defaultProps} payload={payload} />)
// Assert
const titleElement = screen.getByText('Very Long Title')
expect(titleElement).toHaveAttribute('title', 'Very Long Title')
})
})
describe('User Interactions', () => {
it('should call onCheckChange with true when clicking unchecked checkbox', () => {
// Arrange
const mockOnCheckChange = jest.fn()
const { container } = render(
<CrawledResultItem
{...defaultProps}
isChecked={false}
onCheckChange={mockOnCheckChange}
/>,
)
// Act
const checkbox = container.querySelector('[data-testid^="checkbox"]')!
fireEvent.click(checkbox)
// Assert
expect(mockOnCheckChange).toHaveBeenCalledWith(true)
})
it('should call onCheckChange with false when clicking checked checkbox', () => {
// Arrange
const mockOnCheckChange = jest.fn()
const { container } = render(
<CrawledResultItem
{...defaultProps}
isChecked={true}
onCheckChange={mockOnCheckChange}
/>,
)
// Act
const checkbox = container.querySelector('[data-testid^="checkbox"]')!
fireEvent.click(checkbox)
// Assert
expect(mockOnCheckChange).toHaveBeenCalledWith(false)
})
it('should call onPreview when clicking preview button', () => {
// Arrange
const mockOnPreview = jest.fn()
render(<CrawledResultItem {...defaultProps} onPreview={mockOnPreview} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
expect(mockOnPreview).toHaveBeenCalled()
})
it('should toggle radio state when isMultipleChoice is false', () => {
// Arrange
const mockOnCheckChange = jest.fn()
const { container } = render(
<CrawledResultItem
{...defaultProps}
isMultipleChoice={false}
isChecked={false}
onCheckChange={mockOnCheckChange}
/>,
)
// Act - Radio uses size-4 rounded-full classes
const radio = container.querySelector('.size-4.rounded-full')!
fireEvent.click(radio)
// Assert
expect(mockOnCheckChange).toHaveBeenCalledWith(true)
})
})
})
// ==========================================
// CrawledResult Tests
// ==========================================
describe('CrawledResult', () => {
const defaultProps = {
list: createMockCrawlResultItems(3),
checkedList: [] as CrawlResultItemType[],
onSelectedChange: jest.fn(),
usedTime: 1.5,
}
beforeEach(() => {
jest.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
render(<CrawledResult {...defaultProps} />)
// Assert - Check for time info which contains total count
expect(screen.getByText(/1.5/)).toBeInTheDocument()
})
it('should render all list items', () => {
// Arrange & Act
render(<CrawledResult {...defaultProps} />)
// Assert
expect(screen.getByText('Page 1')).toBeInTheDocument()
expect(screen.getByText('Page 2')).toBeInTheDocument()
expect(screen.getByText('Page 3')).toBeInTheDocument()
})
it('should display scrape time info', () => {
// Arrange & Act
render(<CrawledResult {...defaultProps} usedTime={2.5} />)
// Assert - Check for the time display
expect(screen.getByText(/2.5/)).toBeInTheDocument()
})
it('should render select all checkbox when isMultipleChoice is true', () => {
// Arrange & Act
const { container } = render(<CrawledResult {...defaultProps} isMultipleChoice={true} />)
// Assert - Multiple custom checkboxes (select all + items)
const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]')
expect(checkboxes.length).toBe(4) // 1 select all + 3 items
})
it('should not render select all checkbox when isMultipleChoice is false', () => {
// Arrange & Act
const { container } = render(<CrawledResult {...defaultProps} isMultipleChoice={false} />)
// Assert - No select all checkbox, only radio buttons for items
const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]')
expect(checkboxes.length).toBe(0)
// Radio buttons have size-4 and rounded-full classes
const radios = container.querySelectorAll('.size-4.rounded-full')
expect(radios.length).toBe(3)
})
it('should show "Select All" when not all items are checked', () => {
// Arrange & Act
render(<CrawledResult {...defaultProps} checkedList={[]} />)
// Assert
expect(screen.getByText(/selectAll|Select All/i)).toBeInTheDocument()
})
it('should show "Reset All" when all items are checked', () => {
// Arrange
const allChecked = createMockCrawlResultItems(3)
// Act
render(<CrawledResult {...defaultProps} checkedList={allChecked} />)
// Assert
expect(screen.getByText(/resetAll|Reset All/i)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
// Arrange & Act
const { container } = render(
<CrawledResult {...defaultProps} className="custom-class" />,
)
// Assert
expect(container.firstChild).toHaveClass('custom-class')
})
it('should highlight item at previewIndex', () => {
// Arrange & Act
const { container } = render(
<CrawledResult {...defaultProps} previewIndex={1} />,
)
// Assert - Second item should have active state
const items = container.querySelectorAll('[class*="rounded-lg"][class*="cursor-pointer"]')
expect(items[1]).toHaveClass('bg-state-base-active')
})
it('should pass showPreview to items', () => {
// Arrange & Act
render(<CrawledResult {...defaultProps} showPreview={true} />)
// Assert - Preview buttons should be visible
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBe(3)
})
it('should not show preview buttons when showPreview is false', () => {
// Arrange & Act
render(<CrawledResult {...defaultProps} showPreview={false} />)
// Assert
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onSelectedChange with all items when clicking select all', () => {
// Arrange
const mockOnSelectedChange = jest.fn()
const list = createMockCrawlResultItems(3)
const { container } = render(
<CrawledResult
{...defaultProps}
list={list}
checkedList={[]}
onSelectedChange={mockOnSelectedChange}
/>,
)
// Act - Click select all checkbox (first checkbox)
const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]')
fireEvent.click(checkboxes[0])
// Assert
expect(mockOnSelectedChange).toHaveBeenCalledWith(list)
})
it('should call onSelectedChange with empty array when clicking reset all', () => {
// Arrange
const mockOnSelectedChange = jest.fn()
const list = createMockCrawlResultItems(3)
const { container } = render(
<CrawledResult
{...defaultProps}
list={list}
checkedList={list}
onSelectedChange={mockOnSelectedChange}
/>,
)
// Act
const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]')
fireEvent.click(checkboxes[0])
// Assert
expect(mockOnSelectedChange).toHaveBeenCalledWith([])
})
it('should add item to checkedList when checking unchecked item', () => {
// Arrange
const mockOnSelectedChange = jest.fn()
const list = createMockCrawlResultItems(3)
const { container } = render(
<CrawledResult
{...defaultProps}
list={list}
checkedList={[list[0]]}
onSelectedChange={mockOnSelectedChange}
/>,
)
// Act - Click second item checkbox (index 2, accounting for select all)
const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]')
fireEvent.click(checkboxes[2])
// Assert
expect(mockOnSelectedChange).toHaveBeenCalledWith([list[0], list[1]])
})
it('should remove item from checkedList when unchecking checked item', () => {
// Arrange
const mockOnSelectedChange = jest.fn()
const list = createMockCrawlResultItems(3)
const { container } = render(
<CrawledResult
{...defaultProps}
list={list}
checkedList={[list[0], list[1]]}
onSelectedChange={mockOnSelectedChange}
/>,
)
// Act - Uncheck first item (index 1, after select all)
const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]')
fireEvent.click(checkboxes[1])
// Assert
expect(mockOnSelectedChange).toHaveBeenCalledWith([list[1]])
})
it('should replace selection when checking in single choice mode', () => {
// Arrange
const mockOnSelectedChange = jest.fn()
const list = createMockCrawlResultItems(3)
const { container } = render(
<CrawledResult
{...defaultProps}
list={list}
checkedList={[list[0]]}
onSelectedChange={mockOnSelectedChange}
isMultipleChoice={false}
/>,
)
// Act - Click second item radio (Radio uses size-4 rounded-full classes)
const radios = container.querySelectorAll('.size-4.rounded-full')
fireEvent.click(radios[1])
// Assert - Should only select the clicked item
expect(mockOnSelectedChange).toHaveBeenCalledWith([list[1]])
})
it('should call onPreview with item and index when clicking preview', () => {
// Arrange
const mockOnPreview = jest.fn()
const list = createMockCrawlResultItems(3)
render(
<CrawledResult
{...defaultProps}
list={list}
onPreview={mockOnPreview}
showPreview={true}
/>,
)
// Act
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[1]) // Second item's preview button
// Assert
expect(mockOnPreview).toHaveBeenCalledWith(list[1], 1)
})
it('should not crash when clicking preview without onPreview callback', () => {
// Arrange - showPreview is true but onPreview is undefined
const list = createMockCrawlResultItems(3)
render(
<CrawledResult
{...defaultProps}
list={list}
onPreview={undefined}
showPreview={true}
/>,
)
// Act - Click preview button should trigger early return in handlePreview
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[0])
// Assert - Should not throw error, component still renders
expect(screen.getByText('Page 1')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty list', () => {
// Arrange & Act
render(<CrawledResult {...defaultProps} list={[]} usedTime={0.5} />)
// Assert - Should show time info with 0 count
expect(screen.getByText(/0.5/)).toBeInTheDocument()
})
it('should handle single item list', () => {
// Arrange
const singleItem = [createMockCrawlResultItem()]
// Act
render(<CrawledResult {...defaultProps} list={singleItem} />)
// Assert
expect(screen.getByText('Test Page Title')).toBeInTheDocument()
})
it('should format usedTime to one decimal place', () => {
// Arrange & Act
render(<CrawledResult {...defaultProps} usedTime={1.567} />)
// Assert
expect(screen.getByText(/1.6/)).toBeInTheDocument()
})
})
})
// ==========================================
// Crawling Tests
// ==========================================
describe('Crawling', () => {
const defaultProps = {
crawledNum: 5,
totalNum: 10,
}
beforeEach(() => {
jest.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
render(<Crawling {...defaultProps} />)
// Assert
expect(screen.getByText(/5\/10/)).toBeInTheDocument()
})
it('should display crawled count and total', () => {
// Arrange & Act
render(<Crawling crawledNum={3} totalNum={15} />)
// Assert
expect(screen.getByText(/3\/15/)).toBeInTheDocument()
})
it('should render skeleton items', () => {
// Arrange & Act
const { container } = render(<Crawling {...defaultProps} />)
// Assert - Should have 3 skeleton items
const skeletonItems = container.querySelectorAll('.px-2.py-\\[5px\\]')
expect(skeletonItems.length).toBe(3)
})
it('should render header skeleton block', () => {
// Arrange & Act
const { container } = render(<Crawling {...defaultProps} />)
// Assert
const headerBlocks = container.querySelectorAll('.px-4.py-2 .bg-text-quaternary')
expect(headerBlocks.length).toBeGreaterThan(0)
})
})
describe('Props', () => {
it('should apply custom className', () => {
// Arrange & Act
const { container } = render(
<Crawling {...defaultProps} className="custom-crawling-class" />,
)
// Assert
expect(container.firstChild).toHaveClass('custom-crawling-class')
})
it('should handle zero values', () => {
// Arrange & Act
render(<Crawling crawledNum={0} totalNum={0} />)
// Assert
expect(screen.getByText(/0\/0/)).toBeInTheDocument()
})
it('should handle large numbers', () => {
// Arrange & Act
render(<Crawling crawledNum={999} totalNum={1000} />)
// Assert
expect(screen.getByText(/999\/1000/)).toBeInTheDocument()
})
})
describe('Skeleton Structure', () => {
it('should render blocks with correct width classes', () => {
// Arrange & Act
const { container } = render(<Crawling {...defaultProps} />)
// Assert - Check for various width classes
expect(container.querySelector('.w-\\[35\\%\\]')).toBeInTheDocument()
expect(container.querySelector('.w-\\[50\\%\\]')).toBeInTheDocument()
expect(container.querySelector('.w-\\[40\\%\\]')).toBeInTheDocument()
})
})
})
// ==========================================
// ErrorMessage Tests
// ==========================================
describe('ErrorMessage', () => {
const defaultProps = {
title: 'Error Title',
}
beforeEach(() => {
jest.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
render(<ErrorMessage {...defaultProps} />)
// Assert
expect(screen.getByText('Error Title')).toBeInTheDocument()
})
it('should render error icon', () => {
// Arrange & Act
const { container } = render(<ErrorMessage {...defaultProps} />)
// Assert
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
expect(icon).toHaveClass('text-text-destructive')
})
it('should render title', () => {
// Arrange & Act
render(<ErrorMessage title="Custom Error Title" />)
// Assert
expect(screen.getByText('Custom Error Title')).toBeInTheDocument()
})
it('should render error message when provided', () => {
// Arrange & Act
render(<ErrorMessage {...defaultProps} errorMsg="Detailed error description" />)
// Assert
expect(screen.getByText('Detailed error description')).toBeInTheDocument()
})
it('should not render error message when not provided', () => {
// Arrange & Act
render(<ErrorMessage {...defaultProps} />)
// Assert - Should only have title, not error message container
const textElements = screen.getAllByText(/Error Title/)
expect(textElements.length).toBe(1)
})
})
describe('Props', () => {
it('should apply custom className', () => {
// Arrange & Act
const { container } = render(
<ErrorMessage {...defaultProps} className="custom-error-class" />,
)
// Assert
expect(container.firstChild).toHaveClass('custom-error-class')
})
it('should render with empty errorMsg', () => {
// Arrange & Act
render(<ErrorMessage {...defaultProps} errorMsg="" />)
// Assert - Empty string should not render message div
expect(screen.getByText('Error Title')).toBeInTheDocument()
})
it('should handle long title text', () => {
// Arrange
const longTitle = 'This is a very long error title that might wrap to multiple lines'
// Act
render(<ErrorMessage title={longTitle} />)
// Assert
expect(screen.getByText(longTitle)).toBeInTheDocument()
})
it('should handle long error message', () => {
// Arrange
const longErrorMsg = 'This is a very detailed error message explaining what went wrong and how to fix it. It contains multiple sentences.'
// Act
render(<ErrorMessage {...defaultProps} errorMsg={longErrorMsg} />)
// Assert
expect(screen.getByText(longErrorMsg)).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have error background styling', () => {
// Arrange & Act
const { container } = render(<ErrorMessage {...defaultProps} />)
// Assert
expect(container.firstChild).toHaveClass('bg-toast-error-bg')
})
it('should have border styling', () => {
// Arrange & Act
const { container } = render(<ErrorMessage {...defaultProps} />)
// Assert
expect(container.firstChild).toHaveClass('border-components-panel-border')
})
it('should have rounded corners', () => {
// Arrange & Act
const { container } = render(<ErrorMessage {...defaultProps} />)
// Assert
expect(container.firstChild).toHaveClass('rounded-xl')
})
})
})
// ==========================================
// Integration Tests
// ==========================================
describe('Base Components Integration', () => {
it('should render CrawledResult with CrawledResultItem children', () => {
// Arrange
const list = createMockCrawlResultItems(2)
// Act
render(
<CrawledResult
list={list}
checkedList={[]}
onSelectedChange={jest.fn()}
usedTime={1.0}
/>,
)
// Assert - Both items should render
expect(screen.getByText('Page 1')).toBeInTheDocument()
expect(screen.getByText('Page 2')).toBeInTheDocument()
})
it('should render CrawledResult with CheckboxWithLabel for select all', () => {
// Arrange
const list = createMockCrawlResultItems(2)
// Act
const { container } = render(
<CrawledResult
list={list}
checkedList={[]}
onSelectedChange={jest.fn()}
usedTime={1.0}
isMultipleChoice={true}
/>,
)
// Assert - Should have select all checkbox + item checkboxes
const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]')
expect(checkboxes.length).toBe(3) // select all + 2 items
})
it('should allow selecting and previewing items', () => {
// Arrange
const list = createMockCrawlResultItems(3)
const mockOnSelectedChange = jest.fn()
const mockOnPreview = jest.fn()
const { container } = render(
<CrawledResult
list={list}
checkedList={[]}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
showPreview={true}
usedTime={1.0}
/>,
)
// Act - Select first item (index 1, after select all)
const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]')
fireEvent.click(checkboxes[1])
// Assert
expect(mockOnSelectedChange).toHaveBeenCalledWith([list[0]])
// Act - Preview second item
const previewButtons = screen.getAllByRole('button')
fireEvent.click(previewButtons[1])
// Assert
expect(mockOnPreview).toHaveBeenCalledWith(list[1], 1)
})
})