test: add comprehensive unit tests for JinaReader and WaterCrawl comp… (#29768)

Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
This commit is contained in:
Coding On Star
2025-12-18 10:05:53 +08:00
committed by GitHub
parent b3e5d45755
commit 69eab28da1
16 changed files with 9288 additions and 3 deletions

View File

@@ -0,0 +1,873 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import FilePreview from './index'
import type { CustomFile as File } from '@/models/datasets'
import { fetchFilePreview } from '@/service/common'
// Mock the fetchFilePreview service
jest.mock('@/service/common', () => ({
fetchFilePreview: jest.fn(),
}))
const mockFetchFilePreview = fetchFilePreview as jest.MockedFunction<typeof fetchFilePreview>
// Factory function to create mock file objects
const createMockFile = (overrides: Partial<File> = {}): File => {
const file = new window.File(['test content'], 'test-file.txt', {
type: 'text/plain',
}) as File
return Object.assign(file, {
id: 'file-123',
extension: 'txt',
mime_type: 'text/plain',
created_by: 'user-1',
created_at: Date.now(),
...overrides,
})
}
// Helper to render FilePreview with default props
const renderFilePreview = (props: Partial<{ file?: File; hidePreview: () => void }> = {}) => {
const defaultProps = {
file: createMockFile(),
hidePreview: jest.fn(),
...props,
}
return {
...render(<FilePreview {...defaultProps} />),
props: defaultProps,
}
}
// Helper to find the loading spinner element
const findLoadingSpinner = (container: HTMLElement) => {
return container.querySelector('.spin-animation')
}
// ============================================================================
// FilePreview Component Tests
// ============================================================================
describe('FilePreview', () => {
beforeEach(() => {
jest.clearAllMocks()
// Default successful API response
mockFetchFilePreview.mockResolvedValue({ content: 'Preview content here' })
})
// --------------------------------------------------------------------------
// Rendering Tests - Verify component renders properly
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', async () => {
// Arrange & Act
renderFilePreview()
// Assert
await waitFor(() => {
expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument()
})
})
it('should render file preview header', async () => {
// Arrange & Act
renderFilePreview()
// Assert
expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument()
})
it('should render close button with XMarkIcon', async () => {
// Arrange & Act
const { container } = renderFilePreview()
// Assert
const closeButton = container.querySelector('.cursor-pointer')
expect(closeButton).toBeInTheDocument()
const xMarkIcon = closeButton?.querySelector('svg')
expect(xMarkIcon).toBeInTheDocument()
})
it('should render file name without extension', async () => {
// Arrange
const file = createMockFile({ name: 'document.pdf' })
// Act
renderFilePreview({ file })
// Assert
await waitFor(() => {
expect(screen.getByText('document')).toBeInTheDocument()
})
})
it('should render file extension', async () => {
// Arrange
const file = createMockFile({ extension: 'pdf' })
// Act
renderFilePreview({ file })
// Assert
expect(screen.getByText('.pdf')).toBeInTheDocument()
})
it('should apply correct CSS classes to container', async () => {
// Arrange & Act
const { container } = renderFilePreview()
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('h-full')
})
})
// --------------------------------------------------------------------------
// Loading State Tests
// --------------------------------------------------------------------------
describe('Loading State', () => {
it('should show loading indicator initially', async () => {
// Arrange - Delay API response to keep loading state
mockFetchFilePreview.mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve({ content: 'test' }), 100)),
)
// Act
const { container } = renderFilePreview()
// Assert - Loading should be visible initially (using spin-animation class)
const loadingElement = findLoadingSpinner(container)
expect(loadingElement).toBeInTheDocument()
})
it('should hide loading indicator after content loads', async () => {
// Arrange
mockFetchFilePreview.mockResolvedValue({ content: 'Loaded content' })
// Act
const { container } = renderFilePreview()
// Assert
await waitFor(() => {
expect(screen.getByText('Loaded content')).toBeInTheDocument()
})
// Loading should be gone
const loadingElement = findLoadingSpinner(container)
expect(loadingElement).not.toBeInTheDocument()
})
it('should show loading when file changes', async () => {
// Arrange
const file1 = createMockFile({ id: 'file-1', name: 'file1.txt' })
const file2 = createMockFile({ id: 'file-2', name: 'file2.txt' })
let resolveFirst: (value: { content: string }) => void
let resolveSecond: (value: { content: string }) => void
mockFetchFilePreview
.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve }))
.mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve }))
// Act - Initial render
const { rerender, container } = render(
<FilePreview file={file1} hidePreview={jest.fn()} />,
)
// First file loading - spinner should be visible
expect(findLoadingSpinner(container)).toBeInTheDocument()
// Resolve first file
await act(async () => {
resolveFirst({ content: 'Content 1' })
})
await waitFor(() => {
expect(screen.getByText('Content 1')).toBeInTheDocument()
})
// Rerender with new file
rerender(<FilePreview file={file2} hidePreview={jest.fn()} />)
// Should show loading again
await waitFor(() => {
expect(findLoadingSpinner(container)).toBeInTheDocument()
})
// Resolve second file
await act(async () => {
resolveSecond({ content: 'Content 2' })
})
await waitFor(() => {
expect(screen.getByText('Content 2')).toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// API Call Tests
// --------------------------------------------------------------------------
describe('API Calls', () => {
it('should call fetchFilePreview with correct fileID', async () => {
// Arrange
const file = createMockFile({ id: 'test-file-id' })
// Act
renderFilePreview({ file })
// Assert
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledWith({ fileID: 'test-file-id' })
})
})
it('should not call fetchFilePreview when file is undefined', async () => {
// Arrange & Act
renderFilePreview({ file: undefined })
// Assert
expect(mockFetchFilePreview).not.toHaveBeenCalled()
})
it('should not call fetchFilePreview when file has no id', async () => {
// Arrange
const file = createMockFile({ id: undefined })
// Act
renderFilePreview({ file })
// Assert
expect(mockFetchFilePreview).not.toHaveBeenCalled()
})
it('should call fetchFilePreview again when file changes', async () => {
// Arrange
const file1 = createMockFile({ id: 'file-1' })
const file2 = createMockFile({ id: 'file-2' })
// Act
const { rerender } = render(
<FilePreview file={file1} hidePreview={jest.fn()} />,
)
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledWith({ fileID: 'file-1' })
})
rerender(<FilePreview file={file2} hidePreview={jest.fn()} />)
// Assert
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledWith({ fileID: 'file-2' })
expect(mockFetchFilePreview).toHaveBeenCalledTimes(2)
})
})
it('should handle API success and display content', async () => {
// Arrange
mockFetchFilePreview.mockResolvedValue({ content: 'File preview content from API' })
// Act
renderFilePreview()
// Assert
await waitFor(() => {
expect(screen.getByText('File preview content from API')).toBeInTheDocument()
})
})
it('should handle API error gracefully', async () => {
// Arrange
mockFetchFilePreview.mockRejectedValue(new Error('Network error'))
// Act
const { container } = renderFilePreview()
// Assert - Component should not crash, loading may persist
await waitFor(() => {
expect(container.firstChild).toBeInTheDocument()
})
// No error thrown, component still rendered
expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument()
})
it('should handle empty content response', async () => {
// Arrange
mockFetchFilePreview.mockResolvedValue({ content: '' })
// Act
const { container } = renderFilePreview()
// Assert - Should still render without loading
await waitFor(() => {
const loadingElement = findLoadingSpinner(container)
expect(loadingElement).not.toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call hidePreview when close button is clicked', async () => {
// Arrange
const hidePreview = jest.fn()
const { container } = renderFilePreview({ hidePreview })
// Act
const closeButton = container.querySelector('.cursor-pointer') as HTMLElement
fireEvent.click(closeButton)
// Assert
expect(hidePreview).toHaveBeenCalledTimes(1)
})
it('should call hidePreview with event object when clicked', async () => {
// Arrange
const hidePreview = jest.fn()
const { container } = renderFilePreview({ hidePreview })
// Act
const closeButton = container.querySelector('.cursor-pointer') as HTMLElement
fireEvent.click(closeButton)
// Assert - onClick receives the event object
expect(hidePreview).toHaveBeenCalled()
expect(hidePreview.mock.calls[0][0]).toBeDefined()
})
it('should handle multiple clicks on close button', async () => {
// Arrange
const hidePreview = jest.fn()
const { container } = renderFilePreview({ hidePreview })
// Act
const closeButton = container.querySelector('.cursor-pointer') as HTMLElement
fireEvent.click(closeButton)
fireEvent.click(closeButton)
fireEvent.click(closeButton)
// Assert
expect(hidePreview).toHaveBeenCalledTimes(3)
})
})
// --------------------------------------------------------------------------
// State Management Tests
// --------------------------------------------------------------------------
describe('State Management', () => {
it('should initialize with loading state true', async () => {
// Arrange - Keep loading indefinitely (never resolves)
mockFetchFilePreview.mockImplementation(() => new Promise(() => { /* intentionally empty */ }))
// Act
const { container } = renderFilePreview()
// Assert
const loadingElement = findLoadingSpinner(container)
expect(loadingElement).toBeInTheDocument()
})
it('should update previewContent state after successful fetch', async () => {
// Arrange
mockFetchFilePreview.mockResolvedValue({ content: 'New preview content' })
// Act
renderFilePreview()
// Assert
await waitFor(() => {
expect(screen.getByText('New preview content')).toBeInTheDocument()
})
})
it('should reset loading to true when file changes', async () => {
// Arrange
const file1 = createMockFile({ id: 'file-1' })
const file2 = createMockFile({ id: 'file-2' })
mockFetchFilePreview
.mockResolvedValueOnce({ content: 'Content 1' })
.mockImplementationOnce(() => new Promise(() => { /* never resolves */ }))
// Act
const { rerender, container } = render(
<FilePreview file={file1} hidePreview={jest.fn()} />,
)
await waitFor(() => {
expect(screen.getByText('Content 1')).toBeInTheDocument()
})
// Change file
rerender(<FilePreview file={file2} hidePreview={jest.fn()} />)
// Assert - Loading should be shown again
await waitFor(() => {
const loadingElement = findLoadingSpinner(container)
expect(loadingElement).toBeInTheDocument()
})
})
it('should preserve content until new content loads', async () => {
// Arrange
const file1 = createMockFile({ id: 'file-1' })
const file2 = createMockFile({ id: 'file-2' })
let resolveSecond: (value: { content: string }) => void
mockFetchFilePreview
.mockResolvedValueOnce({ content: 'Content 1' })
.mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve }))
// Act
const { rerender } = render(
<FilePreview file={file1} hidePreview={jest.fn()} />,
)
await waitFor(() => {
expect(screen.getByText('Content 1')).toBeInTheDocument()
})
// Change file - loading should replace content
rerender(<FilePreview file={file2} hidePreview={jest.fn()} />)
// Resolve second fetch
await act(async () => {
resolveSecond({ content: 'Content 2' })
})
await waitFor(() => {
expect(screen.getByText('Content 2')).toBeInTheDocument()
expect(screen.queryByText('Content 1')).not.toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// Props Testing
// --------------------------------------------------------------------------
describe('Props', () => {
describe('file prop', () => {
it('should render correctly with file prop', async () => {
// Arrange
const file = createMockFile({ name: 'my-document.pdf', extension: 'pdf' })
// Act
renderFilePreview({ file })
// Assert
expect(screen.getByText('my-document')).toBeInTheDocument()
expect(screen.getByText('.pdf')).toBeInTheDocument()
})
it('should render correctly without file prop', async () => {
// Arrange & Act
renderFilePreview({ file: undefined })
// Assert - Header should still render
expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument()
})
it('should handle file with multiple dots in name', async () => {
// Arrange
const file = createMockFile({ name: 'my.document.v2.pdf' })
// Act
renderFilePreview({ file })
// Assert - Should join all parts except last with comma
expect(screen.getByText('my,document,v2')).toBeInTheDocument()
})
it('should handle file with no extension in name', async () => {
// Arrange
const file = createMockFile({ name: 'README' })
// Act
const { container } = renderFilePreview({ file })
// Assert - getFileName returns empty for single segment, but component still renders
const fileNameElement = container.querySelector('.fileName')
expect(fileNameElement).toBeInTheDocument()
// The first span (file name) should be empty
const fileNameSpan = fileNameElement?.querySelector('span:first-child')
expect(fileNameSpan?.textContent).toBe('')
})
it('should handle file with empty name', async () => {
// Arrange
const file = createMockFile({ name: '' })
// Act
const { container } = renderFilePreview({ file })
// Assert - Should not crash
expect(container.firstChild).toBeInTheDocument()
})
})
describe('hidePreview prop', () => {
it('should accept hidePreview callback', async () => {
// Arrange
const hidePreview = jest.fn()
// Act
renderFilePreview({ hidePreview })
// Assert - No errors thrown
expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle file with undefined id', async () => {
// Arrange
const file = createMockFile({ id: undefined })
// Act
const { container } = renderFilePreview({ file })
// Assert - Should not call API, remain in loading state
expect(mockFetchFilePreview).not.toHaveBeenCalled()
expect(container.firstChild).toBeInTheDocument()
})
it('should handle file with empty string id', async () => {
// Arrange
const file = createMockFile({ id: '' })
// Act
renderFilePreview({ file })
// Assert - Empty string is falsy, should not call API
expect(mockFetchFilePreview).not.toHaveBeenCalled()
})
it('should handle very long file names', async () => {
// Arrange
const longName = `${'a'.repeat(200)}.pdf`
const file = createMockFile({ name: longName })
// Act
renderFilePreview({ file })
// Assert
expect(screen.getByText('a'.repeat(200))).toBeInTheDocument()
})
it('should handle file with special characters in name', async () => {
// Arrange
const file = createMockFile({ name: 'file-with_special@#$%.txt' })
// Act
renderFilePreview({ file })
// Assert
expect(screen.getByText('file-with_special@#$%')).toBeInTheDocument()
})
it('should handle very long preview content', async () => {
// Arrange
const longContent = 'x'.repeat(10000)
mockFetchFilePreview.mockResolvedValue({ content: longContent })
// Act
renderFilePreview()
// Assert
await waitFor(() => {
expect(screen.getByText(longContent)).toBeInTheDocument()
})
})
it('should handle preview content with special characters safely', async () => {
// Arrange
const specialContent = '<script>alert("xss")</script>\n\t& < > "'
mockFetchFilePreview.mockResolvedValue({ content: specialContent })
// Act
const { container } = renderFilePreview()
// Assert - Should render as text, not execute scripts
await waitFor(() => {
const contentDiv = container.querySelector('.fileContent')
expect(contentDiv).toBeInTheDocument()
// Content is escaped by React, so HTML entities are displayed
expect(contentDiv?.textContent).toContain('alert')
})
})
it('should handle preview content with unicode', async () => {
// Arrange
const unicodeContent = '中文内容 🚀 émojis & spëcîal çhàrs'
mockFetchFilePreview.mockResolvedValue({ content: unicodeContent })
// Act
renderFilePreview()
// Assert
await waitFor(() => {
expect(screen.getByText(unicodeContent)).toBeInTheDocument()
})
})
it('should handle preview content with newlines', async () => {
// Arrange
const multilineContent = 'Line 1\nLine 2\nLine 3'
mockFetchFilePreview.mockResolvedValue({ content: multilineContent })
// Act
const { container } = renderFilePreview()
// Assert - Content should be in the DOM
await waitFor(() => {
const contentDiv = container.querySelector('.fileContent')
expect(contentDiv).toBeInTheDocument()
expect(contentDiv?.textContent).toContain('Line 1')
expect(contentDiv?.textContent).toContain('Line 2')
expect(contentDiv?.textContent).toContain('Line 3')
})
})
it('should handle null content from API', async () => {
// Arrange
mockFetchFilePreview.mockResolvedValue({ content: null as unknown as string })
// Act
const { container } = renderFilePreview()
// Assert - Should not crash
await waitFor(() => {
expect(container.firstChild).toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// Side Effects and Cleanup Tests
// --------------------------------------------------------------------------
describe('Side Effects and Cleanup', () => {
it('should trigger effect when file prop changes', async () => {
// Arrange
const file1 = createMockFile({ id: 'file-1' })
const file2 = createMockFile({ id: 'file-2' })
// Act
const { rerender } = render(
<FilePreview file={file1} hidePreview={jest.fn()} />,
)
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledTimes(1)
})
rerender(<FilePreview file={file2} hidePreview={jest.fn()} />)
// Assert
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledTimes(2)
})
})
it('should not trigger effect when hidePreview changes', async () => {
// Arrange
const file = createMockFile()
const hidePreview1 = jest.fn()
const hidePreview2 = jest.fn()
// Act
const { rerender } = render(
<FilePreview file={file} hidePreview={hidePreview1} />,
)
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledTimes(1)
})
rerender(<FilePreview file={file} hidePreview={hidePreview2} />)
// Assert - Should not call API again (file didn't change)
// Note: This depends on useEffect dependency array only including [file]
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledTimes(1)
})
})
it('should handle rapid file changes', async () => {
// Arrange
const files = Array.from({ length: 5 }, (_, i) =>
createMockFile({ id: `file-${i}` }),
)
// Act
const { rerender } = render(
<FilePreview file={files[0]} hidePreview={jest.fn()} />,
)
// Rapidly change files
for (let i = 1; i < files.length; i++)
rerender(<FilePreview file={files[i]} hidePreview={jest.fn()} />)
// Assert - Should have called API for each file
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledTimes(5)
})
})
it('should handle unmount during loading', async () => {
// Arrange
mockFetchFilePreview.mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve({ content: 'delayed' }), 1000)),
)
// Act
const { unmount } = renderFilePreview()
// Unmount before API resolves
unmount()
// Assert - No errors should be thrown (React handles state updates on unmounted)
expect(true).toBe(true)
})
it('should handle file changing from defined to undefined', async () => {
// Arrange
const file = createMockFile()
// Act
const { rerender, container } = render(
<FilePreview file={file} hidePreview={jest.fn()} />,
)
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledTimes(1)
})
rerender(<FilePreview file={undefined} hidePreview={jest.fn()} />)
// Assert - Should not crash, API should not be called again
expect(container.firstChild).toBeInTheDocument()
expect(mockFetchFilePreview).toHaveBeenCalledTimes(1)
})
})
// --------------------------------------------------------------------------
// getFileName Helper Tests
// --------------------------------------------------------------------------
describe('getFileName Helper', () => {
it('should extract name without extension for simple filename', async () => {
// Arrange
const file = createMockFile({ name: 'document.pdf' })
// Act
renderFilePreview({ file })
// Assert
expect(screen.getByText('document')).toBeInTheDocument()
})
it('should handle filename with multiple dots', async () => {
// Arrange
const file = createMockFile({ name: 'file.name.with.dots.txt' })
// Act
renderFilePreview({ file })
// Assert - Should join all parts except last with comma
expect(screen.getByText('file,name,with,dots')).toBeInTheDocument()
})
it('should return empty for filename without dot', async () => {
// Arrange
const file = createMockFile({ name: 'nodotfile' })
// Act
const { container } = renderFilePreview({ file })
// Assert - slice(0, -1) on single element array returns empty
const fileNameElement = container.querySelector('.fileName')
const firstSpan = fileNameElement?.querySelector('span:first-child')
expect(firstSpan?.textContent).toBe('')
})
it('should return empty string when file is undefined', async () => {
// Arrange & Act
const { container } = renderFilePreview({ file: undefined })
// Assert - File name area should have empty first span
const fileNameElement = container.querySelector('.system-xs-medium')
expect(fileNameElement).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Accessibility Tests
// --------------------------------------------------------------------------
describe('Accessibility', () => {
it('should have clickable close button with visual indicator', async () => {
// Arrange & Act
const { container } = renderFilePreview()
// Assert
const closeButton = container.querySelector('.cursor-pointer')
expect(closeButton).toBeInTheDocument()
expect(closeButton).toHaveClass('cursor-pointer')
})
it('should have proper heading structure', async () => {
// Arrange & Act
renderFilePreview()
// Assert
expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Error Handling Tests
// --------------------------------------------------------------------------
describe('Error Handling', () => {
it('should not crash on API network error', async () => {
// Arrange
mockFetchFilePreview.mockRejectedValue(new Error('Network Error'))
// Act
const { container } = renderFilePreview()
// Assert - Component should still render
await waitFor(() => {
expect(container.firstChild).toBeInTheDocument()
})
})
it('should not crash on API timeout', async () => {
// Arrange
mockFetchFilePreview.mockRejectedValue(new Error('Timeout'))
// Act
const { container } = renderFilePreview()
// Assert
await waitFor(() => {
expect(container.firstChild).toBeInTheDocument()
})
})
it('should not crash on malformed API response', async () => {
// Arrange
mockFetchFilePreview.mockResolvedValue({} as { content: string })
// Act
const { container } = renderFilePreview()
// Assert
await waitFor(() => {
expect(container.firstChild).toBeInTheDocument()
})
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,844 @@
import { render, screen } from '@testing-library/react'
import StepThree from './index'
import type { FullDocumentDetail, IconInfo, createDocumentResponse } from '@/models/datasets'
// Mock the EmbeddingProcess component since it has complex async logic
jest.mock('../embedding-process', () => ({
__esModule: true,
default: jest.fn(({ datasetId, batchId, documents, indexingType, retrievalMethod }) => (
<div data-testid="embedding-process">
<span data-testid="ep-dataset-id">{datasetId}</span>
<span data-testid="ep-batch-id">{batchId}</span>
<span data-testid="ep-documents-count">{documents?.length ?? 0}</span>
<span data-testid="ep-indexing-type">{indexingType}</span>
<span data-testid="ep-retrieval-method">{retrievalMethod}</span>
</div>
)),
}))
// Mock useBreakpoints hook
let mockMediaType = 'pc'
jest.mock('@/hooks/use-breakpoints', () => ({
__esModule: true,
MediaType: {
mobile: 'mobile',
tablet: 'tablet',
pc: 'pc',
},
default: jest.fn(() => mockMediaType),
}))
// Mock useDocLink hook
jest.mock('@/context/i18n', () => ({
useDocLink: () => (path?: string) => `https://docs.dify.ai/en-US${path || ''}`,
}))
// Factory function to create mock IconInfo
const createMockIconInfo = (overrides: Partial<IconInfo> = {}): IconInfo => ({
icon: '📙',
icon_type: 'emoji',
icon_background: '#FFF4ED',
icon_url: '',
...overrides,
})
// Factory function to create mock FullDocumentDetail
const createMockDocument = (overrides: Partial<FullDocumentDetail> = {}): FullDocumentDetail => ({
id: 'doc-123',
name: 'test-document.txt',
data_source_type: 'upload_file',
data_source_info: {
upload_file: {
id: 'file-123',
name: 'test-document.txt',
extension: 'txt',
mime_type: 'text/plain',
size: 1024,
created_by: 'user-1',
created_at: Date.now(),
},
},
batch: 'batch-123',
created_api_request_id: 'request-123',
processing_started_at: Date.now(),
parsing_completed_at: Date.now(),
cleaning_completed_at: Date.now(),
splitting_completed_at: Date.now(),
tokens: 100,
indexing_latency: 5000,
completed_at: Date.now(),
paused_by: '',
paused_at: 0,
stopped_at: 0,
indexing_status: 'completed',
disabled_at: 0,
...overrides,
} as FullDocumentDetail)
// Factory function to create mock createDocumentResponse
const createMockCreationCache = (overrides: Partial<createDocumentResponse> = {}): createDocumentResponse => ({
dataset: {
id: 'dataset-123',
name: 'Test Dataset',
icon_info: createMockIconInfo(),
indexing_technique: 'high_quality',
retrieval_model_dict: {
search_method: 'semantic_search',
},
} as createDocumentResponse['dataset'],
batch: 'batch-123',
documents: [createMockDocument()] as createDocumentResponse['documents'],
...overrides,
})
// Helper to render StepThree with default props
const renderStepThree = (props: Partial<Parameters<typeof StepThree>[0]> = {}) => {
const defaultProps = {
...props,
}
return render(<StepThree {...defaultProps} />)
}
// ============================================================================
// StepThree Component Tests
// ============================================================================
describe('StepThree', () => {
beforeEach(() => {
jest.clearAllMocks()
mockMediaType = 'pc'
})
// --------------------------------------------------------------------------
// Rendering Tests - Verify component renders properly
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
renderStepThree()
// Assert
expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
})
it('should render with creation title when datasetId is not provided', () => {
// Arrange & Act
renderStepThree()
// Assert
expect(screen.getByText('datasetCreation.stepThree.creationTitle')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepThree.creationContent')).toBeInTheDocument()
})
it('should render with addition title when datasetId is provided', () => {
// Arrange & Act
renderStepThree({
datasetId: 'existing-dataset-123',
datasetName: 'Existing Dataset',
})
// Assert
expect(screen.getByText('datasetCreation.stepThree.additionTitle')).toBeInTheDocument()
expect(screen.queryByText('datasetCreation.stepThree.creationTitle')).not.toBeInTheDocument()
})
it('should render label text in creation mode', () => {
// Arrange & Act
renderStepThree()
// Assert
expect(screen.getByText('datasetCreation.stepThree.label')).toBeInTheDocument()
})
it('should render side tip panel on desktop', () => {
// Arrange
mockMediaType = 'pc'
// Act
renderStepThree()
// Assert
expect(screen.getByText('datasetCreation.stepThree.sideTipTitle')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepThree.sideTipContent')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore')).toBeInTheDocument()
})
it('should not render side tip panel on mobile', () => {
// Arrange
mockMediaType = 'mobile'
// Act
renderStepThree()
// Assert
expect(screen.queryByText('datasetCreation.stepThree.sideTipTitle')).not.toBeInTheDocument()
expect(screen.queryByText('datasetCreation.stepThree.sideTipContent')).not.toBeInTheDocument()
})
it('should render EmbeddingProcess component', () => {
// Arrange & Act
renderStepThree()
// Assert
expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
})
it('should render documentation link with correct href on desktop', () => {
// Arrange
mockMediaType = 'pc'
// Act
renderStepThree()
// Assert
const link = screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore')
expect(link).toHaveAttribute('href', 'https://docs.dify.ai/en-US/guides/knowledge-base/integrate-knowledge-within-application')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noreferrer noopener')
})
it('should apply correct container classes', () => {
// Arrange & Act
const { container } = renderStepThree()
// Assert
const outerDiv = container.firstChild as HTMLElement
expect(outerDiv).toHaveClass('flex', 'h-full', 'max-h-full', 'w-full', 'justify-center', 'overflow-y-auto')
})
})
// --------------------------------------------------------------------------
// Props Testing - Test all prop variations
// --------------------------------------------------------------------------
describe('Props', () => {
describe('datasetId prop', () => {
it('should render creation mode when datasetId is undefined', () => {
// Arrange & Act
renderStepThree({ datasetId: undefined })
// Assert
expect(screen.getByText('datasetCreation.stepThree.creationTitle')).toBeInTheDocument()
})
it('should render addition mode when datasetId is provided', () => {
// Arrange & Act
renderStepThree({ datasetId: 'dataset-123' })
// Assert
expect(screen.getByText('datasetCreation.stepThree.additionTitle')).toBeInTheDocument()
})
it('should pass datasetId to EmbeddingProcess', () => {
// Arrange
const datasetId = 'my-dataset-id'
// Act
renderStepThree({ datasetId })
// Assert
expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent(datasetId)
})
it('should use creationCache dataset id when datasetId is not provided', () => {
// Arrange
const creationCache = createMockCreationCache()
// Act
renderStepThree({ creationCache })
// Assert
expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('dataset-123')
})
})
describe('datasetName prop', () => {
it('should display datasetName in creation mode', () => {
// Arrange & Act
renderStepThree({ datasetName: 'My Custom Dataset' })
// Assert
expect(screen.getByText('My Custom Dataset')).toBeInTheDocument()
})
it('should display datasetName in addition mode description', () => {
// Arrange & Act
renderStepThree({
datasetId: 'dataset-123',
datasetName: 'Existing Dataset Name',
})
// Assert - Check the text contains the dataset name (in the description)
const description = screen.getByText(/datasetCreation.stepThree.additionP1.*Existing Dataset Name.*datasetCreation.stepThree.additionP2/i)
expect(description).toBeInTheDocument()
})
it('should fallback to creationCache dataset name when datasetName is not provided', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.name = 'Cache Dataset Name'
// Act
renderStepThree({ creationCache })
// Assert
expect(screen.getByText('Cache Dataset Name')).toBeInTheDocument()
})
})
describe('indexingType prop', () => {
it('should pass indexingType to EmbeddingProcess', () => {
// Arrange & Act
renderStepThree({ indexingType: 'high_quality' })
// Assert
expect(screen.getByTestId('ep-indexing-type')).toHaveTextContent('high_quality')
})
it('should use creationCache indexing_technique when indexingType is not provided', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.indexing_technique = 'economy' as any
// Act
renderStepThree({ creationCache })
// Assert
expect(screen.getByTestId('ep-indexing-type')).toHaveTextContent('economy')
})
it('should prefer creationCache indexing_technique over indexingType prop', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.indexing_technique = 'cache_technique' as any
// Act
renderStepThree({ creationCache, indexingType: 'prop_technique' })
// Assert - creationCache takes precedence
expect(screen.getByTestId('ep-indexing-type')).toHaveTextContent('cache_technique')
})
})
describe('retrievalMethod prop', () => {
it('should pass retrievalMethod to EmbeddingProcess', () => {
// Arrange & Act
renderStepThree({ retrievalMethod: 'semantic_search' })
// Assert
expect(screen.getByTestId('ep-retrieval-method')).toHaveTextContent('semantic_search')
})
it('should use creationCache retrieval method when retrievalMethod is not provided', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.retrieval_model_dict = { search_method: 'full_text_search' } as any
// Act
renderStepThree({ creationCache })
// Assert
expect(screen.getByTestId('ep-retrieval-method')).toHaveTextContent('full_text_search')
})
})
describe('creationCache prop', () => {
it('should pass batchId from creationCache to EmbeddingProcess', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.batch = 'custom-batch-123'
// Act
renderStepThree({ creationCache })
// Assert
expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('custom-batch-123')
})
it('should pass documents from creationCache to EmbeddingProcess', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.documents = [createMockDocument(), createMockDocument(), createMockDocument()] as any
// Act
renderStepThree({ creationCache })
// Assert
expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('3')
})
it('should use icon_info from creationCache dataset', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.icon_info = createMockIconInfo({
icon: '🚀',
icon_background: '#FF0000',
})
// Act
const { container } = renderStepThree({ creationCache })
// Assert - Check AppIcon component receives correct props
const appIcon = container.querySelector('span[style*="background"]')
expect(appIcon).toBeInTheDocument()
})
it('should handle undefined creationCache', () => {
// Arrange & Act
renderStepThree({ creationCache: undefined })
// Assert - Should not crash, use fallback values
expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('')
expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('')
})
it('should handle creationCache with undefined dataset', () => {
// Arrange
const creationCache: createDocumentResponse = {
dataset: undefined,
batch: 'batch-123',
documents: [],
}
// Act
renderStepThree({ creationCache })
// Assert - Should use default icon info
expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests - Test null, undefined, empty values and boundaries
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle all props being undefined', () => {
// Arrange & Act
renderStepThree({
datasetId: undefined,
datasetName: undefined,
indexingType: undefined,
retrievalMethod: undefined,
creationCache: undefined,
})
// Assert - Should render creation mode with fallbacks
expect(screen.getByText('datasetCreation.stepThree.creationTitle')).toBeInTheDocument()
expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
})
it('should handle empty string datasetId', () => {
// Arrange & Act
renderStepThree({ datasetId: '' })
// Assert - Empty string is falsy, should show creation mode
expect(screen.getByText('datasetCreation.stepThree.creationTitle')).toBeInTheDocument()
})
it('should handle empty string datasetName', () => {
// Arrange & Act
renderStepThree({ datasetName: '' })
// Assert - Should not crash
expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
})
it('should handle empty documents array in creationCache', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.documents = []
// Act
renderStepThree({ creationCache })
// Assert
expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('0')
})
it('should handle creationCache with missing icon_info', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.icon_info = undefined as any
// Act
renderStepThree({ creationCache })
// Assert - Should use default icon info
expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
})
it('should handle very long datasetName', () => {
// Arrange
const longName = 'A'.repeat(500)
// Act
renderStepThree({ datasetName: longName })
// Assert - Should render without crashing
expect(screen.getByText(longName)).toBeInTheDocument()
})
it('should handle special characters in datasetName', () => {
// Arrange
const specialName = 'Dataset <script>alert("xss")</script> & "quotes" \'apostrophe\''
// Act
renderStepThree({ datasetName: specialName })
// Assert - Should render safely as text
expect(screen.getByText(specialName)).toBeInTheDocument()
})
it('should handle unicode characters in datasetName', () => {
// Arrange
const unicodeName = '数据集名称 🚀 émojis & spëcîal çhàrs'
// Act
renderStepThree({ datasetName: unicodeName })
// Assert
expect(screen.getByText(unicodeName)).toBeInTheDocument()
})
it('should handle creationCache with null dataset name', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.name = null as any
// Act
const { container } = renderStepThree({ creationCache })
// Assert - Should not crash
expect(container.firstChild).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Conditional Rendering Tests - Test mode switching behavior
// --------------------------------------------------------------------------
describe('Conditional Rendering', () => {
describe('Creation Mode (no datasetId)', () => {
it('should show AppIcon component', () => {
// Arrange & Act
const { container } = renderStepThree()
// Assert - AppIcon should be rendered
const appIcon = container.querySelector('span')
expect(appIcon).toBeInTheDocument()
})
it('should show Divider component', () => {
// Arrange & Act
const { container } = renderStepThree()
// Assert - Divider should be rendered (it adds hr with specific classes)
const dividers = container.querySelectorAll('[class*="divider"]')
expect(dividers.length).toBeGreaterThan(0)
})
it('should show dataset name input area', () => {
// Arrange
const datasetName = 'Test Dataset Name'
// Act
renderStepThree({ datasetName })
// Assert
expect(screen.getByText(datasetName)).toBeInTheDocument()
})
})
describe('Addition Mode (with datasetId)', () => {
it('should not show AppIcon component', () => {
// Arrange & Act
renderStepThree({ datasetId: 'dataset-123' })
// Assert - Creation section should not be rendered
expect(screen.queryByText('datasetCreation.stepThree.label')).not.toBeInTheDocument()
})
it('should show addition description with dataset name', () => {
// Arrange & Act
renderStepThree({
datasetId: 'dataset-123',
datasetName: 'My Dataset',
})
// Assert - Description should include dataset name
expect(screen.getByText(/datasetCreation.stepThree.additionP1/)).toBeInTheDocument()
})
})
describe('Mobile vs Desktop', () => {
it('should show side panel on tablet', () => {
// Arrange
mockMediaType = 'tablet'
// Act
renderStepThree()
// Assert - Tablet is not mobile, should show side panel
expect(screen.getByText('datasetCreation.stepThree.sideTipTitle')).toBeInTheDocument()
})
it('should not show side panel on mobile', () => {
// Arrange
mockMediaType = 'mobile'
// Act
renderStepThree()
// Assert
expect(screen.queryByText('datasetCreation.stepThree.sideTipTitle')).not.toBeInTheDocument()
})
it('should render EmbeddingProcess on mobile', () => {
// Arrange
mockMediaType = 'mobile'
// Act
renderStepThree()
// Assert - Main content should still be rendered
expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// EmbeddingProcess Integration Tests - Verify correct props are passed
// --------------------------------------------------------------------------
describe('EmbeddingProcess Integration', () => {
it('should pass correct datasetId to EmbeddingProcess with datasetId prop', () => {
// Arrange & Act
renderStepThree({ datasetId: 'direct-dataset-id' })
// Assert
expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('direct-dataset-id')
})
it('should pass creationCache dataset id when datasetId prop is undefined', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.id = 'cache-dataset-id'
// Act
renderStepThree({ creationCache })
// Assert
expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('cache-dataset-id')
})
it('should pass empty string for datasetId when both sources are undefined', () => {
// Arrange & Act
renderStepThree()
// Assert
expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('')
})
it('should pass batchId from creationCache', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.batch = 'test-batch-456'
// Act
renderStepThree({ creationCache })
// Assert
expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('test-batch-456')
})
it('should pass empty string for batchId when creationCache is undefined', () => {
// Arrange & Act
renderStepThree()
// Assert
expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('')
})
it('should prefer datasetId prop over creationCache dataset id', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.id = 'cache-id'
// Act
renderStepThree({ datasetId: 'prop-id', creationCache })
// Assert - datasetId prop takes precedence
expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('prop-id')
})
})
// --------------------------------------------------------------------------
// Icon Rendering Tests - Verify AppIcon behavior
// --------------------------------------------------------------------------
describe('Icon Rendering', () => {
it('should use default icon info when creationCache is undefined', () => {
// Arrange & Act
const { container } = renderStepThree()
// Assert - Default background color should be applied
const appIcon = container.querySelector('span[style*="background"]')
if (appIcon)
expect(appIcon).toHaveStyle({ background: '#FFF4ED' })
})
it('should use icon_info from creationCache when available', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.icon_info = {
icon: '🎉',
icon_type: 'emoji',
icon_background: '#00FF00',
icon_url: '',
}
// Act
const { container } = renderStepThree({ creationCache })
// Assert - Custom background color should be applied
const appIcon = container.querySelector('span[style*="background"]')
if (appIcon)
expect(appIcon).toHaveStyle({ background: '#00FF00' })
})
it('should use default icon when creationCache dataset icon_info is undefined', () => {
// Arrange
const creationCache = createMockCreationCache()
delete (creationCache.dataset as any).icon_info
// Act
const { container } = renderStepThree({ creationCache })
// Assert - Component should still render with default icon
expect(container.firstChild).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Layout Tests - Verify correct CSS classes and structure
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have correct outer container classes', () => {
// Arrange & Act
const { container } = renderStepThree()
// Assert
const outerDiv = container.firstChild as HTMLElement
expect(outerDiv).toHaveClass('flex')
expect(outerDiv).toHaveClass('h-full')
expect(outerDiv).toHaveClass('justify-center')
})
it('should have correct inner container classes', () => {
// Arrange & Act
const { container } = renderStepThree()
// Assert
const innerDiv = container.querySelector('.max-w-\\[960px\\]')
expect(innerDiv).toBeInTheDocument()
expect(innerDiv).toHaveClass('shrink-0', 'grow')
})
it('should have content wrapper with correct max width', () => {
// Arrange & Act
const { container } = renderStepThree()
// Assert
const contentWrapper = container.querySelector('.max-w-\\[640px\\]')
expect(contentWrapper).toBeInTheDocument()
})
it('should have side tip panel with correct width on desktop', () => {
// Arrange
mockMediaType = 'pc'
// Act
const { container } = renderStepThree()
// Assert
const sidePanel = container.querySelector('.w-\\[328px\\]')
expect(sidePanel).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Accessibility Tests - Verify accessibility features
// --------------------------------------------------------------------------
describe('Accessibility', () => {
it('should have correct link attributes for external documentation link', () => {
// Arrange
mockMediaType = 'pc'
// Act
renderStepThree()
// Assert
const link = screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore')
expect(link.tagName).toBe('A')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noreferrer noopener')
})
it('should have semantic heading structure in creation mode', () => {
// Arrange & Act
renderStepThree()
// Assert
const title = screen.getByText('datasetCreation.stepThree.creationTitle')
expect(title).toBeInTheDocument()
expect(title.className).toContain('title-2xl-semi-bold')
})
it('should have semantic heading structure in addition mode', () => {
// Arrange & Act
renderStepThree({ datasetId: 'dataset-123' })
// Assert
const title = screen.getByText('datasetCreation.stepThree.additionTitle')
expect(title).toBeInTheDocument()
expect(title.className).toContain('title-2xl-semi-bold')
})
})
// --------------------------------------------------------------------------
// Side Panel Tests - Verify side panel behavior
// --------------------------------------------------------------------------
describe('Side Panel', () => {
it('should render RiBookOpenLine icon in side panel', () => {
// Arrange
mockMediaType = 'pc'
// Act
const { container } = renderStepThree()
// Assert - Icon should be present in side panel
const iconContainer = container.querySelector('.size-10')
expect(iconContainer).toBeInTheDocument()
})
it('should have correct side panel section background', () => {
// Arrange
mockMediaType = 'pc'
// Act
const { container } = renderStepThree()
// Assert
const sidePanel = container.querySelector('.bg-background-section')
expect(sidePanel).toBeInTheDocument()
})
it('should have correct padding for side panel', () => {
// Arrange
mockMediaType = 'pc'
// Act
const { container } = renderStepThree()
// Assert
const sidePanelWrapper = container.querySelector('.pr-8')
expect(sidePanelWrapper).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,735 @@
import { render, screen } from '@testing-library/react'
import { Stepper, type StepperProps } from './index'
import { type Step, StepperStep, type StepperStepProps } from './step'
// Test data factory for creating steps
const createStep = (overrides: Partial<Step> = {}): Step => ({
name: 'Test Step',
...overrides,
})
const createSteps = (count: number, namePrefix = 'Step'): Step[] =>
Array.from({ length: count }, (_, i) => createStep({ name: `${namePrefix} ${i + 1}` }))
// Helper to render Stepper with default props
const renderStepper = (props: Partial<StepperProps> = {}) => {
const defaultProps: StepperProps = {
steps: createSteps(3),
activeIndex: 0,
...props,
}
return render(<Stepper {...defaultProps} />)
}
// Helper to render StepperStep with default props
const renderStepperStep = (props: Partial<StepperStepProps> = {}) => {
const defaultProps: StepperStepProps = {
name: 'Test Step',
index: 0,
activeIndex: 0,
...props,
}
return render(<StepperStep {...defaultProps} />)
}
// ============================================================================
// Stepper Component Tests
// ============================================================================
describe('Stepper', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests - Verify component renders properly with various inputs
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
renderStepper()
// Assert
expect(screen.getByText('Step 1')).toBeInTheDocument()
})
it('should render all step names', () => {
// Arrange
const steps = createSteps(3, 'Custom Step')
// Act
renderStepper({ steps })
// Assert
expect(screen.getByText('Custom Step 1')).toBeInTheDocument()
expect(screen.getByText('Custom Step 2')).toBeInTheDocument()
expect(screen.getByText('Custom Step 3')).toBeInTheDocument()
})
it('should render dividers between steps', () => {
// Arrange
const steps = createSteps(3)
// Act
const { container } = renderStepper({ steps })
// Assert - Should have 2 dividers for 3 steps
const dividers = container.querySelectorAll('.bg-divider-deep')
expect(dividers.length).toBe(2)
})
it('should not render divider after last step', () => {
// Arrange
const steps = createSteps(2)
// Act
const { container } = renderStepper({ steps })
// Assert - Should have 1 divider for 2 steps
const dividers = container.querySelectorAll('.bg-divider-deep')
expect(dividers.length).toBe(1)
})
it('should render with flex container layout', () => {
// Arrange & Act
const { container } = renderStepper()
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex', 'items-center', 'gap-3')
})
})
// --------------------------------------------------------------------------
// Props Testing - Test all prop variations and combinations
// --------------------------------------------------------------------------
describe('Props', () => {
describe('steps prop', () => {
it('should render correct number of steps', () => {
// Arrange
const steps = createSteps(5)
// Act
renderStepper({ steps })
// Assert
expect(screen.getByText('Step 1')).toBeInTheDocument()
expect(screen.getByText('Step 2')).toBeInTheDocument()
expect(screen.getByText('Step 3')).toBeInTheDocument()
expect(screen.getByText('Step 4')).toBeInTheDocument()
expect(screen.getByText('Step 5')).toBeInTheDocument()
})
it('should handle single step correctly', () => {
// Arrange
const steps = [createStep({ name: 'Only Step' })]
// Act
const { container } = renderStepper({ steps, activeIndex: 0 })
// Assert
expect(screen.getByText('Only Step')).toBeInTheDocument()
// No dividers for single step
const dividers = container.querySelectorAll('.bg-divider-deep')
expect(dividers.length).toBe(0)
})
it('should handle steps with long names', () => {
// Arrange
const longName = 'This is a very long step name that might overflow'
const steps = [createStep({ name: longName })]
// Act
renderStepper({ steps, activeIndex: 0 })
// Assert
expect(screen.getByText(longName)).toBeInTheDocument()
})
it('should handle steps with special characters', () => {
// Arrange
const steps = [
createStep({ name: 'Step & Configuration' }),
createStep({ name: 'Step <Preview>' }),
createStep({ name: 'Step "Complete"' }),
]
// Act
renderStepper({ steps, activeIndex: 0 })
// Assert
expect(screen.getByText('Step & Configuration')).toBeInTheDocument()
expect(screen.getByText('Step <Preview>')).toBeInTheDocument()
expect(screen.getByText('Step "Complete"')).toBeInTheDocument()
})
})
describe('activeIndex prop', () => {
it('should highlight first step when activeIndex is 0', () => {
// Arrange & Act
renderStepper({ activeIndex: 0 })
// Assert - First step should show "STEP 1" label
expect(screen.getByText('STEP 1')).toBeInTheDocument()
})
it('should highlight second step when activeIndex is 1', () => {
// Arrange & Act
renderStepper({ activeIndex: 1 })
// Assert - Second step should show "STEP 2" label
expect(screen.getByText('STEP 2')).toBeInTheDocument()
})
it('should highlight last step when activeIndex equals steps length - 1', () => {
// Arrange
const steps = createSteps(3)
// Act
renderStepper({ steps, activeIndex: 2 })
// Assert - Third step should show "STEP 3" label
expect(screen.getByText('STEP 3')).toBeInTheDocument()
})
it('should show completed steps with number only (no STEP prefix)', () => {
// Arrange
const steps = createSteps(3)
// Act
renderStepper({ steps, activeIndex: 2 })
// Assert - Completed steps show just the number
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.getByText('STEP 3')).toBeInTheDocument()
})
it('should show disabled steps with number only (no STEP prefix)', () => {
// Arrange
const steps = createSteps(3)
// Act
renderStepper({ steps, activeIndex: 0 })
// Assert - Disabled steps show just the number
expect(screen.getByText('STEP 1')).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.getByText('3')).toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// Edge Cases - Test boundary conditions and unexpected inputs
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle empty steps array', () => {
// Arrange & Act
const { container } = renderStepper({ steps: [] })
// Assert - Container should render but be empty
expect(container.firstChild).toBeInTheDocument()
expect(container.firstChild?.childNodes.length).toBe(0)
})
it('should handle activeIndex greater than steps length', () => {
// Arrange
const steps = createSteps(2)
// Act - activeIndex 5 is beyond array bounds
renderStepper({ steps, activeIndex: 5 })
// Assert - All steps should render as completed (since activeIndex > all indices)
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
})
it('should handle negative activeIndex', () => {
// Arrange
const steps = createSteps(2)
// Act - negative activeIndex
renderStepper({ steps, activeIndex: -1 })
// Assert - All steps should render as disabled (since activeIndex < all indices)
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
})
it('should handle large number of steps', () => {
// Arrange
const steps = createSteps(10)
// Act
const { container } = renderStepper({ steps, activeIndex: 5 })
// Assert
expect(screen.getByText('STEP 6')).toBeInTheDocument()
// Should have 9 dividers for 10 steps
const dividers = container.querySelectorAll('.bg-divider-deep')
expect(dividers.length).toBe(9)
})
it('should handle steps with empty name', () => {
// Arrange
const steps = [createStep({ name: '' })]
// Act
const { container } = renderStepper({ steps, activeIndex: 0 })
// Assert - Should still render the step structure
expect(screen.getByText('STEP 1')).toBeInTheDocument()
expect(container.firstChild).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Integration - Test step state combinations
// --------------------------------------------------------------------------
describe('Step States', () => {
it('should render mixed states: completed, active, disabled', () => {
// Arrange
const steps = createSteps(5)
// Act
renderStepper({ steps, activeIndex: 2 })
// Assert
// Steps 1-2 are completed (show number only)
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
// Step 3 is active (shows STEP prefix)
expect(screen.getByText('STEP 3')).toBeInTheDocument()
// Steps 4-5 are disabled (show number only)
expect(screen.getByText('4')).toBeInTheDocument()
expect(screen.getByText('5')).toBeInTheDocument()
})
it('should transition through all states correctly', () => {
// Arrange
const steps = createSteps(3)
// Act & Assert - Step 1 active
const { rerender } = render(<Stepper steps={steps} activeIndex={0} />)
expect(screen.getByText('STEP 1')).toBeInTheDocument()
// Step 2 active
rerender(<Stepper steps={steps} activeIndex={1} />)
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('STEP 2')).toBeInTheDocument()
// Step 3 active
rerender(<Stepper steps={steps} activeIndex={2} />)
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.getByText('STEP 3')).toBeInTheDocument()
})
})
})
// ============================================================================
// StepperStep Component Tests
// ============================================================================
describe('StepperStep', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
renderStepperStep()
// Assert
expect(screen.getByText('Test Step')).toBeInTheDocument()
})
it('should render step name', () => {
// Arrange & Act
renderStepperStep({ name: 'Configure Dataset' })
// Assert
expect(screen.getByText('Configure Dataset')).toBeInTheDocument()
})
it('should render with flex container layout', () => {
// Arrange & Act
const { container } = renderStepperStep()
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex', 'items-center', 'gap-2')
})
})
// --------------------------------------------------------------------------
// Active State Tests
// --------------------------------------------------------------------------
describe('Active State', () => {
it('should show STEP prefix when active', () => {
// Arrange & Act
renderStepperStep({ index: 0, activeIndex: 0 })
// Assert
expect(screen.getByText('STEP 1')).toBeInTheDocument()
})
it('should apply active styles to label container', () => {
// Arrange & Act
const { container } = renderStepperStep({ index: 0, activeIndex: 0 })
// Assert
const labelContainer = container.querySelector('.bg-state-accent-solid')
expect(labelContainer).toBeInTheDocument()
expect(labelContainer).toHaveClass('px-2')
})
it('should apply active text color to label', () => {
// Arrange & Act
const { container } = renderStepperStep({ index: 0, activeIndex: 0 })
// Assert
const label = container.querySelector('.text-text-primary-on-surface')
expect(label).toBeInTheDocument()
})
it('should apply accent text color to name when active', () => {
// Arrange & Act
const { container } = renderStepperStep({ index: 0, activeIndex: 0 })
// Assert
const nameElement = container.querySelector('.text-text-accent')
expect(nameElement).toBeInTheDocument()
expect(nameElement).toHaveClass('system-xs-semibold-uppercase')
})
it('should calculate active correctly for different indices', () => {
// Test index 1 with activeIndex 1
const { rerender } = render(
<StepperStep name="Step" index={1} activeIndex={1} />,
)
expect(screen.getByText('STEP 2')).toBeInTheDocument()
// Test index 5 with activeIndex 5
rerender(<StepperStep name="Step" index={5} activeIndex={5} />)
expect(screen.getByText('STEP 6')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Completed State Tests (index < activeIndex)
// --------------------------------------------------------------------------
describe('Completed State', () => {
it('should show number only when completed (not active)', () => {
// Arrange & Act
renderStepperStep({ index: 0, activeIndex: 1 })
// Assert
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.queryByText('STEP 1')).not.toBeInTheDocument()
})
it('should apply completed styles to label container', () => {
// Arrange & Act
const { container } = renderStepperStep({ index: 0, activeIndex: 1 })
// Assert
const labelContainer = container.querySelector('.border-text-quaternary')
expect(labelContainer).toBeInTheDocument()
expect(labelContainer).toHaveClass('w-5')
})
it('should apply tertiary text color to label when completed', () => {
// Arrange & Act
const { container } = renderStepperStep({ index: 0, activeIndex: 1 })
// Assert
const label = container.querySelector('.text-text-tertiary')
expect(label).toBeInTheDocument()
})
it('should apply tertiary text color to name when completed', () => {
// Arrange & Act
const { container } = renderStepperStep({ index: 0, activeIndex: 2 })
// Assert
const nameElements = container.querySelectorAll('.text-text-tertiary')
expect(nameElements.length).toBeGreaterThan(0)
})
})
// --------------------------------------------------------------------------
// Disabled State Tests (index > activeIndex)
// --------------------------------------------------------------------------
describe('Disabled State', () => {
it('should show number only when disabled', () => {
// Arrange & Act
renderStepperStep({ index: 2, activeIndex: 0 })
// Assert
expect(screen.getByText('3')).toBeInTheDocument()
expect(screen.queryByText('STEP 3')).not.toBeInTheDocument()
})
it('should apply disabled styles to label container', () => {
// Arrange & Act
const { container } = renderStepperStep({ index: 2, activeIndex: 0 })
// Assert
const labelContainer = container.querySelector('.border-divider-deep')
expect(labelContainer).toBeInTheDocument()
expect(labelContainer).toHaveClass('w-5')
})
it('should apply quaternary text color to label when disabled', () => {
// Arrange & Act
const { container } = renderStepperStep({ index: 2, activeIndex: 0 })
// Assert
const label = container.querySelector('.text-text-quaternary')
expect(label).toBeInTheDocument()
})
it('should apply quaternary text color to name when disabled', () => {
// Arrange & Act
const { container } = renderStepperStep({ index: 2, activeIndex: 0 })
// Assert
const nameElements = container.querySelectorAll('.text-text-quaternary')
expect(nameElements.length).toBeGreaterThan(0)
})
})
// --------------------------------------------------------------------------
// Props Testing
// --------------------------------------------------------------------------
describe('Props', () => {
describe('name prop', () => {
it('should render provided name', () => {
// Arrange & Act
renderStepperStep({ name: 'Custom Name' })
// Assert
expect(screen.getByText('Custom Name')).toBeInTheDocument()
})
it('should handle empty name', () => {
// Arrange & Act
const { container } = renderStepperStep({ name: '' })
// Assert - Label should still render
expect(screen.getByText('STEP 1')).toBeInTheDocument()
expect(container.firstChild).toBeInTheDocument()
})
it('should handle name with whitespace', () => {
// Arrange & Act
renderStepperStep({ name: ' Padded Name ' })
// Assert
expect(screen.getByText('Padded Name')).toBeInTheDocument()
})
})
describe('index prop', () => {
it('should display correct 1-based number for index 0', () => {
// Arrange & Act
renderStepperStep({ index: 0, activeIndex: 0 })
// Assert
expect(screen.getByText('STEP 1')).toBeInTheDocument()
})
it('should display correct 1-based number for index 9', () => {
// Arrange & Act
renderStepperStep({ index: 9, activeIndex: 9 })
// Assert
expect(screen.getByText('STEP 10')).toBeInTheDocument()
})
it('should handle large index values', () => {
// Arrange & Act
renderStepperStep({ index: 99, activeIndex: 99 })
// Assert
expect(screen.getByText('STEP 100')).toBeInTheDocument()
})
})
describe('activeIndex prop', () => {
it('should determine state based on activeIndex comparison', () => {
// Active: index === activeIndex
const { rerender } = render(
<StepperStep name="Step" index={1} activeIndex={1} />,
)
expect(screen.getByText('STEP 2')).toBeInTheDocument()
// Completed: index < activeIndex
rerender(<StepperStep name="Step" index={1} activeIndex={2} />)
expect(screen.getByText('2')).toBeInTheDocument()
// Disabled: index > activeIndex
rerender(<StepperStep name="Step" index={1} activeIndex={0} />)
expect(screen.getByText('2')).toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// Edge Cases
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle zero index correctly', () => {
// Arrange & Act
renderStepperStep({ index: 0, activeIndex: 0 })
// Assert
expect(screen.getByText('STEP 1')).toBeInTheDocument()
})
it('should handle negative activeIndex', () => {
// Arrange & Act
renderStepperStep({ index: 0, activeIndex: -1 })
// Assert - Step should be disabled (index > activeIndex)
expect(screen.getByText('1')).toBeInTheDocument()
})
it('should handle equal boundary (index equals activeIndex)', () => {
// Arrange & Act
renderStepperStep({ index: 5, activeIndex: 5 })
// Assert - Should be active
expect(screen.getByText('STEP 6')).toBeInTheDocument()
})
it('should handle name with HTML-like content safely', () => {
// Arrange & Act
renderStepperStep({ name: '<script>alert("xss")</script>' })
// Assert - Should render as text, not execute
expect(screen.getByText('<script>alert("xss")</script>')).toBeInTheDocument()
})
it('should handle name with unicode characters', () => {
// Arrange & Act
renderStepperStep({ name: 'Step 数据 🚀' })
// Assert
expect(screen.getByText('Step 数据 🚀')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Style Classes Verification
// --------------------------------------------------------------------------
describe('Style Classes', () => {
it('should apply correct typography classes to label', () => {
// Arrange & Act
const { container } = renderStepperStep()
// Assert
const label = container.querySelector('.system-2xs-semibold-uppercase')
expect(label).toBeInTheDocument()
})
it('should apply correct typography classes to name', () => {
// Arrange & Act
const { container } = renderStepperStep()
// Assert
const name = container.querySelector('.system-xs-medium-uppercase')
expect(name).toBeInTheDocument()
})
it('should have rounded pill shape for label container', () => {
// Arrange & Act
const { container } = renderStepperStep()
// Assert
const labelContainer = container.querySelector('.rounded-3xl')
expect(labelContainer).toBeInTheDocument()
})
it('should apply h-5 height to label container', () => {
// Arrange & Act
const { container } = renderStepperStep()
// Assert
const labelContainer = container.querySelector('.h-5')
expect(labelContainer).toBeInTheDocument()
})
})
})
// ============================================================================
// Integration Tests - Stepper and StepperStep working together
// ============================================================================
describe('Stepper Integration', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('should pass correct props to each StepperStep', () => {
// Arrange
const steps = [
createStep({ name: 'First' }),
createStep({ name: 'Second' }),
createStep({ name: 'Third' }),
]
// Act
renderStepper({ steps, activeIndex: 1 })
// Assert - Each step receives correct index and displays correctly
expect(screen.getByText('1')).toBeInTheDocument() // Completed
expect(screen.getByText('First')).toBeInTheDocument()
expect(screen.getByText('STEP 2')).toBeInTheDocument() // Active
expect(screen.getByText('Second')).toBeInTheDocument()
expect(screen.getByText('3')).toBeInTheDocument() // Disabled
expect(screen.getByText('Third')).toBeInTheDocument()
})
it('should maintain correct visual hierarchy across steps', () => {
// Arrange
const steps = createSteps(4)
// Act
const { container } = renderStepper({ steps, activeIndex: 2 })
// Assert - Check visual hierarchy
// Completed steps (0, 1) have border-text-quaternary
const completedLabels = container.querySelectorAll('.border-text-quaternary')
expect(completedLabels.length).toBe(2)
// Active step has bg-state-accent-solid
const activeLabel = container.querySelector('.bg-state-accent-solid')
expect(activeLabel).toBeInTheDocument()
// Disabled step (3) has border-divider-deep
const disabledLabels = container.querySelectorAll('.border-divider-deep')
expect(disabledLabels.length).toBe(1)
})
it('should render correctly with dynamic step updates', () => {
// Arrange
const initialSteps = createSteps(2)
// Act
const { rerender } = render(<Stepper steps={initialSteps} activeIndex={0} />)
expect(screen.getByText('Step 1')).toBeInTheDocument()
expect(screen.getByText('Step 2')).toBeInTheDocument()
// Update with more steps
const updatedSteps = createSteps(4)
rerender(<Stepper steps={updatedSteps} activeIndex={2} />)
// Assert
expect(screen.getByText('STEP 3')).toBeInTheDocument()
expect(screen.getByText('Step 4')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,738 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import StopEmbeddingModal from './index'
// Helper type for component props
type StopEmbeddingModalProps = {
show: boolean
onConfirm: () => void
onHide: () => void
}
// Helper to render StopEmbeddingModal with default props
const renderStopEmbeddingModal = (props: Partial<StopEmbeddingModalProps> = {}) => {
const defaultProps: StopEmbeddingModalProps = {
show: true,
onConfirm: jest.fn(),
onHide: jest.fn(),
...props,
}
return {
...render(<StopEmbeddingModal {...defaultProps} />),
props: defaultProps,
}
}
// ============================================================================
// StopEmbeddingModal Component Tests
// ============================================================================
describe('StopEmbeddingModal', () => {
// Suppress Headless UI warnings in tests
// These warnings are from the library's internal behavior, not our code
let consoleWarnSpy: jest.SpyInstance
let consoleErrorSpy: jest.SpyInstance
beforeAll(() => {
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(jest.fn())
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn())
})
afterAll(() => {
consoleWarnSpy.mockRestore()
consoleErrorSpy.mockRestore()
})
beforeEach(() => {
jest.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests - Verify component renders properly
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing when show is true', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert
expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
})
it('should render modal title', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert
expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
})
it('should render modal content', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert
expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeInTheDocument()
})
it('should render confirm button with correct text', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert
expect(screen.getByText('datasetCreation.stepThree.modelButtonConfirm')).toBeInTheDocument()
})
it('should render cancel button with correct text', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert
expect(screen.getByText('datasetCreation.stepThree.modelButtonCancel')).toBeInTheDocument()
})
it('should not render modal content when show is false', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: false })
// Assert
expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument()
})
it('should render buttons in correct order (cancel first, then confirm)', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert - Due to flex-row-reverse, confirm appears first visually but cancel is first in DOM
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(2)
})
it('should render confirm button with primary variant styling', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert
const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
expect(confirmButton).toHaveClass('ml-2', 'w-24')
})
it('should render cancel button with default styling', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert
const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel')
expect(cancelButton).toHaveClass('w-24')
})
it('should render all modal elements', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert - Modal should contain title, content, and buttons
expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepThree.modelButtonConfirm')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepThree.modelButtonCancel')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Props Testing - Test all prop variations
// --------------------------------------------------------------------------
describe('Props', () => {
describe('show prop', () => {
it('should show modal when show is true', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert
expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
})
it('should hide modal when show is false', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: false })
// Assert
expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument()
})
it('should use default value false when show is not provided', () => {
// Arrange & Act
const onConfirm = jest.fn()
const onHide = jest.fn()
render(<StopEmbeddingModal onConfirm={onConfirm} onHide={onHide} show={false} />)
// Assert
expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument()
})
it('should toggle visibility when show prop changes to true', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
// Act - Initially hidden
const { rerender } = render(
<StopEmbeddingModal show={false} onConfirm={onConfirm} onHide={onHide} />,
)
expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument()
// Act - Show modal
await act(async () => {
rerender(<StopEmbeddingModal show={true} onConfirm={onConfirm} onHide={onHide} />)
})
// Assert - Modal should be visible
await waitFor(() => {
expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
})
})
})
describe('onConfirm prop', () => {
it('should accept onConfirm callback function', () => {
// Arrange
const onConfirm = jest.fn()
// Act
renderStopEmbeddingModal({ onConfirm })
// Assert - No errors thrown
expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
})
})
describe('onHide prop', () => {
it('should accept onHide callback function', () => {
// Arrange
const onHide = jest.fn()
// Act
renderStopEmbeddingModal({ onHide })
// Assert - No errors thrown
expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// User Interactions Tests - Test click events and event handlers
// --------------------------------------------------------------------------
describe('User Interactions', () => {
describe('Confirm Button', () => {
it('should call onConfirm when confirm button is clicked', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
renderStopEmbeddingModal({ onConfirm, onHide })
// Act
const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
await act(async () => {
fireEvent.click(confirmButton)
})
// Assert
expect(onConfirm).toHaveBeenCalledTimes(1)
})
it('should call onHide when confirm button is clicked', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
renderStopEmbeddingModal({ onConfirm, onHide })
// Act
const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
await act(async () => {
fireEvent.click(confirmButton)
})
// Assert
expect(onHide).toHaveBeenCalledTimes(1)
})
it('should call both onConfirm and onHide in correct order when confirm button is clicked', async () => {
// Arrange
const callOrder: string[] = []
const onConfirm = jest.fn(() => callOrder.push('confirm'))
const onHide = jest.fn(() => callOrder.push('hide'))
renderStopEmbeddingModal({ onConfirm, onHide })
// Act
const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
await act(async () => {
fireEvent.click(confirmButton)
})
// Assert - onConfirm should be called before onHide
expect(callOrder).toEqual(['confirm', 'hide'])
})
it('should handle multiple clicks on confirm button', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
renderStopEmbeddingModal({ onConfirm, onHide })
// Act
const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
await act(async () => {
fireEvent.click(confirmButton)
fireEvent.click(confirmButton)
fireEvent.click(confirmButton)
})
// Assert
expect(onConfirm).toHaveBeenCalledTimes(3)
expect(onHide).toHaveBeenCalledTimes(3)
})
})
describe('Cancel Button', () => {
it('should call onHide when cancel button is clicked', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
renderStopEmbeddingModal({ onConfirm, onHide })
// Act
const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel')
await act(async () => {
fireEvent.click(cancelButton)
})
// Assert
expect(onHide).toHaveBeenCalledTimes(1)
})
it('should not call onConfirm when cancel button is clicked', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
renderStopEmbeddingModal({ onConfirm, onHide })
// Act
const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel')
await act(async () => {
fireEvent.click(cancelButton)
})
// Assert
expect(onConfirm).not.toHaveBeenCalled()
})
it('should handle multiple clicks on cancel button', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
renderStopEmbeddingModal({ onConfirm, onHide })
// Act
const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel')
await act(async () => {
fireEvent.click(cancelButton)
fireEvent.click(cancelButton)
})
// Assert
expect(onHide).toHaveBeenCalledTimes(2)
expect(onConfirm).not.toHaveBeenCalled()
})
})
describe('Close Icon', () => {
it('should call onHide when close span is clicked', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
const { container } = renderStopEmbeddingModal({ onConfirm, onHide })
// Act - Find the close span (it should be the span with onClick handler)
const spans = container.querySelectorAll('span')
const closeSpan = Array.from(spans).find(span =>
span.className && span.getAttribute('class')?.includes('close'),
)
if (closeSpan) {
await act(async () => {
fireEvent.click(closeSpan)
})
// Assert
expect(onHide).toHaveBeenCalledTimes(1)
}
else {
// If no close span found with class, just verify the modal renders
expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
}
})
it('should not call onConfirm when close span is clicked', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
const { container } = renderStopEmbeddingModal({ onConfirm, onHide })
// Act
const spans = container.querySelectorAll('span')
const closeSpan = Array.from(spans).find(span =>
span.className && span.getAttribute('class')?.includes('close'),
)
if (closeSpan) {
await act(async () => {
fireEvent.click(closeSpan)
})
// Assert
expect(onConfirm).not.toHaveBeenCalled()
}
})
})
describe('Different Close Methods', () => {
it('should distinguish between confirm and cancel actions', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
renderStopEmbeddingModal({ onConfirm, onHide })
// Act - Click cancel
const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel')
await act(async () => {
fireEvent.click(cancelButton)
})
// Assert
expect(onConfirm).not.toHaveBeenCalled()
expect(onHide).toHaveBeenCalledTimes(1)
// Reset
jest.clearAllMocks()
// Act - Click confirm
const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
await act(async () => {
fireEvent.click(confirmButton)
})
// Assert
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(onHide).toHaveBeenCalledTimes(1)
})
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests - Test null, undefined, empty values and boundaries
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle rapid confirm button clicks', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
renderStopEmbeddingModal({ onConfirm, onHide })
// Act - Rapid clicks
const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
await act(async () => {
for (let i = 0; i < 10; i++)
fireEvent.click(confirmButton)
})
// Assert
expect(onConfirm).toHaveBeenCalledTimes(10)
expect(onHide).toHaveBeenCalledTimes(10)
})
it('should handle rapid cancel button clicks', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
renderStopEmbeddingModal({ onConfirm, onHide })
// Act - Rapid clicks
const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel')
await act(async () => {
for (let i = 0; i < 10; i++)
fireEvent.click(cancelButton)
})
// Assert
expect(onHide).toHaveBeenCalledTimes(10)
expect(onConfirm).not.toHaveBeenCalled()
})
it('should handle callbacks being replaced', async () => {
// Arrange
const onConfirm1 = jest.fn()
const onHide1 = jest.fn()
const onConfirm2 = jest.fn()
const onHide2 = jest.fn()
// Act
const { rerender } = render(
<StopEmbeddingModal show={true} onConfirm={onConfirm1} onHide={onHide1} />,
)
// Replace callbacks
await act(async () => {
rerender(<StopEmbeddingModal show={true} onConfirm={onConfirm2} onHide={onHide2} />)
})
// Click confirm with new callbacks
const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
await act(async () => {
fireEvent.click(confirmButton)
})
// Assert - New callbacks should be called
expect(onConfirm1).not.toHaveBeenCalled()
expect(onHide1).not.toHaveBeenCalled()
expect(onConfirm2).toHaveBeenCalledTimes(1)
expect(onHide2).toHaveBeenCalledTimes(1)
})
it('should render with all required props', () => {
// Arrange & Act
render(
<StopEmbeddingModal
show={true}
onConfirm={jest.fn()}
onHide={jest.fn()}
/>,
)
// Assert
expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Layout and Styling Tests - Verify correct structure
// --------------------------------------------------------------------------
describe('Layout and Styling', () => {
it('should have buttons container with flex-row-reverse', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert
const buttons = screen.getAllByRole('button')
expect(buttons[0].closest('div')).toHaveClass('flex', 'flex-row-reverse')
})
it('should render title and content elements', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert
expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeInTheDocument()
})
it('should render two buttons', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(2)
})
})
// --------------------------------------------------------------------------
// submit Function Tests - Test the internal submit function behavior
// --------------------------------------------------------------------------
describe('submit Function', () => {
it('should execute onConfirm first then onHide', async () => {
// Arrange
let confirmTime = 0
let hideTime = 0
let counter = 0
const onConfirm = jest.fn(() => {
confirmTime = ++counter
})
const onHide = jest.fn(() => {
hideTime = ++counter
})
renderStopEmbeddingModal({ onConfirm, onHide })
// Act
const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
await act(async () => {
fireEvent.click(confirmButton)
})
// Assert
expect(confirmTime).toBe(1)
expect(hideTime).toBe(2)
})
it('should call both callbacks exactly once per click', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
renderStopEmbeddingModal({ onConfirm, onHide })
// Act
const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
await act(async () => {
fireEvent.click(confirmButton)
})
// Assert
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(onHide).toHaveBeenCalledTimes(1)
})
it('should pass no arguments to onConfirm', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
renderStopEmbeddingModal({ onConfirm, onHide })
// Act
const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
await act(async () => {
fireEvent.click(confirmButton)
})
// Assert
expect(onConfirm).toHaveBeenCalledWith()
})
it('should pass no arguments to onHide when called from submit', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
renderStopEmbeddingModal({ onConfirm, onHide })
// Act
const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
await act(async () => {
fireEvent.click(confirmButton)
})
// Assert
expect(onHide).toHaveBeenCalledWith()
})
})
// --------------------------------------------------------------------------
// Modal Integration Tests - Verify Modal component integration
// --------------------------------------------------------------------------
describe('Modal Integration', () => {
it('should pass show prop to Modal as isShow', async () => {
// Arrange & Act
const { rerender } = render(
<StopEmbeddingModal show={true} onConfirm={jest.fn()} onHide={jest.fn()} />,
)
// Assert - Modal should be visible
expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
// Act - Hide modal
await act(async () => {
rerender(<StopEmbeddingModal show={false} onConfirm={jest.fn()} onHide={jest.fn()} />)
})
// Assert - Modal should transition to hidden (wait for transition)
await waitFor(() => {
expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument()
}, { timeout: 3000 })
})
})
// --------------------------------------------------------------------------
// Accessibility Tests
// --------------------------------------------------------------------------
describe('Accessibility', () => {
it('should have buttons that are focusable', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert
const buttons = screen.getAllByRole('button')
buttons.forEach((button) => {
expect(button).not.toHaveAttribute('tabindex', '-1')
})
})
it('should have semantic button elements', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(2)
})
it('should have accessible text content', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert
expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeVisible()
expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeVisible()
expect(screen.getByText('datasetCreation.stepThree.modelButtonConfirm')).toBeVisible()
expect(screen.getByText('datasetCreation.stepThree.modelButtonCancel')).toBeVisible()
})
})
// --------------------------------------------------------------------------
// Component Lifecycle Tests
// --------------------------------------------------------------------------
describe('Component Lifecycle', () => {
it('should unmount cleanly', () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
const { unmount } = renderStopEmbeddingModal({ onConfirm, onHide })
// Act & Assert - Should not throw
expect(() => unmount()).not.toThrow()
})
it('should not call callbacks after unmount', () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
const { unmount } = renderStopEmbeddingModal({ onConfirm, onHide })
// Act
unmount()
// Assert - No callbacks should be called after unmount
expect(onConfirm).not.toHaveBeenCalled()
expect(onHide).not.toHaveBeenCalled()
})
it('should re-render correctly when props update', async () => {
// Arrange
const onConfirm1 = jest.fn()
const onHide1 = jest.fn()
const onConfirm2 = jest.fn()
const onHide2 = jest.fn()
// Act - Initial render
const { rerender } = render(
<StopEmbeddingModal show={true} onConfirm={onConfirm1} onHide={onHide1} />,
)
// Verify initial render
expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
// Update props
await act(async () => {
rerender(<StopEmbeddingModal show={true} onConfirm={onConfirm2} onHide={onHide2} />)
})
// Assert - Still renders correctly
expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,539 @@
import { render, screen } from '@testing-library/react'
import { TopBar, type TopBarProps } from './index'
// Mock next/link to capture href values
jest.mock('next/link', () => {
return ({ children, href, replace, className }: { children: React.ReactNode; href: string; replace?: boolean; className?: string }) => (
<a href={href} data-replace={replace} className={className} data-testid="back-link">
{children}
</a>
)
})
// Helper to render TopBar with default props
const renderTopBar = (props: Partial<TopBarProps> = {}) => {
const defaultProps: TopBarProps = {
activeIndex: 0,
...props,
}
return {
...render(<TopBar {...defaultProps} />),
props: defaultProps,
}
}
// ============================================================================
// TopBar Component Tests
// ============================================================================
describe('TopBar', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests - Verify component renders properly
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
renderTopBar()
// Assert
expect(screen.getByTestId('back-link')).toBeInTheDocument()
})
it('should render back link with arrow icon', () => {
// Arrange & Act
const { container } = renderTopBar()
// Assert
const backLink = screen.getByTestId('back-link')
expect(backLink).toBeInTheDocument()
// Check for the arrow icon (svg element)
const arrowIcon = container.querySelector('svg')
expect(arrowIcon).toBeInTheDocument()
})
it('should render fallback route text', () => {
// Arrange & Act
renderTopBar()
// Assert
expect(screen.getByText('datasetCreation.steps.header.fallbackRoute')).toBeInTheDocument()
})
it('should render Stepper component with 3 steps', () => {
// Arrange & Act
renderTopBar({ activeIndex: 0 })
// Assert - Check for step translations
expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.steps.two')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.steps.three')).toBeInTheDocument()
})
it('should apply default container classes', () => {
// Arrange & Act
const { container } = renderTopBar()
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('relative')
expect(wrapper).toHaveClass('flex')
expect(wrapper).toHaveClass('h-[52px]')
expect(wrapper).toHaveClass('shrink-0')
expect(wrapper).toHaveClass('items-center')
expect(wrapper).toHaveClass('justify-between')
expect(wrapper).toHaveClass('border-b')
expect(wrapper).toHaveClass('border-b-divider-subtle')
})
})
// --------------------------------------------------------------------------
// Props Testing - Test all prop variations
// --------------------------------------------------------------------------
describe('Props', () => {
describe('className prop', () => {
it('should apply custom className when provided', () => {
// Arrange & Act
const { container } = renderTopBar({ className: 'custom-class' })
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('custom-class')
})
it('should merge custom className with default classes', () => {
// Arrange & Act
const { container } = renderTopBar({ className: 'my-custom-class another-class' })
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('relative')
expect(wrapper).toHaveClass('flex')
expect(wrapper).toHaveClass('my-custom-class')
expect(wrapper).toHaveClass('another-class')
})
it('should render correctly without className', () => {
// Arrange & Act
const { container } = renderTopBar({ className: undefined })
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('relative')
expect(wrapper).toHaveClass('flex')
})
it('should handle empty string className', () => {
// Arrange & Act
const { container } = renderTopBar({ className: '' })
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('relative')
})
})
describe('datasetId prop', () => {
it('should set fallback route to /datasets when datasetId is undefined', () => {
// Arrange & Act
renderTopBar({ datasetId: undefined })
// Assert
const backLink = screen.getByTestId('back-link')
expect(backLink).toHaveAttribute('href', '/datasets')
})
it('should set fallback route to /datasets/:id/documents when datasetId is provided', () => {
// Arrange & Act
renderTopBar({ datasetId: 'dataset-123' })
// Assert
const backLink = screen.getByTestId('back-link')
expect(backLink).toHaveAttribute('href', '/datasets/dataset-123/documents')
})
it('should handle various datasetId formats', () => {
// Arrange & Act
renderTopBar({ datasetId: 'abc-def-ghi-123' })
// Assert
const backLink = screen.getByTestId('back-link')
expect(backLink).toHaveAttribute('href', '/datasets/abc-def-ghi-123/documents')
})
it('should handle empty string datasetId', () => {
// Arrange & Act
renderTopBar({ datasetId: '' })
// Assert - Empty string is falsy, so fallback to /datasets
const backLink = screen.getByTestId('back-link')
expect(backLink).toHaveAttribute('href', '/datasets')
})
})
describe('activeIndex prop', () => {
it('should pass activeIndex to Stepper component (index 0)', () => {
// Arrange & Act
const { container } = renderTopBar({ activeIndex: 0 })
// Assert - First step should be active (has specific styling)
const steps = container.querySelectorAll('[class*="system-2xs-semibold-uppercase"]')
expect(steps.length).toBeGreaterThan(0)
})
it('should pass activeIndex to Stepper component (index 1)', () => {
// Arrange & Act
renderTopBar({ activeIndex: 1 })
// Assert - Stepper is rendered with correct props
expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.steps.two')).toBeInTheDocument()
})
it('should pass activeIndex to Stepper component (index 2)', () => {
// Arrange & Act
renderTopBar({ activeIndex: 2 })
// Assert
expect(screen.getByText('datasetCreation.steps.three')).toBeInTheDocument()
})
it('should handle edge case activeIndex of -1', () => {
// Arrange & Act
const { container } = renderTopBar({ activeIndex: -1 })
// Assert - Component should render without crashing
expect(container.firstChild).toBeInTheDocument()
})
it('should handle edge case activeIndex beyond steps length', () => {
// Arrange & Act
const { container } = renderTopBar({ activeIndex: 10 })
// Assert - Component should render without crashing
expect(container.firstChild).toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// Memoization Tests - Test useMemo logic and dependencies
// --------------------------------------------------------------------------
describe('Memoization Logic', () => {
it('should compute fallbackRoute based on datasetId', () => {
// Arrange & Act - With datasetId
const { rerender } = render(<TopBar activeIndex={0} datasetId="test-id" />)
// Assert
expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/test-id/documents')
// Act - Rerender with different datasetId
rerender(<TopBar activeIndex={0} datasetId="new-id" />)
// Assert - Route should update
expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/new-id/documents')
})
it('should update fallbackRoute when datasetId changes from undefined to defined', () => {
// Arrange
const { rerender } = render(<TopBar activeIndex={0} />)
expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets')
// Act
rerender(<TopBar activeIndex={0} datasetId="new-dataset" />)
// Assert
expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/new-dataset/documents')
})
it('should update fallbackRoute when datasetId changes from defined to undefined', () => {
// Arrange
const { rerender } = render(<TopBar activeIndex={0} datasetId="existing-id" />)
expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/existing-id/documents')
// Act
rerender(<TopBar activeIndex={0} datasetId={undefined} />)
// Assert
expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets')
})
it('should not change fallbackRoute when activeIndex changes but datasetId stays same', () => {
// Arrange
const { rerender } = render(<TopBar activeIndex={0} datasetId="stable-id" />)
const initialHref = screen.getByTestId('back-link').getAttribute('href')
// Act
rerender(<TopBar activeIndex={1} datasetId="stable-id" />)
// Assert - href should remain the same
expect(screen.getByTestId('back-link')).toHaveAttribute('href', initialHref)
})
it('should not change fallbackRoute when className changes but datasetId stays same', () => {
// Arrange
const { rerender } = render(<TopBar activeIndex={0} datasetId="stable-id" className="class-1" />)
const initialHref = screen.getByTestId('back-link').getAttribute('href')
// Act
rerender(<TopBar activeIndex={0} datasetId="stable-id" className="class-2" />)
// Assert - href should remain the same
expect(screen.getByTestId('back-link')).toHaveAttribute('href', initialHref)
})
})
// --------------------------------------------------------------------------
// Link Component Tests
// --------------------------------------------------------------------------
describe('Link Component', () => {
it('should render Link with replace prop', () => {
// Arrange & Act
renderTopBar()
// Assert
const backLink = screen.getByTestId('back-link')
expect(backLink).toHaveAttribute('data-replace', 'true')
})
it('should render Link with correct classes', () => {
// Arrange & Act
renderTopBar()
// Assert
const backLink = screen.getByTestId('back-link')
expect(backLink).toHaveClass('inline-flex')
expect(backLink).toHaveClass('h-12')
expect(backLink).toHaveClass('items-center')
expect(backLink).toHaveClass('justify-start')
expect(backLink).toHaveClass('gap-1')
expect(backLink).toHaveClass('py-2')
expect(backLink).toHaveClass('pl-2')
expect(backLink).toHaveClass('pr-6')
})
})
// --------------------------------------------------------------------------
// STEP_T_MAP Tests - Verify step translations
// --------------------------------------------------------------------------
describe('STEP_T_MAP Translations', () => {
it('should render step one translation', () => {
// Arrange & Act
renderTopBar({ activeIndex: 0 })
// Assert
expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument()
})
it('should render step two translation', () => {
// Arrange & Act
renderTopBar({ activeIndex: 1 })
// Assert
expect(screen.getByText('datasetCreation.steps.two')).toBeInTheDocument()
})
it('should render step three translation', () => {
// Arrange & Act
renderTopBar({ activeIndex: 2 })
// Assert
expect(screen.getByText('datasetCreation.steps.three')).toBeInTheDocument()
})
it('should render all three step translations', () => {
// Arrange & Act
renderTopBar({ activeIndex: 0 })
// Assert
expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.steps.two')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.steps.three')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Edge Cases and Error Handling Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle special characters in datasetId', () => {
// Arrange & Act
renderTopBar({ datasetId: 'dataset-with-special_chars.123' })
// Assert
const backLink = screen.getByTestId('back-link')
expect(backLink).toHaveAttribute('href', '/datasets/dataset-with-special_chars.123/documents')
})
it('should handle very long datasetId', () => {
// Arrange
const longId = 'a'.repeat(100)
// Act
renderTopBar({ datasetId: longId })
// Assert
const backLink = screen.getByTestId('back-link')
expect(backLink).toHaveAttribute('href', `/datasets/${longId}/documents`)
})
it('should handle UUID format datasetId', () => {
// Arrange
const uuid = '550e8400-e29b-41d4-a716-446655440000'
// Act
renderTopBar({ datasetId: uuid })
// Assert
const backLink = screen.getByTestId('back-link')
expect(backLink).toHaveAttribute('href', `/datasets/${uuid}/documents`)
})
it('should handle whitespace in className', () => {
// Arrange & Act
const { container } = renderTopBar({ className: ' spaced-class ' })
// Assert - classNames utility handles whitespace
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toBeInTheDocument()
})
it('should render correctly with all props provided', () => {
// Arrange & Act
const { container } = renderTopBar({
className: 'custom-class',
datasetId: 'full-props-id',
activeIndex: 2,
})
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('custom-class')
expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/full-props-id/documents')
})
it('should render correctly with minimal props (only activeIndex)', () => {
// Arrange & Act
const { container } = renderTopBar({ activeIndex: 0 })
// Assert
expect(container.firstChild).toBeInTheDocument()
expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets')
})
})
// --------------------------------------------------------------------------
// Stepper Integration Tests
// --------------------------------------------------------------------------
describe('Stepper Integration', () => {
it('should pass steps array with correct structure to Stepper', () => {
// Arrange & Act
renderTopBar({ activeIndex: 0 })
// Assert - All step names should be rendered
const stepOne = screen.getByText('datasetCreation.steps.one')
const stepTwo = screen.getByText('datasetCreation.steps.two')
const stepThree = screen.getByText('datasetCreation.steps.three')
expect(stepOne).toBeInTheDocument()
expect(stepTwo).toBeInTheDocument()
expect(stepThree).toBeInTheDocument()
})
it('should render Stepper in centered position', () => {
// Arrange & Act
const { container } = renderTopBar({ activeIndex: 0 })
// Assert - Check for centered positioning classes
const centeredContainer = container.querySelector('.absolute.left-1\\/2.top-1\\/2.-translate-x-1\\/2.-translate-y-1\\/2')
expect(centeredContainer).toBeInTheDocument()
})
it('should render step dividers between steps', () => {
// Arrange & Act
const { container } = renderTopBar({ activeIndex: 0 })
// Assert - Check for dividers (h-px w-4 bg-divider-deep)
const dividers = container.querySelectorAll('.h-px.w-4.bg-divider-deep')
expect(dividers.length).toBe(2) // 2 dividers between 3 steps
})
})
// --------------------------------------------------------------------------
// Accessibility Tests
// --------------------------------------------------------------------------
describe('Accessibility', () => {
it('should have accessible back link', () => {
// Arrange & Act
renderTopBar()
// Assert
const backLink = screen.getByTestId('back-link')
expect(backLink).toBeInTheDocument()
// Link should have visible text
expect(screen.getByText('datasetCreation.steps.header.fallbackRoute')).toBeInTheDocument()
})
it('should have visible arrow icon in back link', () => {
// Arrange & Act
const { container } = renderTopBar()
// Assert - Arrow icon should be visible
const arrowIcon = container.querySelector('svg')
expect(arrowIcon).toBeInTheDocument()
expect(arrowIcon).toHaveClass('text-text-primary')
})
})
// --------------------------------------------------------------------------
// Re-render Tests
// --------------------------------------------------------------------------
describe('Re-render Behavior', () => {
it('should update activeIndex on re-render', () => {
// Arrange
const { rerender, container } = render(<TopBar activeIndex={0} />)
// Initial check
expect(container.firstChild).toBeInTheDocument()
// Act - Update activeIndex
rerender(<TopBar activeIndex={1} />)
// Assert - Component should still render
expect(container.firstChild).toBeInTheDocument()
})
it('should update className on re-render', () => {
// Arrange
const { rerender, container } = render(<TopBar activeIndex={0} className="initial-class" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('initial-class')
// Act
rerender(<TopBar activeIndex={0} className="updated-class" />)
// Assert
expect(wrapper).toHaveClass('updated-class')
expect(wrapper).not.toHaveClass('initial-class')
})
it('should handle multiple rapid re-renders', () => {
// Arrange
const { rerender, container } = render(<TopBar activeIndex={0} />)
// Act - Multiple rapid re-renders
rerender(<TopBar activeIndex={1} />)
rerender(<TopBar activeIndex={2} />)
rerender(<TopBar activeIndex={0} datasetId="new-id" />)
rerender(<TopBar activeIndex={1} datasetId="another-id" className="new-class" />)
// Assert - Component should be stable
expect(container.firstChild).toBeInTheDocument()
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('new-class')
expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/another-id/documents')
})
})
})

View File

@@ -0,0 +1,555 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Input from './base/input'
import Header from './base/header'
import CrawledResult from './base/crawled-result'
import CrawledResultItem from './base/crawled-result-item'
import type { CrawlResultItem } from '@/models/datasets'
// ============================================================================
// Test Data Factories
// ============================================================================
const createCrawlResultItem = (overrides: Partial<CrawlResultItem> = {}): CrawlResultItem => ({
title: 'Test Page Title',
markdown: '# Test Content',
description: 'Test description',
source_url: 'https://example.com/page',
...overrides,
})
// ============================================================================
// Input Component Tests
// ============================================================================
describe('Input', () => {
beforeEach(() => {
jest.clearAllMocks()
})
const createInputProps = (overrides: Partial<Parameters<typeof Input>[0]> = {}) => ({
value: '',
onChange: jest.fn(),
...overrides,
})
describe('Rendering', () => {
it('should render text input by default', () => {
const props = createInputProps()
render(<Input {...props} />)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
expect(input).toHaveAttribute('type', 'text')
})
it('should render number input when isNumber is true', () => {
const props = createInputProps({ isNumber: true, value: 0 })
render(<Input {...props} />)
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
expect(input).toHaveAttribute('type', 'number')
expect(input).toHaveAttribute('min', '0')
})
it('should render with placeholder', () => {
const props = createInputProps({ placeholder: 'Enter URL' })
render(<Input {...props} />)
expect(screen.getByPlaceholderText('Enter URL')).toBeInTheDocument()
})
it('should render with initial value', () => {
const props = createInputProps({ value: 'test value' })
render(<Input {...props} />)
expect(screen.getByDisplayValue('test value')).toBeInTheDocument()
})
})
describe('Text Input Behavior', () => {
it('should call onChange with string value for text input', async () => {
const onChange = jest.fn()
const props = createInputProps({ onChange })
render(<Input {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'hello')
expect(onChange).toHaveBeenCalledWith('h')
expect(onChange).toHaveBeenCalledWith('e')
expect(onChange).toHaveBeenCalledWith('l')
expect(onChange).toHaveBeenCalledWith('l')
expect(onChange).toHaveBeenCalledWith('o')
})
})
describe('Number Input Behavior', () => {
it('should call onChange with parsed integer for number input', () => {
const onChange = jest.fn()
const props = createInputProps({ isNumber: true, onChange, value: 0 })
render(<Input {...props} />)
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '42' } })
expect(onChange).toHaveBeenCalledWith(42)
})
it('should call onChange with empty string when input is NaN', () => {
const onChange = jest.fn()
const props = createInputProps({ isNumber: true, onChange, value: 0 })
render(<Input {...props} />)
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: 'abc' } })
expect(onChange).toHaveBeenCalledWith('')
})
it('should call onChange with empty string when input is empty', () => {
const onChange = jest.fn()
const props = createInputProps({ isNumber: true, onChange, value: 5 })
render(<Input {...props} />)
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '' } })
expect(onChange).toHaveBeenCalledWith('')
})
it('should clamp negative values to MIN_VALUE (0)', () => {
const onChange = jest.fn()
const props = createInputProps({ isNumber: true, onChange, value: 0 })
render(<Input {...props} />)
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '-5' } })
expect(onChange).toHaveBeenCalledWith(0)
})
it('should handle decimal input by parsing as integer', () => {
const onChange = jest.fn()
const props = createInputProps({ isNumber: true, onChange, value: 0 })
render(<Input {...props} />)
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '3.7' } })
expect(onChange).toHaveBeenCalledWith(3)
})
})
describe('Component Memoization', () => {
it('should be wrapped with React.memo', () => {
expect(Input.$$typeof).toBeDefined()
})
})
})
// ============================================================================
// Header Component Tests
// ============================================================================
describe('Header', () => {
const createHeaderProps = (overrides: Partial<Parameters<typeof Header>[0]> = {}) => ({
title: 'Test Title',
docTitle: 'Documentation',
docLink: 'https://docs.example.com',
...overrides,
})
describe('Rendering', () => {
it('should render title', () => {
const props = createHeaderProps()
render(<Header {...props} />)
expect(screen.getByText('Test Title')).toBeInTheDocument()
})
it('should render doc link', () => {
const props = createHeaderProps()
render(<Header {...props} />)
const link = screen.getByRole('link')
expect(link).toHaveAttribute('href', 'https://docs.example.com')
expect(link).toHaveAttribute('target', '_blank')
})
it('should render button text when not in pipeline', () => {
const props = createHeaderProps({ buttonText: 'Configure' })
render(<Header {...props} />)
expect(screen.getByText('Configure')).toBeInTheDocument()
})
it('should not render button text when in pipeline', () => {
const props = createHeaderProps({ isInPipeline: true, buttonText: 'Configure' })
render(<Header {...props} />)
expect(screen.queryByText('Configure')).not.toBeInTheDocument()
})
})
describe('isInPipeline Prop', () => {
it('should apply pipeline styles when isInPipeline is true', () => {
const props = createHeaderProps({ isInPipeline: true })
render(<Header {...props} />)
const titleElement = screen.getByText('Test Title')
expect(titleElement).toHaveClass('system-sm-semibold')
})
it('should apply default styles when isInPipeline is false', () => {
const props = createHeaderProps({ isInPipeline: false })
render(<Header {...props} />)
const titleElement = screen.getByText('Test Title')
expect(titleElement).toHaveClass('system-md-semibold')
})
it('should apply compact button styles when isInPipeline is true', () => {
const props = createHeaderProps({ isInPipeline: true })
render(<Header {...props} />)
const button = screen.getByRole('button')
expect(button).toHaveClass('size-6')
expect(button).toHaveClass('px-1')
})
it('should apply default button styles when isInPipeline is false', () => {
const props = createHeaderProps({ isInPipeline: false })
render(<Header {...props} />)
const button = screen.getByRole('button')
expect(button).toHaveClass('gap-x-0.5')
expect(button).toHaveClass('px-1.5')
})
})
describe('User Interactions', () => {
it('should call onClickConfiguration when button is clicked', async () => {
const onClickConfiguration = jest.fn()
const props = createHeaderProps({ onClickConfiguration })
render(<Header {...props} />)
await userEvent.click(screen.getByRole('button'))
expect(onClickConfiguration).toHaveBeenCalledTimes(1)
})
})
describe('Component Memoization', () => {
it('should be wrapped with React.memo', () => {
expect(Header.$$typeof).toBeDefined()
})
})
})
// ============================================================================
// CrawledResultItem Component Tests
// ============================================================================
describe('CrawledResultItem', () => {
const createItemProps = (overrides: Partial<Parameters<typeof CrawledResultItem>[0]> = {}) => ({
payload: createCrawlResultItem(),
isChecked: false,
isPreview: false,
onCheckChange: jest.fn(),
onPreview: jest.fn(),
testId: 'test-item',
...overrides,
})
describe('Rendering', () => {
it('should render title and source URL', () => {
const props = createItemProps({
payload: createCrawlResultItem({
title: 'My Page',
source_url: 'https://mysite.com',
}),
})
render(<CrawledResultItem {...props} />)
expect(screen.getByText('My Page')).toBeInTheDocument()
expect(screen.getByText('https://mysite.com')).toBeInTheDocument()
})
it('should render checkbox (custom Checkbox component)', () => {
const props = createItemProps()
render(<CrawledResultItem {...props} />)
// Find checkbox by data-testid
const checkbox = screen.getByTestId('checkbox-test-item')
expect(checkbox).toBeInTheDocument()
})
it('should render preview button', () => {
const props = createItemProps()
render(<CrawledResultItem {...props} />)
expect(screen.getByText('datasetCreation.stepOne.website.preview')).toBeInTheDocument()
})
})
describe('Checkbox Behavior', () => {
it('should call onCheckChange with true when unchecked item is clicked', async () => {
const onCheckChange = jest.fn()
const props = createItemProps({ isChecked: false, onCheckChange })
render(<CrawledResultItem {...props} />)
const checkbox = screen.getByTestId('checkbox-test-item')
await userEvent.click(checkbox)
expect(onCheckChange).toHaveBeenCalledWith(true)
})
it('should call onCheckChange with false when checked item is clicked', async () => {
const onCheckChange = jest.fn()
const props = createItemProps({ isChecked: true, onCheckChange })
render(<CrawledResultItem {...props} />)
const checkbox = screen.getByTestId('checkbox-test-item')
await userEvent.click(checkbox)
expect(onCheckChange).toHaveBeenCalledWith(false)
})
})
describe('Preview Behavior', () => {
it('should call onPreview when preview button is clicked', async () => {
const onPreview = jest.fn()
const props = createItemProps({ onPreview })
render(<CrawledResultItem {...props} />)
await userEvent.click(screen.getByText('datasetCreation.stepOne.website.preview'))
expect(onPreview).toHaveBeenCalledTimes(1)
})
it('should apply active style when isPreview is true', () => {
const props = createItemProps({ isPreview: true })
const { container } = render(<CrawledResultItem {...props} />)
const wrapper = container.firstChild
expect(wrapper).toHaveClass('bg-state-base-active')
})
it('should not apply active style when isPreview is false', () => {
const props = createItemProps({ isPreview: false })
const { container } = render(<CrawledResultItem {...props} />)
const wrapper = container.firstChild
expect(wrapper).not.toHaveClass('bg-state-base-active')
})
})
describe('Component Memoization', () => {
it('should be wrapped with React.memo', () => {
expect(CrawledResultItem.$$typeof).toBeDefined()
})
})
})
// ============================================================================
// CrawledResult Component Tests
// ============================================================================
describe('CrawledResult', () => {
const createResultProps = (overrides: Partial<Parameters<typeof CrawledResult>[0]> = {}) => ({
list: [
createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }),
createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }),
createCrawlResultItem({ source_url: 'https://page3.com', title: 'Page 3' }),
],
checkedList: [],
onSelectedChange: jest.fn(),
onPreview: jest.fn(),
usedTime: 2.5,
...overrides,
})
// Helper functions to get checkboxes by data-testid
const getSelectAllCheckbox = () => screen.getByTestId('checkbox-select-all')
const getItemCheckbox = (index: number) => screen.getByTestId(`checkbox-item-${index}`)
describe('Rendering', () => {
it('should render all items in list', () => {
const props = createResultProps()
render(<CrawledResult {...props} />)
expect(screen.getByText('Page 1')).toBeInTheDocument()
expect(screen.getByText('Page 2')).toBeInTheDocument()
expect(screen.getByText('Page 3')).toBeInTheDocument()
})
it('should render time info', () => {
const props = createResultProps({ usedTime: 3.456 })
render(<CrawledResult {...props} />)
// The component uses i18n, so we check for the key pattern
expect(screen.getByText(/scrapTimeInfo/)).toBeInTheDocument()
})
it('should render select all checkbox', () => {
const props = createResultProps()
render(<CrawledResult {...props} />)
expect(screen.getByText('datasetCreation.stepOne.website.selectAll')).toBeInTheDocument()
})
it('should render reset all when all items are checked', () => {
const list = [
createCrawlResultItem({ source_url: 'https://page1.com' }),
createCrawlResultItem({ source_url: 'https://page2.com' }),
]
const props = createResultProps({ list, checkedList: list })
render(<CrawledResult {...props} />)
expect(screen.getByText('datasetCreation.stepOne.website.resetAll')).toBeInTheDocument()
})
})
describe('Select All / Deselect All', () => {
it('should call onSelectedChange with all items when select all is clicked', async () => {
const onSelectedChange = jest.fn()
const list = [
createCrawlResultItem({ source_url: 'https://page1.com' }),
createCrawlResultItem({ source_url: 'https://page2.com' }),
]
const props = createResultProps({ list, checkedList: [], onSelectedChange })
render(<CrawledResult {...props} />)
await userEvent.click(getSelectAllCheckbox())
expect(onSelectedChange).toHaveBeenCalledWith(list)
})
it('should call onSelectedChange with empty array when reset all is clicked', async () => {
const onSelectedChange = jest.fn()
const list = [
createCrawlResultItem({ source_url: 'https://page1.com' }),
createCrawlResultItem({ source_url: 'https://page2.com' }),
]
const props = createResultProps({ list, checkedList: list, onSelectedChange })
render(<CrawledResult {...props} />)
await userEvent.click(getSelectAllCheckbox())
expect(onSelectedChange).toHaveBeenCalledWith([])
})
})
describe('Individual Item Selection', () => {
it('should add item to checkedList when unchecked item is checked', async () => {
const onSelectedChange = jest.fn()
const list = [
createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }),
createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }),
]
const props = createResultProps({ list, checkedList: [], onSelectedChange })
render(<CrawledResult {...props} />)
await userEvent.click(getItemCheckbox(0))
expect(onSelectedChange).toHaveBeenCalledWith([list[0]])
})
it('should remove item from checkedList when checked item is unchecked', async () => {
const onSelectedChange = jest.fn()
const list = [
createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }),
createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }),
]
const props = createResultProps({ list, checkedList: [list[0]], onSelectedChange })
render(<CrawledResult {...props} />)
await userEvent.click(getItemCheckbox(0))
expect(onSelectedChange).toHaveBeenCalledWith([])
})
it('should preserve other checked items when unchecking one item', async () => {
const onSelectedChange = jest.fn()
const list = [
createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }),
createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }),
createCrawlResultItem({ source_url: 'https://page3.com', title: 'Page 3' }),
]
const props = createResultProps({ list, checkedList: [list[0], list[1]], onSelectedChange })
render(<CrawledResult {...props} />)
// Click the first item's checkbox to uncheck it
await userEvent.click(getItemCheckbox(0))
expect(onSelectedChange).toHaveBeenCalledWith([list[1]])
})
})
describe('Preview Behavior', () => {
it('should call onPreview with correct item when preview is clicked', async () => {
const onPreview = jest.fn()
const list = [
createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }),
createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }),
]
const props = createResultProps({ list, onPreview })
render(<CrawledResult {...props} />)
// Click preview on second item
const previewButtons = screen.getAllByText('datasetCreation.stepOne.website.preview')
await userEvent.click(previewButtons[1])
expect(onPreview).toHaveBeenCalledWith(list[1])
})
it('should track preview index correctly', async () => {
const onPreview = jest.fn()
const list = [
createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }),
createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }),
]
const props = createResultProps({ list, onPreview })
render(<CrawledResult {...props} />)
// Click preview on first item
const previewButtons = screen.getAllByText('datasetCreation.stepOne.website.preview')
await userEvent.click(previewButtons[0])
expect(onPreview).toHaveBeenCalledWith(list[0])
})
})
describe('Component Memoization', () => {
it('should be wrapped with React.memo', () => {
expect(CrawledResult.$$typeof).toBeDefined()
})
})
describe('Edge Cases', () => {
it('should handle empty list', () => {
const props = createResultProps({ list: [], checkedList: [] })
render(<CrawledResult {...props} />)
// Should still render the header with resetAll (empty list = all checked)
expect(screen.getByText('datasetCreation.stepOne.website.resetAll')).toBeInTheDocument()
})
it('should handle className prop', () => {
const props = createResultProps({ className: 'custom-class' })
const { container } = render(<CrawledResult {...props} />)
expect(container.firstChild).toHaveClass('custom-class')
})
})
})

