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,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)
})
})
})