mirror of
https://github.com/langgenius/dify.git
synced 2025-12-19 14:19:28 +00:00
test: add comprehensive unit tests for JinaReader and WaterCrawl comp… (#29768)
Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
This commit is contained in:
873
web/app/components/datasets/create/file-preview/index.spec.tsx
Normal file
873
web/app/components/datasets/create/file-preview/index.spec.tsx
Normal 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
844
web/app/components/datasets/create/step-three/index.spec.tsx
Normal file
844
web/app/components/datasets/create/step-three/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
735
web/app/components/datasets/create/stepper/index.spec.tsx
Normal file
735
web/app/components/datasets/create/stepper/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
539
web/app/components/datasets/create/top-bar/index.spec.tsx
Normal file
539
web/app/components/datasets/create/top-bar/index.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
555
web/app/components/datasets/create/website/base.spec.tsx
Normal file
555
web/app/components/datasets/create/website/base.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
|
||||
1812
web/app/components/datasets/create/website/watercrawl/index.spec.tsx
Normal file
1812
web/app/components/datasets/create/website/watercrawl/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user