View File

@@ -12,6 +12,7 @@ type Props = {
label: string
labelClassName?: string
tooltip?: string
testId?: string
}
const CheckboxWithLabel: FC<Props> = ({
@@ -21,10 +22,11 @@ const CheckboxWithLabel: FC<Props> = ({
label,
labelClassName,
tooltip,
testId,
}) => {
return (
<label className={cn(className, 'flex h-7 items-center space-x-2')}>
<Checkbox checked={isChecked} onCheck={() => onChange(!isChecked)} />
<Checkbox checked={isChecked} onCheck={() => onChange(!isChecked)} id={testId} />
<div className={cn('text-sm font-normal text-text-secondary', labelClassName)}>{label}</div>
{tooltip && (
<Tooltip

View File

@@ -13,6 +13,7 @@ type Props = {
isPreview: boolean
onCheckChange: (checked: boolean) => void
onPreview: () => void
testId?: string
}
const CrawledResultItem: FC<Props> = ({
@@ -21,6 +22,7 @@ const CrawledResultItem: FC<Props> = ({
isChecked,
onCheckChange,
onPreview,
testId,
}) => {
const { t } = useTranslation()
@@ -31,7 +33,7 @@ const CrawledResultItem: FC<Props> = ({
<div className={cn(isPreview ? 'bg-state-base-active' : 'group hover:bg-state-base-hover', 'cursor-pointer rounded-lg p-2')}>
<div className='relative flex'>
<div className='flex h-5 items-center'>
<Checkbox className='mr-2 shrink-0' checked={isChecked} onCheck={handleCheckChange} />
<Checkbox className='mr-2 shrink-0' checked={isChecked} onCheck={handleCheckChange} id={testId} />
</div>
<div className='flex min-w-0 grow flex-col'>
<div

View File

@@ -61,8 +61,10 @@ const CrawledResult: FC<Props> = ({
<div className='flex h-[34px] items-center justify-between px-4'>
<CheckboxWithLabel
isChecked={isCheckAll}
onChange={handleCheckedAll} label={isCheckAll ? t(`${I18N_PREFIX}.resetAll`) : t(`${I18N_PREFIX}.selectAll`)}
onChange={handleCheckedAll}
label={isCheckAll ? t(`${I18N_PREFIX}.resetAll`) : t(`${I18N_PREFIX}.selectAll`)}
labelClassName='system-[13px] leading-[16px] font-medium text-text-secondary'
testId='select-all'
/>
<div className='text-xs text-text-tertiary'>
{t(`${I18N_PREFIX}.scrapTimeInfo`, {
@@ -80,6 +82,7 @@ const CrawledResult: FC<Props> = ({
payload={item}
isChecked={checkedList.some(checkedItem => checkedItem.source_url === item.source_url)}
onCheckChange={handleItemCheckChange(item)}
testId={`item-${index}`}
/>
))}
</div>

View File

@@ -0,0 +1,396 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import UrlInput from './base/url-input'
// Mock doc link context
jest.mock('@/context/i18n', () => ({
useDocLink: () => () => 'https://docs.example.com',
}))
// ============================================================================
// UrlInput Component Tests
// ============================================================================
describe('UrlInput', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// Helper to create default props for UrlInput
const createUrlInputProps = (overrides: Partial<Parameters<typeof UrlInput>[0]> = {}) => ({
isRunning: false,
onRun: jest.fn(),
...overrides,
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createUrlInputProps()
// Act
render(<UrlInput {...props} />)
// Assert
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument()
})
it('should render input with placeholder from docLink', () => {
// Arrange
const props = createUrlInputProps()
// Act
render(<UrlInput {...props} />)
// Assert
const input = screen.getByRole('textbox')
expect(input).toHaveAttribute('placeholder', 'https://docs.example.com')
})
it('should render run button with correct text when not running', () => {
// Arrange
const props = createUrlInputProps({ isRunning: false })
// Act
render(<UrlInput {...props} />)
// Assert
expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument()
})
it('should render button without text when running', () => {
// Arrange
const props = createUrlInputProps({ isRunning: true })
// Act
render(<UrlInput {...props} />)
// Assert - find button by data-testid when in loading state
const runButton = screen.getByTestId('url-input-run-button')
expect(runButton).toBeInTheDocument()
// Button text should be empty when running
expect(runButton).not.toHaveTextContent(/run/i)
})
it('should show loading state on button when running', () => {
// Arrange
const onRun = jest.fn()
const props = createUrlInputProps({ isRunning: true, onRun })
// Act
render(<UrlInput {...props} />)
// Assert - find button by data-testid when in loading state
const runButton = screen.getByTestId('url-input-run-button')
expect(runButton).toBeInTheDocument()
// Verify button is empty (loading state removes text)
expect(runButton).not.toHaveTextContent(/run/i)
// Verify clicking doesn't trigger onRun when loading
fireEvent.click(runButton)
expect(onRun).not.toHaveBeenCalled()
})
})
// --------------------------------------------------------------------------
// User Input Tests
// --------------------------------------------------------------------------
describe('User Input', () => {
it('should update URL value when user types', async () => {
// Arrange
const props = createUrlInputProps()
// Act
render(<UrlInput {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://test.com')
// Assert
expect(input).toHaveValue('https://test.com')
})
it('should handle URL input clearing', async () => {
// Arrange
const props = createUrlInputProps()
// Act
render(<UrlInput {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://test.com')
await userEvent.clear(input)
// Assert
expect(input).toHaveValue('')
})
it('should handle special characters in URL', async () => {
// Arrange
const props = createUrlInputProps()
// Act
render(<UrlInput {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://example.com/path?query=value&foo=bar')
// Assert
expect(input).toHaveValue('https://example.com/path?query=value&foo=bar')
})
})
// --------------------------------------------------------------------------
// Button Click Tests
// --------------------------------------------------------------------------
describe('Button Click', () => {
it('should call onRun with URL when button is clicked', async () => {
// Arrange
const onRun = jest.fn()
const props = createUrlInputProps({ onRun })
// Act
render(<UrlInput {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://run-test.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
expect(onRun).toHaveBeenCalledWith('https://run-test.com')
expect(onRun).toHaveBeenCalledTimes(1)
})
it('should call onRun with empty string if no URL entered', async () => {
// Arrange
const onRun = jest.fn()
const props = createUrlInputProps({ onRun })
// Act
render(<UrlInput {...props} />)
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
expect(onRun).toHaveBeenCalledWith('')
})
it('should not call onRun when isRunning is true', async () => {
// Arrange
const onRun = jest.fn()
const props = createUrlInputProps({ onRun, isRunning: true })
// Act
render(<UrlInput {...props} />)
const runButton = screen.getByTestId('url-input-run-button')
fireEvent.click(runButton)
// Assert
expect(onRun).not.toHaveBeenCalled()
})
it('should not call onRun when already running', async () => {
// Arrange
const onRun = jest.fn()
// First render with isRunning=false, type URL, then rerender with isRunning=true
const { rerender } = render(<UrlInput isRunning={false} onRun={onRun} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://test.com')
// Rerender with isRunning=true to simulate a running state
rerender(<UrlInput isRunning={true} onRun={onRun} />)
// Find and click the button by data-testid (loading state has no text)
const runButton = screen.getByTestId('url-input-run-button')
fireEvent.click(runButton)
// Assert - onRun should not be called due to early return at line 28
expect(onRun).not.toHaveBeenCalled()
})
it('should prevent multiple clicks when already running', async () => {
// Arrange
const onRun = jest.fn()
const props = createUrlInputProps({ onRun, isRunning: true })
// Act
render(<UrlInput {...props} />)
const runButton = screen.getByTestId('url-input-run-button')
fireEvent.click(runButton)
fireEvent.click(runButton)
fireEvent.click(runButton)
// Assert
expect(onRun).not.toHaveBeenCalled()
})
})
// --------------------------------------------------------------------------
// Props Tests
// --------------------------------------------------------------------------
describe('Props', () => {
it('should respond to isRunning prop change', () => {
// Arrange
const props = createUrlInputProps({ isRunning: false })
// Act
const { rerender } = render(<UrlInput {...props} />)
expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument()
// Change isRunning to true
rerender(<UrlInput {...props} isRunning={true} />)
// Assert - find button by data-testid and verify it's now in loading state
const runButton = screen.getByTestId('url-input-run-button')
expect(runButton).toBeInTheDocument()
// When loading, the button text should be empty
expect(runButton).not.toHaveTextContent(/run/i)
})
it('should call updated onRun callback after prop change', async () => {
// Arrange
const onRun1 = jest.fn()
const onRun2 = jest.fn()
// Act
const { rerender } = render(<UrlInput isRunning={false} onRun={onRun1} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://first.com')
// Change onRun callback
rerender(<UrlInput isRunning={false} onRun={onRun2} />)
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert - new callback should be called
expect(onRun1).not.toHaveBeenCalled()
expect(onRun2).toHaveBeenCalledWith('https://first.com')
})
})
// --------------------------------------------------------------------------
// Callback Stability Tests
// --------------------------------------------------------------------------
describe('Callback Stability', () => {
it('should use memoized handleUrlChange callback', async () => {
// Arrange
const props = createUrlInputProps()
// Act
const { rerender } = render(<UrlInput {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'a')
// Rerender with same props
rerender(<UrlInput {...props} />)
await userEvent.type(input, 'b')
// Assert - input should work correctly across rerenders
expect(input).toHaveValue('ab')
})
it('should maintain URL state across rerenders', async () => {
// Arrange
const props = createUrlInputProps()
// Act
const { rerender } = render(<UrlInput {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://stable.com')
// Rerender
rerender(<UrlInput {...props} />)
// Assert - URL should be maintained
expect(input).toHaveValue('https://stable.com')
})
})
// --------------------------------------------------------------------------
// Component Memoization Tests
// --------------------------------------------------------------------------
describe('Component Memoization', () => {
it('should be wrapped with React.memo', () => {
// Assert
expect(UrlInput.$$typeof).toBeDefined()
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle very long URLs', async () => {
// Arrange
const props = createUrlInputProps()
const longUrl = `https://example.com/${'a'.repeat(1000)}`
// Act
render(<UrlInput {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, longUrl)
// Assert
expect(input).toHaveValue(longUrl)
})
it('should handle URLs with unicode characters', async () => {
// Arrange
const props = createUrlInputProps()
const unicodeUrl = 'https://example.com/路径/测试'
// Act
render(<UrlInput {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, unicodeUrl)
// Assert
expect(input).toHaveValue(unicodeUrl)
})
it('should handle rapid typing', async () => {
// Arrange
const props = createUrlInputProps()
// Act
render(<UrlInput {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://rapid.com', { delay: 1 })
// Assert
expect(input).toHaveValue('https://rapid.com')
})
it('should handle keyboard enter to trigger run', async () => {
// Arrange - Note: This tests if the button can be activated via keyboard
const onRun = jest.fn()
const props = createUrlInputProps({ onRun })
// Act
render(<UrlInput {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://enter.com')
// Focus button and press enter
const button = screen.getByRole('button', { name: /run/i })
button.focus()
await userEvent.keyboard('{Enter}')
// Assert
expect(onRun).toHaveBeenCalledWith('https://enter.com')
})
it('should handle empty URL submission', async () => {
// Arrange
const onRun = jest.fn()
const props = createUrlInputProps({ onRun })
// Act
render(<UrlInput {...props} />)
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert - should call with empty string
expect(onRun).toHaveBeenCalledWith('')
})
})
})

View File

@@ -41,6 +41,7 @@ const UrlInput: FC<Props> = ({
onClick={handleOnRun}
className='ml-2'
loading={isRunning}
data-testid='url-input-run-button'
>
{!isRunning ? t(`${I18N_PREFIX}.run`) : ''}
</Button>

File diff suppressed because it is too large Load Diff

View File

@@ -37,6 +37,7 @@ const Options: FC<Props> = ({
isChecked={payload.crawl_sub_pages}
onChange={handleChange('crawl_sub_pages')}
labelClassName='text-[13px] leading-[16px] font-medium text-text-secondary'
testId='crawl-sub-pages'
/>
<CheckboxWithLabel
label={t(`${I18N_PREFIX}.useSitemap`)}
@@ -44,6 +45,7 @@ const Options: FC<Props> = ({
onChange={handleChange('use_sitemap')}
tooltip={t(`${I18N_PREFIX}.useSitemapTooltip`) as string}
labelClassName='text-[13px] leading-[16px] font-medium text-text-secondary'
testId='use-sitemap'
/>
<div className='flex justify-between space-x-4'>
<Field

File diff suppressed because it is too large Load Diff

View File

@@ -37,6 +37,7 @@ const Options: FC<Props> = ({
isChecked={payload.crawl_sub_pages}
onChange={handleChange('crawl_sub_pages')}
labelClassName='text-[13px] leading-[16px] font-medium text-text-secondary'
testId='crawl-sub-pages'
/>
<div className='flex justify-between space-x-4'>
<Field
@@ -78,6 +79,7 @@ const Options: FC<Props> = ({
isChecked={payload.only_main_content}
onChange={handleChange('only_main_content')}
labelClassName='text-[13px] leading-[16px] font-medium text-text-secondary'
testId='only-main-content'
/>
</div>
)