Merge branch 'main' into 1-26-css-icon

This commit is contained in:
Stephen Zhou
2026-01-27 15:50:47 +08:00
committed by GitHub
106 changed files with 28238 additions and 690 deletions

View File

@ -1,10 +1,20 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { createReactI18nextMock } from '@/test/i18n-mock'
import InputWithCopy from './index'
// Mock navigator.clipboard for foxact/use-clipboard
const mockWriteText = vi.fn(() => Promise.resolve())
// Create a controllable mock for useClipboard
const mockCopy = vi.fn()
let mockCopied = false
const mockReset = vi.fn()
vi.mock('foxact/use-clipboard', () => ({
useClipboard: () => ({
copy: mockCopy,
copied: mockCopied,
reset: mockReset,
}),
}))
// Mock the i18n hook with custom translations for test assertions
vi.mock('react-i18next', () => createReactI18nextMock({
@ -17,13 +27,9 @@ vi.mock('react-i18next', () => createReactI18nextMock({
describe('InputWithCopy component', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWriteText.mockClear()
// Setup navigator.clipboard mock
Object.assign(navigator, {
clipboard: {
writeText: mockWriteText,
},
})
mockCopy.mockClear()
mockReset.mockClear()
mockCopied = false
})
it('renders correctly with default props', () => {
@ -44,31 +50,27 @@ describe('InputWithCopy component', () => {
expect(copyButton).not.toBeInTheDocument()
})
it('copies input value when copy button is clicked', async () => {
it('calls copy function with input value when copy button is clicked', () => {
const mockOnChange = vi.fn()
render(<InputWithCopy value="test value" onChange={mockOnChange} />)
const copyButton = screen.getByRole('button')
fireEvent.click(copyButton)
await waitFor(() => {
expect(mockWriteText).toHaveBeenCalledWith('test value')
})
expect(mockCopy).toHaveBeenCalledWith('test value')
})
it('copies custom value when copyValue prop is provided', async () => {
it('calls copy function with custom value when copyValue prop is provided', () => {
const mockOnChange = vi.fn()
render(<InputWithCopy value="display value" onChange={mockOnChange} copyValue="custom copy value" />)
const copyButton = screen.getByRole('button')
fireEvent.click(copyButton)
await waitFor(() => {
expect(mockWriteText).toHaveBeenCalledWith('custom copy value')
})
expect(mockCopy).toHaveBeenCalledWith('custom copy value')
})
it('calls onCopy callback when copy button is clicked', async () => {
it('calls onCopy callback when copy button is clicked', () => {
const onCopyMock = vi.fn()
const mockOnChange = vi.fn()
render(<InputWithCopy value="test value" onChange={mockOnChange} onCopy={onCopyMock} />)
@ -76,25 +78,21 @@ describe('InputWithCopy component', () => {
const copyButton = screen.getByRole('button')
fireEvent.click(copyButton)
await waitFor(() => {
expect(onCopyMock).toHaveBeenCalledWith('test value')
})
expect(onCopyMock).toHaveBeenCalledWith('test value')
})
it('shows copied state after successful copy', async () => {
it('shows copied state when copied is true', () => {
mockCopied = true
const mockOnChange = vi.fn()
render(<InputWithCopy value="test value" onChange={mockOnChange} />)
const copyButton = screen.getByRole('button')
fireEvent.click(copyButton)
// Hover over the button to trigger tooltip
fireEvent.mouseEnter(copyButton)
// Check if the tooltip shows "Copied" state
await waitFor(() => {
expect(screen.getByText('Copied')).toBeInTheDocument()
}, { timeout: 2000 })
// The icon should change to filled version when copied
// We verify the component renders without error in copied state
expect(copyButton).toBeInTheDocument()
})
it('passes through all input props correctly', () => {
@ -117,22 +115,22 @@ describe('InputWithCopy component', () => {
expect(input).toHaveClass('custom-class')
})
it('handles empty value correctly', async () => {
it('handles empty value correctly', () => {
const mockOnChange = vi.fn()
render(<InputWithCopy value="" onChange={mockOnChange} />)
const input = screen.getByDisplayValue('')
const input = screen.getByRole('textbox')
const copyButton = screen.getByRole('button')
expect(input).toBeInTheDocument()
expect(input).toHaveValue('')
expect(copyButton).toBeInTheDocument()
// Clicking copy button with empty value should call copy with empty string
fireEvent.click(copyButton)
await waitFor(() => {
expect(mockWriteText).toHaveBeenCalledWith('')
})
expect(mockCopy).toHaveBeenCalledWith('')
})
it('maintains focus on input after copy', async () => {
it('maintains focus on input after copy', () => {
const mockOnChange = vi.fn()
render(<InputWithCopy value="test value" onChange={mockOnChange} />)

View File

@ -0,0 +1,426 @@
import type { DefaultModelResponse, Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { RetrievalConfig } from '@/types/app'
import { describe, expect, it } from 'vitest'
import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { RerankingModeEnum } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import { ensureRerankModelSelected, isReRankModelSelected } from './check-rerank-model'
// Test data factory
const createRetrievalConfig = (overrides: Partial<RetrievalConfig> = {}): RetrievalConfig => ({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
...overrides,
})
const createModelItem = (model: string): ModelItem => ({
model,
label: { en_US: model, zh_Hans: model },
model_type: ModelTypeEnum.rerank,
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: {},
load_balancing_enabled: false,
})
const createRerankModelList = (): Model[] => [
{
provider: 'openai',
icon_small: { en_US: '', zh_Hans: '' },
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
models: [
createModelItem('gpt-4-turbo'),
createModelItem('gpt-3.5-turbo'),
],
status: ModelStatusEnum.active,
},
{
provider: 'cohere',
icon_small: { en_US: '', zh_Hans: '' },
label: { en_US: 'Cohere', zh_Hans: 'Cohere' },
models: [
createModelItem('rerank-english-v2.0'),
createModelItem('rerank-multilingual-v2.0'),
],
status: ModelStatusEnum.active,
},
]
const createDefaultRerankModel = (): DefaultModelResponse => ({
model: 'rerank-english-v2.0',
model_type: ModelTypeEnum.rerank,
provider: {
provider: 'cohere',
icon_small: { en_US: '', zh_Hans: '' },
},
})
describe('check-rerank-model', () => {
describe('isReRankModelSelected', () => {
describe('Core Functionality', () => {
it('should return true when reranking is disabled', () => {
const config = createRetrievalConfig({
reranking_enable: false,
})
const result = isReRankModelSelected({
retrievalConfig: config,
rerankModelList: createRerankModelList(),
indexMethod: 'high_quality',
})
expect(result).toBe(true)
})
it('should return true for economy indexMethod', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: true,
})
const result = isReRankModelSelected({
retrievalConfig: config,
rerankModelList: createRerankModelList(),
indexMethod: 'economy',
})
expect(result).toBe(true)
})
it('should return true when model is selected and valid', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: true,
reranking_model: {
reranking_provider_name: 'cohere',
reranking_model_name: 'rerank-english-v2.0',
},
})
const result = isReRankModelSelected({
retrievalConfig: config,
rerankModelList: createRerankModelList(),
indexMethod: 'high_quality',
})
expect(result).toBe(true)
})
})
describe('Edge Cases', () => {
it('should return false when reranking enabled but no model selected for semantic search', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: true,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
})
const result = isReRankModelSelected({
retrievalConfig: config,
rerankModelList: createRerankModelList(),
indexMethod: 'high_quality',
})
expect(result).toBe(false)
})
it('should return false when reranking enabled but no model selected for fullText search', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.fullText,
reranking_enable: true,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
})
const result = isReRankModelSelected({
retrievalConfig: config,
rerankModelList: createRerankModelList(),
indexMethod: 'high_quality',
})
expect(result).toBe(false)
})
it('should return false for hybrid search without WeightedScore mode and no model selected', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.hybrid,
reranking_enable: true,
reranking_mode: RerankingModeEnum.RerankingModel,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
})
const result = isReRankModelSelected({
retrievalConfig: config,
rerankModelList: createRerankModelList(),
indexMethod: 'high_quality',
})
expect(result).toBe(false)
})
it('should return true for hybrid search with WeightedScore mode even without model', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.hybrid,
reranking_enable: true,
reranking_mode: RerankingModeEnum.WeightedScore,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
})
const result = isReRankModelSelected({
retrievalConfig: config,
rerankModelList: createRerankModelList(),
indexMethod: 'high_quality',
})
expect(result).toBe(true)
})
it('should return false when provider exists but model not found', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: true,
reranking_model: {
reranking_provider_name: 'cohere',
reranking_model_name: 'non-existent-model',
},
})
const result = isReRankModelSelected({
retrievalConfig: config,
rerankModelList: createRerankModelList(),
indexMethod: 'high_quality',
})
expect(result).toBe(false)
})
it('should return false when provider not found in list', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: true,
reranking_model: {
reranking_provider_name: 'non-existent-provider',
reranking_model_name: 'some-model',
},
})
const result = isReRankModelSelected({
retrievalConfig: config,
rerankModelList: createRerankModelList(),
indexMethod: 'high_quality',
})
expect(result).toBe(false)
})
it('should return true with empty rerankModelList when reranking disabled', () => {
const config = createRetrievalConfig({
reranking_enable: false,
})
const result = isReRankModelSelected({
retrievalConfig: config,
rerankModelList: [],
indexMethod: 'high_quality',
})
expect(result).toBe(true)
})
it('should return true when indexMethod is undefined', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: true,
})
const result = isReRankModelSelected({
retrievalConfig: config,
rerankModelList: createRerankModelList(),
indexMethod: undefined,
})
expect(result).toBe(true)
})
})
})
describe('ensureRerankModelSelected', () => {
describe('Core Functionality', () => {
it('should return original config when reranking model already selected', () => {
const config = createRetrievalConfig({
reranking_enable: true,
reranking_model: {
reranking_provider_name: 'cohere',
reranking_model_name: 'rerank-english-v2.0',
},
})
const result = ensureRerankModelSelected({
retrievalConfig: config,
rerankDefaultModel: createDefaultRerankModel(),
indexMethod: 'high_quality',
})
expect(result).toEqual(config)
})
it('should apply default model when reranking enabled but no model selected', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: true,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
})
const result = ensureRerankModelSelected({
retrievalConfig: config,
rerankDefaultModel: createDefaultRerankModel(),
indexMethod: 'high_quality',
})
expect(result.reranking_model).toEqual({
reranking_provider_name: 'cohere',
reranking_model_name: 'rerank-english-v2.0',
})
})
it('should apply default model for hybrid search method', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.hybrid,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
})
const result = ensureRerankModelSelected({
retrievalConfig: config,
rerankDefaultModel: createDefaultRerankModel(),
indexMethod: 'high_quality',
})
expect(result.reranking_model).toEqual({
reranking_provider_name: 'cohere',
reranking_model_name: 'rerank-english-v2.0',
})
})
})
describe('Edge Cases', () => {
it('should return original config when indexMethod is not high_quality', () => {
const config = createRetrievalConfig({
reranking_enable: true,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
})
const result = ensureRerankModelSelected({
retrievalConfig: config,
rerankDefaultModel: createDefaultRerankModel(),
indexMethod: 'economy',
})
expect(result).toEqual(config)
})
it('should return original config when rerankDefaultModel is null', () => {
const config = createRetrievalConfig({
reranking_enable: true,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
})
const result = ensureRerankModelSelected({
retrievalConfig: config,
rerankDefaultModel: null as unknown as DefaultModelResponse,
indexMethod: 'high_quality',
})
expect(result).toEqual(config)
})
it('should return original config when reranking disabled and not hybrid search', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
})
const result = ensureRerankModelSelected({
retrievalConfig: config,
rerankDefaultModel: createDefaultRerankModel(),
indexMethod: 'high_quality',
})
expect(result).toEqual(config)
})
it('should return original config when indexMethod is undefined', () => {
const config = createRetrievalConfig({
reranking_enable: true,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
})
const result = ensureRerankModelSelected({
retrievalConfig: config,
rerankDefaultModel: createDefaultRerankModel(),
indexMethod: undefined,
})
expect(result).toEqual(config)
})
it('should preserve other config properties when applying default model', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: true,
top_k: 10,
score_threshold_enabled: true,
score_threshold: 0.8,
})
const result = ensureRerankModelSelected({
retrievalConfig: config,
rerankDefaultModel: createDefaultRerankModel(),
indexMethod: 'high_quality',
})
expect(result.top_k).toBe(10)
expect(result.score_threshold_enabled).toBe(true)
expect(result.score_threshold).toBe(0.8)
expect(result.search_method).toBe(RETRIEVE_METHOD.semantic)
})
})
})
})

View File

@ -0,0 +1,61 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import ChunkingModeLabel from './chunking-mode-label'
describe('ChunkingModeLabel', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<ChunkingModeLabel isGeneralMode={true} isQAMode={false} />)
expect(screen.getByText(/general/i)).toBeInTheDocument()
})
it('should render with Badge wrapper', () => {
const { container } = render(<ChunkingModeLabel isGeneralMode={true} isQAMode={false} />)
// Badge component renders with specific styles
expect(container.querySelector('.flex')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should display general mode text when isGeneralMode is true', () => {
render(<ChunkingModeLabel isGeneralMode={true} isQAMode={false} />)
expect(screen.getByText(/general/i)).toBeInTheDocument()
})
it('should display parent-child mode text when isGeneralMode is false', () => {
render(<ChunkingModeLabel isGeneralMode={false} isQAMode={false} />)
expect(screen.getByText(/parentChild/i)).toBeInTheDocument()
})
it('should append QA suffix when isGeneralMode and isQAMode are both true', () => {
render(<ChunkingModeLabel isGeneralMode={true} isQAMode={true} />)
expect(screen.getByText(/general.*QA/i)).toBeInTheDocument()
})
it('should not append QA suffix when isGeneralMode is true but isQAMode is false', () => {
render(<ChunkingModeLabel isGeneralMode={true} isQAMode={false} />)
const text = screen.getByText(/general/i)
expect(text.textContent).not.toContain('QA')
})
it('should not display QA suffix for parent-child mode even when isQAMode is true', () => {
render(<ChunkingModeLabel isGeneralMode={false} isQAMode={true} />)
expect(screen.getByText(/parentChild/i)).toBeInTheDocument()
expect(screen.queryByText(/QA/i)).not.toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should render icon element', () => {
const { container } = render(<ChunkingModeLabel isGeneralMode={true} isQAMode={false} />)
const iconElement = container.querySelector('svg')
expect(iconElement).toBeInTheDocument()
})
it('should apply correct icon size classes', () => {
const { container } = render(<ChunkingModeLabel isGeneralMode={true} isQAMode={false} />)
const iconElement = container.querySelector('svg')
expect(iconElement).toHaveClass('h-3', 'w-3')
})
})
})

View File

@ -0,0 +1,136 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { CredentialIcon } from './credential-icon'
describe('CredentialIcon', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<CredentialIcon name="Test" />)
expect(screen.getByText('T')).toBeInTheDocument()
})
it('should render first letter when no avatar provided', () => {
render(<CredentialIcon name="Alice" />)
expect(screen.getByText('A')).toBeInTheDocument()
})
it('should render image when avatarUrl is provided', () => {
render(<CredentialIcon name="Test" avatarUrl="https://example.com/avatar.png" />)
const img = screen.getByRole('img')
expect(img).toBeInTheDocument()
expect(img).toHaveAttribute('src', 'https://example.com/avatar.png')
})
})
describe('Props', () => {
it('should apply default size of 20px', () => {
const { container } = render(<CredentialIcon name="Test" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveStyle({ width: '20px', height: '20px' })
})
it('should apply custom size', () => {
const { container } = render(<CredentialIcon name="Test" size={40} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveStyle({ width: '40px', height: '40px' })
})
it('should apply custom className', () => {
const { container } = render(<CredentialIcon name="Test" className="custom-class" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('custom-class')
})
it('should uppercase the first letter', () => {
render(<CredentialIcon name="bob" />)
expect(screen.getByText('B')).toBeInTheDocument()
})
it('should render fallback when avatarUrl is "default"', () => {
render(<CredentialIcon name="Test" avatarUrl="default" />)
expect(screen.getByText('T')).toBeInTheDocument()
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should fallback to letter when image fails to load', () => {
render(<CredentialIcon name="Test" avatarUrl="https://example.com/broken.png" />)
// Initially shows image
const img = screen.getByRole('img')
expect(img).toBeInTheDocument()
// Trigger error event
fireEvent.error(img)
// Should now show letter fallback
expect(screen.getByText('T')).toBeInTheDocument()
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle single character name', () => {
render(<CredentialIcon name="A" />)
expect(screen.getByText('A')).toBeInTheDocument()
})
it('should handle name starting with number', () => {
render(<CredentialIcon name="123test" />)
expect(screen.getByText('1')).toBeInTheDocument()
})
it('should handle name starting with special character', () => {
render(<CredentialIcon name="@user" />)
expect(screen.getByText('@')).toBeInTheDocument()
})
it('should assign consistent background colors based on first letter', () => {
// Same first letter should get same color
const { container: container1 } = render(<CredentialIcon name="Alice" />)
const { container: container2 } = render(<CredentialIcon name="Anna" />)
const wrapper1 = container1.firstChild as HTMLElement
const wrapper2 = container2.firstChild as HTMLElement
// Both should have the same bg class since they start with 'A'
const classes1 = wrapper1.className
const classes2 = wrapper2.className
const bgClass1 = classes1.match(/bg-components-icon-bg-\S+/)?.[0]
const bgClass2 = classes2.match(/bg-components-icon-bg-\S+/)?.[0]
expect(bgClass1).toBe(bgClass2)
})
it('should apply different background colors for different letters', () => {
// 'A' (65) % 4 = 1 → pink, 'B' (66) % 4 = 2 → indigo
const { container: container1 } = render(<CredentialIcon name="Alice" />)
const { container: container2 } = render(<CredentialIcon name="Bob" />)
const wrapper1 = container1.firstChild as HTMLElement
const wrapper2 = container2.firstChild as HTMLElement
const bgClass1 = wrapper1.className.match(/bg-components-icon-bg-\S+/)?.[0]
const bgClass2 = wrapper2.className.match(/bg-components-icon-bg-\S+/)?.[0]
expect(bgClass1).toBeDefined()
expect(bgClass2).toBeDefined()
expect(bgClass1).not.toBe(bgClass2)
})
it('should handle empty avatarUrl string', () => {
render(<CredentialIcon name="Test" avatarUrl="" />)
expect(screen.getByText('T')).toBeInTheDocument()
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
it('should render image with correct dimensions', () => {
render(<CredentialIcon name="Test" avatarUrl="https://example.com/avatar.png" size={32} />)
const img = screen.getByRole('img')
expect(img).toHaveAttribute('width', '32')
expect(img).toHaveAttribute('height', '32')
})
})
})

View File

@ -0,0 +1,115 @@
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import DocumentFileIcon from './document-file-icon'
describe('DocumentFileIcon', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<DocumentFileIcon />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render FileTypeIcon component', () => {
const { container } = render(<DocumentFileIcon extension="pdf" />)
// FileTypeIcon renders an svg or img element
expect(container.querySelector('svg, img')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should determine type from extension prop', () => {
const { container } = render(<DocumentFileIcon extension="pdf" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should determine type from name when extension not provided', () => {
const { container } = render(<DocumentFileIcon name="document.pdf" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle uppercase extension', () => {
const { container } = render(<DocumentFileIcon extension="PDF" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle uppercase name extension', () => {
const { container } = render(<DocumentFileIcon name="DOCUMENT.PDF" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should apply custom className', () => {
const { container } = render(<DocumentFileIcon extension="pdf" className="custom-icon" />)
expect(container.querySelector('.custom-icon')).toBeInTheDocument()
})
it('should pass size prop to FileTypeIcon', () => {
// Testing different size values
const { container: smContainer } = render(<DocumentFileIcon extension="pdf" size="sm" />)
const { container: lgContainer } = render(<DocumentFileIcon extension="pdf" size="lg" />)
expect(smContainer.firstChild).toBeInTheDocument()
expect(lgContainer.firstChild).toBeInTheDocument()
})
})
describe('File Type Mapping', () => {
const testCases = [
{ extension: 'pdf', description: 'PDF files' },
{ extension: 'json', description: 'JSON files' },
{ extension: 'html', description: 'HTML files' },
{ extension: 'txt', description: 'TXT files' },
{ extension: 'markdown', description: 'Markdown files' },
{ extension: 'md', description: 'MD files' },
{ extension: 'xlsx', description: 'XLSX files' },
{ extension: 'xls', description: 'XLS files' },
{ extension: 'csv', description: 'CSV files' },
{ extension: 'doc', description: 'DOC files' },
{ extension: 'docx', description: 'DOCX files' },
]
testCases.forEach(({ extension, description }) => {
it(`should handle ${description}`, () => {
const { container } = render(<DocumentFileIcon extension={extension} />)
expect(container.firstChild).toBeInTheDocument()
})
})
})
describe('Edge Cases', () => {
it('should handle unknown extension with default document type', () => {
const { container } = render(<DocumentFileIcon extension="xyz" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle empty extension string', () => {
const { container } = render(<DocumentFileIcon extension="" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle name without extension', () => {
const { container } = render(<DocumentFileIcon name="document" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle name with multiple dots', () => {
const { container } = render(<DocumentFileIcon name="my.document.file.pdf" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should prioritize extension over name', () => {
// If both are provided, extension should take precedence
const { container } = render(<DocumentFileIcon extension="xlsx" name="document.pdf" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle undefined extension and name', () => {
const { container } = render(<DocumentFileIcon />)
expect(container.firstChild).toBeInTheDocument()
})
it('should apply default size of md', () => {
const { container } = render(<DocumentFileIcon extension="pdf" />)
expect(container.firstChild).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,166 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import { useAutoDisabledDocuments } from '@/service/knowledge/use-document'
import AutoDisabledDocument from './auto-disabled-document'
type AutoDisabledDocumentsResponse = { document_ids: string[] }
const createMockQueryResult = (
data: AutoDisabledDocumentsResponse | undefined,
isLoading: boolean,
) => ({
data,
isLoading,
}) as ReturnType<typeof useAutoDisabledDocuments>
// Mock service hooks
const mockMutateAsync = vi.fn()
const mockInvalidDisabledDocument = vi.fn()
vi.mock('@/service/knowledge/use-document', () => ({
useAutoDisabledDocuments: vi.fn(),
useDocumentEnable: vi.fn(() => ({
mutateAsync: mockMutateAsync,
})),
useInvalidDisabledDocument: vi.fn(() => mockInvalidDisabledDocument),
}))
// Mock Toast
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
const mockUseAutoDisabledDocuments = vi.mocked(useAutoDisabledDocuments)
describe('AutoDisabledDocument', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMutateAsync.mockResolvedValue({})
})
describe('Rendering', () => {
it('should render nothing when loading', () => {
mockUseAutoDisabledDocuments.mockReturnValue(
createMockQueryResult(undefined, true),
)
const { container } = render(<AutoDisabledDocument datasetId="test-dataset" />)
expect(container.firstChild).toBeNull()
})
it('should render nothing when no disabled documents', () => {
mockUseAutoDisabledDocuments.mockReturnValue(
createMockQueryResult({ document_ids: [] }, false),
)
const { container } = render(<AutoDisabledDocument datasetId="test-dataset" />)
expect(container.firstChild).toBeNull()
})
it('should render nothing when document_ids is undefined', () => {
mockUseAutoDisabledDocuments.mockReturnValue(
createMockQueryResult(undefined, false),
)
const { container } = render(<AutoDisabledDocument datasetId="test-dataset" />)
expect(container.firstChild).toBeNull()
})
it('should render StatusWithAction when disabled documents exist', () => {
mockUseAutoDisabledDocuments.mockReturnValue(
createMockQueryResult({ document_ids: ['doc1', 'doc2'] }, false),
)
render(<AutoDisabledDocument datasetId="test-dataset" />)
expect(screen.getByText(/enable/i)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass datasetId to useAutoDisabledDocuments', () => {
mockUseAutoDisabledDocuments.mockReturnValue(
createMockQueryResult({ document_ids: [] }, false),
)
render(<AutoDisabledDocument datasetId="my-dataset-id" />)
expect(mockUseAutoDisabledDocuments).toHaveBeenCalledWith('my-dataset-id')
})
})
describe('User Interactions', () => {
it('should call enableDocument when action button is clicked', async () => {
mockUseAutoDisabledDocuments.mockReturnValue(
createMockQueryResult({ document_ids: ['doc1', 'doc2'] }, false),
)
render(<AutoDisabledDocument datasetId="test-dataset" />)
const actionButton = screen.getByText(/enable/i)
fireEvent.click(actionButton)
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({
datasetId: 'test-dataset',
documentIds: ['doc1', 'doc2'],
})
})
})
it('should invalidate cache after enabling documents', async () => {
mockUseAutoDisabledDocuments.mockReturnValue(
createMockQueryResult({ document_ids: ['doc1'] }, false),
)
render(<AutoDisabledDocument datasetId="test-dataset" />)
const actionButton = screen.getByText(/enable/i)
fireEvent.click(actionButton)
await waitFor(() => {
expect(mockInvalidDisabledDocument).toHaveBeenCalled()
})
})
it('should show success toast after enabling documents', async () => {
mockUseAutoDisabledDocuments.mockReturnValue(
createMockQueryResult({ document_ids: ['doc1'] }, false),
)
render(<AutoDisabledDocument datasetId="test-dataset" />)
const actionButton = screen.getByText(/enable/i)
fireEvent.click(actionButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
type: 'success',
message: expect.any(String),
})
})
})
})
describe('Edge Cases', () => {
it('should handle single disabled document', () => {
mockUseAutoDisabledDocuments.mockReturnValue(
createMockQueryResult({ document_ids: ['doc1'] }, false),
)
render(<AutoDisabledDocument datasetId="test-dataset" />)
expect(screen.getByText(/enable/i)).toBeInTheDocument()
})
it('should handle multiple disabled documents', () => {
mockUseAutoDisabledDocuments.mockReturnValue(
createMockQueryResult({ document_ids: ['doc1', 'doc2', 'doc3', 'doc4', 'doc5'] }, false),
)
render(<AutoDisabledDocument datasetId="test-dataset" />)
expect(screen.getByText(/enable/i)).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,280 @@
import type { ErrorDocsResponse } from '@/models/datasets'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { retryErrorDocs } from '@/service/datasets'
import { useDatasetErrorDocs } from '@/service/knowledge/use-dataset'
import RetryButton from './index-failed'
// Mock service hooks
const mockRefetch = vi.fn()
vi.mock('@/service/knowledge/use-dataset', () => ({
useDatasetErrorDocs: vi.fn(),
}))
vi.mock('@/service/datasets', () => ({
retryErrorDocs: vi.fn(),
}))
const mockUseDatasetErrorDocs = vi.mocked(useDatasetErrorDocs)
const mockRetryErrorDocs = vi.mocked(retryErrorDocs)
// Helper to create mock query result
const createMockQueryResult = (
data: ErrorDocsResponse | undefined,
isLoading: boolean,
) => ({
data,
isLoading,
refetch: mockRefetch,
// Required query result properties
error: null,
isError: false,
isFetched: true,
isFetching: false,
isSuccess: !isLoading && !!data,
status: isLoading ? 'pending' : 'success',
dataUpdatedAt: Date.now(),
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
errorUpdateCount: 0,
isLoadingError: false,
isPaused: false,
isPlaceholderData: false,
isPending: isLoading,
isRefetchError: false,
isRefetching: false,
isStale: false,
fetchStatus: 'idle',
promise: Promise.resolve(data as ErrorDocsResponse),
isFetchedAfterMount: true,
isInitialLoading: false,
}) as unknown as ReturnType<typeof useDatasetErrorDocs>
describe('RetryButton (IndexFailed)', () => {
beforeEach(() => {
vi.clearAllMocks()
mockRefetch.mockResolvedValue({})
})
describe('Rendering', () => {
it('should render nothing when loading', () => {
mockUseDatasetErrorDocs.mockReturnValue(
createMockQueryResult(undefined, true),
)
const { container } = render(<RetryButton datasetId="test-dataset" />)
expect(container.firstChild).toBeNull()
})
it('should render nothing when no error documents', () => {
mockUseDatasetErrorDocs.mockReturnValue(
createMockQueryResult({ total: 0, data: [] }, false),
)
const { container } = render(<RetryButton datasetId="test-dataset" />)
expect(container.firstChild).toBeNull()
})
it('should render StatusWithAction when error documents exist', () => {
mockUseDatasetErrorDocs.mockReturnValue(
createMockQueryResult({
total: 3,
data: [
{ id: 'doc1' },
{ id: 'doc2' },
{ id: 'doc3' },
] as ErrorDocsResponse['data'],
}, false),
)
render(<RetryButton datasetId="test-dataset" />)
expect(screen.getByText(/retry/i)).toBeInTheDocument()
})
it('should display error count in description', () => {
mockUseDatasetErrorDocs.mockReturnValue(
createMockQueryResult({
total: 5,
data: [{ id: 'doc1' }] as ErrorDocsResponse['data'],
}, false),
)
render(<RetryButton datasetId="test-dataset" />)
expect(screen.getByText(/5/)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass datasetId to useDatasetErrorDocs', () => {
mockUseDatasetErrorDocs.mockReturnValue(
createMockQueryResult({ total: 0, data: [] }, false),
)
render(<RetryButton datasetId="my-dataset-id" />)
expect(mockUseDatasetErrorDocs).toHaveBeenCalledWith('my-dataset-id')
})
})
describe('User Interactions', () => {
it('should call retryErrorDocs when retry button is clicked', async () => {
mockUseDatasetErrorDocs.mockReturnValue(
createMockQueryResult({
total: 2,
data: [{ id: 'doc1' }, { id: 'doc2' }] as ErrorDocsResponse['data'],
}, false),
)
mockRetryErrorDocs.mockResolvedValue({ result: 'success' })
render(<RetryButton datasetId="test-dataset" />)
const retryButton = screen.getByText(/retry/i)
fireEvent.click(retryButton)
await waitFor(() => {
expect(mockRetryErrorDocs).toHaveBeenCalledWith({
datasetId: 'test-dataset',
document_ids: ['doc1', 'doc2'],
})
})
})
it('should refetch error docs after successful retry', async () => {
mockUseDatasetErrorDocs.mockReturnValue(
createMockQueryResult({
total: 1,
data: [{ id: 'doc1' }] as ErrorDocsResponse['data'],
}, false),
)
mockRetryErrorDocs.mockResolvedValue({ result: 'success' })
render(<RetryButton datasetId="test-dataset" />)
const retryButton = screen.getByText(/retry/i)
fireEvent.click(retryButton)
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
})
})
it('should disable button while retrying', async () => {
mockUseDatasetErrorDocs.mockReturnValue(
createMockQueryResult({
total: 1,
data: [{ id: 'doc1' }] as ErrorDocsResponse['data'],
}, false),
)
// Delay the response to test loading state
mockRetryErrorDocs.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve({ result: 'success' }), 100)))
render(<RetryButton datasetId="test-dataset" />)
const retryButton = screen.getByText(/retry/i)
fireEvent.click(retryButton)
// Button should show disabled styling during retry
await waitFor(() => {
const button = screen.getByText(/retry/i)
expect(button).toHaveClass('cursor-not-allowed')
expect(button).toHaveClass('text-text-disabled')
})
})
})
describe('State Management', () => {
it('should transition to error state when retry fails', async () => {
mockUseDatasetErrorDocs.mockReturnValue(
createMockQueryResult({
total: 1,
data: [{ id: 'doc1' }] as ErrorDocsResponse['data'],
}, false),
)
mockRetryErrorDocs.mockResolvedValue({ result: 'fail' })
render(<RetryButton datasetId="test-dataset" />)
const retryButton = screen.getByText(/retry/i)
fireEvent.click(retryButton)
await waitFor(() => {
// Button should still be visible after failed retry
expect(screen.getByText(/retry/i)).toBeInTheDocument()
})
})
it('should transition to success state when total becomes 0', async () => {
const { rerender } = render(<RetryButton datasetId="test-dataset" />)
// Initially has errors
mockUseDatasetErrorDocs.mockReturnValue(
createMockQueryResult({
total: 1,
data: [{ id: 'doc1' }] as ErrorDocsResponse['data'],
}, false),
)
rerender(<RetryButton datasetId="test-dataset" />)
expect(screen.getByText(/retry/i)).toBeInTheDocument()
// Now no errors
mockUseDatasetErrorDocs.mockReturnValue(
createMockQueryResult({ total: 0, data: [] }, false),
)
rerender(<RetryButton datasetId="test-dataset" />)
await waitFor(() => {
expect(screen.queryByText(/retry/i)).not.toBeInTheDocument()
})
})
})
describe('Edge Cases', () => {
it('should handle empty data array', () => {
mockUseDatasetErrorDocs.mockReturnValue(
createMockQueryResult({ total: 0, data: [] }, false),
)
const { container } = render(<RetryButton datasetId="test-dataset" />)
expect(container.firstChild).toBeNull()
})
it('should handle undefined data by showing error state', () => {
// When data is undefined but not loading, the component shows error state
// because errorDocs?.total is not strictly equal to 0
mockUseDatasetErrorDocs.mockReturnValue(
createMockQueryResult(undefined, false),
)
render(<RetryButton datasetId="test-dataset" />)
// Component renders with undefined count
expect(screen.getByText(/retry/i)).toBeInTheDocument()
})
it('should handle retry with empty document list', async () => {
mockUseDatasetErrorDocs.mockReturnValue(
createMockQueryResult({ total: 1, data: [] }, false),
)
mockRetryErrorDocs.mockResolvedValue({ result: 'success' })
render(<RetryButton datasetId="test-dataset" />)
const retryButton = screen.getByText(/retry/i)
fireEvent.click(retryButton)
await waitFor(() => {
expect(mockRetryErrorDocs).toHaveBeenCalledWith({
datasetId: 'test-dataset',
document_ids: [],
})
})
})
})
})

View File

@ -0,0 +1,175 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import StatusWithAction from './status-with-action'
describe('StatusWithAction', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<StatusWithAction description="Test description" />)
expect(screen.getByText('Test description')).toBeInTheDocument()
})
it('should render description text', () => {
render(<StatusWithAction description="This is a test message" />)
expect(screen.getByText('This is a test message')).toBeInTheDocument()
})
it('should render icon based on type', () => {
const { container } = render(<StatusWithAction type="success" description="Success" />)
expect(container.querySelector('svg')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should default to info type when type is not provided', () => {
const { container } = render(<StatusWithAction description="Default type" />)
const icon = container.querySelector('svg')
expect(icon).toHaveClass('text-text-accent')
})
it('should render success type with correct color', () => {
const { container } = render(<StatusWithAction type="success" description="Success" />)
const icon = container.querySelector('svg')
expect(icon).toHaveClass('text-text-success')
})
it('should render error type with correct color', () => {
const { container } = render(<StatusWithAction type="error" description="Error" />)
const icon = container.querySelector('svg')
expect(icon).toHaveClass('text-text-destructive')
})
it('should render warning type with correct color', () => {
const { container } = render(<StatusWithAction type="warning" description="Warning" />)
const icon = container.querySelector('svg')
expect(icon).toHaveClass('text-text-warning-secondary')
})
it('should render info type with correct color', () => {
const { container } = render(<StatusWithAction type="info" description="Info" />)
const icon = container.querySelector('svg')
expect(icon).toHaveClass('text-text-accent')
})
it('should render action button when actionText and onAction are provided', () => {
const onAction = vi.fn()
render(
<StatusWithAction
description="Test"
actionText="Click me"
onAction={onAction}
/>,
)
expect(screen.getByText('Click me')).toBeInTheDocument()
})
it('should not render action button when onAction is not provided', () => {
render(<StatusWithAction description="Test" actionText="Click me" />)
expect(screen.queryByText('Click me')).not.toBeInTheDocument()
})
it('should render divider when action is present', () => {
const { container } = render(
<StatusWithAction
description="Test"
actionText="Click me"
onAction={() => {}}
/>,
)
// Divider component renders a div with specific classes
expect(container.querySelector('.bg-divider-regular')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onAction when action button is clicked', () => {
const onAction = vi.fn()
render(
<StatusWithAction
description="Test"
actionText="Click me"
onAction={onAction}
/>,
)
fireEvent.click(screen.getByText('Click me'))
expect(onAction).toHaveBeenCalledTimes(1)
})
it('should call onAction even when disabled (style only)', () => {
// Note: disabled prop only affects styling, not actual click behavior
const onAction = vi.fn()
render(
<StatusWithAction
description="Test"
actionText="Click me"
onAction={onAction}
disabled
/>,
)
fireEvent.click(screen.getByText('Click me'))
expect(onAction).toHaveBeenCalledTimes(1)
})
it('should apply disabled styles when disabled prop is true', () => {
render(
<StatusWithAction
description="Test"
actionText="Click me"
onAction={() => {}}
disabled
/>,
)
const actionButton = screen.getByText('Click me')
expect(actionButton).toHaveClass('cursor-not-allowed')
expect(actionButton).toHaveClass('text-text-disabled')
})
})
describe('Status Background Gradients', () => {
it('should apply success gradient background', () => {
const { container } = render(<StatusWithAction type="success" description="Success" />)
const gradientDiv = container.querySelector('.opacity-40')
expect(gradientDiv?.className).toContain('rgba(23,178,106,0.25)')
})
it('should apply warning gradient background', () => {
const { container } = render(<StatusWithAction type="warning" description="Warning" />)
const gradientDiv = container.querySelector('.opacity-40')
expect(gradientDiv?.className).toContain('rgba(247,144,9,0.25)')
})
it('should apply error gradient background', () => {
const { container } = render(<StatusWithAction type="error" description="Error" />)
const gradientDiv = container.querySelector('.opacity-40')
expect(gradientDiv?.className).toContain('rgba(240,68,56,0.25)')
})
it('should apply info gradient background', () => {
const { container } = render(<StatusWithAction type="info" description="Info" />)
const gradientDiv = container.querySelector('.opacity-40')
expect(gradientDiv?.className).toContain('rgba(11,165,236,0.25)')
})
})
describe('Edge Cases', () => {
it('should handle empty description', () => {
const { container } = render(<StatusWithAction description="" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle long description text', () => {
const longText = 'A'.repeat(500)
render(<StatusWithAction description={longText} />)
expect(screen.getByText(longText)).toBeInTheDocument()
})
it('should handle undefined actionText when onAction is provided', () => {
render(<StatusWithAction description="Test" onAction={() => {}} />)
// Should render without throwing
expect(screen.getByText('Test')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,252 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ImageList from './index'
// Track handleImageClick calls for testing
type FileEntity = {
sourceUrl: string
name: string
mimeType?: string
size?: number
extension?: string
}
let capturedOnClick: ((file: FileEntity) => void) | null = null
// Mock FileThumb to capture click handler
vi.mock('@/app/components/base/file-thumb', () => ({
default: ({ file, onClick }: { file: FileEntity, onClick?: (file: FileEntity) => void }) => {
// Capture the onClick for testing
capturedOnClick = onClick ?? null
return (
<div
data-testid={`file-thumb-${file.sourceUrl}`}
className="cursor-pointer"
onClick={() => onClick?.(file)}
>
{file.name}
</div>
)
},
}))
type ImagePreviewerProps = {
images: ImageInfo[]
initialIndex: number
onClose: () => void
}
type ImageInfo = {
url: string
name: string
size: number
}
// Mock ImagePreviewer since it uses createPortal
vi.mock('../image-previewer', () => ({
default: ({ images, initialIndex, onClose }: ImagePreviewerProps) => (
<div data-testid="image-previewer">
<span data-testid="preview-count">{images.length}</span>
<span data-testid="preview-index">{initialIndex}</span>
<button data-testid="close-preview" onClick={onClose}>Close</button>
</div>
),
}))
const createMockImages = (count: number) => {
return Array.from({ length: count }, (_, i) => ({
name: `image-${i + 1}.png`,
mimeType: 'image/png',
sourceUrl: `https://example.com/image-${i + 1}.png`,
size: 1024 * (i + 1),
extension: 'png',
}))
}
describe('ImageList', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const images = createMockImages(3)
const { container } = render(<ImageList images={images} size="md" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render all images when count is below limit', () => {
const images = createMockImages(5)
render(<ImageList images={images} size="md" limit={9} />)
// Each image renders a FileThumb component
const thumbnails = document.querySelectorAll('[class*="cursor-pointer"]')
expect(thumbnails.length).toBeGreaterThanOrEqual(5)
})
it('should render limited images when count exceeds limit', () => {
const images = createMockImages(15)
render(<ImageList images={images} size="md" limit={9} />)
// More button should be visible
expect(screen.getByText(/\+6/)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
const images = createMockImages(3)
const { container } = render(
<ImageList images={images} size="md" className="custom-class" />,
)
expect(container.firstChild).toHaveClass('custom-class')
})
it('should use default limit of 9', () => {
const images = createMockImages(12)
render(<ImageList images={images} size="md" />)
// Should show "+3" for remaining images
expect(screen.getByText(/\+3/)).toBeInTheDocument()
})
it('should respect custom limit', () => {
const images = createMockImages(10)
render(<ImageList images={images} size="md" limit={5} />)
// Should show "+5" for remaining images
expect(screen.getByText(/\+5/)).toBeInTheDocument()
})
it('should handle size prop sm', () => {
const images = createMockImages(2)
const { container } = render(<ImageList images={images} size="sm" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle size prop md', () => {
const images = createMockImages(2)
const { container } = render(<ImageList images={images} size="md" />)
expect(container.firstChild).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should show all images when More button is clicked', () => {
const images = createMockImages(15)
render(<ImageList images={images} size="md" limit={9} />)
// Click More button
const moreButton = screen.getByText(/\+6/)
fireEvent.click(moreButton)
// More button should disappear
expect(screen.queryByText(/\+6/)).not.toBeInTheDocument()
})
it('should open preview when image is clicked', () => {
const images = createMockImages(3)
render(<ImageList images={images} size="md" />)
// Find and click an image thumbnail
const thumbnails = document.querySelectorAll('[class*="cursor-pointer"]')
if (thumbnails.length > 0) {
fireEvent.click(thumbnails[0])
// Preview should open
expect(screen.getByTestId('image-previewer')).toBeInTheDocument()
}
})
it('should close preview when close button is clicked', () => {
const images = createMockImages(3)
render(<ImageList images={images} size="md" />)
// Open preview
const thumbnails = document.querySelectorAll('[class*="cursor-pointer"]')
if (thumbnails.length > 0) {
fireEvent.click(thumbnails[0])
// Close preview
const closeButton = screen.getByTestId('close-preview')
fireEvent.click(closeButton)
// Preview should be closed
expect(screen.queryByTestId('image-previewer')).not.toBeInTheDocument()
}
})
})
describe('Edge Cases', () => {
it('should handle empty images array', () => {
const { container } = render(<ImageList images={[]} size="md" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should not open preview when clicked image not found in list (index === -1)', () => {
const images = createMockImages(3)
const { rerender } = render(<ImageList images={images} size="md" />)
// Click first image to open preview
const firstThumb = screen.getByTestId('file-thumb-https://example.com/image-1.png')
fireEvent.click(firstThumb)
// Preview should open for valid image
expect(screen.getByTestId('image-previewer')).toBeInTheDocument()
// Close preview
fireEvent.click(screen.getByTestId('close-preview'))
expect(screen.queryByTestId('image-previewer')).not.toBeInTheDocument()
// Now render with images that don't include the previously clicked one
const newImages = createMockImages(2) // Only 2 images
rerender(<ImageList images={newImages} size="md" />)
// Click on a thumbnail that exists
const validThumb = screen.getByTestId('file-thumb-https://example.com/image-1.png')
fireEvent.click(validThumb)
expect(screen.getByTestId('image-previewer')).toBeInTheDocument()
})
it('should return early when file sourceUrl is not found in limitedImages (index === -1)', () => {
const images = createMockImages(3)
render(<ImageList images={images} size="md" />)
// Call the captured onClick with a file that has a non-matching sourceUrl
// This triggers the index === -1 branch (line 44-45)
if (capturedOnClick) {
capturedOnClick({
name: 'nonexistent.png',
mimeType: 'image/png',
sourceUrl: 'https://example.com/nonexistent.png', // Not in the list
size: 1024,
extension: 'png',
})
}
// Preview should NOT open because the file was not found in limitedImages
expect(screen.queryByTestId('image-previewer')).not.toBeInTheDocument()
})
it('should handle single image', () => {
const images = createMockImages(1)
const { container } = render(<ImageList images={images} size="md" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should not show More button when images count equals limit', () => {
const images = createMockImages(9)
render(<ImageList images={images} size="md" limit={9} />)
expect(screen.queryByText(/\+/)).not.toBeInTheDocument()
})
it('should handle limit of 0', () => {
const images = createMockImages(5)
render(<ImageList images={images} size="md" limit={0} />)
// Should show "+5" for all images
expect(screen.getByText(/\+5/)).toBeInTheDocument()
})
it('should handle limit larger than images count', () => {
const images = createMockImages(5)
render(<ImageList images={images} size="md" limit={100} />)
// Should not show More button
expect(screen.queryByText(/\+/)).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,144 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import More from './more'
describe('More', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<More count={5} />)
expect(screen.getByText('+5')).toBeInTheDocument()
})
it('should display count with plus sign', () => {
render(<More count={10} />)
expect(screen.getByText('+10')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should format count as-is when less than 1000', () => {
render(<More count={999} />)
expect(screen.getByText('+999')).toBeInTheDocument()
})
it('should format count with k suffix when 1000 or more', () => {
render(<More count={1500} />)
expect(screen.getByText('+1.5k')).toBeInTheDocument()
})
it('should format count with M suffix when 1000000 or more', () => {
render(<More count={2500000} />)
expect(screen.getByText('+2.5M')).toBeInTheDocument()
})
it('should format 1000 as 1.0k', () => {
render(<More count={1000} />)
expect(screen.getByText('+1.0k')).toBeInTheDocument()
})
it('should format 1000000 as 1.0M', () => {
render(<More count={1000000} />)
expect(screen.getByText('+1.0M')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClick when clicked', () => {
const onClick = vi.fn()
render(<More count={5} onClick={onClick} />)
fireEvent.click(screen.getByText('+5'))
expect(onClick).toHaveBeenCalledTimes(1)
})
it('should not throw when clicked without onClick', () => {
render(<More count={5} />)
// Should not throw
expect(() => {
fireEvent.click(screen.getByText('+5'))
}).not.toThrow()
})
it('should stop event propagation on click', () => {
const parentClick = vi.fn()
const childClick = vi.fn()
render(
<div onClick={parentClick}>
<More count={5} onClick={childClick} />
</div>,
)
fireEvent.click(screen.getByText('+5'))
expect(childClick).toHaveBeenCalled()
expect(parentClick).not.toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should display +0 when count is 0', () => {
render(<More count={0} />)
expect(screen.getByText('+0')).toBeInTheDocument()
})
it('should handle count of 1', () => {
render(<More count={1} />)
expect(screen.getByText('+1')).toBeInTheDocument()
})
it('should handle boundary value 999', () => {
render(<More count={999} />)
expect(screen.getByText('+999')).toBeInTheDocument()
})
it('should handle boundary value 999999', () => {
render(<More count={999999} />)
// 999999 / 1000 = 999.999 -> 1000.0k
expect(screen.getByText('+1000.0k')).toBeInTheDocument()
})
it('should apply cursor-pointer class', () => {
const { container } = render(<More count={5} />)
expect(container.firstChild).toHaveClass('cursor-pointer')
})
})
describe('formatNumber branches', () => {
it('should return "0" when num equals 0', () => {
// This covers line 11-12: if (num === 0) return '0'
render(<More count={0} />)
expect(screen.getByText('+0')).toBeInTheDocument()
})
it('should return num.toString() when num < 1000 and num > 0', () => {
// This covers line 13-14: if (num < 1000) return num.toString()
render(<More count={500} />)
expect(screen.getByText('+500')).toBeInTheDocument()
})
it('should return k format when 1000 <= num < 1000000', () => {
// This covers line 15-16
const { rerender } = render(<More count={5000} />)
expect(screen.getByText('+5.0k')).toBeInTheDocument()
rerender(<More count={999999} />)
expect(screen.getByText('+1000.0k')).toBeInTheDocument()
rerender(<More count={50000} />)
expect(screen.getByText('+50.0k')).toBeInTheDocument()
})
it('should return M format when num >= 1000000', () => {
// This covers line 17
const { rerender } = render(<More count={1000000} />)
expect(screen.getByText('+1.0M')).toBeInTheDocument()
rerender(<More count={5000000} />)
expect(screen.getByText('+5.0M')).toBeInTheDocument()
rerender(<More count={999999999} />)
expect(screen.getByText('+1000.0M')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,525 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ImagePreviewer from './index'
// Mock fetch
const mockFetch = vi.fn()
globalThis.fetch = mockFetch
// Mock URL methods
const mockRevokeObjectURL = vi.fn()
const mockCreateObjectURL = vi.fn(() => 'blob:mock-url')
globalThis.URL.revokeObjectURL = mockRevokeObjectURL
globalThis.URL.createObjectURL = mockCreateObjectURL
// Mock Image
class MockImage {
onload: (() => void) | null = null
onerror: (() => void) | null = null
_src = ''
get src() {
return this._src
}
set src(value: string) {
this._src = value
// Trigger onload after a microtask
setTimeout(() => {
if (this.onload)
this.onload()
}, 0)
}
naturalWidth = 800
naturalHeight = 600
}
;(globalThis as unknown as { Image: typeof MockImage }).Image = MockImage
const createMockImages = () => [
{ url: 'https://example.com/image1.png', name: 'image1.png', size: 1024 },
{ url: 'https://example.com/image2.png', name: 'image2.png', size: 2048 },
{ url: 'https://example.com/image3.png', name: 'image3.png', size: 3072 },
]
describe('ImagePreviewer', () => {
beforeEach(() => {
vi.clearAllMocks()
// Default successful fetch mock
mockFetch.mockResolvedValue({
ok: true,
blob: () => Promise.resolve(new Blob(['test'], { type: 'image/png' })),
})
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', async () => {
const onClose = vi.fn()
const images = createMockImages()
await act(async () => {
render(<ImagePreviewer images={images} onClose={onClose} />)
})
// Should render in portal
expect(document.body.querySelector('.image-previewer')).toBeInTheDocument()
})
it('should render close button', async () => {
const onClose = vi.fn()
const images = createMockImages()
await act(async () => {
render(<ImagePreviewer images={images} onClose={onClose} />)
})
// Esc text should be visible
expect(screen.getByText('Esc')).toBeInTheDocument()
})
it('should show loading state initially', async () => {
const onClose = vi.fn()
const images = createMockImages()
// Delay fetch to see loading state
mockFetch.mockImplementation(() => new Promise(() => {}))
await act(async () => {
render(<ImagePreviewer images={images} onClose={onClose} />)
})
// Loading component should be visible
expect(document.body.querySelector('.image-previewer')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should start at initialIndex', async () => {
const onClose = vi.fn()
const images = createMockImages()
await act(async () => {
render(<ImagePreviewer images={images} initialIndex={1} onClose={onClose} />)
})
await waitFor(() => {
// Should start at second image
expect(screen.getByText('image2.png')).toBeInTheDocument()
})
})
it('should default initialIndex to 0', async () => {
const onClose = vi.fn()
const images = createMockImages()
await act(async () => {
render(<ImagePreviewer images={images} onClose={onClose} />)
})
await waitFor(() => {
expect(screen.getByText('image1.png')).toBeInTheDocument()
})
})
})
describe('User Interactions', () => {
it('should call onClose when close button is clicked', async () => {
const onClose = vi.fn()
const images = createMockImages()
await act(async () => {
render(<ImagePreviewer images={images} onClose={onClose} />)
})
// Find and click close button (the one with RiCloseLine icon)
const closeButton = document.querySelector('.absolute.right-6 button')
if (closeButton) {
fireEvent.click(closeButton)
expect(onClose).toHaveBeenCalledTimes(1)
}
})
it('should navigate to next image when next button is clicked', async () => {
const onClose = vi.fn()
const images = createMockImages()
await act(async () => {
render(<ImagePreviewer images={images} onClose={onClose} />)
})
await waitFor(() => {
expect(screen.getByText('image1.png')).toBeInTheDocument()
})
// Find and click next button (right arrow)
const buttons = document.querySelectorAll('button')
const nextButton = Array.from(buttons).find(btn =>
btn.className.includes('right-8'),
)
if (nextButton) {
await act(async () => {
fireEvent.click(nextButton)
})
await waitFor(() => {
expect(screen.getByText('image2.png')).toBeInTheDocument()
})
}
})
it('should navigate to previous image when prev button is clicked', async () => {
const onClose = vi.fn()
const images = createMockImages()
await act(async () => {
render(<ImagePreviewer images={images} initialIndex={1} onClose={onClose} />)
})
await waitFor(() => {
expect(screen.getByText('image2.png')).toBeInTheDocument()
})
// Find and click prev button (left arrow)
const buttons = document.querySelectorAll('button')
const prevButton = Array.from(buttons).find(btn =>
btn.className.includes('left-8'),
)
if (prevButton) {
await act(async () => {
fireEvent.click(prevButton)
})
await waitFor(() => {
expect(screen.getByText('image1.png')).toBeInTheDocument()
})
}
})
it('should disable prev button at first image', async () => {
const onClose = vi.fn()
const images = createMockImages()
await act(async () => {
render(<ImagePreviewer images={images} initialIndex={0} onClose={onClose} />)
})
const buttons = document.querySelectorAll('button')
const prevButton = Array.from(buttons).find(btn =>
btn.className.includes('left-8'),
)
expect(prevButton).toBeDisabled()
})
it('should disable next button at last image', async () => {
const onClose = vi.fn()
const images = createMockImages()
await act(async () => {
render(<ImagePreviewer images={images} initialIndex={2} onClose={onClose} />)
})
const buttons = document.querySelectorAll('button')
const nextButton = Array.from(buttons).find(btn =>
btn.className.includes('right-8'),
)
expect(nextButton).toBeDisabled()
})
})
describe('Image Loading', () => {
it('should fetch images on mount', async () => {
const onClose = vi.fn()
const images = createMockImages()
await act(async () => {
render(<ImagePreviewer images={images} onClose={onClose} />)
})
await waitFor(() => {
expect(mockFetch).toHaveBeenCalled()
})
})
it('should show error state when fetch fails', async () => {
const onClose = vi.fn()
const images = createMockImages()
mockFetch.mockRejectedValue(new Error('Network error'))
await act(async () => {
render(<ImagePreviewer images={images} onClose={onClose} />)
})
await waitFor(() => {
expect(screen.getByText(/Failed to load image/)).toBeInTheDocument()
})
})
it('should show retry button on error', async () => {
const onClose = vi.fn()
const images = createMockImages()
mockFetch.mockRejectedValue(new Error('Network error'))
await act(async () => {
render(<ImagePreviewer images={images} onClose={onClose} />)
})
await waitFor(() => {
// Retry button should be visible
const retryButton = document.querySelector('button.rounded-full')
expect(retryButton).toBeInTheDocument()
})
})
})
describe('Navigation Boundary Cases', () => {
it('should not navigate past first image when prevImage is called at index 0', async () => {
const onClose = vi.fn()
const images = createMockImages()
await act(async () => {
render(<ImagePreviewer images={images} initialIndex={0} onClose={onClose} />)
})
await waitFor(() => {
expect(screen.getByText('image1.png')).toBeInTheDocument()
})
// Click prev button multiple times - should stay at first image
const buttons = document.querySelectorAll('button')
const prevButton = Array.from(buttons).find(btn =>
btn.className.includes('left-8'),
)
if (prevButton) {
await act(async () => {
fireEvent.click(prevButton)
fireEvent.click(prevButton)
})
// Should still be at first image
await waitFor(() => {
expect(screen.getByText('image1.png')).toBeInTheDocument()
})
}
})
it('should not navigate past last image when nextImage is called at last index', async () => {
const onClose = vi.fn()
const images = createMockImages()
await act(async () => {
render(<ImagePreviewer images={images} initialIndex={2} onClose={onClose} />)
})
await waitFor(() => {
expect(screen.getByText('image3.png')).toBeInTheDocument()
})
// Click next button multiple times - should stay at last image
const buttons = document.querySelectorAll('button')
const nextButton = Array.from(buttons).find(btn =>
btn.className.includes('right-8'),
)
if (nextButton) {
await act(async () => {
fireEvent.click(nextButton)
fireEvent.click(nextButton)
})
// Should still be at last image
await waitFor(() => {
expect(screen.getByText('image3.png')).toBeInTheDocument()
})
}
})
})
describe('Retry Functionality', () => {
it('should retry image load when retry button is clicked', async () => {
const onClose = vi.fn()
const images = createMockImages()
// First fail, then succeed
let callCount = 0
mockFetch.mockImplementation(() => {
callCount++
if (callCount === 1) {
return Promise.reject(new Error('Network error'))
}
return Promise.resolve({
ok: true,
blob: () => Promise.resolve(new Blob(['test'], { type: 'image/png' })),
})
})
await act(async () => {
render(<ImagePreviewer images={images} onClose={onClose} />)
})
// Wait for error state
await waitFor(() => {
expect(screen.getByText(/Failed to load image/)).toBeInTheDocument()
})
// Click retry button
const retryButton = document.querySelector('button.rounded-full')
if (retryButton) {
await act(async () => {
fireEvent.click(retryButton)
})
// Should refetch the image
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledTimes(4) // 3 initial + 1 retry
})
}
})
it('should show retry button and call retryImage when clicked', async () => {
const onClose = vi.fn()
const images = createMockImages()
mockFetch.mockRejectedValue(new Error('Network error'))
await act(async () => {
render(<ImagePreviewer images={images} onClose={onClose} />)
})
await waitFor(() => {
expect(screen.getByText(/Failed to load image/)).toBeInTheDocument()
})
// Find and click the retry button (not the nav buttons)
const allButtons = document.querySelectorAll('button')
const retryButton = Array.from(allButtons).find(btn =>
btn.className.includes('rounded-full') && !btn.className.includes('left-8') && !btn.className.includes('right-8'),
)
expect(retryButton).toBeInTheDocument()
if (retryButton) {
mockFetch.mockClear()
mockFetch.mockResolvedValue({
ok: true,
blob: () => Promise.resolve(new Blob(['test'], { type: 'image/png' })),
})
await act(async () => {
fireEvent.click(retryButton)
})
await waitFor(() => {
expect(mockFetch).toHaveBeenCalled()
})
}
})
})
describe('Image Cache', () => {
it('should clean up blob URLs on unmount', async () => {
const onClose = vi.fn()
const images = createMockImages()
// First render to populate cache
const { unmount } = await act(async () => {
const result = render(<ImagePreviewer images={images} onClose={onClose} />)
return result
})
await waitFor(() => {
expect(mockFetch).toHaveBeenCalled()
})
// Store the call count for verification
const _firstCallCount = mockFetch.mock.calls.length
unmount()
// Note: The imageCache is cleared on unmount, so this test verifies
// the cleanup behavior rather than caching across mounts
expect(mockRevokeObjectURL).toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should handle single image', async () => {
const onClose = vi.fn()
const images = [createMockImages()[0]]
await act(async () => {
render(<ImagePreviewer images={images} onClose={onClose} />)
})
// Both navigation buttons should be disabled
const buttons = document.querySelectorAll('button')
const prevButton = Array.from(buttons).find(btn =>
btn.className.includes('left-8'),
)
const nextButton = Array.from(buttons).find(btn =>
btn.className.includes('right-8'),
)
expect(prevButton).toBeDisabled()
expect(nextButton).toBeDisabled()
})
it('should stop event propagation on container click', async () => {
const onClose = vi.fn()
const parentClick = vi.fn()
const images = createMockImages()
await act(async () => {
render(
<div onClick={parentClick}>
<ImagePreviewer images={images} onClose={onClose} />
</div>,
)
})
const container = document.querySelector('.image-previewer')
if (container) {
fireEvent.click(container)
expect(parentClick).not.toHaveBeenCalled()
}
})
it('should display image dimensions when loaded', async () => {
const onClose = vi.fn()
const images = createMockImages()
await act(async () => {
render(<ImagePreviewer images={images} onClose={onClose} />)
})
await waitFor(() => {
// Should display dimensions (800 × 600 from MockImage)
expect(screen.getByText(/800.*600/)).toBeInTheDocument()
})
})
it('should display file size', async () => {
const onClose = vi.fn()
const images = createMockImages()
await act(async () => {
render(<ImagePreviewer images={images} onClose={onClose} />)
})
await waitFor(() => {
// Should display formatted file size
expect(screen.getByText('image1.png')).toBeInTheDocument()
})
})
})
})

View File

@ -0,0 +1,922 @@
import type { PropsWithChildren } from 'react'
import type { FileEntity } from '../types'
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import { FileContextProvider } from '../store'
import { useUpload } from './use-upload'
// Mock dependencies
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: vi.fn(() => ({
data: {
image_file_batch_limit: 10,
single_chunk_attachment_limit: 20,
attachment_image_file_size_limit: 15,
},
})),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
type FileUploadOptions = {
file: File
onProgressCallback?: (progress: number) => void
onSuccessCallback?: (res: { id: string, extension: string, mime_type: string, size: number }) => void
onErrorCallback?: (error?: Error) => void
}
const mockFileUpload = vi.fn<(options: FileUploadOptions) => void>()
const mockGetFileUploadErrorMessage = vi.fn(() => 'Upload error')
vi.mock('@/app/components/base/file-uploader/utils', () => ({
fileUpload: (options: FileUploadOptions) => mockFileUpload(options),
getFileUploadErrorMessage: () => mockGetFileUploadErrorMessage(),
}))
const createWrapper = () => {
return ({ children }: PropsWithChildren) => (
<FileContextProvider>
{children}
</FileContextProvider>
)
}
const createMockFile = (name = 'test.png', _size = 1024, type = 'image/png') => {
return new File(['test content'], name, { type })
}
// Mock FileReader
type EventCallback = () => void
class MockFileReader {
result: string | ArrayBuffer | null = null
onload: EventCallback | null = null
onerror: EventCallback | null = null
private listeners: Record<string, EventCallback[]> = {}
addEventListener(event: string, callback: EventCallback) {
if (!this.listeners[event])
this.listeners[event] = []
this.listeners[event].push(callback)
}
removeEventListener(event: string, callback: EventCallback) {
if (this.listeners[event])
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback)
}
readAsDataURL(_file: File) {
setTimeout(() => {
this.result = 'data:image/png;base64,mockBase64Data'
this.listeners.load?.forEach(cb => cb())
}, 0)
}
triggerError() {
this.listeners.error?.forEach(cb => cb())
}
}
describe('useUpload hook', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFileUpload.mockImplementation(({ onSuccessCallback }) => {
setTimeout(() => {
onSuccessCallback?.({ id: 'uploaded-id', extension: 'png', mime_type: 'image/png', size: 1024 })
}, 0)
})
// Mock FileReader globally
vi.stubGlobal('FileReader', MockFileReader)
})
describe('Initialization', () => {
it('should initialize with default state', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
expect(result.current.dragging).toBe(false)
expect(result.current.uploaderRef).toBeDefined()
expect(result.current.dragRef).toBeDefined()
expect(result.current.dropRef).toBeDefined()
})
it('should return file upload config', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
expect(result.current.fileUploadConfig).toBeDefined()
expect(result.current.fileUploadConfig.imageFileBatchLimit).toBe(10)
expect(result.current.fileUploadConfig.singleChunkAttachmentLimit).toBe(20)
expect(result.current.fileUploadConfig.imageFileSizeLimit).toBe(15)
})
})
describe('File Operations', () => {
it('should expose selectHandle function', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
expect(typeof result.current.selectHandle).toBe('function')
})
it('should expose fileChangeHandle function', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
expect(typeof result.current.fileChangeHandle).toBe('function')
})
it('should expose handleRemoveFile function', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
expect(typeof result.current.handleRemoveFile).toBe('function')
})
it('should expose handleReUploadFile function', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
expect(typeof result.current.handleReUploadFile).toBe('function')
})
it('should expose handleLocalFileUpload function', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
expect(typeof result.current.handleLocalFileUpload).toBe('function')
})
})
describe('File Validation', () => {
it('should show error toast for invalid file type', async () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
const mockEvent = {
target: {
files: [createMockFile('test.exe', 1024, 'application/x-msdownload')],
},
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(mockEvent)
})
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
})
})
})
it('should not reject valid image file types', async () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
const mockFile = createMockFile('test.png', 1024, 'image/png')
const mockEvent = {
target: {
files: [mockFile],
},
} as unknown as React.ChangeEvent<HTMLInputElement>
// File type validation should pass for png files
// The actual upload will fail without proper FileReader mock,
// but we're testing that type validation doesn't reject valid files
act(() => {
result.current.fileChangeHandle(mockEvent)
})
// Should not show type error for valid image type
type ToastCall = [{ type: string, message: string }]
const mockNotify = vi.mocked(Toast.notify)
const calls = mockNotify.mock.calls as ToastCall[]
const typeErrorCalls = calls.filter(
(call: ToastCall) => call[0].type === 'error' && call[0].message.includes('Extension'),
)
expect(typeErrorCalls.length).toBe(0)
})
})
describe('Drag and Drop Refs', () => {
it('should provide dragRef', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
expect(result.current.dragRef).toBeDefined()
expect(result.current.dragRef.current).toBeNull()
})
it('should provide dropRef', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
expect(result.current.dropRef).toBeDefined()
expect(result.current.dropRef.current).toBeNull()
})
it('should provide uploaderRef', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
expect(result.current.uploaderRef).toBeDefined()
expect(result.current.uploaderRef.current).toBeNull()
})
})
describe('Edge Cases', () => {
it('should handle empty file list', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
const mockEvent = {
target: {
files: [],
},
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(mockEvent)
})
// Should not throw and not show error
expect(Toast.notify).not.toHaveBeenCalled()
})
it('should handle null files', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
const mockEvent = {
target: {
files: null,
},
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(mockEvent)
})
// Should not throw
expect(true).toBe(true)
})
it('should respect batch limit from config', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
// Config should have batch limit of 10
expect(result.current.fileUploadConfig.imageFileBatchLimit).toBe(10)
})
})
describe('File Size Validation', () => {
it('should show error for files exceeding size limit', async () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
// Create a file larger than 15MB limit (15 * 1024 * 1024 bytes)
const largeFile = new File(['x'.repeat(16 * 1024 * 1024)], 'large.png', { type: 'image/png' })
Object.defineProperty(largeFile, 'size', { value: 16 * 1024 * 1024 })
const mockEvent = {
target: {
files: [largeFile],
},
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(mockEvent)
})
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
})
})
})
})
describe('handleRemoveFile', () => {
it('should remove file from store', async () => {
const onChange = vi.fn()
const initialFiles: Partial<FileEntity>[] = [
{ id: 'file1', name: 'test1.png', progress: 100 },
{ id: 'file2', name: 'test2.png', progress: 100 },
]
const wrapper = ({ children }: PropsWithChildren) => (
<FileContextProvider value={initialFiles as FileEntity[]} onChange={onChange}>
{children}
</FileContextProvider>
)
const { result } = renderHook(() => useUpload(), { wrapper })
act(() => {
result.current.handleRemoveFile('file1')
})
expect(onChange).toHaveBeenCalledWith([
{ id: 'file2', name: 'test2.png', progress: 100 },
])
})
})
describe('handleReUploadFile', () => {
it('should re-upload file when called with valid fileId', async () => {
const onChange = vi.fn()
const initialFiles: Partial<FileEntity>[] = [
{ id: 'file1', name: 'test1.png', progress: -1, originalFile: new File(['test'], 'test1.png') },
]
const wrapper = ({ children }: PropsWithChildren) => (
<FileContextProvider value={initialFiles as FileEntity[]} onChange={onChange}>
{children}
</FileContextProvider>
)
const { result } = renderHook(() => useUpload(), { wrapper })
act(() => {
result.current.handleReUploadFile('file1')
})
await waitFor(() => {
expect(mockFileUpload).toHaveBeenCalled()
})
})
it('should not re-upload when fileId is not found', () => {
const onChange = vi.fn()
const initialFiles: Partial<FileEntity>[] = [
{ id: 'file1', name: 'test1.png', progress: -1, originalFile: new File(['test'], 'test1.png') },
]
const wrapper = ({ children }: PropsWithChildren) => (
<FileContextProvider value={initialFiles as FileEntity[]} onChange={onChange}>
{children}
</FileContextProvider>
)
const { result } = renderHook(() => useUpload(), { wrapper })
act(() => {
result.current.handleReUploadFile('nonexistent')
})
// fileUpload should not be called for nonexistent file
expect(mockFileUpload).not.toHaveBeenCalled()
})
it('should handle upload error during re-upload', async () => {
mockFileUpload.mockImplementation(({ onErrorCallback }: FileUploadOptions) => {
setTimeout(() => {
onErrorCallback?.(new Error('Upload failed'))
}, 0)
})
const onChange = vi.fn()
const initialFiles: Partial<FileEntity>[] = [
{ id: 'file1', name: 'test1.png', progress: -1, originalFile: new File(['test'], 'test1.png') },
]
const wrapper = ({ children }: PropsWithChildren) => (
<FileContextProvider value={initialFiles as FileEntity[]} onChange={onChange}>
{children}
</FileContextProvider>
)
const { result } = renderHook(() => useUpload(), { wrapper })
act(() => {
result.current.handleReUploadFile('file1')
})
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
type: 'error',
message: 'Upload error',
})
})
})
})
describe('handleLocalFileUpload', () => {
it('should upload file and update progress', async () => {
mockFileUpload.mockImplementation(({ onProgressCallback, onSuccessCallback }: FileUploadOptions) => {
setTimeout(() => {
onProgressCallback?.(50)
setTimeout(() => {
onSuccessCallback?.({ id: 'uploaded-id', extension: 'png', mime_type: 'image/png', size: 1024 })
}, 10)
}, 0)
})
const onChange = vi.fn()
const wrapper = ({ children }: PropsWithChildren) => (
<FileContextProvider onChange={onChange}>
{children}
</FileContextProvider>
)
const { result } = renderHook(() => useUpload(), { wrapper })
const mockFile = createMockFile('test.png', 1024, 'image/png')
await act(async () => {
result.current.handleLocalFileUpload(mockFile)
})
await waitFor(() => {
expect(mockFileUpload).toHaveBeenCalled()
})
})
it('should handle upload error', async () => {
mockFileUpload.mockImplementation(({ onErrorCallback }: FileUploadOptions) => {
setTimeout(() => {
onErrorCallback?.(new Error('Upload failed'))
}, 0)
})
const onChange = vi.fn()
const wrapper = ({ children }: PropsWithChildren) => (
<FileContextProvider onChange={onChange}>
{children}
</FileContextProvider>
)
const { result } = renderHook(() => useUpload(), { wrapper })
const mockFile = createMockFile('test.png', 1024, 'image/png')
await act(async () => {
result.current.handleLocalFileUpload(mockFile)
})
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
type: 'error',
message: 'Upload error',
})
})
})
})
describe('Attachment Limit', () => {
it('should show error when exceeding single chunk attachment limit', async () => {
const onChange = vi.fn()
// Pre-populate with 19 files (limit is 20)
const initialFiles: Partial<FileEntity>[] = Array.from({ length: 19 }, (_, i) => ({
id: `file${i}`,
name: `test${i}.png`,
progress: 100,
}))
const wrapper = ({ children }: PropsWithChildren) => (
<FileContextProvider value={initialFiles as FileEntity[]} onChange={onChange}>
{children}
</FileContextProvider>
)
const { result } = renderHook(() => useUpload(), { wrapper })
// Try to add 2 more files (would exceed limit of 20)
const mockEvent = {
target: {
files: [
createMockFile('new1.png'),
createMockFile('new2.png'),
],
},
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(mockEvent)
})
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
})
})
})
})
describe('selectHandle', () => {
it('should trigger click on uploader input when called', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
// Create a mock input element
const mockInput = document.createElement('input')
const clickSpy = vi.spyOn(mockInput, 'click')
// Manually set the ref
Object.defineProperty(result.current.uploaderRef, 'current', {
value: mockInput,
writable: true,
})
act(() => {
result.current.selectHandle()
})
expect(clickSpy).toHaveBeenCalled()
})
it('should not throw when uploaderRef is null', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
expect(() => {
act(() => {
result.current.selectHandle()
})
}).not.toThrow()
})
})
describe('FileReader Error Handling', () => {
it('should show error toast when FileReader encounters an error', async () => {
// Create a custom MockFileReader that triggers error
class ErrorFileReader {
result: string | ArrayBuffer | null = null
private listeners: Record<string, EventCallback[]> = {}
addEventListener(event: string, callback: EventCallback) {
if (!this.listeners[event])
this.listeners[event] = []
this.listeners[event].push(callback)
}
removeEventListener(event: string, callback: EventCallback) {
if (this.listeners[event])
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback)
}
readAsDataURL(_file: File) {
// Trigger error instead of load
setTimeout(() => {
this.listeners.error?.forEach(cb => cb())
}, 0)
}
}
vi.stubGlobal('FileReader', ErrorFileReader)
const onChange = vi.fn()
const wrapper = ({ children }: PropsWithChildren) => (
<FileContextProvider onChange={onChange}>
{children}
</FileContextProvider>
)
const { result } = renderHook(() => useUpload(), { wrapper })
const mockFile = createMockFile('test.png', 1024, 'image/png')
await act(async () => {
result.current.handleLocalFileUpload(mockFile)
})
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
})
})
// Restore original MockFileReader
vi.stubGlobal('FileReader', MockFileReader)
})
})
describe('Drag and Drop Functionality', () => {
// Test component that renders the hook with actual DOM elements
const TestComponent = ({ onStateChange }: { onStateChange?: (dragging: boolean) => void }) => {
const { dragging, dragRef, dropRef } = useUpload()
// Report dragging state changes to parent
React.useEffect(() => {
onStateChange?.(dragging)
}, [dragging, onStateChange])
return (
<div ref={dropRef} data-testid="drop-zone">
<div ref={dragRef} data-testid="drag-boundary">
<span data-testid="dragging-state">{dragging ? 'dragging' : 'not-dragging'}</span>
</div>
</div>
)
}
it('should set dragging to true on dragEnter when target is not dragRef', async () => {
const onStateChange = vi.fn()
render(
<FileContextProvider>
<TestComponent onStateChange={onStateChange} />
</FileContextProvider>,
)
const dropZone = screen.getByTestId('drop-zone')
// Fire dragenter event on dropZone (not dragRef)
await act(async () => {
fireEvent.dragEnter(dropZone, {
dataTransfer: { items: [] },
})
})
// Verify dragging state changed to true
expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging')
})
it('should set dragging to false on dragLeave when target matches dragRef', async () => {
render(
<FileContextProvider>
<TestComponent />
</FileContextProvider>,
)
const dropZone = screen.getByTestId('drop-zone')
const dragBoundary = screen.getByTestId('drag-boundary')
// First trigger dragenter to set dragging to true
await act(async () => {
fireEvent.dragEnter(dropZone, {
dataTransfer: { items: [] },
})
})
expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging')
// Then trigger dragleave on dragBoundary to set dragging to false
await act(async () => {
fireEvent.dragLeave(dragBoundary, {
dataTransfer: { items: [] },
})
})
expect(screen.getByTestId('dragging-state')).toHaveTextContent('not-dragging')
})
it('should handle drop event with files and reset dragging state', async () => {
const onChange = vi.fn()
render(
<FileContextProvider onChange={onChange}>
<TestComponent />
</FileContextProvider>,
)
const dropZone = screen.getByTestId('drop-zone')
const mockFile = new File(['test content'], 'test.png', { type: 'image/png' })
// First trigger dragenter
await act(async () => {
fireEvent.dragEnter(dropZone, {
dataTransfer: { items: [] },
})
})
expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging')
// Then trigger drop with files
await act(async () => {
fireEvent.drop(dropZone, {
dataTransfer: {
items: [{
webkitGetAsEntry: () => null,
getAsFile: () => mockFile,
}],
},
})
})
// Dragging should be reset to false after drop
expect(screen.getByTestId('dragging-state')).toHaveTextContent('not-dragging')
})
it('should return early when dataTransfer is null on drop', async () => {
render(
<FileContextProvider>
<TestComponent />
</FileContextProvider>,
)
const dropZone = screen.getByTestId('drop-zone')
// Fire dragenter first
await act(async () => {
fireEvent.dragEnter(dropZone)
})
// Fire drop without dataTransfer
await act(async () => {
fireEvent.drop(dropZone)
})
// Should still reset dragging state
expect(screen.getByTestId('dragging-state')).toHaveTextContent('not-dragging')
})
it('should not trigger file upload for invalid file types on drop', async () => {
render(
<FileContextProvider>
<TestComponent />
</FileContextProvider>,
)
const dropZone = screen.getByTestId('drop-zone')
const invalidFile = new File(['test'], 'test.exe', { type: 'application/x-msdownload' })
await act(async () => {
fireEvent.drop(dropZone, {
dataTransfer: {
items: [{
webkitGetAsEntry: () => null,
getAsFile: () => invalidFile,
}],
},
})
})
// Should show error toast for invalid file type
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
})
})
})
it('should handle drop with webkitGetAsEntry for file entries', async () => {
const onChange = vi.fn()
const mockFile = new File(['test'], 'test.png', { type: 'image/png' })
render(
<FileContextProvider onChange={onChange}>
<TestComponent />
</FileContextProvider>,
)
const dropZone = screen.getByTestId('drop-zone')
// Create a mock file entry that simulates webkitGetAsEntry behavior
const mockFileEntry = {
isFile: true,
isDirectory: false,
file: (callback: (file: File) => void) => callback(mockFile),
}
await act(async () => {
fireEvent.drop(dropZone, {
dataTransfer: {
items: [{
webkitGetAsEntry: () => mockFileEntry,
getAsFile: () => mockFile,
}],
},
})
})
// Dragging should be reset
expect(screen.getByTestId('dragging-state')).toHaveTextContent('not-dragging')
})
})
describe('Drag Events', () => {
const TestComponent = () => {
const { dragging, dragRef, dropRef } = useUpload()
return (
<div ref={dropRef} data-testid="drop-zone">
<div ref={dragRef} data-testid="drag-boundary">
<span data-testid="dragging-state">{dragging ? 'dragging' : 'not-dragging'}</span>
</div>
</div>
)
}
it('should handle dragEnter event and update dragging state', async () => {
render(
<FileContextProvider>
<TestComponent />
</FileContextProvider>,
)
const dropZone = screen.getByTestId('drop-zone')
// Initially not dragging
expect(screen.getByTestId('dragging-state')).toHaveTextContent('not-dragging')
// Fire dragEnter
await act(async () => {
fireEvent.dragEnter(dropZone, {
dataTransfer: { items: [] },
})
})
// Should be dragging now
expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging')
})
it('should handle dragOver event without changing state', async () => {
render(
<FileContextProvider>
<TestComponent />
</FileContextProvider>,
)
const dropZone = screen.getByTestId('drop-zone')
// First trigger dragenter to set dragging
await act(async () => {
fireEvent.dragEnter(dropZone)
})
expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging')
// dragOver should not change the dragging state
await act(async () => {
fireEvent.dragOver(dropZone)
})
// Should still be dragging
expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging')
})
it('should not set dragging to true when dragEnter target is dragRef', async () => {
render(
<FileContextProvider>
<TestComponent />
</FileContextProvider>,
)
const dragBoundary = screen.getByTestId('drag-boundary')
// Fire dragEnter directly on dragRef
await act(async () => {
fireEvent.dragEnter(dragBoundary)
})
// Should not be dragging when target is dragRef itself
expect(screen.getByTestId('dragging-state')).toHaveTextContent('not-dragging')
})
it('should not set dragging to false when dragLeave target is not dragRef', async () => {
render(
<FileContextProvider>
<TestComponent />
</FileContextProvider>,
)
const dropZone = screen.getByTestId('drop-zone')
// First trigger dragenter on dropZone to set dragging
await act(async () => {
fireEvent.dragEnter(dropZone)
})
expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging')
// dragLeave on dropZone (not dragRef) should not change dragging state
await act(async () => {
fireEvent.dragLeave(dropZone)
})
// Should still be dragging (only dragLeave on dragRef resets)
expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging')
})
})
})

View File

@ -0,0 +1,107 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { FileContextProvider } from '../store'
import ImageInput from './image-input'
// Mock dependencies
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: vi.fn(() => ({
data: {
image_file_batch_limit: 10,
single_chunk_attachment_limit: 20,
attachment_image_file_size_limit: 15,
},
})),
}))
const renderWithProvider = (ui: React.ReactElement) => {
return render(
<FileContextProvider>
{ui}
</FileContextProvider>,
)
}
describe('ImageInput (image-uploader-in-chunk)', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = renderWithProvider(<ImageInput />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render file input element', () => {
renderWithProvider(<ImageInput />)
const input = document.querySelector('input[type="file"]')
expect(input).toBeInTheDocument()
})
it('should have hidden file input', () => {
renderWithProvider(<ImageInput />)
const input = document.querySelector('input[type="file"]')
expect(input).toHaveClass('hidden')
})
it('should render upload icon', () => {
const { container } = renderWithProvider(<ImageInput />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})
it('should render browse text', () => {
renderWithProvider(<ImageInput />)
expect(screen.getByText(/browse/i)).toBeInTheDocument()
})
})
describe('File Input Props', () => {
it('should accept multiple files', () => {
renderWithProvider(<ImageInput />)
const input = document.querySelector('input[type="file"]')
expect(input).toHaveAttribute('multiple')
})
it('should have accept attribute for images', () => {
renderWithProvider(<ImageInput />)
const input = document.querySelector('input[type="file"]')
expect(input).toHaveAttribute('accept')
})
})
describe('User Interactions', () => {
it('should open file dialog when browse is clicked', () => {
renderWithProvider(<ImageInput />)
const browseText = screen.getByText(/browse/i)
const input = document.querySelector('input[type="file"]') as HTMLInputElement
const clickSpy = vi.spyOn(input, 'click')
fireEvent.click(browseText)
expect(clickSpy).toHaveBeenCalled()
})
})
describe('Drag and Drop', () => {
it('should have drop zone area', () => {
const { container } = renderWithProvider(<ImageInput />)
// The drop zone has dashed border styling
expect(container.querySelector('.border-dashed')).toBeInTheDocument()
})
it('should apply accent styles when dragging', () => {
// This would require simulating drag events
// Just verify the base structure exists
const { container } = renderWithProvider(<ImageInput />)
expect(container.querySelector('.border-components-dropzone-border')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should display file size limit from config', () => {
renderWithProvider(<ImageInput />)
// The tip text should contain the size limit (15 from mock)
const tipText = document.querySelector('.system-xs-regular')
expect(tipText).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,198 @@
import type { FileEntity } from '../types'
import { fireEvent, render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ImageItem from './image-item'
const createMockFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
id: 'test-id',
name: 'test.png',
progress: 100,
base64Url: 'data:image/png;base64,test',
sourceUrl: 'https://example.com/test.png',
size: 1024,
...overrides,
} as FileEntity)
describe('ImageItem (image-uploader-in-chunk)', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const file = createMockFile()
const { container } = render(<ImageItem file={file} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render image preview', () => {
const file = createMockFile()
const { container } = render(<ImageItem file={file} />)
// FileImageRender component should be present
expect(container.querySelector('.group\\/file-image')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should show delete button when showDeleteAction is true', () => {
const file = createMockFile()
const { container } = render(
<ImageItem file={file} showDeleteAction onRemove={() => {}} />,
)
// Delete button has RiCloseLine icon
const deleteButton = container.querySelector('button')
expect(deleteButton).toBeInTheDocument()
})
it('should not show delete button when showDeleteAction is false', () => {
const file = createMockFile()
const { container } = render(<ImageItem file={file} showDeleteAction={false} />)
const deleteButton = container.querySelector('button')
expect(deleteButton).not.toBeInTheDocument()
})
it('should use base64Url for image when available', () => {
const file = createMockFile({ base64Url: 'data:image/png;base64,custom' })
const { container } = render(<ImageItem file={file} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should fallback to sourceUrl when base64Url is not available', () => {
const file = createMockFile({ base64Url: undefined })
const { container } = render(<ImageItem file={file} />)
expect(container.firstChild).toBeInTheDocument()
})
})
describe('Progress States', () => {
it('should show progress indicator when progress is between 0 and 99', () => {
const file = createMockFile({ progress: 50, uploadedId: undefined })
const { container } = render(<ImageItem file={file} />)
// Progress circle should be visible
expect(container.querySelector('.bg-background-overlay-alt')).toBeInTheDocument()
})
it('should not show progress indicator when upload is complete', () => {
const file = createMockFile({ progress: 100, uploadedId: 'uploaded-123' })
const { container } = render(<ImageItem file={file} />)
expect(container.querySelector('.bg-background-overlay-alt')).not.toBeInTheDocument()
})
it('should show retry button when progress is -1 (error)', () => {
const file = createMockFile({ progress: -1 })
const { container } = render(<ImageItem file={file} />)
// Error state shows destructive overlay
expect(container.querySelector('.bg-background-overlay-destructive')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onPreview when image is clicked', () => {
const onPreview = vi.fn()
const file = createMockFile()
const { container } = render(<ImageItem file={file} onPreview={onPreview} />)
const imageContainer = container.querySelector('.group\\/file-image')
if (imageContainer) {
fireEvent.click(imageContainer)
expect(onPreview).toHaveBeenCalledWith('test-id')
}
})
it('should call onRemove when delete button is clicked', () => {
const onRemove = vi.fn()
const file = createMockFile()
const { container } = render(
<ImageItem file={file} showDeleteAction onRemove={onRemove} />,
)
const deleteButton = container.querySelector('button')
if (deleteButton) {
fireEvent.click(deleteButton)
expect(onRemove).toHaveBeenCalledWith('test-id')
}
})
it('should call onReUpload when error overlay is clicked', () => {
const onReUpload = vi.fn()
const file = createMockFile({ progress: -1 })
const { container } = render(<ImageItem file={file} onReUpload={onReUpload} />)
const errorOverlay = container.querySelector('.bg-background-overlay-destructive')
if (errorOverlay) {
fireEvent.click(errorOverlay)
expect(onReUpload).toHaveBeenCalledWith('test-id')
}
})
it('should stop event propagation on delete button click', () => {
const onRemove = vi.fn()
const onPreview = vi.fn()
const file = createMockFile()
const { container } = render(
<ImageItem file={file} showDeleteAction onRemove={onRemove} onPreview={onPreview} />,
)
const deleteButton = container.querySelector('button')
if (deleteButton) {
fireEvent.click(deleteButton)
expect(onRemove).toHaveBeenCalled()
expect(onPreview).not.toHaveBeenCalled()
}
})
it('should stop event propagation on retry click', () => {
const onReUpload = vi.fn()
const onPreview = vi.fn()
const file = createMockFile({ progress: -1 })
const { container } = render(
<ImageItem file={file} onReUpload={onReUpload} onPreview={onPreview} />,
)
const errorOverlay = container.querySelector('.bg-background-overlay-destructive')
if (errorOverlay) {
fireEvent.click(errorOverlay)
expect(onReUpload).toHaveBeenCalled()
// onPreview should not be called due to stopPropagation
}
})
})
describe('Edge Cases', () => {
it('should handle missing onPreview callback', () => {
const file = createMockFile()
const { container } = render(<ImageItem file={file} />)
const imageContainer = container.querySelector('.group\\/file-image')
expect(() => {
if (imageContainer)
fireEvent.click(imageContainer)
}).not.toThrow()
})
it('should handle missing onRemove callback', () => {
const file = createMockFile()
const { container } = render(<ImageItem file={file} showDeleteAction />)
const deleteButton = container.querySelector('button')
expect(() => {
if (deleteButton)
fireEvent.click(deleteButton)
}).not.toThrow()
})
it('should handle missing onReUpload callback', () => {
const file = createMockFile({ progress: -1 })
const { container } = render(<ImageItem file={file} />)
const errorOverlay = container.querySelector('.bg-background-overlay-destructive')
expect(() => {
if (errorOverlay)
fireEvent.click(errorOverlay)
}).not.toThrow()
})
it('should handle progress of 0', () => {
const file = createMockFile({ progress: 0 })
const { container } = render(<ImageItem file={file} />)
// Progress overlay should be visible at 0%
expect(container.querySelector('.bg-background-overlay-alt')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,167 @@
import type { FileEntity } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ImageUploaderInChunkWrapper from './index'
// Mock dependencies
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: vi.fn(() => ({
data: {
image_file_batch_limit: 10,
single_chunk_attachment_limit: 20,
attachment_image_file_size_limit: 15,
},
})),
}))
vi.mock('@/app/components/datasets/common/image-previewer', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<div data-testid="image-previewer">
<button data-testid="close-preview" onClick={onClose}>Close</button>
</div>
),
}))
describe('ImageUploaderInChunk', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const onChange = vi.fn()
const { container } = render(
<ImageUploaderInChunkWrapper value={[]} onChange={onChange} />,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render ImageInput when not disabled', () => {
const onChange = vi.fn()
render(<ImageUploaderInChunkWrapper value={[]} onChange={onChange} />)
// ImageInput renders an input element
expect(document.querySelector('input[type="file"]')).toBeInTheDocument()
})
it('should not render ImageInput when disabled', () => {
const onChange = vi.fn()
render(<ImageUploaderInChunkWrapper value={[]} onChange={onChange} disabled />)
// ImageInput should not be present
expect(document.querySelector('input[type="file"]')).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
const onChange = vi.fn()
const { container } = render(
<ImageUploaderInChunkWrapper
value={[]}
onChange={onChange}
className="custom-class"
/>,
)
expect(container.firstChild).toHaveClass('custom-class')
})
it('should render files when value is provided', () => {
const onChange = vi.fn()
const files: FileEntity[] = [
{
id: 'file1',
name: 'test1.png',
extension: 'png',
mimeType: 'image/png',
progress: 100,
base64Url: 'data:image/png;base64,test1',
size: 1024,
},
{
id: 'file2',
name: 'test2.png',
extension: 'png',
mimeType: 'image/png',
progress: 100,
base64Url: 'data:image/png;base64,test2',
size: 2048,
},
]
render(<ImageUploaderInChunkWrapper value={files} onChange={onChange} />)
// Each file renders an ImageItem
const fileItems = document.querySelectorAll('.group\\/file-image')
expect(fileItems.length).toBeGreaterThanOrEqual(2)
})
})
describe('User Interactions', () => {
it('should show preview when image is clicked', () => {
const onChange = vi.fn()
const files: FileEntity[] = [
{
id: 'file1',
name: 'test.png',
extension: 'png',
mimeType: 'image/png',
progress: 100,
uploadedId: 'uploaded-1',
base64Url: 'data:image/png;base64,test',
size: 1024,
},
]
render(<ImageUploaderInChunkWrapper value={files} onChange={onChange} />)
// Find and click the file item
const fileItem = document.querySelector('.group\\/file-image')
if (fileItem) {
fireEvent.click(fileItem)
expect(screen.getByTestId('image-previewer')).toBeInTheDocument()
}
})
it('should close preview when close button is clicked', () => {
const onChange = vi.fn()
const files: FileEntity[] = [
{
id: 'file1',
name: 'test.png',
extension: 'png',
mimeType: 'image/png',
progress: 100,
uploadedId: 'uploaded-1',
base64Url: 'data:image/png;base64,test',
size: 1024,
},
]
render(<ImageUploaderInChunkWrapper value={files} onChange={onChange} />)
// Open preview
const fileItem = document.querySelector('.group\\/file-image')
if (fileItem) {
fireEvent.click(fileItem)
// Close preview
const closeButton = screen.getByTestId('close-preview')
fireEvent.click(closeButton)
expect(screen.queryByTestId('image-previewer')).not.toBeInTheDocument()
}
})
})
describe('Edge Cases', () => {
it('should handle empty files array', () => {
const onChange = vi.fn()
const { container } = render(
<ImageUploaderInChunkWrapper value={[]} onChange={onChange} />,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle undefined value', () => {
const onChange = vi.fn()
const { container } = render(
<ImageUploaderInChunkWrapper value={undefined} onChange={onChange} />,
)
expect(container.firstChild).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,125 @@
import type { FileEntity } from '../types'
import { fireEvent, render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { FileContextProvider } from '../store'
import ImageInput from './image-input'
// Mock dependencies
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: vi.fn(() => ({
data: {
image_file_batch_limit: 10,
single_chunk_attachment_limit: 20,
attachment_image_file_size_limit: 15,
},
})),
}))
const renderWithProvider = (ui: React.ReactElement, initialFiles: FileEntity[] = []) => {
return render(
<FileContextProvider value={initialFiles}>
{ui}
</FileContextProvider>,
)
}
describe('ImageInput (image-uploader-in-retrieval-testing)', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = renderWithProvider(<ImageInput />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render file input element', () => {
renderWithProvider(<ImageInput />)
const input = document.querySelector('input[type="file"]')
expect(input).toBeInTheDocument()
})
it('should have hidden file input', () => {
renderWithProvider(<ImageInput />)
const input = document.querySelector('input[type="file"]')
expect(input).toHaveClass('hidden')
})
it('should render add image icon', () => {
const { container } = renderWithProvider(<ImageInput />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})
it('should show tip text when no files are uploaded', () => {
renderWithProvider(<ImageInput />)
// Tip text should be visible
expect(document.querySelector('.system-sm-regular')).toBeInTheDocument()
})
it('should hide tip text when files exist', () => {
const files: FileEntity[] = [
{
id: 'file1',
name: 'test.png',
extension: 'png',
mimeType: 'image/png',
size: 1024,
progress: 100,
uploadedId: 'uploaded-1',
},
]
renderWithProvider(<ImageInput />, files)
// Tip text should not be visible
expect(document.querySelector('.text-text-quaternary')).not.toBeInTheDocument()
})
})
describe('File Input Props', () => {
it('should accept multiple files', () => {
renderWithProvider(<ImageInput />)
const input = document.querySelector('input[type="file"]')
expect(input).toHaveAttribute('multiple')
})
it('should have accept attribute', () => {
renderWithProvider(<ImageInput />)
const input = document.querySelector('input[type="file"]')
expect(input).toHaveAttribute('accept')
})
})
describe('User Interactions', () => {
it('should open file dialog when icon is clicked', () => {
renderWithProvider(<ImageInput />)
const clickableArea = document.querySelector('.cursor-pointer')
const input = document.querySelector('input[type="file"]') as HTMLInputElement
const clickSpy = vi.spyOn(input, 'click')
if (clickableArea)
fireEvent.click(clickableArea)
expect(clickSpy).toHaveBeenCalled()
})
})
describe('Tooltip', () => {
it('should have tooltip component', () => {
const { container } = renderWithProvider(<ImageInput />)
// Tooltip wrapper should exist
expect(container.firstChild).toBeInTheDocument()
})
it('should disable tooltip when no files exist', () => {
// When files.length === 0, tooltip should be disabled
renderWithProvider(<ImageInput />)
// Component renders with tip text visible instead of tooltip
expect(document.querySelector('.system-sm-regular')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should render icon container with correct styling', () => {
const { container } = renderWithProvider(<ImageInput />)
expect(container.querySelector('.border-dashed')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,149 @@
import type { FileEntity } from '../types'
import { fireEvent, render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ImageItem from './image-item'
const createMockFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
id: 'test-id',
name: 'test.png',
progress: 100,
base64Url: 'data:image/png;base64,test',
sourceUrl: 'https://example.com/test.png',
size: 1024,
...overrides,
} as FileEntity)
describe('ImageItem (image-uploader-in-retrieval-testing)', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const file = createMockFile()
const { container } = render(<ImageItem file={file} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with size-20 class', () => {
const file = createMockFile()
const { container } = render(<ImageItem file={file} />)
expect(container.querySelector('.size-20')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should show delete button when showDeleteAction is true', () => {
const file = createMockFile()
const { container } = render(
<ImageItem file={file} showDeleteAction onRemove={() => {}} />,
)
const deleteButton = container.querySelector('button')
expect(deleteButton).toBeInTheDocument()
})
it('should not show delete button when showDeleteAction is false', () => {
const file = createMockFile()
const { container } = render(<ImageItem file={file} showDeleteAction={false} />)
const deleteButton = container.querySelector('button')
expect(deleteButton).not.toBeInTheDocument()
})
})
describe('Progress States', () => {
it('should show progress indicator when uploading', () => {
const file = createMockFile({ progress: 50, uploadedId: undefined })
const { container } = render(<ImageItem file={file} />)
expect(container.querySelector('.bg-background-overlay-alt')).toBeInTheDocument()
})
it('should not show progress indicator when upload is complete', () => {
const file = createMockFile({ progress: 100, uploadedId: 'uploaded-123' })
const { container } = render(<ImageItem file={file} />)
expect(container.querySelector('.bg-background-overlay-alt')).not.toBeInTheDocument()
})
it('should show error overlay when progress is -1', () => {
const file = createMockFile({ progress: -1 })
const { container } = render(<ImageItem file={file} />)
expect(container.querySelector('.bg-background-overlay-destructive')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onPreview when clicked', () => {
const onPreview = vi.fn()
const file = createMockFile()
const { container } = render(<ImageItem file={file} onPreview={onPreview} />)
const imageContainer = container.querySelector('.group\\/file-image')
if (imageContainer) {
fireEvent.click(imageContainer)
expect(onPreview).toHaveBeenCalledWith('test-id')
}
})
it('should call onRemove when delete button is clicked', () => {
const onRemove = vi.fn()
const file = createMockFile()
const { container } = render(
<ImageItem file={file} showDeleteAction onRemove={onRemove} />,
)
const deleteButton = container.querySelector('button')
if (deleteButton) {
fireEvent.click(deleteButton)
expect(onRemove).toHaveBeenCalledWith('test-id')
}
})
it('should call onReUpload when error overlay is clicked', () => {
const onReUpload = vi.fn()
const file = createMockFile({ progress: -1 })
const { container } = render(<ImageItem file={file} onReUpload={onReUpload} />)
const errorOverlay = container.querySelector('.bg-background-overlay-destructive')
if (errorOverlay) {
fireEvent.click(errorOverlay)
expect(onReUpload).toHaveBeenCalledWith('test-id')
}
})
it('should stop propagation on delete click', () => {
const onRemove = vi.fn()
const onPreview = vi.fn()
const file = createMockFile()
const { container } = render(
<ImageItem file={file} showDeleteAction onRemove={onRemove} onPreview={onPreview} />,
)
const deleteButton = container.querySelector('button')
if (deleteButton) {
fireEvent.click(deleteButton)
expect(onRemove).toHaveBeenCalled()
expect(onPreview).not.toHaveBeenCalled()
}
})
})
describe('Edge Cases', () => {
it('should handle missing callbacks', () => {
const file = createMockFile()
const { container } = render(<ImageItem file={file} />)
expect(() => {
const imageContainer = container.querySelector('.group\\/file-image')
if (imageContainer)
fireEvent.click(imageContainer)
}).not.toThrow()
})
it('should use base64Url when available', () => {
const file = createMockFile({ base64Url: 'data:custom' })
const { container } = render(<ImageItem file={file} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should fallback to sourceUrl', () => {
const file = createMockFile({ base64Url: undefined })
const { container } = render(<ImageItem file={file} />)
expect(container.firstChild).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,238 @@
import type { FileEntity } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ImageUploaderInRetrievalTestingWrapper from './index'
// Mock dependencies
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: vi.fn(() => ({
data: {
image_file_batch_limit: 10,
single_chunk_attachment_limit: 20,
attachment_image_file_size_limit: 15,
},
})),
}))
vi.mock('@/app/components/datasets/common/image-previewer', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<div data-testid="image-previewer">
<button data-testid="close-preview" onClick={onClose}>Close</button>
</div>
),
}))
describe('ImageUploaderInRetrievalTesting', () => {
const defaultProps = {
textArea: <textarea data-testid="text-area" />,
actionButton: <button data-testid="action-button">Submit</button>,
onChange: vi.fn(),
}
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(
<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={[]} />,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render textArea prop', () => {
render(<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={[]} />)
expect(screen.getByTestId('text-area')).toBeInTheDocument()
})
it('should render actionButton prop', () => {
render(<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={[]} />)
expect(screen.getByTestId('action-button')).toBeInTheDocument()
})
it('should render ImageInput when showUploader is true (default)', () => {
render(<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={[]} />)
expect(document.querySelector('input[type="file"]')).toBeInTheDocument()
})
it('should not render ImageInput when showUploader is false', () => {
render(
<ImageUploaderInRetrievalTestingWrapper
{...defaultProps}
value={[]}
showUploader={false}
/>,
)
expect(document.querySelector('input[type="file"]')).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(
<ImageUploaderInRetrievalTestingWrapper
{...defaultProps}
value={[]}
className="custom-class"
/>,
)
expect(container.firstChild).toHaveClass('custom-class')
})
it('should apply actionAreaClassName', () => {
const { container } = render(
<ImageUploaderInRetrievalTestingWrapper
{...defaultProps}
value={[]}
actionAreaClassName="action-area-class"
/>,
)
// The action area should have the custom class
expect(container.querySelector('.action-area-class')).toBeInTheDocument()
})
it('should render file list when files are provided', () => {
const files: FileEntity[] = [
{
id: 'file1',
name: 'test1.png',
extension: 'png',
mimeType: 'image/png',
progress: 100,
uploadedId: 'uploaded-1',
base64Url: 'data:image/png;base64,test1',
size: 1024,
},
]
render(<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={files} />)
const fileItems = document.querySelectorAll('.group\\/file-image')
expect(fileItems.length).toBeGreaterThanOrEqual(1)
})
it('should not render file list when files are empty', () => {
const { container } = render(
<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={[]} />,
)
// File list container should not be present
expect(container.querySelector('.bg-background-default')).not.toBeInTheDocument()
})
it('should not render file list when showUploader is false', () => {
const files: FileEntity[] = [
{
id: 'file1',
name: 'test1.png',
extension: 'png',
mimeType: 'image/png',
progress: 100,
uploadedId: 'uploaded-1',
base64Url: 'data:image/png;base64,test1',
size: 1024,
},
]
const { container } = render(
<ImageUploaderInRetrievalTestingWrapper
{...defaultProps}
value={files}
showUploader={false}
/>,
)
expect(container.querySelector('.bg-background-default')).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should show preview when image is clicked', () => {
const files: FileEntity[] = [
{
id: 'file1',
name: 'test.png',
extension: 'png',
mimeType: 'image/png',
progress: 100,
uploadedId: 'uploaded-1',
base64Url: 'data:image/png;base64,test',
size: 1024,
},
]
render(<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={files} />)
const fileItem = document.querySelector('.group\\/file-image')
if (fileItem) {
fireEvent.click(fileItem)
expect(screen.getByTestId('image-previewer')).toBeInTheDocument()
}
})
it('should close preview when close button is clicked', () => {
const files: FileEntity[] = [
{
id: 'file1',
name: 'test.png',
extension: 'png',
mimeType: 'image/png',
progress: 100,
uploadedId: 'uploaded-1',
base64Url: 'data:image/png;base64,test',
size: 1024,
},
]
render(<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={files} />)
const fileItem = document.querySelector('.group\\/file-image')
if (fileItem) {
fireEvent.click(fileItem)
const closeButton = screen.getByTestId('close-preview')
fireEvent.click(closeButton)
expect(screen.queryByTestId('image-previewer')).not.toBeInTheDocument()
}
})
})
describe('Layout', () => {
it('should use justify-between when showUploader is true', () => {
const { container } = render(
<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={[]} />,
)
expect(container.querySelector('.justify-between')).toBeInTheDocument()
})
it('should use justify-end when showUploader is false', () => {
const { container } = render(
<ImageUploaderInRetrievalTestingWrapper
{...defaultProps}
value={[]}
showUploader={false}
/>,
)
expect(container.querySelector('.justify-end')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle undefined value', () => {
const { container } = render(
<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={undefined} />,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle multiple files', () => {
const files: FileEntity[] = Array.from({ length: 5 }, (_, i) => ({
id: `file${i}`,
name: `test${i}.png`,
extension: 'png',
mimeType: 'image/png',
progress: 100,
uploadedId: `uploaded-${i}`,
base64Url: `data:image/png;base64,test${i}`,
size: 1024 * (i + 1),
}))
render(<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={files} />)
const fileItems = document.querySelectorAll('.group\\/file-image')
expect(fileItems.length).toBe(5)
})
})
})

View File

@ -0,0 +1,305 @@
import type { FileEntity } from './types'
import { act, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import {
createFileStore,
FileContextProvider,
useFileStore,
useFileStoreWithSelector,
} from './store'
const createMockFile = (id: string): FileEntity => ({
id,
name: `file-${id}.png`,
size: 1024,
extension: 'png',
mimeType: 'image/png',
progress: 0,
})
describe('image-uploader store', () => {
describe('createFileStore', () => {
it('should create store with empty array by default', () => {
const store = createFileStore()
expect(store.getState().files).toEqual([])
})
it('should create store with initial value', () => {
const initialFiles = [createMockFile('1'), createMockFile('2')]
const store = createFileStore(initialFiles)
expect(store.getState().files).toHaveLength(2)
})
it('should create copy of initial value', () => {
const initialFiles = [createMockFile('1')]
const store = createFileStore(initialFiles)
store.getState().files.push(createMockFile('2'))
expect(initialFiles).toHaveLength(1)
})
it('should update files with setFiles', () => {
const store = createFileStore()
const newFiles = [createMockFile('1'), createMockFile('2')]
act(() => {
store.getState().setFiles(newFiles)
})
expect(store.getState().files).toEqual(newFiles)
})
it('should call onChange when setFiles is called', () => {
const onChange = vi.fn()
const store = createFileStore([], onChange)
const newFiles = [createMockFile('1')]
act(() => {
store.getState().setFiles(newFiles)
})
expect(onChange).toHaveBeenCalledWith(newFiles)
})
it('should not throw when onChange is not provided', () => {
const store = createFileStore([])
const newFiles = [createMockFile('1')]
expect(() => {
act(() => {
store.getState().setFiles(newFiles)
})
}).not.toThrow()
})
it('should handle undefined initial value', () => {
const store = createFileStore(undefined)
expect(store.getState().files).toEqual([])
})
it('should handle null-like falsy value with empty array fallback', () => {
// Test the ternary: value ? [...value] : []
const store = createFileStore(null as unknown as FileEntity[])
expect(store.getState().files).toEqual([])
})
it('should handle empty array as initial value', () => {
const store = createFileStore([])
expect(store.getState().files).toEqual([])
})
})
describe('FileContextProvider', () => {
it('should render children', () => {
render(
<FileContextProvider>
<div>Test Child</div>
</FileContextProvider>,
)
expect(screen.getByText('Test Child')).toBeInTheDocument()
})
it('should provide store to children', () => {
const TestComponent = () => {
const store = useFileStore()
// useFileStore returns a store that's truthy by design
return <div data-testid="store-exists">{store !== null ? 'yes' : 'no'}</div>
}
render(
<FileContextProvider>
<TestComponent />
</FileContextProvider>,
)
expect(screen.getByTestId('store-exists')).toHaveTextContent('yes')
})
it('should initialize store with value prop', () => {
const initialFiles = [createMockFile('1')]
const TestComponent = () => {
const store = useFileStore()
return <div data-testid="file-count">{store.getState().files.length}</div>
}
render(
<FileContextProvider value={initialFiles}>
<TestComponent />
</FileContextProvider>,
)
expect(screen.getByTestId('file-count')).toHaveTextContent('1')
})
it('should call onChange when files change', () => {
const onChange = vi.fn()
const newFiles = [createMockFile('1')]
const TestComponent = () => {
const store = useFileStore()
return (
<button onClick={() => store.getState().setFiles(newFiles)}>
Set Files
</button>
)
}
render(
<FileContextProvider onChange={onChange}>
<TestComponent />
</FileContextProvider>,
)
act(() => {
screen.getByRole('button').click()
})
expect(onChange).toHaveBeenCalledWith(newFiles)
})
it('should reuse existing store on re-render (storeRef.current already exists)', () => {
const initialFiles = [createMockFile('1')]
let renderCount = 0
const TestComponent = () => {
const store = useFileStore()
renderCount++
return (
<div>
<span data-testid="file-count">{store.getState().files.length}</span>
<span data-testid="render-count">{renderCount}</span>
</div>
)
}
const { rerender } = render(
<FileContextProvider value={initialFiles}>
<TestComponent />
</FileContextProvider>,
)
expect(screen.getByTestId('file-count')).toHaveTextContent('1')
// Re-render the provider - should reuse the same store
rerender(
<FileContextProvider value={initialFiles}>
<TestComponent />
</FileContextProvider>,
)
// Store should still have the same files (store was reused)
expect(screen.getByTestId('file-count')).toHaveTextContent('1')
expect(renderCount).toBeGreaterThan(1)
})
})
describe('useFileStore', () => {
it('should return store from context', () => {
const TestComponent = () => {
const store = useFileStore()
// useFileStore returns a store that's truthy by design
return <div data-testid="result">{store !== null ? 'has store' : 'no store'}</div>
}
render(
<FileContextProvider>
<TestComponent />
</FileContextProvider>,
)
expect(screen.getByTestId('result')).toHaveTextContent('has store')
})
})
describe('useFileStoreWithSelector', () => {
it('should throw error when used outside provider', () => {
const TestComponent = () => {
try {
useFileStoreWithSelector(state => state.files)
return <div>No Error</div>
}
catch {
return <div>Error</div>
}
}
render(<TestComponent />)
expect(screen.getByText('Error')).toBeInTheDocument()
})
it('should select files from store', () => {
const initialFiles = [createMockFile('1'), createMockFile('2')]
const TestComponent = () => {
const files = useFileStoreWithSelector(state => state.files)
return <div data-testid="files-count">{files.length}</div>
}
render(
<FileContextProvider value={initialFiles}>
<TestComponent />
</FileContextProvider>,
)
expect(screen.getByTestId('files-count')).toHaveTextContent('2')
})
it('should select setFiles function from store', () => {
const onChange = vi.fn()
const TestComponent = () => {
const setFiles = useFileStoreWithSelector(state => state.setFiles)
return (
<button onClick={() => setFiles([createMockFile('new')])}>
Update
</button>
)
}
render(
<FileContextProvider onChange={onChange}>
<TestComponent />
</FileContextProvider>,
)
act(() => {
screen.getByRole('button').click()
})
expect(onChange).toHaveBeenCalled()
})
it('should re-render when selected state changes', () => {
const renderCount = { current: 0 }
const TestComponent = () => {
const files = useFileStoreWithSelector(state => state.files)
const setFiles = useFileStoreWithSelector(state => state.setFiles)
renderCount.current++
return (
<div>
<span data-testid="count">{files.length}</span>
<button onClick={() => setFiles([...files, createMockFile('new')])}>
Add
</button>
</div>
)
}
render(
<FileContextProvider>
<TestComponent />
</FileContextProvider>,
)
expect(screen.getByTestId('count')).toHaveTextContent('0')
act(() => {
screen.getByRole('button').click()
})
expect(screen.getByTestId('count')).toHaveTextContent('1')
})
})
})

View File

@ -0,0 +1,310 @@
import type { FileEntity } from './types'
import type { FileUploadConfigResponse } from '@/models/common'
import { describe, expect, it } from 'vitest'
import {
DEFAULT_IMAGE_FILE_BATCH_LIMIT,
DEFAULT_IMAGE_FILE_SIZE_LIMIT,
DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
} from './constants'
import { fileIsUploaded, getFileType, getFileUploadConfig, traverseFileEntry } from './utils'
describe('image-uploader utils', () => {
describe('getFileType', () => {
it('should return file extension for a simple filename', () => {
const file = { name: 'image.png' } as File
expect(getFileType(file)).toBe('png')
})
it('should return file extension for filename with multiple dots', () => {
const file = { name: 'my.photo.image.jpg' } as File
expect(getFileType(file)).toBe('jpg')
})
it('should return empty string for null/undefined file', () => {
expect(getFileType(null as unknown as File)).toBe('')
expect(getFileType(undefined as unknown as File)).toBe('')
})
it('should return filename for file without extension', () => {
const file = { name: 'README' } as File
expect(getFileType(file)).toBe('README')
})
it('should handle various file extensions', () => {
expect(getFileType({ name: 'doc.pdf' } as File)).toBe('pdf')
expect(getFileType({ name: 'image.jpeg' } as File)).toBe('jpeg')
expect(getFileType({ name: 'video.mp4' } as File)).toBe('mp4')
expect(getFileType({ name: 'archive.tar.gz' } as File)).toBe('gz')
})
})
describe('fileIsUploaded', () => {
it('should return true when uploadedId is set', () => {
const file = { uploadedId: 'some-id', progress: 50 } as Partial<FileEntity>
expect(fileIsUploaded(file as FileEntity)).toBe(true)
})
it('should return true when progress is 100', () => {
const file = { progress: 100 } as Partial<FileEntity>
expect(fileIsUploaded(file as FileEntity)).toBe(true)
})
it('should return undefined when neither uploadedId nor 100 progress', () => {
const file = { progress: 50 } as Partial<FileEntity>
expect(fileIsUploaded(file as FileEntity)).toBeUndefined()
})
it('should return undefined when progress is 0', () => {
const file = { progress: 0 } as Partial<FileEntity>
expect(fileIsUploaded(file as FileEntity)).toBeUndefined()
})
it('should return true when uploadedId is empty string and progress is 100', () => {
const file = { uploadedId: '', progress: 100 } as Partial<FileEntity>
expect(fileIsUploaded(file as FileEntity)).toBe(true)
})
})
describe('getFileUploadConfig', () => {
it('should return default values when response is undefined', () => {
const result = getFileUploadConfig(undefined)
expect(result).toEqual({
imageFileSizeLimit: DEFAULT_IMAGE_FILE_SIZE_LIMIT,
imageFileBatchLimit: DEFAULT_IMAGE_FILE_BATCH_LIMIT,
singleChunkAttachmentLimit: DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
})
})
it('should return values from response when valid', () => {
const response: Partial<FileUploadConfigResponse> = {
image_file_batch_limit: 20,
single_chunk_attachment_limit: 10,
attachment_image_file_size_limit: 5,
}
const result = getFileUploadConfig(response as FileUploadConfigResponse)
expect(result).toEqual({
imageFileSizeLimit: 5,
imageFileBatchLimit: 20,
singleChunkAttachmentLimit: 10,
})
})
it('should use default values when response values are 0', () => {
const response: Partial<FileUploadConfigResponse> = {
image_file_batch_limit: 0,
single_chunk_attachment_limit: 0,
attachment_image_file_size_limit: 0,
}
const result = getFileUploadConfig(response as FileUploadConfigResponse)
expect(result).toEqual({
imageFileSizeLimit: DEFAULT_IMAGE_FILE_SIZE_LIMIT,
imageFileBatchLimit: DEFAULT_IMAGE_FILE_BATCH_LIMIT,
singleChunkAttachmentLimit: DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
})
})
it('should use default values when response values are negative', () => {
const response: Partial<FileUploadConfigResponse> = {
image_file_batch_limit: -5,
single_chunk_attachment_limit: -10,
attachment_image_file_size_limit: -1,
}
const result = getFileUploadConfig(response as FileUploadConfigResponse)
expect(result).toEqual({
imageFileSizeLimit: DEFAULT_IMAGE_FILE_SIZE_LIMIT,
imageFileBatchLimit: DEFAULT_IMAGE_FILE_BATCH_LIMIT,
singleChunkAttachmentLimit: DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
})
})
it('should handle string values in response', () => {
const response = {
image_file_batch_limit: '15',
single_chunk_attachment_limit: '8',
attachment_image_file_size_limit: '3',
} as unknown as FileUploadConfigResponse
const result = getFileUploadConfig(response)
expect(result).toEqual({
imageFileSizeLimit: 3,
imageFileBatchLimit: 15,
singleChunkAttachmentLimit: 8,
})
})
it('should handle null values in response', () => {
const response = {
image_file_batch_limit: null,
single_chunk_attachment_limit: null,
attachment_image_file_size_limit: null,
} as unknown as FileUploadConfigResponse
const result = getFileUploadConfig(response)
expect(result).toEqual({
imageFileSizeLimit: DEFAULT_IMAGE_FILE_SIZE_LIMIT,
imageFileBatchLimit: DEFAULT_IMAGE_FILE_BATCH_LIMIT,
singleChunkAttachmentLimit: DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
})
})
it('should handle undefined values in response', () => {
const response = {
image_file_batch_limit: undefined,
single_chunk_attachment_limit: undefined,
attachment_image_file_size_limit: undefined,
} as unknown as FileUploadConfigResponse
const result = getFileUploadConfig(response)
expect(result).toEqual({
imageFileSizeLimit: DEFAULT_IMAGE_FILE_SIZE_LIMIT,
imageFileBatchLimit: DEFAULT_IMAGE_FILE_BATCH_LIMIT,
singleChunkAttachmentLimit: DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
})
})
it('should handle partial response', () => {
const response: Partial<FileUploadConfigResponse> = {
image_file_batch_limit: 25,
}
const result = getFileUploadConfig(response as FileUploadConfigResponse)
expect(result.imageFileBatchLimit).toBe(25)
expect(result.imageFileSizeLimit).toBe(DEFAULT_IMAGE_FILE_SIZE_LIMIT)
expect(result.singleChunkAttachmentLimit).toBe(DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT)
})
it('should handle non-number non-string values (object, boolean, etc) with default fallback', () => {
// This tests the getNumberValue function's final return 0 case
// When value is neither number nor string (e.g., object, boolean, array)
const response = {
image_file_batch_limit: { invalid: 'object' }, // Object - not number or string
single_chunk_attachment_limit: true, // Boolean - not number or string
attachment_image_file_size_limit: ['array'], // Array - not number or string
} as unknown as FileUploadConfigResponse
const result = getFileUploadConfig(response)
// All should fall back to defaults since getNumberValue returns 0 for these types
expect(result).toEqual({
imageFileSizeLimit: DEFAULT_IMAGE_FILE_SIZE_LIMIT,
imageFileBatchLimit: DEFAULT_IMAGE_FILE_BATCH_LIMIT,
singleChunkAttachmentLimit: DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
})
})
it('should handle NaN string values', () => {
const response = {
image_file_batch_limit: 'not-a-number',
single_chunk_attachment_limit: '',
attachment_image_file_size_limit: 'abc',
} as unknown as FileUploadConfigResponse
const result = getFileUploadConfig(response)
// NaN values should result in defaults (since NaN > 0 is false)
expect(result).toEqual({
imageFileSizeLimit: DEFAULT_IMAGE_FILE_SIZE_LIMIT,
imageFileBatchLimit: DEFAULT_IMAGE_FILE_BATCH_LIMIT,
singleChunkAttachmentLimit: DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
})
})
})
describe('traverseFileEntry', () => {
type MockFile = { name: string, relativePath?: string }
type FileCallback = (file: MockFile) => void
type EntriesCallback = (entries: FileSystemEntry[]) => void
it('should resolve with file array for file entry', async () => {
const mockFile: MockFile = { name: 'test.png' }
const mockEntry = {
isFile: true,
isDirectory: false,
file: (callback: FileCallback) => callback(mockFile),
}
const result = await traverseFileEntry(mockEntry)
expect(result).toHaveLength(1)
expect(result[0].name).toBe('test.png')
expect(result[0].relativePath).toBe('test.png')
})
it('should resolve with file array with prefix for nested file', async () => {
const mockFile: MockFile = { name: 'test.png' }
const mockEntry = {
isFile: true,
isDirectory: false,
file: (callback: FileCallback) => callback(mockFile),
}
const result = await traverseFileEntry(mockEntry, 'folder/')
expect(result).toHaveLength(1)
expect(result[0].relativePath).toBe('folder/test.png')
})
it('should resolve empty array for unknown entry type', async () => {
const mockEntry = {
isFile: false,
isDirectory: false,
}
const result = await traverseFileEntry(mockEntry)
expect(result).toEqual([])
})
it('should handle directory with no files', async () => {
const mockEntry = {
isFile: false,
isDirectory: true,
name: 'empty-folder',
createReader: () => ({
readEntries: (callback: EntriesCallback) => callback([]),
}),
}
const result = await traverseFileEntry(mockEntry)
expect(result).toEqual([])
})
it('should handle directory with files', async () => {
const mockFile1: MockFile = { name: 'file1.png' }
const mockFile2: MockFile = { name: 'file2.png' }
const mockFileEntry1 = {
isFile: true,
isDirectory: false,
file: (callback: FileCallback) => callback(mockFile1),
}
const mockFileEntry2 = {
isFile: true,
isDirectory: false,
file: (callback: FileCallback) => callback(mockFile2),
}
let readCount = 0
const mockEntry = {
isFile: false,
isDirectory: true,
name: 'folder',
createReader: () => ({
readEntries: (callback: EntriesCallback) => {
if (readCount === 0) {
readCount++
callback([mockFileEntry1, mockFileEntry2] as unknown as FileSystemEntry[])
}
else {
callback([])
}
},
}),
}
const result = await traverseFileEntry(mockEntry)
expect(result).toHaveLength(2)
expect(result[0].relativePath).toBe('folder/file1.png')
expect(result[1].relativePath).toBe('folder/file2.png')
})
})
})

View File

@ -0,0 +1,323 @@
import type { RetrievalConfig } from '@/types/app'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { RerankingModeEnum, WeightedScoreEnum } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import RetrievalParamConfig from './index'
// Mock dependencies
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModel: vi.fn(() => ({
modelList: [
{
provider: 'cohere',
models: [{ model: 'rerank-english-v2.0' }],
},
],
})),
useCurrentProviderAndModel: vi.fn(() => ({
currentModel: {
provider: 'cohere',
model: 'rerank-english-v2.0',
},
})),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
type ModelSelectorProps = {
onSelect: (model: { provider: string, model: string }) => void
}
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
default: ({ onSelect }: ModelSelectorProps) => (
<button data-testid="model-selector" onClick={() => onSelect({ provider: 'cohere', model: 'rerank-english-v2.0' })}>
Select Model
</button>
),
}))
type WeightedScoreProps = {
value: { value: number[] }
onChange: (newValue: { value: number[] }) => void
}
vi.mock('@/app/components/app/configuration/dataset-config/params-config/weighted-score', () => ({
default: ({ value, onChange }: WeightedScoreProps) => (
<div data-testid="weighted-score">
<input
data-testid="weight-input"
type="range"
value={value.value[0]}
onChange={e => onChange({ value: [Number(e.target.value), 1 - Number(e.target.value)] })}
/>
</div>
),
}))
const createDefaultConfig = (overrides: Partial<RetrievalConfig> = {}): RetrievalConfig => ({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
reranking_mode: RerankingModeEnum.RerankingModel,
...overrides,
})
describe('RetrievalParamConfig', () => {
const defaultOnChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(
<RetrievalParamConfig
type={RETRIEVE_METHOD.semantic}
value={createDefaultConfig()}
onChange={defaultOnChange}
/>,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render TopKItem', () => {
render(
<RetrievalParamConfig
type={RETRIEVE_METHOD.semantic}
value={createDefaultConfig()}
onChange={defaultOnChange}
/>,
)
// TopKItem contains "Top K" text
expect(screen.getByText(/top.*k/i)).toBeInTheDocument()
})
})
describe('Semantic Search Mode', () => {
it('should show rerank toggle for semantic search', () => {
const { container } = render(
<RetrievalParamConfig
type={RETRIEVE_METHOD.semantic}
value={createDefaultConfig()}
onChange={defaultOnChange}
/>,
)
// Switch component should be present
expect(container.querySelector('[role="switch"]')).toBeInTheDocument()
})
it('should show model selector when reranking is enabled', () => {
render(
<RetrievalParamConfig
type={RETRIEVE_METHOD.semantic}
value={createDefaultConfig({ reranking_enable: true })}
onChange={defaultOnChange}
/>,
)
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
})
it('should not show model selector when reranking is disabled', () => {
render(
<RetrievalParamConfig
type={RETRIEVE_METHOD.semantic}
value={createDefaultConfig({ reranking_enable: false })}
onChange={defaultOnChange}
/>,
)
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
})
})
describe('FullText Search Mode', () => {
it('should show rerank toggle for fullText search', () => {
const { container } = render(
<RetrievalParamConfig
type={RETRIEVE_METHOD.fullText}
value={createDefaultConfig({ search_method: RETRIEVE_METHOD.fullText })}
onChange={defaultOnChange}
/>,
)
expect(container.querySelector('[role="switch"]')).toBeInTheDocument()
})
})
describe('Hybrid Search Mode', () => {
it('should show reranking mode options for hybrid search', () => {
render(
<RetrievalParamConfig
type={RETRIEVE_METHOD.hybrid}
value={createDefaultConfig({
search_method: RETRIEVE_METHOD.hybrid,
reranking_mode: RerankingModeEnum.RerankingModel,
})}
onChange={defaultOnChange}
/>,
)
// Should show weighted score and reranking model options
expect(screen.getAllByText(/weight/i).length).toBeGreaterThan(0)
})
it('should show WeightedScore component when WeightedScore mode is selected', () => {
render(
<RetrievalParamConfig
type={RETRIEVE_METHOD.hybrid}
value={createDefaultConfig({
search_method: RETRIEVE_METHOD.hybrid,
reranking_mode: RerankingModeEnum.WeightedScore,
weights: {
weight_type: WeightedScoreEnum.Customized,
vector_setting: {
vector_weight: 0.7,
embedding_provider_name: '',
embedding_model_name: '',
},
keyword_setting: {
keyword_weight: 0.3,
},
},
})}
onChange={defaultOnChange}
/>,
)
expect(screen.getByTestId('weighted-score')).toBeInTheDocument()
})
it('should show model selector when RerankingModel mode is selected', () => {
render(
<RetrievalParamConfig
type={RETRIEVE_METHOD.hybrid}
value={createDefaultConfig({
search_method: RETRIEVE_METHOD.hybrid,
reranking_mode: RerankingModeEnum.RerankingModel,
})}
onChange={defaultOnChange}
/>,
)
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
})
})
describe('Keyword Search Mode', () => {
it('should not show rerank toggle for keyword search', () => {
const { container } = render(
<RetrievalParamConfig
type={RETRIEVE_METHOD.keywordSearch}
value={createDefaultConfig()}
onChange={defaultOnChange}
/>,
)
// Switch should not be present for economical mode
expect(container.querySelector('[role="switch"]')).not.toBeInTheDocument()
})
it('should still show TopKItem for keyword search', () => {
render(
<RetrievalParamConfig
type={RETRIEVE_METHOD.keywordSearch}
value={createDefaultConfig()}
onChange={defaultOnChange}
/>,
)
expect(screen.getByText(/top.*k/i)).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onChange when model is selected', () => {
render(
<RetrievalParamConfig
type={RETRIEVE_METHOD.semantic}
value={createDefaultConfig({ reranking_enable: true })}
onChange={defaultOnChange}
/>,
)
const modelSelector = screen.getByTestId('model-selector')
fireEvent.click(modelSelector)
expect(defaultOnChange).toHaveBeenCalledWith(expect.objectContaining({
reranking_model: {
reranking_provider_name: 'cohere',
reranking_model_name: 'rerank-english-v2.0',
},
}))
})
})
describe('Multi-Modal Tip', () => {
it('should show multi-modal tip when showMultiModalTip is true and reranking is enabled', () => {
render(
<RetrievalParamConfig
type={RETRIEVE_METHOD.semantic}
value={createDefaultConfig({ reranking_enable: true })}
onChange={defaultOnChange}
showMultiModalTip
/>,
)
// Warning icon should be present
expect(document.querySelector('.text-text-warning-secondary')).toBeInTheDocument()
})
it('should not show multi-modal tip when showMultiModalTip is false', () => {
render(
<RetrievalParamConfig
type={RETRIEVE_METHOD.semantic}
value={createDefaultConfig({ reranking_enable: true })}
onChange={defaultOnChange}
showMultiModalTip={false}
/>,
)
expect(document.querySelector('.text-text-warning-secondary')).not.toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle undefined reranking_model', () => {
const config = createDefaultConfig()
const { container } = render(
<RetrievalParamConfig
type={RETRIEVE_METHOD.semantic}
value={config}
onChange={defaultOnChange}
/>,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle switching from semantic to hybrid search', () => {
const { rerender } = render(
<RetrievalParamConfig
type={RETRIEVE_METHOD.semantic}
value={createDefaultConfig()}
onChange={defaultOnChange}
/>,
)
rerender(
<RetrievalParamConfig
type={RETRIEVE_METHOD.hybrid}
value={createDefaultConfig({
search_method: RETRIEVE_METHOD.hybrid,
reranking_mode: RerankingModeEnum.RerankingModel,
})}
onChange={defaultOnChange}
/>,
)
expect(screen.getAllByText(/weight/i).length).toBeGreaterThan(0)
})
})
})

View File

@ -0,0 +1,154 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DSLConfirmModal from './dsl-confirm-modal'
// ============================================================================
// DSLConfirmModal Component Tests
// ============================================================================
describe('DSLConfirmModal', () => {
const defaultProps = {
onCancel: vi.fn(),
onConfirm: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<DSLConfirmModal {...defaultProps} />)
expect(screen.getByText(/appCreateDSLErrorTitle/i)).toBeInTheDocument()
})
it('should render title', () => {
render(<DSLConfirmModal {...defaultProps} />)
expect(screen.getByText(/appCreateDSLErrorTitle/i)).toBeInTheDocument()
})
it('should render error message parts', () => {
render(<DSLConfirmModal {...defaultProps} />)
expect(screen.getByText(/appCreateDSLErrorPart1/i)).toBeInTheDocument()
expect(screen.getByText(/appCreateDSLErrorPart2/i)).toBeInTheDocument()
expect(screen.getByText(/appCreateDSLErrorPart3/i)).toBeInTheDocument()
expect(screen.getByText(/appCreateDSLErrorPart4/i)).toBeInTheDocument()
})
it('should render cancel button', () => {
render(<DSLConfirmModal {...defaultProps} />)
expect(screen.getByText(/Cancel/i)).toBeInTheDocument()
})
it('should render confirm button', () => {
render(<DSLConfirmModal {...defaultProps} />)
expect(screen.getByText(/Confirm/i)).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Versions Display Tests
// --------------------------------------------------------------------------
describe('Versions Display', () => {
it('should display imported version when provided', () => {
render(
<DSLConfirmModal
{...defaultProps}
versions={{ importedVersion: '1.0.0', systemVersion: '2.0.0' }}
/>,
)
expect(screen.getByText('1.0.0')).toBeInTheDocument()
})
it('should display system version when provided', () => {
render(
<DSLConfirmModal
{...defaultProps}
versions={{ importedVersion: '1.0.0', systemVersion: '2.0.0' }}
/>,
)
expect(screen.getByText('2.0.0')).toBeInTheDocument()
})
it('should use default empty versions when not provided', () => {
render(<DSLConfirmModal {...defaultProps} />)
// Should render without errors
expect(screen.getByText(/appCreateDSLErrorTitle/i)).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onCancel when cancel button is clicked', () => {
render(<DSLConfirmModal {...defaultProps} />)
const cancelButton = screen.getByText(/Cancel/i)
fireEvent.click(cancelButton)
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1)
})
it('should call onConfirm when confirm button is clicked', () => {
render(<DSLConfirmModal {...defaultProps} />)
const confirmButton = screen.getByText(/Confirm/i)
fireEvent.click(confirmButton)
expect(defaultProps.onConfirm).toHaveBeenCalledTimes(1)
})
it('should call onCancel when modal is closed', () => {
render(<DSLConfirmModal {...defaultProps} />)
// Modal close is triggered by clicking backdrop or close button
// The onClose prop is mapped to onCancel
const cancelButton = screen.getByText(/Cancel/i)
fireEvent.click(cancelButton)
expect(defaultProps.onCancel).toHaveBeenCalled()
})
})
// --------------------------------------------------------------------------
// Button State Tests
// --------------------------------------------------------------------------
describe('Button State', () => {
it('should enable confirm button by default', () => {
render(<DSLConfirmModal {...defaultProps} />)
const confirmButton = screen.getByText(/Confirm/i)
expect(confirmButton).not.toBeDisabled()
})
it('should disable confirm button when confirmDisabled is true', () => {
render(<DSLConfirmModal {...defaultProps} confirmDisabled={true} />)
const confirmButton = screen.getByText(/Confirm/i)
expect(confirmButton).toBeDisabled()
})
it('should enable confirm button when confirmDisabled is false', () => {
render(<DSLConfirmModal {...defaultProps} confirmDisabled={false} />)
const confirmButton = screen.getByText(/Confirm/i)
expect(confirmButton).not.toBeDisabled()
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have button container with proper styling', () => {
render(<DSLConfirmModal {...defaultProps} />)
const cancelButton = screen.getByText(/Cancel/i)
const buttonContainer = cancelButton.parentElement
expect(buttonContainer).toHaveClass('flex', 'items-start', 'justify-end', 'gap-2')
})
})
})

View File

@ -0,0 +1,93 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Header from './header'
// ============================================================================
// Header Component Tests
// ============================================================================
describe('Header', () => {
const defaultProps = {
onClose: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Header {...defaultProps} />)
expect(screen.getByText(/importFromDSL/i)).toBeInTheDocument()
})
it('should render title', () => {
render(<Header {...defaultProps} />)
expect(screen.getByText(/importFromDSL/i)).toBeInTheDocument()
})
it('should render close button', () => {
const { container } = render(<Header {...defaultProps} />)
const closeButton = container.querySelector('[class*="cursor-pointer"]')
expect(closeButton).toBeInTheDocument()
})
it('should render close icon', () => {
const { container } = render(<Header {...defaultProps} />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onClose when close button is clicked', () => {
const { container } = render(<Header {...defaultProps} />)
const closeButton = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(closeButton!)
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container styling', () => {
const { container } = render(<Header {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('title-2xl-semi-bold', 'relative', 'flex', 'items-center')
})
it('should have close button positioned absolutely', () => {
const { container } = render(<Header {...defaultProps} />)
const closeButton = container.querySelector('[class*="absolute"]')
expect(closeButton).toHaveClass('right-5', 'top-5')
})
it('should have padding classes', () => {
const { container } = render(<Header {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('pb-3', 'pl-6', 'pr-14', 'pt-6')
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Header {...defaultProps} />)
rerender(<Header {...defaultProps} />)
expect(screen.getByText(/importFromDSL/i)).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,121 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal'
import Tab from './index'
// ============================================================================
// Tab Component Tests
// ============================================================================
describe('Tab', () => {
const defaultProps = {
currentTab: CreateFromDSLModalTab.FROM_FILE,
setCurrentTab: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Tab {...defaultProps} />)
expect(screen.getByText(/importFromDSLFile/i)).toBeInTheDocument()
})
it('should render file tab', () => {
render(<Tab {...defaultProps} />)
expect(screen.getByText(/importFromDSLFile/i)).toBeInTheDocument()
})
it('should render URL tab', () => {
render(<Tab {...defaultProps} />)
expect(screen.getByText(/importFromDSLUrl/i)).toBeInTheDocument()
})
it('should render both tabs', () => {
render(<Tab {...defaultProps} />)
const tabs = screen.getAllByText(/importFromDSL/i)
expect(tabs.length).toBe(2)
})
})
// --------------------------------------------------------------------------
// Active State Tests
// --------------------------------------------------------------------------
describe('Active State', () => {
it('should mark file tab as active when currentTab is FROM_FILE', () => {
const { container } = render(
<Tab {...defaultProps} currentTab={CreateFromDSLModalTab.FROM_FILE} />,
)
const activeIndicators = container.querySelectorAll('[class*="bg-util-colors-blue-brand"]')
expect(activeIndicators.length).toBe(1)
})
it('should mark URL tab as active when currentTab is FROM_URL', () => {
const { container } = render(
<Tab {...defaultProps} currentTab={CreateFromDSLModalTab.FROM_URL} />,
)
const activeIndicators = container.querySelectorAll('[class*="bg-util-colors-blue-brand"]')
expect(activeIndicators.length).toBe(1)
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call setCurrentTab with FROM_FILE when file tab is clicked', () => {
render(<Tab {...defaultProps} currentTab={CreateFromDSLModalTab.FROM_URL} />)
const fileTab = screen.getByText(/importFromDSLFile/i)
fireEvent.click(fileTab)
// bind() prepends the bound argument, so setCurrentTab is called with (FROM_FILE, event)
expect(defaultProps.setCurrentTab).toHaveBeenCalledWith(
CreateFromDSLModalTab.FROM_FILE,
expect.anything(),
)
})
it('should call setCurrentTab with FROM_URL when URL tab is clicked', () => {
render(<Tab {...defaultProps} currentTab={CreateFromDSLModalTab.FROM_FILE} />)
const urlTab = screen.getByText(/importFromDSLUrl/i)
fireEvent.click(urlTab)
// bind() prepends the bound argument, so setCurrentTab is called with (FROM_URL, event)
expect(defaultProps.setCurrentTab).toHaveBeenCalledWith(
CreateFromDSLModalTab.FROM_URL,
expect.anything(),
)
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container styling', () => {
const { container } = render(<Tab {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('system-md-semibold', 'flex', 'h-9', 'items-center', 'gap-x-6')
})
it('should have border bottom', () => {
const { container } = render(<Tab {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('border-b', 'border-divider-subtle')
})
it('should have padding', () => {
const { container } = render(<Tab {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('px-6')
})
})
})

View File

@ -0,0 +1,112 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Item from './item'
// ============================================================================
// Item Component Tests
// ============================================================================
describe('Item', () => {
const defaultProps = {
isActive: false,
label: 'Tab Label',
onClick: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Item {...defaultProps} />)
expect(screen.getByText('Tab Label')).toBeInTheDocument()
})
it('should render label', () => {
render(<Item {...defaultProps} label="Custom Label" />)
expect(screen.getByText('Custom Label')).toBeInTheDocument()
})
it('should not render indicator when inactive', () => {
const { container } = render(<Item {...defaultProps} isActive={false} />)
const indicator = container.querySelector('[class*="bg-util-colors-blue-brand"]')
expect(indicator).not.toBeInTheDocument()
})
it('should render indicator when active', () => {
const { container } = render(<Item {...defaultProps} isActive={true} />)
const indicator = container.querySelector('[class*="bg-util-colors-blue-brand"]')
expect(indicator).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Active State Tests
// --------------------------------------------------------------------------
describe('Active State', () => {
it('should have tertiary text color when inactive', () => {
const { container } = render(<Item {...defaultProps} isActive={false} />)
const item = container.firstChild as HTMLElement
expect(item).toHaveClass('text-text-tertiary')
})
it('should have primary text color when active', () => {
const { container } = render(<Item {...defaultProps} isActive={true} />)
const item = container.firstChild as HTMLElement
expect(item).toHaveClass('text-text-primary')
})
it('should show active indicator bar when active', () => {
const { container } = render(<Item {...defaultProps} isActive={true} />)
const indicator = container.querySelector('[class*="absolute"]')
expect(indicator).toHaveClass('bottom-0', 'h-0.5', 'w-full')
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onClick when clicked', () => {
render(<Item {...defaultProps} />)
const item = screen.getByText('Tab Label')
fireEvent.click(item)
expect(defaultProps.onClick).toHaveBeenCalledTimes(1)
})
it('should have cursor pointer', () => {
const { container } = render(<Item {...defaultProps} />)
const item = container.firstChild as HTMLElement
expect(item).toHaveClass('cursor-pointer')
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container styling', () => {
const { container } = render(<Item {...defaultProps} />)
const item = container.firstChild as HTMLElement
expect(item).toHaveClass('system-md-semibold', 'relative', 'flex', 'h-full', 'items-center')
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Item {...defaultProps} />)
rerender(<Item {...defaultProps} />)
expect(screen.getByText('Tab Label')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,205 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Uploader from './uploader'
// Mock ToastContext
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
ToastContext: {
Provider: ({ children }: { children: React.ReactNode }) => children,
Consumer: ({ children }: { children: (value: { notify: typeof mockNotify }) => React.ReactNode }) => children({ notify: mockNotify }),
},
}))
// Mock use-context-selector
vi.mock('use-context-selector', () => ({
useContext: () => ({ notify: mockNotify }),
}))
// ============================================================================
// Test Data Factories
// ============================================================================
const createMockFile = (name = 'test.pipeline', _size = 1024): File => {
return new File(['test content'], name, { type: 'application/octet-stream' })
}
// ============================================================================
// Uploader Component Tests
// ============================================================================
describe('Uploader', () => {
const defaultProps = {
file: undefined,
updateFile: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests - No File
// --------------------------------------------------------------------------
describe('Rendering - No File', () => {
it('should render without crashing', () => {
render(<Uploader {...defaultProps} />)
expect(screen.getByText(/dslUploader\.button/i)).toBeInTheDocument()
})
it('should render upload prompt when no file', () => {
render(<Uploader {...defaultProps} />)
expect(screen.getByText(/dslUploader\.button/i)).toBeInTheDocument()
})
it('should render browse link when no file', () => {
render(<Uploader {...defaultProps} />)
expect(screen.getByText(/dslUploader\.browse/i)).toBeInTheDocument()
})
it('should render upload icon when no file', () => {
const { container } = render(<Uploader {...defaultProps} />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})
it('should have hidden file input', () => {
render(<Uploader {...defaultProps} />)
const input = document.getElementById('fileUploader') as HTMLInputElement
expect(input).toBeInTheDocument()
expect(input.style.display).toBe('none')
})
it('should accept .pipeline files', () => {
render(<Uploader {...defaultProps} />)
const input = document.getElementById('fileUploader') as HTMLInputElement
expect(input.accept).toBe('.pipeline')
})
})
// --------------------------------------------------------------------------
// Rendering Tests - With File
// --------------------------------------------------------------------------
describe('Rendering - With File', () => {
it('should render file name when file is provided', () => {
const file = createMockFile('my-pipeline.pipeline')
render(<Uploader {...defaultProps} file={file} />)
expect(screen.getByText('my-pipeline.pipeline')).toBeInTheDocument()
})
it('should render PIPELINE label when file is provided', () => {
const file = createMockFile()
render(<Uploader {...defaultProps} file={file} />)
expect(screen.getByText('PIPELINE')).toBeInTheDocument()
})
it('should render delete button when file is provided', () => {
const file = createMockFile()
const { container } = render(<Uploader {...defaultProps} file={file} />)
const deleteButton = container.querySelector('[class*="group-hover:flex"]')
expect(deleteButton).toBeInTheDocument()
})
it('should render node tree icon when file is provided', () => {
const file = createMockFile()
const { container } = render(<Uploader {...defaultProps} file={file} />)
const icons = container.querySelectorAll('svg')
expect(icons.length).toBeGreaterThan(0)
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should open file dialog when browse is clicked', () => {
render(<Uploader {...defaultProps} />)
const input = document.getElementById('fileUploader') as HTMLInputElement
const clickSpy = vi.spyOn(input, 'click')
const browseLink = screen.getByText(/dslUploader\.browse/i)
fireEvent.click(browseLink)
expect(clickSpy).toHaveBeenCalled()
})
it('should call updateFile when file input changes', () => {
render(<Uploader {...defaultProps} />)
const input = document.getElementById('fileUploader') as HTMLInputElement
const file = createMockFile()
Object.defineProperty(input, 'files', {
value: [file],
writable: true,
})
fireEvent.change(input)
expect(defaultProps.updateFile).toHaveBeenCalledWith(file)
})
it('should call updateFile with undefined when delete is clicked', () => {
const file = createMockFile()
const { container } = render(<Uploader {...defaultProps} file={file} />)
const deleteButton = container.querySelector('[class*="group-hover:flex"] button')
if (deleteButton)
fireEvent.click(deleteButton)
expect(defaultProps.updateFile).toHaveBeenCalledWith()
})
})
// --------------------------------------------------------------------------
// Custom className Tests
// --------------------------------------------------------------------------
describe('Custom className', () => {
it('should apply custom className', () => {
const { container } = render(<Uploader {...defaultProps} className="custom-class" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('custom-class')
})
it('should merge custom className with default', () => {
const { container } = render(<Uploader {...defaultProps} className="custom-class" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('mt-6', 'custom-class')
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container styling', () => {
const { container } = render(<Uploader {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('mt-6')
})
it('should have dropzone styling when no file', () => {
const { container } = render(<Uploader {...defaultProps} />)
const dropzone = container.querySelector('[class*="border-dashed"]')
expect(dropzone).toBeInTheDocument()
})
it('should have file card styling when file is provided', () => {
const file = createMockFile()
const { container } = render(<Uploader {...defaultProps} file={file} />)
const fileCard = container.querySelector('[class*="rounded-lg"]')
expect(fileCard).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Uploader {...defaultProps} />)
rerender(<Uploader {...defaultProps} />)
expect(screen.getByText(/dslUploader\.button/i)).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,224 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Footer from './footer'
// Configurable mock for search params
let mockSearchParams = new URLSearchParams()
const mockReplace = vi.fn()
// Mock next/navigation
vi.mock('next/navigation', () => ({
useRouter: () => ({ replace: mockReplace }),
useSearchParams: () => mockSearchParams,
}))
// Mock service hook
const mockInvalidDatasetList = vi.fn()
vi.mock('@/service/knowledge/use-dataset', () => ({
useInvalidDatasetList: () => mockInvalidDatasetList,
}))
// Mock CreateFromDSLModal to capture props
let capturedActiveTab: string | undefined
let capturedDslUrl: string | undefined
vi.mock('./create-options/create-from-dsl-modal', () => ({
default: ({ show, onClose, onSuccess, activeTab, dslUrl }: {
show: boolean
onClose: () => void
onSuccess: () => void
activeTab?: string
dslUrl?: string
}) => {
capturedActiveTab = activeTab
capturedDslUrl = dslUrl
return show
? (
<div data-testid="dsl-modal">
<button data-testid="close-modal" onClick={onClose}>Close</button>
<button data-testid="success-modal" onClick={onSuccess}>Success</button>
</div>
)
: null
},
CreateFromDSLModalTab: {
FROM_URL: 'FROM_URL',
FROM_FILE: 'FROM_FILE',
},
}))
// ============================================================================
// Footer Component Tests
// ============================================================================
describe('Footer', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSearchParams = new URLSearchParams()
capturedActiveTab = undefined
capturedDslUrl = undefined
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Footer />)
expect(screen.getByText(/importDSL/i)).toBeInTheDocument()
})
it('should render import button with icon', () => {
const { container } = render(<Footer />)
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should not show modal initially', () => {
render(<Footer />)
expect(screen.queryByTestId('dsl-modal')).not.toBeInTheDocument()
})
it('should render divider', () => {
const { container } = render(<Footer />)
const divider = container.querySelector('[class*="w-8"]')
expect(divider).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should open modal when import button is clicked', () => {
render(<Footer />)
const importButton = screen.getByText(/importDSL/i)
fireEvent.click(importButton)
expect(screen.getByTestId('dsl-modal')).toBeInTheDocument()
})
it('should close modal when onClose is called', () => {
render(<Footer />)
// Open modal
const importButton = screen.getByText(/importDSL/i)
fireEvent.click(importButton)
expect(screen.getByTestId('dsl-modal')).toBeInTheDocument()
// Close modal
const closeButton = screen.getByTestId('close-modal')
fireEvent.click(closeButton)
expect(screen.queryByTestId('dsl-modal')).not.toBeInTheDocument()
})
it('should call invalidDatasetList on success', () => {
render(<Footer />)
// Open modal
const importButton = screen.getByText(/importDSL/i)
fireEvent.click(importButton)
// Trigger success
const successButton = screen.getByTestId('success-modal')
fireEvent.click(successButton)
expect(mockInvalidDatasetList).toHaveBeenCalled()
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container classes', () => {
const { container } = render(<Footer />)
const footerDiv = container.firstChild as HTMLElement
expect(footerDiv).toHaveClass('absolute', 'bottom-0', 'left-0', 'right-0', 'z-10')
})
it('should have backdrop blur effect', () => {
const { container } = render(<Footer />)
const footerDiv = container.firstChild as HTMLElement
expect(footerDiv).toHaveClass('backdrop-blur-[6px]')
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Footer />)
rerender(<Footer />)
expect(screen.getByText(/importDSL/i)).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// URL Parameter Tests (Branch Coverage)
// --------------------------------------------------------------------------
describe('URL Parameter Handling', () => {
it('should set activeTab to FROM_URL when dslUrl is present', () => {
mockSearchParams = new URLSearchParams('remoteInstallUrl=https://example.com/dsl')
render(<Footer />)
// Open modal to trigger prop capture
const importButton = screen.getByText(/importDSL/i)
fireEvent.click(importButton)
expect(capturedActiveTab).toBe('FROM_URL')
expect(capturedDslUrl).toBe('https://example.com/dsl')
})
it('should set activeTab to undefined when dslUrl is not present', () => {
mockSearchParams = new URLSearchParams()
render(<Footer />)
// Open modal to trigger prop capture
const importButton = screen.getByText(/importDSL/i)
fireEvent.click(importButton)
expect(capturedActiveTab).toBeUndefined()
expect(capturedDslUrl).toBeUndefined()
})
it('should call replace when closing modal with dslUrl present', () => {
mockSearchParams = new URLSearchParams('remoteInstallUrl=https://example.com/dsl')
render(<Footer />)
// Open modal
const importButton = screen.getByText(/importDSL/i)
fireEvent.click(importButton)
expect(screen.getByTestId('dsl-modal')).toBeInTheDocument()
// Close modal
const closeButton = screen.getByTestId('close-modal')
fireEvent.click(closeButton)
expect(mockReplace).toHaveBeenCalledWith('/datasets/create-from-pipeline')
})
it('should not call replace when closing modal without dslUrl', () => {
mockSearchParams = new URLSearchParams()
render(<Footer />)
// Open modal
const importButton = screen.getByText(/importDSL/i)
fireEvent.click(importButton)
// Close modal
const closeButton = screen.getByTestId('close-modal')
fireEvent.click(closeButton)
expect(mockReplace).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,71 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import Header from './header'
// ============================================================================
// Header Component Tests
// ============================================================================
describe('Header', () => {
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Header />)
expect(screen.getByText(/backToKnowledge/i)).toBeInTheDocument()
})
it('should render back button with link to datasets', () => {
render(<Header />)
const link = screen.getByRole('link')
expect(link).toHaveAttribute('href', '/datasets')
})
it('should render arrow icon in button', () => {
const { container } = render(<Header />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})
it('should render button with correct styling', () => {
render(<Header />)
const button = screen.getByRole('button')
expect(button).toHaveClass('rounded-full')
})
it('should have replace attribute on link', () => {
const { container } = render(<Header />)
const link = container.querySelector('a[href="/datasets"]')
expect(link).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container classes', () => {
const { container } = render(<Header />)
const headerDiv = container.firstChild as HTMLElement
expect(headerDiv).toHaveClass('relative', 'flex', 'px-16', 'pb-2', 'pt-5')
})
it('should position link absolutely at bottom left', () => {
const { container } = render(<Header />)
const link = container.querySelector('a')
expect(link).toHaveClass('absolute', 'bottom-0', 'left-5')
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Header />)
rerender(<Header />)
expect(screen.getByText(/backToKnowledge/i)).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,101 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import CreateFromPipeline from './index'
// Mock child components to isolate testing
vi.mock('./header', () => ({
default: () => <div data-testid="mock-header">Header</div>,
}))
vi.mock('./list', () => ({
default: () => <div data-testid="mock-list">List</div>,
}))
vi.mock('./footer', () => ({
default: () => <div data-testid="mock-footer">Footer</div>,
}))
vi.mock('../../base/effect', () => ({
default: ({ className }: { className?: string }) => (
<div data-testid="mock-effect" className={className}>Effect</div>
),
}))
// ============================================================================
// CreateFromPipeline Component Tests
// ============================================================================
describe('CreateFromPipeline', () => {
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<CreateFromPipeline />)
expect(screen.getByTestId('mock-header')).toBeInTheDocument()
})
it('should render Header component', () => {
render(<CreateFromPipeline />)
expect(screen.getByTestId('mock-header')).toBeInTheDocument()
})
it('should render List component', () => {
render(<CreateFromPipeline />)
expect(screen.getByTestId('mock-list')).toBeInTheDocument()
})
it('should render Footer component', () => {
render(<CreateFromPipeline />)
expect(screen.getByTestId('mock-footer')).toBeInTheDocument()
})
it('should render Effect component', () => {
render(<CreateFromPipeline />)
expect(screen.getByTestId('mock-effect')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container classes', () => {
const { container } = render(<CreateFromPipeline />)
const mainDiv = container.firstChild as HTMLElement
expect(mainDiv).toHaveClass('relative', 'flex', 'flex-col', 'overflow-hidden', 'rounded-t-2xl')
})
it('should have correct height calculation', () => {
const { container } = render(<CreateFromPipeline />)
const mainDiv = container.firstChild as HTMLElement
expect(mainDiv).toHaveClass('h-[calc(100vh-56px)]')
})
it('should have border and background styling', () => {
const { container } = render(<CreateFromPipeline />)
const mainDiv = container.firstChild as HTMLElement
expect(mainDiv).toHaveClass('border-t', 'border-effects-highlight', 'bg-background-default-subtle')
})
it('should position Effect component correctly', () => {
render(<CreateFromPipeline />)
const effect = screen.getByTestId('mock-effect')
expect(effect).toHaveClass('left-8', 'top-[-34px]', 'opacity-20')
})
})
// --------------------------------------------------------------------------
// Component Order Tests
// --------------------------------------------------------------------------
describe('Component Order', () => {
it('should render components in correct order', () => {
const { container } = render(<CreateFromPipeline />)
const children = Array.from(container.firstChild?.childNodes || [])
// Effect, Header, List, Footer
expect(children.length).toBe(4)
})
})
})

View File

@ -0,0 +1,276 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import BuiltInPipelineList from './built-in-pipeline-list'
// Mock child components
vi.mock('./create-card', () => ({
default: () => <div data-testid="create-card">CreateCard</div>,
}))
vi.mock('./template-card', () => ({
default: ({ type, pipeline, showMoreOperations }: { type: string, pipeline: { name: string }, showMoreOperations?: boolean }) => (
<div data-testid="template-card" data-type={type} data-show-more={String(showMoreOperations)}>
{pipeline.name}
</div>
),
}))
// Configurable locale mock
let mockLocale = 'en-US'
// Mock hooks
vi.mock('@/context/i18n', () => ({
useLocale: () => mockLocale,
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn((selector) => {
const state = { systemFeatures: { enable_marketplace: true } }
return selector(state)
}),
}))
const mockUsePipelineTemplateList = vi.fn()
vi.mock('@/service/use-pipeline', () => ({
usePipelineTemplateList: (...args: unknown[]) => mockUsePipelineTemplateList(...args),
}))
// ============================================================================
// BuiltInPipelineList Component Tests
// ============================================================================
describe('BuiltInPipelineList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockLocale = 'en-US'
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: { pipeline_templates: [] },
isLoading: false,
})
render(<BuiltInPipelineList />)
expect(screen.getByTestId('create-card')).toBeInTheDocument()
})
it('should always render CreateCard', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: null,
isLoading: true,
})
render(<BuiltInPipelineList />)
expect(screen.getByTestId('create-card')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Loading State Tests
// --------------------------------------------------------------------------
describe('Loading State', () => {
it('should not render TemplateCards when loading', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: {
pipeline_templates: [{ name: 'Pipeline 1' }],
},
isLoading: true,
})
render(<BuiltInPipelineList />)
expect(screen.queryByTestId('template-card')).not.toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Rendering with Data Tests
// --------------------------------------------------------------------------
describe('Rendering with Data', () => {
it('should render TemplateCard for each pipeline when not loading', () => {
const mockPipelines = [
{ name: 'Pipeline 1' },
{ name: 'Pipeline 2' },
]
mockUsePipelineTemplateList.mockReturnValue({
data: { pipeline_templates: mockPipelines },
isLoading: false,
})
render(<BuiltInPipelineList />)
const cards = screen.getAllByTestId('template-card')
expect(cards).toHaveLength(2)
})
it('should pass correct props to TemplateCard', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: {
pipeline_templates: [{ name: 'Test Pipeline' }],
},
isLoading: false,
})
render(<BuiltInPipelineList />)
const card = screen.getByTestId('template-card')
expect(card).toHaveAttribute('data-type', 'built-in')
expect(card).toHaveAttribute('data-show-more', 'false')
})
it('should render CreateCard as first element', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: {
pipeline_templates: [{ name: 'Pipeline 1' }],
},
isLoading: false,
})
const { container } = render(<BuiltInPipelineList />)
const grid = container.querySelector('.grid')
const firstChild = grid?.firstChild as HTMLElement
expect(firstChild).toHaveAttribute('data-testid', 'create-card')
})
})
// --------------------------------------------------------------------------
// API Call Tests
// --------------------------------------------------------------------------
describe('API Call', () => {
it('should call usePipelineTemplateList with type built-in', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: null,
isLoading: true,
})
render(<BuiltInPipelineList />)
expect(mockUsePipelineTemplateList).toHaveBeenCalledWith(
expect.objectContaining({ type: 'built-in' }),
expect.any(Boolean),
)
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have grid layout', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: { pipeline_templates: [] },
isLoading: false,
})
const { container } = render(<BuiltInPipelineList />)
const grid = container.querySelector('.grid')
expect(grid).toHaveClass('grid-cols-1', 'gap-3', 'py-2')
})
it('should have responsive grid columns', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: { pipeline_templates: [] },
isLoading: false,
})
const { container } = render(<BuiltInPipelineList />)
const grid = container.querySelector('.grid')
expect(grid).toHaveClass('sm:grid-cols-2', 'md:grid-cols-3', 'lg:grid-cols-4')
})
})
// --------------------------------------------------------------------------
// Locale Handling Tests (Branch Coverage)
// --------------------------------------------------------------------------
describe('Locale Handling', () => {
it('should use zh-Hans locale when set', () => {
mockLocale = 'zh-Hans'
mockUsePipelineTemplateList.mockReturnValue({
data: { pipeline_templates: [] },
isLoading: false,
})
render(<BuiltInPipelineList />)
expect(mockUsePipelineTemplateList).toHaveBeenCalledWith(
expect.objectContaining({ language: 'zh-Hans' }),
expect.any(Boolean),
)
})
it('should use ja-JP locale when set', () => {
mockLocale = 'ja-JP'
mockUsePipelineTemplateList.mockReturnValue({
data: { pipeline_templates: [] },
isLoading: false,
})
render(<BuiltInPipelineList />)
expect(mockUsePipelineTemplateList).toHaveBeenCalledWith(
expect.objectContaining({ language: 'ja-JP' }),
expect.any(Boolean),
)
})
it('should fallback to default language for unsupported locales', () => {
mockLocale = 'fr-FR'
mockUsePipelineTemplateList.mockReturnValue({
data: { pipeline_templates: [] },
isLoading: false,
})
render(<BuiltInPipelineList />)
// Should fall back to LanguagesSupported[0] which is 'en-US'
expect(mockUsePipelineTemplateList).toHaveBeenCalledWith(
expect.objectContaining({ language: 'en-US' }),
expect.any(Boolean),
)
})
it('should fallback to default language for en-US locale', () => {
mockLocale = 'en-US'
mockUsePipelineTemplateList.mockReturnValue({
data: { pipeline_templates: [] },
isLoading: false,
})
render(<BuiltInPipelineList />)
expect(mockUsePipelineTemplateList).toHaveBeenCalledWith(
expect.objectContaining({ language: 'en-US' }),
expect.any(Boolean),
)
})
})
// --------------------------------------------------------------------------
// Empty Data Tests
// --------------------------------------------------------------------------
describe('Empty Data', () => {
it('should handle null pipeline_templates', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: { pipeline_templates: null },
isLoading: false,
})
render(<BuiltInPipelineList />)
expect(screen.getByTestId('create-card')).toBeInTheDocument()
expect(screen.queryByTestId('template-card')).not.toBeInTheDocument()
})
it('should handle undefined data', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: undefined,
isLoading: false,
})
render(<BuiltInPipelineList />)
expect(screen.getByTestId('create-card')).toBeInTheDocument()
expect(screen.queryByTestId('template-card')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,190 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CreateCard from './create-card'
// Mock next/navigation
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}))
// Mock amplitude tracking
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
// Mock Toast
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
// Mock service hooks
const mockCreateEmptyDataset = vi.fn()
const mockInvalidDatasetList = vi.fn()
vi.mock('@/service/knowledge/use-create-dataset', () => ({
useCreatePipelineDataset: () => ({
mutateAsync: mockCreateEmptyDataset,
}),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useInvalidDatasetList: () => mockInvalidDatasetList,
}))
// ============================================================================
// CreateCard Component Tests
// ============================================================================
describe('CreateCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<CreateCard />)
expect(screen.getByText(/createFromScratch\.title/i)).toBeInTheDocument()
})
it('should render title and description', () => {
render(<CreateCard />)
expect(screen.getByText(/createFromScratch\.title/i)).toBeInTheDocument()
expect(screen.getByText(/createFromScratch\.description/i)).toBeInTheDocument()
})
it('should render add icon', () => {
const { container } = render(<CreateCard />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call createEmptyDataset when clicked', async () => {
mockCreateEmptyDataset.mockImplementation((_data, callbacks) => {
callbacks.onSuccess({ id: 'new-dataset-id' })
return Promise.resolve()
})
render(<CreateCard />)
const card = screen.getByText(/createFromScratch\.title/i).closest('div[class*="cursor-pointer"]')
fireEvent.click(card!)
await waitFor(() => {
expect(mockCreateEmptyDataset).toHaveBeenCalled()
})
})
it('should navigate to pipeline page on success', async () => {
mockCreateEmptyDataset.mockImplementation((_data, callbacks) => {
callbacks.onSuccess({ id: 'test-dataset-123' })
return Promise.resolve()
})
render(<CreateCard />)
const card = screen.getByText(/createFromScratch\.title/i).closest('div[class*="cursor-pointer"]')
fireEvent.click(card!)
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/datasets/test-dataset-123/pipeline')
})
})
it('should invalidate dataset list on success', async () => {
mockCreateEmptyDataset.mockImplementation((_data, callbacks) => {
callbacks.onSuccess({ id: 'test-id' })
return Promise.resolve()
})
render(<CreateCard />)
const card = screen.getByText(/createFromScratch\.title/i).closest('div[class*="cursor-pointer"]')
fireEvent.click(card!)
await waitFor(() => {
expect(mockInvalidDatasetList).toHaveBeenCalled()
})
})
it('should handle error callback', async () => {
mockCreateEmptyDataset.mockImplementation((_data, callbacks) => {
callbacks.onError(new Error('Create failed'))
return Promise.resolve()
})
render(<CreateCard />)
const card = screen.getByText(/createFromScratch\.title/i).closest('div[class*="cursor-pointer"]')
fireEvent.click(card!)
// Should not throw and should handle error gracefully
await waitFor(() => {
expect(mockCreateEmptyDataset).toHaveBeenCalled()
})
})
it('should not navigate when data is undefined', async () => {
mockCreateEmptyDataset.mockImplementation((_data, callbacks) => {
callbacks.onSuccess(undefined)
return Promise.resolve()
})
render(<CreateCard />)
const card = screen.getByText(/createFromScratch\.title/i).closest('div[class*="cursor-pointer"]')
fireEvent.click(card!)
await waitFor(() => {
expect(mockCreateEmptyDataset).toHaveBeenCalled()
})
expect(mockPush).not.toHaveBeenCalled()
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper card styling', () => {
const { container } = render(<CreateCard />)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('relative', 'flex', 'cursor-pointer', 'flex-col', 'rounded-xl')
})
it('should have fixed height', () => {
const { container } = render(<CreateCard />)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('h-[132px]')
})
it('should have shadow and border', () => {
const { container } = render(<CreateCard />)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('border-[0.5px]', 'shadow-xs')
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<CreateCard />)
rerender(<CreateCard />)
expect(screen.getByText(/createFromScratch\.title/i)).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,151 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CustomizedList from './customized-list'
// Mock TemplateCard
vi.mock('./template-card', () => ({
default: ({ type, pipeline }: { type: string, pipeline: { name: string } }) => (
<div data-testid="template-card" data-type={type}>
{pipeline.name}
</div>
),
}))
// Mock usePipelineTemplateList hook
const mockUsePipelineTemplateList = vi.fn()
vi.mock('@/service/use-pipeline', () => ({
usePipelineTemplateList: (...args: unknown[]) => mockUsePipelineTemplateList(...args),
}))
// ============================================================================
// CustomizedList Component Tests
// ============================================================================
describe('CustomizedList', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Loading State Tests
// --------------------------------------------------------------------------
describe('Loading State', () => {
it('should return null when loading', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: null,
isLoading: true,
})
const { container } = render(<CustomizedList />)
expect(container.firstChild).toBeNull()
})
})
// --------------------------------------------------------------------------
// Empty State Tests
// --------------------------------------------------------------------------
describe('Empty State', () => {
it('should return null when list is empty', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: { pipeline_templates: [] },
isLoading: false,
})
const { container } = render(<CustomizedList />)
expect(container.firstChild).toBeNull()
})
it('should return null when data is undefined', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: undefined,
isLoading: false,
})
const { container } = render(<CustomizedList />)
expect(container.firstChild).toBeNull()
})
})
// --------------------------------------------------------------------------
// Rendering with Data Tests
// --------------------------------------------------------------------------
describe('Rendering with Data', () => {
it('should render title when list has items', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: {
pipeline_templates: [
{ name: 'Pipeline 1' },
],
},
isLoading: false,
})
render(<CustomizedList />)
expect(screen.getByText(/customized/i)).toBeInTheDocument()
})
it('should render TemplateCard for each pipeline', () => {
const mockPipelines = [
{ name: 'Pipeline 1' },
{ name: 'Pipeline 2' },
{ name: 'Pipeline 3' },
]
mockUsePipelineTemplateList.mockReturnValue({
data: { pipeline_templates: mockPipelines },
isLoading: false,
})
render(<CustomizedList />)
const cards = screen.getAllByTestId('template-card')
expect(cards).toHaveLength(3)
})
it('should pass correct props to TemplateCard', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: {
pipeline_templates: [{ name: 'Test Pipeline' }],
},
isLoading: false,
})
render(<CustomizedList />)
const card = screen.getByTestId('template-card')
expect(card).toHaveAttribute('data-type', 'customized')
expect(card).toHaveTextContent('Test Pipeline')
})
})
// --------------------------------------------------------------------------
// API Call Tests
// --------------------------------------------------------------------------
describe('API Call', () => {
it('should call usePipelineTemplateList with type customized', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: null,
isLoading: true,
})
render(<CustomizedList />)
expect(mockUsePipelineTemplateList).toHaveBeenCalledWith({ type: 'customized' })
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have grid layout for cards', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: {
pipeline_templates: [{ name: 'Pipeline 1' }],
},
isLoading: false,
})
const { container } = render(<CustomizedList />)
const grid = container.querySelector('.grid')
expect(grid).toHaveClass('grid-cols-1', 'gap-3', 'py-2')
})
})
})

View File

@ -0,0 +1,70 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import List from './index'
// Mock child components
vi.mock('./built-in-pipeline-list', () => ({
default: () => <div data-testid="built-in-list">BuiltInPipelineList</div>,
}))
vi.mock('./customized-list', () => ({
default: () => <div data-testid="customized-list">CustomizedList</div>,
}))
// ============================================================================
// List Component Tests
// ============================================================================
describe('List', () => {
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<List />)
expect(screen.getByTestId('built-in-list')).toBeInTheDocument()
})
it('should render BuiltInPipelineList component', () => {
render(<List />)
expect(screen.getByTestId('built-in-list')).toBeInTheDocument()
})
it('should render CustomizedList component', () => {
render(<List />)
expect(screen.getByTestId('customized-list')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container classes', () => {
const { container } = render(<List />)
const listDiv = container.firstChild as HTMLElement
expect(listDiv).toHaveClass('grow', 'overflow-y-auto', 'px-16', 'pb-[60px]', 'pt-1')
})
it('should have gap between items', () => {
const { container } = render(<List />)
const listDiv = container.firstChild as HTMLElement
expect(listDiv).toHaveClass('gap-y-1')
})
})
// --------------------------------------------------------------------------
// Component Order Tests
// --------------------------------------------------------------------------
describe('Component Order', () => {
it('should render BuiltInPipelineList before CustomizedList', () => {
const { container } = render(<List />)
const children = Array.from(container.firstChild?.childNodes || [])
expect(children.length).toBe(2)
expect((children[0] as HTMLElement).getAttribute('data-testid')).toBe('built-in-list')
expect((children[1] as HTMLElement).getAttribute('data-testid')).toBe('customized-list')
})
})
})

View File

@ -0,0 +1,154 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Actions from './actions'
// ============================================================================
// Actions Component Tests
// ============================================================================
describe('Actions', () => {
const defaultProps = {
onApplyTemplate: vi.fn(),
handleShowTemplateDetails: vi.fn(),
showMoreOperations: true,
openEditModal: vi.fn(),
handleExportDSL: vi.fn(),
handleDelete: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Actions {...defaultProps} />)
expect(screen.getByText(/operations\.choose/i)).toBeInTheDocument()
})
it('should render choose button', () => {
render(<Actions {...defaultProps} />)
expect(screen.getByText(/operations\.choose/i)).toBeInTheDocument()
})
it('should render details button', () => {
render(<Actions {...defaultProps} />)
expect(screen.getByText(/operations\.details/i)).toBeInTheDocument()
})
it('should render add icon', () => {
const { container } = render(<Actions {...defaultProps} />)
const icons = container.querySelectorAll('svg')
expect(icons.length).toBeGreaterThan(0)
})
it('should render arrow icon for details', () => {
const { container } = render(<Actions {...defaultProps} />)
const icons = container.querySelectorAll('svg')
expect(icons.length).toBeGreaterThan(1)
})
})
// --------------------------------------------------------------------------
// More Operations Tests
// --------------------------------------------------------------------------
describe('More Operations', () => {
it('should render more operations button when showMoreOperations is true', () => {
const { container } = render(<Actions {...defaultProps} showMoreOperations={true} />)
// CustomPopover should be rendered with more button
const moreButton = container.querySelector('[class*="rounded-lg"]')
expect(moreButton).toBeInTheDocument()
})
it('should not render more operations button when showMoreOperations is false', () => {
render(<Actions {...defaultProps} showMoreOperations={false} />)
// Should only have choose and details buttons
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(2)
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onApplyTemplate when choose button is clicked', () => {
render(<Actions {...defaultProps} />)
const chooseButton = screen.getByText(/operations\.choose/i).closest('button')
fireEvent.click(chooseButton!)
expect(defaultProps.onApplyTemplate).toHaveBeenCalledTimes(1)
})
it('should call handleShowTemplateDetails when details button is clicked', () => {
render(<Actions {...defaultProps} />)
const detailsButton = screen.getByText(/operations\.details/i).closest('button')
fireEvent.click(detailsButton!)
expect(defaultProps.handleShowTemplateDetails).toHaveBeenCalledTimes(1)
})
})
// --------------------------------------------------------------------------
// Button Variants Tests
// --------------------------------------------------------------------------
describe('Button Variants', () => {
it('should have primary variant for choose button', () => {
render(<Actions {...defaultProps} />)
const chooseButton = screen.getByText(/operations\.choose/i).closest('button')
expect(chooseButton).toHaveClass('btn-primary')
})
it('should have secondary variant for details button', () => {
render(<Actions {...defaultProps} />)
const detailsButton = screen.getByText(/operations\.details/i).closest('button')
expect(detailsButton).toHaveClass('btn-secondary')
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have absolute positioning', () => {
const { container } = render(<Actions {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('absolute', 'bottom-0', 'left-0')
})
it('should be hidden by default', () => {
const { container } = render(<Actions {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('hidden')
})
it('should show on group hover', () => {
const { container } = render(<Actions {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('group-hover:flex')
})
it('should have proper z-index', () => {
const { container } = render(<Actions {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('z-10')
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Actions {...defaultProps} />)
rerender(<Actions {...defaultProps} />)
expect(screen.getByText(/operations\.choose/i)).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,199 @@
import type { IconInfo } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import Content from './content'
// ============================================================================
// Test Data Factories
// ============================================================================
const createIconInfo = (overrides: Partial<IconInfo> = {}): IconInfo => ({
icon_type: 'emoji',
icon: '📊',
icon_background: '#FFF4ED',
icon_url: '',
...overrides,
})
const createImageIconInfo = (overrides: Partial<IconInfo> = {}): IconInfo => ({
icon_type: 'image',
icon: 'file-id-123',
icon_background: '',
icon_url: 'https://example.com/icon.png',
...overrides,
})
// ============================================================================
// Content Component Tests
// ============================================================================
describe('Content', () => {
const defaultProps = {
name: 'Test Pipeline',
description: 'This is a test pipeline description',
iconInfo: createIconInfo(),
chunkStructure: 'text' as ChunkingMode,
}
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Content {...defaultProps} />)
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
})
it('should render name', () => {
render(<Content {...defaultProps} />)
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
})
it('should render description', () => {
render(<Content {...defaultProps} />)
expect(screen.getByText('This is a test pipeline description')).toBeInTheDocument()
})
it('should render chunking mode text', () => {
render(<Content {...defaultProps} />)
// The translation key should be rendered
expect(screen.getByText(/chunkingMode/i)).toBeInTheDocument()
})
it('should have title attribute for truncation', () => {
render(<Content {...defaultProps} />)
const nameElement = screen.getByText('Test Pipeline')
expect(nameElement).toHaveAttribute('title', 'Test Pipeline')
})
it('should have title attribute on description', () => {
render(<Content {...defaultProps} />)
const descElement = screen.getByText('This is a test pipeline description')
expect(descElement).toHaveAttribute('title', 'This is a test pipeline description')
})
})
// --------------------------------------------------------------------------
// Icon Rendering Tests
// --------------------------------------------------------------------------
describe('Icon Rendering', () => {
it('should render emoji icon correctly', () => {
const { container } = render(<Content {...defaultProps} />)
// AppIcon component should be rendered
const iconContainer = container.querySelector('[class*="shrink-0"]')
expect(iconContainer).toBeInTheDocument()
})
it('should render image icon correctly', () => {
const props = {
...defaultProps,
iconInfo: createImageIconInfo(),
}
const { container } = render(<Content {...props} />)
const iconContainer = container.querySelector('[class*="shrink-0"]')
expect(iconContainer).toBeInTheDocument()
})
it('should render chunk structure icon', () => {
const { container } = render(<Content {...defaultProps} />)
// Icon should be rendered in the corner
const icons = container.querySelectorAll('svg')
expect(icons.length).toBeGreaterThan(0)
})
})
// --------------------------------------------------------------------------
// Chunk Structure Tests
// --------------------------------------------------------------------------
describe('Chunk Structure', () => {
it('should handle text chunk structure', () => {
render(<Content {...defaultProps} chunkStructure={ChunkingMode.text} />)
expect(screen.getByText(/chunkingMode/i)).toBeInTheDocument()
})
it('should handle parent-child chunk structure', () => {
render(<Content {...defaultProps} chunkStructure={ChunkingMode.parentChild} />)
expect(screen.getByText(/chunkingMode/i)).toBeInTheDocument()
})
it('should handle qa chunk structure', () => {
render(<Content {...defaultProps} chunkStructure={ChunkingMode.qa} />)
expect(screen.getByText(/chunkingMode/i)).toBeInTheDocument()
})
it('should fallback to General icon for unknown chunk structure', () => {
const { container } = render(
<Content {...defaultProps} chunkStructure={'unknown' as ChunkingMode} />,
)
const icons = container.querySelectorAll('svg')
expect(icons.length).toBeGreaterThan(0)
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper header layout', () => {
const { container } = render(<Content {...defaultProps} />)
const header = container.querySelector('[class*="gap-x-3"]')
expect(header).toBeInTheDocument()
})
it('should have truncate class on name', () => {
render(<Content {...defaultProps} />)
const nameElement = screen.getByText('Test Pipeline')
expect(nameElement).toHaveClass('truncate')
})
it('should have line-clamp on description', () => {
render(<Content {...defaultProps} />)
const descElement = screen.getByText('This is a test pipeline description')
expect(descElement).toHaveClass('line-clamp-3')
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle empty name', () => {
render(<Content {...defaultProps} name="" />)
const { container } = render(<Content {...defaultProps} name="" />)
expect(container).toBeInTheDocument()
})
it('should handle empty description', () => {
render(<Content {...defaultProps} description="" />)
const { container } = render(<Content {...defaultProps} description="" />)
expect(container).toBeInTheDocument()
})
it('should handle long name', () => {
const longName = 'A'.repeat(100)
render(<Content {...defaultProps} name={longName} />)
const nameElement = screen.getByText(longName)
expect(nameElement).toHaveClass('truncate')
})
it('should handle long description', () => {
const longDesc = 'A'.repeat(500)
render(<Content {...defaultProps} description={longDesc} />)
const descElement = screen.getByText(longDesc)
expect(descElement).toHaveClass('line-clamp-3')
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Content {...defaultProps} />)
rerender(<Content {...defaultProps} />)
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,182 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import ChunkStructureCard from './chunk-structure-card'
import { EffectColor } from './types'
// ============================================================================
// ChunkStructureCard Component Tests
// ============================================================================
describe('ChunkStructureCard', () => {
const defaultProps = {
icon: <span data-testid="test-icon">Icon</span>,
title: 'General',
description: 'General chunk structure description',
effectColor: EffectColor.indigo,
}
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<ChunkStructureCard {...defaultProps} />)
expect(screen.getByText('General')).toBeInTheDocument()
})
it('should render title', () => {
render(<ChunkStructureCard {...defaultProps} />)
expect(screen.getByText('General')).toBeInTheDocument()
})
it('should render description', () => {
render(<ChunkStructureCard {...defaultProps} />)
expect(screen.getByText('General chunk structure description')).toBeInTheDocument()
})
it('should render icon', () => {
render(<ChunkStructureCard {...defaultProps} />)
expect(screen.getByTestId('test-icon')).toBeInTheDocument()
})
it('should not render description when empty', () => {
render(<ChunkStructureCard {...defaultProps} description="" />)
expect(screen.getByText('General')).toBeInTheDocument()
expect(screen.queryByText('General chunk structure description')).not.toBeInTheDocument()
})
it('should not render description when undefined', () => {
const { description: _, ...propsWithoutDesc } = defaultProps
render(<ChunkStructureCard {...propsWithoutDesc} />)
expect(screen.getByText('General')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Effect Colors Tests
// --------------------------------------------------------------------------
describe('Effect Colors', () => {
it('should apply indigo effect color', () => {
const { container } = render(
<ChunkStructureCard {...defaultProps} effectColor={EffectColor.indigo} />,
)
const effectElement = container.querySelector('[class*="blur-"]')
expect(effectElement).toHaveClass('bg-util-colors-indigo-indigo-600')
})
it('should apply blueLight effect color', () => {
const { container } = render(
<ChunkStructureCard {...defaultProps} effectColor={EffectColor.blueLight} />,
)
const effectElement = container.querySelector('[class*="blur-"]')
expect(effectElement).toHaveClass('bg-util-colors-blue-light-blue-light-500')
})
it('should apply green effect color', () => {
const { container } = render(
<ChunkStructureCard {...defaultProps} effectColor={EffectColor.green} />,
)
const effectElement = container.querySelector('[class*="blur-"]')
expect(effectElement).toHaveClass('bg-util-colors-teal-teal-600')
})
it('should handle none effect color', () => {
const { container } = render(
<ChunkStructureCard {...defaultProps} effectColor={EffectColor.none} />,
)
const effectElement = container.querySelector('[class*="blur-"]')
expect(effectElement).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Icon Background Tests
// --------------------------------------------------------------------------
describe('Icon Background', () => {
it('should apply indigo icon background', () => {
const { container } = render(
<ChunkStructureCard {...defaultProps} effectColor={EffectColor.indigo} />,
)
const iconBg = container.querySelector('[class*="bg-components-icon-bg"]')
expect(iconBg).toHaveClass('bg-components-icon-bg-indigo-solid')
})
it('should apply blue light icon background', () => {
const { container } = render(
<ChunkStructureCard {...defaultProps} effectColor={EffectColor.blueLight} />,
)
const iconBg = container.querySelector('[class*="bg-components-icon-bg"]')
expect(iconBg).toHaveClass('bg-components-icon-bg-blue-light-solid')
})
it('should apply green icon background', () => {
const { container } = render(
<ChunkStructureCard {...defaultProps} effectColor={EffectColor.green} />,
)
const iconBg = container.querySelector('[class*="bg-components-icon-bg"]')
expect(iconBg).toHaveClass('bg-components-icon-bg-teal-solid')
})
})
// --------------------------------------------------------------------------
// Custom className Tests
// --------------------------------------------------------------------------
describe('Custom className', () => {
it('should apply custom className', () => {
const { container } = render(
<ChunkStructureCard {...defaultProps} className="custom-class" />,
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('custom-class')
})
it('should merge custom className with default classes', () => {
const { container } = render(
<ChunkStructureCard {...defaultProps} className="custom-class" />,
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('relative', 'flex', 'custom-class')
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper card styling', () => {
const { container } = render(<ChunkStructureCard {...defaultProps} />)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('relative', 'flex', 'overflow-hidden', 'rounded-xl')
})
it('should have border styling', () => {
const { container } = render(<ChunkStructureCard {...defaultProps} />)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('border-[0.5px]', 'border-components-panel-border-subtle')
})
it('should have shadow styling', () => {
const { container } = render(<ChunkStructureCard {...defaultProps} />)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('shadow-xs')
})
it('should have blur effect element', () => {
const { container } = render(<ChunkStructureCard {...defaultProps} />)
const blurElement = container.querySelector('[class*="blur-"]')
expect(blurElement).toHaveClass('absolute', '-left-1', '-top-1', 'size-14', 'rounded-full')
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<ChunkStructureCard {...defaultProps} />)
rerender(<ChunkStructureCard {...defaultProps} />)
expect(screen.getByText('General')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,138 @@
import { renderHook } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import { useChunkStructureConfig } from './hooks'
import { EffectColor } from './types'
// ============================================================================
// useChunkStructureConfig Hook Tests
// ============================================================================
describe('useChunkStructureConfig', () => {
// --------------------------------------------------------------------------
// Return Value Tests
// --------------------------------------------------------------------------
describe('Return Value', () => {
it('should return config object', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current).toBeDefined()
expect(typeof result.current).toBe('object')
})
it('should have config for text chunking mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.text]).toBeDefined()
})
it('should have config for parent-child chunking mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.parentChild]).toBeDefined()
})
it('should have config for qa chunking mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.qa]).toBeDefined()
})
})
// --------------------------------------------------------------------------
// Text/General Config Tests
// --------------------------------------------------------------------------
describe('Text/General Config', () => {
it('should have title for text mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.text].title).toBe('General')
})
it('should have description for text mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.text].description).toBeDefined()
})
it('should have icon for text mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.text].icon).toBeDefined()
})
it('should have indigo effect color for text mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.text].effectColor).toBe(EffectColor.indigo)
})
})
// --------------------------------------------------------------------------
// Parent-Child Config Tests
// --------------------------------------------------------------------------
describe('Parent-Child Config', () => {
it('should have title for parent-child mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.parentChild].title).toBe('Parent-Child')
})
it('should have description for parent-child mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.parentChild].description).toBeDefined()
})
it('should have icon for parent-child mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.parentChild].icon).toBeDefined()
})
it('should have blueLight effect color for parent-child mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.parentChild].effectColor).toBe(EffectColor.blueLight)
})
})
// --------------------------------------------------------------------------
// Q&A Config Tests
// --------------------------------------------------------------------------
describe('Q&A Config', () => {
it('should have title for qa mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.qa].title).toBe('Q&A')
})
it('should have description for qa mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.qa].description).toBeDefined()
})
it('should have icon for qa mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.qa].icon).toBeDefined()
})
it('should have green effect color for qa mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.qa].effectColor).toBe(EffectColor.green)
})
})
// --------------------------------------------------------------------------
// Option Structure Tests
// --------------------------------------------------------------------------
describe('Option Structure', () => {
it('should have all required fields in each option', () => {
const { result } = renderHook(() => useChunkStructureConfig())
Object.values(result.current).forEach((option) => {
expect(option).toHaveProperty('icon')
expect(option).toHaveProperty('title')
expect(option).toHaveProperty('description')
expect(option).toHaveProperty('effectColor')
})
})
it('should cover all ChunkingMode values', () => {
const { result } = renderHook(() => useChunkStructureConfig())
const modes = Object.values(ChunkingMode)
modes.forEach((mode) => {
expect(result.current[mode]).toBeDefined()
})
})
})
})

View File

@ -0,0 +1,360 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Details from './index'
// Mock WorkflowPreview
vi.mock('@/app/components/workflow/workflow-preview', () => ({
default: ({ className }: { className?: string }) => (
<div data-testid="workflow-preview" className={className}>
WorkflowPreview
</div>
),
}))
// Mock service hook
const mockUsePipelineTemplateById = vi.fn()
vi.mock('@/service/use-pipeline', () => ({
usePipelineTemplateById: (...args: unknown[]) => mockUsePipelineTemplateById(...args),
}))
// ============================================================================
// Test Data Factories
// ============================================================================
const createPipelineTemplateInfo = (overrides = {}) => ({
name: 'Test Pipeline',
description: 'This is a test pipeline',
icon_info: {
icon_type: 'emoji',
icon: '📊',
icon_background: '#FFF4ED',
icon_url: '',
},
created_by: 'Test User',
chunk_structure: 'text',
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
export_data: '',
...overrides,
})
const createImageIconPipelineInfo = () => ({
...createPipelineTemplateInfo(),
icon_info: {
icon_type: 'image',
icon: 'file-id-123',
icon_background: '',
icon_url: 'https://example.com/icon.png',
},
})
// ============================================================================
// Details Component Tests
// ============================================================================
describe('Details', () => {
const defaultProps = {
id: 'pipeline-1',
type: 'customized' as const,
onApplyTemplate: vi.fn(),
onClose: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Loading State Tests
// --------------------------------------------------------------------------
describe('Loading State', () => {
it('should show loading when data is not available', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: null,
})
render(<Details {...defaultProps} />)
// Loading component should be rendered
expect(screen.queryByText('Test Pipeline')).not.toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing when data is available', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
render(<Details {...defaultProps} />)
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
})
it('should render pipeline name', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
render(<Details {...defaultProps} />)
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
})
it('should render pipeline description', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
render(<Details {...defaultProps} />)
expect(screen.getByText('This is a test pipeline')).toBeInTheDocument()
})
it('should render created by when available', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
render(<Details {...defaultProps} />)
expect(screen.getByText(/details\.createdBy/i)).toBeInTheDocument()
})
it('should not render created by when not available', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo({ created_by: '' }),
})
render(<Details {...defaultProps} />)
expect(screen.queryByText(/details\.createdBy/i)).not.toBeInTheDocument()
})
it('should render use template button', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
render(<Details {...defaultProps} />)
expect(screen.getByText(/operations\.useTemplate/i)).toBeInTheDocument()
})
it('should render structure section', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
render(<Details {...defaultProps} />)
expect(screen.getByText(/details\.structure/i)).toBeInTheDocument()
})
it('should render close button', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
const { container } = render(<Details {...defaultProps} />)
const closeButton = container.querySelector('button[type="button"]')
expect(closeButton).toBeInTheDocument()
})
it('should render workflow preview', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
render(<Details {...defaultProps} />)
expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
})
it('should render tooltip for structure', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
render(<Details {...defaultProps} />)
// Tooltip component should be present
expect(screen.getByText(/details\.structure/i)).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onClose when close button is clicked', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
const { container } = render(<Details {...defaultProps} />)
const closeButton = container.querySelector('button[type="button"]')
fireEvent.click(closeButton!)
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
})
it('should call onApplyTemplate when use template button is clicked', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
render(<Details {...defaultProps} />)
const useButton = screen.getByText(/operations\.useTemplate/i).closest('button')
fireEvent.click(useButton!)
expect(defaultProps.onApplyTemplate).toHaveBeenCalledTimes(1)
})
})
// --------------------------------------------------------------------------
// Icon Types Tests
// --------------------------------------------------------------------------
describe('Icon Types', () => {
it('should handle emoji icon type', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
render(<Details {...defaultProps} />)
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
})
it('should handle image icon type', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createImageIconPipelineInfo(),
})
render(<Details {...defaultProps} />)
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
})
it('should have default icon when data is null', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: null,
})
// When data is null, component shows loading state
// The default icon is only used in useMemo when pipelineTemplateInfo is null
render(<Details {...defaultProps} />)
// Should not crash and should render (loading state)
expect(screen.queryByText('Test Pipeline')).not.toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// API Call Tests
// --------------------------------------------------------------------------
describe('API Call', () => {
it('should call usePipelineTemplateById with correct params', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
render(<Details {...defaultProps} />)
expect(mockUsePipelineTemplateById).toHaveBeenCalledWith(
{ template_id: 'pipeline-1', type: 'customized' },
true,
)
})
it('should call usePipelineTemplateById with built-in type', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
render(<Details {...defaultProps} type="built-in" />)
expect(mockUsePipelineTemplateById).toHaveBeenCalledWith(
{ template_id: 'pipeline-1', type: 'built-in' },
true,
)
})
})
// --------------------------------------------------------------------------
// Chunk Structure Tests
// --------------------------------------------------------------------------
describe('Chunk Structure', () => {
it('should render chunk structure card for text mode', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo({ chunk_structure: 'text' }),
})
render(<Details {...defaultProps} />)
expect(screen.getByText(/details\.structure/i)).toBeInTheDocument()
})
it('should render chunk structure card for parent-child mode', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo({ chunk_structure: 'hierarchical' }),
})
render(<Details {...defaultProps} />)
expect(screen.getByText(/details\.structure/i)).toBeInTheDocument()
})
it('should render chunk structure card for qa mode', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo({ chunk_structure: 'qa' }),
})
render(<Details {...defaultProps} />)
expect(screen.getByText(/details\.structure/i)).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container styling', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
const { container } = render(<Details {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex', 'h-full')
})
it('should have fixed width sidebar', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
const { container } = render(<Details {...defaultProps} />)
const sidebar = container.querySelector('[class*="w-[360px]"]')
expect(sidebar).toBeInTheDocument()
})
it('should have workflow preview container with grow class', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
const { container } = render(<Details {...defaultProps} />)
const previewContainer = container.querySelector('[class*="grow"]')
expect(previewContainer).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
const { rerender } = render(<Details {...defaultProps} />)
rerender(<Details {...defaultProps} />)
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,665 @@
import type { PipelineTemplate } from '@/models/pipeline'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import { ChunkingMode } from '@/models/datasets'
import EditPipelineInfo from './edit-pipeline-info'
// Mock service hooks
const mockUpdatePipeline = vi.fn()
const mockInvalidCustomizedTemplateList = vi.fn()
vi.mock('@/service/use-pipeline', () => ({
useUpdateTemplateInfo: () => ({
mutateAsync: mockUpdatePipeline,
}),
useInvalidCustomizedTemplateList: () => mockInvalidCustomizedTemplateList,
}))
// Mock Toast
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
// Mock AppIconPicker to capture interactions
let _mockOnSelect: ((icon: { type: 'emoji' | 'image', icon?: string, background?: string, fileId?: string, url?: string }) => void) | undefined
let _mockOnClose: (() => void) | undefined
vi.mock('@/app/components/base/app-icon-picker', () => ({
default: ({ onSelect, onClose }: {
onSelect: (icon: { type: 'emoji' | 'image', icon?: string, background?: string, fileId?: string, url?: string }) => void
onClose: () => void
}) => {
_mockOnSelect = onSelect
_mockOnClose = onClose
return (
<div data-testid="app-icon-picker">
<button data-testid="select-emoji" onClick={() => onSelect({ type: 'emoji', icon: '🎯', background: '#FFEAD5' })}>
Select Emoji
</button>
<button data-testid="select-image" onClick={() => onSelect({ type: 'image', fileId: 'new-file-id', url: 'https://new-icon.com/icon.png' })}>
Select Image
</button>
<button data-testid="close-picker" onClick={onClose}>
Close Picker
</button>
</div>
)
},
}))
// ============================================================================
// Test Data Factories
// ============================================================================
const createPipelineTemplate = (overrides: Partial<PipelineTemplate> = {}): PipelineTemplate => ({
id: 'pipeline-1',
name: 'Test Pipeline',
description: 'Test pipeline description',
icon: {
icon_type: 'emoji',
icon: '📊',
icon_background: '#FFF4ED',
icon_url: '',
},
chunk_structure: ChunkingMode.text,
position: 0,
...overrides,
})
const createImagePipelineTemplate = (): PipelineTemplate => ({
id: 'pipeline-2',
name: 'Image Pipeline',
description: 'Pipeline with image icon',
icon: {
icon_type: 'image',
icon: 'file-id-123',
icon_background: '',
icon_url: 'https://example.com/icon.png',
},
chunk_structure: ChunkingMode.text,
position: 1,
})
// ============================================================================
// EditPipelineInfo Component Tests
// ============================================================================
describe('EditPipelineInfo', () => {
const defaultProps = {
onClose: vi.fn(),
pipeline: createPipelineTemplate(),
}
beforeEach(() => {
vi.clearAllMocks()
_mockOnSelect = undefined
_mockOnClose = undefined
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<EditPipelineInfo {...defaultProps} />)
expect(screen.getByText(/editPipelineInfo/i)).toBeInTheDocument()
})
it('should render title', () => {
render(<EditPipelineInfo {...defaultProps} />)
expect(screen.getByText(/editPipelineInfo/i)).toBeInTheDocument()
})
it('should render close button', () => {
const { container } = render(<EditPipelineInfo {...defaultProps} />)
const closeButton = container.querySelector('button[type="button"]')
expect(closeButton).toBeInTheDocument()
})
it('should render name input with initial value', () => {
render(<EditPipelineInfo {...defaultProps} />)
const input = screen.getByDisplayValue('Test Pipeline')
expect(input).toBeInTheDocument()
})
it('should render description textarea with initial value', () => {
render(<EditPipelineInfo {...defaultProps} />)
const textarea = screen.getByDisplayValue('Test pipeline description')
expect(textarea).toBeInTheDocument()
})
it('should render save and cancel buttons', () => {
render(<EditPipelineInfo {...defaultProps} />)
expect(screen.getByText(/operation\.save/i)).toBeInTheDocument()
expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
})
it('should render name and icon label', () => {
render(<EditPipelineInfo {...defaultProps} />)
expect(screen.getByText(/pipelineNameAndIcon/i)).toBeInTheDocument()
})
it('should render description label', () => {
render(<EditPipelineInfo {...defaultProps} />)
expect(screen.getByText(/knowledgeDescription/i)).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onClose when close button is clicked', () => {
const { container } = render(<EditPipelineInfo {...defaultProps} />)
const closeButton = container.querySelector('button[type="button"]')
fireEvent.click(closeButton!)
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
})
it('should call onClose when cancel button is clicked', () => {
render(<EditPipelineInfo {...defaultProps} />)
const cancelButton = screen.getByText(/operation\.cancel/i)
fireEvent.click(cancelButton)
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
})
it('should update name when input changes', () => {
render(<EditPipelineInfo {...defaultProps} />)
const input = screen.getByDisplayValue('Test Pipeline')
fireEvent.change(input, { target: { value: 'New Pipeline Name' } })
expect(screen.getByDisplayValue('New Pipeline Name')).toBeInTheDocument()
})
it('should update description when textarea changes', () => {
render(<EditPipelineInfo {...defaultProps} />)
const textarea = screen.getByDisplayValue('Test pipeline description')
fireEvent.change(textarea, { target: { value: 'New description' } })
expect(screen.getByDisplayValue('New description')).toBeInTheDocument()
})
it('should call updatePipeline when save is clicked with valid name', async () => {
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
render(<EditPipelineInfo {...defaultProps} />)
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockUpdatePipeline).toHaveBeenCalled()
})
})
it('should invalidate template list on successful save', async () => {
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
render(<EditPipelineInfo {...defaultProps} />)
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockInvalidCustomizedTemplateList).toHaveBeenCalled()
})
})
it('should call onClose on successful save', async () => {
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
render(<EditPipelineInfo {...defaultProps} />)
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(defaultProps.onClose).toHaveBeenCalled()
})
})
})
// --------------------------------------------------------------------------
// Validation Tests
// --------------------------------------------------------------------------
describe('Validation', () => {
it('should show error toast when name is empty', async () => {
render(<EditPipelineInfo {...defaultProps} />)
const input = screen.getByDisplayValue('Test Pipeline')
fireEvent.change(input, { target: { value: '' } })
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
type: 'error',
message: 'Please enter a name for the Knowledge Base.',
})
})
})
it('should not call updatePipeline when name is empty', async () => {
render(<EditPipelineInfo {...defaultProps} />)
const input = screen.getByDisplayValue('Test Pipeline')
fireEvent.change(input, { target: { value: '' } })
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockUpdatePipeline).not.toHaveBeenCalled()
})
})
})
// --------------------------------------------------------------------------
// Icon Types Tests (Branch Coverage for lines 29-30, 36-37)
// --------------------------------------------------------------------------
describe('Icon Types', () => {
it('should initialize with emoji icon type when pipeline has emoji icon', () => {
const { container } = render(<EditPipelineInfo {...defaultProps} />)
// Should render component with emoji icon
expect(container.querySelector('[class*="cursor-pointer"]')).toBeInTheDocument()
expect(screen.getByDisplayValue('Test Pipeline')).toBeInTheDocument()
})
it('should initialize with image icon type when pipeline has image icon', async () => {
const imagePipeline = createImagePipelineTemplate()
// Verify test data has image icon type - this ensures the factory returns correct data
expect(imagePipeline.icon.icon_type).toBe('image')
expect(imagePipeline.icon.icon).toBe('file-id-123')
expect(imagePipeline.icon.icon_url).toBe('https://example.com/icon.png')
const props = {
onClose: vi.fn(),
pipeline: imagePipeline,
}
const { container } = render(<EditPipelineInfo {...props} />)
// Component should initialize with image icon state
expect(screen.getByDisplayValue('Image Pipeline')).toBeInTheDocument()
expect(container.querySelector('[class*="cursor-pointer"]')).toBeInTheDocument()
})
it('should render correctly with image icon and then update', () => {
// This test exercises both the initialization and update paths for image icon
const imagePipeline = createImagePipelineTemplate()
const props = {
...defaultProps,
pipeline: imagePipeline,
}
const { container } = render(<EditPipelineInfo {...props} />)
// Verify component rendered with image pipeline
expect(screen.getByDisplayValue('Image Pipeline')).toBeInTheDocument()
// Open icon picker
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
})
it('should save correct icon_info when starting with image icon type', async () => {
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
const props = {
...defaultProps,
pipeline: createImagePipelineTemplate(),
}
render(<EditPipelineInfo {...props} />)
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockUpdatePipeline).toHaveBeenCalledWith(
expect.objectContaining({
icon_info: expect.objectContaining({
icon_type: 'image',
icon: 'file-id-123',
}),
}),
expect.any(Object),
)
})
})
it('should save correct icon_info when starting with emoji icon type', async () => {
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
render(<EditPipelineInfo {...defaultProps} />)
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockUpdatePipeline).toHaveBeenCalledWith(
expect.objectContaining({
icon_info: expect.objectContaining({
icon_type: 'emoji',
icon: '📊',
}),
}),
expect.any(Object),
)
})
})
it('should revert to initial image icon when picker is closed without selection', () => {
const props = {
...defaultProps,
pipeline: createImagePipelineTemplate(),
}
const { container } = render(<EditPipelineInfo {...props} />)
// Open picker
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
// Close without selection - should revert to original image icon
const closeButton = screen.getByTestId('close-picker')
fireEvent.click(closeButton)
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
})
it('should switch from image icon to emoji icon when selected', async () => {
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
const props = {
...defaultProps,
pipeline: createImagePipelineTemplate(),
}
const { container } = render(<EditPipelineInfo {...props} />)
// Open picker and select emoji
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
const selectEmojiButton = screen.getByTestId('select-emoji')
fireEvent.click(selectEmojiButton)
// Save
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockUpdatePipeline).toHaveBeenCalledWith(
expect.objectContaining({
icon_info: expect.objectContaining({
icon_type: 'emoji',
icon: '🎯',
}),
}),
expect.any(Object),
)
})
})
it('should switch from emoji icon to image icon when selected', async () => {
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
const { container } = render(<EditPipelineInfo {...defaultProps} />)
// Open picker and select image
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
const selectImageButton = screen.getByTestId('select-image')
fireEvent.click(selectImageButton)
// Save
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockUpdatePipeline).toHaveBeenCalledWith(
expect.objectContaining({
icon_info: expect.objectContaining({
icon_type: 'image',
icon: 'new-file-id',
}),
}),
expect.any(Object),
)
})
})
})
// --------------------------------------------------------------------------
// AppIconPicker Tests (Branch Coverage)
// --------------------------------------------------------------------------
describe('AppIconPicker', () => {
it('should not show picker initially', () => {
render(<EditPipelineInfo {...defaultProps} />)
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
})
it('should open picker when icon is clicked', () => {
const { container } = render(<EditPipelineInfo {...defaultProps} />)
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
})
it('should close picker and update icon when emoji is selected', () => {
const { container } = render(<EditPipelineInfo {...defaultProps} />)
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
const selectEmojiButton = screen.getByTestId('select-emoji')
fireEvent.click(selectEmojiButton)
// Picker should close
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
})
it('should close picker and update icon when image is selected', () => {
const { container } = render(<EditPipelineInfo {...defaultProps} />)
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
const selectImageButton = screen.getByTestId('select-image')
fireEvent.click(selectImageButton)
// Picker should close
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
})
it('should revert icon when picker is closed without selection', () => {
const { container } = render(<EditPipelineInfo {...defaultProps} />)
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
const closeButton = screen.getByTestId('close-picker')
fireEvent.click(closeButton)
// Picker should close
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
})
it('should save with new emoji icon selection', async () => {
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
const { container } = render(<EditPipelineInfo {...defaultProps} />)
// Open picker and select new emoji
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
const selectEmojiButton = screen.getByTestId('select-emoji')
fireEvent.click(selectEmojiButton)
// Save
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockUpdatePipeline).toHaveBeenCalledWith(
expect.objectContaining({
icon_info: expect.objectContaining({
icon_type: 'emoji',
icon: '🎯',
icon_background: '#FFEAD5',
}),
}),
expect.any(Object),
)
})
})
it('should save with new image icon selection', async () => {
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
const { container } = render(<EditPipelineInfo {...defaultProps} />)
// Open picker and select new image
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
const selectImageButton = screen.getByTestId('select-image')
fireEvent.click(selectImageButton)
// Save
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockUpdatePipeline).toHaveBeenCalledWith(
expect.objectContaining({
icon_info: expect.objectContaining({
icon_type: 'image',
icon: 'new-file-id',
icon_url: 'https://new-icon.com/icon.png',
}),
}),
expect.any(Object),
)
})
})
})
// --------------------------------------------------------------------------
// Save Request Tests
// --------------------------------------------------------------------------
describe('Save Request', () => {
it('should send correct request with emoji icon', async () => {
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
render(<EditPipelineInfo {...defaultProps} />)
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockUpdatePipeline).toHaveBeenCalledWith(
expect.objectContaining({
template_id: 'pipeline-1',
name: 'Test Pipeline',
description: 'Test pipeline description',
icon_info: expect.objectContaining({
icon_type: 'emoji',
}),
}),
expect.any(Object),
)
})
})
it('should send correct request with image icon', async () => {
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
const props = {
...defaultProps,
pipeline: createImagePipelineTemplate(),
}
render(<EditPipelineInfo {...props} />)
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockUpdatePipeline).toHaveBeenCalledWith(
expect.objectContaining({
template_id: 'pipeline-2',
icon_info: expect.objectContaining({
icon_type: 'image',
}),
}),
expect.any(Object),
)
})
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container styling', () => {
const { container } = render(<EditPipelineInfo {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('relative', 'flex', 'flex-col')
})
it('should have close button in header', () => {
const { container } = render(<EditPipelineInfo {...defaultProps} />)
const closeButton = container.querySelector('button.absolute')
expect(closeButton).toHaveClass('right-5', 'top-5')
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<EditPipelineInfo {...defaultProps} />)
rerender(<EditPipelineInfo {...defaultProps} />)
expect(screen.getByText(/editPipelineInfo/i)).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,722 @@
import type { PipelineTemplate } from '@/models/pipeline'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import { ChunkingMode } from '@/models/datasets'
import TemplateCard from './index'
// Mock next/navigation
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}))
// Mock amplitude tracking
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
// Mock Toast
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
// Mock downloadFile utility
vi.mock('@/utils/format', () => ({
downloadFile: vi.fn(),
}))
// Capture Confirm callbacks
let _capturedOnConfirm: (() => void) | undefined
let _capturedOnCancel: (() => void) | undefined
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, onConfirm, onCancel, title, content }: {
isShow: boolean
onConfirm: () => void
onCancel: () => void
title: string
content: string
}) => {
_capturedOnConfirm = onConfirm
_capturedOnCancel = onCancel
return isShow
? (
<div data-testid="confirm-dialog">
<div data-testid="confirm-title">{title}</div>
<div data-testid="confirm-content">{content}</div>
<button data-testid="confirm-cancel" onClick={onCancel}>Cancel</button>
<button data-testid="confirm-submit" onClick={onConfirm}>Confirm</button>
</div>
)
: null
},
}))
// Capture Actions callbacks
let _capturedHandleDelete: (() => void) | undefined
let _capturedHandleExportDSL: (() => void) | undefined
let _capturedOpenEditModal: (() => void) | undefined
vi.mock('./actions', () => ({
default: ({ onApplyTemplate, handleShowTemplateDetails, showMoreOperations, openEditModal, handleExportDSL, handleDelete }: {
onApplyTemplate: () => void
handleShowTemplateDetails: () => void
showMoreOperations: boolean
openEditModal: () => void
handleExportDSL: () => void
handleDelete: () => void
}) => {
_capturedHandleDelete = handleDelete
_capturedHandleExportDSL = handleExportDSL
_capturedOpenEditModal = openEditModal
return (
<div data-testid="actions">
<button data-testid="action-choose" onClick={onApplyTemplate}>operations.choose</button>
<button data-testid="action-details" onClick={handleShowTemplateDetails}>operations.details</button>
{showMoreOperations && (
<>
<button data-testid="action-edit" onClick={openEditModal}>Edit</button>
<button data-testid="action-export" onClick={handleExportDSL}>Export</button>
<button data-testid="action-delete" onClick={handleDelete}>Delete</button>
</>
)}
</div>
)
},
}))
// Mock EditPipelineInfo component
vi.mock('./edit-pipeline-info', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<div data-testid="edit-pipeline-info">
<button data-testid="edit-close" onClick={onClose}>Close</button>
</div>
),
}))
// Mock Details component
vi.mock('./details', () => ({
default: ({ onClose, onApplyTemplate }: { onClose: () => void, onApplyTemplate: () => void }) => (
<div data-testid="details-component">
<button data-testid="details-close" onClick={onClose}>Close</button>
<button data-testid="details-apply" onClick={onApplyTemplate}>Apply</button>
</div>
),
}))
// Mock service hooks
const mockCreateDataset = vi.fn()
const mockInvalidDatasetList = vi.fn()
const mockGetPipelineTemplateInfo = vi.fn()
const mockDeletePipeline = vi.fn()
const mockExportPipelineDSL = vi.fn()
const mockInvalidCustomizedTemplateList = vi.fn()
const mockHandleCheckPluginDependencies = vi.fn()
// Configurable isPending for export
let mockIsExporting = false
vi.mock('@/service/knowledge/use-create-dataset', () => ({
useCreatePipelineDatasetFromCustomized: () => ({
mutateAsync: mockCreateDataset,
}),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useInvalidDatasetList: () => mockInvalidDatasetList,
}))
vi.mock('@/service/use-pipeline', () => ({
usePipelineTemplateById: () => ({
refetch: mockGetPipelineTemplateInfo,
}),
useDeleteTemplate: () => ({
mutateAsync: mockDeletePipeline,
}),
useExportTemplateDSL: () => ({
mutateAsync: mockExportPipelineDSL,
get isPending() { return mockIsExporting },
}),
useInvalidCustomizedTemplateList: () => mockInvalidCustomizedTemplateList,
}))
// Mock plugin dependencies hook
vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
usePluginDependencies: () => ({
handleCheckPluginDependencies: mockHandleCheckPluginDependencies,
}),
}))
// ============================================================================
// Test Data Factories
// ============================================================================
const createPipelineTemplate = (overrides: Partial<PipelineTemplate> = {}): PipelineTemplate => ({
id: 'pipeline-1',
name: 'Test Pipeline',
description: 'Test pipeline description',
icon: {
icon_type: 'emoji',
icon: '📊',
icon_background: '#FFF4ED',
icon_url: '',
},
chunk_structure: ChunkingMode.text,
position: 1,
...overrides,
})
// ============================================================================
// TemplateCard Component Tests
// ============================================================================
describe('TemplateCard', () => {
const defaultProps = {
pipeline: createPipelineTemplate(),
showMoreOperations: true,
type: 'customized' as const,
}
beforeEach(() => {
vi.clearAllMocks()
mockIsExporting = false
_capturedOnConfirm = undefined
_capturedOnCancel = undefined
_capturedHandleDelete = undefined
_capturedHandleExportDSL = undefined
_capturedOpenEditModal = undefined
mockHandleCheckPluginDependencies.mockResolvedValue(undefined)
mockGetPipelineTemplateInfo.mockResolvedValue({
data: {
export_data: 'yaml_content_here',
},
})
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<TemplateCard {...defaultProps} />)
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
})
it('should render pipeline name', () => {
render(<TemplateCard {...defaultProps} />)
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
})
it('should render pipeline description', () => {
render(<TemplateCard {...defaultProps} />)
expect(screen.getByText('Test pipeline description')).toBeInTheDocument()
})
it('should render Content component', () => {
render(<TemplateCard {...defaultProps} />)
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
expect(screen.getByText('Test pipeline description')).toBeInTheDocument()
})
it('should render Actions component', () => {
render(<TemplateCard {...defaultProps} />)
expect(screen.getByTestId('actions')).toBeInTheDocument()
expect(screen.getByTestId('action-choose')).toBeInTheDocument()
expect(screen.getByTestId('action-details')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Use Template Flow Tests
// --------------------------------------------------------------------------
describe('Use Template Flow', () => {
it('should show error when template info fetch fails', async () => {
mockGetPipelineTemplateInfo.mockResolvedValue({ data: null })
render(<TemplateCard {...defaultProps} />)
const chooseButton = screen.getByTestId('action-choose')
fireEvent.click(chooseButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
})
})
})
it('should create dataset when template is applied', async () => {
mockCreateDataset.mockImplementation((_data, callbacks) => {
callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: 'pipe-123' })
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const chooseButton = screen.getByTestId('action-choose')
fireEvent.click(chooseButton)
await waitFor(() => {
expect(mockCreateDataset).toHaveBeenCalled()
})
})
it('should navigate to pipeline page on successful creation', async () => {
mockCreateDataset.mockImplementation((_data, callbacks) => {
callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: 'pipe-123' })
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const chooseButton = screen.getByTestId('action-choose')
fireEvent.click(chooseButton)
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/datasets/new-dataset-123/pipeline')
})
})
it('should invalidate dataset list on successful creation', async () => {
mockCreateDataset.mockImplementation((_data, callbacks) => {
callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: 'pipe-123' })
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const chooseButton = screen.getByTestId('action-choose')
fireEvent.click(chooseButton)
await waitFor(() => {
expect(mockInvalidDatasetList).toHaveBeenCalled()
})
})
it('should show success toast on successful creation', async () => {
mockCreateDataset.mockImplementation((_data, callbacks) => {
callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: 'pipe-123' })
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const chooseButton = screen.getByTestId('action-choose')
fireEvent.click(chooseButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
type: 'success',
message: expect.any(String),
})
})
})
it('should show error toast on creation failure', async () => {
mockCreateDataset.mockImplementation((_data, callbacks) => {
callbacks.onError(new Error('Creation failed'))
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const chooseButton = screen.getByTestId('action-choose')
fireEvent.click(chooseButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
})
})
})
})
// --------------------------------------------------------------------------
// Details Modal Tests
// --------------------------------------------------------------------------
describe('Details Modal', () => {
it('should open details modal when details button is clicked', async () => {
render(<TemplateCard {...defaultProps} />)
const detailsButton = screen.getByTestId('action-details')
fireEvent.click(detailsButton)
await waitFor(() => {
expect(screen.getByTestId('details-component')).toBeInTheDocument()
})
})
it('should close details modal when close is triggered', async () => {
render(<TemplateCard {...defaultProps} />)
const detailsButton = screen.getByTestId('action-details')
fireEvent.click(detailsButton)
await waitFor(() => {
expect(screen.getByTestId('details-component')).toBeInTheDocument()
})
const closeButton = screen.getByTestId('details-close')
fireEvent.click(closeButton)
await waitFor(() => {
expect(screen.queryByTestId('details-component')).not.toBeInTheDocument()
})
})
it('should trigger use template from details modal', async () => {
mockCreateDataset.mockImplementation((_data, callbacks) => {
callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: 'pipe-123' })
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const detailsButton = screen.getByTestId('action-details')
fireEvent.click(detailsButton)
await waitFor(() => {
expect(screen.getByTestId('details-component')).toBeInTheDocument()
})
const applyButton = screen.getByTestId('details-apply')
fireEvent.click(applyButton)
await waitFor(() => {
expect(mockCreateDataset).toHaveBeenCalled()
})
})
})
// --------------------------------------------------------------------------
// Pipeline ID Branch Tests
// --------------------------------------------------------------------------
describe('Pipeline ID Branch', () => {
it('should call handleCheckPluginDependencies when pipeline_id is present', async () => {
mockCreateDataset.mockImplementation((_data, callbacks) => {
callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: 'pipe-123' })
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const chooseButton = screen.getByTestId('action-choose')
fireEvent.click(chooseButton)
await waitFor(() => {
expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('pipe-123', true)
})
})
it('should not call handleCheckPluginDependencies when pipeline_id is falsy', async () => {
mockCreateDataset.mockImplementation((_data, callbacks) => {
callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: '' })
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const chooseButton = screen.getByTestId('action-choose')
fireEvent.click(chooseButton)
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/datasets/new-dataset-123/pipeline')
})
expect(mockHandleCheckPluginDependencies).not.toHaveBeenCalled()
})
it('should not call handleCheckPluginDependencies when pipeline_id is null', async () => {
mockCreateDataset.mockImplementation((_data, callbacks) => {
callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: null })
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const chooseButton = screen.getByTestId('action-choose')
fireEvent.click(chooseButton)
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/datasets/new-dataset-123/pipeline')
})
expect(mockHandleCheckPluginDependencies).not.toHaveBeenCalled()
})
})
// --------------------------------------------------------------------------
// Export DSL Tests (Branch Coverage)
// --------------------------------------------------------------------------
describe('Export DSL', () => {
it('should not export when already exporting', async () => {
mockIsExporting = true
render(<TemplateCard {...defaultProps} />)
const exportButton = screen.getByTestId('action-export')
fireEvent.click(exportButton)
// Export should not be called due to isExporting check
expect(mockExportPipelineDSL).not.toHaveBeenCalled()
})
it('should call exportPipelineDSL on export action', async () => {
mockExportPipelineDSL.mockImplementation((_id, callbacks) => {
callbacks.onSuccess({ data: 'yaml_content' })
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const exportButton = screen.getByTestId('action-export')
fireEvent.click(exportButton)
await waitFor(() => {
expect(mockExportPipelineDSL).toHaveBeenCalledWith('pipeline-1', expect.any(Object))
})
})
it('should show success toast on export success', async () => {
mockExportPipelineDSL.mockImplementation((_id, callbacks) => {
callbacks.onSuccess({ data: 'yaml_content' })
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const exportButton = screen.getByTestId('action-export')
fireEvent.click(exportButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
type: 'success',
message: expect.any(String),
})
})
})
it('should show error toast on export failure', async () => {
mockExportPipelineDSL.mockImplementation((_id, callbacks) => {
callbacks.onError(new Error('Export failed'))
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const exportButton = screen.getByTestId('action-export')
fireEvent.click(exportButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
})
})
})
it('should call downloadFile on successful export', async () => {
const { downloadFile } = await import('@/utils/format')
mockExportPipelineDSL.mockImplementation((_id, callbacks) => {
callbacks.onSuccess({ data: 'yaml_content' })
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const exportButton = screen.getByTestId('action-export')
fireEvent.click(exportButton)
await waitFor(() => {
expect(downloadFile).toHaveBeenCalledWith(expect.objectContaining({
fileName: 'Test Pipeline.pipeline',
}))
})
})
})
// --------------------------------------------------------------------------
// Delete Flow Tests
// --------------------------------------------------------------------------
describe('Delete Flow', () => {
it('should show confirm dialog when delete is clicked', async () => {
render(<TemplateCard {...defaultProps} />)
const deleteButton = screen.getByTestId('action-delete')
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
})
it('should close confirm dialog when cancel is clicked (onCancelDelete)', async () => {
render(<TemplateCard {...defaultProps} />)
const deleteButton = screen.getByTestId('action-delete')
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
const cancelButton = screen.getByTestId('confirm-cancel')
fireEvent.click(cancelButton)
await waitFor(() => {
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
})
})
it('should call deletePipeline when confirm is clicked (onConfirmDelete)', async () => {
mockDeletePipeline.mockImplementation((_id, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const deleteButton = screen.getByTestId('action-delete')
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
const confirmButton = screen.getByTestId('confirm-submit')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(mockDeletePipeline).toHaveBeenCalledWith('pipeline-1', expect.any(Object))
})
})
it('should invalidate template list on successful delete', async () => {
mockDeletePipeline.mockImplementation((_id, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const deleteButton = screen.getByTestId('action-delete')
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
const confirmButton = screen.getByTestId('confirm-submit')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(mockInvalidCustomizedTemplateList).toHaveBeenCalled()
})
})
it('should close confirm dialog after successful delete', async () => {
mockDeletePipeline.mockImplementation((_id, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const deleteButton = screen.getByTestId('action-delete')
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
const confirmButton = screen.getByTestId('confirm-submit')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// Edit Modal Tests
// --------------------------------------------------------------------------
describe('Edit Modal', () => {
it('should open edit modal when edit button is clicked', async () => {
render(<TemplateCard {...defaultProps} />)
const editButton = screen.getByTestId('action-edit')
fireEvent.click(editButton)
await waitFor(() => {
expect(screen.getByTestId('edit-pipeline-info')).toBeInTheDocument()
})
})
it('should close edit modal when close is triggered', async () => {
render(<TemplateCard {...defaultProps} />)
const editButton = screen.getByTestId('action-edit')
fireEvent.click(editButton)
await waitFor(() => {
expect(screen.getByTestId('edit-pipeline-info')).toBeInTheDocument()
})
const closeButton = screen.getByTestId('edit-close')
fireEvent.click(closeButton)
await waitFor(() => {
expect(screen.queryByTestId('edit-pipeline-info')).not.toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// Props Tests
// --------------------------------------------------------------------------
describe('Props', () => {
it('should show more operations when showMoreOperations is true', () => {
render(<TemplateCard {...defaultProps} showMoreOperations={true} />)
expect(screen.getByTestId('action-edit')).toBeInTheDocument()
expect(screen.getByTestId('action-export')).toBeInTheDocument()
expect(screen.getByTestId('action-delete')).toBeInTheDocument()
})
it('should hide more operations when showMoreOperations is false', () => {
render(<TemplateCard {...defaultProps} showMoreOperations={false} />)
expect(screen.queryByTestId('action-edit')).not.toBeInTheDocument()
expect(screen.queryByTestId('action-export')).not.toBeInTheDocument()
expect(screen.queryByTestId('action-delete')).not.toBeInTheDocument()
})
it('should default showMoreOperations to true', () => {
const { pipeline, type } = defaultProps
render(<TemplateCard pipeline={pipeline} type={type} />)
expect(screen.getByTestId('action-edit')).toBeInTheDocument()
})
it('should handle built-in type', () => {
render(<TemplateCard {...defaultProps} type="built-in" />)
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
})
it('should handle customized type', () => {
render(<TemplateCard {...defaultProps} type="customized" />)
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper card styling', () => {
const { container } = render(<TemplateCard {...defaultProps} />)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('group', 'relative', 'flex', 'cursor-pointer', 'flex-col', 'rounded-xl')
})
it('should have fixed height', () => {
const { container } = render(<TemplateCard {...defaultProps} />)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('h-[132px]')
})
it('should have shadow and border', () => {
const { container } = render(<TemplateCard {...defaultProps} />)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('border-[0.5px]', 'shadow-xs')
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<TemplateCard {...defaultProps} />)
rerender(<TemplateCard {...defaultProps} />)
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,144 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Operations from './operations'
// ============================================================================
// Operations Component Tests
// ============================================================================
describe('Operations', () => {
const defaultProps = {
openEditModal: vi.fn(),
onDelete: vi.fn(),
onExport: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Operations {...defaultProps} />)
expect(screen.getByText(/editInfo/i)).toBeInTheDocument()
})
it('should render all operation buttons', () => {
render(<Operations {...defaultProps} />)
expect(screen.getByText(/editInfo/i)).toBeInTheDocument()
expect(screen.getByText(/exportPipeline/i)).toBeInTheDocument()
expect(screen.getByText(/delete/i)).toBeInTheDocument()
})
it('should have proper container styling', () => {
const { container } = render(<Operations {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('relative', 'flex', 'flex-col', 'rounded-xl')
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call openEditModal when edit is clicked', () => {
render(<Operations {...defaultProps} />)
const editButton = screen.getByText(/editInfo/i).closest('div[class*="cursor-pointer"]')
fireEvent.click(editButton!)
expect(defaultProps.openEditModal).toHaveBeenCalledTimes(1)
})
it('should call onExport when export is clicked', () => {
render(<Operations {...defaultProps} />)
const exportButton = screen.getByText(/exportPipeline/i).closest('div[class*="cursor-pointer"]')
fireEvent.click(exportButton!)
expect(defaultProps.onExport).toHaveBeenCalledTimes(1)
})
it('should call onDelete when delete is clicked', () => {
render(<Operations {...defaultProps} />)
const deleteButton = screen.getByText(/delete/i).closest('div[class*="cursor-pointer"]')
fireEvent.click(deleteButton!)
expect(defaultProps.onDelete).toHaveBeenCalledTimes(1)
})
it('should stop propagation on edit click', () => {
const stopPropagation = vi.fn()
const preventDefault = vi.fn()
render(<Operations {...defaultProps} />)
const editButton = screen.getByText(/editInfo/i).closest('div[class*="cursor-pointer"]')
fireEvent.click(editButton!, {
stopPropagation,
preventDefault,
})
expect(defaultProps.openEditModal).toHaveBeenCalled()
})
it('should stop propagation on export click', () => {
render(<Operations {...defaultProps} />)
const exportButton = screen.getByText(/exportPipeline/i).closest('div[class*="cursor-pointer"]')
fireEvent.click(exportButton!)
expect(defaultProps.onExport).toHaveBeenCalled()
})
it('should stop propagation on delete click', () => {
render(<Operations {...defaultProps} />)
const deleteButton = screen.getByText(/delete/i).closest('div[class*="cursor-pointer"]')
fireEvent.click(deleteButton!)
expect(defaultProps.onDelete).toHaveBeenCalled()
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have divider between sections', () => {
const { container } = render(<Operations {...defaultProps} />)
const divider = container.querySelector('[class*="bg-divider"]')
expect(divider).toBeInTheDocument()
})
it('should have hover states on buttons', () => {
render(<Operations {...defaultProps} />)
const editButton = screen.getByText(/editInfo/i).closest('div[class*="cursor-pointer"]')
expect(editButton).toHaveClass('hover:bg-state-base-hover')
})
it('should have destructive hover state on delete button', () => {
render(<Operations {...defaultProps} />)
const deleteButton = screen.getByText(/delete/i).closest('div[class*="cursor-pointer"]')
expect(deleteButton).toHaveClass('hover:bg-state-destructive-hover')
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Operations {...defaultProps} />)
rerender(<Operations {...defaultProps} />)
expect(screen.getByText(/editInfo/i)).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,407 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ============================================================================
// Component Imports (after mocks)
// ============================================================================
import UrlInput from './url-input'
// ============================================================================
// Mock Setup
// ============================================================================
// Mock useDocLink hook
vi.mock('@/context/i18n', () => ({
useDocLink: vi.fn(() => () => 'https://docs.example.com'),
}))
// ============================================================================
// UrlInput Component Tests
// ============================================================================
describe('UrlInput', () => {
const mockOnRun = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render input with placeholder from docLink', () => {
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
expect(input).toHaveAttribute('placeholder', 'https://docs.example.com')
})
it('should render button with run text when not running', () => {
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const button = screen.getByRole('button')
expect(button).toHaveTextContent(/run/i)
})
it('should render button without run text when running', () => {
render(<UrlInput isRunning={true} onRun={mockOnRun} />)
const button = screen.getByRole('button')
// Button should not have "run" text when running (shows loading state instead)
expect(button).not.toHaveTextContent(/run/i)
})
it('should show loading state on button when running', () => {
render(<UrlInput isRunning={true} onRun={mockOnRun} />)
// Button should show loading text when running
const button = screen.getByRole('button')
expect(button).toHaveTextContent(/loading/i)
})
it('should not show loading state on button when not running', () => {
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const button = screen.getByRole('button')
expect(button).not.toHaveTextContent(/loading/i)
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should update input value when user types', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
await user.type(input, 'https://example.com')
expect(input).toHaveValue('https://example.com')
})
it('should call onRun with url when button is clicked and not running', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
await user.type(input, 'https://example.com')
const button = screen.getByRole('button')
await user.click(button)
expect(mockOnRun).toHaveBeenCalledWith('https://example.com')
expect(mockOnRun).toHaveBeenCalledTimes(1)
})
it('should NOT call onRun when button is clicked and isRunning is true', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={true} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
// Use fireEvent since userEvent might not work well with disabled-like states
fireEvent.change(input, { target: { value: 'https://example.com' } })
const button = screen.getByRole('button')
await user.click(button)
// onRun should NOT be called because isRunning is true
expect(mockOnRun).not.toHaveBeenCalled()
})
it('should call onRun with empty string when button clicked with empty input', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const button = screen.getByRole('button')
await user.click(button)
expect(mockOnRun).toHaveBeenCalledWith('')
expect(mockOnRun).toHaveBeenCalledTimes(1)
})
it('should handle multiple button clicks when not running', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
await user.type(input, 'https://test.com')
const button = screen.getByRole('button')
await user.click(button)
await user.click(button)
expect(mockOnRun).toHaveBeenCalledTimes(2)
expect(mockOnRun).toHaveBeenCalledWith('https://test.com')
})
})
// --------------------------------------------------------------------------
// Props Variations Tests
// --------------------------------------------------------------------------
describe('Props Variations', () => {
it('should update button state when isRunning changes from false to true', () => {
const { rerender } = render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const button = screen.getByRole('button')
expect(button).toHaveTextContent(/run/i)
rerender(<UrlInput isRunning={true} onRun={mockOnRun} />)
// When running, button shows loading state instead of "run" text
expect(button).not.toHaveTextContent(/run/i)
})
it('should update button state when isRunning changes from true to false', () => {
const { rerender } = render(<UrlInput isRunning={true} onRun={mockOnRun} />)
const button = screen.getByRole('button')
// When running, button shows loading state instead of "run" text
expect(button).not.toHaveTextContent(/run/i)
rerender(<UrlInput isRunning={false} onRun={mockOnRun} />)
expect(button).toHaveTextContent(/run/i)
})
it('should preserve input value when isRunning prop changes', async () => {
const user = userEvent.setup()
const { rerender } = render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
await user.type(input, 'https://preserved.com')
expect(input).toHaveValue('https://preserved.com')
rerender(<UrlInput isRunning={true} onRun={mockOnRun} />)
expect(input).toHaveValue('https://preserved.com')
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle special characters in url', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
const specialUrl = 'https://example.com/path?query=test&param=value#anchor'
await user.type(input, specialUrl)
const button = screen.getByRole('button')
await user.click(button)
expect(mockOnRun).toHaveBeenCalledWith(specialUrl)
})
it('should handle unicode characters in url', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
const unicodeUrl = 'https://example.com/路径/文件'
await user.type(input, unicodeUrl)
const button = screen.getByRole('button')
await user.click(button)
expect(mockOnRun).toHaveBeenCalledWith(unicodeUrl)
})
it('should handle very long url', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
const longUrl = `https://example.com/${'a'.repeat(500)}`
// Use fireEvent for long text to avoid timeout
fireEvent.change(input, { target: { value: longUrl } })
const button = screen.getByRole('button')
await user.click(button)
expect(mockOnRun).toHaveBeenCalledWith(longUrl)
})
it('should handle whitespace in url', async () => {
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: ' https://example.com ' } })
const button = screen.getByRole('button')
fireEvent.click(button)
expect(mockOnRun).toHaveBeenCalledWith(' https://example.com ')
})
it('should handle rapid input changes', async () => {
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'a' } })
fireEvent.change(input, { target: { value: 'ab' } })
fireEvent.change(input, { target: { value: 'abc' } })
fireEvent.change(input, { target: { value: 'https://final.com' } })
expect(input).toHaveValue('https://final.com')
const button = screen.getByRole('button')
fireEvent.click(button)
expect(mockOnRun).toHaveBeenCalledWith('https://final.com')
})
})
// --------------------------------------------------------------------------
// handleOnRun Branch Coverage Tests
// --------------------------------------------------------------------------
describe('handleOnRun Branch Coverage', () => {
it('should return early when isRunning is true (branch: isRunning = true)', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={true} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'https://test.com' } })
const button = screen.getByRole('button')
await user.click(button)
// The early return should prevent onRun from being called
expect(mockOnRun).not.toHaveBeenCalled()
})
it('should call onRun when isRunning is false (branch: isRunning = false)', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'https://test.com' } })
const button = screen.getByRole('button')
await user.click(button)
// onRun should be called when isRunning is false
expect(mockOnRun).toHaveBeenCalledWith('https://test.com')
})
})
// --------------------------------------------------------------------------
// Button Text Branch Coverage Tests
// --------------------------------------------------------------------------
describe('Button Text Branch Coverage', () => {
it('should display run text when isRunning is false (branch: !isRunning = true)', () => {
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const button = screen.getByRole('button')
// When !isRunning is true, button shows the translated "run" text
expect(button).toHaveTextContent(/run/i)
})
it('should not display run text when isRunning is true (branch: !isRunning = false)', () => {
render(<UrlInput isRunning={true} onRun={mockOnRun} />)
const button = screen.getByRole('button')
// When !isRunning is false, button shows empty string '' (loading state shows spinner)
expect(button).not.toHaveTextContent(/run/i)
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<UrlInput isRunning={false} onRun={mockOnRun} />)
rerender(<UrlInput isRunning={false} onRun={mockOnRun} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should use useCallback for handleUrlChange', async () => {
const user = userEvent.setup()
const { rerender } = render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
await user.type(input, 'test')
rerender(<UrlInput isRunning={false} onRun={mockOnRun} />)
// Input should maintain value after rerender
expect(input).toHaveValue('test')
})
it('should use useCallback for handleOnRun', async () => {
const user = userEvent.setup()
const { rerender } = render(<UrlInput isRunning={false} onRun={mockOnRun} />)
rerender(<UrlInput isRunning={false} onRun={mockOnRun} />)
const button = screen.getByRole('button')
await user.click(button)
expect(mockOnRun).toHaveBeenCalledTimes(1)
})
})
// --------------------------------------------------------------------------
// Integration Tests
// --------------------------------------------------------------------------
describe('Integration', () => {
it('should complete full workflow: type url -> click run -> verify callback', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
// Type URL
const input = screen.getByRole('textbox')
await user.type(input, 'https://mywebsite.com')
// Click run
const button = screen.getByRole('button')
await user.click(button)
// Verify callback
expect(mockOnRun).toHaveBeenCalledWith('https://mywebsite.com')
})
it('should show correct states during running workflow', () => {
const { rerender } = render(<UrlInput isRunning={false} onRun={mockOnRun} />)
// Initial state: not running
expect(screen.getByRole('button')).toHaveTextContent(/run/i)
// Simulate running state
rerender(<UrlInput isRunning={true} onRun={mockOnRun} />)
expect(screen.getByRole('button')).not.toHaveTextContent(/run/i)
// Simulate finished state
rerender(<UrlInput isRunning={false} onRun={mockOnRun} />)
expect(screen.getByRole('button')).toHaveTextContent(/run/i)
})
})
})

View File

@ -0,0 +1,701 @@
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ============================================================================
// Component Import (after mocks)
// ============================================================================
import FireCrawl from './index'
// ============================================================================
// Mock Setup - Only mock API calls and context
// ============================================================================
// Mock API service
const mockCreateFirecrawlTask = vi.fn()
const mockCheckFirecrawlTaskStatus = vi.fn()
vi.mock('@/service/datasets', () => ({
createFirecrawlTask: (...args: unknown[]) => mockCreateFirecrawlTask(...args),
checkFirecrawlTaskStatus: (...args: unknown[]) => mockCheckFirecrawlTaskStatus(...args),
}))
// Mock modal context
const mockSetShowAccountSettingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContextSelector: vi.fn(() => mockSetShowAccountSettingModal),
}))
// Mock sleep utility to speed up tests
vi.mock('@/utils', () => ({
sleep: vi.fn(() => Promise.resolve()),
}))
// Mock useDocLink hook for UrlInput placeholder
vi.mock('@/context/i18n', () => ({
useDocLink: vi.fn(() => () => 'https://docs.example.com'),
}))
// ============================================================================
// Test Data Factory
// ============================================================================
const createMockCrawlOptions = (overrides: Partial<CrawlOptions> = {}): CrawlOptions => ({
crawl_sub_pages: true,
limit: 10,
max_depth: 2,
excludes: '',
includes: '',
only_main_content: false,
use_sitemap: false,
...overrides,
})
const createMockCrawlResultItem = (overrides: Partial<CrawlResultItem> = {}): CrawlResultItem => ({
title: 'Test Page',
markdown: '# Test Content',
description: 'Test page description',
source_url: 'https://example.com/page',
...overrides,
})
// ============================================================================
// FireCrawl Component Tests
// ============================================================================
describe('FireCrawl', () => {
const mockOnPreview = vi.fn()
const mockOnCheckedCrawlResultChange = vi.fn()
const mockOnJobIdChange = vi.fn()
const mockOnCrawlOptionsChange = vi.fn()
const defaultProps = {
onPreview: mockOnPreview,
checkedCrawlResult: [] as CrawlResultItem[],
onCheckedCrawlResultChange: mockOnCheckedCrawlResultChange,
onJobIdChange: mockOnJobIdChange,
crawlOptions: createMockCrawlOptions(),
onCrawlOptionsChange: mockOnCrawlOptionsChange,
}
beforeEach(() => {
vi.clearAllMocks()
mockCreateFirecrawlTask.mockReset()
mockCheckFirecrawlTaskStatus.mockReset()
})
// Helper to get URL input (first textbox with specific placeholder)
const getUrlInput = () => {
return screen.getByPlaceholderText('https://docs.example.com')
}
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<FireCrawl {...defaultProps} />)
expect(screen.getByText(/firecrawlTitle/i)).toBeInTheDocument()
})
it('should render Header component with correct props', () => {
render(<FireCrawl {...defaultProps} />)
expect(screen.getByText(/firecrawlTitle/i)).toBeInTheDocument()
expect(screen.getByText(/configureFirecrawl/i)).toBeInTheDocument()
expect(screen.getByText(/firecrawlDoc/i)).toBeInTheDocument()
})
it('should render UrlInput component', () => {
render(<FireCrawl {...defaultProps} />)
expect(getUrlInput()).toBeInTheDocument()
expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument()
})
it('should render Options component', () => {
render(<FireCrawl {...defaultProps} />)
expect(screen.getByText(/crawlSubPage/i)).toBeInTheDocument()
expect(screen.getByText(/limit/i)).toBeInTheDocument()
})
it('should not render crawling or result components initially', () => {
render(<FireCrawl {...defaultProps} />)
// Crawling and result components should not be visible in init state
expect(screen.queryByText(/crawling/i)).not.toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Configuration Button Tests
// --------------------------------------------------------------------------
describe('Configuration Button', () => {
it('should call setShowAccountSettingModal when configure button is clicked', async () => {
const user = userEvent.setup()
render(<FireCrawl {...defaultProps} />)
const configButton = screen.getByText(/configureFirecrawl/i)
await user.click(configButton)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'data-source',
})
})
})
// --------------------------------------------------------------------------
// URL Validation Tests
// --------------------------------------------------------------------------
describe('URL Validation', () => {
it('should show error toast when URL is empty', async () => {
const user = userEvent.setup()
render(<FireCrawl {...defaultProps} />)
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
// Should not call API when validation fails
expect(mockCreateFirecrawlTask).not.toHaveBeenCalled()
})
it('should show error toast when URL does not start with http:// or https://', async () => {
const user = userEvent.setup()
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'invalid-url.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
// Should not call API when validation fails
expect(mockCreateFirecrawlTask).not.toHaveBeenCalled()
})
it('should show error toast when limit is empty', async () => {
const user = userEvent.setup()
const propsWithEmptyLimit = {
...defaultProps,
crawlOptions: createMockCrawlOptions({ limit: '' as unknown as number }),
}
render(<FireCrawl {...propsWithEmptyLimit} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
// Should not call API when validation fails
expect(mockCreateFirecrawlTask).not.toHaveBeenCalled()
})
it('should show error toast when limit is null', async () => {
const user = userEvent.setup()
const propsWithNullLimit = {
...defaultProps,
crawlOptions: createMockCrawlOptions({ limit: null as unknown as number }),
}
render(<FireCrawl {...propsWithNullLimit} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
expect(mockCreateFirecrawlTask).not.toHaveBeenCalled()
})
it('should accept valid http:// URL', async () => {
const user = userEvent.setup()
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job-id' })
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
status: 'completed',
data: [],
total: 0,
current: 0,
time_consuming: 1,
})
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'http://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(mockCreateFirecrawlTask).toHaveBeenCalled()
})
})
it('should accept valid https:// URL', async () => {
const user = userEvent.setup()
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job-id' })
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
status: 'completed',
data: [],
total: 0,
current: 0,
time_consuming: 1,
})
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(mockCreateFirecrawlTask).toHaveBeenCalled()
})
})
})
// --------------------------------------------------------------------------
// Crawl Execution Tests
// --------------------------------------------------------------------------
describe('Crawl Execution', () => {
it('should call createFirecrawlTask with correct parameters', async () => {
const user = userEvent.setup()
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job-id' })
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
status: 'completed',
data: [],
total: 0,
current: 0,
time_consuming: 1,
})
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(mockCreateFirecrawlTask).toHaveBeenCalledWith({
url: 'https://example.com',
options: expect.objectContaining({
crawl_sub_pages: true,
limit: 10,
max_depth: 2,
}),
})
})
})
it('should call onJobIdChange with job_id from API response', async () => {
const user = userEvent.setup()
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'my-job-123' })
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
status: 'completed',
data: [],
total: 0,
current: 0,
time_consuming: 1,
})
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(mockOnJobIdChange).toHaveBeenCalledWith('my-job-123')
})
})
it('should remove empty max_depth from crawlOptions before sending to API', async () => {
const user = userEvent.setup()
const propsWithEmptyMaxDepth = {
...defaultProps,
crawlOptions: createMockCrawlOptions({ max_depth: '' as unknown as number }),
}
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job-id' })
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
status: 'completed',
data: [],
total: 0,
current: 0,
time_consuming: 1,
})
render(<FireCrawl {...propsWithEmptyMaxDepth} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(mockCreateFirecrawlTask).toHaveBeenCalledWith({
url: 'https://example.com',
options: expect.not.objectContaining({
max_depth: '',
}),
})
})
})
it('should show loading state while running', async () => {
const user = userEvent.setup()
mockCreateFirecrawlTask.mockImplementation(() => new Promise(() => {})) // Never resolves
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
// Button should show loading state (no longer show "run" text)
await waitFor(() => {
expect(runButton).not.toHaveTextContent(/run/i)
})
})
})
// --------------------------------------------------------------------------
// Crawl Status Polling Tests
// --------------------------------------------------------------------------
describe('Crawl Status Polling', () => {
it('should handle completed status', async () => {
const user = userEvent.setup()
const mockResults = [createMockCrawlResultItem()]
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
status: 'completed',
data: mockResults,
total: 1,
current: 1,
time_consuming: 2.5,
})
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith(mockResults)
})
})
it('should handle error status from API', async () => {
const user = userEvent.setup()
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
status: 'error',
message: 'Crawl failed',
data: [],
})
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(screen.getByText(/exceptionErrorTitle/i)).toBeInTheDocument()
})
})
it('should handle missing status as error', async () => {
const user = userEvent.setup()
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
status: undefined,
message: 'No status',
data: [],
})
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(screen.getByText(/exceptionErrorTitle/i)).toBeInTheDocument()
})
})
it('should poll again when status is pending', async () => {
const user = userEvent.setup()
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
mockCheckFirecrawlTaskStatus
.mockResolvedValueOnce({
status: 'pending',
data: [{ title: 'Page 1', markdown: 'content', source_url: 'https://example.com/1' }],
total: 5,
current: 1,
})
.mockResolvedValueOnce({
status: 'completed',
data: [{ title: 'Page 1', markdown: 'content', source_url: 'https://example.com/1' }],
total: 5,
current: 5,
time_consuming: 3,
})
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(mockCheckFirecrawlTaskStatus).toHaveBeenCalledTimes(2)
})
})
it('should update progress during crawling', async () => {
const user = userEvent.setup()
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
mockCheckFirecrawlTaskStatus
.mockResolvedValueOnce({
status: 'pending',
data: [{ title: 'Page 1', markdown: 'content', source_url: 'https://example.com/1' }],
total: 10,
current: 3,
})
.mockResolvedValueOnce({
status: 'completed',
data: [{ title: 'Page 1', markdown: 'content', source_url: 'https://example.com/1' }],
total: 10,
current: 10,
time_consuming: 5,
})
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalled()
})
})
})
// --------------------------------------------------------------------------
// Error Handling Tests
// --------------------------------------------------------------------------
describe('Error Handling', () => {
it('should handle API exception during task creation', async () => {
const user = userEvent.setup()
mockCreateFirecrawlTask.mockRejectedValueOnce(new Error('Network error'))
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(screen.getByText(/exceptionErrorTitle/i)).toBeInTheDocument()
})
})
it('should handle API exception during status check', async () => {
const user = userEvent.setup()
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
mockCheckFirecrawlTaskStatus.mockRejectedValueOnce({
json: () => Promise.resolve({ message: 'Status check failed' }),
})
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(screen.getByText(/exceptionErrorTitle/i)).toBeInTheDocument()
})
})
it('should display error message from API', async () => {
const user = userEvent.setup()
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
status: 'error',
message: 'Custom error message',
data: [],
})
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(screen.getByText('Custom error message')).toBeInTheDocument()
})
})
it('should display unknown error when no error message provided', async () => {
const user = userEvent.setup()
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
status: 'error',
message: undefined,
data: [],
})
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(screen.getByText(/unknownError/i)).toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// Options Change Tests
// --------------------------------------------------------------------------
describe('Options Change', () => {
it('should call onCrawlOptionsChange when options change', () => {
render(<FireCrawl {...defaultProps} />)
// Find and change limit input
const limitInput = screen.getByDisplayValue('10')
fireEvent.change(limitInput, { target: { value: '20' } })
expect(mockOnCrawlOptionsChange).toHaveBeenCalledWith(
expect.objectContaining({ limit: 20 }),
)
})
it('should call onCrawlOptionsChange when checkbox changes', () => {
const { container } = render(<FireCrawl {...defaultProps} />)
// Use data-testid to find checkboxes since they are custom div elements
const checkboxes = container.querySelectorAll('[data-testid^="checkbox-"]')
fireEvent.click(checkboxes[0]) // crawl_sub_pages
expect(mockOnCrawlOptionsChange).toHaveBeenCalledWith(
expect.objectContaining({ crawl_sub_pages: false }),
)
})
})
// --------------------------------------------------------------------------
// Crawled Result Display Tests
// --------------------------------------------------------------------------
describe('Crawled Result Display', () => {
it('should display CrawledResult when crawl is finished successfully', async () => {
const user = userEvent.setup()
const mockResults = [
createMockCrawlResultItem({ title: 'Result Page 1' }),
createMockCrawlResultItem({ title: 'Result Page 2' }),
]
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
status: 'completed',
data: mockResults,
total: 2,
current: 2,
time_consuming: 1.5,
})
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(screen.getByText('Result Page 1')).toBeInTheDocument()
expect(screen.getByText('Result Page 2')).toBeInTheDocument()
})
})
it('should limit total to crawlOptions.limit', async () => {
const user = userEvent.setup()
const propsWithLimit5 = {
...defaultProps,
crawlOptions: createMockCrawlOptions({ limit: 5 }),
}
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
status: 'completed',
data: [],
total: 100, // API returns more than limit
current: 5,
time_consuming: 1,
})
render(<FireCrawl {...propsWithLimit5} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
// Total should be capped to limit (5)
expect(mockCheckFirecrawlTaskStatus).toHaveBeenCalled()
})
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<FireCrawl {...defaultProps} />)
rerender(<FireCrawl {...defaultProps} />)
expect(screen.getByText(/firecrawlTitle/i)).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,405 @@
import type { CrawlOptions } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Options from './options'
// ============================================================================
// Test Data Factory
// ============================================================================
const createMockCrawlOptions = (overrides: Partial<CrawlOptions> = {}): CrawlOptions => ({
crawl_sub_pages: true,
limit: 10,
max_depth: 2,
excludes: '',
includes: '',
only_main_content: false,
use_sitemap: false,
...overrides,
})
// ============================================================================
// Options Component Tests
// ============================================================================
describe('Options', () => {
const mockOnChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
// Helper to get checkboxes by test id pattern
const getCheckboxes = (container: HTMLElement) => {
return container.querySelectorAll('[data-testid^="checkbox-"]')
}
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
const payload = createMockCrawlOptions()
render(<Options payload={payload} onChange={mockOnChange} />)
// Check that key elements are rendered
expect(screen.getByText(/crawlSubPage/i)).toBeInTheDocument()
expect(screen.getByText(/limit/i)).toBeInTheDocument()
expect(screen.getByText(/maxDepth/i)).toBeInTheDocument()
})
it('should render all form fields', () => {
const payload = createMockCrawlOptions()
render(<Options payload={payload} onChange={mockOnChange} />)
// Checkboxes
expect(screen.getByText(/crawlSubPage/i)).toBeInTheDocument()
expect(screen.getByText(/extractOnlyMainContent/i)).toBeInTheDocument()
// Text/Number fields
expect(screen.getByText(/limit/i)).toBeInTheDocument()
expect(screen.getByText(/maxDepth/i)).toBeInTheDocument()
expect(screen.getByText(/excludePaths/i)).toBeInTheDocument()
expect(screen.getByText(/includeOnlyPaths/i)).toBeInTheDocument()
})
it('should render with custom className', () => {
const payload = createMockCrawlOptions()
const { container } = render(
<Options payload={payload} onChange={mockOnChange} className="custom-class" />,
)
const rootElement = container.firstChild as HTMLElement
expect(rootElement).toHaveClass('custom-class')
})
it('should render limit field with required indicator', () => {
const payload = createMockCrawlOptions()
render(<Options payload={payload} onChange={mockOnChange} />)
// Limit field should have required indicator (*)
const requiredIndicator = screen.getByText('*')
expect(requiredIndicator).toBeInTheDocument()
})
it('should render placeholder for excludes field', () => {
const payload = createMockCrawlOptions()
render(<Options payload={payload} onChange={mockOnChange} />)
const excludesInput = screen.getByPlaceholderText('blog/*, /about/*')
expect(excludesInput).toBeInTheDocument()
})
it('should render placeholder for includes field', () => {
const payload = createMockCrawlOptions()
render(<Options payload={payload} onChange={mockOnChange} />)
const includesInput = screen.getByPlaceholderText('articles/*')
expect(includesInput).toBeInTheDocument()
})
it('should render two checkboxes', () => {
const payload = createMockCrawlOptions()
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
expect(checkboxes.length).toBe(2)
})
})
// --------------------------------------------------------------------------
// Props Display Tests
// --------------------------------------------------------------------------
describe('Props Display', () => {
it('should display crawl_sub_pages checkbox with check icon when true', () => {
const payload = createMockCrawlOptions({ crawl_sub_pages: true })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
// First checkbox should have check icon when checked
expect(checkboxes[0].querySelector('svg')).toBeInTheDocument()
})
it('should display crawl_sub_pages checkbox without check icon when false', () => {
const payload = createMockCrawlOptions({ crawl_sub_pages: false })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
// First checkbox should not have check icon when unchecked
expect(checkboxes[0].querySelector('svg')).not.toBeInTheDocument()
})
it('should display only_main_content checkbox with check icon when true', () => {
const payload = createMockCrawlOptions({ only_main_content: true })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
// Second checkbox should have check icon when checked
expect(checkboxes[1].querySelector('svg')).toBeInTheDocument()
})
it('should display only_main_content checkbox without check icon when false', () => {
const payload = createMockCrawlOptions({ only_main_content: false })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
// Second checkbox should not have check icon when unchecked
expect(checkboxes[1].querySelector('svg')).not.toBeInTheDocument()
})
it('should display limit value in input', () => {
const payload = createMockCrawlOptions({ limit: 25 })
render(<Options payload={payload} onChange={mockOnChange} />)
const limitInput = screen.getByDisplayValue('25')
expect(limitInput).toBeInTheDocument()
})
it('should display max_depth value in input', () => {
const payload = createMockCrawlOptions({ max_depth: 5 })
render(<Options payload={payload} onChange={mockOnChange} />)
const maxDepthInput = screen.getByDisplayValue('5')
expect(maxDepthInput).toBeInTheDocument()
})
it('should display excludes value in input', () => {
const payload = createMockCrawlOptions({ excludes: 'test/*' })
render(<Options payload={payload} onChange={mockOnChange} />)
const excludesInput = screen.getByDisplayValue('test/*')
expect(excludesInput).toBeInTheDocument()
})
it('should display includes value in input', () => {
const payload = createMockCrawlOptions({ includes: 'docs/*' })
render(<Options payload={payload} onChange={mockOnChange} />)
const includesInput = screen.getByDisplayValue('docs/*')
expect(includesInput).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onChange with updated crawl_sub_pages when checkbox is clicked', () => {
const payload = createMockCrawlOptions({ crawl_sub_pages: true })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
fireEvent.click(checkboxes[0])
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
crawl_sub_pages: false,
})
})
it('should call onChange with updated only_main_content when checkbox is clicked', () => {
const payload = createMockCrawlOptions({ only_main_content: false })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
fireEvent.click(checkboxes[1])
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
only_main_content: true,
})
})
it('should call onChange with updated limit when input changes', () => {
const payload = createMockCrawlOptions({ limit: 10 })
render(<Options payload={payload} onChange={mockOnChange} />)
const limitInput = screen.getByDisplayValue('10')
fireEvent.change(limitInput, { target: { value: '50' } })
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
limit: 50,
})
})
it('should call onChange with updated max_depth when input changes', () => {
const payload = createMockCrawlOptions({ max_depth: 2 })
render(<Options payload={payload} onChange={mockOnChange} />)
const maxDepthInput = screen.getByDisplayValue('2')
fireEvent.change(maxDepthInput, { target: { value: '10' } })
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
max_depth: 10,
})
})
it('should call onChange with updated excludes when input changes', () => {
const payload = createMockCrawlOptions({ excludes: '' })
render(<Options payload={payload} onChange={mockOnChange} />)
const excludesInput = screen.getByPlaceholderText('blog/*, /about/*')
fireEvent.change(excludesInput, { target: { value: 'admin/*' } })
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
excludes: 'admin/*',
})
})
it('should call onChange with updated includes when input changes', () => {
const payload = createMockCrawlOptions({ includes: '' })
render(<Options payload={payload} onChange={mockOnChange} />)
const includesInput = screen.getByPlaceholderText('articles/*')
fireEvent.change(includesInput, { target: { value: 'public/*' } })
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
includes: 'public/*',
})
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle empty string values', () => {
const payload = createMockCrawlOptions({
limit: '',
max_depth: '',
excludes: '',
includes: '',
} as unknown as CrawlOptions)
render(<Options payload={payload} onChange={mockOnChange} />)
// Component should render without crashing
expect(screen.getByText(/limit/i)).toBeInTheDocument()
})
it('should handle zero values', () => {
const payload = createMockCrawlOptions({
limit: 0,
max_depth: 0,
})
render(<Options payload={payload} onChange={mockOnChange} />)
// Zero values should be displayed
const zeroInputs = screen.getAllByDisplayValue('0')
expect(zeroInputs.length).toBeGreaterThanOrEqual(1)
})
it('should handle large numbers', () => {
const payload = createMockCrawlOptions({
limit: 9999,
max_depth: 100,
})
render(<Options payload={payload} onChange={mockOnChange} />)
expect(screen.getByDisplayValue('9999')).toBeInTheDocument()
expect(screen.getByDisplayValue('100')).toBeInTheDocument()
})
it('should handle special characters in text fields', () => {
const payload = createMockCrawlOptions({
excludes: 'path/*/file?query=1&param=2',
includes: 'docs/**/*.md',
})
render(<Options payload={payload} onChange={mockOnChange} />)
expect(screen.getByDisplayValue('path/*/file?query=1&param=2')).toBeInTheDocument()
expect(screen.getByDisplayValue('docs/**/*.md')).toBeInTheDocument()
})
it('should preserve other payload fields when updating one field', () => {
const payload = createMockCrawlOptions({
crawl_sub_pages: true,
limit: 10,
max_depth: 2,
excludes: 'test/*',
includes: 'docs/*',
only_main_content: true,
})
render(<Options payload={payload} onChange={mockOnChange} />)
const limitInput = screen.getByDisplayValue('10')
fireEvent.change(limitInput, { target: { value: '20' } })
expect(mockOnChange).toHaveBeenCalledWith({
crawl_sub_pages: true,
limit: 20,
max_depth: 2,
excludes: 'test/*',
includes: 'docs/*',
only_main_content: true,
use_sitemap: false,
})
})
})
// --------------------------------------------------------------------------
// handleChange Callback Tests
// --------------------------------------------------------------------------
describe('handleChange Callback', () => {
it('should create a new callback for each key', () => {
const payload = createMockCrawlOptions()
render(<Options payload={payload} onChange={mockOnChange} />)
// Change limit
const limitInput = screen.getByDisplayValue('10')
fireEvent.change(limitInput, { target: { value: '15' } })
expect(mockOnChange).toHaveBeenCalledWith(
expect.objectContaining({ limit: 15 }),
)
// Change max_depth
const maxDepthInput = screen.getByDisplayValue('2')
fireEvent.change(maxDepthInput, { target: { value: '5' } })
expect(mockOnChange).toHaveBeenCalledWith(
expect.objectContaining({ max_depth: 5 }),
)
})
it('should handle multiple rapid changes', () => {
const payload = createMockCrawlOptions({ limit: 10 })
render(<Options payload={payload} onChange={mockOnChange} />)
const limitInput = screen.getByDisplayValue('10')
fireEvent.change(limitInput, { target: { value: '11' } })
fireEvent.change(limitInput, { target: { value: '12' } })
fireEvent.change(limitInput, { target: { value: '13' } })
expect(mockOnChange).toHaveBeenCalledTimes(3)
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const payload = createMockCrawlOptions()
const { rerender } = render(<Options payload={payload} onChange={mockOnChange} />)
rerender(<Options payload={payload} onChange={mockOnChange} />)
expect(screen.getByText(/limit/i)).toBeInTheDocument()
})
it('should re-render when payload changes', () => {
const payload1 = createMockCrawlOptions({ limit: 10 })
const payload2 = createMockCrawlOptions({ limit: 20 })
const { rerender } = render(<Options payload={payload1} onChange={mockOnChange} />)
expect(screen.getByDisplayValue('10')).toBeInTheDocument()
rerender(<Options payload={payload2} onChange={mockOnChange} />)
expect(screen.getByDisplayValue('20')).toBeInTheDocument()
})
})
})

View File

@ -70,6 +70,11 @@ const createDefaultProps = (overrides: Partial<Parameters<typeof JinaReader>[0]>
describe('JinaReader', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
describe('Rendering', () => {
@ -158,7 +163,7 @@ describe('JinaReader', () => {
describe('Props', () => {
it('should call onCrawlOptionsChange when options change', async () => {
// Arrange
const user = userEvent.setup()
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const onCrawlOptionsChange = vi.fn()
const props = createDefaultProps({ onCrawlOptionsChange })
@ -237,9 +242,10 @@ describe('JinaReader', () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
let resolvePromise: () => void
mockCreateTask.mockImplementation(() => new Promise((resolve) => {
const taskPromise = new Promise((resolve) => {
resolvePromise = () => resolve({ data: { title: 'T', content: 'C', description: 'D', url: 'https://example.com' } })
}))
})
mockCreateTask.mockImplementation(() => taskPromise)
const props = createDefaultProps()
@ -257,8 +263,11 @@ describe('JinaReader', () => {
expect(screen.getByText(/totalPageScraped/i)).toBeInTheDocument()
})
// Cleanup - resolve the promise
// Cleanup - resolve the promise and wait for component to finish
resolvePromise!()
await waitFor(() => {
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
})
})
it('should transition to finished state after successful crawl', async () => {
@ -394,7 +403,11 @@ describe('JinaReader', () => {
it('should update controlFoldOptions when step changes', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
mockCreateTask.mockImplementation(() => new Promise((_resolve) => { /* pending */ }))
let resolvePromise: () => void
const taskPromise = new Promise((resolve) => {
resolvePromise = () => resolve({ data: { title: 'T', content: 'C', description: 'D', url: 'https://example.com' } })
})
mockCreateTask.mockImplementation(() => taskPromise)
const props = createDefaultProps()
@ -412,6 +425,12 @@ describe('JinaReader', () => {
await waitFor(() => {
expect(screen.getByText(/totalPageScraped/i)).toBeInTheDocument()
})
// Cleanup - resolve the promise
resolvePromise!()
await waitFor(() => {
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
})
})
})
@ -1073,9 +1092,13 @@ describe('JinaReader', () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
let resolveCheckStatus: () => void
const checkStatusPromise = new Promise((resolve) => {
resolveCheckStatus = () => resolve({ status: 'completed', current: 0, total: 0, data: [] })
})
mockCreateTask.mockResolvedValueOnce({ job_id: 'zero-current-job' })
mockCheckStatus.mockImplementation(() => new Promise(() => { /* never resolves */ }))
mockCheckStatus.mockImplementation(() => checkStatusPromise)
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ limit: 10 }),
@ -1091,15 +1114,25 @@ describe('JinaReader', () => {
await waitFor(() => {
expect(screen.getByText(/totalPageScraped.*0\/10/)).toBeInTheDocument()
})
// Cleanup - resolve the promise
resolveCheckStatus!()
await waitFor(() => {
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
})
})
it('should show 0/0 progress when limit is zero string', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
let resolveCheckStatus: () => void
const checkStatusPromise = new Promise((resolve) => {
resolveCheckStatus = () => resolve({ status: 'completed', current: 0, total: 0, data: [] })
})
mockCreateTask.mockResolvedValueOnce({ job_id: 'zero-total-job' })
mockCheckStatus.mockImplementation(() => new Promise(() => { /* never resolves */ }))
mockCheckStatus.mockImplementation(() => checkStatusPromise)
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ limit: '0' }),
@ -1115,6 +1148,12 @@ describe('JinaReader', () => {
await waitFor(() => {
expect(screen.getByText(/totalPageScraped.*0\/0/)).toBeInTheDocument()
})
// Cleanup - resolve the promise
resolveCheckStatus!()
await waitFor(() => {
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
})
})
it('should complete successfully when result data is undefined', async () => {
@ -1150,9 +1189,13 @@ describe('JinaReader', () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
let resolveCheckStatus: () => void
const checkStatusPromise = new Promise((resolve) => {
resolveCheckStatus = () => resolve({ status: 'completed', current: 0, total: 0, data: [] })
})
mockCreateTask.mockResolvedValueOnce({ job_id: 'no-total-job' })
mockCheckStatus.mockImplementation(() => new Promise(() => { /* never resolves */ }))
mockCheckStatus.mockImplementation(() => checkStatusPromise)
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ limit: 15 }),
@ -1168,12 +1211,22 @@ describe('JinaReader', () => {
await waitFor(() => {
expect(screen.getByText(/totalPageScraped.*0\/15/)).toBeInTheDocument()
})
// Cleanup - resolve the promise
resolveCheckStatus!()
await waitFor(() => {
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
})
})
it('should fallback to limit when crawlResult has zero total', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
let resolveCheckStatus: () => void
const checkStatusPromise = new Promise((resolve) => {
resolveCheckStatus = () => resolve({ status: 'completed', current: 0, total: 0, data: [] })
})
mockCreateTask.mockResolvedValueOnce({ job_id: 'both-zero-job' })
mockCheckStatus
@ -1183,7 +1236,7 @@ describe('JinaReader', () => {
total: 0,
data: [],
})
.mockImplementationOnce(() => new Promise(() => { /* never resolves */ }))
.mockImplementationOnce(() => checkStatusPromise)
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ limit: 5 }),
@ -1199,6 +1252,12 @@ describe('JinaReader', () => {
await waitFor(() => {
expect(screen.getByText(/totalPageScraped/)).toBeInTheDocument()
})
// Cleanup - resolve the promise
resolveCheckStatus!()
await waitFor(() => {
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
})
})
it('should construct result item from direct data response', async () => {
@ -1437,9 +1496,13 @@ describe('JinaReader', () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
let resolveCheckStatus: () => void
const checkStatusPromise = new Promise((resolve) => {
resolveCheckStatus = () => resolve({ status: 'completed', current: 0, total: 0, data: [] })
})
mockCreateTask.mockResolvedValueOnce({ job_id: 'progress-job' })
mockCheckStatus.mockImplementation(() => new Promise((_resolve) => { /* pending */ })) // Never resolves
mockCheckStatus.mockImplementation(() => checkStatusPromise)
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ limit: 10 }),
@ -1455,6 +1518,12 @@ describe('JinaReader', () => {
await waitFor(() => {
expect(screen.getByText(/totalPageScraped.*0\/10/)).toBeInTheDocument()
})
// Cleanup - resolve the promise
resolveCheckStatus!()
await waitFor(() => {
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
})
})
it('should display time consumed after crawl completion', async () => {

View File

@ -0,0 +1,214 @@
import type { SortType } from '@/service/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DataSourceType } from '@/models/datasets'
import DocumentsHeader from './documents-header'
// Mock the context hooks
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
// Mock child components that require API calls
vi.mock('@/app/components/datasets/common/document-status-with-action/auto-disabled-document', () => ({
default: () => <div data-testid="auto-disabled-document">AutoDisabledDocument</div>,
}))
vi.mock('@/app/components/datasets/common/document-status-with-action/index-failed', () => ({
default: () => <div data-testid="index-failed">IndexFailed</div>,
}))
vi.mock('@/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<div data-testid="metadata-drawer">
<button onClick={onClose}>Close</button>
MetadataDrawer
</div>
),
}))
describe('DocumentsHeader', () => {
const defaultProps = {
datasetId: 'dataset-123',
dataSourceType: DataSourceType.FILE,
embeddingAvailable: true,
isFreePlan: false,
statusFilterValue: 'all',
sortValue: 'created_at' as SortType,
inputValue: '',
onStatusFilterChange: vi.fn(),
onStatusFilterClear: vi.fn(),
onSortChange: vi.fn(),
onInputChange: vi.fn(),
isShowEditMetadataModal: false,
showEditMetadataModal: vi.fn(),
hideEditMetadataModal: vi.fn(),
datasetMetaData: [],
builtInMetaData: [],
builtInEnabled: true,
onAddMetaData: vi.fn(),
onRenameMetaData: vi.fn(),
onDeleteMetaData: vi.fn(),
onBuiltInEnabledChange: vi.fn(),
onAddDocument: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<DocumentsHeader {...defaultProps} />)
expect(screen.getByText(/list\.title/i)).toBeInTheDocument()
})
it('should render title', () => {
render(<DocumentsHeader {...defaultProps} />)
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(/list\.title/i)
})
it('should render description text', () => {
render(<DocumentsHeader {...defaultProps} />)
expect(screen.getByText(/list\.desc/i)).toBeInTheDocument()
})
it('should render learn more link', () => {
render(<DocumentsHeader {...defaultProps} />)
const link = screen.getByRole('link')
expect(link).toHaveTextContent(/list\.learnMore/i)
expect(link).toHaveAttribute('href', expect.stringContaining('use-dify/knowledge'))
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
it('should render filter input', () => {
render(<DocumentsHeader {...defaultProps} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
})
describe('AutoDisabledDocument', () => {
it('should show AutoDisabledDocument when not free plan', () => {
render(<DocumentsHeader {...defaultProps} isFreePlan={false} />)
expect(screen.getByTestId('auto-disabled-document')).toBeInTheDocument()
})
it('should not show AutoDisabledDocument when on free plan', () => {
render(<DocumentsHeader {...defaultProps} isFreePlan={true} />)
expect(screen.queryByTestId('auto-disabled-document')).not.toBeInTheDocument()
})
})
describe('IndexFailed', () => {
it('should always show IndexFailed component', () => {
render(<DocumentsHeader {...defaultProps} />)
expect(screen.getByTestId('index-failed')).toBeInTheDocument()
})
})
describe('Embedding Availability', () => {
it('should show metadata button when embedding is available', () => {
render(<DocumentsHeader {...defaultProps} embeddingAvailable={true} />)
expect(screen.getByText(/metadata\.metadata/i)).toBeInTheDocument()
})
it('should show add document button when embedding is available', () => {
render(<DocumentsHeader {...defaultProps} embeddingAvailable={true} />)
expect(screen.getByText(/list\.addFile/i)).toBeInTheDocument()
})
it('should show warning when embedding is not available', () => {
render(<DocumentsHeader {...defaultProps} embeddingAvailable={false} />)
expect(screen.queryByText(/metadata\.metadata/i)).not.toBeInTheDocument()
expect(screen.queryByText(/list\.addFile/i)).not.toBeInTheDocument()
})
})
describe('Add Button Text', () => {
it('should show "Add File" for FILE data source', () => {
render(<DocumentsHeader {...defaultProps} dataSourceType={DataSourceType.FILE} />)
expect(screen.getByText(/list\.addFile/i)).toBeInTheDocument()
})
it('should show "Add Pages" for NOTION data source', () => {
render(<DocumentsHeader {...defaultProps} dataSourceType={DataSourceType.NOTION} />)
expect(screen.getByText(/list\.addPages/i)).toBeInTheDocument()
})
it('should show "Add Url" for WEB data source', () => {
render(<DocumentsHeader {...defaultProps} dataSourceType={DataSourceType.WEB} />)
expect(screen.getByText(/list\.addUrl/i)).toBeInTheDocument()
})
})
describe('Metadata Modal', () => {
it('should show metadata drawer when isShowEditMetadataModal is true', () => {
render(<DocumentsHeader {...defaultProps} isShowEditMetadataModal={true} />)
expect(screen.getByTestId('metadata-drawer')).toBeInTheDocument()
})
it('should not show metadata drawer when isShowEditMetadataModal is false', () => {
render(<DocumentsHeader {...defaultProps} isShowEditMetadataModal={false} />)
expect(screen.queryByTestId('metadata-drawer')).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call showEditMetadataModal when metadata button is clicked', () => {
const showEditMetadataModal = vi.fn()
render(<DocumentsHeader {...defaultProps} showEditMetadataModal={showEditMetadataModal} />)
const metadataButton = screen.getByText(/metadata\.metadata/i)
fireEvent.click(metadataButton)
expect(showEditMetadataModal).toHaveBeenCalledTimes(1)
})
it('should call onAddDocument when add button is clicked', () => {
const onAddDocument = vi.fn()
render(<DocumentsHeader {...defaultProps} onAddDocument={onAddDocument} />)
const addButton = screen.getByText(/list\.addFile/i)
fireEvent.click(addButton)
expect(onAddDocument).toHaveBeenCalledTimes(1)
})
it('should call onInputChange when typing in search input', () => {
const onInputChange = vi.fn()
render(<DocumentsHeader {...defaultProps} onInputChange={onInputChange} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'search query' } })
expect(onInputChange).toHaveBeenCalledWith('search query')
})
})
describe('Edge Cases', () => {
it('should handle undefined dataSourceType', () => {
render(<DocumentsHeader {...defaultProps} dataSourceType={undefined} />)
// Should default to "Add File" text
expect(screen.getByText(/list\.addFile/i)).toBeInTheDocument()
})
it('should handle empty metadata arrays', () => {
render(
<DocumentsHeader
{...defaultProps}
isShowEditMetadataModal={true}
datasetMetaData={[]}
builtInMetaData={[]}
/>,
)
expect(screen.getByTestId('metadata-drawer')).toBeInTheDocument()
})
it('should render with descending sort order', () => {
render(<DocumentsHeader {...defaultProps} sortValue="-created_at" />)
// Component should still render correctly
expect(screen.getByText(/list\.title/i)).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,95 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import EmptyElement from './empty-element'
describe('EmptyElement', () => {
const defaultProps = {
canAdd: true,
onClick: vi.fn(),
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<EmptyElement {...defaultProps} />)
expect(screen.getByText(/list\.empty\.title/i)).toBeInTheDocument()
})
it('should render title text', () => {
render(<EmptyElement {...defaultProps} />)
expect(screen.getByText(/list\.empty\.title/i)).toBeInTheDocument()
})
it('should render tip text for upload type', () => {
render(<EmptyElement {...defaultProps} type="upload" />)
expect(screen.getByText(/list\.empty\.upload\.tip/i)).toBeInTheDocument()
})
it('should render tip text for sync type', () => {
render(<EmptyElement {...defaultProps} type="sync" />)
expect(screen.getByText(/list\.empty\.sync\.tip/i)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should use upload type by default', () => {
render(<EmptyElement {...defaultProps} />)
expect(screen.getByText(/list\.empty\.upload\.tip/i)).toBeInTheDocument()
})
it('should render FolderPlusIcon for upload type', () => {
const { container } = render(<EmptyElement {...defaultProps} type="upload" />)
// FolderPlusIcon has specific SVG attributes
const svgs = container.querySelectorAll('svg')
expect(svgs.length).toBeGreaterThan(0)
})
it('should render NotionIcon for sync type', () => {
const { container } = render(<EmptyElement {...defaultProps} type="sync" />)
// NotionIcon has clipPath
const clipPath = container.querySelector('clipPath')
expect(clipPath).toBeInTheDocument()
})
})
describe('Add Button', () => {
it('should show add button when canAdd is true and type is upload', () => {
render(<EmptyElement {...defaultProps} canAdd={true} type="upload" />)
expect(screen.getByRole('button')).toBeInTheDocument()
expect(screen.getByText(/list\.addFile/i)).toBeInTheDocument()
})
it('should not show add button when canAdd is false', () => {
render(<EmptyElement {...defaultProps} canAdd={false} type="upload" />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('should not show add button for sync type', () => {
render(<EmptyElement {...defaultProps} canAdd={true} type="sync" />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('should not show add button for sync type even when canAdd is true', () => {
render(<EmptyElement canAdd={true} onClick={vi.fn()} type="sync" />)
expect(screen.queryByText(/list\.addFile/i)).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClick when add button is clicked', () => {
const handleClick = vi.fn()
render(<EmptyElement canAdd={true} onClick={handleClick} type="upload" />)
const button = screen.getByRole('button')
fireEvent.click(button)
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {
it('should handle default canAdd value (true)', () => {
render(<EmptyElement onClick={vi.fn()} canAdd={true} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,81 @@
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { FolderPlusIcon, NotionIcon, ThreeDotsIcon } from './icons'
describe('Icons', () => {
describe('FolderPlusIcon', () => {
it('should render without crashing', () => {
render(<FolderPlusIcon />)
const svg = document.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should have correct dimensions', () => {
render(<FolderPlusIcon />)
const svg = document.querySelector('svg')
expect(svg).toHaveAttribute('width', '20')
expect(svg).toHaveAttribute('height', '20')
})
it('should apply custom className', () => {
render(<FolderPlusIcon className="custom-class" />)
const svg = document.querySelector('svg')
expect(svg).toHaveClass('custom-class')
})
it('should have empty className by default', () => {
render(<FolderPlusIcon />)
const svg = document.querySelector('svg')
expect(svg).toHaveAttribute('class', '')
})
})
describe('ThreeDotsIcon', () => {
it('should render without crashing', () => {
const { container } = render(<ThreeDotsIcon />)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should have correct dimensions', () => {
const { container } = render(<ThreeDotsIcon />)
const svg = container.querySelector('svg')
expect(svg).toHaveAttribute('width', '16')
expect(svg).toHaveAttribute('height', '16')
})
it('should apply custom className', () => {
const { container } = render(<ThreeDotsIcon className="custom-class" />)
const svg = container.querySelector('svg')
expect(svg).toHaveClass('custom-class')
})
})
describe('NotionIcon', () => {
it('should render without crashing', () => {
const { container } = render(<NotionIcon />)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should have correct dimensions', () => {
const { container } = render(<NotionIcon />)
const svg = container.querySelector('svg')
expect(svg).toHaveAttribute('width', '20')
expect(svg).toHaveAttribute('height', '20')
})
it('should apply custom className', () => {
const { container } = render(<NotionIcon className="custom-class" />)
const svg = container.querySelector('svg')
expect(svg).toHaveClass('custom-class')
})
it('should contain clipPath definition', () => {
const { container } = render(<NotionIcon />)
const clipPath = container.querySelector('clipPath')
expect(clipPath).toBeInTheDocument()
expect(clipPath).toHaveAttribute('id', 'clip0_2164_11263')
})
})
})

View File

@ -0,0 +1,381 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DataSourceType } from '@/models/datasets'
import Operations from './operations'
// Mock services
vi.mock('@/service/knowledge/use-document', () => ({
useDocumentArchive: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
useDocumentUnArchive: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
useDocumentEnable: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
useDocumentDisable: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
useDocumentDelete: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
useDocumentDownload: () => ({ mutateAsync: vi.fn().mockResolvedValue({ url: 'https://example.com/download' }), isPending: false }),
useSyncDocument: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
useSyncWebsite: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
useDocumentPause: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
useDocumentResume: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
}))
// Mock utils
vi.mock('@/utils/download', () => ({
downloadUrl: vi.fn(),
}))
// Mock router
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
}),
}))
describe('Operations', () => {
const defaultDetail = {
name: 'Test Document',
enabled: true,
archived: false,
id: 'doc-123',
data_source_type: DataSourceType.FILE,
doc_form: 'text',
display_status: 'available',
}
const defaultProps = {
embeddingAvailable: true,
detail: defaultDetail,
datasetId: 'dataset-456',
onUpdate: vi.fn(),
scene: 'list' as const,
className: '',
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Operations {...defaultProps} />)
// Should render at least the container
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
})
it('should render switch in list scene', () => {
const { container } = render(<Operations {...defaultProps} scene="list" />)
// Switch component should be rendered
const switchEl = container.querySelector('[role="switch"]')
expect(switchEl).toBeInTheDocument()
})
it('should render settings button when embedding is available', () => {
const { container } = render(<Operations {...defaultProps} />)
// Settings button has RiEqualizer2Line icon inside
const settingsButton = container.querySelector('button.mr-2.cursor-pointer')
expect(settingsButton).toBeInTheDocument()
})
})
describe('Switch Behavior', () => {
it('should render enabled switch when document is enabled', () => {
const { container } = render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, enabled: true, archived: false }}
/>,
)
const switchEl = container.querySelector('[role="switch"]')
expect(switchEl).toHaveAttribute('aria-checked', 'true')
})
it('should render disabled switch when document is disabled', () => {
const { container } = render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, enabled: false, archived: false }}
/>,
)
const switchEl = container.querySelector('[role="switch"]')
expect(switchEl).toHaveAttribute('aria-checked', 'false')
})
it('should show tooltip and disable switch when document is archived', () => {
const { container } = render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, archived: true }}
/>,
)
const switchEl = container.querySelector('[role="switch"]')
// Archived documents have visually disabled switch (CSS-based)
expect(switchEl).toHaveClass('!cursor-not-allowed', '!opacity-50')
})
})
describe('Embedding Not Available', () => {
it('should show disabled switch when embedding not available in list scene', () => {
const { container } = render(
<Operations
{...defaultProps}
embeddingAvailable={false}
scene="list"
/>,
)
const switchEl = container.querySelector('[role="switch"]')
// Switch is visually disabled (CSS-based)
expect(switchEl).toHaveClass('!cursor-not-allowed', '!opacity-50')
})
it('should not show settings or popover when embedding not available', () => {
render(
<Operations
{...defaultProps}
embeddingAvailable={false}
/>,
)
expect(screen.queryByRole('button', { name: /settings/i })).not.toBeInTheDocument()
})
})
describe('More Actions Popover', () => {
it('should show rename option for non-archived documents', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, archived: false }}
/>,
)
// Click on the more actions button
const moreButton = document.querySelector('[class*="commonIcon"]')
expect(moreButton).toBeInTheDocument()
if (moreButton)
fireEvent.click(moreButton)
await waitFor(() => {
expect(screen.getByText(/list\.table\.rename/i)).toBeInTheDocument()
})
})
it('should show download option for FILE type documents', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, data_source_type: DataSourceType.FILE }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await waitFor(() => {
expect(screen.getByText(/list\.action\.download/i)).toBeInTheDocument()
})
})
it('should show sync option for notion documents', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, data_source_type: 'notion_import' }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await waitFor(() => {
expect(screen.getByText(/list\.action\.sync/i)).toBeInTheDocument()
})
})
it('should show sync option for web documents', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, data_source_type: DataSourceType.WEB }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await waitFor(() => {
expect(screen.getByText(/list\.action\.sync/i)).toBeInTheDocument()
})
})
it('should show archive option for non-archived documents', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, archived: false }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await waitFor(() => {
expect(screen.getByText(/list\.action\.archive/i)).toBeInTheDocument()
})
})
it('should show unarchive option for archived documents', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, archived: true }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await waitFor(() => {
expect(screen.getByText(/list\.action\.unarchive/i)).toBeInTheDocument()
})
})
it('should show delete option', async () => {
render(<Operations {...defaultProps} />)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await waitFor(() => {
expect(screen.getByText(/list\.action\.delete/i)).toBeInTheDocument()
})
})
it('should show pause option when status is indexing', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, display_status: 'indexing', archived: false }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await waitFor(() => {
expect(screen.getByText(/list\.action\.pause/i)).toBeInTheDocument()
})
})
it('should show resume option when status is paused', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, display_status: 'paused', archived: false }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await waitFor(() => {
expect(screen.getByText(/list\.action\.resume/i)).toBeInTheDocument()
})
})
})
describe('Delete Confirmation Modal', () => {
it('should show delete confirmation modal when delete is clicked', async () => {
render(<Operations {...defaultProps} />)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await waitFor(() => {
const deleteButton = screen.getByText(/list\.action\.delete/i)
fireEvent.click(deleteButton)
})
await waitFor(() => {
expect(screen.getByText(/list\.delete\.title/i)).toBeInTheDocument()
expect(screen.getByText(/list\.delete\.content/i)).toBeInTheDocument()
})
})
})
describe('Scene Variations', () => {
it('should render correctly in detail scene', () => {
render(<Operations {...defaultProps} scene="detail" />)
// Settings button should still be visible
expect(screen.getAllByRole('button').length).toBeGreaterThan(0)
})
it('should apply different styles in detail scene', () => {
const { container } = render(<Operations {...defaultProps} scene="detail" />)
// The component should render without the list-specific styles
expect(container.firstChild).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle undefined detail properties', () => {
render(
<Operations
{...defaultProps}
detail={{
name: '',
enabled: false,
archived: false,
id: '',
data_source_type: '',
doc_form: '',
display_status: undefined,
}}
/>,
)
// Should not crash
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
})
it('should stop event propagation on click', () => {
const parentHandler = vi.fn()
render(
<div onClick={parentHandler}>
<Operations {...defaultProps} />
</div>,
)
const container = document.querySelector('.flex.items-center')
if (container)
fireEvent.click(container)
// Parent handler should not be called due to stopPropagation
expect(parentHandler).not.toHaveBeenCalled()
})
it('should handle custom className', () => {
render(<Operations {...defaultProps} className="custom-class" />)
// Component should render with the custom class
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
})
})
describe('Selected IDs Handling', () => {
it('should pass selectedIds to operations', () => {
render(
<Operations
{...defaultProps}
selectedIds={['doc-123', 'doc-456']}
onSelectedIdChange={vi.fn()}
/>,
)
// Component should render correctly with selectedIds
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,183 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Import after mock
import { renameDocumentName } from '@/service/datasets'
import RenameModal from './rename-modal'
// Mock the service
vi.mock('@/service/datasets', () => ({
renameDocumentName: vi.fn(),
}))
const mockRenameDocumentName = vi.mocked(renameDocumentName)
describe('RenameModal', () => {
const defaultProps = {
datasetId: 'dataset-123',
documentId: 'doc-456',
name: 'Original Document',
onClose: vi.fn(),
onSaved: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<RenameModal {...defaultProps} />)
expect(screen.getByText(/list\.table\.rename/i)).toBeInTheDocument()
})
it('should render modal title', () => {
render(<RenameModal {...defaultProps} />)
expect(screen.getByText(/list\.table\.rename/i)).toBeInTheDocument()
})
it('should render name label', () => {
render(<RenameModal {...defaultProps} />)
expect(screen.getByText(/list\.table\.name/i)).toBeInTheDocument()
})
it('should render input with initial name', () => {
render(<RenameModal {...defaultProps} />)
const input = screen.getByRole('textbox')
expect(input).toHaveValue('Original Document')
})
it('should render cancel button', () => {
render(<RenameModal {...defaultProps} />)
expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
})
it('should render save button', () => {
render(<RenameModal {...defaultProps} />)
expect(screen.getByText(/operation\.save/i)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should display the provided name in input', () => {
render(<RenameModal {...defaultProps} name="Custom Name" />)
const input = screen.getByRole('textbox')
expect(input).toHaveValue('Custom Name')
})
})
describe('User Interactions', () => {
it('should update input value when typing', () => {
render(<RenameModal {...defaultProps} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'New Name' } })
expect(input).toHaveValue('New Name')
})
it('should call onClose when cancel button is clicked', () => {
const handleClose = vi.fn()
render(<RenameModal {...defaultProps} onClose={handleClose} />)
const cancelButton = screen.getByText(/operation\.cancel/i)
fireEvent.click(cancelButton)
expect(handleClose).toHaveBeenCalledTimes(1)
})
it('should call renameDocumentName with correct params when save is clicked', async () => {
mockRenameDocumentName.mockResolvedValueOnce({ result: 'success' })
render(<RenameModal {...defaultProps} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'New Document Name' } })
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockRenameDocumentName).toHaveBeenCalledWith({
datasetId: 'dataset-123',
documentId: 'doc-456',
name: 'New Document Name',
})
})
})
it('should call onSaved and onClose on successful save', async () => {
mockRenameDocumentName.mockResolvedValueOnce({ result: 'success' })
const handleSaved = vi.fn()
const handleClose = vi.fn()
render(<RenameModal {...defaultProps} onSaved={handleSaved} onClose={handleClose} />)
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(handleSaved).toHaveBeenCalledTimes(1)
expect(handleClose).toHaveBeenCalledTimes(1)
})
})
})
describe('Loading State', () => {
it('should show loading state while saving', async () => {
// Create a promise that we can resolve manually
let resolvePromise: (value: { result: 'success' | 'fail' }) => void
const pendingPromise = new Promise<{ result: 'success' | 'fail' }>((resolve) => {
resolvePromise = resolve
})
mockRenameDocumentName.mockReturnValueOnce(pendingPromise)
render(<RenameModal {...defaultProps} />)
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
// The button should be in loading state
await waitFor(() => {
const buttons = screen.getAllByRole('button')
const saveBtn = buttons.find(btn => btn.textContent?.includes('operation.save'))
expect(saveBtn).toBeInTheDocument()
})
// Resolve the promise to clean up
resolvePromise!({ result: 'success' })
})
})
describe('Error Handling', () => {
it('should handle API error gracefully', async () => {
const error = new Error('API Error')
mockRenameDocumentName.mockRejectedValueOnce(error)
const handleSaved = vi.fn()
const handleClose = vi.fn()
render(<RenameModal {...defaultProps} onSaved={handleSaved} onClose={handleClose} />)
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
// onSaved and onClose should not be called on error
expect(handleSaved).not.toHaveBeenCalled()
expect(handleClose).not.toHaveBeenCalled()
})
})
})
describe('Edge Cases', () => {
it('should handle empty name', () => {
render(<RenameModal {...defaultProps} name="" />)
const input = screen.getByRole('textbox')
expect(input).toHaveValue('')
})
it('should handle name with special characters', () => {
render(<RenameModal {...defaultProps} name="Document <with> 'special' chars" />)
const input = screen.getByRole('textbox')
expect(input).toHaveValue('Document <with> \'special\' chars')
})
})
})

View File

@ -0,0 +1,279 @@
import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types'
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import type { NotionPage } from '@/models/common'
import type { CrawlResultItem, CustomFile, FileIndexingEstimateResponse, FileItem } from '@/models/datasets'
import type { OnlineDriveFile } from '@/models/pipeline'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DatasourceType } from '@/models/pipeline'
import { StepOnePreview, StepTwoPreview } from './preview-panel'
// Mock context hooks (底层依赖)
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: vi.fn((selector: (state: unknown) => unknown) => {
const mockState = {
dataset: {
id: 'mock-dataset-id',
doc_form: 'text_model',
pipeline_id: 'mock-pipeline-id',
},
}
return selector(mockState)
}),
}))
// Mock API hooks (底层依赖)
vi.mock('@/service/use-common', () => ({
useFilePreview: vi.fn(() => ({
data: { content: 'Mock file content for testing' },
isFetching: false,
})),
}))
vi.mock('@/service/use-pipeline', () => ({
usePreviewOnlineDocument: vi.fn(() => ({
mutateAsync: vi.fn().mockResolvedValue({ content: 'Mock document content' }),
isPending: false,
})),
}))
// Mock data source store
vi.mock('../data-source/store', () => ({
useDataSourceStore: vi.fn(() => ({
getState: () => ({ currentCredentialId: 'mock-credential-id' }),
})),
}))
describe('StepOnePreview', () => {
const mockDatasource: Datasource = {
nodeId: 'test-node-id',
nodeData: { type: 'data-source' } as unknown as DataSourceNodeType,
}
const mockLocalFile: CustomFile = {
id: 'file-1',
name: 'test-file.txt',
type: 'text/plain',
size: 1024,
progress: 100,
extension: 'txt',
} as unknown as CustomFile
const mockWebsite: CrawlResultItem = {
source_url: 'https://example.com',
title: 'Example Site',
markdown: 'Mock markdown content',
} as CrawlResultItem
const defaultProps = {
datasource: mockDatasource,
currentLocalFile: undefined,
currentDocument: undefined,
currentWebsite: undefined,
hidePreviewLocalFile: vi.fn(),
hidePreviewOnlineDocument: vi.fn(),
hideWebsitePreview: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<StepOnePreview {...defaultProps} />)
expect(container.querySelector('.h-full')).toBeInTheDocument()
})
it('should render container with correct structure', () => {
const { container } = render(<StepOnePreview {...defaultProps} />)
expect(container.querySelector('.flex.h-full.flex-col')).toBeInTheDocument()
})
})
describe('Conditional Rendering - FilePreview', () => {
it('should render FilePreview when currentLocalFile is provided', () => {
render(<StepOnePreview {...defaultProps} currentLocalFile={mockLocalFile} />)
// FilePreview renders a preview header with file name
expect(screen.getByText(/test-file/i)).toBeInTheDocument()
})
it('should not render FilePreview when currentLocalFile is undefined', () => {
const { container } = render(<StepOnePreview {...defaultProps} currentLocalFile={undefined} />)
// Container should still render but without file preview content
expect(container.querySelector('.h-full')).toBeInTheDocument()
})
})
describe('Conditional Rendering - WebsitePreview', () => {
it('should render WebsitePreview when currentWebsite is provided', () => {
render(<StepOnePreview {...defaultProps} currentWebsite={mockWebsite} />)
// WebsitePreview displays the website title and URL
expect(screen.getByText('Example Site')).toBeInTheDocument()
expect(screen.getByText('https://example.com')).toBeInTheDocument()
})
it('should not render WebsitePreview when currentWebsite is undefined', () => {
const { container } = render(<StepOnePreview {...defaultProps} currentWebsite={undefined} />)
expect(container.querySelector('.h-full')).toBeInTheDocument()
})
it('should call hideWebsitePreview when close button is clicked', () => {
const hideWebsitePreview = vi.fn()
render(
<StepOnePreview
{...defaultProps}
currentWebsite={mockWebsite}
hideWebsitePreview={hideWebsitePreview}
/>,
)
// Find and click the close button (RiCloseLine icon)
const closeButton = screen.getByRole('button')
closeButton.click()
expect(hideWebsitePreview).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {
it('should handle website with long markdown content', () => {
const longWebsite: CrawlResultItem = {
...mockWebsite,
markdown: 'A'.repeat(10000),
}
render(<StepOnePreview {...defaultProps} currentWebsite={longWebsite} />)
expect(screen.getByText('Example Site')).toBeInTheDocument()
})
})
})
describe('StepTwoPreview', () => {
const mockFileList: FileItem[] = [
{
file: {
id: 'file-1',
name: 'file1.txt',
extension: 'txt',
size: 1024,
} as CustomFile,
progress: 100,
},
{
file: {
id: 'file-2',
name: 'file2.txt',
extension: 'txt',
size: 2048,
} as CustomFile,
progress: 100,
},
] as FileItem[]
const mockOnlineDocuments: (NotionPage & { workspace_id: string })[] = [
{
page_id: 'page-1',
page_name: 'Page 1',
type: 'page',
workspace_id: 'workspace-1',
page_icon: null,
is_bound: false,
parent_id: '',
},
]
const mockWebsitePages: CrawlResultItem[] = [
{ source_url: 'https://example.com', title: 'Example', markdown: 'Content' } as CrawlResultItem,
]
const mockOnlineDriveFiles: OnlineDriveFile[] = [
{ id: 'drive-1', name: 'drive-file.txt' } as OnlineDriveFile,
]
const mockEstimateData: FileIndexingEstimateResponse = {
tokens: 1000,
total_price: 0.01,
total_segments: 10,
} as FileIndexingEstimateResponse
const defaultProps = {
datasourceType: DatasourceType.localFile,
localFileList: mockFileList,
onlineDocuments: mockOnlineDocuments,
websitePages: mockWebsitePages,
selectedOnlineDriveFileList: mockOnlineDriveFiles,
isIdle: true,
isPendingPreview: false,
estimateData: mockEstimateData,
onPreview: vi.fn(),
handlePreviewFileChange: vi.fn(),
handlePreviewOnlineDocumentChange: vi.fn(),
handlePreviewWebsitePageChange: vi.fn(),
handlePreviewOnlineDriveFileChange: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<StepTwoPreview {...defaultProps} />)
expect(container.querySelector('.h-full')).toBeInTheDocument()
})
it('should render ChunkPreview component structure', () => {
const { container } = render(<StepTwoPreview {...defaultProps} />)
expect(container.querySelector('.flex.h-full.flex-col')).toBeInTheDocument()
})
})
describe('Props Passing', () => {
it('should render preview button when isIdle is true', () => {
render(<StepTwoPreview {...defaultProps} isIdle={true} />)
// ChunkPreview shows a preview button when idle
const previewButton = screen.queryByRole('button')
expect(previewButton).toBeInTheDocument()
})
it('should call onPreview when preview button is clicked', () => {
const onPreview = vi.fn()
render(<StepTwoPreview {...defaultProps} isIdle={true} onPreview={onPreview} />)
// Find and click the preview button
const buttons = screen.getAllByRole('button')
const previewButton = buttons.find(btn => btn.textContent?.toLowerCase().includes('preview'))
if (previewButton) {
previewButton.click()
expect(onPreview).toHaveBeenCalled()
}
})
})
describe('Edge Cases', () => {
it('should handle empty localFileList', () => {
const { container } = render(<StepTwoPreview {...defaultProps} localFileList={[]} />)
expect(container.querySelector('.h-full')).toBeInTheDocument()
})
it('should handle empty onlineDocuments', () => {
const { container } = render(<StepTwoPreview {...defaultProps} onlineDocuments={[]} />)
expect(container.querySelector('.h-full')).toBeInTheDocument()
})
it('should handle empty websitePages', () => {
const { container } = render(<StepTwoPreview {...defaultProps} websitePages={[]} />)
expect(container.querySelector('.h-full')).toBeInTheDocument()
})
it('should handle empty onlineDriveFiles', () => {
const { container } = render(<StepTwoPreview {...defaultProps} selectedOnlineDriveFileList={[]} />)
expect(container.querySelector('.h-full')).toBeInTheDocument()
})
it('should handle undefined estimateData', () => {
const { container } = render(<StepTwoPreview {...defaultProps} estimateData={undefined} />)
expect(container.querySelector('.h-full')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,413 @@
import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types'
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import type { Node } from '@/app/components/workflow/types'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DatasourceType } from '@/models/pipeline'
import StepOneContent from './step-one-content'
// Mock context providers and hooks (底层依赖)
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(() => ({
setShowPricingModal: vi.fn(),
})),
}))
// Mock billing components that have complex provider dependencies
vi.mock('@/app/components/billing/vector-space-full', () => ({
default: () => <div data-testid="vector-space-full">Vector Space Full</div>,
}))
vi.mock('@/app/components/billing/upgrade-btn', () => ({
default: ({ onClick }: { onClick?: () => void }) => (
<button data-testid="upgrade-btn" onClick={onClick}>Upgrade</button>
),
}))
// Mock data source store
vi.mock('../data-source/store', () => ({
useDataSourceStore: vi.fn(() => ({
getState: () => ({
localFileList: [],
currentCredentialId: 'mock-credential-id',
}),
setState: vi.fn(),
})),
useDataSourceStoreWithSelector: vi.fn((selector: (state: unknown) => unknown) => {
const mockState = {
localFileList: [],
onlineDocuments: [],
websitePages: [],
selectedOnlineDriveFileList: [],
}
return selector(mockState)
}),
}))
// Mock file upload config
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: vi.fn(() => ({
data: {
file_size_limit: 15 * 1024 * 1024,
batch_count_limit: 20,
document_file_extensions: ['.txt', '.md', '.pdf'],
},
isLoading: false,
})),
}))
// Mock hooks used by data source options
vi.mock('../hooks', () => ({
useDatasourceOptions: vi.fn(() => [
{ label: 'Local File', value: 'node-1', data: { type: 'data-source' } },
]),
}))
// Mock useDatasourceIcon hook to avoid complex data source list transformation
vi.mock('../data-source-options/hooks', () => ({
useDatasourceIcon: vi.fn(() => '/icons/local-file.svg'),
}))
// Mock the entire local-file component since it has deep context dependencies
vi.mock('../data-source/local-file', () => ({
default: ({ allowedExtensions, supportBatchUpload }: {
allowedExtensions: string[]
supportBatchUpload: boolean
}) => (
<div data-testid="local-file">
<div>Drag and drop file here</div>
<span data-testid="allowed-extensions">{allowedExtensions.join(',')}</span>
<span data-testid="support-batch-upload">{String(supportBatchUpload)}</span>
</div>
),
}))
// Mock online documents since it has complex OAuth/API dependencies
vi.mock('../data-source/online-documents', () => ({
default: ({ nodeId, onCredentialChange }: {
nodeId: string
onCredentialChange: (credentialId: string) => void
}) => (
<div data-testid="online-documents">
<span data-testid="online-doc-node-id">{nodeId}</span>
<button data-testid="credential-change-btn" onClick={() => onCredentialChange('new-credential')}>
Change Credential
</button>
</div>
),
}))
// Mock website crawl
vi.mock('../data-source/website-crawl', () => ({
default: ({ nodeId, onCredentialChange }: {
nodeId: string
onCredentialChange: (credentialId: string) => void
}) => (
<div data-testid="website-crawl">
<span data-testid="website-crawl-node-id">{nodeId}</span>
<button data-testid="website-credential-btn" onClick={() => onCredentialChange('website-credential')}>
Change Website Credential
</button>
</div>
),
}))
// Mock online drive
vi.mock('../data-source/online-drive', () => ({
default: ({ nodeId, onCredentialChange }: {
nodeId: string
onCredentialChange: (credentialId: string) => void
}) => (
<div data-testid="online-drive">
<span data-testid="online-drive-node-id">{nodeId}</span>
<button data-testid="drive-credential-btn" onClick={() => onCredentialChange('drive-credential')}>
Change Drive Credential
</button>
</div>
),
}))
// Mock locale context
vi.mock('@/context/i18n', () => ({
useLocale: vi.fn(() => 'en'),
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
}))
// Mock theme hook
vi.mock('@/hooks/use-theme', () => ({
default: vi.fn(() => 'light'),
}))
// Mock upload service
vi.mock('@/service/base', () => ({
upload: vi.fn().mockResolvedValue({ id: 'uploaded-file-id' }),
}))
// Mock next/navigation
vi.mock('next/navigation', () => ({
useParams: () => ({ datasetId: 'mock-dataset-id' }),
useRouter: () => ({ push: vi.fn() }),
usePathname: () => '/datasets/mock-dataset-id',
}))
// Mock pipeline service hooks
vi.mock('@/service/use-pipeline', () => ({
useNotionWorkspaces: vi.fn(() => ({
data: [],
isLoading: false,
})),
useNotionPages: vi.fn(() => ({
data: { pages: [] },
isLoading: false,
})),
useDataSourceList: vi.fn(() => ({
data: [
{
type: 'local_file',
declaration: {
identity: {
name: 'Local File',
icon: '/icons/local-file.svg',
},
},
},
],
isSuccess: true,
isLoading: false,
})),
useCrawlResult: vi.fn(() => ({
data: { data: [] },
isLoading: false,
})),
useSupportedOauth: vi.fn(() => ({
data: [],
isLoading: false,
})),
useOnlineDriveCredentialList: vi.fn(() => ({
data: [],
isLoading: false,
})),
useOnlineDriveFileList: vi.fn(() => ({
data: { data: [] },
isLoading: false,
})),
}))
describe('StepOneContent', () => {
const mockDatasource: Datasource = {
nodeId: 'test-node-id',
nodeData: {
type: 'data-source',
fileExtensions: ['txt', 'pdf'],
title: 'Test Data Source',
desc: 'Test description',
} as unknown as DataSourceNodeType,
}
const mockPipelineNodes: Node<DataSourceNodeType>[] = [
{
id: 'node-1',
data: {
type: 'data-source',
title: 'Node 1',
desc: 'Description 1',
} as unknown as DataSourceNodeType,
} as Node<DataSourceNodeType>,
{
id: 'node-2',
data: {
type: 'data-source',
title: 'Node 2',
desc: 'Description 2',
} as unknown as DataSourceNodeType,
} as Node<DataSourceNodeType>,
]
const defaultProps = {
datasource: mockDatasource,
datasourceType: DatasourceType.localFile,
pipelineNodes: mockPipelineNodes,
supportBatchUpload: true,
localFileListLength: 0,
isShowVectorSpaceFull: false,
showSelect: false,
totalOptions: 10,
selectedOptions: 5,
tip: 'Test tip',
nextBtnDisabled: false,
onSelectDataSource: vi.fn(),
onCredentialChange: vi.fn(),
onSelectAll: vi.fn(),
onNextStep: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<StepOneContent {...defaultProps} />)
expect(container.querySelector('.flex.flex-col')).toBeInTheDocument()
})
it('should render DataSourceOptions component', () => {
render(<StepOneContent {...defaultProps} />)
// DataSourceOptions renders option cards
expect(screen.getByText('Local File')).toBeInTheDocument()
})
it('should render Actions component with next button', () => {
render(<StepOneContent {...defaultProps} />)
// Actions component renders a next step button (uses i18n key)
const nextButton = screen.getByRole('button', { name: /datasetCreation\.stepOne\.button/i })
expect(nextButton).toBeInTheDocument()
})
})
describe('Conditional Rendering - DatasourceType', () => {
it('should render LocalFile component when datasourceType is localFile', () => {
render(<StepOneContent {...defaultProps} datasourceType={DatasourceType.localFile} />)
expect(screen.getByTestId('local-file')).toBeInTheDocument()
})
it('should render OnlineDocuments component when datasourceType is onlineDocument', () => {
render(<StepOneContent {...defaultProps} datasourceType={DatasourceType.onlineDocument} />)
expect(screen.getByTestId('online-documents')).toBeInTheDocument()
})
it('should render WebsiteCrawl component when datasourceType is websiteCrawl', () => {
render(<StepOneContent {...defaultProps} datasourceType={DatasourceType.websiteCrawl} />)
expect(screen.getByTestId('website-crawl')).toBeInTheDocument()
})
it('should render OnlineDrive component when datasourceType is onlineDrive', () => {
render(<StepOneContent {...defaultProps} datasourceType={DatasourceType.onlineDrive} />)
expect(screen.getByTestId('online-drive')).toBeInTheDocument()
})
it('should not render data source component when datasourceType is undefined', () => {
const { container } = render(<StepOneContent {...defaultProps} datasourceType={undefined} />)
expect(container.querySelector('.flex.flex-col')).toBeInTheDocument()
expect(screen.queryByTestId('local-file')).not.toBeInTheDocument()
})
})
describe('Conditional Rendering - VectorSpaceFull', () => {
it('should render VectorSpaceFull when isShowVectorSpaceFull is true', () => {
render(<StepOneContent {...defaultProps} isShowVectorSpaceFull={true} />)
expect(screen.getByTestId('vector-space-full')).toBeInTheDocument()
})
it('should not render VectorSpaceFull when isShowVectorSpaceFull is false', () => {
render(<StepOneContent {...defaultProps} isShowVectorSpaceFull={false} />)
expect(screen.queryByTestId('vector-space-full')).not.toBeInTheDocument()
})
})
describe('Conditional Rendering - UpgradeCard', () => {
it('should render UpgradeCard when batch upload not supported and has local files', () => {
render(
<StepOneContent
{...defaultProps}
supportBatchUpload={false}
datasourceType={DatasourceType.localFile}
localFileListLength={3}
/>,
)
// UpgradeCard contains an upgrade button
expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument()
})
it('should not render UpgradeCard when batch upload is supported', () => {
render(
<StepOneContent
{...defaultProps}
supportBatchUpload={true}
datasourceType={DatasourceType.localFile}
localFileListLength={3}
/>,
)
// The upgrade card should not be present
const upgradeCard = screen.queryByText(/upload multiple files/i)
expect(upgradeCard).not.toBeInTheDocument()
})
it('should not render UpgradeCard when datasourceType is not localFile', () => {
render(
<StepOneContent
{...defaultProps}
supportBatchUpload={false}
datasourceType={undefined}
localFileListLength={3}
/>,
)
expect(screen.queryByTestId('upgrade-btn')).not.toBeInTheDocument()
})
it('should not render UpgradeCard when localFileListLength is 0', () => {
render(
<StepOneContent
{...defaultProps}
supportBatchUpload={false}
datasourceType={DatasourceType.localFile}
localFileListLength={0}
/>,
)
expect(screen.queryByTestId('upgrade-btn')).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onNextStep when next button is clicked', () => {
const onNextStep = vi.fn()
render(<StepOneContent {...defaultProps} onNextStep={onNextStep} />)
const nextButton = screen.getByRole('button', { name: /datasetCreation\.stepOne\.button/i })
nextButton.click()
expect(onNextStep).toHaveBeenCalledTimes(1)
})
it('should disable next button when nextBtnDisabled is true', () => {
render(<StepOneContent {...defaultProps} nextBtnDisabled={true} />)
const nextButton = screen.getByRole('button', { name: /datasetCreation\.stepOne\.button/i })
expect(nextButton).toBeDisabled()
})
})
describe('Edge Cases', () => {
it('should handle undefined datasource when datasourceType is undefined', () => {
const { container } = render(
<StepOneContent {...defaultProps} datasource={undefined} datasourceType={undefined} />,
)
expect(container.querySelector('.flex.flex-col')).toBeInTheDocument()
})
it('should handle empty pipelineNodes array', () => {
render(<StepOneContent {...defaultProps} pipelineNodes={[]} />)
// Should still render but DataSourceOptions may show no options
const { container } = render(<StepOneContent {...defaultProps} pipelineNodes={[]} />)
expect(container.querySelector('.flex.flex-col')).toBeInTheDocument()
})
it('should handle undefined totalOptions', () => {
render(<StepOneContent {...defaultProps} totalOptions={undefined} />)
const nextButton = screen.getByRole('button', { name: /datasetCreation\.stepOne\.button/i })
expect(nextButton).toBeInTheDocument()
})
it('should handle undefined selectedOptions', () => {
render(<StepOneContent {...defaultProps} selectedOptions={undefined} />)
const nextButton = screen.getByRole('button', { name: /datasetCreation\.stepOne\.button/i })
expect(nextButton).toBeInTheDocument()
})
it('should handle empty tip', () => {
render(<StepOneContent {...defaultProps} tip="" />)
const nextButton = screen.getByRole('button', { name: /datasetCreation\.stepOne\.button/i })
expect(nextButton).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,97 @@
import type { InitialDocumentDetail } from '@/models/pipeline'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import StepThreeContent from './step-three-content'
// Mock context hooks used by Processing component
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: vi.fn((selector: (state: unknown) => unknown) => {
const mockState = {
dataset: {
id: 'mock-dataset-id',
indexing_technique: 'high_quality',
retrieval_model_dict: {
search_method: 'semantic_search',
},
},
}
return selector(mockState)
}),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
}))
// Mock EmbeddingProcess component as it has complex dependencies
vi.mock('../processing/embedding-process', () => ({
default: ({ datasetId, batchId, documents }: {
datasetId: string
batchId: string
documents: InitialDocumentDetail[]
}) => (
<div data-testid="embedding-process">
<span data-testid="dataset-id">{datasetId}</span>
<span data-testid="batch-id">{batchId}</span>
<span data-testid="documents-count">{documents.length}</span>
</div>
),
}))
describe('StepThreeContent', () => {
const mockDocuments: InitialDocumentDetail[] = [
{ id: 'doc1', name: 'Document 1' } as InitialDocumentDetail,
{ id: 'doc2', name: 'Document 2' } as InitialDocumentDetail,
]
const defaultProps = {
batchId: 'test-batch-id',
documents: mockDocuments,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<StepThreeContent {...defaultProps} />)
expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
})
it('should render Processing component', () => {
render(<StepThreeContent {...defaultProps} />)
expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass batchId to Processing component', () => {
render(<StepThreeContent {...defaultProps} />)
expect(screen.getByTestId('batch-id')).toHaveTextContent('test-batch-id')
})
it('should pass documents to Processing component', () => {
render(<StepThreeContent {...defaultProps} />)
expect(screen.getByTestId('documents-count')).toHaveTextContent('2')
})
it('should handle empty documents array', () => {
render(<StepThreeContent batchId="test-batch-id" documents={[]} />)
expect(screen.getByTestId('documents-count')).toHaveTextContent('0')
})
})
describe('Edge Cases', () => {
it('should render with different batchId', () => {
render(<StepThreeContent batchId="another-batch-id" documents={mockDocuments} />)
expect(screen.getByTestId('batch-id')).toHaveTextContent('another-batch-id')
})
it('should render with single document', () => {
const singleDocument = [mockDocuments[0]]
render(<StepThreeContent batchId="test-batch-id" documents={singleDocument} />)
expect(screen.getByTestId('documents-count')).toHaveTextContent('1')
})
})
})

View File

@ -0,0 +1,136 @@
import type { RefObject } from 'react'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import StepTwoContent from './step-two-content'
// Mock ProcessDocuments component as it has complex hook dependencies
vi.mock('../process-documents', () => ({
default: vi.fn().mockImplementation(({
dataSourceNodeId,
isRunning,
onProcess,
onPreview,
onSubmit,
onBack,
}: {
dataSourceNodeId: string
isRunning: boolean
onProcess: () => void
onPreview: () => void
onSubmit: (data: Record<string, unknown>) => void
onBack: () => void
}) => (
<div data-testid="process-documents">
<span data-testid="data-source-node-id">{dataSourceNodeId}</span>
<span data-testid="is-running">{String(isRunning)}</span>
<button data-testid="process-btn" onClick={onProcess}>Process</button>
<button data-testid="preview-btn" onClick={onPreview}>Preview</button>
<button data-testid="submit-btn" onClick={() => onSubmit({ key: 'value' })}>Submit</button>
<button data-testid="back-btn" onClick={onBack}>Back</button>
</div>
)),
}))
describe('StepTwoContent', () => {
const mockFormRef: RefObject<{ submit: () => void } | null> = { current: null }
const defaultProps = {
formRef: mockFormRef,
dataSourceNodeId: 'test-node-id',
isRunning: false,
onProcess: vi.fn(),
onPreview: vi.fn(),
onSubmit: vi.fn(),
onBack: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<StepTwoContent {...defaultProps} />)
expect(screen.getByTestId('process-documents')).toBeInTheDocument()
})
it('should render ProcessDocuments component', () => {
render(<StepTwoContent {...defaultProps} />)
expect(screen.getByTestId('process-documents')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass dataSourceNodeId to ProcessDocuments', () => {
render(<StepTwoContent {...defaultProps} />)
expect(screen.getByTestId('data-source-node-id')).toHaveTextContent('test-node-id')
})
it('should pass isRunning false to ProcessDocuments', () => {
render(<StepTwoContent {...defaultProps} isRunning={false} />)
expect(screen.getByTestId('is-running')).toHaveTextContent('false')
})
it('should pass isRunning true to ProcessDocuments', () => {
render(<StepTwoContent {...defaultProps} isRunning={true} />)
expect(screen.getByTestId('is-running')).toHaveTextContent('true')
})
it('should pass different dataSourceNodeId', () => {
render(<StepTwoContent {...defaultProps} dataSourceNodeId="different-node-id" />)
expect(screen.getByTestId('data-source-node-id')).toHaveTextContent('different-node-id')
})
})
describe('User Interactions', () => {
it('should call onProcess when process button is clicked', () => {
const onProcess = vi.fn()
render(<StepTwoContent {...defaultProps} onProcess={onProcess} />)
screen.getByTestId('process-btn').click()
expect(onProcess).toHaveBeenCalledTimes(1)
})
it('should call onPreview when preview button is clicked', () => {
const onPreview = vi.fn()
render(<StepTwoContent {...defaultProps} onPreview={onPreview} />)
screen.getByTestId('preview-btn').click()
expect(onPreview).toHaveBeenCalledTimes(1)
})
it('should call onSubmit when submit button is clicked', () => {
const onSubmit = vi.fn()
render(<StepTwoContent {...defaultProps} onSubmit={onSubmit} />)
screen.getByTestId('submit-btn').click()
expect(onSubmit).toHaveBeenCalledTimes(1)
expect(onSubmit).toHaveBeenCalledWith({ key: 'value' })
})
it('should call onBack when back button is clicked', () => {
const onBack = vi.fn()
render(<StepTwoContent {...defaultProps} onBack={onBack} />)
screen.getByTestId('back-btn').click()
expect(onBack).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {
it('should handle empty dataSourceNodeId', () => {
render(<StepTwoContent {...defaultProps} dataSourceNodeId="" />)
expect(screen.getByTestId('data-source-node-id')).toHaveTextContent('')
})
it('should handle null formRef', () => {
const nullRef = { current: null }
render(<StepTwoContent {...defaultProps} formRef={nullRef} />)
expect(screen.getByTestId('process-documents')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,243 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LanguagesSupported } from '@/i18n-config/language'
import { ChunkingMode } from '@/models/datasets'
import CSVDownload from './csv-downloader'
// Mock useLocale
let mockLocale = LanguagesSupported[0] // en-US
vi.mock('@/context/i18n', () => ({
useLocale: () => mockLocale,
}))
// Mock react-papaparse
const MockCSVDownloader = ({ children, data, filename, type }: { children: ReactNode, data: unknown, filename: string, type: string }) => (
<div
data-testid="csv-downloader-link"
data-filename={filename}
data-type={type}
data-data={JSON.stringify(data)}
>
{children}
</div>
)
vi.mock('react-papaparse', () => ({
useCSVDownloader: () => ({
CSVDownloader: MockCSVDownloader,
Type: { Link: 'link' },
}),
}))
describe('CSVDownloader', () => {
beforeEach(() => {
vi.clearAllMocks()
mockLocale = LanguagesSupported[0] // Reset to English
})
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<CSVDownload docForm={ChunkingMode.text} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render structure title', () => {
// Arrange & Act
render(<CSVDownload docForm={ChunkingMode.text} />)
// Assert - i18n key format
expect(screen.getByText(/csvStructureTitle/i)).toBeInTheDocument()
})
it('should render download template link', () => {
// Arrange & Act
render(<CSVDownload docForm={ChunkingMode.text} />)
// Assert
expect(screen.getByTestId('csv-downloader-link')).toBeInTheDocument()
expect(screen.getByText(/list\.batchModal\.template/i)).toBeInTheDocument()
})
})
// Table structure for QA mode
describe('QA Mode Table', () => {
it('should render QA table with question and answer columns when docForm is qa', () => {
// Arrange & Act
render(<CSVDownload docForm={ChunkingMode.qa} />)
// Assert - Check for question/answer headers
const questionHeaders = screen.getAllByText(/list\.batchModal\.question/i)
const answerHeaders = screen.getAllByText(/list\.batchModal\.answer/i)
expect(questionHeaders.length).toBeGreaterThan(0)
expect(answerHeaders.length).toBeGreaterThan(0)
})
it('should render two data rows for QA mode', () => {
// Arrange & Act
const { container } = render(<CSVDownload docForm={ChunkingMode.qa} />)
// Assert
const tbody = container.querySelector('tbody')
expect(tbody).toBeInTheDocument()
const rows = tbody?.querySelectorAll('tr')
expect(rows?.length).toBe(2)
})
})
// Table structure for Text mode
describe('Text Mode Table', () => {
it('should render text table with content column when docForm is text', () => {
// Arrange & Act
render(<CSVDownload docForm={ChunkingMode.text} />)
// Assert - Check for content header
expect(screen.getByText(/list\.batchModal\.contentTitle/i)).toBeInTheDocument()
})
it('should not render question/answer columns in text mode', () => {
// Arrange & Act
render(<CSVDownload docForm={ChunkingMode.text} />)
// Assert
expect(screen.queryByText(/list\.batchModal\.question/i)).not.toBeInTheDocument()
expect(screen.queryByText(/list\.batchModal\.answer/i)).not.toBeInTheDocument()
})
it('should render two data rows for text mode', () => {
// Arrange & Act
const { container } = render(<CSVDownload docForm={ChunkingMode.text} />)
// Assert
const tbody = container.querySelector('tbody')
expect(tbody).toBeInTheDocument()
const rows = tbody?.querySelectorAll('tr')
expect(rows?.length).toBe(2)
})
})
// CSV Template Data
describe('CSV Template Data', () => {
it('should provide English QA template when locale is English and docForm is qa', () => {
// Arrange
mockLocale = LanguagesSupported[0] // en-US
// Act
render(<CSVDownload docForm={ChunkingMode.qa} />)
// Assert
const link = screen.getByTestId('csv-downloader-link')
const data = JSON.parse(link.getAttribute('data-data') || '[]')
expect(data).toEqual([
['question', 'answer'],
['question1', 'answer1'],
['question2', 'answer2'],
])
})
it('should provide English text template when locale is English and docForm is text', () => {
// Arrange
mockLocale = LanguagesSupported[0] // en-US
// Act
render(<CSVDownload docForm={ChunkingMode.text} />)
// Assert
const link = screen.getByTestId('csv-downloader-link')
const data = JSON.parse(link.getAttribute('data-data') || '[]')
expect(data).toEqual([
['segment content'],
['content1'],
['content2'],
])
})
it('should provide Chinese QA template when locale is Chinese and docForm is qa', () => {
// Arrange
mockLocale = LanguagesSupported[1] // zh-Hans
// Act
render(<CSVDownload docForm={ChunkingMode.qa} />)
// Assert
const link = screen.getByTestId('csv-downloader-link')
const data = JSON.parse(link.getAttribute('data-data') || '[]')
expect(data).toEqual([
['问题', '答案'],
['问题 1', '答案 1'],
['问题 2', '答案 2'],
])
})
it('should provide Chinese text template when locale is Chinese and docForm is text', () => {
// Arrange
mockLocale = LanguagesSupported[1] // zh-Hans
// Act
render(<CSVDownload docForm={ChunkingMode.text} />)
// Assert
const link = screen.getByTestId('csv-downloader-link')
const data = JSON.parse(link.getAttribute('data-data') || '[]')
expect(data).toEqual([
['分段内容'],
['内容 1'],
['内容 2'],
])
})
})
// CSVDownloader props
describe('CSVDownloader Props', () => {
it('should set filename to template', () => {
// Arrange & Act
render(<CSVDownload docForm={ChunkingMode.text} />)
// Assert
const link = screen.getByTestId('csv-downloader-link')
expect(link.getAttribute('data-filename')).toBe('template')
})
it('should set type to Link', () => {
// Arrange & Act
render(<CSVDownload docForm={ChunkingMode.text} />)
// Assert
const link = screen.getByTestId('csv-downloader-link')
expect(link.getAttribute('data-type')).toBe('link')
})
})
// Edge cases
describe('Edge Cases', () => {
it('should maintain structure when rerendered with different docForm', () => {
// Arrange
const { rerender } = render(<CSVDownload docForm={ChunkingMode.text} />)
// Act
rerender(<CSVDownload docForm={ChunkingMode.qa} />)
// Assert - should now show QA table
expect(screen.getAllByText(/list\.batchModal\.question/i).length).toBeGreaterThan(0)
})
it('should render correctly for non-English locales', () => {
// Arrange
mockLocale = LanguagesSupported[1] // zh-Hans
// Act
render(<CSVDownload docForm={ChunkingMode.qa} />)
// Assert - Check that Chinese template is used
const link = screen.getByTestId('csv-downloader-link')
const data = JSON.parse(link.getAttribute('data-data') || '[]')
expect(data[0]).toEqual(['问题', '答案'])
})
})
})

View File

@ -0,0 +1,485 @@
import type { ReactNode } from 'react'
import type { CustomFile, FileItem } from '@/models/datasets'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Theme } from '@/types/app'
import CSVUploader from './csv-uploader'
// Mock upload service
const mockUpload = vi.fn()
vi.mock('@/service/base', () => ({
upload: (...args: unknown[]) => mockUpload(...args),
}))
// Mock useFileUploadConfig
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: () => ({
data: { file_size_limit: 15 },
}),
}))
// Mock useTheme
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: Theme.light }),
}))
// Mock ToastContext
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
ToastContext: {
Provider: ({ children }: { children: ReactNode }) => children,
Consumer: ({ children }: { children: (ctx: { notify: typeof mockNotify }) => ReactNode }) => children({ notify: mockNotify }),
},
}))
// Create a mock ToastContext for useContext
vi.mock('use-context-selector', async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>
return {
...actual,
useContext: () => ({ notify: mockNotify }),
}
})
describe('CSVUploader', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const defaultProps = {
file: undefined as FileItem | undefined,
updateFile: vi.fn(),
}
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<CSVUploader {...defaultProps} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render upload area when no file is present', () => {
// Arrange & Act
render(<CSVUploader {...defaultProps} />)
// Assert
expect(screen.getByText(/list\.batchModal\.csvUploadTitle/i)).toBeInTheDocument()
expect(screen.getByText(/list\.batchModal\.browse/i)).toBeInTheDocument()
})
it('should render hidden file input', () => {
// Arrange & Act
const { container } = render(<CSVUploader {...defaultProps} />)
// Assert
const fileInput = container.querySelector('input[type="file"]')
expect(fileInput).toBeInTheDocument()
expect(fileInput).toHaveStyle({ display: 'none' })
})
it('should accept only CSV files', () => {
// Arrange & Act
const { container } = render(<CSVUploader {...defaultProps} />)
// Assert
const fileInput = container.querySelector('input[type="file"]')
expect(fileInput).toHaveAttribute('accept', '.csv')
})
})
// File display tests
describe('File Display', () => {
it('should display file info when file is present', () => {
// Arrange
const mockFile: FileItem = {
fileID: 'file-1',
file: new File(['content'], 'test-file.csv', { type: 'text/csv' }) as CustomFile,
progress: 100,
}
// Act
render(<CSVUploader {...defaultProps} file={mockFile} />)
// Assert
expect(screen.getByText('test-file')).toBeInTheDocument()
expect(screen.getByText('.csv')).toBeInTheDocument()
})
it('should not show upload area when file is present', () => {
// Arrange
const mockFile: FileItem = {
fileID: 'file-1',
file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile,
progress: 100,
}
// Act
render(<CSVUploader {...defaultProps} file={mockFile} />)
// Assert
expect(screen.queryByText(/list\.batchModal\.csvUploadTitle/i)).not.toBeInTheDocument()
})
it('should show change button when file is present', () => {
// Arrange
const mockFile: FileItem = {
fileID: 'file-1',
file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile,
progress: 100,
}
// Act
render(<CSVUploader {...defaultProps} file={mockFile} />)
// Assert
expect(screen.getByText(/stepOne\.uploader\.change/i)).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should trigger file input click when browse is clicked', () => {
// Arrange
const { container } = render(<CSVUploader {...defaultProps} />)
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
const clickSpy = vi.spyOn(fileInput, 'click')
// Act
fireEvent.click(screen.getByText(/list\.batchModal\.browse/i))
// Assert
expect(clickSpy).toHaveBeenCalled()
})
it('should call updateFile when file is selected', async () => {
// Arrange
const mockUpdateFile = vi.fn()
mockUpload.mockResolvedValueOnce({ id: 'uploaded-id' })
const { container } = render(
<CSVUploader {...defaultProps} updateFile={mockUpdateFile} />,
)
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
const testFile = new File(['content'], 'test.csv', { type: 'text/csv' })
// Act
fireEvent.change(fileInput, { target: { files: [testFile] } })
// Assert
await waitFor(() => {
expect(mockUpdateFile).toHaveBeenCalled()
})
})
it('should call updateFile with undefined when remove is clicked', () => {
// Arrange
const mockUpdateFile = vi.fn()
const mockFile: FileItem = {
fileID: 'file-1',
file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile,
progress: 100,
}
const { container } = render(
<CSVUploader {...defaultProps} file={mockFile} updateFile={mockUpdateFile} />,
)
// Act
const deleteButton = container.querySelector('.cursor-pointer')
if (deleteButton)
fireEvent.click(deleteButton)
// Assert
expect(mockUpdateFile).toHaveBeenCalledWith()
})
})
// Validation tests
describe('Validation', () => {
it('should show error for non-CSV files', () => {
// Arrange
const { container } = render(<CSVUploader {...defaultProps} />)
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
const testFile = new File(['content'], 'test.txt', { type: 'text/plain' })
// Act
fireEvent.change(fileInput, { target: { files: [testFile] } })
// Assert
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
)
})
it('should show error for files exceeding size limit', () => {
// Arrange
const { container } = render(<CSVUploader {...defaultProps} />)
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
// Create a mock file with a large size (16MB) without actually creating the data
const testFile = new File(['test'], 'large.csv', { type: 'text/csv' })
Object.defineProperty(testFile, 'size', { value: 16 * 1024 * 1024 })
// Act
fireEvent.change(fileInput, { target: { files: [testFile] } })
// Assert
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
)
})
})
// Upload progress tests
describe('Upload Progress', () => {
it('should show progress indicator when upload is in progress', () => {
// Arrange
const mockFile: FileItem = {
fileID: 'file-1',
file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile,
progress: 50,
}
// Act
const { container } = render(<CSVUploader {...defaultProps} file={mockFile} />)
// Assert - SimplePieChart should be rendered for progress 0-99
// The pie chart would be in the hidden group element
expect(container.querySelector('.group')).toBeInTheDocument()
})
it('should not show progress for completed uploads', () => {
// Arrange
const mockFile: FileItem = {
fileID: 'file-1',
file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile,
progress: 100,
}
// Act
render(<CSVUploader {...defaultProps} file={mockFile} />)
// Assert - File name should be displayed
expect(screen.getByText('test')).toBeInTheDocument()
})
})
// Props tests
describe('Props', () => {
it('should call updateFile prop when provided', async () => {
// Arrange
const mockUpdateFile = vi.fn()
mockUpload.mockResolvedValueOnce({ id: 'test-id' })
const { container } = render(
<CSVUploader file={undefined} updateFile={mockUpdateFile} />,
)
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
const testFile = new File(['content'], 'test.csv', { type: 'text/csv' })
// Act
fireEvent.change(fileInput, { target: { files: [testFile] } })
// Assert
await waitFor(() => {
expect(mockUpdateFile).toHaveBeenCalled()
})
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle empty file list', () => {
// Arrange
const mockUpdateFile = vi.fn()
const { container } = render(
<CSVUploader {...defaultProps} updateFile={mockUpdateFile} />,
)
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
// Act
fireEvent.change(fileInput, { target: { files: [] } })
// Assert
expect(mockUpdateFile).not.toHaveBeenCalled()
})
it('should handle null file', () => {
// Arrange
const mockUpdateFile = vi.fn()
const { container } = render(
<CSVUploader {...defaultProps} updateFile={mockUpdateFile} />,
)
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
// Act
fireEvent.change(fileInput, { target: { files: null } })
// Assert
expect(mockUpdateFile).not.toHaveBeenCalled()
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender } = render(<CSVUploader {...defaultProps} />)
// Act
const mockFile: FileItem = {
fileID: 'file-1',
file: new File(['content'], 'updated.csv', { type: 'text/csv' }) as CustomFile,
progress: 100,
}
rerender(<CSVUploader {...defaultProps} file={mockFile} />)
// Assert
expect(screen.getByText('updated')).toBeInTheDocument()
})
it('should handle upload error', async () => {
// Arrange
const mockUpdateFile = vi.fn()
mockUpload.mockRejectedValueOnce(new Error('Upload failed'))
const { container } = render(
<CSVUploader {...defaultProps} updateFile={mockUpdateFile} />,
)
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
const testFile = new File(['content'], 'test.csv', { type: 'text/csv' })
// Act
fireEvent.change(fileInput, { target: { files: [testFile] } })
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
)
})
})
it('should handle file without extension', () => {
// Arrange
const { container } = render(<CSVUploader {...defaultProps} />)
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
const testFile = new File(['content'], 'noextension', { type: 'text/plain' })
// Act
fireEvent.change(fileInput, { target: { files: [testFile] } })
// Assert
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
)
})
})
// Drag and drop tests
// Note: Native drag and drop events use addEventListener which is set up in useEffect.
// Testing these requires triggering native DOM events on the actual dropRef element.
describe('Drag and Drop', () => {
it('should render drop zone element', () => {
// Arrange & Act
const { container } = render(<CSVUploader {...defaultProps} />)
// Assert - drop zone should exist for drag and drop
const dropZone = container.querySelector('div > div')
expect(dropZone).toBeInTheDocument()
})
it('should have drag overlay element that can appear during drag', () => {
// Arrange & Act
const { container } = render(<CSVUploader {...defaultProps} />)
// Assert - component structure supports dragging
expect(container.querySelector('div')).toBeInTheDocument()
})
})
// Upload progress callback tests
describe('Upload Progress Callbacks', () => {
it('should update progress during file upload', async () => {
// Arrange
const mockUpdateFile = vi.fn()
let progressCallback: ((e: ProgressEvent) => void) | undefined
mockUpload.mockImplementation(({ onprogress }) => {
progressCallback = onprogress
return Promise.resolve({ id: 'uploaded-id' })
})
const { container } = render(
<CSVUploader {...defaultProps} updateFile={mockUpdateFile} />,
)
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
const testFile = new File(['content'], 'test.csv', { type: 'text/csv' })
// Act
fireEvent.change(fileInput, { target: { files: [testFile] } })
// Simulate progress event
if (progressCallback) {
const progressEvent = new ProgressEvent('progress', {
lengthComputable: true,
loaded: 50,
total: 100,
})
progressCallback(progressEvent)
}
// Assert
await waitFor(() => {
expect(mockUpdateFile).toHaveBeenCalledWith(
expect.objectContaining({
progress: expect.any(Number),
}),
)
})
})
it('should handle progress event with lengthComputable false', async () => {
// Arrange
const mockUpdateFile = vi.fn()
let progressCallback: ((e: ProgressEvent) => void) | undefined
mockUpload.mockImplementation(({ onprogress }) => {
progressCallback = onprogress
return Promise.resolve({ id: 'uploaded-id' })
})
const { container } = render(
<CSVUploader {...defaultProps} updateFile={mockUpdateFile} />,
)
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
const testFile = new File(['content'], 'test.csv', { type: 'text/csv' })
// Act
fireEvent.change(fileInput, { target: { files: [testFile] } })
// Simulate progress event with lengthComputable false
if (progressCallback) {
const progressEvent = new ProgressEvent('progress', {
lengthComputable: false,
loaded: 50,
total: 100,
})
progressCallback(progressEvent)
}
// Assert - should complete upload without progress updates when lengthComputable is false
await waitFor(() => {
expect(mockUpdateFile).toHaveBeenCalled()
})
})
})
})

View File

@ -0,0 +1,232 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import BatchModal from './index'
// Mock child components
vi.mock('./csv-downloader', () => ({
default: ({ docForm }: { docForm: ChunkingMode }) => (
<div data-testid="csv-downloader" data-doc-form={docForm}>
CSV Downloader
</div>
),
}))
vi.mock('./csv-uploader', () => ({
default: ({ file, updateFile }: { file: { file?: { id: string } } | undefined, updateFile: (file: { file: { id: string } } | undefined) => void }) => (
<div data-testid="csv-uploader">
<button
data-testid="upload-btn"
onClick={() => updateFile({ file: { id: 'test-file-id' } })}
>
Upload
</button>
<button
data-testid="clear-btn"
onClick={() => updateFile(undefined)}
>
Clear
</button>
{file && <span data-testid="file-info">{file.file?.id}</span>}
</div>
),
}))
describe('BatchModal', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const defaultProps = {
isShow: true,
docForm: ChunkingMode.text,
onCancel: vi.fn(),
onConfirm: vi.fn(),
}
// Rendering tests
describe('Rendering', () => {
it('should render without crashing when isShow is true', () => {
// Arrange & Act
render(<BatchModal {...defaultProps} />)
// Assert
expect(screen.getByText(/list\.batchModal\.title/i)).toBeInTheDocument()
})
it('should not render content when isShow is false', () => {
// Arrange & Act
render(<BatchModal {...defaultProps} isShow={false} />)
// Assert - Modal is closed
expect(screen.queryByText(/list\.batchModal\.title/i)).not.toBeInTheDocument()
})
it('should render CSVDownloader component', () => {
// Arrange & Act
render(<BatchModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('csv-downloader')).toBeInTheDocument()
})
it('should render CSVUploader component', () => {
// Arrange & Act
render(<BatchModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('csv-uploader')).toBeInTheDocument()
})
it('should render cancel and run buttons', () => {
// Arrange & Act
render(<BatchModal {...defaultProps} />)
// Assert
expect(screen.getByText(/list\.batchModal\.cancel/i)).toBeInTheDocument()
expect(screen.getByText(/list\.batchModal\.run/i)).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should call onCancel when cancel button is clicked', () => {
// Arrange
const mockOnCancel = vi.fn()
render(<BatchModal {...defaultProps} onCancel={mockOnCancel} />)
// Act
fireEvent.click(screen.getByText(/list\.batchModal\.cancel/i))
// Assert
expect(mockOnCancel).toHaveBeenCalledTimes(1)
})
it('should disable run button when no file is uploaded', () => {
// Arrange & Act
render(<BatchModal {...defaultProps} />)
// Assert
const runButton = screen.getByText(/list\.batchModal\.run/i).closest('button')
expect(runButton).toBeDisabled()
})
it('should enable run button after file is uploaded', async () => {
// Arrange
render(<BatchModal {...defaultProps} />)
// Act
fireEvent.click(screen.getByTestId('upload-btn'))
// Assert
await waitFor(() => {
const runButton = screen.getByText(/list\.batchModal\.run/i).closest('button')
expect(runButton).not.toBeDisabled()
})
})
it('should call onConfirm with file when run button is clicked', async () => {
// Arrange
const mockOnConfirm = vi.fn()
const mockOnCancel = vi.fn()
render(<BatchModal {...defaultProps} onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
// Act - upload file first
fireEvent.click(screen.getByTestId('upload-btn'))
await waitFor(() => {
const runButton = screen.getByText(/list\.batchModal\.run/i).closest('button')
expect(runButton).not.toBeDisabled()
})
// Act - click run
fireEvent.click(screen.getByText(/list\.batchModal\.run/i))
// Assert
expect(mockOnCancel).toHaveBeenCalledTimes(1)
expect(mockOnConfirm).toHaveBeenCalledWith({ file: { id: 'test-file-id' } })
})
})
// Props tests
describe('Props', () => {
it('should pass docForm to CSVDownloader', () => {
// Arrange & Act
render(<BatchModal {...defaultProps} docForm={ChunkingMode.qa} />)
// Assert
expect(screen.getByTestId('csv-downloader').getAttribute('data-doc-form')).toBe(ChunkingMode.qa)
})
})
// State reset tests
describe('State Reset', () => {
it('should reset file when modal is closed and reopened', async () => {
// Arrange
const { rerender } = render(<BatchModal {...defaultProps} />)
// Upload a file
fireEvent.click(screen.getByTestId('upload-btn'))
await waitFor(() => {
expect(screen.getByTestId('file-info')).toBeInTheDocument()
})
// Close modal
rerender(<BatchModal {...defaultProps} isShow={false} />)
// Reopen modal
rerender(<BatchModal {...defaultProps} isShow={true} />)
// Assert - file should be cleared
expect(screen.queryByTestId('file-info')).not.toBeInTheDocument()
})
})
// Edge cases
describe('Edge Cases', () => {
it('should not call onConfirm when no file is present', () => {
// Arrange
const mockOnConfirm = vi.fn()
render(<BatchModal {...defaultProps} onConfirm={mockOnConfirm} />)
// Act - try to click run (should be disabled)
const runButton = screen.getByText(/list\.batchModal\.run/i).closest('button')
if (runButton)
fireEvent.click(runButton)
// Assert
expect(mockOnConfirm).not.toHaveBeenCalled()
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender } = render(<BatchModal {...defaultProps} />)
// Act
rerender(<BatchModal {...defaultProps} docForm={ChunkingMode.qa} />)
// Assert
expect(screen.getByText(/list\.batchModal\.title/i)).toBeInTheDocument()
})
it('should handle file cleared after upload', async () => {
// Arrange
const mockOnConfirm = vi.fn()
render(<BatchModal {...defaultProps} onConfirm={mockOnConfirm} />)
// Upload a file first
fireEvent.click(screen.getByTestId('upload-btn'))
await waitFor(() => {
expect(screen.getByTestId('file-info')).toBeInTheDocument()
})
// Clear the file
fireEvent.click(screen.getByTestId('clear-btn'))
// Assert - run button should be disabled again
const runButton = screen.getByText(/list\.batchModal\.run/i).closest('button')
expect(runButton).toBeDisabled()
})
})
})

View File

@ -0,0 +1,330 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import ChildSegmentDetail from './child-segment-detail'
// Mock segment list context
let mockFullScreen = false
const mockToggleFullScreen = vi.fn()
vi.mock('./index', () => ({
useSegmentListContext: (selector: (state: { fullScreen: boolean, toggleFullScreen: () => void }) => unknown) => {
const state = {
fullScreen: mockFullScreen,
toggleFullScreen: mockToggleFullScreen,
}
return selector(state)
},
}))
// Mock event emitter context
let mockSubscriptionCallback: ((v: string) => void) | null = null
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
useSubscription: (callback: (v: string) => void) => {
mockSubscriptionCallback = callback
},
},
}),
}))
// Mock child components
vi.mock('./common/action-buttons', () => ({
default: ({ handleCancel, handleSave, loading, isChildChunk }: { handleCancel: () => void, handleSave: () => void, loading: boolean, isChildChunk?: boolean }) => (
<div data-testid="action-buttons">
<button onClick={handleCancel} data-testid="cancel-btn">Cancel</button>
<button onClick={handleSave} disabled={loading} data-testid="save-btn">Save</button>
<span data-testid="is-child-chunk">{isChildChunk ? 'true' : 'false'}</span>
</div>
),
}))
vi.mock('./common/chunk-content', () => ({
default: ({ question, onQuestionChange, isEditMode }: { question: string, onQuestionChange: (v: string) => void, isEditMode: boolean }) => (
<div data-testid="chunk-content">
<input
data-testid="content-input"
value={question}
onChange={e => onQuestionChange(e.target.value)}
/>
<span data-testid="edit-mode">{isEditMode ? 'editing' : 'viewing'}</span>
</div>
),
}))
vi.mock('./common/dot', () => ({
default: () => <span data-testid="dot"></span>,
}))
vi.mock('./common/segment-index-tag', () => ({
SegmentIndexTag: ({ positionId, labelPrefix }: { positionId?: string, labelPrefix?: string }) => (
<span data-testid="segment-index-tag">
{labelPrefix}
{' '}
{positionId}
</span>
),
}))
describe('ChildSegmentDetail', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFullScreen = false
mockSubscriptionCallback = null
})
const defaultChildChunkInfo = {
id: 'child-chunk-1',
content: 'Test content',
position: 1,
updated_at: 1609459200, // 2021-01-01
}
const defaultProps = {
chunkId: 'chunk-1',
childChunkInfo: defaultChildChunkInfo,
onUpdate: vi.fn(),
onCancel: vi.fn(),
docForm: ChunkingMode.text,
}
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<ChildSegmentDetail {...defaultProps} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render edit child chunk title', () => {
// Arrange & Act
render(<ChildSegmentDetail {...defaultProps} />)
// Assert
expect(screen.getByText(/segment\.editChildChunk/i)).toBeInTheDocument()
})
it('should render chunk content component', () => {
// Arrange & Act
render(<ChildSegmentDetail {...defaultProps} />)
// Assert
expect(screen.getByTestId('chunk-content')).toBeInTheDocument()
})
it('should render segment index tag', () => {
// Arrange & Act
render(<ChildSegmentDetail {...defaultProps} />)
// Assert
expect(screen.getByTestId('segment-index-tag')).toBeInTheDocument()
})
it('should render word count', () => {
// Arrange & Act
render(<ChildSegmentDetail {...defaultProps} />)
// Assert
expect(screen.getByText(/segment\.characters/i)).toBeInTheDocument()
})
it('should render edit time', () => {
// Arrange & Act
render(<ChildSegmentDetail {...defaultProps} />)
// Assert
expect(screen.getByText(/segment\.editedAt/i)).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should call onCancel when close button is clicked', () => {
// Arrange
const mockOnCancel = vi.fn()
const { container } = render(
<ChildSegmentDetail {...defaultProps} onCancel={mockOnCancel} />,
)
// Act
const closeButtons = container.querySelectorAll('.cursor-pointer')
if (closeButtons.length > 1)
fireEvent.click(closeButtons[1])
// Assert
expect(mockOnCancel).toHaveBeenCalled()
})
it('should call toggleFullScreen when expand button is clicked', () => {
// Arrange
const { container } = render(<ChildSegmentDetail {...defaultProps} />)
// Act
const expandButtons = container.querySelectorAll('.cursor-pointer')
if (expandButtons.length > 0)
fireEvent.click(expandButtons[0])
// Assert
expect(mockToggleFullScreen).toHaveBeenCalled()
})
it('should call onUpdate when save is clicked', () => {
// Arrange
const mockOnUpdate = vi.fn()
render(<ChildSegmentDetail {...defaultProps} onUpdate={mockOnUpdate} />)
// Act
fireEvent.click(screen.getByTestId('save-btn'))
// Assert
expect(mockOnUpdate).toHaveBeenCalledWith(
'chunk-1',
'child-chunk-1',
'Test content',
)
})
it('should update content when input changes', () => {
// Arrange
render(<ChildSegmentDetail {...defaultProps} />)
// Act
fireEvent.change(screen.getByTestId('content-input'), {
target: { value: 'Updated content' },
})
// Assert
expect(screen.getByTestId('content-input')).toHaveValue('Updated content')
})
})
// Full screen mode
describe('Full Screen Mode', () => {
it('should show action buttons in header when fullScreen is true', () => {
// Arrange
mockFullScreen = true
// Act
render(<ChildSegmentDetail {...defaultProps} />)
// Assert
expect(screen.getByTestId('action-buttons')).toBeInTheDocument()
})
it('should not show footer action buttons when fullScreen is true', () => {
// Arrange
mockFullScreen = true
// Act
render(<ChildSegmentDetail {...defaultProps} />)
// Assert - footer with border-t-divider-subtle should not exist
const actionButtons = screen.getAllByTestId('action-buttons')
// Only one action buttons set should exist in fullScreen mode
expect(actionButtons.length).toBe(1)
})
it('should show footer action buttons when fullScreen is false', () => {
// Arrange
mockFullScreen = false
// Act
render(<ChildSegmentDetail {...defaultProps} />)
// Assert
expect(screen.getByTestId('action-buttons')).toBeInTheDocument()
})
})
// Props
describe('Props', () => {
it('should pass isChildChunk true to ActionButtons', () => {
// Arrange & Act
render(<ChildSegmentDetail {...defaultProps} />)
// Assert
expect(screen.getByTestId('is-child-chunk')).toHaveTextContent('true')
})
it('should pass isEditMode true to ChunkContent', () => {
// Arrange & Act
render(<ChildSegmentDetail {...defaultProps} />)
// Assert
expect(screen.getByTestId('edit-mode')).toHaveTextContent('editing')
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle undefined childChunkInfo', () => {
// Arrange & Act
const { container } = render(
<ChildSegmentDetail {...defaultProps} childChunkInfo={undefined} />,
)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should handle empty content', () => {
// Arrange
const emptyChildChunkInfo = { ...defaultChildChunkInfo, content: '' }
// Act
render(<ChildSegmentDetail {...defaultProps} childChunkInfo={emptyChildChunkInfo} />)
// Assert
expect(screen.getByTestId('content-input')).toHaveValue('')
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender } = render(<ChildSegmentDetail {...defaultProps} />)
// Act
const updatedInfo = { ...defaultChildChunkInfo, content: 'New content' }
rerender(<ChildSegmentDetail {...defaultProps} childChunkInfo={updatedInfo} />)
// Assert
expect(screen.getByTestId('content-input')).toBeInTheDocument()
})
})
// Event subscription tests
describe('Event Subscription', () => {
it('should register event subscription', () => {
// Arrange & Act
render(<ChildSegmentDetail {...defaultProps} />)
// Assert - subscription callback should be registered
expect(mockSubscriptionCallback).not.toBeNull()
})
it('should have save button enabled by default', () => {
// Arrange & Act
render(<ChildSegmentDetail {...defaultProps} />)
// Assert - save button should be enabled initially
expect(screen.getByTestId('save-btn')).not.toBeDisabled()
})
})
// Cancel behavior
describe('Cancel Behavior', () => {
it('should call onCancel when cancel button is clicked', () => {
// Arrange
const mockOnCancel = vi.fn()
render(<ChildSegmentDetail {...defaultProps} onCancel={mockOnCancel} />)
// Act
fireEvent.click(screen.getByTestId('cancel-btn'))
// Assert
expect(mockOnCancel).toHaveBeenCalled()
})
})
})

View File

@ -1,499 +1,430 @@
import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context'
import type { ChildChunkDetail, ChunkingMode, ParentMode } from '@/models/datasets'
import type { ChildChunkDetail } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ChildSegmentList from './child-segment-list'
// ============================================================================
// Hoisted Mocks
// ============================================================================
const {
mockParentMode,
mockCurrChildChunk,
} = vi.hoisted(() => ({
mockParentMode: { current: 'paragraph' as ParentMode },
mockCurrChildChunk: { current: { childChunkInfo: undefined, showModal: false } as { childChunkInfo?: ChildChunkDetail, showModal: boolean } },
}))
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { count?: number, ns?: string }) => {
if (key === 'segment.childChunks')
return options?.count === 1 ? 'child chunk' : 'child chunks'
if (key === 'segment.searchResults')
return 'search results'
if (key === 'segment.edited')
return 'edited'
if (key === 'operation.add')
return 'Add'
const prefix = options?.ns ? `${options.ns}.` : ''
return `${prefix}${key}`
},
}),
}))
// Mock document context
let mockParentMode = 'paragraph'
vi.mock('../context', () => ({
useDocumentContext: (selector: (value: DocumentContextValue) => unknown) => {
const value: DocumentContextValue = {
datasetId: 'test-dataset-id',
documentId: 'test-document-id',
docForm: 'text' as ChunkingMode,
parentMode: mockParentMode.current,
}
return selector(value)
useDocumentContext: (selector: (state: { parentMode: string }) => unknown) => {
return selector({ parentMode: mockParentMode })
},
}))
// Mock segment list context
let mockCurrChildChunk: { childChunkInfo: { id: string } } | null = null
vi.mock('./index', () => ({
useSegmentListContext: (selector: (value: { currChildChunk: { childChunkInfo?: ChildChunkDetail, showModal: boolean } }) => unknown) => {
return selector({ currChildChunk: mockCurrChildChunk.current })
useSegmentListContext: (selector: (state: { currChildChunk: { childChunkInfo: { id: string } } | null }) => unknown) => {
return selector({ currChildChunk: mockCurrChildChunk })
},
}))
// Mock skeleton component
vi.mock('./skeleton/full-doc-list-skeleton', () => ({
default: () => <div data-testid="full-doc-list-skeleton">Loading...</div>,
}))
// Mock Empty component
// Mock child components
vi.mock('./common/empty', () => ({
default: ({ onClearFilter }: { onClearFilter: () => void }) => (
<div data-testid="empty-component">
<button onClick={onClearFilter}>Clear Filter</button>
<div data-testid="empty">
<button onClick={onClearFilter} data-testid="clear-filter-btn">Clear Filter</button>
</div>
),
}))
vi.mock('./skeleton/full-doc-list-skeleton', () => ({
default: () => <div data-testid="full-doc-skeleton">Loading...</div>,
}))
vi.mock('../../../formatted-text/flavours/edit-slice', () => ({
EditSlice: ({
label,
text,
onDelete,
className,
labelClassName,
onClick,
}: {
label: string
text: string
onDelete: () => void
className: string
labelClassName: string
contentClassName: string
labelInnerClassName: string
showDivider: boolean
onClick: (e: React.MouseEvent) => void
offsetOptions: unknown
}) => (
<div data-testid="edit-slice" className={className}>
<span data-testid="slice-label" className={labelClassName}>{label}</span>
<span data-testid="slice-text">{text}</span>
<button data-testid="delete-slice-btn" onClick={onDelete}>Delete</button>
<button data-testid="click-slice-btn" onClick={e => onClick(e)}>Click</button>
</div>
),
}))
// Mock FormattedText and EditSlice
vi.mock('../../../formatted-text/formatted', () => ({
FormattedText: ({ children, className }: { children: React.ReactNode, className?: string }) => (
FormattedText: ({ children, className }: { children: React.ReactNode, className: string }) => (
<div data-testid="formatted-text" className={className}>{children}</div>
),
}))
vi.mock('../../../formatted-text/flavours/edit-slice', () => ({
EditSlice: ({ label, text, onDelete, onClick, labelClassName, contentClassName }: {
label: string
text: string
onDelete: () => void
onClick: (e: React.MouseEvent) => void
labelClassName?: string
contentClassName?: string
}) => (
<div data-testid="edit-slice" onClick={onClick}>
<span data-testid="edit-slice-label" className={labelClassName}>{label}</span>
<span data-testid="edit-slice-content" className={contentClassName}>{text}</span>
<button
data-testid="delete-button"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
>
Delete
</button>
</div>
),
}))
// ============================================================================
// Test Data Factories
// ============================================================================
const createMockChildChunk = (overrides: Partial<ChildChunkDetail> = {}): ChildChunkDetail => ({
id: `child-${Math.random().toString(36).substr(2, 9)}`,
position: 1,
segment_id: 'segment-1',
content: 'Child chunk content',
word_count: 100,
created_at: 1700000000,
updated_at: 1700000000,
type: 'automatic',
...overrides,
})
// ============================================================================
// Tests
// ============================================================================
describe('ChildSegmentList', () => {
const defaultProps = {
childChunks: [] as ChildChunkDetail[],
parentChunkId: 'parent-1',
enabled: true,
}
beforeEach(() => {
vi.clearAllMocks()
mockParentMode.current = 'paragraph'
mockCurrChildChunk.current = { childChunkInfo: undefined, showModal: false }
mockParentMode = 'paragraph'
mockCurrChildChunk = null
})
const createMockChildChunk = (id: string, content: string, edited = false): ChildChunkDetail => ({
id,
content,
position: 1,
word_count: 10,
segment_id: 'seg-1',
created_at: Date.now(),
updated_at: edited ? Date.now() + 1000 : Date.now(),
type: 'automatic',
})
const defaultProps = {
childChunks: [createMockChildChunk('child-1', 'Child content 1')],
parentChunkId: 'parent-1',
handleInputChange: vi.fn(),
handleAddNewChildChunk: vi.fn(),
enabled: true,
onDelete: vi.fn(),
onClickSlice: vi.fn(),
total: 1,
inputValue: '',
onClearFilter: vi.fn(),
isLoading: false,
focused: false,
}
// Rendering tests
describe('Rendering', () => {
it('should render with empty child chunks', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<ChildSegmentList {...defaultProps} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render total count text', () => {
// Arrange & Act
render(<ChildSegmentList {...defaultProps} />)
expect(screen.getByText(/child chunks/i)).toBeInTheDocument()
// Assert
expect(screen.getByText(/segment\.childChunks/i)).toBeInTheDocument()
})
it('should render child chunks when provided', () => {
const childChunks = [
createMockChildChunk({ id: 'child-1', position: 1, content: 'First chunk' }),
createMockChildChunk({ id: 'child-2', position: 2, content: 'Second chunk' }),
]
it('should render add button', () => {
// Arrange & Act
render(<ChildSegmentList {...defaultProps} />)
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} />)
// In paragraph mode, content is collapsed by default
expect(screen.getByText(/2 child chunks/i)).toBeInTheDocument()
})
it('should render total count correctly with total prop in full-doc mode', () => {
mockParentMode.current = 'full-doc'
const childChunks = [createMockChildChunk()]
// Pass inputValue="" to ensure isSearching is false
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} total={5} isLoading={false} inputValue="" />)
expect(screen.getByText(/5 child chunks/i)).toBeInTheDocument()
})
it('should render loading skeleton in full-doc mode when loading', () => {
mockParentMode.current = 'full-doc'
render(<ChildSegmentList {...defaultProps} isLoading={true} />)
expect(screen.getByTestId('full-doc-list-skeleton')).toBeInTheDocument()
})
it('should not render loading skeleton when not loading', () => {
mockParentMode.current = 'full-doc'
render(<ChildSegmentList {...defaultProps} isLoading={false} />)
expect(screen.queryByTestId('full-doc-list-skeleton')).not.toBeInTheDocument()
// Assert
expect(screen.getByText(/operation\.add/i)).toBeInTheDocument()
})
})
// Paragraph mode tests
describe('Paragraph Mode', () => {
beforeEach(() => {
mockParentMode.current = 'paragraph'
mockParentMode = 'paragraph'
})
it('should show collapse icon in paragraph mode', () => {
const childChunks = [createMockChildChunk()]
it('should render collapsed by default in paragraph mode', () => {
// Arrange & Act
render(<ChildSegmentList {...defaultProps} />)
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} />)
// Check for collapse/expand behavior
const totalRow = screen.getByText(/1 child chunk/i).closest('div')
expect(totalRow).toBeInTheDocument()
})
it('should toggle collapsed state when clicked', () => {
const childChunks = [createMockChildChunk({ content: 'Test content' })]
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} />)
// Initially collapsed in paragraph mode - content should not be visible
// Assert - collapsed icon should be present
expect(screen.queryByTestId('formatted-text')).not.toBeInTheDocument()
})
// Find and click the toggle area
const toggleArea = screen.getByText(/1 child chunk/i).closest('div')
it('should expand when clicking toggle in paragraph mode', () => {
// Arrange
render(<ChildSegmentList {...defaultProps} />)
// Click to expand
// Act - click on the collapse toggle
const toggleArea = screen.getByText(/segment\.childChunks/i).closest('div')
if (toggleArea)
fireEvent.click(toggleArea)
// After expansion, content should be visible
// Assert - child chunks should be visible
expect(screen.getByTestId('formatted-text')).toBeInTheDocument()
})
it('should apply opacity when disabled', () => {
const { container } = render(<ChildSegmentList {...defaultProps} enabled={false} />)
it('should collapse when clicking toggle again', () => {
// Arrange
render(<ChildSegmentList {...defaultProps} />)
const wrapper = container.firstChild
expect(wrapper).toHaveClass('opacity-50')
})
// Act - click twice
const toggleArea = screen.getByText(/segment\.childChunks/i).closest('div')
if (toggleArea) {
fireEvent.click(toggleArea)
fireEvent.click(toggleArea)
}
it('should not apply opacity when enabled', () => {
const { container } = render(<ChildSegmentList {...defaultProps} enabled={true} />)
const wrapper = container.firstChild
expect(wrapper).not.toHaveClass('opacity-50')
// Assert - child chunks should be hidden
expect(screen.queryByTestId('formatted-text')).not.toBeInTheDocument()
})
})
describe('Full-Doc Mode', () => {
// Full doc mode tests
describe('Full Doc Mode', () => {
beforeEach(() => {
mockParentMode.current = 'full-doc'
mockParentMode = 'full-doc'
})
it('should show content by default in full-doc mode', () => {
const childChunks = [createMockChildChunk({ content: 'Full doc content' })]
it('should render input field in full-doc mode', () => {
// Arrange & Act
render(<ChildSegmentList {...defaultProps} />)
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} isLoading={false} />)
expect(screen.getByTestId('formatted-text')).toBeInTheDocument()
})
it('should render search input in full-doc mode', () => {
render(<ChildSegmentList {...defaultProps} inputValue="" handleInputChange={vi.fn()} />)
const input = document.querySelector('input')
// Assert
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
})
it('should render child chunks without collapse in full-doc mode', () => {
// Arrange & Act
render(<ChildSegmentList {...defaultProps} />)
// Assert
expect(screen.getByTestId('formatted-text')).toBeInTheDocument()
})
it('should call handleInputChange when input changes', () => {
const handleInputChange = vi.fn()
// Arrange
const mockHandleInputChange = vi.fn()
render(<ChildSegmentList {...defaultProps} handleInputChange={mockHandleInputChange} />)
render(<ChildSegmentList {...defaultProps} inputValue="" handleInputChange={handleInputChange} />)
// Act
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'search term' } })
const input = document.querySelector('input')
if (input) {
fireEvent.change(input, { target: { value: 'test search' } })
expect(handleInputChange).toHaveBeenCalledWith('test search')
}
// Assert
expect(mockHandleInputChange).toHaveBeenCalledWith('search term')
})
it('should show search results text when searching', () => {
render(<ChildSegmentList {...defaultProps} inputValue="search term" total={3} />)
// Arrange & Act
render(<ChildSegmentList {...defaultProps} inputValue="search" total={5} />)
expect(screen.getByText(/3 search results/i)).toBeInTheDocument()
// Assert
expect(screen.getByText(/segment\.searchResults/i)).toBeInTheDocument()
})
it('should show empty component when no results and searching', () => {
render(
<ChildSegmentList
{...defaultProps}
childChunks={[]}
inputValue="search term"
onClearFilter={vi.fn()}
isLoading={false}
/>,
)
// Arrange & Act
render(<ChildSegmentList {...defaultProps} inputValue="search" childChunks={[]} total={0} />)
expect(screen.getByTestId('empty-component')).toBeInTheDocument()
// Assert
expect(screen.getByTestId('empty')).toBeInTheDocument()
})
it('should call onClearFilter when clear button clicked in empty state', () => {
const onClearFilter = vi.fn()
it('should show loading skeleton when isLoading is true', () => {
// Arrange & Act
render(<ChildSegmentList {...defaultProps} isLoading={true} />)
render(
<ChildSegmentList
{...defaultProps}
childChunks={[]}
inputValue="search term"
onClearFilter={onClearFilter}
isLoading={false}
/>,
)
// Assert
expect(screen.getByTestId('full-doc-skeleton')).toBeInTheDocument()
})
const clearButton = screen.getByText('Clear Filter')
fireEvent.click(clearButton)
it('should handle undefined total in full-doc mode', () => {
// Arrange & Act
const { container } = render(<ChildSegmentList {...defaultProps} total={undefined} />)
expect(onClearFilter).toHaveBeenCalled()
// Assert - component should render without crashing
expect(container.firstChild).toBeInTheDocument()
})
})
describe('Child Chunk Items', () => {
it('should render edited label when chunk is edited', () => {
mockParentMode.current = 'full-doc'
const editedChunk = createMockChildChunk({
id: 'edited-chunk',
position: 1,
created_at: 1700000000,
updated_at: 1700000001, // Different from created_at
})
// User Interactions
describe('User Interactions', () => {
it('should call handleAddNewChildChunk when add button is clicked', () => {
// Arrange
mockParentMode = 'full-doc'
const mockHandleAddNewChildChunk = vi.fn()
render(<ChildSegmentList {...defaultProps} handleAddNewChildChunk={mockHandleAddNewChildChunk} />)
render(<ChildSegmentList {...defaultProps} childChunks={[editedChunk]} isLoading={false} />)
// Act
fireEvent.click(screen.getByText(/operation\.add/i))
expect(screen.getByText(/C-1 · edited/i)).toBeInTheDocument()
})
it('should not show edited label when chunk is not edited', () => {
mockParentMode.current = 'full-doc'
const normalChunk = createMockChildChunk({
id: 'normal-chunk',
position: 2,
created_at: 1700000000,
updated_at: 1700000000, // Same as created_at
})
render(<ChildSegmentList {...defaultProps} childChunks={[normalChunk]} isLoading={false} />)
expect(screen.getByText('C-2')).toBeInTheDocument()
expect(screen.queryByText(/edited/i)).not.toBeInTheDocument()
})
it('should call onClickSlice when chunk is clicked', () => {
mockParentMode.current = 'full-doc'
const onClickSlice = vi.fn()
const chunk = createMockChildChunk({ id: 'clickable-chunk' })
render(
<ChildSegmentList
{...defaultProps}
childChunks={[chunk]}
onClickSlice={onClickSlice}
isLoading={false}
/>,
)
const editSlice = screen.getByTestId('edit-slice')
fireEvent.click(editSlice)
expect(onClickSlice).toHaveBeenCalledWith(chunk)
// Assert
expect(mockHandleAddNewChildChunk).toHaveBeenCalledWith('parent-1')
})
it('should call onDelete when delete button is clicked', () => {
mockParentMode.current = 'full-doc'
const onDelete = vi.fn()
const chunk = createMockChildChunk({ id: 'deletable-chunk', segment_id: 'seg-1' })
// Arrange
mockParentMode = 'full-doc'
const mockOnDelete = vi.fn()
render(<ChildSegmentList {...defaultProps} onDelete={mockOnDelete} />)
render(
<ChildSegmentList
{...defaultProps}
childChunks={[chunk]}
onDelete={onDelete}
isLoading={false}
/>,
)
// Act
fireEvent.click(screen.getByTestId('delete-slice-btn'))
const deleteButton = screen.getByTestId('delete-button')
fireEvent.click(deleteButton)
expect(onDelete).toHaveBeenCalledWith('seg-1', 'deletable-chunk')
// Assert
expect(mockOnDelete).toHaveBeenCalledWith('seg-1', 'child-1')
})
it('should apply focused styles when chunk is currently selected', () => {
mockParentMode.current = 'full-doc'
const chunk = createMockChildChunk({ id: 'focused-chunk' })
mockCurrChildChunk.current = { childChunkInfo: chunk, showModal: true }
it('should call onClickSlice when slice is clicked', () => {
// Arrange
mockParentMode = 'full-doc'
const mockOnClickSlice = vi.fn()
render(<ChildSegmentList {...defaultProps} onClickSlice={mockOnClickSlice} />)
render(<ChildSegmentList {...defaultProps} childChunks={[chunk]} isLoading={false} />)
// Act
fireEvent.click(screen.getByTestId('click-slice-btn'))
const label = screen.getByTestId('edit-slice-label')
// Assert
expect(mockOnClickSlice).toHaveBeenCalledWith(expect.objectContaining({ id: 'child-1' }))
})
it('should call onClearFilter when clear filter button is clicked', () => {
// Arrange
mockParentMode = 'full-doc'
const mockOnClearFilter = vi.fn()
render(<ChildSegmentList {...defaultProps} inputValue="search" childChunks={[]} onClearFilter={mockOnClearFilter} />)
// Act
fireEvent.click(screen.getByTestId('clear-filter-btn'))
// Assert
expect(mockOnClearFilter).toHaveBeenCalled()
})
})
// Focused state
describe('Focused State', () => {
it('should apply focused style when currChildChunk matches', () => {
// Arrange
mockParentMode = 'full-doc'
mockCurrChildChunk = { childChunkInfo: { id: 'child-1' } }
// Act
render(<ChildSegmentList {...defaultProps} />)
// Assert - check for focused class on label
const label = screen.getByTestId('slice-label')
expect(label).toHaveClass('bg-state-accent-solid')
})
})
describe('Add Button', () => {
it('should call handleAddNewChildChunk when Add button is clicked', () => {
const handleAddNewChildChunk = vi.fn()
it('should not apply focused style when currChildChunk does not match', () => {
// Arrange
mockParentMode = 'full-doc'
mockCurrChildChunk = { childChunkInfo: { id: 'other-child' } }
render(
<ChildSegmentList
{...defaultProps}
handleAddNewChildChunk={handleAddNewChildChunk}
parentChunkId="parent-123"
/>,
)
// Act
render(<ChildSegmentList {...defaultProps} />)
const addButton = screen.getByText('Add')
fireEvent.click(addButton)
expect(handleAddNewChildChunk).toHaveBeenCalledWith('parent-123')
})
it('should disable Add button when loading in full-doc mode', () => {
mockParentMode.current = 'full-doc'
render(<ChildSegmentList {...defaultProps} isLoading={true} />)
const addButton = screen.getByText('Add')
expect(addButton).toBeDisabled()
})
it('should stop propagation when Add button is clicked', () => {
const handleAddNewChildChunk = vi.fn()
const parentClickHandler = vi.fn()
render(
<div onClick={parentClickHandler}>
<ChildSegmentList
{...defaultProps}
handleAddNewChildChunk={handleAddNewChildChunk}
/>
</div>,
)
const addButton = screen.getByText('Add')
fireEvent.click(addButton)
expect(handleAddNewChildChunk).toHaveBeenCalled()
// Parent should not be called due to stopPropagation
// Assert
const label = screen.getByTestId('slice-label')
expect(label).not.toHaveClass('bg-state-accent-solid')
})
})
describe('computeTotalInfo function', () => {
it('should return search results when searching in full-doc mode', () => {
mockParentMode.current = 'full-doc'
// Enabled/Disabled state
describe('Enabled State', () => {
it('should apply opacity when enabled is false', () => {
// Arrange & Act
const { container } = render(<ChildSegmentList {...defaultProps} enabled={false} />)
render(<ChildSegmentList {...defaultProps} inputValue="search" total={10} />)
expect(screen.getByText(/10 search results/i)).toBeInTheDocument()
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('opacity-50')
})
it('should return "--" when total is 0 in full-doc mode', () => {
mockParentMode.current = 'full-doc'
it('should not apply opacity when enabled is true', () => {
// Arrange & Act
const { container } = render(<ChildSegmentList {...defaultProps} enabled={true} />)
render(<ChildSegmentList {...defaultProps} total={0} />)
// When total is 0, displayText is '--'
expect(screen.getByText(/--/)).toBeInTheDocument()
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).not.toHaveClass('opacity-50')
})
it('should use childChunks length in paragraph mode', () => {
mockParentMode.current = 'paragraph'
const childChunks = [
createMockChildChunk(),
createMockChildChunk(),
createMockChildChunk(),
]
it('should not apply opacity when focused is true even if enabled is false', () => {
// Arrange & Act
const { container } = render(<ChildSegmentList {...defaultProps} enabled={false} focused={true} />)
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} />)
expect(screen.getByText(/3 child chunks/i)).toBeInTheDocument()
})
})
describe('Focused State', () => {
it('should not apply opacity when focused even if disabled', () => {
const { container } = render(
<ChildSegmentList {...defaultProps} enabled={false} focused={true} />,
)
const wrapper = container.firstChild
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).not.toHaveClass('opacity-50')
})
})
describe('Input clear button', () => {
it('should call handleInputChange with empty string when clear is clicked', () => {
mockParentMode.current = 'full-doc'
const handleInputChange = vi.fn()
// Edited indicator
describe('Edited Indicator', () => {
it('should show edited indicator for edited chunks', () => {
// Arrange
mockParentMode = 'full-doc'
const editedChunk = createMockChildChunk('child-edited', 'Edited content', true)
render(
<ChildSegmentList
{...defaultProps}
inputValue="test"
handleInputChange={handleInputChange}
/>,
)
// Act
render(<ChildSegmentList {...defaultProps} childChunks={[editedChunk]} />)
// Find the clear button (it's the showClearIcon button in Input)
const input = document.querySelector('input')
if (input) {
// Trigger clear by simulating the input's onClear
const clearButton = document.querySelector('[class*="cursor-pointer"]')
if (clearButton)
fireEvent.click(clearButton)
}
// Assert
const label = screen.getByTestId('slice-label')
expect(label.textContent).toContain('segment.edited')
})
})
// Multiple chunks
describe('Multiple Chunks', () => {
it('should render multiple child chunks', () => {
// Arrange
mockParentMode = 'full-doc'
const chunks = [
createMockChildChunk('child-1', 'Content 1'),
createMockChildChunk('child-2', 'Content 2'),
createMockChildChunk('child-3', 'Content 3'),
]
// Act
render(<ChildSegmentList {...defaultProps} childChunks={chunks} total={3} />)
// Assert
expect(screen.getAllByTestId('edit-slice')).toHaveLength(3)
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle empty childChunks array', () => {
// Arrange
mockParentMode = 'full-doc'
// Act
const { container } = render(<ChildSegmentList {...defaultProps} childChunks={[]} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should maintain structure when rerendered', () => {
// Arrange
mockParentMode = 'full-doc'
const { rerender } = render(<ChildSegmentList {...defaultProps} />)
// Act
const newChunks = [createMockChildChunk('new-child', 'New content')]
rerender(<ChildSegmentList {...defaultProps} childChunks={newChunks} />)
// Assert
expect(screen.getByText('New content')).toBeInTheDocument()
})
it('should disable add button when loading', () => {
// Arrange
mockParentMode = 'full-doc'
// Act
render(<ChildSegmentList {...defaultProps} isLoading={true} />)
// Assert
const addButton = screen.getByText(/operation\.add/i)
expect(addButton).toBeDisabled()
})
})
})

View File

@ -0,0 +1,523 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import { DocumentContext } from '../../context'
import ActionButtons from './action-buttons'
// Mock useKeyPress from ahooks to capture and test callback functions
const mockUseKeyPress = vi.fn()
vi.mock('ahooks', () => ({
useKeyPress: (keys: string | string[], callback: (e: KeyboardEvent) => void, options?: object) => {
mockUseKeyPress(keys, callback, options)
},
}))
// Create wrapper component for providing context
const createWrapper = (contextValue: {
docForm?: ChunkingMode
parentMode?: 'paragraph' | 'full-doc'
}) => {
return ({ children }: { children: React.ReactNode }) => (
<DocumentContext.Provider value={contextValue}>
{children}
</DocumentContext.Provider>
)
}
// Helper to get captured callbacks from useKeyPress mock
const getEscCallback = (): ((e: KeyboardEvent) => void) | undefined => {
const escCall = mockUseKeyPress.mock.calls.find(
(call) => {
const keys = call[0]
return Array.isArray(keys) && keys.includes('esc')
},
)
return escCall?.[1]
}
const getCtrlSCallback = (): ((e: KeyboardEvent) => void) | undefined => {
const ctrlSCall = mockUseKeyPress.mock.calls.find(
(call) => {
const keys = call[0]
return typeof keys === 'string' && keys.includes('.s')
},
)
return ctrlSCall?.[1]
}
describe('ActionButtons', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseKeyPress.mockClear()
})
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
loading={false}
/>,
{ wrapper: createWrapper({}) },
)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render cancel button', () => {
// Arrange & Act
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
loading={false}
/>,
{ wrapper: createWrapper({}) },
)
// Assert
expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
})
it('should render save button', () => {
// Arrange & Act
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
loading={false}
/>,
{ wrapper: createWrapper({}) },
)
// Assert
expect(screen.getByText(/operation\.save/i)).toBeInTheDocument()
})
it('should render ESC keyboard hint on cancel button', () => {
// Arrange & Act
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
loading={false}
/>,
{ wrapper: createWrapper({}) },
)
// Assert
expect(screen.getByText('ESC')).toBeInTheDocument()
})
it('should render S keyboard hint on save button', () => {
// Arrange & Act
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
loading={false}
/>,
{ wrapper: createWrapper({}) },
)
// Assert
expect(screen.getByText('S')).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should call handleCancel when cancel button is clicked', () => {
// Arrange
const mockHandleCancel = vi.fn()
render(
<ActionButtons
handleCancel={mockHandleCancel}
handleSave={vi.fn()}
loading={false}
/>,
{ wrapper: createWrapper({}) },
)
// Act
const cancelButton = screen.getAllByRole('button')[0]
fireEvent.click(cancelButton)
// Assert
expect(mockHandleCancel).toHaveBeenCalledTimes(1)
})
it('should call handleSave when save button is clicked', () => {
// Arrange
const mockHandleSave = vi.fn()
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={mockHandleSave}
loading={false}
/>,
{ wrapper: createWrapper({}) },
)
// Act
const buttons = screen.getAllByRole('button')
const saveButton = buttons[buttons.length - 1] // Save button is last
fireEvent.click(saveButton)
// Assert
expect(mockHandleSave).toHaveBeenCalledTimes(1)
})
it('should disable save button when loading is true', () => {
// Arrange & Act
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
loading={true}
/>,
{ wrapper: createWrapper({}) },
)
// Assert
const buttons = screen.getAllByRole('button')
const saveButton = buttons[buttons.length - 1]
expect(saveButton).toBeDisabled()
})
})
// Regeneration button tests
describe('Regeneration Button', () => {
it('should show regeneration button in parent-child paragraph mode for edit action', () => {
// Arrange & Act
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
handleRegeneration={vi.fn()}
loading={false}
actionType="edit"
isChildChunk={false}
showRegenerationButton={true}
/>,
{ wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
)
// Assert
expect(screen.getByText(/operation\.saveAndRegenerate/i)).toBeInTheDocument()
})
it('should not show regeneration button when isChildChunk is true', () => {
// Arrange & Act
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
handleRegeneration={vi.fn()}
loading={false}
actionType="edit"
isChildChunk={true}
showRegenerationButton={true}
/>,
{ wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
)
// Assert
expect(screen.queryByText(/operation\.saveAndRegenerate/i)).not.toBeInTheDocument()
})
it('should not show regeneration button when showRegenerationButton is false', () => {
// Arrange & Act
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
handleRegeneration={vi.fn()}
loading={false}
actionType="edit"
isChildChunk={false}
showRegenerationButton={false}
/>,
{ wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
)
// Assert
expect(screen.queryByText(/operation\.saveAndRegenerate/i)).not.toBeInTheDocument()
})
it('should not show regeneration button when actionType is add', () => {
// Arrange & Act
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
handleRegeneration={vi.fn()}
loading={false}
actionType="add"
isChildChunk={false}
showRegenerationButton={true}
/>,
{ wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
)
// Assert
expect(screen.queryByText(/operation\.saveAndRegenerate/i)).not.toBeInTheDocument()
})
it('should call handleRegeneration when regeneration button is clicked', () => {
// Arrange
const mockHandleRegeneration = vi.fn()
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
handleRegeneration={mockHandleRegeneration}
loading={false}
actionType="edit"
isChildChunk={false}
showRegenerationButton={true}
/>,
{ wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
)
// Act
const regenerationButton = screen.getByText(/operation\.saveAndRegenerate/i).closest('button')
if (regenerationButton)
fireEvent.click(regenerationButton)
// Assert
expect(mockHandleRegeneration).toHaveBeenCalledTimes(1)
})
it('should disable regeneration button when loading is true', () => {
// Arrange & Act
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
handleRegeneration={vi.fn()}
loading={true}
actionType="edit"
isChildChunk={false}
showRegenerationButton={true}
/>,
{ wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
)
// Assert
const regenerationButton = screen.getByText(/operation\.saveAndRegenerate/i).closest('button')
expect(regenerationButton).toBeDisabled()
})
})
// Default props tests
describe('Default Props', () => {
it('should use default actionType of edit', () => {
// Arrange & Act - when not specifying actionType and other conditions are met
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
handleRegeneration={vi.fn()}
loading={false}
showRegenerationButton={true}
/>,
{ wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
)
// Assert - regeneration button should show with default actionType='edit'
expect(screen.getByText(/operation\.saveAndRegenerate/i)).toBeInTheDocument()
})
it('should use default isChildChunk of false', () => {
// Arrange & Act - when not specifying isChildChunk
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
handleRegeneration={vi.fn()}
loading={false}
actionType="edit"
showRegenerationButton={true}
/>,
{ wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
)
// Assert - regeneration button should show with default isChildChunk=false
expect(screen.getByText(/operation\.saveAndRegenerate/i)).toBeInTheDocument()
})
it('should use default showRegenerationButton of true', () => {
// Arrange & Act - when not specifying showRegenerationButton
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
handleRegeneration={vi.fn()}
loading={false}
actionType="edit"
isChildChunk={false}
/>,
{ wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
)
// Assert - regeneration button should show with default showRegenerationButton=true
expect(screen.getByText(/operation\.saveAndRegenerate/i)).toBeInTheDocument()
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle missing context values gracefully', () => {
// Arrange & Act & Assert - should not throw
expect(() => {
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
loading={false}
/>,
{ wrapper: createWrapper({}) },
)
}).not.toThrow()
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender } = render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
loading={false}
/>,
{ wrapper: createWrapper({}) },
)
// Act
rerender(
<DocumentContext.Provider value={{}}>
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
loading={true}
/>
</DocumentContext.Provider>,
)
// Assert
expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
expect(screen.getByText(/operation\.save/i)).toBeInTheDocument()
})
})
// Keyboard shortcuts tests via useKeyPress callbacks
describe('Keyboard Shortcuts', () => {
it('should display ctrl key hint on save button', () => {
// Arrange & Act
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
loading={false}
/>,
{ wrapper: createWrapper({}) },
)
// Assert - check for ctrl key hint (Ctrl or Cmd depending on system)
const kbdElements = document.querySelectorAll('.system-kbd')
expect(kbdElements.length).toBeGreaterThan(0)
})
it('should call handleCancel and preventDefault when ESC key is pressed', () => {
// Arrange
const mockHandleCancel = vi.fn()
const mockPreventDefault = vi.fn()
render(
<ActionButtons
handleCancel={mockHandleCancel}
handleSave={vi.fn()}
loading={false}
/>,
{ wrapper: createWrapper({}) },
)
// Act - get the ESC callback and invoke it
const escCallback = getEscCallback()
expect(escCallback).toBeDefined()
escCallback!({ preventDefault: mockPreventDefault } as unknown as KeyboardEvent)
// Assert
expect(mockPreventDefault).toHaveBeenCalledTimes(1)
expect(mockHandleCancel).toHaveBeenCalledTimes(1)
})
it('should call handleSave and preventDefault when Ctrl+S is pressed and not loading', () => {
// Arrange
const mockHandleSave = vi.fn()
const mockPreventDefault = vi.fn()
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={mockHandleSave}
loading={false}
/>,
{ wrapper: createWrapper({}) },
)
// Act - get the Ctrl+S callback and invoke it
const ctrlSCallback = getCtrlSCallback()
expect(ctrlSCallback).toBeDefined()
ctrlSCallback!({ preventDefault: mockPreventDefault } as unknown as KeyboardEvent)
// Assert
expect(mockPreventDefault).toHaveBeenCalledTimes(1)
expect(mockHandleSave).toHaveBeenCalledTimes(1)
})
it('should not call handleSave when Ctrl+S is pressed while loading', () => {
// Arrange
const mockHandleSave = vi.fn()
const mockPreventDefault = vi.fn()
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={mockHandleSave}
loading={true}
/>,
{ wrapper: createWrapper({}) },
)
// Act - get the Ctrl+S callback and invoke it
const ctrlSCallback = getCtrlSCallback()
expect(ctrlSCallback).toBeDefined()
ctrlSCallback!({ preventDefault: mockPreventDefault } as unknown as KeyboardEvent)
// Assert
expect(mockPreventDefault).toHaveBeenCalledTimes(1)
expect(mockHandleSave).not.toHaveBeenCalled()
})
it('should register useKeyPress with correct options for Ctrl+S', () => {
// Arrange & Act
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
loading={false}
/>,
{ wrapper: createWrapper({}) },
)
// Assert - verify useKeyPress was called with correct options
const ctrlSCall = mockUseKeyPress.mock.calls.find(
call => typeof call[0] === 'string' && call[0].includes('.s'),
)
expect(ctrlSCall).toBeDefined()
expect(ctrlSCall![2]).toEqual({ exactMatch: true, useCapture: true })
})
})
})

View File

@ -0,0 +1,194 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AddAnother from './add-another'
describe('AddAnother', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(
<AddAnother isChecked={false} onCheck={vi.fn()} />,
)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render the checkbox', () => {
// Arrange & Act
const { container } = render(
<AddAnother isChecked={false} onCheck={vi.fn()} />,
)
// Assert - Checkbox component renders with shrink-0 class
const checkbox = container.querySelector('.shrink-0')
expect(checkbox).toBeInTheDocument()
})
it('should render the add another text', () => {
// Arrange & Act
render(<AddAnother isChecked={false} onCheck={vi.fn()} />)
// Assert - i18n key format
expect(screen.getByText(/segment\.addAnother/i)).toBeInTheDocument()
})
it('should render with correct base styling classes', () => {
// Arrange & Act
const { container } = render(
<AddAnother isChecked={false} onCheck={vi.fn()} />,
)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex')
expect(wrapper).toHaveClass('items-center')
expect(wrapper).toHaveClass('gap-x-1')
expect(wrapper).toHaveClass('pl-1')
})
})
// Props tests
describe('Props', () => {
it('should render unchecked state when isChecked is false', () => {
// Arrange & Act
const { container } = render(
<AddAnother isChecked={false} onCheck={vi.fn()} />,
)
// Assert - unchecked checkbox has border class
const checkbox = container.querySelector('.border-components-checkbox-border')
expect(checkbox).toBeInTheDocument()
})
it('should render checked state when isChecked is true', () => {
// Arrange & Act
const { container } = render(
<AddAnother isChecked={true} onCheck={vi.fn()} />,
)
// Assert - checked checkbox has bg-components-checkbox-bg class
const checkbox = container.querySelector('.bg-components-checkbox-bg')
expect(checkbox).toBeInTheDocument()
})
it('should apply custom className', () => {
// Arrange & Act
const { container } = render(
<AddAnother
isChecked={false}
onCheck={vi.fn()}
className="custom-class"
/>,
)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('custom-class')
})
})
// User Interactions
describe('User Interactions', () => {
it('should call onCheck when checkbox is clicked', () => {
// Arrange
const mockOnCheck = vi.fn()
const { container } = render(
<AddAnother isChecked={false} onCheck={mockOnCheck} />,
)
// Act - click on the checkbox element
const checkbox = container.querySelector('.shrink-0')
if (checkbox)
fireEvent.click(checkbox)
// Assert
expect(mockOnCheck).toHaveBeenCalledTimes(1)
})
it('should toggle checked state on multiple clicks', () => {
// Arrange
const mockOnCheck = vi.fn()
const { container, rerender } = render(
<AddAnother isChecked={false} onCheck={mockOnCheck} />,
)
// Act - first click
const checkbox = container.querySelector('.shrink-0')
if (checkbox) {
fireEvent.click(checkbox)
rerender(<AddAnother isChecked={true} onCheck={mockOnCheck} />)
fireEvent.click(checkbox)
}
// Assert
expect(mockOnCheck).toHaveBeenCalledTimes(2)
})
})
// Structure tests
describe('Structure', () => {
it('should render text with tertiary text color', () => {
// Arrange & Act
const { container } = render(
<AddAnother isChecked={false} onCheck={vi.fn()} />,
)
// Assert
const textElement = container.querySelector('.text-text-tertiary')
expect(textElement).toBeInTheDocument()
})
it('should render text with xs medium font styling', () => {
// Arrange & Act
const { container } = render(
<AddAnother isChecked={false} onCheck={vi.fn()} />,
)
// Assert
const textElement = container.querySelector('.system-xs-medium')
expect(textElement).toBeInTheDocument()
})
})
// Edge cases
describe('Edge Cases', () => {
it('should maintain structure when rerendered', () => {
// Arrange
const mockOnCheck = vi.fn()
const { rerender, container } = render(
<AddAnother isChecked={false} onCheck={mockOnCheck} />,
)
// Act
rerender(<AddAnother isChecked={true} onCheck={mockOnCheck} />)
// Assert
const checkbox = container.querySelector('.shrink-0')
expect(checkbox).toBeInTheDocument()
})
it('should handle rapid state changes', () => {
// Arrange
const mockOnCheck = vi.fn()
const { container } = render(
<AddAnother isChecked={false} onCheck={mockOnCheck} />,
)
// Act
const checkbox = container.querySelector('.shrink-0')
if (checkbox) {
for (let i = 0; i < 5; i++)
fireEvent.click(checkbox)
}
// Assert
expect(mockOnCheck).toHaveBeenCalledTimes(5)
})
})
})

View File

@ -0,0 +1,277 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import BatchAction from './batch-action'
describe('BatchAction', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const defaultProps = {
selectedIds: ['1', '2', '3'],
onBatchEnable: vi.fn(),
onBatchDisable: vi.fn(),
onBatchDelete: vi.fn().mockResolvedValue(undefined),
onCancel: vi.fn(),
}
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<BatchAction {...defaultProps} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should display selected count', () => {
// Arrange & Act
render(<BatchAction {...defaultProps} />)
// Assert
expect(screen.getByText('3')).toBeInTheDocument()
})
it('should render enable button', () => {
// Arrange & Act
render(<BatchAction {...defaultProps} />)
// Assert
expect(screen.getByText(/batchAction\.enable/i)).toBeInTheDocument()
})
it('should render disable button', () => {
// Arrange & Act
render(<BatchAction {...defaultProps} />)
// Assert
expect(screen.getByText(/batchAction\.disable/i)).toBeInTheDocument()
})
it('should render delete button', () => {
// Arrange & Act
render(<BatchAction {...defaultProps} />)
// Assert
expect(screen.getByText(/batchAction\.delete/i)).toBeInTheDocument()
})
it('should render cancel button', () => {
// Arrange & Act
render(<BatchAction {...defaultProps} />)
// Assert
expect(screen.getByText(/batchAction\.cancel/i)).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should call onBatchEnable when enable button is clicked', () => {
// Arrange
const mockOnBatchEnable = vi.fn()
render(<BatchAction {...defaultProps} onBatchEnable={mockOnBatchEnable} />)
// Act
fireEvent.click(screen.getByText(/batchAction\.enable/i))
// Assert
expect(mockOnBatchEnable).toHaveBeenCalledTimes(1)
})
it('should call onBatchDisable when disable button is clicked', () => {
// Arrange
const mockOnBatchDisable = vi.fn()
render(<BatchAction {...defaultProps} onBatchDisable={mockOnBatchDisable} />)
// Act
fireEvent.click(screen.getByText(/batchAction\.disable/i))
// Assert
expect(mockOnBatchDisable).toHaveBeenCalledTimes(1)
})
it('should call onCancel when cancel button is clicked', () => {
// Arrange
const mockOnCancel = vi.fn()
render(<BatchAction {...defaultProps} onCancel={mockOnCancel} />)
// Act
fireEvent.click(screen.getByText(/batchAction\.cancel/i))
// Assert
expect(mockOnCancel).toHaveBeenCalledTimes(1)
})
it('should show delete confirmation dialog when delete button is clicked', () => {
// Arrange
render(<BatchAction {...defaultProps} />)
// Act
fireEvent.click(screen.getByText(/batchAction\.delete/i))
// Assert - Confirm dialog should appear
expect(screen.getByText(/list\.delete\.title/i)).toBeInTheDocument()
})
it('should call onBatchDelete when confirm is clicked in delete dialog', async () => {
// Arrange
const mockOnBatchDelete = vi.fn().mockResolvedValue(undefined)
render(<BatchAction {...defaultProps} onBatchDelete={mockOnBatchDelete} />)
// Act - open delete dialog
fireEvent.click(screen.getByText(/batchAction\.delete/i))
// Act - click confirm
const confirmButton = screen.getByText(/operation\.sure/i)
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockOnBatchDelete).toHaveBeenCalledTimes(1)
})
})
})
// Optional props tests
describe('Optional Props', () => {
it('should render download button when onBatchDownload is provided', () => {
// Arrange & Act
render(<BatchAction {...defaultProps} onBatchDownload={vi.fn()} />)
// Assert
expect(screen.getByText(/batchAction\.download/i)).toBeInTheDocument()
})
it('should not render download button when onBatchDownload is not provided', () => {
// Arrange & Act
render(<BatchAction {...defaultProps} />)
// Assert
expect(screen.queryByText(/batchAction\.download/i)).not.toBeInTheDocument()
})
it('should render archive button when onArchive is provided', () => {
// Arrange & Act
render(<BatchAction {...defaultProps} onArchive={vi.fn()} />)
// Assert
expect(screen.getByText(/batchAction\.archive/i)).toBeInTheDocument()
})
it('should render metadata button when onEditMetadata is provided', () => {
// Arrange & Act
render(<BatchAction {...defaultProps} onEditMetadata={vi.fn()} />)
// Assert
expect(screen.getByText(/metadata\.metadata/i)).toBeInTheDocument()
})
it('should render re-index button when onBatchReIndex is provided', () => {
// Arrange & Act
render(<BatchAction {...defaultProps} onBatchReIndex={vi.fn()} />)
// Assert
expect(screen.getByText(/batchAction\.reIndex/i)).toBeInTheDocument()
})
it('should call onBatchDownload when download button is clicked', () => {
// Arrange
const mockOnBatchDownload = vi.fn()
render(<BatchAction {...defaultProps} onBatchDownload={mockOnBatchDownload} />)
// Act
fireEvent.click(screen.getByText(/batchAction\.download/i))
// Assert
expect(mockOnBatchDownload).toHaveBeenCalledTimes(1)
})
it('should call onArchive when archive button is clicked', () => {
// Arrange
const mockOnArchive = vi.fn()
render(<BatchAction {...defaultProps} onArchive={mockOnArchive} />)
// Act
fireEvent.click(screen.getByText(/batchAction\.archive/i))
// Assert
expect(mockOnArchive).toHaveBeenCalledTimes(1)
})
it('should call onEditMetadata when metadata button is clicked', () => {
// Arrange
const mockOnEditMetadata = vi.fn()
render(<BatchAction {...defaultProps} onEditMetadata={mockOnEditMetadata} />)
// Act
fireEvent.click(screen.getByText(/metadata\.metadata/i))
// Assert
expect(mockOnEditMetadata).toHaveBeenCalledTimes(1)
})
it('should call onBatchReIndex when re-index button is clicked', () => {
// Arrange
const mockOnBatchReIndex = vi.fn()
render(<BatchAction {...defaultProps} onBatchReIndex={mockOnBatchReIndex} />)
// Act
fireEvent.click(screen.getByText(/batchAction\.reIndex/i))
// Assert
expect(mockOnBatchReIndex).toHaveBeenCalledTimes(1)
})
it('should apply custom className', () => {
// Arrange & Act
const { container } = render(<BatchAction {...defaultProps} className="custom-class" />)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('custom-class')
})
})
// Selected count display tests
describe('Selected Count', () => {
it('should display correct count for single selection', () => {
// Arrange & Act
render(<BatchAction {...defaultProps} selectedIds={['1']} />)
// Assert
expect(screen.getByText('1')).toBeInTheDocument()
})
it('should display correct count for multiple selections', () => {
// Arrange & Act
render(<BatchAction {...defaultProps} selectedIds={['1', '2', '3', '4', '5']} />)
// Assert
expect(screen.getByText('5')).toBeInTheDocument()
})
})
// Edge cases
describe('Edge Cases', () => {
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender } = render(<BatchAction {...defaultProps} />)
// Act
rerender(<BatchAction {...defaultProps} selectedIds={['1', '2']} />)
// Assert
expect(screen.getByText('2')).toBeInTheDocument()
})
it('should handle empty selectedIds array', () => {
// Arrange & Act
render(<BatchAction {...defaultProps} selectedIds={[]} />)
// Assert
expect(screen.getByText('0')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,317 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import ChunkContent from './chunk-content'
// Mock ResizeObserver
const OriginalResizeObserver = globalThis.ResizeObserver
class MockResizeObserver {
observe = vi.fn()
disconnect = vi.fn()
unobserve = vi.fn()
}
beforeAll(() => {
globalThis.ResizeObserver = MockResizeObserver as typeof ResizeObserver
})
afterAll(() => {
globalThis.ResizeObserver = OriginalResizeObserver
})
describe('ChunkContent', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const defaultProps = {
question: 'Test question content',
onQuestionChange: vi.fn(),
docForm: ChunkingMode.text,
}
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<ChunkContent {...defaultProps} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render textarea in edit mode with text docForm', () => {
// Arrange & Act
render(<ChunkContent {...defaultProps} isEditMode={true} />)
// Assert
const textarea = screen.getByRole('textbox')
expect(textarea).toBeInTheDocument()
})
it('should render Markdown content in view mode with text docForm', () => {
// Arrange & Act
const { container } = render(<ChunkContent {...defaultProps} isEditMode={false} />)
// Assert - In view mode, textarea should not be present, Markdown renders instead
expect(container.querySelector('textarea')).not.toBeInTheDocument()
})
})
// QA mode tests
describe('QA Mode', () => {
it('should render QA layout when docForm is qa', () => {
// Arrange & Act
render(
<ChunkContent
{...defaultProps}
docForm={ChunkingMode.qa}
answer="Test answer"
onAnswerChange={vi.fn()}
isEditMode={true}
/>,
)
// Assert - QA mode has QUESTION and ANSWER labels
expect(screen.getByText('QUESTION')).toBeInTheDocument()
expect(screen.getByText('ANSWER')).toBeInTheDocument()
})
it('should display question value in QA mode', () => {
// Arrange & Act
render(
<ChunkContent
{...defaultProps}
docForm={ChunkingMode.qa}
question="My question"
answer="My answer"
onAnswerChange={vi.fn()}
isEditMode={true}
/>,
)
// Assert
const textareas = screen.getAllByRole('textbox')
expect(textareas[0]).toHaveValue('My question')
})
it('should display answer value in QA mode', () => {
// Arrange & Act
render(
<ChunkContent
{...defaultProps}
docForm={ChunkingMode.qa}
question="My question"
answer="My answer"
onAnswerChange={vi.fn()}
isEditMode={true}
/>,
)
// Assert
const textareas = screen.getAllByRole('textbox')
expect(textareas[1]).toHaveValue('My answer')
})
})
// User Interactions
describe('User Interactions', () => {
it('should call onQuestionChange when textarea value changes in text mode', () => {
// Arrange
const mockOnQuestionChange = vi.fn()
render(
<ChunkContent
{...defaultProps}
isEditMode={true}
onQuestionChange={mockOnQuestionChange}
/>,
)
// Act
const textarea = screen.getByRole('textbox')
fireEvent.change(textarea, { target: { value: 'New content' } })
// Assert
expect(mockOnQuestionChange).toHaveBeenCalledWith('New content')
})
it('should call onQuestionChange when question textarea changes in QA mode', () => {
// Arrange
const mockOnQuestionChange = vi.fn()
render(
<ChunkContent
{...defaultProps}
docForm={ChunkingMode.qa}
isEditMode={true}
onQuestionChange={mockOnQuestionChange}
onAnswerChange={vi.fn()}
/>,
)
// Act
const textareas = screen.getAllByRole('textbox')
fireEvent.change(textareas[0], { target: { value: 'New question' } })
// Assert
expect(mockOnQuestionChange).toHaveBeenCalledWith('New question')
})
it('should call onAnswerChange when answer textarea changes in QA mode', () => {
// Arrange
const mockOnAnswerChange = vi.fn()
render(
<ChunkContent
{...defaultProps}
docForm={ChunkingMode.qa}
isEditMode={true}
answer="Old answer"
onAnswerChange={mockOnAnswerChange}
/>,
)
// Act
const textareas = screen.getAllByRole('textbox')
fireEvent.change(textareas[1], { target: { value: 'New answer' } })
// Assert
expect(mockOnAnswerChange).toHaveBeenCalledWith('New answer')
})
it('should disable textarea when isEditMode is false in text mode', () => {
// Arrange & Act
const { container } = render(
<ChunkContent {...defaultProps} isEditMode={false} />,
)
// Assert - In view mode, Markdown is rendered instead of textarea
expect(container.querySelector('textarea')).not.toBeInTheDocument()
})
it('should disable textareas when isEditMode is false in QA mode', () => {
// Arrange & Act
render(
<ChunkContent
{...defaultProps}
docForm={ChunkingMode.qa}
isEditMode={false}
answer="Answer"
onAnswerChange={vi.fn()}
/>,
)
// Assert
const textareas = screen.getAllByRole('textbox')
textareas.forEach((textarea) => {
expect(textarea).toBeDisabled()
})
})
})
// DocForm variations
describe('DocForm Variations', () => {
it('should handle ChunkingMode.text', () => {
// Arrange & Act
render(<ChunkContent {...defaultProps} docForm={ChunkingMode.text} isEditMode={true} />)
// Assert
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should handle ChunkingMode.qa', () => {
// Arrange & Act
render(
<ChunkContent
{...defaultProps}
docForm={ChunkingMode.qa}
answer="answer"
onAnswerChange={vi.fn()}
isEditMode={true}
/>,
)
// Assert - QA mode should show both question and answer
expect(screen.getByText('QUESTION')).toBeInTheDocument()
expect(screen.getByText('ANSWER')).toBeInTheDocument()
})
it('should handle ChunkingMode.parentChild similar to text mode', () => {
// Arrange & Act
render(
<ChunkContent
{...defaultProps}
docForm={ChunkingMode.parentChild}
isEditMode={true}
/>,
)
// Assert - parentChild should render like text mode
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle empty question', () => {
// Arrange & Act
render(
<ChunkContent
{...defaultProps}
question=""
isEditMode={true}
/>,
)
// Assert
const textarea = screen.getByRole('textbox')
expect(textarea).toHaveValue('')
})
it('should handle empty answer in QA mode', () => {
// Arrange & Act
render(
<ChunkContent
{...defaultProps}
docForm={ChunkingMode.qa}
question="question"
answer=""
onAnswerChange={vi.fn()}
isEditMode={true}
/>,
)
// Assert
const textareas = screen.getAllByRole('textbox')
expect(textareas[1]).toHaveValue('')
})
it('should handle undefined answer in QA mode', () => {
// Arrange & Act
render(
<ChunkContent
{...defaultProps}
docForm={ChunkingMode.qa}
isEditMode={true}
/>,
)
// Assert - should render without crashing
expect(screen.getByText('QUESTION')).toBeInTheDocument()
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender } = render(
<ChunkContent {...defaultProps} question="Initial" isEditMode={true} />,
)
// Act
rerender(
<ChunkContent {...defaultProps} question="Updated" isEditMode={true} />,
)
// Assert
const textarea = screen.getByRole('textbox')
expect(textarea).toHaveValue('Updated')
})
})
})

View File

@ -0,0 +1,60 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import Dot from './dot'
describe('Dot', () => {
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<Dot />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render the dot character', () => {
// Arrange & Act
render(<Dot />)
// Assert
expect(screen.getByText('·')).toBeInTheDocument()
})
it('should render with correct styling classes', () => {
// Arrange & Act
const { container } = render(<Dot />)
// Assert
const dotElement = container.firstChild as HTMLElement
expect(dotElement).toHaveClass('system-xs-medium')
expect(dotElement).toHaveClass('text-text-quaternary')
})
})
// Memoization tests
describe('Memoization', () => {
it('should render consistently across multiple renders', () => {
// Arrange & Act
const { container: container1 } = render(<Dot />)
const { container: container2 } = render(<Dot />)
// Assert
expect(container1.firstChild?.textContent).toBe(container2.firstChild?.textContent)
})
})
// Edge cases
describe('Edge Cases', () => {
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender } = render(<Dot />)
// Act
rerender(<Dot />)
// Assert
expect(screen.getByText('·')).toBeInTheDocument()
})
})
})

View File

@ -1,129 +1,153 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Empty from './empty'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
if (key === 'segment.empty')
return 'No results found'
if (key === 'segment.clearFilter')
return 'Clear Filter'
return key
},
}),
}))
describe('Empty Component', () => {
const defaultProps = {
onClearFilter: vi.fn(),
}
describe('Empty', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests
describe('Rendering', () => {
it('should render empty state message', () => {
render(<Empty {...defaultProps} />)
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<Empty onClearFilter={vi.fn()} />)
expect(screen.getByText('No results found')).toBeInTheDocument()
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render the file list icon', () => {
// Arrange & Act
const { container } = render(<Empty onClearFilter={vi.fn()} />)
// Assert - RiFileList2Line icon should be rendered
const icon = container.querySelector('.h-6.w-6')
expect(icon).toBeInTheDocument()
})
it('should render empty message text', () => {
// Arrange & Act
render(<Empty onClearFilter={vi.fn()} />)
// Assert - i18n key format: datasetDocuments:segment.empty
expect(screen.getByText(/segment\.empty/i)).toBeInTheDocument()
})
it('should render clear filter button', () => {
render(<Empty {...defaultProps} />)
// Arrange & Act
render(<Empty onClearFilter={vi.fn()} />)
expect(screen.getByText('Clear Filter')).toBeInTheDocument()
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render icon', () => {
const { container } = render(<Empty {...defaultProps} />)
it('should render background empty cards', () => {
// Arrange & Act
const { container } = render(<Empty onClearFilter={vi.fn()} />)
// Check for the icon container
// Assert - should have 10 background cards
const emptyCards = container.querySelectorAll('.bg-background-section-burn')
expect(emptyCards).toHaveLength(10)
})
})
// User Interactions
describe('User Interactions', () => {
it('should call onClearFilter when clear filter button is clicked', () => {
// Arrange
const mockOnClearFilter = vi.fn()
render(<Empty onClearFilter={mockOnClearFilter} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
expect(mockOnClearFilter).toHaveBeenCalledTimes(1)
})
})
// Structure tests
describe('Structure', () => {
it('should render the decorative lines', () => {
// Arrange & Act
const { container } = render(<Empty onClearFilter={vi.fn()} />)
// Assert - there should be 4 Line components (SVG elements)
const svgElements = container.querySelectorAll('svg')
expect(svgElements.length).toBeGreaterThanOrEqual(4)
})
it('should render mask overlay', () => {
// Arrange & Act
const { container } = render(<Empty onClearFilter={vi.fn()} />)
// Assert
const maskElement = container.querySelector('.bg-dataset-chunk-list-mask-bg')
expect(maskElement).toBeInTheDocument()
})
it('should render icon container with proper styling', () => {
// Arrange & Act
const { container } = render(<Empty onClearFilter={vi.fn()} />)
// Assert
const iconContainer = container.querySelector('.shadow-lg')
expect(iconContainer).toBeInTheDocument()
})
it('should render decorative lines', () => {
const { container } = render(<Empty {...defaultProps} />)
it('should render clear filter button with accent text styling', () => {
// Arrange & Act
render(<Empty onClearFilter={vi.fn()} />)
// Check for SVG lines
const svgs = container.querySelectorAll('svg')
expect(svgs.length).toBeGreaterThan(0)
})
it('should render background cards', () => {
const { container } = render(<Empty {...defaultProps} />)
// Check for background empty cards (10 of them)
const backgroundCards = container.querySelectorAll('.rounded-xl.bg-background-section-burn')
expect(backgroundCards.length).toBe(10)
})
it('should render mask overlay', () => {
const { container } = render(<Empty {...defaultProps} />)
const maskOverlay = container.querySelector('.bg-dataset-chunk-list-mask-bg')
expect(maskOverlay).toBeInTheDocument()
// Assert
const button = screen.getByRole('button')
expect(button).toHaveClass('text-text-accent')
})
})
describe('Interactions', () => {
it('should call onClearFilter when clear filter button is clicked', () => {
const onClearFilter = vi.fn()
// Props tests
describe('Props', () => {
it('should accept onClearFilter callback prop', () => {
// Arrange
const mockCallback = vi.fn()
render(<Empty onClearFilter={onClearFilter} />)
// Act
render(<Empty onClearFilter={mockCallback} />)
fireEvent.click(screen.getByRole('button'))
const clearButton = screen.getByText('Clear Filter')
fireEvent.click(clearButton)
expect(onClearFilter).toHaveBeenCalledTimes(1)
// Assert
expect(mockCallback).toHaveBeenCalled()
})
})
describe('Memoization', () => {
it('should be memoized', () => {
// Empty is wrapped with React.memo
const { rerender } = render(<Empty {...defaultProps} />)
// Edge cases
describe('Edge Cases', () => {
it('should handle multiple clicks on clear filter button', () => {
// Arrange
const mockOnClearFilter = vi.fn()
render(<Empty onClearFilter={mockOnClearFilter} />)
// Same props should not cause re-render issues
rerender(<Empty {...defaultProps} />)
// Act
const button = screen.getByRole('button')
fireEvent.click(button)
fireEvent.click(button)
fireEvent.click(button)
expect(screen.getByText('No results found')).toBeInTheDocument()
// Assert
expect(mockOnClearFilter).toHaveBeenCalledTimes(3)
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender, container } = render(<Empty onClearFilter={vi.fn()} />)
// Act
rerender(<Empty onClearFilter={vi.fn()} />)
// Assert
const emptyCards = container.querySelectorAll('.bg-background-section-burn')
expect(emptyCards).toHaveLength(10)
})
})
})
describe('EmptyCard Component', () => {
it('should render within Empty component', () => {
const { container } = render(<Empty onClearFilter={vi.fn()} />)
// EmptyCard renders as background cards
const emptyCards = container.querySelectorAll('.h-32.w-full')
expect(emptyCards.length).toBe(10)
})
it('should have correct opacity', () => {
const { container } = render(<Empty onClearFilter={vi.fn()} />)
const emptyCards = container.querySelectorAll('.opacity-30')
expect(emptyCards.length).toBe(10)
})
})
describe('Line Component', () => {
it('should render SVG lines within Empty component', () => {
const { container } = render(<Empty onClearFilter={vi.fn()} />)
// Line components render as SVG elements (4 Line components + 1 icon SVG)
const lines = container.querySelectorAll('svg')
expect(lines.length).toBeGreaterThanOrEqual(4)
})
it('should have gradient definition', () => {
const { container } = render(<Empty onClearFilter={vi.fn()} />)
const gradients = container.querySelectorAll('linearGradient')
expect(gradients.length).toBeGreaterThan(0)
})
})

View File

@ -0,0 +1,262 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import FullScreenDrawer from './full-screen-drawer'
// Mock the Drawer component since it has high complexity
vi.mock('./drawer', () => ({
default: ({ children, open, panelClassName, panelContentClassName, showOverlay, needCheckChunks, modal }: { children: ReactNode, open: boolean, panelClassName: string, panelContentClassName: string, showOverlay: boolean, needCheckChunks: boolean, modal: boolean }) => {
if (!open)
return null
return (
<div
data-testid="drawer-mock"
data-panel-class={panelClassName}
data-panel-content-class={panelContentClassName}
data-show-overlay={showOverlay}
data-need-check-chunks={needCheckChunks}
data-modal={modal}
>
{children}
</div>
)
},
}))
describe('FullScreenDrawer', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests
describe('Rendering', () => {
it('should render without crashing when open', () => {
// Arrange & Act
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
)
// Assert
expect(screen.getByTestId('drawer-mock')).toBeInTheDocument()
})
it('should not render when closed', () => {
// Arrange & Act
render(
<FullScreenDrawer isOpen={false} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
)
// Assert
expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument()
})
it('should render children content', () => {
// Arrange & Act
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<div>Test Content</div>
</FullScreenDrawer>,
)
// Assert
expect(screen.getByText('Test Content')).toBeInTheDocument()
})
})
// Props tests
describe('Props', () => {
it('should pass fullScreen=true to Drawer with full width class', () => {
// Arrange & Act
render(
<FullScreenDrawer isOpen={true} fullScreen={true}>
<div>Content</div>
</FullScreenDrawer>,
)
// Assert
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-panel-class')).toContain('w-full')
})
it('should pass fullScreen=false to Drawer with fixed width class', () => {
// Arrange & Act
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
)
// Assert
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-panel-class')).toContain('w-[568px]')
})
it('should pass showOverlay prop with default true', () => {
// Arrange & Act
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
)
// Assert
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-show-overlay')).toBe('true')
})
it('should pass showOverlay=false when specified', () => {
// Arrange & Act
render(
<FullScreenDrawer isOpen={true} fullScreen={false} showOverlay={false}>
<div>Content</div>
</FullScreenDrawer>,
)
// Assert
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-show-overlay')).toBe('false')
})
it('should pass needCheckChunks prop with default false', () => {
// Arrange & Act
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
)
// Assert
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-need-check-chunks')).toBe('false')
})
it('should pass needCheckChunks=true when specified', () => {
// Arrange & Act
render(
<FullScreenDrawer isOpen={true} fullScreen={false} needCheckChunks={true}>
<div>Content</div>
</FullScreenDrawer>,
)
// Assert
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-need-check-chunks')).toBe('true')
})
it('should pass modal prop with default false', () => {
// Arrange & Act
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
)
// Assert
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-modal')).toBe('false')
})
it('should pass modal=true when specified', () => {
// Arrange & Act
render(
<FullScreenDrawer isOpen={true} fullScreen={false} modal={true}>
<div>Content</div>
</FullScreenDrawer>,
)
// Assert
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-modal')).toBe('true')
})
})
// Styling tests
describe('Styling', () => {
it('should apply panel content classes for non-fullScreen mode', () => {
// Arrange & Act
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
)
// Assert
const drawer = screen.getByTestId('drawer-mock')
const contentClass = drawer.getAttribute('data-panel-content-class')
expect(contentClass).toContain('bg-components-panel-bg')
expect(contentClass).toContain('rounded-xl')
})
it('should apply panel content classes without border for fullScreen mode', () => {
// Arrange & Act
render(
<FullScreenDrawer isOpen={true} fullScreen={true}>
<div>Content</div>
</FullScreenDrawer>,
)
// Assert
const drawer = screen.getByTestId('drawer-mock')
const contentClass = drawer.getAttribute('data-panel-content-class')
expect(contentClass).toContain('bg-components-panel-bg')
expect(contentClass).not.toContain('rounded-xl')
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle undefined onClose gracefully', () => {
// Arrange & Act & Assert - should not throw
expect(() => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
)
}).not.toThrow()
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender } = render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
)
// Act
rerender(
<FullScreenDrawer isOpen={true} fullScreen={true}>
<div>Updated Content</div>
</FullScreenDrawer>,
)
// Assert
expect(screen.getByText('Updated Content')).toBeInTheDocument()
})
it('should handle toggle between open and closed states', () => {
// Arrange
const { rerender } = render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
)
expect(screen.getByTestId('drawer-mock')).toBeInTheDocument()
// Act
rerender(
<FullScreenDrawer isOpen={false} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
)
// Assert
expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,317 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Keywords from './keywords'
describe('Keywords', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(
<Keywords
keywords={['test']}
onKeywordsChange={vi.fn()}
/>,
)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render the keywords label', () => {
// Arrange & Act
render(
<Keywords
keywords={['test']}
onKeywordsChange={vi.fn()}
/>,
)
// Assert - i18n key format
expect(screen.getByText(/segment\.keywords/i)).toBeInTheDocument()
})
it('should render with correct container classes', () => {
// Arrange & Act
const { container } = render(
<Keywords
keywords={['test']}
onKeywordsChange={vi.fn()}
/>,
)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex')
expect(wrapper).toHaveClass('flex-col')
})
})
// Props tests
describe('Props', () => {
it('should display dash when no keywords and actionType is view', () => {
// Arrange & Act
render(
<Keywords
segInfo={{ id: '1', keywords: [] }}
keywords={[]}
onKeywordsChange={vi.fn()}
actionType="view"
/>,
)
// Assert
expect(screen.getByText('-')).toBeInTheDocument()
})
it('should not display dash when actionType is edit', () => {
// Arrange & Act
render(
<Keywords
segInfo={{ id: '1', keywords: [] }}
keywords={[]}
onKeywordsChange={vi.fn()}
actionType="edit"
/>,
)
// Assert
expect(screen.queryByText('-')).not.toBeInTheDocument()
})
it('should not display dash when actionType is add', () => {
// Arrange & Act
render(
<Keywords
segInfo={{ id: '1', keywords: [] }}
keywords={[]}
onKeywordsChange={vi.fn()}
actionType="add"
/>,
)
// Assert
expect(screen.queryByText('-')).not.toBeInTheDocument()
})
it('should apply custom className', () => {
// Arrange & Act
const { container } = render(
<Keywords
keywords={['test']}
onKeywordsChange={vi.fn()}
className="custom-class"
/>,
)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('custom-class')
})
it('should use default actionType of view', () => {
// Arrange & Act
render(
<Keywords
segInfo={{ id: '1', keywords: [] }}
keywords={[]}
onKeywordsChange={vi.fn()}
/>,
)
// Assert - dash should appear in view mode with empty keywords
expect(screen.getByText('-')).toBeInTheDocument()
})
})
// Structure tests
describe('Structure', () => {
it('should render label with uppercase styling', () => {
// Arrange & Act
const { container } = render(
<Keywords
keywords={['test']}
onKeywordsChange={vi.fn()}
/>,
)
// Assert
const labelElement = container.querySelector('.system-xs-medium-uppercase')
expect(labelElement).toBeInTheDocument()
})
it('should render keywords container with overflow handling', () => {
// Arrange & Act
const { container } = render(
<Keywords
keywords={['test']}
onKeywordsChange={vi.fn()}
/>,
)
// Assert
const keywordsContainer = container.querySelector('.overflow-auto')
expect(keywordsContainer).toBeInTheDocument()
})
it('should render keywords container with max height', () => {
// Arrange & Act
const { container } = render(
<Keywords
keywords={['test']}
onKeywordsChange={vi.fn()}
/>,
)
// Assert
const keywordsContainer = container.querySelector('.max-h-\\[200px\\]')
expect(keywordsContainer).toBeInTheDocument()
})
})
// Edit mode tests
describe('Edit Mode', () => {
it('should render TagInput component when keywords exist', () => {
// Arrange & Act
const { container } = render(
<Keywords
segInfo={{ id: '1', keywords: ['keyword1', 'keyword2'] }}
keywords={['keyword1', 'keyword2']}
onKeywordsChange={vi.fn()}
isEditMode={true}
/>,
)
// Assert - TagInput should be rendered instead of dash
expect(screen.queryByText('-')).not.toBeInTheDocument()
expect(container.querySelector('.flex-wrap')).toBeInTheDocument()
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle empty keywords array in view mode without segInfo keywords', () => {
// Arrange & Act
const { container } = render(
<Keywords
keywords={[]}
onKeywordsChange={vi.fn()}
actionType="view"
/>,
)
// Assert - container should be rendered
expect(container.firstChild).toBeInTheDocument()
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender, container } = render(
<Keywords
segInfo={{ id: '1', keywords: ['test'] }}
keywords={['test']}
onKeywordsChange={vi.fn()}
/>,
)
// Act
rerender(
<Keywords
segInfo={{ id: '1', keywords: ['test', 'new'] }}
keywords={['test', 'new']}
onKeywordsChange={vi.fn()}
/>,
)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should handle segInfo with undefined keywords showing dash in view mode', () => {
// Arrange & Act
render(
<Keywords
segInfo={{ id: '1' }}
keywords={['test']}
onKeywordsChange={vi.fn()}
actionType="view"
/>,
)
// Assert - dash should show because segInfo.keywords is undefined/empty
expect(screen.getByText('-')).toBeInTheDocument()
})
})
// TagInput callback tests
describe('TagInput Callback', () => {
it('should call onKeywordsChange when keywords are modified', () => {
// Arrange
const mockOnKeywordsChange = vi.fn()
render(
<Keywords
segInfo={{ id: '1', keywords: ['existing'] }}
keywords={['existing']}
onKeywordsChange={mockOnKeywordsChange}
isEditMode={true}
actionType="edit"
/>,
)
// Assert - TagInput should be rendered
expect(screen.queryByText('-')).not.toBeInTheDocument()
})
it('should disable add when isEditMode is false', () => {
// Arrange & Act
const { container } = render(
<Keywords
segInfo={{ id: '1', keywords: ['test'] }}
keywords={['test']}
onKeywordsChange={vi.fn()}
isEditMode={false}
actionType="view"
/>,
)
// Assert - TagInput should exist but with disabled add
expect(container.firstChild).toBeInTheDocument()
})
it('should disable remove when only one keyword exists in edit mode', () => {
// Arrange & Act
const { container } = render(
<Keywords
segInfo={{ id: '1', keywords: ['only-one'] }}
keywords={['only-one']}
onKeywordsChange={vi.fn()}
isEditMode={true}
actionType="edit"
/>,
)
// Assert - component should render
expect(container.firstChild).toBeInTheDocument()
})
it('should allow remove when multiple keywords exist in edit mode', () => {
// Arrange & Act
const { container } = render(
<Keywords
segInfo={{ id: '1', keywords: ['first', 'second'] }}
keywords={['first', 'second']}
onKeywordsChange={vi.fn()}
isEditMode={true}
actionType="edit"
/>,
)
// Assert - component should render
expect(container.firstChild).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,327 @@
import type { ReactNode } from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { EventEmitterContextProvider, useEventEmitterContextContext } from '@/context/event-emitter'
import RegenerationModal from './regeneration-modal'
// Store emit function for triggering events in tests
let emitFunction: ((v: string) => void) | null = null
const EmitCapture = () => {
const { eventEmitter } = useEventEmitterContextContext()
emitFunction = eventEmitter?.emit?.bind(eventEmitter) || null
return null
}
// Custom wrapper that captures emit function
const TestWrapper = ({ children }: { children: ReactNode }) => {
return (
<EventEmitterContextProvider>
<EmitCapture />
{children}
</EventEmitterContextProvider>
)
}
// Create a wrapper component with event emitter context
const createWrapper = () => {
return ({ children }: { children: ReactNode }) => (
<TestWrapper>
{children}
</TestWrapper>
)
}
describe('RegenerationModal', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const defaultProps = {
isShow: true,
onConfirm: vi.fn(),
onCancel: vi.fn(),
onClose: vi.fn(),
}
// Rendering tests
describe('Rendering', () => {
it('should render without crashing when isShow is true', () => {
// Arrange & Act
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
// Assert
expect(screen.getByText(/segment\.regenerationConfirmTitle/i)).toBeInTheDocument()
})
it('should not render content when isShow is false', () => {
// Arrange & Act
render(<RegenerationModal {...defaultProps} isShow={false} />, { wrapper: createWrapper() })
// Assert - Modal container might exist but content should not be visible
expect(screen.queryByText(/segment\.regenerationConfirmTitle/i)).not.toBeInTheDocument()
})
it('should render confirmation message', () => {
// Arrange & Act
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
// Assert
expect(screen.getByText(/segment\.regenerationConfirmMessage/i)).toBeInTheDocument()
})
it('should render cancel button in default state', () => {
// Arrange & Act
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
// Assert
expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
})
it('should render regenerate button in default state', () => {
// Arrange & Act
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
// Assert
expect(screen.getByText(/operation\.regenerate/i)).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should call onCancel when cancel button is clicked', () => {
// Arrange
const mockOnCancel = vi.fn()
render(<RegenerationModal {...defaultProps} onCancel={mockOnCancel} />, { wrapper: createWrapper() })
// Act
fireEvent.click(screen.getByText(/operation\.cancel/i))
// Assert
expect(mockOnCancel).toHaveBeenCalledTimes(1)
})
it('should call onConfirm when regenerate button is clicked', () => {
// Arrange
const mockOnConfirm = vi.fn()
render(<RegenerationModal {...defaultProps} onConfirm={mockOnConfirm} />, { wrapper: createWrapper() })
// Act
fireEvent.click(screen.getByText(/operation\.regenerate/i))
// Assert
expect(mockOnConfirm).toHaveBeenCalledTimes(1)
})
})
// Modal content states - these would require event emitter manipulation
describe('Modal States', () => {
it('should show default content initially', () => {
// Arrange & Act
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
// Assert
expect(screen.getByText(/segment\.regenerationConfirmTitle/i)).toBeInTheDocument()
expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle toggling isShow prop', () => {
// Arrange
const { rerender } = render(
<RegenerationModal {...defaultProps} isShow={true} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText(/segment\.regenerationConfirmTitle/i)).toBeInTheDocument()
// Act
rerender(
<TestWrapper>
<RegenerationModal {...defaultProps} isShow={false} />
</TestWrapper>,
)
// Assert
expect(screen.queryByText(/segment\.regenerationConfirmTitle/i)).not.toBeInTheDocument()
})
it('should maintain handlers when rerendered', () => {
// Arrange
const mockOnConfirm = vi.fn()
const { rerender } = render(
<RegenerationModal {...defaultProps} onConfirm={mockOnConfirm} />,
{ wrapper: createWrapper() },
)
// Act
rerender(
<TestWrapper>
<RegenerationModal {...defaultProps} onConfirm={mockOnConfirm} />
</TestWrapper>,
)
fireEvent.click(screen.getByText(/operation\.regenerate/i))
// Assert
expect(mockOnConfirm).toHaveBeenCalledTimes(1)
})
})
// Loading state
describe('Loading State', () => {
it('should show regenerating content when update-segment event is emitted', async () => {
// Arrange
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
// Act
act(() => {
if (emitFunction)
emitFunction('update-segment')
})
// Assert
await waitFor(() => {
expect(screen.getByText(/segment\.regeneratingTitle/i)).toBeInTheDocument()
})
})
it('should show regenerating message during loading', async () => {
// Arrange
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
// Act
act(() => {
if (emitFunction)
emitFunction('update-segment')
})
// Assert
await waitFor(() => {
expect(screen.getByText(/segment\.regeneratingMessage/i)).toBeInTheDocument()
})
})
it('should disable regenerate button during loading', async () => {
// Arrange
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
// Act
act(() => {
if (emitFunction)
emitFunction('update-segment')
})
// Assert
await waitFor(() => {
const button = screen.getByText(/operation\.regenerate/i).closest('button')
expect(button).toBeDisabled()
})
})
})
// Success state
describe('Success State', () => {
it('should show success content when update-segment-success event is emitted followed by done', async () => {
// Arrange
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
// Act - trigger loading then success then done
act(() => {
if (emitFunction) {
emitFunction('update-segment')
emitFunction('update-segment-success')
emitFunction('update-segment-done')
}
})
// Assert
await waitFor(() => {
expect(screen.getByText(/segment\.regenerationSuccessTitle/i)).toBeInTheDocument()
})
})
it('should show success message when completed', async () => {
// Arrange
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
// Act
act(() => {
if (emitFunction) {
emitFunction('update-segment')
emitFunction('update-segment-success')
emitFunction('update-segment-done')
}
})
// Assert
await waitFor(() => {
expect(screen.getByText(/segment\.regenerationSuccessMessage/i)).toBeInTheDocument()
})
})
it('should show close button with countdown in success state', async () => {
// Arrange
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
// Act
act(() => {
if (emitFunction) {
emitFunction('update-segment')
emitFunction('update-segment-success')
emitFunction('update-segment-done')
}
})
// Assert
await waitFor(() => {
expect(screen.getByText(/operation\.close/i)).toBeInTheDocument()
})
})
it('should call onClose when close button is clicked in success state', async () => {
// Arrange
const mockOnClose = vi.fn()
render(<RegenerationModal {...defaultProps} onClose={mockOnClose} />, { wrapper: createWrapper() })
// Act
act(() => {
if (emitFunction) {
emitFunction('update-segment')
emitFunction('update-segment-success')
emitFunction('update-segment-done')
}
})
await waitFor(() => {
expect(screen.getByText(/operation\.close/i)).toBeInTheDocument()
})
fireEvent.click(screen.getByText(/operation\.close/i))
// Assert
expect(mockOnClose).toHaveBeenCalled()
})
})
// State transitions
describe('State Transitions', () => {
it('should return to default content when update fails (no success event)', async () => {
// Arrange
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
// Act - trigger loading then done without success
act(() => {
if (emitFunction) {
emitFunction('update-segment')
emitFunction('update-segment-done')
}
})
// Assert - should show default content
await waitFor(() => {
expect(screen.getByText(/segment\.regenerationConfirmTitle/i)).toBeInTheDocument()
})
})
})
})

View File

@ -0,0 +1,215 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import SegmentIndexTag from './segment-index-tag'
describe('SegmentIndexTag', () => {
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<SegmentIndexTag positionId={1} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render the Chunk icon', () => {
// Arrange & Act
const { container } = render(<SegmentIndexTag positionId={1} />)
// Assert
const icon = container.querySelector('.h-3.w-3')
expect(icon).toBeInTheDocument()
})
it('should render with correct container classes', () => {
// Arrange & Act
const { container } = render(<SegmentIndexTag positionId={1} />)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex')
expect(wrapper).toHaveClass('items-center')
})
})
// Props tests
describe('Props', () => {
it('should render position ID with default prefix', () => {
// Arrange & Act
render(<SegmentIndexTag positionId={5} />)
// Assert - default prefix is 'Chunk'
expect(screen.getByText('Chunk-05')).toBeInTheDocument()
})
it('should render position ID without padding for two-digit numbers', () => {
// Arrange & Act
render(<SegmentIndexTag positionId={15} />)
// Assert
expect(screen.getByText('Chunk-15')).toBeInTheDocument()
})
it('should render position ID without padding for three-digit numbers', () => {
// Arrange & Act
render(<SegmentIndexTag positionId={123} />)
// Assert
expect(screen.getByText('Chunk-123')).toBeInTheDocument()
})
it('should render custom label when provided', () => {
// Arrange & Act
render(<SegmentIndexTag positionId={1} label="Custom Label" />)
// Assert
expect(screen.getByText('Custom Label')).toBeInTheDocument()
})
it('should use custom labelPrefix', () => {
// Arrange & Act
render(<SegmentIndexTag positionId={3} labelPrefix="Segment" />)
// Assert
expect(screen.getByText('Segment-03')).toBeInTheDocument()
})
it('should apply custom className', () => {
// Arrange & Act
const { container } = render(
<SegmentIndexTag positionId={1} className="custom-class" />,
)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('custom-class')
})
it('should apply custom iconClassName', () => {
// Arrange & Act
const { container } = render(
<SegmentIndexTag positionId={1} iconClassName="custom-icon-class" />,
)
// Assert
const icon = container.querySelector('.custom-icon-class')
expect(icon).toBeInTheDocument()
})
it('should apply custom labelClassName', () => {
// Arrange & Act
const { container } = render(
<SegmentIndexTag positionId={1} labelClassName="custom-label-class" />,
)
// Assert
const label = container.querySelector('.custom-label-class')
expect(label).toBeInTheDocument()
})
it('should handle string positionId', () => {
// Arrange & Act
render(<SegmentIndexTag positionId="7" />)
// Assert
expect(screen.getByText('Chunk-07')).toBeInTheDocument()
})
})
// Memoization tests
describe('Memoization', () => {
it('should compute localPositionId based on positionId and labelPrefix', () => {
// Arrange & Act
const { rerender } = render(<SegmentIndexTag positionId={1} />)
expect(screen.getByText('Chunk-01')).toBeInTheDocument()
// Act - change positionId
rerender(<SegmentIndexTag positionId={2} />)
// Assert
expect(screen.getByText('Chunk-02')).toBeInTheDocument()
})
it('should update when labelPrefix changes', () => {
// Arrange & Act
const { rerender } = render(<SegmentIndexTag positionId={1} labelPrefix="Chunk" />)
expect(screen.getByText('Chunk-01')).toBeInTheDocument()
// Act - change labelPrefix
rerender(<SegmentIndexTag positionId={1} labelPrefix="Part" />)
// Assert
expect(screen.getByText('Part-01')).toBeInTheDocument()
})
})
// Structure tests
describe('Structure', () => {
it('should render icon with tertiary text color', () => {
// Arrange & Act
const { container } = render(<SegmentIndexTag positionId={1} />)
// Assert
const icon = container.querySelector('.text-text-tertiary')
expect(icon).toBeInTheDocument()
})
it('should render label with xs medium font styling', () => {
// Arrange & Act
const { container } = render(<SegmentIndexTag positionId={1} />)
// Assert
const label = container.querySelector('.system-xs-medium')
expect(label).toBeInTheDocument()
})
it('should render icon with margin-right spacing', () => {
// Arrange & Act
const { container } = render(<SegmentIndexTag positionId={1} />)
// Assert
const icon = container.querySelector('.mr-0\\.5')
expect(icon).toBeInTheDocument()
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle positionId of 0', () => {
// Arrange & Act
render(<SegmentIndexTag positionId={0} />)
// Assert
expect(screen.getByText('Chunk-00')).toBeInTheDocument()
})
it('should handle undefined positionId', () => {
// Arrange & Act
render(<SegmentIndexTag />)
// Assert - should display 'Chunk-undefined' or similar
expect(screen.getByText(/Chunk-/)).toBeInTheDocument()
})
it('should prioritize label over computed positionId', () => {
// Arrange & Act
render(<SegmentIndexTag positionId={99} label="Override" />)
// Assert
expect(screen.getByText('Override')).toBeInTheDocument()
expect(screen.queryByText('Chunk-99')).not.toBeInTheDocument()
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender, container } = render(<SegmentIndexTag positionId={1} />)
// Act
rerender(<SegmentIndexTag positionId={1} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,151 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import Tag from './tag'
describe('Tag', () => {
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<Tag text="test" />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render the hash symbol', () => {
// Arrange & Act
render(<Tag text="test" />)
// Assert
expect(screen.getByText('#')).toBeInTheDocument()
})
it('should render the text content', () => {
// Arrange & Act
render(<Tag text="keyword" />)
// Assert
expect(screen.getByText('keyword')).toBeInTheDocument()
})
it('should render with correct base styling classes', () => {
// Arrange & Act
const { container } = render(<Tag text="test" />)
// Assert
const tagElement = container.firstChild as HTMLElement
expect(tagElement).toHaveClass('inline-flex')
expect(tagElement).toHaveClass('items-center')
expect(tagElement).toHaveClass('gap-x-0.5')
})
})
// Props tests
describe('Props', () => {
it('should apply custom className', () => {
// Arrange & Act
const { container } = render(<Tag text="test" className="custom-class" />)
// Assert
const tagElement = container.firstChild as HTMLElement
expect(tagElement).toHaveClass('custom-class')
})
it('should render different text values', () => {
// Arrange & Act
const { rerender } = render(<Tag text="first" />)
expect(screen.getByText('first')).toBeInTheDocument()
// Act
rerender(<Tag text="second" />)
// Assert
expect(screen.getByText('second')).toBeInTheDocument()
})
})
// Structure tests
describe('Structure', () => {
it('should render hash with quaternary text color', () => {
// Arrange & Act
const { container } = render(<Tag text="test" />)
// Assert
const hashSpan = container.querySelector('.text-text-quaternary')
expect(hashSpan).toBeInTheDocument()
expect(hashSpan).toHaveTextContent('#')
})
it('should render text with tertiary text color', () => {
// Arrange & Act
const { container } = render(<Tag text="test" />)
// Assert
const textSpan = container.querySelector('.text-text-tertiary')
expect(textSpan).toBeInTheDocument()
expect(textSpan).toHaveTextContent('test')
})
it('should have truncate class for text overflow', () => {
// Arrange & Act
const { container } = render(<Tag text="very-long-text-that-might-overflow" />)
// Assert
const textSpan = container.querySelector('.truncate')
expect(textSpan).toBeInTheDocument()
})
it('should have max-width constraint on text', () => {
// Arrange & Act
const { container } = render(<Tag text="test" />)
// Assert
const textSpan = container.querySelector('.max-w-12')
expect(textSpan).toBeInTheDocument()
})
})
// Memoization tests
describe('Memoization', () => {
it('should render consistently with same props', () => {
// Arrange & Act
const { container: container1 } = render(<Tag text="test" />)
const { container: container2 } = render(<Tag text="test" />)
// Assert
expect(container1.firstChild?.textContent).toBe(container2.firstChild?.textContent)
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle empty text', () => {
// Arrange & Act
render(<Tag text="" />)
// Assert - should still render the hash symbol
expect(screen.getByText('#')).toBeInTheDocument()
})
it('should handle special characters in text', () => {
// Arrange & Act
render(<Tag text="test-tag_1" />)
// Assert
expect(screen.getByText('test-tag_1')).toBeInTheDocument()
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender } = render(<Tag text="test" />)
// Act
rerender(<Tag text="test" />)
// Assert
expect(screen.getByText('#')).toBeInTheDocument()
expect(screen.getByText('test')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,130 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DisplayToggle from './display-toggle'
describe('DisplayToggle', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
render(<DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render button with proper styling', () => {
// Arrange & Act
render(<DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />)
// Assert
const button = screen.getByRole('button')
expect(button).toHaveClass('flex')
expect(button).toHaveClass('items-center')
expect(button).toHaveClass('justify-center')
expect(button).toHaveClass('rounded-lg')
})
})
// Props tests
describe('Props', () => {
it('should render expand icon when isCollapsed is true', () => {
// Arrange & Act
const { container } = render(
<DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />,
)
// Assert - RiLineHeight icon for expand
const icon = container.querySelector('.h-4.w-4')
expect(icon).toBeInTheDocument()
})
it('should render collapse icon when isCollapsed is false', () => {
// Arrange & Act
const { container } = render(
<DisplayToggle isCollapsed={false} toggleCollapsed={vi.fn()} />,
)
// Assert - Collapse icon
const icon = container.querySelector('.h-4.w-4')
expect(icon).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should call toggleCollapsed when button is clicked', () => {
// Arrange
const mockToggle = vi.fn()
render(<DisplayToggle isCollapsed={true} toggleCollapsed={mockToggle} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
expect(mockToggle).toHaveBeenCalledTimes(1)
})
it('should call toggleCollapsed on multiple clicks', () => {
// Arrange
const mockToggle = vi.fn()
render(<DisplayToggle isCollapsed={true} toggleCollapsed={mockToggle} />)
// Act
const button = screen.getByRole('button')
fireEvent.click(button)
fireEvent.click(button)
fireEvent.click(button)
// Assert
expect(mockToggle).toHaveBeenCalledTimes(3)
})
})
// Tooltip tests
describe('Tooltip', () => {
it('should render with tooltip wrapper', () => {
// Arrange & Act
const { container } = render(
<DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />,
)
// Assert - Tooltip renders a wrapper around button
expect(container.firstChild).toBeInTheDocument()
})
})
// Edge cases
describe('Edge Cases', () => {
it('should toggle icon when isCollapsed prop changes', () => {
// Arrange
const { rerender, container } = render(
<DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />,
)
// Act
rerender(<DisplayToggle isCollapsed={false} toggleCollapsed={vi.fn()} />)
// Assert - icon should still be present
const icon = container.querySelector('.h-4.w-4')
expect(icon).toBeInTheDocument()
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender } = render(
<DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />,
)
// Act
rerender(<DisplayToggle isCollapsed={false} toggleCollapsed={vi.fn()} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,507 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import NewChildSegmentModal from './new-child-segment'
// Mock next/navigation
vi.mock('next/navigation', () => ({
useParams: () => ({
datasetId: 'test-dataset-id',
documentId: 'test-document-id',
}),
}))
// Mock ToastContext
const mockNotify = vi.fn()
vi.mock('use-context-selector', async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>
return {
...actual,
useContext: () => ({ notify: mockNotify }),
}
})
// Mock document context
let mockParentMode = 'paragraph'
vi.mock('../context', () => ({
useDocumentContext: (selector: (state: { parentMode: string }) => unknown) => {
return selector({ parentMode: mockParentMode })
},
}))
// Mock segment list context
let mockFullScreen = false
const mockToggleFullScreen = vi.fn()
vi.mock('./index', () => ({
useSegmentListContext: (selector: (state: { fullScreen: boolean, toggleFullScreen: () => void }) => unknown) => {
const state = {
fullScreen: mockFullScreen,
toggleFullScreen: mockToggleFullScreen,
}
return selector(state)
},
}))
// Mock useAddChildSegment
const mockAddChildSegment = vi.fn()
vi.mock('@/service/knowledge/use-segment', () => ({
useAddChildSegment: () => ({
mutateAsync: mockAddChildSegment,
}),
}))
// Mock app store
vi.mock('@/app/components/app/store', () => ({
useStore: () => ({ appSidebarExpand: 'expand' }),
}))
// Mock child components
vi.mock('./common/action-buttons', () => ({
default: ({ handleCancel, handleSave, loading, actionType, isChildChunk }: { handleCancel: () => void, handleSave: () => void, loading: boolean, actionType: string, isChildChunk?: boolean }) => (
<div data-testid="action-buttons">
<button onClick={handleCancel} data-testid="cancel-btn">Cancel</button>
<button onClick={handleSave} disabled={loading} data-testid="save-btn">
{loading ? 'Saving...' : 'Save'}
</button>
<span data-testid="action-type">{actionType}</span>
<span data-testid="is-child-chunk">{isChildChunk ? 'true' : 'false'}</span>
</div>
),
}))
vi.mock('./common/add-another', () => ({
default: ({ isChecked, onCheck, className }: { isChecked: boolean, onCheck: () => void, className?: string }) => (
<div data-testid="add-another" className={className}>
<input
type="checkbox"
checked={isChecked}
onChange={onCheck}
data-testid="add-another-checkbox"
/>
</div>
),
}))
vi.mock('./common/chunk-content', () => ({
default: ({ question, onQuestionChange, isEditMode }: { question: string, onQuestionChange: (v: string) => void, isEditMode: boolean }) => (
<div data-testid="chunk-content">
<input
data-testid="content-input"
value={question}
onChange={e => onQuestionChange(e.target.value)}
/>
<span data-testid="edit-mode">{isEditMode ? 'editing' : 'viewing'}</span>
</div>
),
}))
vi.mock('./common/dot', () => ({
default: () => <span data-testid="dot"></span>,
}))
vi.mock('./common/segment-index-tag', () => ({
SegmentIndexTag: ({ label }: { label: string }) => <span data-testid="segment-index-tag">{label}</span>,
}))
describe('NewChildSegmentModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFullScreen = false
mockParentMode = 'paragraph'
})
const defaultProps = {
chunkId: 'chunk-1',
onCancel: vi.fn(),
onSave: vi.fn(),
viewNewlyAddedChildChunk: vi.fn(),
}
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<NewChildSegmentModal {...defaultProps} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render add child chunk title', () => {
// Arrange & Act
render(<NewChildSegmentModal {...defaultProps} />)
// Assert
expect(screen.getByText(/segment\.addChildChunk/i)).toBeInTheDocument()
})
it('should render chunk content component', () => {
// Arrange & Act
render(<NewChildSegmentModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('chunk-content')).toBeInTheDocument()
})
it('should render segment index tag with new child chunk label', () => {
// Arrange & Act
render(<NewChildSegmentModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('segment-index-tag')).toBeInTheDocument()
})
it('should render add another checkbox', () => {
// Arrange & Act
render(<NewChildSegmentModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('add-another')).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should call onCancel when close button is clicked', () => {
// Arrange
const mockOnCancel = vi.fn()
const { container } = render(
<NewChildSegmentModal {...defaultProps} onCancel={mockOnCancel} />,
)
// Act
const closeButtons = container.querySelectorAll('.cursor-pointer')
if (closeButtons.length > 1)
fireEvent.click(closeButtons[1])
// Assert
expect(mockOnCancel).toHaveBeenCalled()
})
it('should call toggleFullScreen when expand button is clicked', () => {
// Arrange
const { container } = render(<NewChildSegmentModal {...defaultProps} />)
// Act
const expandButtons = container.querySelectorAll('.cursor-pointer')
if (expandButtons.length > 0)
fireEvent.click(expandButtons[0])
// Assert
expect(mockToggleFullScreen).toHaveBeenCalled()
})
it('should update content when input changes', () => {
// Arrange
render(<NewChildSegmentModal {...defaultProps} />)
// Act
fireEvent.change(screen.getByTestId('content-input'), {
target: { value: 'New content' },
})
// Assert
expect(screen.getByTestId('content-input')).toHaveValue('New content')
})
it('should toggle add another checkbox', () => {
// Arrange
render(<NewChildSegmentModal {...defaultProps} />)
const checkbox = screen.getByTestId('add-another-checkbox')
// Act
fireEvent.click(checkbox)
// Assert
expect(checkbox).toBeInTheDocument()
})
})
// Save validation
describe('Save Validation', () => {
it('should show error when content is empty', async () => {
// Arrange
render(<NewChildSegmentModal {...defaultProps} />)
// Act
fireEvent.click(screen.getByTestId('save-btn'))
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
)
})
})
})
// Successful save
describe('Successful Save', () => {
it('should call addChildSegment when valid content is provided', async () => {
// Arrange
mockAddChildSegment.mockImplementation((_params, options) => {
options.onSuccess({ data: { id: 'new-child-id' } })
options.onSettled()
return Promise.resolve()
})
render(<NewChildSegmentModal {...defaultProps} />)
fireEvent.change(screen.getByTestId('content-input'), {
target: { value: 'Valid content' },
})
// Act
fireEvent.click(screen.getByTestId('save-btn'))
// Assert
await waitFor(() => {
expect(mockAddChildSegment).toHaveBeenCalledWith(
expect.objectContaining({
datasetId: 'test-dataset-id',
documentId: 'test-document-id',
segmentId: 'chunk-1',
body: expect.objectContaining({
content: 'Valid content',
}),
}),
expect.any(Object),
)
})
})
it('should show success notification after save', async () => {
// Arrange
mockAddChildSegment.mockImplementation((_params, options) => {
options.onSuccess({ data: { id: 'new-child-id' } })
options.onSettled()
return Promise.resolve()
})
render(<NewChildSegmentModal {...defaultProps} />)
fireEvent.change(screen.getByTestId('content-input'), {
target: { value: 'Valid content' },
})
// Act
fireEvent.click(screen.getByTestId('save-btn'))
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
}),
)
})
})
})
// Full screen mode
describe('Full Screen Mode', () => {
it('should show action buttons in header when fullScreen', () => {
// Arrange
mockFullScreen = true
// Act
render(<NewChildSegmentModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('action-buttons')).toBeInTheDocument()
})
it('should show add another in header when fullScreen', () => {
// Arrange
mockFullScreen = true
// Act
render(<NewChildSegmentModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('add-another')).toBeInTheDocument()
})
})
// Props
describe('Props', () => {
it('should pass actionType add to ActionButtons', () => {
// Arrange & Act
render(<NewChildSegmentModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('action-type')).toHaveTextContent('add')
})
it('should pass isChildChunk true to ActionButtons', () => {
// Arrange & Act
render(<NewChildSegmentModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('is-child-chunk')).toHaveTextContent('true')
})
it('should pass isEditMode true to ChunkContent', () => {
// Arrange & Act
render(<NewChildSegmentModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('edit-mode')).toHaveTextContent('editing')
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle undefined viewNewlyAddedChildChunk', () => {
// Arrange
const props = { ...defaultProps, viewNewlyAddedChildChunk: undefined }
// Act
const { container } = render(<NewChildSegmentModal {...props} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender } = render(<NewChildSegmentModal {...defaultProps} />)
// Act
rerender(<NewChildSegmentModal {...defaultProps} chunkId="chunk-2" />)
// Assert
expect(screen.getByTestId('chunk-content')).toBeInTheDocument()
})
})
// Add another behavior
describe('Add Another Behavior', () => {
it('should close modal when add another is unchecked after save', async () => {
// Arrange
const mockOnCancel = vi.fn()
mockAddChildSegment.mockImplementation((_params, options) => {
options.onSuccess({ data: { id: 'new-child-id' } })
options.onSettled()
return Promise.resolve()
})
render(<NewChildSegmentModal {...defaultProps} onCancel={mockOnCancel} />)
// Uncheck add another
fireEvent.click(screen.getByTestId('add-another-checkbox'))
// Enter valid content
fireEvent.change(screen.getByTestId('content-input'), {
target: { value: 'Valid content' },
})
// Act
fireEvent.click(screen.getByTestId('save-btn'))
// Assert - modal should close
await waitFor(() => {
expect(mockOnCancel).toHaveBeenCalled()
})
})
it('should not close modal when add another is checked after save', async () => {
// Arrange
const mockOnCancel = vi.fn()
mockAddChildSegment.mockImplementation((_params, options) => {
options.onSuccess({ data: { id: 'new-child-id' } })
options.onSettled()
return Promise.resolve()
})
render(<NewChildSegmentModal {...defaultProps} onCancel={mockOnCancel} />)
// Enter valid content (add another is checked by default)
fireEvent.change(screen.getByTestId('content-input'), {
target: { value: 'Valid content' },
})
// Act
fireEvent.click(screen.getByTestId('save-btn'))
// Assert - modal should not close, only content cleared
await waitFor(() => {
expect(screen.getByTestId('content-input')).toHaveValue('')
})
})
})
// View newly added chunk
describe('View Newly Added Chunk', () => {
it('should show custom button in full-doc mode after save', async () => {
// Arrange
mockParentMode = 'full-doc'
mockAddChildSegment.mockImplementation((_params, options) => {
options.onSuccess({ data: { id: 'new-child-id' } })
options.onSettled()
return Promise.resolve()
})
render(<NewChildSegmentModal {...defaultProps} />)
// Enter valid content
fireEvent.change(screen.getByTestId('content-input'), {
target: { value: 'Valid content' },
})
// Act
fireEvent.click(screen.getByTestId('save-btn'))
// Assert - success notification with custom component
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
customComponent: expect.anything(),
}),
)
})
})
it('should not show custom button in paragraph mode after save', async () => {
// Arrange
mockParentMode = 'paragraph'
const mockOnSave = vi.fn()
mockAddChildSegment.mockImplementation((_params, options) => {
options.onSuccess({ data: { id: 'new-child-id' } })
options.onSettled()
return Promise.resolve()
})
render(<NewChildSegmentModal {...defaultProps} onSave={mockOnSave} />)
// Enter valid content
fireEvent.change(screen.getByTestId('content-input'), {
target: { value: 'Valid content' },
})
// Act
fireEvent.click(screen.getByTestId('save-btn'))
// Assert - onSave should be called with data
await waitFor(() => {
expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ id: 'new-child-id' }))
})
})
})
// Cancel behavior
describe('Cancel Behavior', () => {
it('should call onCancel when close button is clicked', () => {
// Arrange
const mockOnCancel = vi.fn()
render(<NewChildSegmentModal {...defaultProps} onCancel={mockOnCancel} />)
// Act
fireEvent.click(screen.getByTestId('cancel-btn'))
// Assert
expect(mockOnCancel).toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,270 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { noop } from 'es-toolkit/function'
import { createContext, useContextSelector } from 'use-context-selector'
import { describe, expect, it, vi } from 'vitest'
import ChunkContent from './chunk-content'
// Create mock context matching the actual SegmentListContextValue
type SegmentListContextValue = {
isCollapsed: boolean
fullScreen: boolean
toggleFullScreen: (fullscreen?: boolean) => void
currSegment: { showModal: boolean }
currChildChunk: { showModal: boolean }
}
const MockSegmentListContext = createContext<SegmentListContextValue>({
isCollapsed: true,
fullScreen: false,
toggleFullScreen: noop,
currSegment: { showModal: false },
currChildChunk: { showModal: false },
})
// Mock the context module
vi.mock('..', () => ({
useSegmentListContext: (selector: (value: SegmentListContextValue) => unknown) => {
return useContextSelector(MockSegmentListContext, selector)
},
}))
// Helper to create wrapper with context
const createWrapper = (isCollapsed: boolean = true) => {
return ({ children }: { children: ReactNode }) => (
<MockSegmentListContext.Provider
value={{
isCollapsed,
fullScreen: false,
toggleFullScreen: noop,
currSegment: { showModal: false },
currChildChunk: { showModal: false },
}}
>
{children}
</MockSegmentListContext.Provider>
)
}
describe('ChunkContent', () => {
const defaultDetail = {
content: 'Test content',
sign_content: 'Test sign content',
}
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(
<ChunkContent detail={defaultDetail} isFullDocMode={false} />,
{ wrapper: createWrapper() },
)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render content in non-QA mode', () => {
// Arrange & Act
const { container } = render(
<ChunkContent detail={defaultDetail} isFullDocMode={false} />,
{ wrapper: createWrapper() },
)
// Assert - should render without Q and A labels
expect(container.textContent).not.toContain('Q')
expect(container.textContent).not.toContain('A')
})
})
// QA mode tests
describe('QA Mode', () => {
it('should render Q and A labels when answer is present', () => {
// Arrange
const qaDetail = {
content: 'Question content',
sign_content: 'Sign content',
answer: 'Answer content',
}
// Act
render(
<ChunkContent detail={qaDetail} isFullDocMode={false} />,
{ wrapper: createWrapper() },
)
// Assert
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
})
it('should not render Q and A labels when answer is undefined', () => {
// Arrange & Act
render(
<ChunkContent detail={defaultDetail} isFullDocMode={false} />,
{ wrapper: createWrapper() },
)
// Assert
expect(screen.queryByText('Q')).not.toBeInTheDocument()
expect(screen.queryByText('A')).not.toBeInTheDocument()
})
})
// Props tests
describe('Props', () => {
it('should apply custom className', () => {
// Arrange & Act
const { container } = render(
<ChunkContent
detail={defaultDetail}
isFullDocMode={false}
className="custom-class"
/>,
{ wrapper: createWrapper() },
)
// Assert
expect(container.querySelector('.custom-class')).toBeInTheDocument()
})
it('should handle isFullDocMode=true', () => {
// Arrange & Act
const { container } = render(
<ChunkContent detail={defaultDetail} isFullDocMode={true} />,
{ wrapper: createWrapper() },
)
// Assert - should have line-clamp-3 class
expect(container.querySelector('.line-clamp-3')).toBeInTheDocument()
})
it('should handle isFullDocMode=false with isCollapsed=true', () => {
// Arrange & Act
const { container } = render(
<ChunkContent detail={defaultDetail} isFullDocMode={false} />,
{ wrapper: createWrapper(true) },
)
// Assert - should have line-clamp-2 class
expect(container.querySelector('.line-clamp-2')).toBeInTheDocument()
})
it('should handle isFullDocMode=false with isCollapsed=false', () => {
// Arrange & Act
const { container } = render(
<ChunkContent detail={defaultDetail} isFullDocMode={false} />,
{ wrapper: createWrapper(false) },
)
// Assert - should have line-clamp-20 class
expect(container.querySelector('.line-clamp-20')).toBeInTheDocument()
})
})
// Content priority tests
describe('Content Priority', () => {
it('should prefer sign_content over content when both exist', () => {
// Arrange
const detail = {
content: 'Regular content',
sign_content: 'Sign content',
}
// Act
const { container } = render(
<ChunkContent detail={detail} isFullDocMode={false} />,
{ wrapper: createWrapper() },
)
// Assert - The component uses sign_content || content
expect(container.firstChild).toBeInTheDocument()
})
it('should use content when sign_content is empty', () => {
// Arrange
const detail = {
content: 'Regular content',
sign_content: '',
}
// Act
const { container } = render(
<ChunkContent detail={detail} isFullDocMode={false} />,
{ wrapper: createWrapper() },
)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle empty content', () => {
// Arrange
const emptyDetail = {
content: '',
sign_content: '',
}
// Act
const { container } = render(
<ChunkContent detail={emptyDetail} isFullDocMode={false} />,
{ wrapper: createWrapper() },
)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should handle empty answer in QA mode', () => {
// Arrange
const qaDetail = {
content: 'Question',
sign_content: '',
answer: '',
}
// Act - empty answer is falsy, so QA mode won't render
render(
<ChunkContent detail={qaDetail} isFullDocMode={false} />,
{ wrapper: createWrapper() },
)
// Assert - should not show Q and A labels since answer is empty string (falsy)
expect(screen.queryByText('Q')).not.toBeInTheDocument()
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender, container } = render(
<ChunkContent detail={defaultDetail} isFullDocMode={false} />,
{ wrapper: createWrapper() },
)
// Act
rerender(
<MockSegmentListContext.Provider
value={{
isCollapsed: true,
fullScreen: false,
toggleFullScreen: noop,
currSegment: { showModal: false },
currChildChunk: { showModal: false },
}}
>
<ChunkContent
detail={{ ...defaultDetail, content: 'Updated content' }}
isFullDocMode={false}
/>
</MockSegmentListContext.Provider>,
)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,679 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode } from '@/models/datasets'
import SegmentDetail from './segment-detail'
// Mock dataset detail context
let mockIndexingTechnique = IndexingType.QUALIFIED
let mockRuntimeMode = 'general'
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: { dataset: { indexing_technique: string, runtime_mode: string } }) => unknown) => {
return selector({
dataset: {
indexing_technique: mockIndexingTechnique,
runtime_mode: mockRuntimeMode,
},
})
},
}))
// Mock document context
let mockParentMode = 'paragraph'
vi.mock('../context', () => ({
useDocumentContext: (selector: (state: { parentMode: string }) => unknown) => {
return selector({ parentMode: mockParentMode })
},
}))
// Mock segment list context
let mockFullScreen = false
const mockToggleFullScreen = vi.fn()
vi.mock('./index', () => ({
useSegmentListContext: (selector: (state: { fullScreen: boolean, toggleFullScreen: () => void }) => unknown) => {
const state = {
fullScreen: mockFullScreen,
toggleFullScreen: mockToggleFullScreen,
}
return selector(state)
},
}))
// Mock event emitter context
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
useSubscription: vi.fn(),
},
}),
}))
// Mock child components
vi.mock('./common/action-buttons', () => ({
default: ({ handleCancel, handleSave, handleRegeneration, loading, showRegenerationButton }: { handleCancel: () => void, handleSave: () => void, handleRegeneration?: () => void, loading: boolean, showRegenerationButton?: boolean }) => (
<div data-testid="action-buttons">
<button onClick={handleCancel} data-testid="cancel-btn">Cancel</button>
<button onClick={handleSave} disabled={loading} data-testid="save-btn">Save</button>
{showRegenerationButton && (
<button onClick={handleRegeneration} data-testid="regenerate-btn">Regenerate</button>
)}
</div>
),
}))
vi.mock('./common/chunk-content', () => ({
default: ({ docForm, question, answer, onQuestionChange, onAnswerChange, isEditMode }: { docForm: string, question: string, answer: string, onQuestionChange: (v: string) => void, onAnswerChange: (v: string) => void, isEditMode: boolean }) => (
<div data-testid="chunk-content">
<input
data-testid="question-input"
value={question}
onChange={e => onQuestionChange(e.target.value)}
/>
{docForm === ChunkingMode.qa && (
<input
data-testid="answer-input"
value={answer}
onChange={e => onAnswerChange(e.target.value)}
/>
)}
<span data-testid="edit-mode">{isEditMode ? 'editing' : 'viewing'}</span>
</div>
),
}))
vi.mock('./common/dot', () => ({
default: () => <span data-testid="dot"></span>,
}))
vi.mock('./common/keywords', () => ({
default: ({ keywords, onKeywordsChange, _isEditMode, actionType }: { keywords: string[], onKeywordsChange: (v: string[]) => void, _isEditMode?: boolean, actionType: string }) => (
<div data-testid="keywords">
<span data-testid="keywords-action">{actionType}</span>
<input
data-testid="keywords-input"
value={keywords.join(',')}
onChange={e => onKeywordsChange(e.target.value.split(',').filter(Boolean))}
/>
</div>
),
}))
vi.mock('./common/segment-index-tag', () => ({
SegmentIndexTag: ({ positionId, label, labelPrefix }: { positionId?: string, label?: string, labelPrefix?: string }) => (
<span data-testid="segment-index-tag">
{labelPrefix}
{' '}
{positionId}
{' '}
{label}
</span>
),
}))
vi.mock('./common/regeneration-modal', () => ({
default: ({ isShow, onConfirm, onCancel, onClose }: { isShow: boolean, onConfirm: () => void, onCancel: () => void, onClose: () => void }) => (
isShow
? (
<div data-testid="regeneration-modal">
<button onClick={onConfirm} data-testid="confirm-regeneration">Confirm</button>
<button onClick={onCancel} data-testid="cancel-regeneration">Cancel</button>
<button onClick={onClose} data-testid="close-regeneration">Close</button>
</div>
)
: null
),
}))
vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-chunk', () => ({
default: ({ disabled, value, onChange }: { value?: unknown[], onChange?: (v: unknown[]) => void, disabled?: boolean }) => {
return (
<div data-testid="image-uploader">
<span data-testid="uploader-disabled">{disabled ? 'disabled' : 'enabled'}</span>
<span data-testid="attachments-count">{value?.length || 0}</span>
<button
data-testid="add-attachment-btn"
onClick={() => onChange?.([...(value || []), { id: 'new-attachment' }])}
>
Add
</button>
</div>
)
},
}))
describe('SegmentDetail', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFullScreen = false
mockIndexingTechnique = IndexingType.QUALIFIED
mockRuntimeMode = 'general'
mockParentMode = 'paragraph'
})
const defaultSegInfo = {
id: 'segment-1',
content: 'Test content',
sign_content: 'Signed content',
answer: 'Test answer',
position: 1,
word_count: 100,
keywords: ['keyword1', 'keyword2'],
attachments: [],
}
const defaultProps = {
segInfo: defaultSegInfo,
onUpdate: vi.fn(),
onCancel: vi.fn(),
isEditMode: false,
docForm: ChunkingMode.text,
onModalStateChange: vi.fn(),
}
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<SegmentDetail {...defaultProps} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render title for view mode', () => {
// Arrange & Act
render(<SegmentDetail {...defaultProps} isEditMode={false} />)
// Assert
expect(screen.getByText(/segment\.chunkDetail/i)).toBeInTheDocument()
})
it('should render title for edit mode', () => {
// Arrange & Act
render(<SegmentDetail {...defaultProps} isEditMode={true} />)
// Assert
expect(screen.getByText(/segment\.editChunk/i)).toBeInTheDocument()
})
it('should render chunk content component', () => {
// Arrange & Act
render(<SegmentDetail {...defaultProps} />)
// Assert
expect(screen.getByTestId('chunk-content')).toBeInTheDocument()
})
it('should render image uploader', () => {
// Arrange & Act
render(<SegmentDetail {...defaultProps} />)
// Assert
expect(screen.getByTestId('image-uploader')).toBeInTheDocument()
})
it('should render segment index tag', () => {
// Arrange & Act
render(<SegmentDetail {...defaultProps} />)
// Assert
expect(screen.getByTestId('segment-index-tag')).toBeInTheDocument()
})
})
// Edit mode vs View mode
describe('Edit/View Mode', () => {
it('should pass isEditMode to ChunkContent', () => {
// Arrange & Act
render(<SegmentDetail {...defaultProps} isEditMode={true} />)
// Assert
expect(screen.getByTestId('edit-mode')).toHaveTextContent('editing')
})
it('should disable image uploader in view mode', () => {
// Arrange & Act
render(<SegmentDetail {...defaultProps} isEditMode={false} />)
// Assert
expect(screen.getByTestId('uploader-disabled')).toHaveTextContent('disabled')
})
it('should enable image uploader in edit mode', () => {
// Arrange & Act
render(<SegmentDetail {...defaultProps} isEditMode={true} />)
// Assert
expect(screen.getByTestId('uploader-disabled')).toHaveTextContent('enabled')
})
it('should show action buttons in edit mode', () => {
// Arrange & Act
render(<SegmentDetail {...defaultProps} isEditMode={true} />)
// Assert
expect(screen.getByTestId('action-buttons')).toBeInTheDocument()
})
it('should not show action buttons in view mode (non-fullscreen)', () => {
// Arrange & Act
render(<SegmentDetail {...defaultProps} isEditMode={false} />)
// Assert
expect(screen.queryByTestId('action-buttons')).not.toBeInTheDocument()
})
})
// Keywords display
describe('Keywords', () => {
it('should show keywords component when indexing is ECONOMICAL', () => {
// Arrange
mockIndexingTechnique = IndexingType.ECONOMICAL
// Act
render(<SegmentDetail {...defaultProps} />)
// Assert
expect(screen.getByTestId('keywords')).toBeInTheDocument()
})
it('should not show keywords when indexing is QUALIFIED', () => {
// Arrange
mockIndexingTechnique = IndexingType.QUALIFIED
// Act
render(<SegmentDetail {...defaultProps} />)
// Assert
expect(screen.queryByTestId('keywords')).not.toBeInTheDocument()
})
it('should pass view action type when not in edit mode', () => {
// Arrange
mockIndexingTechnique = IndexingType.ECONOMICAL
// Act
render(<SegmentDetail {...defaultProps} isEditMode={false} />)
// Assert
expect(screen.getByTestId('keywords-action')).toHaveTextContent('view')
})
it('should pass edit action type when in edit mode', () => {
// Arrange
mockIndexingTechnique = IndexingType.ECONOMICAL
// Act
render(<SegmentDetail {...defaultProps} isEditMode={true} />)
// Assert
expect(screen.getByTestId('keywords-action')).toHaveTextContent('edit')
})
})
// User Interactions
describe('User Interactions', () => {
it('should call onCancel when close button is clicked', () => {
// Arrange
const mockOnCancel = vi.fn()
const { container } = render(<SegmentDetail {...defaultProps} onCancel={mockOnCancel} />)
// Act
const closeButtons = container.querySelectorAll('.cursor-pointer')
if (closeButtons.length > 1)
fireEvent.click(closeButtons[1])
// Assert
expect(mockOnCancel).toHaveBeenCalled()
})
it('should call toggleFullScreen when expand button is clicked', () => {
// Arrange
const { container } = render(<SegmentDetail {...defaultProps} />)
// Act
const expandButtons = container.querySelectorAll('.cursor-pointer')
if (expandButtons.length > 0)
fireEvent.click(expandButtons[0])
// Assert
expect(mockToggleFullScreen).toHaveBeenCalled()
})
it('should call onUpdate when save is clicked', () => {
// Arrange
const mockOnUpdate = vi.fn()
render(<SegmentDetail {...defaultProps} isEditMode={true} onUpdate={mockOnUpdate} />)
// Act
fireEvent.click(screen.getByTestId('save-btn'))
// Assert
expect(mockOnUpdate).toHaveBeenCalledWith(
'segment-1',
expect.any(String),
expect.any(String),
expect.any(Array),
expect.any(Array),
)
})
it('should update question when input changes', () => {
// Arrange
render(<SegmentDetail {...defaultProps} isEditMode={true} />)
// Act
fireEvent.change(screen.getByTestId('question-input'), {
target: { value: 'Updated content' },
})
// Assert
expect(screen.getByTestId('question-input')).toHaveValue('Updated content')
})
})
// Regeneration Modal
describe('Regeneration Modal', () => {
it('should show regeneration button when runtimeMode is general', () => {
// Arrange
mockRuntimeMode = 'general'
// Act
render(<SegmentDetail {...defaultProps} isEditMode={true} />)
// Assert
expect(screen.getByTestId('regenerate-btn')).toBeInTheDocument()
})
it('should not show regeneration button when runtimeMode is not general', () => {
// Arrange
mockRuntimeMode = 'pipeline'
// Act
render(<SegmentDetail {...defaultProps} isEditMode={true} />)
// Assert
expect(screen.queryByTestId('regenerate-btn')).not.toBeInTheDocument()
})
it('should show regeneration modal when regenerate is clicked', () => {
// Arrange
render(<SegmentDetail {...defaultProps} isEditMode={true} />)
// Act
fireEvent.click(screen.getByTestId('regenerate-btn'))
// Assert
expect(screen.getByTestId('regeneration-modal')).toBeInTheDocument()
})
it('should call onModalStateChange when regeneration modal opens', () => {
// Arrange
const mockOnModalStateChange = vi.fn()
render(
<SegmentDetail
{...defaultProps}
isEditMode={true}
onModalStateChange={mockOnModalStateChange}
/>,
)
// Act
fireEvent.click(screen.getByTestId('regenerate-btn'))
// Assert
expect(mockOnModalStateChange).toHaveBeenCalledWith(true)
})
it('should close modal when cancel is clicked', () => {
// Arrange
const mockOnModalStateChange = vi.fn()
render(
<SegmentDetail
{...defaultProps}
isEditMode={true}
onModalStateChange={mockOnModalStateChange}
/>,
)
fireEvent.click(screen.getByTestId('regenerate-btn'))
// Act
fireEvent.click(screen.getByTestId('cancel-regeneration'))
// Assert
expect(mockOnModalStateChange).toHaveBeenCalledWith(false)
expect(screen.queryByTestId('regeneration-modal')).not.toBeInTheDocument()
})
})
// Full screen mode
describe('Full Screen Mode', () => {
it('should show action buttons in header when fullScreen and editMode', () => {
// Arrange
mockFullScreen = true
// Act
render(<SegmentDetail {...defaultProps} isEditMode={true} />)
// Assert
expect(screen.getByTestId('action-buttons')).toBeInTheDocument()
})
it('should apply full screen styling when fullScreen is true', () => {
// Arrange
mockFullScreen = true
// Act
const { container } = render(<SegmentDetail {...defaultProps} />)
// Assert
const header = container.querySelector('.border-divider-subtle')
expect(header).toBeInTheDocument()
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle segInfo with minimal data', () => {
// Arrange
const minimalSegInfo = {
id: 'segment-minimal',
position: 1,
word_count: 0,
}
// Act
const { container } = render(<SegmentDetail {...defaultProps} segInfo={minimalSegInfo} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should handle empty keywords array', () => {
// Arrange
mockIndexingTechnique = IndexingType.ECONOMICAL
const segInfo = { ...defaultSegInfo, keywords: [] }
// Act
render(<SegmentDetail {...defaultProps} segInfo={segInfo} />)
// Assert
expect(screen.getByTestId('keywords-input')).toHaveValue('')
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender } = render(<SegmentDetail {...defaultProps} isEditMode={false} />)
// Act
rerender(<SegmentDetail {...defaultProps} isEditMode={true} />)
// Assert
expect(screen.getByTestId('action-buttons')).toBeInTheDocument()
})
})
// Attachments
describe('Attachments', () => {
it('should update attachments when onChange is called', () => {
// Arrange
render(<SegmentDetail {...defaultProps} isEditMode={true} />)
// Act
fireEvent.click(screen.getByTestId('add-attachment-btn'))
// Assert
expect(screen.getByTestId('attachments-count')).toHaveTextContent('1')
})
it('should pass attachments to onUpdate when save is clicked', () => {
// Arrange
const mockOnUpdate = vi.fn()
render(<SegmentDetail {...defaultProps} isEditMode={true} onUpdate={mockOnUpdate} />)
// Add an attachment
fireEvent.click(screen.getByTestId('add-attachment-btn'))
// Act
fireEvent.click(screen.getByTestId('save-btn'))
// Assert
expect(mockOnUpdate).toHaveBeenCalledWith(
'segment-1',
expect.any(String),
expect.any(String),
expect.any(Array),
expect.arrayContaining([expect.objectContaining({ id: 'new-attachment' })]),
)
})
it('should initialize attachments from segInfo', () => {
// Arrange
const segInfoWithAttachments = {
...defaultSegInfo,
attachments: [
{ id: 'att-1', name: 'file1.jpg', size: 1000, mime_type: 'image/jpeg', extension: 'jpg', source_url: 'http://example.com/file1.jpg' },
],
}
// Act
render(<SegmentDetail {...defaultProps} segInfo={segInfoWithAttachments} isEditMode={true} />)
// Assert
expect(screen.getByTestId('attachments-count')).toHaveTextContent('1')
})
})
// Regeneration confirmation
describe('Regeneration Confirmation', () => {
it('should call onUpdate with needRegenerate true when confirm regeneration is clicked', () => {
// Arrange
const mockOnUpdate = vi.fn()
render(<SegmentDetail {...defaultProps} isEditMode={true} onUpdate={mockOnUpdate} />)
// Open regeneration modal
fireEvent.click(screen.getByTestId('regenerate-btn'))
// Act
fireEvent.click(screen.getByTestId('confirm-regeneration'))
// Assert
expect(mockOnUpdate).toHaveBeenCalledWith(
'segment-1',
expect.any(String),
expect.any(String),
expect.any(Array),
expect.any(Array),
true,
)
})
it('should close modal and edit drawer when close after regeneration is clicked', () => {
// Arrange
const mockOnCancel = vi.fn()
const mockOnModalStateChange = vi.fn()
render(
<SegmentDetail
{...defaultProps}
isEditMode={true}
onCancel={mockOnCancel}
onModalStateChange={mockOnModalStateChange}
/>,
)
// Open regeneration modal
fireEvent.click(screen.getByTestId('regenerate-btn'))
// Act
fireEvent.click(screen.getByTestId('close-regeneration'))
// Assert
expect(mockOnModalStateChange).toHaveBeenCalledWith(false)
expect(mockOnCancel).toHaveBeenCalled()
})
})
// QA mode
describe('QA Mode', () => {
it('should render answer input in QA mode', () => {
// Arrange & Act
render(<SegmentDetail {...defaultProps} docForm={ChunkingMode.qa} isEditMode={true} />)
// Assert
expect(screen.getByTestId('answer-input')).toBeInTheDocument()
})
it('should update answer when input changes', () => {
// Arrange
render(<SegmentDetail {...defaultProps} docForm={ChunkingMode.qa} isEditMode={true} />)
// Act
fireEvent.change(screen.getByTestId('answer-input'), {
target: { value: 'Updated answer' },
})
// Assert
expect(screen.getByTestId('answer-input')).toHaveValue('Updated answer')
})
it('should calculate word count correctly in QA mode', () => {
// Arrange & Act
render(<SegmentDetail {...defaultProps} docForm={ChunkingMode.qa} isEditMode={true} />)
// Assert - should show combined length of question and answer
expect(screen.getByText(/segment\.characters/i)).toBeInTheDocument()
})
})
// Full doc mode
describe('Full Doc Mode', () => {
it('should show label in full-doc parent-child mode', () => {
// Arrange
mockParentMode = 'full-doc'
// Act
render(<SegmentDetail {...defaultProps} docForm={ChunkingMode.parentChild} />)
// Assert
expect(screen.getByTestId('segment-index-tag')).toBeInTheDocument()
})
})
// Keywords update
describe('Keywords Update', () => {
it('should update keywords when changed in edit mode', () => {
// Arrange
mockIndexingTechnique = IndexingType.ECONOMICAL
render(<SegmentDetail {...defaultProps} isEditMode={true} />)
// Act
fireEvent.change(screen.getByTestId('keywords-input'), {
target: { value: 'new,keywords' },
})
// Assert
expect(screen.getByTestId('keywords-input')).toHaveValue('new,keywords')
})
})
})

View File

@ -0,0 +1,442 @@
import type { SegmentDetailModel } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import SegmentList from './segment-list'
// Mock document context
let mockDocForm = ChunkingMode.text
let mockParentMode = 'paragraph'
vi.mock('../context', () => ({
useDocumentContext: (selector: (state: { docForm: ChunkingMode, parentMode: string }) => unknown) => {
return selector({
docForm: mockDocForm,
parentMode: mockParentMode,
})
},
}))
// Mock segment list context
let mockCurrSegment: { segInfo: { id: string } } | null = null
let mockCurrChildChunk: { childChunkInfo: { segment_id: string } } | null = null
vi.mock('./index', () => ({
useSegmentListContext: (selector: (state: { currSegment: { segInfo: { id: string } } | null, currChildChunk: { childChunkInfo: { segment_id: string } } | null }) => unknown) => {
return selector({
currSegment: mockCurrSegment,
currChildChunk: mockCurrChildChunk,
})
},
}))
// Mock child components
vi.mock('./common/empty', () => ({
default: ({ onClearFilter }: { onClearFilter: () => void }) => (
<div data-testid="empty">
<button onClick={onClearFilter} data-testid="clear-filter-btn">Clear Filter</button>
</div>
),
}))
vi.mock('./segment-card', () => ({
default: ({
detail,
onClick,
onChangeSwitch,
onClickEdit,
onDelete,
onDeleteChildChunk,
handleAddNewChildChunk,
onClickSlice,
archived,
embeddingAvailable,
focused,
}: {
detail: SegmentDetailModel
onClick: () => void
onChangeSwitch: (enabled: boolean, segId?: string) => Promise<void>
onClickEdit: () => void
onDelete: (segId: string) => Promise<void>
onDeleteChildChunk: (segId: string, childChunkId: string) => Promise<void>
handleAddNewChildChunk: (parentChunkId: string) => void
onClickSlice: (childChunk: unknown) => void
archived: boolean
embeddingAvailable: boolean
focused: { segmentIndex: boolean, segmentContent: boolean }
}) => (
<div data-testid="segment-card" data-id={detail.id}>
<span data-testid="segment-content">{detail.content}</span>
<span data-testid="archived">{archived ? 'true' : 'false'}</span>
<span data-testid="embedding-available">{embeddingAvailable ? 'true' : 'false'}</span>
<span data-testid="focused-index">{focused.segmentIndex ? 'true' : 'false'}</span>
<span data-testid="focused-content">{focused.segmentContent ? 'true' : 'false'}</span>
<button onClick={onClick} data-testid="card-click">Click</button>
<button onClick={onClickEdit} data-testid="edit-btn">Edit</button>
<button onClick={() => onChangeSwitch(true, detail.id)} data-testid="switch-btn">Switch</button>
<button onClick={() => onDelete(detail.id)} data-testid="delete-btn">Delete</button>
<button onClick={() => onDeleteChildChunk(detail.id, 'child-1')} data-testid="delete-child-btn">Delete Child</button>
<button onClick={() => handleAddNewChildChunk(detail.id)} data-testid="add-child-btn">Add Child</button>
<button onClick={() => onClickSlice({ id: 'slice-1' })} data-testid="click-slice-btn">Click Slice</button>
</div>
),
}))
vi.mock('./skeleton/general-list-skeleton', () => ({
default: () => <div data-testid="general-skeleton">Loading...</div>,
}))
vi.mock('./skeleton/paragraph-list-skeleton', () => ({
default: () => <div data-testid="paragraph-skeleton">Loading Paragraph...</div>,
}))
describe('SegmentList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDocForm = ChunkingMode.text
mockParentMode = 'paragraph'
mockCurrSegment = null
mockCurrChildChunk = null
})
const createMockSegment = (id: string, content: string): SegmentDetailModel => ({
id,
content,
position: 1,
word_count: 10,
tokens: 5,
hit_count: 0,
enabled: true,
status: 'completed',
created_at: Date.now(),
updated_at: Date.now(),
keywords: [],
document_id: 'doc-1',
sign_content: content,
index_node_id: `index-${id}`,
index_node_hash: `hash-${id}`,
answer: '',
error: null,
disabled_at: null,
disabled_by: null,
} as unknown as SegmentDetailModel)
const defaultProps = {
ref: null,
isLoading: false,
items: [createMockSegment('seg-1', 'Segment 1 content')],
selectedSegmentIds: [],
onSelected: vi.fn(),
onClick: vi.fn(),
onChangeSwitch: vi.fn(),
onDelete: vi.fn(),
onDeleteChildChunk: vi.fn(),
handleAddNewChildChunk: vi.fn(),
onClickSlice: vi.fn(),
archived: false,
embeddingAvailable: true,
onClearFilter: vi.fn(),
}
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<SegmentList {...defaultProps} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render segment cards for each item', () => {
// Arrange
const items = [
createMockSegment('seg-1', 'Content 1'),
createMockSegment('seg-2', 'Content 2'),
]
// Act
render(<SegmentList {...defaultProps} items={items} />)
// Assert
expect(screen.getAllByTestId('segment-card')).toHaveLength(2)
})
it('should render empty component when items is empty', () => {
// Arrange & Act
render(<SegmentList {...defaultProps} items={[]} />)
// Assert
expect(screen.getByTestId('empty')).toBeInTheDocument()
})
})
// Loading state
describe('Loading State', () => {
it('should render general skeleton when loading and docForm is text', () => {
// Arrange
mockDocForm = ChunkingMode.text
// Act
render(<SegmentList {...defaultProps} isLoading={true} />)
// Assert
expect(screen.getByTestId('general-skeleton')).toBeInTheDocument()
})
it('should render paragraph skeleton when loading and docForm is parentChild with paragraph mode', () => {
// Arrange
mockDocForm = ChunkingMode.parentChild
mockParentMode = 'paragraph'
// Act
render(<SegmentList {...defaultProps} isLoading={true} />)
// Assert
expect(screen.getByTestId('paragraph-skeleton')).toBeInTheDocument()
})
it('should render general skeleton when loading and docForm is parentChild with full-doc mode', () => {
// Arrange
mockDocForm = ChunkingMode.parentChild
mockParentMode = 'full-doc'
// Act
render(<SegmentList {...defaultProps} isLoading={true} />)
// Assert
expect(screen.getByTestId('general-skeleton')).toBeInTheDocument()
})
})
// Props passing
describe('Props Passing', () => {
it('should pass archived prop to SegmentCard', () => {
// Arrange & Act
render(<SegmentList {...defaultProps} archived={true} />)
// Assert
expect(screen.getByTestId('archived')).toHaveTextContent('true')
})
it('should pass embeddingAvailable prop to SegmentCard', () => {
// Arrange & Act
render(<SegmentList {...defaultProps} embeddingAvailable={false} />)
// Assert
expect(screen.getByTestId('embedding-available')).toHaveTextContent('false')
})
})
// Focused state
describe('Focused State', () => {
it('should set focused index when currSegment matches', () => {
// Arrange
mockCurrSegment = { segInfo: { id: 'seg-1' } }
// Act
render(<SegmentList {...defaultProps} />)
// Assert
expect(screen.getByTestId('focused-index')).toHaveTextContent('true')
})
it('should set focused content when currSegment matches', () => {
// Arrange
mockCurrSegment = { segInfo: { id: 'seg-1' } }
// Act
render(<SegmentList {...defaultProps} />)
// Assert
expect(screen.getByTestId('focused-content')).toHaveTextContent('true')
})
it('should set focused when currChildChunk parent matches', () => {
// Arrange
mockCurrChildChunk = { childChunkInfo: { segment_id: 'seg-1' } }
// Act
render(<SegmentList {...defaultProps} />)
// Assert
expect(screen.getByTestId('focused-index')).toHaveTextContent('true')
})
})
// Clear filter
describe('Clear Filter', () => {
it('should call onClearFilter when clear filter button is clicked', async () => {
// Arrange
const mockOnClearFilter = vi.fn()
render(<SegmentList {...defaultProps} items={[]} onClearFilter={mockOnClearFilter} />)
// Act
screen.getByTestId('clear-filter-btn').click()
// Assert
expect(mockOnClearFilter).toHaveBeenCalled()
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle single item without divider', () => {
// Arrange & Act
render(<SegmentList {...defaultProps} items={[createMockSegment('seg-1', 'Content')]} />)
// Assert
expect(screen.getByTestId('segment-card')).toBeInTheDocument()
})
it('should handle multiple items with dividers', () => {
// Arrange
const items = [
createMockSegment('seg-1', 'Content 1'),
createMockSegment('seg-2', 'Content 2'),
createMockSegment('seg-3', 'Content 3'),
]
// Act
render(<SegmentList {...defaultProps} items={items} />)
// Assert
expect(screen.getAllByTestId('segment-card')).toHaveLength(3)
})
it('should maintain structure when rerendered with different items', () => {
// Arrange
const { rerender } = render(
<SegmentList {...defaultProps} items={[createMockSegment('seg-1', 'Content 1')]} />,
)
// Act
rerender(
<SegmentList
{...defaultProps}
items={[
createMockSegment('seg-2', 'Content 2'),
createMockSegment('seg-3', 'Content 3'),
]}
/>,
)
// Assert
expect(screen.getAllByTestId('segment-card')).toHaveLength(2)
})
})
// Checkbox Selection
describe('Checkbox Selection', () => {
it('should render checkbox for each segment', () => {
// Arrange & Act
const { container } = render(<SegmentList {...defaultProps} />)
// Assert - Checkbox component should exist
const checkboxes = container.querySelectorAll('[class*="checkbox"]')
expect(checkboxes.length).toBeGreaterThan(0)
})
it('should pass selectedSegmentIds to check state', () => {
// Arrange & Act
const { container } = render(<SegmentList {...defaultProps} selectedSegmentIds={['seg-1']} />)
// Assert - component should render with selected state
expect(container.firstChild).toBeInTheDocument()
})
it('should handle empty selectedSegmentIds', () => {
// Arrange & Act
const { container } = render(<SegmentList {...defaultProps} selectedSegmentIds={[]} />)
// Assert - component should render
expect(container.firstChild).toBeInTheDocument()
})
})
// Card Actions
describe('Card Actions', () => {
it('should call onClick when card is clicked', () => {
// Arrange
const mockOnClick = vi.fn()
render(<SegmentList {...defaultProps} onClick={mockOnClick} />)
// Act
fireEvent.click(screen.getByTestId('card-click'))
// Assert
expect(mockOnClick).toHaveBeenCalled()
})
it('should call onChangeSwitch when switch button is clicked', async () => {
// Arrange
const mockOnChangeSwitch = vi.fn().mockResolvedValue(undefined)
render(<SegmentList {...defaultProps} onChangeSwitch={mockOnChangeSwitch} />)
// Act
fireEvent.click(screen.getByTestId('switch-btn'))
// Assert
expect(mockOnChangeSwitch).toHaveBeenCalledWith(true, 'seg-1')
})
it('should call onDelete when delete button is clicked', async () => {
// Arrange
const mockOnDelete = vi.fn().mockResolvedValue(undefined)
render(<SegmentList {...defaultProps} onDelete={mockOnDelete} />)
// Act
fireEvent.click(screen.getByTestId('delete-btn'))
// Assert
expect(mockOnDelete).toHaveBeenCalledWith('seg-1')
})
it('should call onDeleteChildChunk when delete child button is clicked', async () => {
// Arrange
const mockOnDeleteChildChunk = vi.fn().mockResolvedValue(undefined)
render(<SegmentList {...defaultProps} onDeleteChildChunk={mockOnDeleteChildChunk} />)
// Act
fireEvent.click(screen.getByTestId('delete-child-btn'))
// Assert
expect(mockOnDeleteChildChunk).toHaveBeenCalledWith('seg-1', 'child-1')
})
it('should call handleAddNewChildChunk when add child button is clicked', () => {
// Arrange
const mockHandleAddNewChildChunk = vi.fn()
render(<SegmentList {...defaultProps} handleAddNewChildChunk={mockHandleAddNewChildChunk} />)
// Act
fireEvent.click(screen.getByTestId('add-child-btn'))
// Assert
expect(mockHandleAddNewChildChunk).toHaveBeenCalledWith('seg-1')
})
it('should call onClickSlice when click slice button is clicked', () => {
// Arrange
const mockOnClickSlice = vi.fn()
render(<SegmentList {...defaultProps} onClickSlice={mockOnClickSlice} />)
// Act
fireEvent.click(screen.getByTestId('click-slice-btn'))
// Assert
expect(mockOnClickSlice).toHaveBeenCalledWith({ id: 'slice-1' })
})
it('should call onClick with edit mode when edit button is clicked', () => {
// Arrange
const mockOnClick = vi.fn()
render(<SegmentList {...defaultProps} onClick={mockOnClick} />)
// Act
fireEvent.click(screen.getByTestId('edit-btn'))
// Assert - onClick is called from onClickEdit with isEditMode=true
expect(mockOnClick).toHaveBeenCalled()
})
})
})

View File

@ -1,93 +1,124 @@
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import FullDocListSkeleton from './full-doc-list-skeleton'
describe('FullDocListSkeleton', () => {
// Rendering tests
describe('Rendering', () => {
it('should render the skeleton container', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<FullDocListSkeleton />)
const skeletonContainer = container.firstChild
expect(skeletonContainer).toHaveClass('flex', 'w-full', 'grow', 'flex-col')
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render 15 Slice components', () => {
it('should render the correct number of slice elements', () => {
// Arrange & Act
const { container } = render(<FullDocListSkeleton />)
// Each Slice has a specific structure with gap-y-1
const slices = container.querySelectorAll('.gap-y-1')
expect(slices.length).toBe(15)
// Assert - component renders 15 slices
const sliceElements = container.querySelectorAll('.flex.flex-col.gap-y-1')
expect(sliceElements).toHaveLength(15)
})
it('should render mask overlay', () => {
it('should render mask overlay element', () => {
// Arrange & Act
const { container } = render(<FullDocListSkeleton />)
const maskOverlay = container.querySelector('.bg-dataset-chunk-list-mask-bg')
expect(maskOverlay).toBeInTheDocument()
// Assert - check for the mask overlay element
const maskElement = container.querySelector('.bg-dataset-chunk-list-mask-bg')
expect(maskElement).toBeInTheDocument()
})
it('should have overflow hidden', () => {
it('should render with correct container classes', () => {
// Arrange & Act
const { container } = render(<FullDocListSkeleton />)
const skeletonContainer = container.firstChild
expect(skeletonContainer).toHaveClass('overflow-y-hidden')
// Assert
const containerElement = container.firstChild as HTMLElement
expect(containerElement).toHaveClass('relative')
expect(containerElement).toHaveClass('z-10')
expect(containerElement).toHaveClass('flex')
expect(containerElement).toHaveClass('w-full')
expect(containerElement).toHaveClass('grow')
expect(containerElement).toHaveClass('flex-col')
expect(containerElement).toHaveClass('gap-y-3')
expect(containerElement).toHaveClass('overflow-y-hidden')
})
})
describe('Slice Component', () => {
it('should render slice with correct structure', () => {
// Structure tests
describe('Structure', () => {
it('should render slice elements with proper structure', () => {
// Arrange & Act
const { container } = render(<FullDocListSkeleton />)
// Each slice has two rows
const sliceRows = container.querySelectorAll('.bg-state-base-hover')
expect(sliceRows.length).toBeGreaterThan(0)
// Assert - each slice should have the content placeholder elements
const slices = container.querySelectorAll('.flex.flex-col.gap-y-1')
slices.forEach((slice) => {
// Each slice should have children for the skeleton content
expect(slice.children.length).toBeGreaterThan(0)
})
})
it('should render label placeholder in each slice', () => {
it('should render slice with width placeholder elements', () => {
// Arrange & Act
const { container } = render(<FullDocListSkeleton />)
// Label placeholder has specific width
const labelPlaceholders = container.querySelectorAll('.w-\\[30px\\]')
expect(labelPlaceholders.length).toBe(15) // One per slice
// Assert - check for skeleton content width class
const widthElements = container.querySelectorAll('.w-2\\/3')
expect(widthElements.length).toBeGreaterThan(0)
})
it('should render content placeholder in each slice', () => {
it('should render slice elements with background classes', () => {
// Arrange & Act
const { container } = render(<FullDocListSkeleton />)
// Content placeholder has 2/3 width
const contentPlaceholders = container.querySelectorAll('.w-2\\/3')
expect(contentPlaceholders.length).toBe(15) // One per slice
// Assert - check for skeleton background classes
const bgElements = container.querySelectorAll('.bg-state-base-hover')
expect(bgElements.length).toBeGreaterThan(0)
})
})
// Memoization tests
describe('Memoization', () => {
it('should be memoized', () => {
it('should render consistently across multiple renders', () => {
// Arrange & Act
const { container: container1 } = render(<FullDocListSkeleton />)
const { container: container2 } = render(<FullDocListSkeleton />)
// Assert - structure should be identical
const slices1 = container1.querySelectorAll('.flex.flex-col.gap-y-1')
const slices2 = container2.querySelectorAll('.flex.flex-col.gap-y-1')
expect(slices1.length).toBe(slices2.length)
})
})
// Edge cases
describe('Edge Cases', () => {
it('should maintain structure when rendered multiple times', () => {
// Arrange
const { rerender, container } = render(<FullDocListSkeleton />)
const initialContent = container.innerHTML
// Rerender should produce same output
// Act
rerender(<FullDocListSkeleton />)
rerender(<FullDocListSkeleton />)
expect(container.innerHTML).toBe(initialContent)
})
})
describe('Styling', () => {
it('should have correct z-index layering', () => {
const { container } = render(<FullDocListSkeleton />)
const skeletonContainer = container.firstChild
expect(skeletonContainer).toHaveClass('z-10')
const maskOverlay = container.querySelector('.z-20')
expect(maskOverlay).toBeInTheDocument()
// Assert
const sliceElements = container.querySelectorAll('.flex.flex-col.gap-y-1')
expect(sliceElements).toHaveLength(15)
})
it('should have gap between slices', () => {
it('should not have accessibility issues with skeleton content', () => {
// Arrange & Act
const { container } = render(<FullDocListSkeleton />)
const skeletonContainer = container.firstChild
expect(skeletonContainer).toHaveClass('gap-y-3')
// Assert - skeleton should be purely visual, no interactive elements
const buttons = container.querySelectorAll('button')
const links = container.querySelectorAll('a')
expect(buttons).toHaveLength(0)
expect(links).toHaveLength(0)
})
})
})

View File

@ -0,0 +1,195 @@
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import GeneralListSkeleton, { CardSkelton } from './general-list-skeleton'
describe('CardSkelton', () => {
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<CardSkelton />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render skeleton rows', () => {
// Arrange & Act
const { container } = render(<CardSkelton />)
// Assert - component should have skeleton rectangle elements
const skeletonRectangles = container.querySelectorAll('.bg-text-quaternary')
expect(skeletonRectangles.length).toBeGreaterThan(0)
})
it('should render with proper container padding', () => {
// Arrange & Act
const { container } = render(<CardSkelton />)
// Assert
expect(container.querySelector('.p-1')).toBeInTheDocument()
expect(container.querySelector('.pb-2')).toBeInTheDocument()
})
})
// Structure tests
describe('Structure', () => {
it('should render skeleton points as separators', () => {
// Arrange & Act
const { container } = render(<CardSkelton />)
// Assert - check for opacity class on skeleton points
const opacityElements = container.querySelectorAll('.opacity-20')
expect(opacityElements.length).toBeGreaterThan(0)
})
it('should render width-constrained skeleton elements', () => {
// Arrange & Act
const { container } = render(<CardSkelton />)
// Assert - check for various width classes
expect(container.querySelector('.w-\\[72px\\]')).toBeInTheDocument()
expect(container.querySelector('.w-24')).toBeInTheDocument()
expect(container.querySelector('.w-full')).toBeInTheDocument()
})
})
})
describe('GeneralListSkeleton', () => {
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<GeneralListSkeleton />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render the correct number of list items', () => {
// Arrange & Act
const { container } = render(<GeneralListSkeleton />)
// Assert - component renders 10 items (Checkbox is a div with shrink-0 and h-4 w-4)
const listItems = container.querySelectorAll('.items-start.gap-x-2')
expect(listItems).toHaveLength(10)
})
it('should render mask overlay element', () => {
// Arrange & Act
const { container } = render(<GeneralListSkeleton />)
// Assert
const maskElement = container.querySelector('.bg-dataset-chunk-list-mask-bg')
expect(maskElement).toBeInTheDocument()
})
it('should render with correct container classes', () => {
// Arrange & Act
const { container } = render(<GeneralListSkeleton />)
// Assert
const containerElement = container.firstChild as HTMLElement
expect(containerElement).toHaveClass('relative')
expect(containerElement).toHaveClass('z-10')
expect(containerElement).toHaveClass('flex')
expect(containerElement).toHaveClass('grow')
expect(containerElement).toHaveClass('flex-col')
expect(containerElement).toHaveClass('overflow-y-hidden')
})
})
// Checkbox tests
describe('Checkboxes', () => {
it('should render disabled checkboxes', () => {
// Arrange & Act
const { container } = render(<GeneralListSkeleton />)
// Assert - Checkbox component uses cursor-not-allowed class when disabled
const disabledCheckboxes = container.querySelectorAll('.cursor-not-allowed')
expect(disabledCheckboxes.length).toBeGreaterThan(0)
})
it('should render checkboxes with shrink-0 class for consistent sizing', () => {
// Arrange & Act
const { container } = render(<GeneralListSkeleton />)
// Assert
const checkboxContainers = container.querySelectorAll('.shrink-0')
expect(checkboxContainers.length).toBeGreaterThan(0)
})
})
// Divider tests
describe('Dividers', () => {
it('should render dividers between items except for the last one', () => {
// Arrange & Act
const { container } = render(<GeneralListSkeleton />)
// Assert - should have 9 dividers (not after last item)
const dividers = container.querySelectorAll('.bg-divider-subtle')
expect(dividers).toHaveLength(9)
})
})
// Structure tests
describe('Structure', () => {
it('should render list items with proper gap styling', () => {
// Arrange & Act
const { container } = render(<GeneralListSkeleton />)
// Assert
const listItems = container.querySelectorAll('.gap-x-2')
expect(listItems.length).toBeGreaterThan(0)
})
it('should render CardSkelton inside each list item', () => {
// Arrange & Act
const { container } = render(<GeneralListSkeleton />)
// Assert - each list item should contain card skeleton content
const cardContainers = container.querySelectorAll('.grow')
expect(cardContainers.length).toBeGreaterThan(0)
})
})
// Memoization tests
describe('Memoization', () => {
it('should render consistently across multiple renders', () => {
// Arrange & Act
const { container: container1 } = render(<GeneralListSkeleton />)
const { container: container2 } = render(<GeneralListSkeleton />)
// Assert
const checkboxes1 = container1.querySelectorAll('input[type="checkbox"]')
const checkboxes2 = container2.querySelectorAll('input[type="checkbox"]')
expect(checkboxes1.length).toBe(checkboxes2.length)
})
})
// Edge cases
describe('Edge Cases', () => {
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender, container } = render(<GeneralListSkeleton />)
// Act
rerender(<GeneralListSkeleton />)
// Assert
const listItems = container.querySelectorAll('.items-start.gap-x-2')
expect(listItems).toHaveLength(10)
})
it('should not have interactive elements besides disabled checkboxes', () => {
// Arrange & Act
const { container } = render(<GeneralListSkeleton />)
// Assert
const buttons = container.querySelectorAll('button')
const links = container.querySelectorAll('a')
expect(buttons).toHaveLength(0)
expect(links).toHaveLength(0)
})
})
})

View File

@ -0,0 +1,151 @@
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import ParagraphListSkeleton from './paragraph-list-skeleton'
describe('ParagraphListSkeleton', () => {
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<ParagraphListSkeleton />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render the correct number of list items', () => {
// Arrange & Act
const { container } = render(<ParagraphListSkeleton />)
// Assert - component renders 10 items
const listItems = container.querySelectorAll('.items-start.gap-x-2')
expect(listItems).toHaveLength(10)
})
it('should render mask overlay element', () => {
// Arrange & Act
const { container } = render(<ParagraphListSkeleton />)
// Assert
const maskElement = container.querySelector('.bg-dataset-chunk-list-mask-bg')
expect(maskElement).toBeInTheDocument()
})
it('should render with correct container classes', () => {
// Arrange & Act
const { container } = render(<ParagraphListSkeleton />)
// Assert
const containerElement = container.firstChild as HTMLElement
expect(containerElement).toHaveClass('relative')
expect(containerElement).toHaveClass('z-10')
expect(containerElement).toHaveClass('flex')
expect(containerElement).toHaveClass('h-full')
expect(containerElement).toHaveClass('flex-col')
expect(containerElement).toHaveClass('overflow-y-hidden')
})
})
// Checkbox tests
describe('Checkboxes', () => {
it('should render disabled checkboxes', () => {
// Arrange & Act
const { container } = render(<ParagraphListSkeleton />)
// Assert - Checkbox component uses cursor-not-allowed class when disabled
const disabledCheckboxes = container.querySelectorAll('.cursor-not-allowed')
expect(disabledCheckboxes.length).toBeGreaterThan(0)
})
it('should render checkboxes with shrink-0 class for consistent sizing', () => {
// Arrange & Act
const { container } = render(<ParagraphListSkeleton />)
// Assert
const checkboxContainers = container.querySelectorAll('.shrink-0')
expect(checkboxContainers.length).toBeGreaterThan(0)
})
})
// Divider tests
describe('Dividers', () => {
it('should render dividers between items except for the last one', () => {
// Arrange & Act
const { container } = render(<ParagraphListSkeleton />)
// Assert - should have 9 dividers (not after last item)
const dividers = container.querySelectorAll('.bg-divider-subtle')
expect(dividers).toHaveLength(9)
})
})
// Structure tests
describe('Structure', () => {
it('should render arrow icon for expand button styling', () => {
// Arrange & Act
const { container } = render(<ParagraphListSkeleton />)
// Assert - paragraph list skeleton has expand button styled area
const expandBtnElements = container.querySelectorAll('.bg-dataset-child-chunk-expand-btn-bg')
expect(expandBtnElements.length).toBeGreaterThan(0)
})
it('should render skeleton rectangles with quaternary text color', () => {
// Arrange & Act
const { container } = render(<ParagraphListSkeleton />)
// Assert
const skeletonElements = container.querySelectorAll('.bg-text-quaternary')
expect(skeletonElements.length).toBeGreaterThan(0)
})
it('should render CardSkelton inside each list item', () => {
// Arrange & Act
const { container } = render(<ParagraphListSkeleton />)
// Assert - each list item should contain card skeleton content
const cardContainers = container.querySelectorAll('.grow')
expect(cardContainers.length).toBeGreaterThan(0)
})
})
// Memoization tests
describe('Memoization', () => {
it('should render consistently across multiple renders', () => {
// Arrange & Act
const { container: container1 } = render(<ParagraphListSkeleton />)
const { container: container2 } = render(<ParagraphListSkeleton />)
// Assert
const items1 = container1.querySelectorAll('.items-start.gap-x-2')
const items2 = container2.querySelectorAll('.items-start.gap-x-2')
expect(items1.length).toBe(items2.length)
})
})
// Edge cases
describe('Edge Cases', () => {
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender, container } = render(<ParagraphListSkeleton />)
// Act
rerender(<ParagraphListSkeleton />)
// Assert
const listItems = container.querySelectorAll('.items-start.gap-x-2')
expect(listItems).toHaveLength(10)
})
it('should not have interactive elements besides disabled checkboxes', () => {
// Arrange & Act
const { container } = render(<ParagraphListSkeleton />)
// Assert
const buttons = container.querySelectorAll('button')
const links = container.querySelectorAll('a')
expect(buttons).toHaveLength(0)
expect(links).toHaveLength(0)
})
})
})

View File

@ -0,0 +1,132 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import ParentChunkCardSkelton from './parent-chunk-card-skeleton'
describe('ParentChunkCardSkelton', () => {
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
render(<ParentChunkCardSkelton />)
// Assert
expect(screen.getByTestId('parent-chunk-card-skeleton')).toBeInTheDocument()
})
it('should render with correct container classes', () => {
// Arrange & Act
render(<ParentChunkCardSkelton />)
// Assert
const container = screen.getByTestId('parent-chunk-card-skeleton')
expect(container).toHaveClass('flex')
expect(container).toHaveClass('flex-col')
expect(container).toHaveClass('pb-2')
})
it('should render skeleton rectangles', () => {
// Arrange & Act
const { container } = render(<ParentChunkCardSkelton />)
// Assert
const skeletonRectangles = container.querySelectorAll('.bg-text-quaternary')
expect(skeletonRectangles.length).toBeGreaterThan(0)
})
})
// i18n tests
describe('i18n', () => {
it('should render view more button with translated text', () => {
// Arrange & Act
render(<ParentChunkCardSkelton />)
// Assert - the button should contain translated text
const viewMoreButton = screen.getByRole('button')
expect(viewMoreButton).toBeInTheDocument()
})
it('should render disabled view more button', () => {
// Arrange & Act
render(<ParentChunkCardSkelton />)
// Assert
const viewMoreButton = screen.getByRole('button')
expect(viewMoreButton).toBeDisabled()
})
})
// Structure tests
describe('Structure', () => {
it('should render skeleton points as separators', () => {
// Arrange & Act
const { container } = render(<ParentChunkCardSkelton />)
// Assert
const opacityElements = container.querySelectorAll('.opacity-20')
expect(opacityElements.length).toBeGreaterThan(0)
})
it('should render width-constrained skeleton elements', () => {
// Arrange & Act
const { container } = render(<ParentChunkCardSkelton />)
// Assert - check for various width classes
expect(container.querySelector('.w-\\[72px\\]')).toBeInTheDocument()
expect(container.querySelector('.w-24')).toBeInTheDocument()
expect(container.querySelector('.w-full')).toBeInTheDocument()
expect(container.querySelector('.w-2\\/3')).toBeInTheDocument()
})
it('should render button with proper styling classes', () => {
// Arrange & Act
render(<ParentChunkCardSkelton />)
// Assert
const button = screen.getByRole('button')
expect(button).toHaveClass('system-xs-semibold-uppercase')
expect(button).toHaveClass('text-components-button-secondary-accent-text-disabled')
})
})
// Memoization tests
describe('Memoization', () => {
it('should render consistently across multiple renders', () => {
// Arrange & Act
const { container: container1 } = render(<ParentChunkCardSkelton />)
const { container: container2 } = render(<ParentChunkCardSkelton />)
// Assert
const skeletons1 = container1.querySelectorAll('.bg-text-quaternary')
const skeletons2 = container2.querySelectorAll('.bg-text-quaternary')
expect(skeletons1.length).toBe(skeletons2.length)
})
})
// Edge cases
describe('Edge Cases', () => {
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender, container } = render(<ParentChunkCardSkelton />)
// Act
rerender(<ParentChunkCardSkelton />)
// Assert
expect(screen.getByTestId('parent-chunk-card-skeleton')).toBeInTheDocument()
const skeletons = container.querySelectorAll('.bg-text-quaternary')
expect(skeletons.length).toBeGreaterThan(0)
})
it('should have only one interactive element (disabled button)', () => {
// Arrange & Act
const { container } = render(<ParentChunkCardSkelton />)
// Assert
const buttons = container.querySelectorAll('button')
const links = container.querySelectorAll('a')
expect(buttons).toHaveLength(1)
expect(buttons[0]).toBeDisabled()
expect(links).toHaveLength(0)
})
})
})

View File

@ -0,0 +1,118 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import StatusItem from './status-item'
describe('StatusItem', () => {
const defaultItem = {
value: '1',
name: 'Test Status',
}
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<StatusItem item={defaultItem} selected={false} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render item name', () => {
// Arrange & Act
render(<StatusItem item={defaultItem} selected={false} />)
// Assert
expect(screen.getByText('Test Status')).toBeInTheDocument()
})
it('should render with correct styling classes', () => {
// Arrange & Act
const { container } = render(<StatusItem item={defaultItem} selected={false} />)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex')
expect(wrapper).toHaveClass('items-center')
expect(wrapper).toHaveClass('justify-between')
})
})
// Props tests
describe('Props', () => {
it('should show check icon when selected is true', () => {
// Arrange & Act
const { container } = render(<StatusItem item={defaultItem} selected={true} />)
// Assert - RiCheckLine icon should be present
const checkIcon = container.querySelector('.text-text-accent')
expect(checkIcon).toBeInTheDocument()
})
it('should not show check icon when selected is false', () => {
// Arrange & Act
const { container } = render(<StatusItem item={defaultItem} selected={false} />)
// Assert - RiCheckLine icon should not be present
const checkIcon = container.querySelector('.text-text-accent')
expect(checkIcon).not.toBeInTheDocument()
})
it('should render different item names', () => {
// Arrange & Act
const item = { value: '2', name: 'Different Status' }
render(<StatusItem item={item} selected={false} />)
// Assert
expect(screen.getByText('Different Status')).toBeInTheDocument()
})
})
// Memoization tests
describe('Memoization', () => {
it('should render consistently with same props', () => {
// Arrange & Act
const { container: container1 } = render(<StatusItem item={defaultItem} selected={true} />)
const { container: container2 } = render(<StatusItem item={defaultItem} selected={true} />)
// Assert
expect(container1.textContent).toBe(container2.textContent)
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle empty item name', () => {
// Arrange
const emptyItem = { value: '1', name: '' }
// Act
const { container } = render(<StatusItem item={emptyItem} selected={false} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should handle special characters in item name', () => {
// Arrange
const specialItem = { value: '1', name: 'Status <>&"' }
// Act
render(<StatusItem item={specialItem} selected={false} />)
// Assert
expect(screen.getByText('Status <>&"')).toBeInTheDocument()
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender } = render(<StatusItem item={defaultItem} selected={false} />)
// Act
rerender(<StatusItem item={defaultItem} selected={true} />)
// Assert
expect(screen.getByText('Test Status')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,169 @@
import { render } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import { DocumentTitle } from './document-title'
// Mock next/navigation
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}))
// Mock DocumentPicker
vi.mock('../../common/document-picker', () => ({
default: ({ datasetId, value, onChange }: { datasetId: string, value: unknown, onChange: (doc: { id: string }) => void }) => (
<div
data-testid="document-picker"
data-dataset-id={datasetId}
data-value={JSON.stringify(value)}
onClick={() => onChange({ id: 'new-doc-id' })}
>
Document Picker
</div>
),
}))
describe('DocumentTitle', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(
<DocumentTitle datasetId="dataset-1" />,
)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render DocumentPicker component', () => {
// Arrange & Act
const { getByTestId } = render(
<DocumentTitle datasetId="dataset-1" />,
)
// Assert
expect(getByTestId('document-picker')).toBeInTheDocument()
})
it('should render with correct container classes', () => {
// Arrange & Act
const { container } = render(
<DocumentTitle datasetId="dataset-1" />,
)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex')
expect(wrapper).toHaveClass('flex-1')
expect(wrapper).toHaveClass('items-center')
expect(wrapper).toHaveClass('justify-start')
})
})
// Props tests
describe('Props', () => {
it('should pass datasetId to DocumentPicker', () => {
// Arrange & Act
const { getByTestId } = render(
<DocumentTitle datasetId="test-dataset-id" />,
)
// Assert
expect(getByTestId('document-picker').getAttribute('data-dataset-id')).toBe('test-dataset-id')
})
it('should pass value props to DocumentPicker', () => {
// Arrange & Act
const { getByTestId } = render(
<DocumentTitle
datasetId="dataset-1"
name="test-document"
extension="pdf"
chunkingMode={ChunkingMode.text}
parent_mode="paragraph"
/>,
)
// Assert
const value = JSON.parse(getByTestId('document-picker').getAttribute('data-value') || '{}')
expect(value.name).toBe('test-document')
expect(value.extension).toBe('pdf')
expect(value.chunkingMode).toBe(ChunkingMode.text)
expect(value.parentMode).toBe('paragraph')
})
it('should default parentMode to paragraph when parent_mode is undefined', () => {
// Arrange & Act
const { getByTestId } = render(
<DocumentTitle datasetId="dataset-1" />,
)
// Assert
const value = JSON.parse(getByTestId('document-picker').getAttribute('data-value') || '{}')
expect(value.parentMode).toBe('paragraph')
})
it('should apply custom wrapperCls', () => {
// Arrange & Act
const { container } = render(
<DocumentTitle datasetId="dataset-1" wrapperCls="custom-wrapper" />,
)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('custom-wrapper')
})
})
// Navigation tests
describe('Navigation', () => {
it('should navigate to document page when document is selected', () => {
// Arrange
const { getByTestId } = render(
<DocumentTitle datasetId="dataset-1" />,
)
// Act
getByTestId('document-picker').click()
// Assert
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents/new-doc-id')
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle undefined optional props', () => {
// Arrange & Act
const { getByTestId } = render(
<DocumentTitle datasetId="dataset-1" />,
)
// Assert
const value = JSON.parse(getByTestId('document-picker').getAttribute('data-value') || '{}')
expect(value.name).toBeUndefined()
expect(value.extension).toBeUndefined()
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender, getByTestId } = render(
<DocumentTitle datasetId="dataset-1" name="doc1" />,
)
// Act
rerender(<DocumentTitle datasetId="dataset-2" name="doc2" />)
// Assert
expect(getByTestId('document-picker').getAttribute('data-dataset-id')).toBe('dataset-2')
})
})
})

View File

@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
import type { DataSourceInfo, FileItem, LegacyDataSourceInfo } from '@/models/datasets'
import type { DataSourceInfo, FileItem, FullDocumentDetail, LegacyDataSourceInfo } from '@/models/datasets'
import { RiArrowLeftLine, RiLayoutLeft2Line, RiLayoutRight2Line } from '@remixicon/react'
import { useRouter } from 'next/navigation'
import * as React from 'react'
@ -256,7 +256,7 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
className="mr-2 mt-3"
datasetId={datasetId}
documentId={documentId}
docDetail={{ ...documentDetail, ...documentMetadata, doc_type: documentMetadata?.doc_type === 'others' ? '' : documentMetadata?.doc_type } as any}
docDetail={{ ...documentDetail, ...documentMetadata, doc_type: documentMetadata?.doc_type === 'others' ? '' : documentMetadata?.doc_type } as FullDocumentDetail}
/>
</FloatRightContainer>
</div>

View File

@ -0,0 +1,545 @@
import type { FullDocumentDetail } from '@/models/datasets'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Metadata, { FieldInfo } from './index'
// Mock document context
vi.mock('../context', () => ({
useDocumentContext: (selector: (state: { datasetId: string, documentId: string }) => unknown) => {
return selector({ datasetId: 'test-dataset-id', documentId: 'test-document-id' })
},
}))
// Mock ToastContext
const mockNotify = vi.fn()
vi.mock('use-context-selector', async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>
return {
...actual,
useContext: () => ({ notify: mockNotify }),
}
})
// Mock modifyDocMetadata
const mockModifyDocMetadata = vi.fn()
vi.mock('@/service/datasets', () => ({
modifyDocMetadata: (...args: unknown[]) => mockModifyDocMetadata(...args),
}))
// Mock useMetadataMap and related hooks
vi.mock('@/hooks/use-metadata', () => ({
useMetadataMap: () => ({
book: {
text: 'Book',
iconName: 'book',
subFieldsMap: {
title: { label: 'Title', inputType: 'input' },
language: { label: 'Language', inputType: 'select' },
author: { label: 'Author', inputType: 'input' },
publisher: { label: 'Publisher', inputType: 'input' },
publication_date: { label: 'Publication Date', inputType: 'input' },
isbn: { label: 'ISBN', inputType: 'input' },
category: { label: 'Category', inputType: 'select' },
},
},
web_page: {
text: 'Web Page',
iconName: 'web',
subFieldsMap: {
title: { label: 'Title', inputType: 'input' },
url: { label: 'URL', inputType: 'input' },
language: { label: 'Language', inputType: 'select' },
},
},
paper: {
text: 'Paper',
iconName: 'paper',
subFieldsMap: {
title: { label: 'Title', inputType: 'input' },
language: { label: 'Language', inputType: 'select' },
},
},
social_media_post: {
text: 'Social Media Post',
iconName: 'social',
subFieldsMap: {
platform: { label: 'Platform', inputType: 'input' },
},
},
personal_document: {
text: 'Personal Document',
iconName: 'personal',
subFieldsMap: {
document_type: { label: 'Document Type', inputType: 'select' },
},
},
business_document: {
text: 'Business Document',
iconName: 'business',
subFieldsMap: {
document_type: { label: 'Document Type', inputType: 'select' },
},
},
im_chat_log: {
text: 'IM Chat Log',
iconName: 'chat',
subFieldsMap: {
platform: { label: 'Platform', inputType: 'input' },
},
},
originInfo: {
text: 'Origin Info',
subFieldsMap: {
data_source_type: { label: 'Data Source Type', inputType: 'input' },
name: { label: 'Name', inputType: 'input' },
},
},
technicalParameters: {
text: 'Technical Parameters',
subFieldsMap: {
segment_count: { label: 'Segment Count', inputType: 'input' },
hit_count: { label: 'Hit Count', inputType: 'input', render: (v: number, segCount?: number) => `${v}/${segCount}` },
},
},
}),
useLanguages: () => ({
en: 'English',
zh: 'Chinese',
}),
useBookCategories: () => ({
'fiction': 'Fiction',
'non-fiction': 'Non-Fiction',
}),
usePersonalDocCategories: () => ({
resume: 'Resume',
letter: 'Letter',
}),
useBusinessDocCategories: () => ({
report: 'Report',
proposal: 'Proposal',
}),
}))
// Mock getTextWidthWithCanvas
vi.mock('@/utils', () => ({
asyncRunSafe: async (promise: Promise<unknown>) => {
try {
const result = await promise
return [null, result]
}
catch (e) {
return [e, null]
}
},
getTextWidthWithCanvas: () => 100,
}))
describe('Metadata', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const createMockDocDetail = (overrides = {}): FullDocumentDetail => ({
id: 'doc-1',
name: 'Test Document',
doc_type: 'book',
doc_metadata: {
title: 'Test Book',
author: 'Test Author',
language: 'en',
},
data_source_type: 'upload_file',
segment_count: 10,
hit_count: 5,
...overrides,
} as FullDocumentDetail)
const defaultProps = {
docDetail: createMockDocDetail(),
loading: false,
onUpdate: vi.fn(),
}
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<Metadata {...defaultProps} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render metadata title', () => {
// Arrange & Act
render(<Metadata {...defaultProps} />)
// Assert
expect(screen.getByText(/metadata\.title/i)).toBeInTheDocument()
})
it('should render edit button', () => {
// Arrange & Act
render(<Metadata {...defaultProps} />)
// Assert
expect(screen.getByText(/operation\.edit/i)).toBeInTheDocument()
})
it('should show loading state', () => {
// Arrange & Act
render(<Metadata {...defaultProps} loading={true} />)
// Assert - Loading component should be rendered
expect(screen.queryByText(/metadata\.title/i)).not.toBeInTheDocument()
})
it('should display document type icon and text', () => {
// Arrange & Act
render(<Metadata {...defaultProps} />)
// Assert
expect(screen.getByText('Book')).toBeInTheDocument()
})
})
// Edit mode tests
describe('Edit Mode', () => {
it('should enter edit mode when edit button is clicked', () => {
// Arrange
render(<Metadata {...defaultProps} />)
// Act
fireEvent.click(screen.getByText(/operation\.edit/i))
// Assert
expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
expect(screen.getByText(/operation\.save/i)).toBeInTheDocument()
})
it('should show change link in edit mode', () => {
// Arrange
render(<Metadata {...defaultProps} />)
// Act
fireEvent.click(screen.getByText(/operation\.edit/i))
// Assert
expect(screen.getByText(/operation\.change/i)).toBeInTheDocument()
})
it('should cancel edit and restore values when cancel is clicked', () => {
// Arrange
render(<Metadata {...defaultProps} />)
// Enter edit mode
fireEvent.click(screen.getByText(/operation\.edit/i))
// Act
fireEvent.click(screen.getByText(/operation\.cancel/i))
// Assert - should be back to view mode
expect(screen.getByText(/operation\.edit/i)).toBeInTheDocument()
})
it('should save metadata when save button is clicked', async () => {
// Arrange
mockModifyDocMetadata.mockResolvedValueOnce({})
render(<Metadata {...defaultProps} />)
// Enter edit mode
fireEvent.click(screen.getByText(/operation\.edit/i))
// Act
fireEvent.click(screen.getByText(/operation\.save/i))
// Assert
await waitFor(() => {
expect(mockModifyDocMetadata).toHaveBeenCalled()
})
})
it('should show success notification after successful save', async () => {
// Arrange
mockModifyDocMetadata.mockResolvedValueOnce({})
render(<Metadata {...defaultProps} />)
// Enter edit mode
fireEvent.click(screen.getByText(/operation\.edit/i))
// Act
fireEvent.click(screen.getByText(/operation\.save/i))
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
}),
)
})
})
it('should show error notification after failed save', async () => {
// Arrange
mockModifyDocMetadata.mockRejectedValueOnce(new Error('Save failed'))
render(<Metadata {...defaultProps} />)
// Enter edit mode
fireEvent.click(screen.getByText(/operation\.edit/i))
// Act
fireEvent.click(screen.getByText(/operation\.save/i))
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
)
})
})
})
// Document type selection
describe('Document Type Selection', () => {
it('should show doc type selection when no doc_type exists', () => {
// Arrange
const docDetail = createMockDocDetail({ doc_type: '' })
// Act
render(<Metadata {...defaultProps} docDetail={docDetail} />)
// Assert
expect(screen.getByText(/metadata\.docTypeSelectTitle/i)).toBeInTheDocument()
})
it('should show description when no doc_type exists', () => {
// Arrange
const docDetail = createMockDocDetail({ doc_type: '' })
// Act
render(<Metadata {...defaultProps} docDetail={docDetail} />)
// Assert
expect(screen.getByText(/metadata\.desc/i)).toBeInTheDocument()
})
it('should show change link in edit mode when doc_type exists', () => {
// Arrange
render(<Metadata {...defaultProps} />)
// Enter edit mode
fireEvent.click(screen.getByText(/operation\.edit/i))
// Assert
expect(screen.getByText(/operation\.change/i)).toBeInTheDocument()
})
it('should show doc type change title after clicking change', () => {
// Arrange
render(<Metadata {...defaultProps} />)
// Enter edit mode
fireEvent.click(screen.getByText(/operation\.edit/i))
// Act
fireEvent.click(screen.getByText(/operation\.change/i))
// Assert
expect(screen.getByText(/metadata\.docTypeChangeTitle/i)).toBeInTheDocument()
})
})
// Origin info and technical parameters
describe('Fixed Fields', () => {
it('should render origin info fields', () => {
// Arrange & Act
render(<Metadata {...defaultProps} />)
// Assert - Origin info fields should be displayed
expect(screen.getByText('Data Source Type')).toBeInTheDocument()
})
it('should render technical parameters fields', () => {
// Arrange & Act
render(<Metadata {...defaultProps} />)
// Assert
expect(screen.getByText('Segment Count')).toBeInTheDocument()
expect(screen.getByText('Hit Count')).toBeInTheDocument()
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle doc_type as others', () => {
// Arrange
const docDetail = createMockDocDetail({ doc_type: 'others' })
// Act
const { container } = render(<Metadata {...defaultProps} docDetail={docDetail} />)
// Assert - should render without crashing
expect(container.firstChild).toBeInTheDocument()
})
it('should handle undefined docDetail gracefully', () => {
// Arrange & Act
const { container } = render(<Metadata {...defaultProps} docDetail={undefined} loading={false} />)
// Assert - should render without crashing
expect(container.firstChild).toBeInTheDocument()
})
it('should update document type display when docDetail changes', () => {
// Arrange
const { rerender } = render(<Metadata {...defaultProps} />)
// Act - verify initial state shows Book
expect(screen.getByText('Book')).toBeInTheDocument()
// Update with new doc type
const updatedDocDetail = createMockDocDetail({ doc_type: 'paper' })
rerender(<Metadata {...defaultProps} docDetail={updatedDocDetail} />)
// Assert
expect(screen.getByText('Paper')).toBeInTheDocument()
})
})
// First meta action button
describe('First Meta Action Button', () => {
it('should show first meta action button when no doc type exists', () => {
// Arrange
const docDetail = createMockDocDetail({ doc_type: '' })
// Act
render(<Metadata {...defaultProps} docDetail={docDetail} />)
// Assert
expect(screen.getByText(/metadata\.firstMetaAction/i)).toBeInTheDocument()
})
})
})
// FieldInfo component tests
describe('FieldInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const defaultFieldInfoProps = {
label: 'Test Label',
value: 'Test Value',
displayedValue: 'Test Display Value',
}
// Rendering
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<FieldInfo {...defaultFieldInfoProps} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render label', () => {
// Arrange & Act
render(<FieldInfo {...defaultFieldInfoProps} />)
// Assert
expect(screen.getByText('Test Label')).toBeInTheDocument()
})
it('should render displayed value in view mode', () => {
// Arrange & Act
render(<FieldInfo {...defaultFieldInfoProps} showEdit={false} />)
// Assert
expect(screen.getByText('Test Display Value')).toBeInTheDocument()
})
})
// Edit mode
describe('Edit Mode', () => {
it('should render input when showEdit is true and inputType is input', () => {
// Arrange & Act
render(<FieldInfo {...defaultFieldInfoProps} showEdit={true} inputType="input" onUpdate={vi.fn()} />)
// Assert
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render select when showEdit is true and inputType is select', () => {
// Arrange & Act
render(
<FieldInfo
{...defaultFieldInfoProps}
showEdit={true}
inputType="select"
selectOptions={[{ value: 'opt1', name: 'Option 1' }]}
onUpdate={vi.fn()}
/>,
)
// Assert - SimpleSelect should be rendered
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render textarea when showEdit is true and inputType is textarea', () => {
// Arrange & Act
render(<FieldInfo {...defaultFieldInfoProps} showEdit={true} inputType="textarea" onUpdate={vi.fn()} />)
// Assert
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should call onUpdate when input value changes', () => {
// Arrange
const mockOnUpdate = vi.fn()
render(<FieldInfo {...defaultFieldInfoProps} showEdit={true} inputType="input" onUpdate={mockOnUpdate} />)
// Act
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New Value' } })
// Assert
expect(mockOnUpdate).toHaveBeenCalledWith('New Value')
})
it('should call onUpdate when textarea value changes', () => {
// Arrange
const mockOnUpdate = vi.fn()
render(<FieldInfo {...defaultFieldInfoProps} showEdit={true} inputType="textarea" onUpdate={mockOnUpdate} />)
// Act
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New Textarea Value' } })
// Assert
expect(mockOnUpdate).toHaveBeenCalledWith('New Textarea Value')
})
})
// Props
describe('Props', () => {
it('should render value icon when provided', () => {
// Arrange & Act
render(<FieldInfo {...defaultFieldInfoProps} valueIcon={<span data-testid="value-icon">Icon</span>} />)
// Assert
expect(screen.getByTestId('value-icon')).toBeInTheDocument()
})
it('should use defaultValue when provided', () => {
// Arrange & Act
render(<FieldInfo {...defaultFieldInfoProps} showEdit={true} inputType="input" defaultValue="Default" onUpdate={vi.fn()} />)
// Assert
const input = screen.getByRole('textbox')
expect(input).toHaveAttribute('placeholder')
})
})
})

View File

@ -0,0 +1,503 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import { IndexingType } from '../../create/step-two'
import NewSegmentModal from './new-segment'
// Mock next/navigation
vi.mock('next/navigation', () => ({
useParams: () => ({
datasetId: 'test-dataset-id',
documentId: 'test-document-id',
}),
}))
// Mock ToastContext
const mockNotify = vi.fn()
vi.mock('use-context-selector', async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>
return {
...actual,
useContext: () => ({ notify: mockNotify }),
}
})
// Mock dataset detail context
let mockIndexingTechnique = IndexingType.QUALIFIED
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: { dataset: { indexing_technique: string } }) => unknown) => {
return selector({ dataset: { indexing_technique: mockIndexingTechnique } })
},
}))
// Mock segment list context
let mockFullScreen = false
const mockToggleFullScreen = vi.fn()
vi.mock('./completed', () => ({
useSegmentListContext: (selector: (state: { fullScreen: boolean, toggleFullScreen: () => void }) => unknown) => {
const state = {
fullScreen: mockFullScreen,
toggleFullScreen: mockToggleFullScreen,
}
return selector(state)
},
}))
// Mock useAddSegment
const mockAddSegment = vi.fn()
vi.mock('@/service/knowledge/use-segment', () => ({
useAddSegment: () => ({
mutateAsync: mockAddSegment,
}),
}))
// Mock app store
vi.mock('@/app/components/app/store', () => ({
useStore: () => ({ appSidebarExpand: 'expand' }),
}))
// Mock child components
vi.mock('./completed/common/action-buttons', () => ({
default: ({ handleCancel, handleSave, loading, actionType }: { handleCancel: () => void, handleSave: () => void, loading: boolean, actionType: string }) => (
<div data-testid="action-buttons">
<button onClick={handleCancel} data-testid="cancel-btn">Cancel</button>
<button onClick={handleSave} disabled={loading} data-testid="save-btn">
{loading ? 'Saving...' : 'Save'}
</button>
<span data-testid="action-type">{actionType}</span>
</div>
),
}))
vi.mock('./completed/common/add-another', () => ({
default: ({ isChecked, onCheck, className }: { isChecked: boolean, onCheck: () => void, className?: string }) => (
<div data-testid="add-another" className={className}>
<input
type="checkbox"
checked={isChecked}
onChange={onCheck}
data-testid="add-another-checkbox"
/>
</div>
),
}))
vi.mock('./completed/common/chunk-content', () => ({
default: ({ docForm, question, answer, onQuestionChange, onAnswerChange, isEditMode }: { docForm: string, question: string, answer: string, onQuestionChange: (v: string) => void, onAnswerChange: (v: string) => void, isEditMode: boolean }) => (
<div data-testid="chunk-content">
<input
data-testid="question-input"
value={question}
onChange={e => onQuestionChange(e.target.value)}
placeholder={docForm === ChunkingMode.qa ? 'Question' : 'Content'}
/>
{docForm === ChunkingMode.qa && (
<input
data-testid="answer-input"
value={answer}
onChange={e => onAnswerChange(e.target.value)}
placeholder="Answer"
/>
)}
<span data-testid="edit-mode">{isEditMode ? 'editing' : 'viewing'}</span>
</div>
),
}))
vi.mock('./completed/common/dot', () => ({
default: () => <span data-testid="dot"></span>,
}))
vi.mock('./completed/common/keywords', () => ({
default: ({ keywords, onKeywordsChange, _isEditMode, _actionType }: { keywords: string[], onKeywordsChange: (v: string[]) => void, _isEditMode?: boolean, _actionType?: string }) => (
<div data-testid="keywords">
<input
data-testid="keywords-input"
value={keywords.join(',')}
onChange={e => onKeywordsChange(e.target.value.split(',').filter(Boolean))}
/>
</div>
),
}))
vi.mock('./completed/common/segment-index-tag', () => ({
SegmentIndexTag: ({ label }: { label: string }) => <span data-testid="segment-index-tag">{label}</span>,
}))
vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-chunk', () => ({
default: ({ onChange }: { value?: unknown[], onChange: (v: { uploadedId: string }[]) => void }) => (
<div data-testid="image-uploader">
<button
data-testid="upload-image-btn"
onClick={() => onChange([{ uploadedId: 'img-1' }])}
>
Upload Image
</button>
</div>
),
}))
describe('NewSegmentModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFullScreen = false
mockIndexingTechnique = IndexingType.QUALIFIED
})
const defaultProps = {
onCancel: vi.fn(),
docForm: ChunkingMode.text,
onSave: vi.fn(),
viewNewlyAddedChunk: vi.fn(),
}
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<NewSegmentModal {...defaultProps} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render title text', () => {
// Arrange & Act
render(<NewSegmentModal {...defaultProps} />)
// Assert
expect(screen.getByText(/segment\.addChunk/i)).toBeInTheDocument()
})
it('should render chunk content component', () => {
// Arrange & Act
render(<NewSegmentModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('chunk-content')).toBeInTheDocument()
})
it('should render image uploader', () => {
// Arrange & Act
render(<NewSegmentModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('image-uploader')).toBeInTheDocument()
})
it('should render segment index tag', () => {
// Arrange & Act
render(<NewSegmentModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('segment-index-tag')).toBeInTheDocument()
})
it('should render dot separator', () => {
// Arrange & Act
render(<NewSegmentModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('dot')).toBeInTheDocument()
})
})
// Keywords display
describe('Keywords', () => {
it('should show keywords component when indexing is ECONOMICAL', () => {
// Arrange
mockIndexingTechnique = IndexingType.ECONOMICAL
// Act
render(<NewSegmentModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('keywords')).toBeInTheDocument()
})
it('should not show keywords when indexing is QUALIFIED', () => {
// Arrange
mockIndexingTechnique = IndexingType.QUALIFIED
// Act
render(<NewSegmentModal {...defaultProps} />)
// Assert
expect(screen.queryByTestId('keywords')).not.toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should call onCancel when close button is clicked', () => {
// Arrange
const mockOnCancel = vi.fn()
const { container } = render(<NewSegmentModal {...defaultProps} onCancel={mockOnCancel} />)
// Act - find and click close button (RiCloseLine icon wrapper)
const closeButtons = container.querySelectorAll('.cursor-pointer')
// The close button is the second cursor-pointer element
if (closeButtons.length > 1)
fireEvent.click(closeButtons[1])
// Assert
expect(mockOnCancel).toHaveBeenCalled()
})
it('should update question when typing', () => {
// Arrange
render(<NewSegmentModal {...defaultProps} />)
const questionInput = screen.getByTestId('question-input')
// Act
fireEvent.change(questionInput, { target: { value: 'New question content' } })
// Assert
expect(questionInput).toHaveValue('New question content')
})
it('should update answer when docForm is QA and typing', () => {
// Arrange
render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.qa} />)
const answerInput = screen.getByTestId('answer-input')
// Act
fireEvent.change(answerInput, { target: { value: 'New answer content' } })
// Assert
expect(answerInput).toHaveValue('New answer content')
})
it('should toggle add another checkbox', () => {
// Arrange
render(<NewSegmentModal {...defaultProps} />)
const checkbox = screen.getByTestId('add-another-checkbox')
// Act
fireEvent.click(checkbox)
// Assert - checkbox state should toggle
expect(checkbox).toBeInTheDocument()
})
})
// Save validation
describe('Save Validation', () => {
it('should show error when content is empty for text mode', async () => {
// Arrange
render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.text} />)
// Act
fireEvent.click(screen.getByTestId('save-btn'))
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
)
})
})
it('should show error when question is empty for QA mode', async () => {
// Arrange
render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.qa} />)
// Act
fireEvent.click(screen.getByTestId('save-btn'))
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
)
})
})
it('should show error when answer is empty for QA mode', async () => {
// Arrange
render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.qa} />)
fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Question' } })
// Act
fireEvent.click(screen.getByTestId('save-btn'))
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
)
})
})
})
// Successful save
describe('Successful Save', () => {
it('should call addSegment when valid content is provided for text mode', async () => {
// Arrange
mockAddSegment.mockImplementation((_params, options) => {
options.onSuccess()
options.onSettled()
return Promise.resolve()
})
render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.text} />)
fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Valid content' } })
// Act
fireEvent.click(screen.getByTestId('save-btn'))
// Assert
await waitFor(() => {
expect(mockAddSegment).toHaveBeenCalledWith(
expect.objectContaining({
datasetId: 'test-dataset-id',
documentId: 'test-document-id',
body: expect.objectContaining({
content: 'Valid content',
}),
}),
expect.any(Object),
)
})
})
it('should show success notification after save', async () => {
// Arrange
mockAddSegment.mockImplementation((_params, options) => {
options.onSuccess()
options.onSettled()
return Promise.resolve()
})
render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.text} />)
fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Valid content' } })
// Act
fireEvent.click(screen.getByTestId('save-btn'))
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
}),
)
})
})
})
// Full screen mode
describe('Full Screen Mode', () => {
it('should apply full screen styling when fullScreen is true', () => {
// Arrange
mockFullScreen = true
// Act
const { container } = render(<NewSegmentModal {...defaultProps} />)
// Assert
const header = container.querySelector('.border-divider-subtle')
expect(header).toBeInTheDocument()
})
it('should show action buttons in header when fullScreen', () => {
// Arrange
mockFullScreen = true
// Act
render(<NewSegmentModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('action-buttons')).toBeInTheDocument()
})
it('should show add another in header when fullScreen', () => {
// Arrange
mockFullScreen = true
// Act
render(<NewSegmentModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('add-another')).toBeInTheDocument()
})
it('should call toggleFullScreen when expand button is clicked', () => {
// Arrange
const { container } = render(<NewSegmentModal {...defaultProps} />)
// Act - click the expand button (first cursor-pointer)
const expandButtons = container.querySelectorAll('.cursor-pointer')
if (expandButtons.length > 0)
fireEvent.click(expandButtons[0])
// Assert
expect(mockToggleFullScreen).toHaveBeenCalled()
})
})
// Props
describe('Props', () => {
it('should pass actionType add to ActionButtons', () => {
// Arrange & Act
render(<NewSegmentModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('action-type')).toHaveTextContent('add')
})
it('should pass isEditMode true to ChunkContent', () => {
// Arrange & Act
render(<NewSegmentModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('edit-mode')).toHaveTextContent('editing')
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle keyword changes for ECONOMICAL indexing', () => {
// Arrange
mockIndexingTechnique = IndexingType.ECONOMICAL
render(<NewSegmentModal {...defaultProps} />)
// Act
fireEvent.change(screen.getByTestId('keywords-input'), {
target: { value: 'keyword1,keyword2' },
})
// Assert
expect(screen.getByTestId('keywords-input')).toHaveValue('keyword1,keyword2')
})
it('should handle image upload', () => {
// Arrange
render(<NewSegmentModal {...defaultProps} />)
// Act
fireEvent.click(screen.getByTestId('upload-image-btn'))
// Assert - image uploader should be rendered
expect(screen.getByTestId('image-uploader')).toBeInTheDocument()
})
it('should maintain structure when rerendered with different docForm', () => {
// Arrange
const { rerender } = render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.text} />)
// Act
rerender(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.qa} />)
// Assert
expect(screen.getByTestId('answer-input')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,351 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Plan } from '@/app/components/billing/type'
import SegmentAdd, { ProcessStatus } from './index'
// Mock provider context
let mockPlan = { type: Plan.professional }
let mockEnableBilling = true
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
plan: mockPlan,
enableBilling: mockEnableBilling,
}),
}))
// Mock PlanUpgradeModal
vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({
default: ({ show, onClose, title, description }: { show: boolean, onClose: () => void, title?: string, description?: string }) => (
show
? (
<div data-testid="plan-upgrade-modal">
<span data-testid="modal-title">{title}</span>
<span data-testid="modal-description">{description}</span>
<button onClick={onClose} data-testid="close-modal">Close</button>
</div>
)
: null
),
}))
// Mock Popover
vi.mock('@/app/components/base/popover', () => ({
default: ({ htmlContent, btnElement, disabled }: { htmlContent: ReactNode, btnElement: ReactNode, disabled?: boolean }) => (
<div data-testid="popover">
<button data-testid="popover-btn" disabled={disabled}>
{btnElement}
</button>
<div data-testid="popover-content">{htmlContent}</div>
</div>
),
}))
describe('SegmentAdd', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPlan = { type: Plan.professional }
mockEnableBilling = true
})
const defaultProps = {
importStatus: undefined as ProcessStatus | string | undefined,
clearProcessStatus: vi.fn(),
showNewSegmentModal: vi.fn(),
showBatchModal: vi.fn(),
embedding: false,
}
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<SegmentAdd {...defaultProps} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render add button when no importStatus', () => {
// Arrange & Act
render(<SegmentAdd {...defaultProps} />)
// Assert
expect(screen.getByText(/list\.action\.addButton/i)).toBeInTheDocument()
})
it('should render popover for batch add', () => {
// Arrange & Act
render(<SegmentAdd {...defaultProps} />)
// Assert
expect(screen.getByTestId('popover')).toBeInTheDocument()
})
})
// Import Status displays
describe('Import Status Display', () => {
it('should show processing indicator when status is WAITING', () => {
// Arrange & Act
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.WAITING} />)
// Assert
expect(screen.getByText(/list\.batchModal\.processing/i)).toBeInTheDocument()
})
it('should show processing indicator when status is PROCESSING', () => {
// Arrange & Act
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.PROCESSING} />)
// Assert
expect(screen.getByText(/list\.batchModal\.processing/i)).toBeInTheDocument()
})
it('should show completed status with ok button', () => {
// Arrange & Act
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.COMPLETED} />)
// Assert
expect(screen.getByText(/list\.batchModal\.completed/i)).toBeInTheDocument()
expect(screen.getByText(/list\.batchModal\.ok/i)).toBeInTheDocument()
})
it('should show error status with ok button', () => {
// Arrange & Act
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.ERROR} />)
// Assert
expect(screen.getByText(/list\.batchModal\.error/i)).toBeInTheDocument()
expect(screen.getByText(/list\.batchModal\.ok/i)).toBeInTheDocument()
})
it('should not show add button when importStatus is set', () => {
// Arrange & Act
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.PROCESSING} />)
// Assert
expect(screen.queryByText(/list\.action\.addButton/i)).not.toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should call showNewSegmentModal when add button is clicked', () => {
// Arrange
const mockShowNewSegmentModal = vi.fn()
render(<SegmentAdd {...defaultProps} showNewSegmentModal={mockShowNewSegmentModal} />)
// Act
fireEvent.click(screen.getByText(/list\.action\.addButton/i))
// Assert
expect(mockShowNewSegmentModal).toHaveBeenCalledTimes(1)
})
it('should call clearProcessStatus when ok is clicked on completed status', () => {
// Arrange
const mockClearProcessStatus = vi.fn()
render(
<SegmentAdd
{...defaultProps}
importStatus={ProcessStatus.COMPLETED}
clearProcessStatus={mockClearProcessStatus}
/>,
)
// Act
fireEvent.click(screen.getByText(/list\.batchModal\.ok/i))
// Assert
expect(mockClearProcessStatus).toHaveBeenCalledTimes(1)
})
it('should call clearProcessStatus when ok is clicked on error status', () => {
// Arrange
const mockClearProcessStatus = vi.fn()
render(
<SegmentAdd
{...defaultProps}
importStatus={ProcessStatus.ERROR}
clearProcessStatus={mockClearProcessStatus}
/>,
)
// Act
fireEvent.click(screen.getByText(/list\.batchModal\.ok/i))
// Assert
expect(mockClearProcessStatus).toHaveBeenCalledTimes(1)
})
it('should render batch add option in popover', () => {
// Arrange & Act
render(<SegmentAdd {...defaultProps} />)
// Assert
expect(screen.getByText(/list\.action\.batchAdd/i)).toBeInTheDocument()
})
it('should call showBatchModal when batch add is clicked', () => {
// Arrange
const mockShowBatchModal = vi.fn()
render(<SegmentAdd {...defaultProps} showBatchModal={mockShowBatchModal} />)
// Act
fireEvent.click(screen.getByText(/list\.action\.batchAdd/i))
// Assert
expect(mockShowBatchModal).toHaveBeenCalledTimes(1)
})
})
// Disabled state (embedding)
describe('Embedding State', () => {
it('should disable add button when embedding is true', () => {
// Arrange & Act
render(<SegmentAdd {...defaultProps} embedding={true} />)
// Assert
const addButton = screen.getByText(/list\.action\.addButton/i).closest('button')
expect(addButton).toBeDisabled()
})
it('should disable popover button when embedding is true', () => {
// Arrange & Act
render(<SegmentAdd {...defaultProps} embedding={true} />)
// Assert
expect(screen.getByTestId('popover-btn')).toBeDisabled()
})
it('should apply disabled styling when embedding is true', () => {
// Arrange & Act
const { container } = render(<SegmentAdd {...defaultProps} embedding={true} />)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('border-components-button-secondary-border-disabled')
})
})
// Plan upgrade modal
describe('Plan Upgrade Modal', () => {
it('should show plan upgrade modal when sandbox user tries to add', () => {
// Arrange
mockPlan = { type: Plan.sandbox }
render(<SegmentAdd {...defaultProps} />)
// Act
fireEvent.click(screen.getByText(/list\.action\.addButton/i))
// Assert
expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument()
})
it('should not call showNewSegmentModal for sandbox users', () => {
// Arrange
mockPlan = { type: Plan.sandbox }
const mockShowNewSegmentModal = vi.fn()
render(<SegmentAdd {...defaultProps} showNewSegmentModal={mockShowNewSegmentModal} />)
// Act
fireEvent.click(screen.getByText(/list\.action\.addButton/i))
// Assert
expect(mockShowNewSegmentModal).not.toHaveBeenCalled()
})
it('should allow add when billing is disabled regardless of plan', () => {
// Arrange
mockPlan = { type: Plan.sandbox }
mockEnableBilling = false
const mockShowNewSegmentModal = vi.fn()
render(<SegmentAdd {...defaultProps} showNewSegmentModal={mockShowNewSegmentModal} />)
// Act
fireEvent.click(screen.getByText(/list\.action\.addButton/i))
// Assert
expect(mockShowNewSegmentModal).toHaveBeenCalledTimes(1)
})
it('should close plan upgrade modal when close button is clicked', () => {
// Arrange
mockPlan = { type: Plan.sandbox }
render(<SegmentAdd {...defaultProps} />)
// Show modal
fireEvent.click(screen.getByText(/list\.action\.addButton/i))
expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument()
// Act
fireEvent.click(screen.getByTestId('close-modal'))
// Assert
expect(screen.queryByTestId('plan-upgrade-modal')).not.toBeInTheDocument()
})
})
// Progress bar width tests
describe('Progress Bar', () => {
it('should show 3/12 width progress bar for WAITING status', () => {
// Arrange & Act
const { container } = render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.WAITING} />)
// Assert
const progressBar = container.querySelector('.w-3\\/12')
expect(progressBar).toBeInTheDocument()
})
it('should show 2/3 width progress bar for PROCESSING status', () => {
// Arrange & Act
const { container } = render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.PROCESSING} />)
// Assert
const progressBar = container.querySelector('.w-2\\/3')
expect(progressBar).toBeInTheDocument()
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle unknown importStatus string', () => {
// Arrange & Act - pass unknown status
const { container } = render(<SegmentAdd {...defaultProps} importStatus="unknown" />)
// Assert - empty fragment is rendered for unknown status (container exists but has no visible content)
expect(container).toBeInTheDocument()
expect(container.textContent).toBe('')
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender } = render(<SegmentAdd {...defaultProps} />)
// Act
rerender(<SegmentAdd {...defaultProps} embedding={true} />)
// Assert
const addButton = screen.getByText(/list\.action\.addButton/i).closest('button')
expect(addButton).toBeDisabled()
})
it('should handle callback change', () => {
// Arrange
const mockShowNewSegmentModal1 = vi.fn()
const mockShowNewSegmentModal2 = vi.fn()
const { rerender } = render(
<SegmentAdd {...defaultProps} showNewSegmentModal={mockShowNewSegmentModal1} />,
)
// Act
rerender(<SegmentAdd {...defaultProps} showNewSegmentModal={mockShowNewSegmentModal2} />)
fireEvent.click(screen.getByText(/list\.action\.addButton/i))
// Assert
expect(mockShowNewSegmentModal1).not.toHaveBeenCalled()
expect(mockShowNewSegmentModal2).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -0,0 +1,374 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DocumentSettings from './document-settings'
// Mock next/navigation
const mockPush = vi.fn()
const mockBack = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
back: mockBack,
}),
}))
// Mock use-context-selector
vi.mock('use-context-selector', async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>
return {
...actual,
useContext: () => ({
indexingTechnique: 'qualified',
dataset: { id: 'dataset-1' },
}),
}
})
// Mock hooks
const mockInvalidDocumentList = vi.fn()
const mockInvalidDocumentDetail = vi.fn()
let mockDocumentDetail: Record<string, unknown> | null = {
name: 'test-document',
data_source_type: 'upload_file',
data_source_info: {
upload_file: { id: 'file-1', name: 'test.pdf' },
},
}
let mockError: Error | null = null
vi.mock('@/service/knowledge/use-document', () => ({
useDocumentDetail: () => ({
data: mockDocumentDetail,
error: mockError,
}),
useInvalidDocumentList: () => mockInvalidDocumentList,
useInvalidDocumentDetail: () => mockInvalidDocumentDetail,
}))
// Mock useDefaultModel
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useDefaultModel: () => ({
data: { model: 'text-embedding-ada-002' },
}),
}))
// Mock child components
vi.mock('@/app/components/base/app-unavailable', () => ({
default: ({ code, unknownReason }: { code?: number, unknownReason?: string }) => (
<div data-testid="app-unavailable">
<span data-testid="error-code">{code}</span>
<span data-testid="error-reason">{unknownReason}</span>
</div>
),
}))
vi.mock('@/app/components/base/loading', () => ({
default: ({ type }: { type?: string }) => (
<div data-testid="loading" data-type={type}>Loading...</div>
),
}))
vi.mock('@/app/components/datasets/create/step-two', () => ({
default: ({
isAPIKeySet,
onSetting,
datasetId,
dataSourceType,
files,
onSave,
onCancel,
isSetting,
}: {
isAPIKeySet?: boolean
onSetting?: () => void
datasetId?: string
dataSourceType?: string
files?: unknown[]
onSave?: () => void
onCancel?: () => void
isSetting?: boolean
}) => (
<div data-testid="step-two">
<span data-testid="api-key-set">{isAPIKeySet ? 'true' : 'false'}</span>
<span data-testid="dataset-id">{datasetId}</span>
<span data-testid="data-source-type">{dataSourceType}</span>
<span data-testid="is-setting">{isSetting ? 'true' : 'false'}</span>
<span data-testid="files-count">{files?.length || 0}</span>
<button onClick={onSetting} data-testid="setting-btn">Setting</button>
<button onClick={onSave} data-testid="save-btn">Save</button>
<button onClick={onCancel} data-testid="cancel-btn">Cancel</button>
</div>
),
}))
vi.mock('@/app/components/header/account-setting', () => ({
default: ({ activeTab, onCancel }: { activeTab?: string, onCancel?: () => void }) => (
<div data-testid="account-setting">
<span data-testid="active-tab">{activeTab}</span>
<button onClick={onCancel} data-testid="close-setting">Close</button>
</div>
),
}))
describe('DocumentSettings', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDocumentDetail = {
name: 'test-document',
data_source_type: 'upload_file',
data_source_info: {
upload_file: { id: 'file-1', name: 'test.pdf' },
},
}
mockError = null
})
const defaultProps = {
datasetId: 'dataset-1',
documentId: 'document-1',
}
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<DocumentSettings {...defaultProps} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render StepTwo component when data is loaded', () => {
// Arrange & Act
render(<DocumentSettings {...defaultProps} />)
// Assert
expect(screen.getByTestId('step-two')).toBeInTheDocument()
})
it('should render loading when documentDetail is not available', () => {
// Arrange
mockDocumentDetail = null
// Act
render(<DocumentSettings {...defaultProps} />)
// Assert
expect(screen.getByTestId('loading')).toBeInTheDocument()
})
it('should render AppUnavailable when error occurs', () => {
// Arrange
mockError = new Error('Error loading document')
// Act
render(<DocumentSettings {...defaultProps} />)
// Assert
expect(screen.getByTestId('app-unavailable')).toBeInTheDocument()
expect(screen.getByTestId('error-code')).toHaveTextContent('500')
})
})
// Props passing
describe('Props Passing', () => {
it('should pass datasetId to StepTwo', () => {
// Arrange & Act
render(<DocumentSettings {...defaultProps} />)
// Assert
expect(screen.getByTestId('dataset-id')).toHaveTextContent('dataset-1')
})
it('should pass isSetting true to StepTwo', () => {
// Arrange & Act
render(<DocumentSettings {...defaultProps} />)
// Assert
expect(screen.getByTestId('is-setting')).toHaveTextContent('true')
})
it('should pass isAPIKeySet when embedding model is available', () => {
// Arrange & Act
render(<DocumentSettings {...defaultProps} />)
// Assert
expect(screen.getByTestId('api-key-set')).toHaveTextContent('true')
})
it('should pass data source type to StepTwo', () => {
// Arrange & Act
render(<DocumentSettings {...defaultProps} />)
// Assert
expect(screen.getByTestId('data-source-type')).toHaveTextContent('upload_file')
})
})
// User Interactions
describe('User Interactions', () => {
it('should call router.back when cancel is clicked', () => {
// Arrange
render(<DocumentSettings {...defaultProps} />)
// Act
fireEvent.click(screen.getByTestId('cancel-btn'))
// Assert
expect(mockBack).toHaveBeenCalled()
})
it('should navigate to document page when save is clicked', () => {
// Arrange
render(<DocumentSettings {...defaultProps} />)
// Act
fireEvent.click(screen.getByTestId('save-btn'))
// Assert
expect(mockInvalidDocumentList).toHaveBeenCalled()
expect(mockInvalidDocumentDetail).toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents/document-1')
})
it('should show AccountSetting modal when setting button is clicked', () => {
// Arrange
render(<DocumentSettings {...defaultProps} />)
// Act
fireEvent.click(screen.getByTestId('setting-btn'))
// Assert
expect(screen.getByTestId('account-setting')).toBeInTheDocument()
})
it('should hide AccountSetting modal when close is clicked', async () => {
// Arrange
render(<DocumentSettings {...defaultProps} />)
fireEvent.click(screen.getByTestId('setting-btn'))
expect(screen.getByTestId('account-setting')).toBeInTheDocument()
// Act
fireEvent.click(screen.getByTestId('close-setting'))
// Assert
expect(screen.queryByTestId('account-setting')).not.toBeInTheDocument()
})
})
// Data source types
describe('Data Source Types', () => {
it('should handle legacy upload_file data source', () => {
// Arrange
mockDocumentDetail = {
name: 'test-document',
data_source_type: 'upload_file',
data_source_info: {
upload_file: { id: 'file-1', name: 'test.pdf' },
},
}
// Act
render(<DocumentSettings {...defaultProps} />)
// Assert
expect(screen.getByTestId('files-count')).toHaveTextContent('1')
})
it('should handle website crawl data source', () => {
// Arrange
mockDocumentDetail = {
name: 'test-website',
data_source_type: 'website_crawl',
data_source_info: {
title: 'Test Page',
source_url: 'https://example.com',
content: 'Page content',
description: 'Page description',
},
}
// Act
render(<DocumentSettings {...defaultProps} />)
// Assert
expect(screen.getByTestId('data-source-type')).toHaveTextContent('website_crawl')
})
it('should handle local file data source', () => {
// Arrange
mockDocumentDetail = {
name: 'local-file',
data_source_type: 'upload_file',
data_source_info: {
related_id: 'file-id',
transfer_method: 'local',
name: 'local-file.pdf',
extension: 'pdf',
},
}
// Act
render(<DocumentSettings {...defaultProps} />)
// Assert
expect(screen.getByTestId('files-count')).toHaveTextContent('1')
})
it('should handle online document (Notion) data source', () => {
// Arrange
mockDocumentDetail = {
name: 'notion-page',
data_source_type: 'notion_import',
data_source_info: {
workspace_id: 'ws-1',
credential_id: 'cred-1',
page: {
page_id: 'page-1',
page_name: 'Test Page',
page_icon: '📄',
type: 'page',
},
},
}
// Act
render(<DocumentSettings {...defaultProps} />)
// Assert
expect(screen.getByTestId('data-source-type')).toHaveTextContent('notion_import')
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle undefined data_source_info', () => {
// Arrange
mockDocumentDetail = {
name: 'test-document',
data_source_type: 'upload_file',
data_source_info: undefined,
}
// Act
render(<DocumentSettings {...defaultProps} />)
// Assert
expect(screen.getByTestId('files-count')).toHaveTextContent('0')
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender } = render(
<DocumentSettings datasetId="dataset-1" documentId="doc-1" />,
)
// Act
rerender(<DocumentSettings datasetId="dataset-2" documentId="doc-2" />)
// Assert
expect(screen.getByTestId('step-two')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,143 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Settings from './index'
// Mock the dataset detail context
let mockRuntimeMode: string | undefined = 'general'
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: { dataset: { runtime_mode: string | undefined } }) => unknown) => {
return selector({ dataset: { runtime_mode: mockRuntimeMode } })
},
}))
// Mock child components
vi.mock('./document-settings', () => ({
default: ({ datasetId, documentId }: { datasetId: string, documentId: string }) => (
<div data-testid="document-settings">
DocumentSettings -
{' '}
{datasetId}
{' '}
-
{' '}
{documentId}
</div>
),
}))
vi.mock('./pipeline-settings', () => ({
default: ({ datasetId, documentId }: { datasetId: string, documentId: string }) => (
<div data-testid="pipeline-settings">
PipelineSettings -
{' '}
{datasetId}
{' '}
-
{' '}
{documentId}
</div>
),
}))
describe('Settings', () => {
beforeEach(() => {
vi.clearAllMocks()
mockRuntimeMode = 'general'
})
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(
<Settings datasetId="dataset-1" documentId="doc-1" />,
)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
})
// Conditional rendering tests
describe('Conditional Rendering', () => {
it('should render DocumentSettings when runtimeMode is general', () => {
// Arrange
mockRuntimeMode = 'general'
// Act
render(<Settings datasetId="dataset-1" documentId="doc-1" />)
// Assert
expect(screen.getByTestId('document-settings')).toBeInTheDocument()
expect(screen.queryByTestId('pipeline-settings')).not.toBeInTheDocument()
})
it('should render PipelineSettings when runtimeMode is not general', () => {
// Arrange
mockRuntimeMode = 'pipeline'
// Act
render(<Settings datasetId="dataset-1" documentId="doc-1" />)
// Assert
expect(screen.getByTestId('pipeline-settings')).toBeInTheDocument()
expect(screen.queryByTestId('document-settings')).not.toBeInTheDocument()
})
})
// Props passing tests
describe('Props', () => {
it('should pass datasetId and documentId to DocumentSettings', () => {
// Arrange
mockRuntimeMode = 'general'
// Act
render(<Settings datasetId="test-dataset" documentId="test-document" />)
// Assert
expect(screen.getByText(/test-dataset/)).toBeInTheDocument()
expect(screen.getByText(/test-document/)).toBeInTheDocument()
})
it('should pass datasetId and documentId to PipelineSettings', () => {
// Arrange
mockRuntimeMode = 'pipeline'
// Act
render(<Settings datasetId="test-dataset" documentId="test-document" />)
// Assert
expect(screen.getByText(/test-dataset/)).toBeInTheDocument()
expect(screen.getByText(/test-document/)).toBeInTheDocument()
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle undefined runtimeMode as non-general', () => {
// Arrange
mockRuntimeMode = undefined
// Act
render(<Settings datasetId="dataset-1" documentId="doc-1" />)
// Assert - undefined !== 'general', so PipelineSettings should render
expect(screen.getByTestId('pipeline-settings')).toBeInTheDocument()
})
it('should maintain structure when rerendered', () => {
// Arrange
mockRuntimeMode = 'general'
const { rerender } = render(
<Settings datasetId="dataset-1" documentId="doc-1" />,
)
// Act
rerender(<Settings datasetId="dataset-2" documentId="doc-2" />)
// Assert
expect(screen.getByText(/dataset-2/)).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,154 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import LeftHeader from './left-header'
// Mock next/navigation
const mockBack = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
back: mockBack,
}),
}))
describe('LeftHeader', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<LeftHeader title="Test Title" />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render the title', () => {
// Arrange & Act
render(<LeftHeader title="My Document Title" />)
// Assert
expect(screen.getByText('My Document Title')).toBeInTheDocument()
})
it('should render the process documents text', () => {
// Arrange & Act
render(<LeftHeader title="Test" />)
// Assert - i18n key format
expect(screen.getByText(/addDocuments\.steps\.processDocuments/i)).toBeInTheDocument()
})
it('should render back button', () => {
// Arrange & Act
render(<LeftHeader title="Test" />)
// Assert
const backButton = screen.getByRole('button')
expect(backButton).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should call router.back when back button is clicked', () => {
// Arrange
render(<LeftHeader title="Test" />)
// Act
const backButton = screen.getByRole('button')
fireEvent.click(backButton)
// Assert
expect(mockBack).toHaveBeenCalledTimes(1)
})
it('should call router.back multiple times on multiple clicks', () => {
// Arrange
render(<LeftHeader title="Test" />)
// Act
const backButton = screen.getByRole('button')
fireEvent.click(backButton)
fireEvent.click(backButton)
// Assert
expect(mockBack).toHaveBeenCalledTimes(2)
})
})
// Props tests
describe('Props', () => {
it('should render different titles', () => {
// Arrange
const { rerender } = render(<LeftHeader title="First Title" />)
expect(screen.getByText('First Title')).toBeInTheDocument()
// Act
rerender(<LeftHeader title="Second Title" />)
// Assert
expect(screen.getByText('Second Title')).toBeInTheDocument()
})
})
// Styling tests
describe('Styling', () => {
it('should have back button with proper styling', () => {
// Arrange & Act
render(<LeftHeader title="Test" />)
// Assert
const backButton = screen.getByRole('button')
expect(backButton).toHaveClass('absolute')
expect(backButton).toHaveClass('rounded-full')
})
it('should render title with gradient background styling', () => {
// Arrange & Act
const { container } = render(<LeftHeader title="Test" />)
// Assert
const titleElement = container.querySelector('.bg-pipeline-add-documents-title-bg')
expect(titleElement).toBeInTheDocument()
})
})
// Accessibility tests
describe('Accessibility', () => {
it('should have aria-label on back button', () => {
// Arrange & Act
render(<LeftHeader title="Test" />)
// Assert
const backButton = screen.getByRole('button')
expect(backButton).toHaveAttribute('aria-label')
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle empty title', () => {
// Arrange & Act
const { container } = render(<LeftHeader title="" />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender } = render(<LeftHeader title="Test" />)
// Act
rerender(<LeftHeader title="Updated Test" />)
// Assert
expect(screen.getByText('Updated Test')).toBeInTheDocument()
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,158 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Actions from './actions'
describe('Actions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<Actions onProcess={vi.fn()} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render save and process button', () => {
// Arrange & Act
render(<Actions onProcess={vi.fn()} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render button with translated text', () => {
// Arrange & Act
render(<Actions onProcess={vi.fn()} />)
// Assert - i18n key format
expect(screen.getByText(/operations\.saveAndProcess/i)).toBeInTheDocument()
})
it('should render with correct container styling', () => {
// Arrange & Act
const { container } = render(<Actions onProcess={vi.fn()} />)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex')
expect(wrapper).toHaveClass('items-center')
expect(wrapper).toHaveClass('justify-end')
})
})
// User Interactions
describe('User Interactions', () => {
it('should call onProcess when button is clicked', () => {
// Arrange
const mockOnProcess = vi.fn()
render(<Actions onProcess={mockOnProcess} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
expect(mockOnProcess).toHaveBeenCalledTimes(1)
})
it('should not call onProcess when button is disabled', () => {
// Arrange
const mockOnProcess = vi.fn()
render(<Actions onProcess={mockOnProcess} runDisabled={true} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
expect(mockOnProcess).not.toHaveBeenCalled()
})
})
// Props tests
describe('Props', () => {
it('should disable button when runDisabled is true', () => {
// Arrange & Act
render(<Actions onProcess={vi.fn()} runDisabled={true} />)
// Assert
expect(screen.getByRole('button')).toBeDisabled()
})
it('should enable button when runDisabled is false', () => {
// Arrange & Act
render(<Actions onProcess={vi.fn()} runDisabled={false} />)
// Assert
expect(screen.getByRole('button')).not.toBeDisabled()
})
it('should enable button when runDisabled is undefined (default)', () => {
// Arrange & Act
render(<Actions onProcess={vi.fn()} />)
// Assert
expect(screen.getByRole('button')).not.toBeDisabled()
})
})
// Button variant tests
describe('Button Styling', () => {
it('should render button with primary variant', () => {
// Arrange & Act
render(<Actions onProcess={vi.fn()} />)
// Assert - primary variant buttons have specific classes
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle multiple rapid clicks', () => {
// Arrange
const mockOnProcess = vi.fn()
render(<Actions onProcess={mockOnProcess} />)
// Act
const button = screen.getByRole('button')
fireEvent.click(button)
fireEvent.click(button)
fireEvent.click(button)
// Assert
expect(mockOnProcess).toHaveBeenCalledTimes(3)
})
it('should maintain structure when rerendered', () => {
// Arrange
const mockOnProcess = vi.fn()
const { rerender } = render(<Actions onProcess={mockOnProcess} />)
// Act
rerender(<Actions onProcess={mockOnProcess} runDisabled={true} />)
// Assert
expect(screen.getByRole('button')).toBeDisabled()
})
it('should handle callback change', () => {
// Arrange
const mockOnProcess1 = vi.fn()
const mockOnProcess2 = vi.fn()
const { rerender } = render(<Actions onProcess={mockOnProcess1} />)
// Act
rerender(<Actions onProcess={mockOnProcess2} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(mockOnProcess1).not.toHaveBeenCalled()
expect(mockOnProcess2).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -0,0 +1,720 @@
import type { DocumentListResponse } from '@/models/datasets'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useProviderContext } from '@/context/provider-context'
import { DataSourceType } from '@/models/datasets'
import { useDocumentList } from '@/service/knowledge/use-document'
import useDocumentsPageState from './hooks/use-documents-page-state'
import Documents from './index'
// Type for mock selector function - use `as MockState` to bypass strict type checking in tests
type MockSelector = Parameters<typeof useDatasetDetailContextWithSelector>[0]
type MockState = Parameters<MockSelector>[0]
// Mock Next.js router
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
replace: vi.fn(),
prefetch: vi.fn(),
}),
usePathname: () => '/datasets/test-dataset-id/documents',
useSearchParams: () => new URLSearchParams(),
}))
// Mock context providers
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: vi.fn((selector: (state: unknown) => unknown) => {
const mockState = {
dataset: {
id: 'test-dataset-id',
name: 'Test Dataset',
embedding_available: true,
data_source_type: DataSourceType.FILE,
runtime_mode: 'rag',
},
}
return selector(mockState as MockState)
}),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(() => ({
plan: { type: 'professional' },
})),
}))
// Mock document service hooks
const mockInvalidDocumentList = vi.fn()
const mockInvalidDocumentDetail = vi.fn()
vi.mock('@/service/knowledge/use-document', () => ({
useDocumentList: vi.fn(() => ({
data: {
data: [
{
id: 'doc-1',
name: 'Document 1',
indexing_status: 'completed',
data_source_type: 'upload_file',
position: 1,
enabled: true,
},
{
id: 'doc-2',
name: 'Document 2',
indexing_status: 'indexing',
data_source_type: 'upload_file',
position: 2,
enabled: true,
},
],
total: 2,
page: 1,
limit: 10,
has_more: false,
} as DocumentListResponse,
isLoading: false,
refetch: vi.fn(),
})),
useInvalidDocumentList: vi.fn(() => mockInvalidDocumentList),
useInvalidDocumentDetail: vi.fn(() => mockInvalidDocumentDetail),
}))
// Mock segment service hooks
vi.mock('@/service/knowledge/use-segment', () => ({
useSegmentListKey: 'segment-list-key',
useChildSegmentListKey: 'child-segment-list-key',
}))
// Mock base service hooks
vi.mock('@/service/use-base', () => ({
useInvalid: vi.fn(() => vi.fn()),
}))
// Mock metadata hook
vi.mock('../metadata/hooks/use-edit-dataset-metadata', () => ({
default: vi.fn(() => ({
isShowEditModal: false,
showEditModal: vi.fn(),
hideEditModal: vi.fn(),
datasetMetaData: [],
handleAddMetaData: vi.fn(),
handleRename: vi.fn(),
handleDeleteMetaData: vi.fn(),
builtInEnabled: false,
setBuiltInEnabled: vi.fn(),
builtInMetaData: [],
})),
}))
// Mock page state hook
const mockSetSelectedIds = vi.fn()
const mockHandleInputChange = vi.fn()
const mockHandleStatusFilterChange = vi.fn()
const mockHandleStatusFilterClear = vi.fn()
const mockHandleSortChange = vi.fn()
const mockHandlePageChange = vi.fn()
const mockHandleLimitChange = vi.fn()
const mockUpdatePollingState = vi.fn()
const mockAdjustPageForTotal = vi.fn()
vi.mock('./hooks/use-documents-page-state', () => ({
default: vi.fn(() => ({
inputValue: '',
searchValue: '',
debouncedSearchValue: '',
handleInputChange: mockHandleInputChange,
statusFilterValue: 'all',
sortValue: '-created_at' as const,
normalizedStatusFilterValue: 'all',
handleStatusFilterChange: mockHandleStatusFilterChange,
handleStatusFilterClear: mockHandleStatusFilterClear,
handleSortChange: mockHandleSortChange,
currPage: 0,
limit: 10,
handlePageChange: mockHandlePageChange,
handleLimitChange: mockHandleLimitChange,
selectedIds: [] as string[],
setSelectedIds: mockSetSelectedIds,
timerCanRun: false,
updatePollingState: mockUpdatePollingState,
adjustPageForTotal: mockAdjustPageForTotal,
})),
}))
// Mock child components - these have deep dependency chains (QueryClient, API hooks, contexts)
// Mocking them allows us to test the Documents component logic in isolation
vi.mock('./components/documents-header', () => ({
default: ({
datasetId,
embeddingAvailable,
onInputChange,
onAddDocument,
onStatusFilterChange,
onStatusFilterClear,
onSortChange,
}: {
datasetId: string
dataSourceType?: string
embeddingAvailable: boolean
isFreePlan: boolean
statusFilterValue: string
sortValue: string
inputValue: string
onInputChange: (value: string) => void
onAddDocument: () => void
onStatusFilterChange: (value: string) => void
onStatusFilterClear: () => void
onSortChange: (value: string) => void
isShowEditMetadataModal: boolean
showEditMetadataModal: () => void
hideEditMetadataModal: () => void
datasetMetaData: unknown[]
builtInMetaData: unknown[]
builtInEnabled: boolean
onAddMetaData: () => void
onRenameMetaData: () => void
onDeleteMetaData: () => void
onBuiltInEnabledChange: () => void
}) => (
<div data-testid="documents-header">
<span data-testid="header-dataset-id">{datasetId}</span>
<span data-testid="header-embedding-available">{String(embeddingAvailable)}</span>
<input
data-testid="search-input"
onChange={e => onInputChange(e.target.value)}
placeholder="Search documents"
/>
<button data-testid="add-document-btn" onClick={onAddDocument}>
Add Document
</button>
<button data-testid="status-filter-btn" onClick={() => onStatusFilterChange('completed')}>
Filter Status
</button>
<button data-testid="clear-filter-btn" onClick={onStatusFilterClear}>
Clear Filter
</button>
<button data-testid="sort-btn" onClick={() => onSortChange('-updated_at')}>
Sort
</button>
</div>
),
}))
vi.mock('./components/empty-element', () => ({
default: ({ canAdd, onClick, type }: {
canAdd: boolean
onClick: () => void
type: 'sync' | 'upload'
}) => (
<div data-testid="empty-element">
<span data-testid="empty-can-add">{String(canAdd)}</span>
<span data-testid="empty-type">{type}</span>
<button data-testid="empty-add-btn" onClick={onClick}>
Add Document
</button>
</div>
),
}))
vi.mock('./components/list', () => ({
default: ({
documents,
datasetId,
onUpdate,
selectedIds,
onSelectedIdChange,
pagination,
}: {
embeddingAvailable: boolean
documents: unknown[]
datasetId: string
onUpdate: () => void
selectedIds: string[]
onSelectedIdChange: (ids: string[]) => void
statusFilterValue: string
remoteSortValue: string
pagination: {
total: number
limit: number
current: number
onChange: (page: number) => void
onLimitChange: (limit: number) => void
}
onManageMetadata: () => void
}) => (
<div data-testid="documents-list">
<span data-testid="list-dataset-id">{datasetId}</span>
<span data-testid="list-documents-count">{documents.length}</span>
<span data-testid="list-selected-count">{selectedIds.length}</span>
<span data-testid="list-total">{pagination.total}</span>
<span data-testid="list-current-page">{pagination.current}</span>
<button data-testid="update-btn" onClick={onUpdate}>
Update
</button>
<button data-testid="select-btn" onClick={() => onSelectedIdChange(['doc-1'])}>
Select Doc
</button>
<button data-testid="page-change-btn" onClick={() => pagination.onChange(1)}>
Next Page
</button>
<button data-testid="limit-change-btn" onClick={() => pagination.onLimitChange(20)}>
Change Limit
</button>
</div>
),
}))
describe('Documents', () => {
const defaultProps = {
datasetId: 'test-dataset-id',
}
beforeEach(() => {
vi.clearAllMocks()
mockPush.mockClear()
// Reset context mocks to default
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector: MockSelector) => {
const mockState = {
dataset: {
id: 'test-dataset-id',
name: 'Test Dataset',
embedding_available: true,
data_source_type: DataSourceType.FILE,
runtime_mode: 'rag',
},
}
return selector(mockState as MockState)
})
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Documents {...defaultProps} />)
expect(screen.getByTestId('documents-header')).toBeInTheDocument()
})
it('should render DocumentsHeader with correct props', () => {
render(<Documents {...defaultProps} />)
expect(screen.getByTestId('header-dataset-id')).toHaveTextContent('test-dataset-id')
expect(screen.getByTestId('header-embedding-available')).toHaveTextContent('true')
})
it('should render document list when documents exist', () => {
render(<Documents {...defaultProps} />)
expect(screen.getByTestId('documents-list')).toBeInTheDocument()
expect(screen.getByTestId('list-documents-count')).toHaveTextContent('2')
})
it('should render loading state when isLoading is true', () => {
vi.mocked(useDocumentList).mockReturnValueOnce({
data: undefined,
isLoading: true,
refetch: vi.fn(),
} as unknown as ReturnType<typeof useDocumentList>)
render(<Documents {...defaultProps} />)
expect(screen.queryByTestId('documents-list')).not.toBeInTheDocument()
})
it('should render empty element when no documents exist', () => {
vi.mocked(useDocumentList).mockReturnValueOnce({
data: { data: [], total: 0, page: 1, limit: 10, has_more: false },
isLoading: false,
refetch: vi.fn(),
} as unknown as ReturnType<typeof useDocumentList>)
render(<Documents {...defaultProps} />)
expect(screen.getByTestId('empty-element')).toBeInTheDocument()
expect(screen.getByTestId('empty-can-add')).toHaveTextContent('true')
expect(screen.getByTestId('empty-type')).toHaveTextContent('upload')
})
it('should render sync type empty element for Notion data source', () => {
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector: MockSelector) => {
const mockState = {
dataset: {
id: 'test-dataset-id',
name: 'Test Dataset',
embedding_available: true,
data_source_type: DataSourceType.NOTION,
runtime_mode: 'rag',
},
}
return selector(mockState as MockState)
})
vi.mocked(useDocumentList).mockReturnValueOnce({
data: { data: [], total: 0, page: 1, limit: 10, has_more: false },
isLoading: false,
refetch: vi.fn(),
} as unknown as ReturnType<typeof useDocumentList>)
render(<Documents {...defaultProps} />)
expect(screen.getByTestId('empty-type')).toHaveTextContent('sync')
})
})
describe('Props', () => {
it('should pass datasetId to child components', () => {
render(<Documents {...defaultProps} />)
expect(screen.getByTestId('header-dataset-id')).toHaveTextContent('test-dataset-id')
})
it('should handle different datasetId', () => {
render(<Documents datasetId="different-dataset-id" />)
expect(screen.getByTestId('header-dataset-id')).toHaveTextContent('different-dataset-id')
})
})
describe('User Interactions', () => {
it('should call handleInputChange when search input changes', async () => {
render(<Documents {...defaultProps} />)
const searchInput = screen.getByTestId('search-input')
fireEvent.change(searchInput, { target: { value: 'test' } })
expect(mockHandleInputChange).toHaveBeenCalledWith('test')
})
it('should call handleStatusFilterChange when filter button is clicked', () => {
render(<Documents {...defaultProps} />)
screen.getByTestId('status-filter-btn').click()
expect(mockHandleStatusFilterChange).toHaveBeenCalledWith('completed')
})
it('should call handleStatusFilterClear when clear button is clicked', () => {
render(<Documents {...defaultProps} />)
screen.getByTestId('clear-filter-btn').click()
expect(mockHandleStatusFilterClear).toHaveBeenCalled()
})
it('should call handleSortChange when sort button is clicked', () => {
render(<Documents {...defaultProps} />)
screen.getByTestId('sort-btn').click()
expect(mockHandleSortChange).toHaveBeenCalledWith('-updated_at')
})
it('should call setSelectedIds when document is selected', () => {
render(<Documents {...defaultProps} />)
screen.getByTestId('select-btn').click()
expect(mockSetSelectedIds).toHaveBeenCalledWith(['doc-1'])
})
it('should call handlePageChange when page changes', () => {
render(<Documents {...defaultProps} />)
screen.getByTestId('page-change-btn').click()
expect(mockHandlePageChange).toHaveBeenCalledWith(1)
})
it('should call handleLimitChange when limit changes', () => {
render(<Documents {...defaultProps} />)
screen.getByTestId('limit-change-btn').click()
expect(mockHandleLimitChange).toHaveBeenCalledWith(20)
})
})
describe('Router Navigation', () => {
it('should navigate to create page when add document is clicked', () => {
render(<Documents {...defaultProps} />)
screen.getByTestId('add-document-btn').click()
expect(mockPush).toHaveBeenCalledWith('/datasets/test-dataset-id/documents/create')
})
it('should navigate to pipeline create page when dataset is rag_pipeline mode', () => {
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector: MockSelector) => {
const mockState = {
dataset: {
id: 'test-dataset-id',
name: 'Test Dataset',
embedding_available: true,
data_source_type: DataSourceType.FILE,
runtime_mode: 'rag_pipeline',
},
}
return selector(mockState as MockState)
})
render(<Documents {...defaultProps} />)
screen.getByTestId('add-document-btn').click()
expect(mockPush).toHaveBeenCalledWith('/datasets/test-dataset-id/documents/create-from-pipeline')
})
it('should navigate from empty element add button', () => {
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector: MockSelector) => {
const mockState = {
dataset: {
id: 'test-dataset-id',
name: 'Test Dataset',
embedding_available: true,
data_source_type: DataSourceType.FILE,
runtime_mode: 'rag',
},
}
return selector(mockState as MockState)
})
vi.mocked(useDocumentList).mockReturnValueOnce({
data: { data: [], total: 0, page: 1, limit: 10, has_more: false },
isLoading: false,
refetch: vi.fn(),
} as unknown as ReturnType<typeof useDocumentList>)
render(<Documents {...defaultProps} />)
screen.getByTestId('empty-add-btn').click()
expect(mockPush).toHaveBeenCalledWith('/datasets/test-dataset-id/documents/create')
})
})
describe('Side Effects and Cleanup', () => {
it('should call updatePollingState when documents response changes', () => {
render(<Documents {...defaultProps} />)
expect(mockUpdatePollingState).toHaveBeenCalled()
})
it('should call adjustPageForTotal when documents response changes', () => {
render(<Documents {...defaultProps} />)
expect(mockAdjustPageForTotal).toHaveBeenCalled()
})
})
describe('Callback Stability and Memoization', () => {
it('should call handleUpdate with invalidation functions', async () => {
render(<Documents {...defaultProps} />)
screen.getByTestId('update-btn').click()
expect(mockInvalidDocumentList).toHaveBeenCalled()
expect(mockInvalidDocumentDetail).toHaveBeenCalled()
})
it('should handle update with delayed chunk invalidation', async () => {
vi.useFakeTimers()
render(<Documents {...defaultProps} />)
screen.getByTestId('update-btn').click()
expect(mockInvalidDocumentList).toHaveBeenCalled()
expect(mockInvalidDocumentDetail).toHaveBeenCalled()
await act(async () => {
vi.advanceTimersByTime(5000)
})
vi.useRealTimers()
})
})
describe('Edge Cases and Error Handling', () => {
it('should handle undefined dataset gracefully', () => {
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector: MockSelector) => {
const mockState = { dataset: undefined }
return selector(mockState as MockState)
})
render(<Documents {...defaultProps} />)
expect(screen.getByTestId('documents-header')).toBeInTheDocument()
})
it('should handle empty documents array', () => {
vi.mocked(useDocumentList).mockReturnValueOnce({
data: { data: [], total: 0, page: 1, limit: 10, has_more: false },
isLoading: false,
refetch: vi.fn(),
} as unknown as ReturnType<typeof useDocumentList>)
render(<Documents {...defaultProps} />)
expect(screen.getByTestId('empty-element')).toBeInTheDocument()
})
it('should handle undefined documentsRes', () => {
vi.mocked(useDocumentList).mockReturnValueOnce({
data: undefined,
isLoading: false,
refetch: vi.fn(),
} as unknown as ReturnType<typeof useDocumentList>)
render(<Documents {...defaultProps} />)
expect(screen.getByTestId('empty-element')).toBeInTheDocument()
})
it('should handle embedding not available', () => {
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector: MockSelector) => {
const mockState = {
dataset: {
id: 'test-dataset-id',
name: 'Test Dataset',
embedding_available: false,
data_source_type: DataSourceType.FILE,
runtime_mode: 'rag',
},
}
return selector(mockState as MockState)
})
render(<Documents {...defaultProps} />)
expect(screen.getByTestId('header-embedding-available')).toHaveTextContent('false')
})
it('should handle free plan user', () => {
vi.mocked(useProviderContext).mockReturnValueOnce({
plan: { type: 'sandbox' },
} as ReturnType<typeof useProviderContext>)
render(<Documents {...defaultProps} />)
expect(screen.getByTestId('documents-header')).toBeInTheDocument()
})
})
describe('Polling State', () => {
it('should enable polling when documents are indexing', () => {
vi.mocked(useDocumentsPageState).mockReturnValueOnce({
inputValue: '',
searchValue: '',
debouncedSearchValue: '',
handleInputChange: mockHandleInputChange,
statusFilterValue: 'all',
sortValue: '-created_at' as const,
normalizedStatusFilterValue: 'all',
handleStatusFilterChange: mockHandleStatusFilterChange,
handleStatusFilterClear: mockHandleStatusFilterClear,
handleSortChange: mockHandleSortChange,
currPage: 0,
limit: 10,
handlePageChange: mockHandlePageChange,
handleLimitChange: mockHandleLimitChange,
selectedIds: [] as string[],
setSelectedIds: mockSetSelectedIds,
timerCanRun: true,
updatePollingState: mockUpdatePollingState,
adjustPageForTotal: mockAdjustPageForTotal,
})
render(<Documents {...defaultProps} />)
expect(screen.getByTestId('documents-list')).toBeInTheDocument()
})
})
describe('Pagination', () => {
it('should display correct total in list', () => {
render(<Documents {...defaultProps} />)
expect(screen.getByTestId('list-total')).toHaveTextContent('2')
})
it('should display correct current page', () => {
render(<Documents {...defaultProps} />)
expect(screen.getByTestId('list-current-page')).toHaveTextContent('0')
})
it('should handle page changes', () => {
vi.mocked(useDocumentsPageState).mockReturnValueOnce({
inputValue: '',
searchValue: '',
debouncedSearchValue: '',
handleInputChange: mockHandleInputChange,
statusFilterValue: 'all',
sortValue: '-created_at' as const,
normalizedStatusFilterValue: 'all',
handleStatusFilterChange: mockHandleStatusFilterChange,
handleStatusFilterClear: mockHandleStatusFilterClear,
handleSortChange: mockHandleSortChange,
currPage: 2,
limit: 10,
handlePageChange: mockHandlePageChange,
handleLimitChange: mockHandleLimitChange,
selectedIds: [] as string[],
setSelectedIds: mockSetSelectedIds,
timerCanRun: false,
updatePollingState: mockUpdatePollingState,
adjustPageForTotal: mockAdjustPageForTotal,
})
render(<Documents {...defaultProps} />)
expect(screen.getByTestId('list-current-page')).toHaveTextContent('2')
})
})
describe('Selection State', () => {
it('should display selected count', () => {
vi.mocked(useDocumentsPageState).mockReturnValueOnce({
inputValue: '',
searchValue: '',
debouncedSearchValue: '',
handleInputChange: mockHandleInputChange,
statusFilterValue: 'all',
sortValue: '-created_at' as const,
normalizedStatusFilterValue: 'all',
handleStatusFilterChange: mockHandleStatusFilterChange,
handleStatusFilterClear: mockHandleStatusFilterClear,
handleSortChange: mockHandleSortChange,
currPage: 0,
limit: 10,
handlePageChange: mockHandlePageChange,
handleLimitChange: mockHandleLimitChange,
selectedIds: ['doc-1', 'doc-2'],
setSelectedIds: mockSetSelectedIds,
timerCanRun: false,
updatePollingState: mockUpdatePollingState,
adjustPageForTotal: mockAdjustPageForTotal,
})
render(<Documents {...defaultProps} />)
expect(screen.getByTestId('list-selected-count')).toHaveTextContent('2')
})
})
describe('Filter and Sort State', () => {
it('should pass filter value to list', () => {
vi.mocked(useDocumentsPageState).mockReturnValueOnce({
inputValue: 'test search',
searchValue: 'test search',
debouncedSearchValue: 'test search',
handleInputChange: mockHandleInputChange,
statusFilterValue: 'completed',
sortValue: '-created_at' as const,
normalizedStatusFilterValue: 'completed',
handleStatusFilterChange: mockHandleStatusFilterChange,
handleStatusFilterClear: mockHandleStatusFilterClear,
handleSortChange: mockHandleSortChange,
currPage: 0,
limit: 10,
handlePageChange: mockHandlePageChange,
handleLimitChange: mockHandleLimitChange,
selectedIds: [] as string[],
setSelectedIds: mockSetSelectedIds,
timerCanRun: false,
updatePollingState: mockUpdatePollingState,
adjustPageForTotal: mockAdjustPageForTotal,
})
render(<Documents {...defaultProps} />)
expect(screen.getByTestId('documents-list')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,792 @@
import type { DataSet } from '@/models/datasets'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ============================================================================
// Component Imports (after mocks)
// ============================================================================
import Card from './card'
import ApiAccess from './index'
// ============================================================================
// Mock Setup
// ============================================================================
// Mock next/navigation
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
}),
usePathname: () => '/test',
useSearchParams: () => new URLSearchParams(),
}))
// Mock next/link
vi.mock('next/link', () => ({
default: ({ children, href, ...props }: { children: React.ReactNode, href: string, [key: string]: unknown }) => (
<a href={href} {...props}>{children}</a>
),
}))
// Dataset context mock data
const mockDataset: Partial<DataSet> = {
id: 'dataset-123',
name: 'Test Dataset',
enable_api: true,
}
// Mock use-context-selector
vi.mock('use-context-selector', () => ({
useContext: vi.fn(() => ({ dataset: mockDataset })),
useContextSelector: vi.fn((_, selector) => selector({ dataset: mockDataset })),
createContext: vi.fn(() => ({})),
}))
// Mock dataset detail context
const mockMutateDatasetRes = vi.fn()
vi.mock('@/context/dataset-detail', () => ({
default: {},
useDatasetDetailContext: vi.fn(() => ({
dataset: mockDataset,
mutateDatasetRes: mockMutateDatasetRes,
})),
useDatasetDetailContextWithSelector: vi.fn((selector: (v: { dataset?: typeof mockDataset, mutateDatasetRes?: () => void }) => unknown) =>
selector({ dataset: mockDataset as DataSet, mutateDatasetRes: mockMutateDatasetRes }),
),
}))
// Mock app context for workspace permissions
let mockIsCurrentWorkspaceManager = true
vi.mock('@/context/app-context', () => ({
useSelector: vi.fn((selector: (state: { isCurrentWorkspaceManager: boolean }) => unknown) =>
selector({ isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager }),
),
}))
// Mock service hooks
const mockEnableDatasetServiceApi = vi.fn(() => Promise.resolve({ result: 'success' }))
const mockDisableDatasetServiceApi = vi.fn(() => Promise.resolve({ result: 'success' }))
vi.mock('@/service/knowledge/use-dataset', () => ({
useDatasetApiBaseUrl: vi.fn(() => ({
data: { api_base_url: 'https://api.example.com' },
isLoading: false,
})),
useEnableDatasetServiceApi: vi.fn(() => ({
mutateAsync: mockEnableDatasetServiceApi,
isPending: false,
})),
useDisableDatasetServiceApi: vi.fn(() => ({
mutateAsync: mockDisableDatasetServiceApi,
isPending: false,
})),
}))
// Mock API access URL hook
vi.mock('@/hooks/use-api-access-url', () => ({
useDatasetApiAccessUrl: vi.fn(() => 'https://docs.dify.ai/api-reference/datasets'),
}))
// ============================================================================
// ApiAccess Component Tests
// ============================================================================
describe('ApiAccess', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<ApiAccess expand={true} apiEnabled={true} />)
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
})
it('should render API title when expanded', () => {
render(<ApiAccess expand={true} apiEnabled={true} />)
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
})
it('should not render API title when collapsed', () => {
render(<ApiAccess expand={false} apiEnabled={true} />)
expect(screen.queryByText(/appMenus\.apiAccess/i)).not.toBeInTheDocument()
})
it('should render ApiAggregate icon', () => {
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})
it('should render Indicator component', () => {
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
const indicatorElement = container.querySelector('.relative.flex.h-8')
expect(indicatorElement).toBeInTheDocument()
})
it('should render with proper container padding', () => {
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('p-3', 'pt-2')
})
})
// --------------------------------------------------------------------------
// Props Variations Tests
// --------------------------------------------------------------------------
describe('Props Variations', () => {
it('should apply compressed layout when expand is false', () => {
const { container } = render(<ApiAccess expand={false} apiEnabled={true} />)
const triggerContainer = container.querySelector('[class*="w-8"]')
expect(triggerContainer).toBeInTheDocument()
})
it('should apply full width when expand is true', () => {
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
const trigger = container.querySelector('.w-full')
expect(trigger).toBeInTheDocument()
})
it('should pass apiEnabled=true to Indicator with green color', () => {
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
// Indicator uses color prop - test the visual presence
const indicatorContainer = container.querySelector('.relative.flex.h-8')
expect(indicatorContainer).toBeInTheDocument()
})
it('should pass apiEnabled=false to Indicator with yellow color', () => {
const { container } = render(<ApiAccess expand={false} apiEnabled={false} />)
const indicatorContainer = container.querySelector('.relative.flex.h-8')
expect(indicatorContainer).toBeInTheDocument()
})
it('should position Indicator absolutely when collapsed', () => {
const { container } = render(<ApiAccess expand={false} apiEnabled={true} />)
// When collapsed, Indicator has 'absolute -right-px -top-px' classes
const triggerDiv = container.querySelector('[class*="w-8"][class*="justify-center"]')
expect(triggerDiv).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should toggle popup open state on click', async () => {
const user = userEvent.setup()
render(<ApiAccess expand={true} apiEnabled={true} />)
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
expect(trigger).toBeInTheDocument()
if (trigger)
await user.click(trigger)
// After click, the popup should toggle (Card should be rendered via portal)
})
it('should apply hover styles on trigger', () => {
render(<ApiAccess expand={true} apiEnabled={true} />)
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('div[class*="cursor-pointer"]')
expect(trigger).toHaveClass('cursor-pointer')
})
it('should toggle open state from false to true on first click', async () => {
const user = userEvent.setup()
render(<ApiAccess expand={true} apiEnabled={true} />)
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
// The handleToggle function should flip open from false to true
})
it('should toggle open state back to false on second click', async () => {
const user = userEvent.setup()
render(<ApiAccess expand={true} apiEnabled={true} />)
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
if (trigger) {
await user.click(trigger) // open
await user.click(trigger) // close
}
// The handleToggle function should flip open from true to false
})
it('should apply open state styling when popup is open', async () => {
const user = userEvent.setup()
render(<ApiAccess expand={true} apiEnabled={true} />)
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
// When open, the trigger should have bg-state-base-hover class
})
})
// --------------------------------------------------------------------------
// Portal and Card Integration Tests
// --------------------------------------------------------------------------
describe('Portal and Card Integration', () => {
it('should render Card component inside portal when open', async () => {
const user = userEvent.setup()
render(<ApiAccess expand={true} apiEnabled={true} />)
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
// Wait for portal content to appear
await waitFor(() => {
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
})
})
it('should pass apiEnabled prop to Card component', async () => {
const user = userEvent.setup()
render(<ApiAccess expand={true} apiEnabled={false} />)
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
await waitFor(() => {
expect(screen.getByText(/serviceApi\.disabled/i)).toBeInTheDocument()
})
})
it('should use correct portal placement configuration', () => {
render(<ApiAccess expand={true} apiEnabled={true} />)
// PortalToFollowElem is configured with placement="top-start"
// The component should render without errors
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
})
it('should use correct portal offset configuration', () => {
render(<ApiAccess expand={true} apiEnabled={true} />)
// PortalToFollowElem is configured with offset={{ mainAxis: 4, crossAxis: -4 }}
// The component should render without errors
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle rapid toggle clicks gracefully', async () => {
const user = userEvent.setup()
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
// Use a more specific selector to find the trigger in the main component
const trigger = container.querySelector('.p-3 [class*="cursor-pointer"]')
if (trigger) {
// Rapid clicks
await user.click(trigger)
await user.click(trigger)
await user.click(trigger)
}
// Component should handle state changes without errors - use getAllByText since Card may be open
const elements = screen.getAllByText(/appMenus\.apiAccess/i)
expect(elements.length).toBeGreaterThan(0)
})
it('should render correctly when both expand and apiEnabled are false', () => {
render(<ApiAccess expand={false} apiEnabled={false} />)
// Should render without title but with indicator
expect(screen.queryByText(/appMenus\.apiAccess/i)).not.toBeInTheDocument()
})
it('should maintain state across prop changes', () => {
const { rerender } = render(<ApiAccess expand={true} apiEnabled={true} />)
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
rerender(<ApiAccess expand={true} apiEnabled={false} />)
// Component should still render after prop change
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<ApiAccess expand={true} apiEnabled={true} />)
rerender(<ApiAccess expand={true} apiEnabled={true} />)
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
})
it('should not re-render unnecessarily with same props', () => {
const { rerender } = render(<ApiAccess expand={true} apiEnabled={true} />)
rerender(<ApiAccess expand={true} apiEnabled={true} />)
rerender(<ApiAccess expand={true} apiEnabled={true} />)
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
})
})
})
// ============================================================================
// Card Component Tests
// ============================================================================
describe('Card (api-access)', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCurrentWorkspaceManager = true
mockEnableDatasetServiceApi.mockResolvedValue({ result: 'success' })
mockDisableDatasetServiceApi.mockResolvedValue({ result: 'success' })
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Card apiEnabled={true} />)
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
})
it('should display enabled status when API is enabled', () => {
render(<Card apiEnabled={true} />)
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
})
it('should display disabled status when API is disabled', () => {
render(<Card apiEnabled={false} />)
expect(screen.getByText(/serviceApi\.disabled/i)).toBeInTheDocument()
})
it('should render switch component', () => {
render(<Card apiEnabled={true} />)
expect(screen.getByRole('switch')).toBeInTheDocument()
})
it('should render API Reference link', () => {
render(<Card apiEnabled={true} />)
expect(screen.getByText(/overview\.apiInfo\.doc/i)).toBeInTheDocument()
})
it('should render Indicator component', () => {
const { container } = render(<Card apiEnabled={true} />)
// Indicator is rendered - verify card structure
const cardContainer = container.querySelector('.w-\\[208px\\]')
expect(cardContainer).toBeInTheDocument()
})
it('should render description tip text', () => {
render(<Card apiEnabled={true} />)
expect(screen.getByText(/appMenus\.apiAccessTip/i)).toBeInTheDocument()
})
it('should apply success text color when enabled', () => {
render(<Card apiEnabled={true} />)
const statusText = screen.getByText(/serviceApi\.enabled/i)
expect(statusText).toHaveClass('text-text-success')
})
it('should apply warning text color when disabled', () => {
render(<Card apiEnabled={false} />)
const statusText = screen.getByText(/serviceApi\.disabled/i)
expect(statusText).toHaveClass('text-text-warning')
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call enableDatasetServiceApi when switch is toggled on', async () => {
const user = userEvent.setup()
render(<Card apiEnabled={false} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
expect(mockEnableDatasetServiceApi).toHaveBeenCalledWith('dataset-123')
})
})
it('should call disableDatasetServiceApi when switch is toggled off', async () => {
const user = userEvent.setup()
render(<Card apiEnabled={true} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
expect(mockDisableDatasetServiceApi).toHaveBeenCalledWith('dataset-123')
})
})
it('should call mutateDatasetRes after successful API enable', async () => {
const user = userEvent.setup()
render(<Card apiEnabled={false} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
expect(mockMutateDatasetRes).toHaveBeenCalled()
})
})
it('should call mutateDatasetRes after successful API disable', async () => {
const user = userEvent.setup()
render(<Card apiEnabled={true} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
expect(mockMutateDatasetRes).toHaveBeenCalled()
})
})
it('should not call mutateDatasetRes on API enable failure', async () => {
mockEnableDatasetServiceApi.mockResolvedValueOnce({ result: 'fail' })
const user = userEvent.setup()
render(<Card apiEnabled={false} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
expect(mockEnableDatasetServiceApi).toHaveBeenCalled()
})
expect(mockMutateDatasetRes).not.toHaveBeenCalled()
})
it('should not call mutateDatasetRes on API disable failure', async () => {
mockDisableDatasetServiceApi.mockResolvedValueOnce({ result: 'fail' })
const user = userEvent.setup()
render(<Card apiEnabled={true} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
expect(mockDisableDatasetServiceApi).toHaveBeenCalled()
})
expect(mockMutateDatasetRes).not.toHaveBeenCalled()
})
it('should have correct href for API Reference link', () => {
render(<Card apiEnabled={true} />)
const apiRefLink = screen.getByText(/overview\.apiInfo\.doc/i).closest('a')
expect(apiRefLink).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets')
})
it('should open API Reference in new tab', () => {
render(<Card apiEnabled={true} />)
const apiRefLink = screen.getByText(/overview\.apiInfo\.doc/i).closest('a')
expect(apiRefLink).toHaveAttribute('target', '_blank')
expect(apiRefLink).toHaveAttribute('rel', 'noopener noreferrer')
})
})
// --------------------------------------------------------------------------
// Permission Handling Tests
// --------------------------------------------------------------------------
describe('Permission Handling', () => {
it('should disable switch when user is not workspace manager', () => {
mockIsCurrentWorkspaceManager = false
render(<Card apiEnabled={true} />)
const switchButton = screen.getByRole('switch')
expect(switchButton).toHaveClass('!cursor-not-allowed')
expect(switchButton).toHaveClass('!opacity-50')
})
it('should enable switch when user is workspace manager', () => {
mockIsCurrentWorkspaceManager = true
render(<Card apiEnabled={true} />)
const switchButton = screen.getByRole('switch')
expect(switchButton).not.toHaveClass('!cursor-not-allowed')
expect(switchButton).not.toHaveClass('!opacity-50')
})
it('should not trigger API call when switch is disabled and clicked', async () => {
mockIsCurrentWorkspaceManager = false
const user = userEvent.setup()
render(<Card apiEnabled={false} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
// API should not be called when disabled
expect(mockEnableDatasetServiceApi).not.toHaveBeenCalled()
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle empty datasetId gracefully', async () => {
const { useDatasetDetailContextWithSelector } = await import('@/context/dataset-detail')
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector) => {
return selector({
dataset: { ...mockDataset, id: '' } as DataSet,
mutateDatasetRes: mockMutateDatasetRes,
})
})
const user = userEvent.setup()
render(<Card apiEnabled={false} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
expect(mockEnableDatasetServiceApi).toHaveBeenCalledWith('')
})
// Reset mock
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation(selector =>
selector({ dataset: mockDataset as DataSet, mutateDatasetRes: mockMutateDatasetRes }),
)
})
it('should handle undefined datasetId gracefully when enabling API', async () => {
const { useDatasetDetailContextWithSelector } = await import('@/context/dataset-detail')
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector) => {
const partialDataset = { ...mockDataset } as Partial<DataSet>
delete partialDataset.id
return selector({
dataset: partialDataset as DataSet,
mutateDatasetRes: mockMutateDatasetRes,
})
})
const user = userEvent.setup()
render(<Card apiEnabled={false} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
// Should use fallback empty string
expect(mockEnableDatasetServiceApi).toHaveBeenCalledWith('')
})
// Reset mock
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation(selector =>
selector({ dataset: mockDataset as DataSet, mutateDatasetRes: mockMutateDatasetRes }),
)
})
it('should handle undefined datasetId gracefully when disabling API', async () => {
const { useDatasetDetailContextWithSelector } = await import('@/context/dataset-detail')
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector) => {
const partialDataset = { ...mockDataset } as Partial<DataSet>
delete partialDataset.id
return selector({
dataset: partialDataset as DataSet,
mutateDatasetRes: mockMutateDatasetRes,
})
})
const user = userEvent.setup()
render(<Card apiEnabled={true} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
// Should use fallback empty string for disableDatasetServiceApi
expect(mockDisableDatasetServiceApi).toHaveBeenCalledWith('')
})
// Reset mock
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation(selector =>
selector({ dataset: mockDataset as DataSet, mutateDatasetRes: mockMutateDatasetRes }),
)
})
it('should handle undefined mutateDatasetRes gracefully', async () => {
const { useDatasetDetailContextWithSelector } = await import('@/context/dataset-detail')
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector) => {
return selector({
dataset: mockDataset as DataSet,
mutateDatasetRes: undefined,
})
})
const user = userEvent.setup()
render(<Card apiEnabled={false} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
expect(mockEnableDatasetServiceApi).toHaveBeenCalled()
})
// Should not throw error when mutateDatasetRes is undefined
// Reset mock
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation(selector =>
selector({ dataset: mockDataset as DataSet, mutateDatasetRes: mockMutateDatasetRes }),
)
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Card apiEnabled={true} />)
rerender(<Card apiEnabled={true} />)
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
})
it('should use useCallback for onToggle handler', () => {
const { rerender } = render(<Card apiEnabled={true} />)
rerender(<Card apiEnabled={true} />)
// Component should render without issues with memoized callbacks
expect(screen.getByRole('switch')).toBeInTheDocument()
})
it('should update when apiEnabled prop changes', () => {
const { rerender } = render(<Card apiEnabled={true} />)
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
rerender(<Card apiEnabled={false} />)
expect(screen.getByText(/serviceApi\.disabled/i)).toBeInTheDocument()
})
})
})
// ============================================================================
// Integration Tests
// ============================================================================
describe('ApiAccess Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCurrentWorkspaceManager = true
mockEnableDatasetServiceApi.mockResolvedValue({ result: 'success' })
mockDisableDatasetServiceApi.mockResolvedValue({ result: 'success' })
})
it('should open Card popup and toggle API status', async () => {
const user = userEvent.setup()
render(<ApiAccess expand={true} apiEnabled={false} />)
// Open popup
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
// Wait for Card to appear
await waitFor(() => {
expect(screen.getByText(/serviceApi\.disabled/i)).toBeInTheDocument()
})
// Toggle API on
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
expect(mockEnableDatasetServiceApi).toHaveBeenCalledWith('dataset-123')
})
})
it('should complete full workflow: open -> view status -> toggle -> verify callback', async () => {
const user = userEvent.setup()
render(<ApiAccess expand={true} apiEnabled={true} />)
// Open popup
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
// Verify enabled status is shown
await waitFor(() => {
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
})
// Toggle API off
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
// Verify API call and callback
await waitFor(() => {
expect(mockDisableDatasetServiceApi).toHaveBeenCalledWith('dataset-123')
expect(mockMutateDatasetRes).toHaveBeenCalled()
})
})
it('should navigate to API Reference from Card', async () => {
const user = userEvent.setup()
render(<ApiAccess expand={true} apiEnabled={true} />)
// Open popup
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
// Wait for Card to appear
await waitFor(() => {
expect(screen.getByText(/overview\.apiInfo\.doc/i)).toBeInTheDocument()
})
// Verify link
const apiRefLink = screen.getByText(/overview\.apiInfo\.doc/i).closest('a')
expect(apiRefLink).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets')
})
})

View File

@ -0,0 +1,772 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ============================================================================
// Component Imports (after mocks)
// ============================================================================
import Card from './card'
import ServiceApi from './index'
// ============================================================================
// Mock Setup
// ============================================================================
// Mock next/navigation
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
}),
usePathname: () => '/test',
useSearchParams: () => new URLSearchParams(),
}))
// Mock next/link
vi.mock('next/link', () => ({
default: ({ children, href, ...props }: { children: React.ReactNode, href: string, [key: string]: unknown }) => (
<a href={href} {...props}>{children}</a>
),
}))
// Mock API access URL hook
vi.mock('@/hooks/use-api-access-url', () => ({
useDatasetApiAccessUrl: vi.fn(() => 'https://docs.dify.ai/api-reference/datasets'),
}))
// Mock SecretKeyModal to avoid complex modal rendering
vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({
default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => (
isShow
? (
<div data-testid="secret-key-modal">
<button onClick={onClose} data-testid="close-modal-btn">Close</button>
</div>
)
: null
),
}))
// ============================================================================
// ServiceApi Component Tests
// ============================================================================
describe('ServiceApi', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<ServiceApi apiBaseUrl="https://api.example.com" />)
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
})
it('should render service API title', () => {
render(<ServiceApi apiBaseUrl="https://api.example.com" />)
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
})
it('should render Indicator component', () => {
const { container } = render(<ServiceApi apiBaseUrl="https://api.example.com" />)
const triggerContainer = container.querySelector('.relative.flex.h-8')
expect(triggerContainer).toBeInTheDocument()
})
it('should render trigger button with proper styling', () => {
const { container } = render(<ServiceApi apiBaseUrl="https://api.example.com" />)
const trigger = container.querySelector('.cursor-pointer')
expect(trigger).toBeInTheDocument()
})
it('should render with border and background styles', () => {
const { container } = render(<ServiceApi apiBaseUrl="https://api.example.com" />)
const trigger = container.querySelector('[class*="border-components-button-secondary-border-hover"]')
expect(trigger).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Props Variations Tests
// --------------------------------------------------------------------------
describe('Props Variations', () => {
it('should show green Indicator when apiBaseUrl is provided', () => {
const { container } = render(<ServiceApi apiBaseUrl="https://api.example.com" />)
// When apiBaseUrl is truthy, Indicator color is green
const triggerContainer = container.querySelector('.relative.flex.h-8')
expect(triggerContainer).toBeInTheDocument()
})
it('should show yellow Indicator when apiBaseUrl is empty', () => {
const { container } = render(<ServiceApi apiBaseUrl="" />)
// When apiBaseUrl is falsy, Indicator color is yellow
const triggerContainer = container.querySelector('.relative.flex.h-8')
expect(triggerContainer).toBeInTheDocument()
})
it('should handle long apiBaseUrl without breaking layout', () => {
const longUrl = 'https://api.example.com/v1/very/long/path/to/endpoint/that/might/break/layout'
render(<ServiceApi apiBaseUrl={longUrl} />)
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
})
it('should handle special characters in apiBaseUrl', () => {
const specialUrl = 'https://api.example.com?query=test&param=value#anchor'
render(<ServiceApi apiBaseUrl={specialUrl} />)
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should toggle popup open state on click', async () => {
const user = userEvent.setup()
render(<ServiceApi apiBaseUrl="https://api.example.com" />)
const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]')
expect(trigger).toBeInTheDocument()
if (trigger)
await user.click(trigger)
// After click, the Card should be rendered
})
it('should apply hover styles on trigger', () => {
render(<ServiceApi apiBaseUrl="https://api.example.com" />)
const trigger = screen.getByText(/serviceApi\.title/i).closest('div[class*="cursor-pointer"]')
expect(trigger).toHaveClass('cursor-pointer')
})
it('should toggle open state from false to true on first click', async () => {
const user = userEvent.setup()
render(<ServiceApi apiBaseUrl="https://api.example.com" />)
const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
// Card should be visible after clicking
await waitFor(() => {
expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
})
})
it('should toggle open state back to false on second click', async () => {
const user = userEvent.setup()
render(<ServiceApi apiBaseUrl="https://api.example.com" />)
const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]')
if (trigger) {
await user.click(trigger) // open
await user.click(trigger) // close
}
// Component should handle the toggle without errors
})
it('should apply open state styling when popup is open', async () => {
const user = userEvent.setup()
render(<ServiceApi apiBaseUrl="https://api.example.com" />)
const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
// When open, the trigger should have hover background class
})
})
// --------------------------------------------------------------------------
// Portal and Card Integration Tests
// --------------------------------------------------------------------------
describe('Portal and Card Integration', () => {
it('should render Card component inside portal when open', async () => {
const user = userEvent.setup()
render(<ServiceApi apiBaseUrl="https://api.example.com" />)
const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
// Wait for portal content to appear
await waitFor(() => {
expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
})
})
it('should pass apiBaseUrl prop to Card component', async () => {
const user = userEvent.setup()
const testUrl = 'https://test-api.example.com'
render(<ServiceApi apiBaseUrl={testUrl} />)
const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
await waitFor(() => {
expect(screen.getByText(testUrl)).toBeInTheDocument()
})
})
it('should use correct portal placement configuration', () => {
render(<ServiceApi apiBaseUrl="https://api.example.com" />)
// PortalToFollowElem is configured with placement="top-start"
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
})
it('should use correct portal offset configuration', () => {
render(<ServiceApi apiBaseUrl="https://api.example.com" />)
// PortalToFollowElem is configured with offset={{ mainAxis: 4, crossAxis: -4 }}
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle rapid toggle clicks gracefully', async () => {
const user = userEvent.setup()
render(<ServiceApi apiBaseUrl="https://api.example.com" />)
const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]')
if (trigger) {
// Rapid clicks
await user.click(trigger)
await user.click(trigger)
await user.click(trigger)
}
// Component should handle state changes without errors
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
})
it('should render correctly with empty apiBaseUrl', () => {
render(<ServiceApi apiBaseUrl="" />)
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
})
it('should maintain state across prop changes', () => {
const { rerender } = render(<ServiceApi apiBaseUrl="https://api.example.com" />)
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
rerender(<ServiceApi apiBaseUrl="https://new-api.example.com" />)
// Component should still render after prop change
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
})
it('should handle undefined-like apiBaseUrl values', () => {
// Empty string is the closest to undefined for this prop
render(<ServiceApi apiBaseUrl="" />)
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<ServiceApi apiBaseUrl="https://api.example.com" />)
rerender(<ServiceApi apiBaseUrl="https://api.example.com" />)
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
})
it('should not re-render unnecessarily with same props', () => {
const { rerender } = render(<ServiceApi apiBaseUrl="https://api.example.com" />)
rerender(<ServiceApi apiBaseUrl="https://api.example.com" />)
rerender(<ServiceApi apiBaseUrl="https://api.example.com" />)
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
})
it('should update when apiBaseUrl prop changes', () => {
const { rerender } = render(<ServiceApi apiBaseUrl="https://api.example.com" />)
rerender(<ServiceApi apiBaseUrl="https://new-api.example.com" />)
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
})
})
})
// ============================================================================
// Card Component Tests
// ============================================================================
describe('Card (service-api)', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Card apiBaseUrl="https://api.example.com" />)
expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
})
it('should display card title', () => {
render(<Card apiBaseUrl="https://api.example.com" />)
expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
})
it('should display enabled status', () => {
render(<Card apiBaseUrl="https://api.example.com" />)
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
})
it('should render endpoint label', () => {
render(<Card apiBaseUrl="https://api.example.com" />)
expect(screen.getByText(/serviceApi\.card\.endpoint/i)).toBeInTheDocument()
})
it('should display apiBaseUrl in endpoint field', () => {
const testUrl = 'https://api.example.com'
render(<Card apiBaseUrl={testUrl} />)
expect(screen.getByText(testUrl)).toBeInTheDocument()
})
it('should render Indicator component', () => {
const { container } = render(<Card apiBaseUrl="https://api.example.com" />)
// Card container should be present
const cardContainer = container.querySelector('.flex.w-\\[360px\\]')
expect(cardContainer).toBeInTheDocument()
})
it('should render API Key button', () => {
render(<Card apiBaseUrl="https://api.example.com" />)
expect(screen.getByText(/serviceApi\.card\.apiKey/i)).toBeInTheDocument()
})
it('should render API Reference button', () => {
render(<Card apiBaseUrl="https://api.example.com" />)
expect(screen.getByText(/serviceApi\.card\.apiReference/i)).toBeInTheDocument()
})
it('should render CopyFeedback component for endpoint', () => {
const { container } = render(<Card apiBaseUrl="https://api.example.com" />)
// CopyFeedback should be in the endpoint section
const copyButton = container.querySelector('[class*="bg-components-input-bg-normal"]')
expect(copyButton).toBeInTheDocument()
})
it('should render ApiAggregate icon in header', () => {
const { container } = render(<Card apiBaseUrl="https://api.example.com" />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Props Variations Tests
// --------------------------------------------------------------------------
describe('Props Variations', () => {
it('should show green Indicator when apiBaseUrl is provided', () => {
const { container } = render(<Card apiBaseUrl="https://api.example.com" />)
const cardContainer = container.querySelector('.flex.w-\\[360px\\]')
expect(cardContainer).toBeInTheDocument()
})
it('should show yellow Indicator when apiBaseUrl is empty', () => {
const { container } = render(<Card apiBaseUrl="" />)
const cardContainer = container.querySelector('.flex.w-\\[360px\\]')
expect(cardContainer).toBeInTheDocument()
})
it('should display different apiBaseUrl values correctly', () => {
const testUrls = [
'https://api.example.com',
'https://localhost:3000',
'https://api.production.example.com/v1',
]
testUrls.forEach((url) => {
const { unmount } = render(<Card apiBaseUrl={url} />)
expect(screen.getByText(url)).toBeInTheDocument()
unmount()
})
})
it('should handle empty apiBaseUrl', () => {
render(<Card apiBaseUrl="" />)
expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
})
it('should truncate long apiBaseUrl', () => {
const longUrl = 'https://api.example.com/v1/very/long/path/to/endpoint/that/should/truncate'
const { container } = render(<Card apiBaseUrl={longUrl} />)
const truncateElement = container.querySelector('.truncate')
expect(truncateElement).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should open SecretKeyModal when API Key button is clicked', async () => {
const user = userEvent.setup()
render(<Card apiBaseUrl="https://api.example.com" />)
const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button')
expect(apiKeyButton).toBeInTheDocument()
if (apiKeyButton)
await user.click(apiKeyButton)
await waitFor(() => {
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
})
})
it('should close SecretKeyModal when close button is clicked', async () => {
const user = userEvent.setup()
render(<Card apiBaseUrl="https://api.example.com" />)
// Open modal
const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button')
if (apiKeyButton)
await user.click(apiKeyButton)
await waitFor(() => {
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
})
// Close modal
const closeButton = screen.getByTestId('close-modal-btn')
await user.click(closeButton)
await waitFor(() => {
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
})
})
it('should have correct href for API Reference link', () => {
render(<Card apiBaseUrl="https://api.example.com" />)
const apiRefLink = screen.getByText(/serviceApi\.card\.apiReference/i).closest('a')
expect(apiRefLink).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets')
})
it('should open API Reference in new tab', () => {
render(<Card apiBaseUrl="https://api.example.com" />)
const apiRefLink = screen.getByText(/serviceApi\.card\.apiReference/i).closest('a')
expect(apiRefLink).toHaveAttribute('target', '_blank')
expect(apiRefLink).toHaveAttribute('rel', 'noopener noreferrer')
})
it('should toggle modal visibility correctly', async () => {
const user = userEvent.setup()
render(<Card apiBaseUrl="https://api.example.com" />)
// Initially modal should not be visible
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
// Open modal
const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button')
if (apiKeyButton)
await user.click(apiKeyButton)
// Modal should be visible
await waitFor(() => {
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
})
// Close modal
const closeButton = screen.getByTestId('close-modal-btn')
await user.click(closeButton)
// Modal should not be visible again
await waitFor(() => {
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// Modal State Tests
// --------------------------------------------------------------------------
describe('Modal State', () => {
it('should initialize with modal closed', () => {
render(<Card apiBaseUrl="https://api.example.com" />)
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
})
it('should open modal on handleOpenSecretKeyModal', async () => {
const user = userEvent.setup()
render(<Card apiBaseUrl="https://api.example.com" />)
const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button')
if (apiKeyButton)
await user.click(apiKeyButton)
await waitFor(() => {
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
})
})
it('should close modal on handleCloseSecretKeyModal', async () => {
const user = userEvent.setup()
render(<Card apiBaseUrl="https://api.example.com" />)
// Open modal first
const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button')
if (apiKeyButton)
await user.click(apiKeyButton)
await waitFor(() => {
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
})
// Close modal
const closeButton = screen.getByTestId('close-modal-btn')
await user.click(closeButton)
await waitFor(() => {
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
})
})
it('should handle multiple open/close cycles', async () => {
const user = userEvent.setup()
render(<Card apiBaseUrl="https://api.example.com" />)
const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button')
// First cycle
if (apiKeyButton)
await user.click(apiKeyButton)
await waitFor(() => {
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
})
await user.click(screen.getByTestId('close-modal-btn'))
await waitFor(() => {
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
})
// Second cycle
if (apiKeyButton)
await user.click(apiKeyButton)
await waitFor(() => {
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle empty apiBaseUrl gracefully', () => {
render(<Card apiBaseUrl="" />)
expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
// Endpoint field should show empty string
})
it('should handle very long apiBaseUrl', () => {
const longUrl = 'https://'.concat('a'.repeat(500), '.com')
render(<Card apiBaseUrl={longUrl} />)
expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
})
it('should handle special characters in apiBaseUrl', () => {
const specialUrl = 'https://api.example.com/path?query=test&param=value#anchor'
render(<Card apiBaseUrl={specialUrl} />)
expect(screen.getByText(specialUrl)).toBeInTheDocument()
})
it('should render without errors when all buttons are clickable', async () => {
const user = userEvent.setup()
render(<Card apiBaseUrl="https://api.example.com" />)
// Click API Key button
const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button')
if (apiKeyButton)
await user.click(apiKeyButton)
// Close modal
await waitFor(() => {
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
})
await user.click(screen.getByTestId('close-modal-btn'))
await waitFor(() => {
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
})
// Component should still be functional
expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Card apiBaseUrl="https://api.example.com" />)
rerender(<Card apiBaseUrl="https://api.example.com" />)
expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
})
it('should use useCallback for handlers', () => {
const { rerender } = render(<Card apiBaseUrl="https://api.example.com" />)
rerender(<Card apiBaseUrl="https://api.example.com" />)
// Component should render without issues with memoized callbacks
expect(screen.getByText(/serviceApi\.card\.apiKey/i)).toBeInTheDocument()
})
it('should update when apiBaseUrl prop changes', () => {
const { rerender } = render(<Card apiBaseUrl="https://api.example.com" />)
expect(screen.getByText('https://api.example.com')).toBeInTheDocument()
rerender(<Card apiBaseUrl="https://new-api.example.com" />)
expect(screen.getByText('https://new-api.example.com')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Copy Functionality Tests
// --------------------------------------------------------------------------
describe('Copy Functionality', () => {
it('should render CopyFeedback component for apiBaseUrl', () => {
const { container } = render(<Card apiBaseUrl="https://api.example.com" />)
const copyContainer = container.querySelector('[class*="bg-components-input-bg-normal"]')
expect(copyContainer).toBeInTheDocument()
})
it('should pass apiBaseUrl to CopyFeedback component', () => {
const testUrl = 'https://api.example.com'
render(<Card apiBaseUrl={testUrl} />)
// The URL should be displayed in the copy section
expect(screen.getByText(testUrl)).toBeInTheDocument()
})
})
})
// ============================================================================
// Integration Tests
// ============================================================================
describe('ServiceApi Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should open Card popup and display endpoint', async () => {
const user = userEvent.setup()
const testUrl = 'https://api.example.com'
render(<ServiceApi apiBaseUrl={testUrl} />)
// Open popup
const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
// Wait for Card to appear
await waitFor(() => {
expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
expect(screen.getByText(testUrl)).toBeInTheDocument()
})
})
it('should complete full workflow: open -> view endpoint -> access API key', async () => {
const user = userEvent.setup()
render(<ServiceApi apiBaseUrl="https://api.example.com" />)
// Open popup
const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
// Verify Card content
await waitFor(() => {
expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
})
// Open API Key modal
const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button')
if (apiKeyButton)
await user.click(apiKeyButton)
// Verify modal appears
await waitFor(() => {
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
})
})
it('should navigate to API Reference from Card', async () => {
const user = userEvent.setup()
render(<ServiceApi apiBaseUrl="https://api.example.com" />)
// Open popup
const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
// Wait for Card to appear
await waitFor(() => {
expect(screen.getByText(/serviceApi\.card\.apiReference/i)).toBeInTheDocument()
})
// Verify link
const apiRefLink = screen.getByText(/serviceApi\.card\.apiReference/i).closest('a')
expect(apiRefLink).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets')
})
it('should reflect apiBaseUrl status in Indicator color', () => {
// With URL - should be green
const { rerender } = render(<ServiceApi apiBaseUrl="https://api.example.com" />)
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
// Without URL - should be yellow
rerender(<ServiceApi apiBaseUrl="" />)
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
})
})

View File

@ -61,6 +61,9 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
const providerMap = useMemo(() => new Map(
providers.map(p => [p.provider, p.preferred_provider_type]),
), [providers])
const installedProvidersMap = useMemo(() => new Map(
providers.map(p => [p.provider, p.custom_configuration.available_credentials]),
), [providers])
const { formatTime } = useTimestamp()
const {
plugins: allPlugins,
@ -73,8 +76,8 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
const selectedPluginIdRef = useRef<string | null>(null)
const handleIconClick = useCallback((key: ModelProviderQuotaGetPaid) => {
const providerType = providerMap.get(key)
if (!providerType && allPlugins) {
const isInstalled = providerMap.get(key)
if (!isInstalled && allPlugins) {
const pluginId = providerKeyToPluginId[key]
const plugin = allPlugins.find(p => p.plugin_id === pluginId)
if (plugin) {
@ -131,13 +134,14 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
<div className="flex items-center gap-1">
{allProviders.filter(({ key }) => trial_models.includes(key)).map(({ key, Icon }) => {
const providerType = providerMap.get(key)
const usingQuota = providerType === PreferredProviderTypeEnum.system
const isConfigured = (installedProvidersMap.get(key)?.length ?? 0) > 0 // means the provider is configured API key
const getTooltipKey = () => {
if (usingQuota)
return 'modelProvider.card.modelSupported'
if (providerType === PreferredProviderTypeEnum.custom)
// if provider type is not set, it means the provider is not installed
if (!providerType)
return 'modelProvider.card.modelNotSupported'
if (isConfigured && providerType === PreferredProviderTypeEnum.custom)
return 'modelProvider.card.modelAPI'
return 'modelProvider.card.modelNotSupported'
return 'modelProvider.card.modelSupported'
}
return (
<Tooltip
@ -149,7 +153,7 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
onClick={() => handleIconClick(key)}
>
<Icon className="h-6 w-6 rounded-lg" />
{!usingQuota && (
{!providerType && (
<div className="absolute inset-0 rounded-lg border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge opacity-30" />
)}
</div>

View File

@ -21,7 +21,7 @@ import {
export { ModelProviderQuotaGetPaid } from '@/types/model-provider'
export const MODEL_PROVIDER_QUOTA_GET_PAID = [ModelProviderQuotaGetPaid.ANTHROPIC, ModelProviderQuotaGetPaid.OPENAI, ModelProviderQuotaGetPaid.GEMINI, ModelProviderQuotaGetPaid.X, ModelProviderQuotaGetPaid.DEEPSEEK, ModelProviderQuotaGetPaid.TONGYI]
export const MODEL_PROVIDER_QUOTA_GET_PAID = [ModelProviderQuotaGetPaid.OPENAI, ModelProviderQuotaGetPaid.ANTHROPIC, ModelProviderQuotaGetPaid.GEMINI, ModelProviderQuotaGetPaid.X, ModelProviderQuotaGetPaid.DEEPSEEK, ModelProviderQuotaGetPaid.TONGYI]
export const modelNameMap = {
[ModelProviderQuotaGetPaid.OPENAI]: 'OpenAI',

View File

@ -0,0 +1,191 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Countdown, { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from './countdown'
// Mock useCountDown from ahooks
let mockTime = COUNT_DOWN_TIME_MS
let mockOnEnd: (() => void) | undefined
vi.mock('ahooks', () => ({
useCountDown: ({ onEnd }: { leftTime: number, onEnd?: () => void }) => {
mockOnEnd = onEnd
return [mockTime]
},
}))
describe('Countdown', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTime = COUNT_DOWN_TIME_MS
mockOnEnd = undefined
localStorage.clear()
})
// Rendering Tests
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Countdown />)
expect(screen.getByText('login.checkCode.didNotReceiveCode')).toBeInTheDocument()
})
it('should display countdown time when time > 0', () => {
mockTime = 30000 // 30 seconds
render(<Countdown />)
// The countdown displays number and 's' in the same span
expect(screen.getByText(/30/)).toBeInTheDocument()
expect(screen.getByText(/s$/)).toBeInTheDocument()
})
it('should display resend link when time <= 0', () => {
mockTime = 0
render(<Countdown />)
expect(screen.getByText('login.checkCode.resend')).toBeInTheDocument()
expect(screen.queryByText('s')).not.toBeInTheDocument()
})
it('should not display resend link when time > 0', () => {
mockTime = 1000
render(<Countdown />)
expect(screen.queryByText('login.checkCode.resend')).not.toBeInTheDocument()
})
})
// State Management Tests
describe('State Management', () => {
it('should initialize leftTime from localStorage if available', () => {
const savedTime = 45000
vi.mocked(localStorage.getItem).mockReturnValueOnce(String(savedTime))
render(<Countdown />)
expect(localStorage.getItem).toHaveBeenCalledWith(COUNT_DOWN_KEY)
})
it('should use default COUNT_DOWN_TIME_MS when localStorage is empty', () => {
vi.mocked(localStorage.getItem).mockReturnValueOnce(null)
render(<Countdown />)
expect(localStorage.getItem).toHaveBeenCalledWith(COUNT_DOWN_KEY)
})
it('should save time to localStorage on time change', () => {
mockTime = 50000
render(<Countdown />)
expect(localStorage.setItem).toHaveBeenCalledWith(COUNT_DOWN_KEY, String(mockTime))
})
})
// Event Handler Tests
describe('Event Handlers', () => {
it('should call onResend callback when resend is clicked', () => {
mockTime = 0
const onResend = vi.fn()
render(<Countdown onResend={onResend} />)
const resendLink = screen.getByText('login.checkCode.resend')
fireEvent.click(resendLink)
expect(onResend).toHaveBeenCalledTimes(1)
})
it('should reset countdown when resend is clicked', () => {
mockTime = 0
render(<Countdown />)
const resendLink = screen.getByText('login.checkCode.resend')
fireEvent.click(resendLink)
expect(localStorage.setItem).toHaveBeenCalledWith(COUNT_DOWN_KEY, String(COUNT_DOWN_TIME_MS))
})
it('should work without onResend callback (optional prop)', () => {
mockTime = 0
render(<Countdown />)
const resendLink = screen.getByText('login.checkCode.resend')
expect(() => fireEvent.click(resendLink)).not.toThrow()
})
})
// Countdown End Tests
describe('Countdown End', () => {
it('should remove localStorage item when countdown ends', () => {
render(<Countdown />)
// Simulate countdown end
act(() => {
mockOnEnd?.()
})
expect(localStorage.removeItem).toHaveBeenCalledWith(COUNT_DOWN_KEY)
})
})
// Edge Cases
describe('Edge Cases', () => {
it('should handle time exactly at 0', () => {
mockTime = 0
render(<Countdown />)
expect(screen.getByText('login.checkCode.resend')).toBeInTheDocument()
})
it('should handle negative time values', () => {
mockTime = -1000
render(<Countdown />)
expect(screen.getByText('login.checkCode.resend')).toBeInTheDocument()
})
it('should round time display correctly', () => {
mockTime = 29500 // Should display as 30 (Math.round)
render(<Countdown />)
expect(screen.getByText(/30/)).toBeInTheDocument()
})
it('should display 1 second correctly', () => {
mockTime = 1000
render(<Countdown />)
expect(screen.getByText(/^1/)).toBeInTheDocument()
})
})
// Props Tests
describe('Props', () => {
it('should render correctly with onResend prop', () => {
const onResend = vi.fn()
mockTime = 0
render(<Countdown onResend={onResend} />)
expect(screen.getByText('login.checkCode.resend')).toBeInTheDocument()
})
it('should render correctly without any props', () => {
render(<Countdown />)
expect(screen.getByText('login.checkCode.didNotReceiveCode')).toBeInTheDocument()
})
})
// Exported Constants
describe('Exported Constants', () => {
it('should export COUNT_DOWN_TIME_MS as 59000', () => {
expect(COUNT_DOWN_TIME_MS).toBe(59000)
})
it('should export COUNT_DOWN_KEY as leftTime', () => {
expect(COUNT_DOWN_KEY).toBe('leftTime')
})
})
})

View File

@ -1,5 +1,6 @@
import type { Credential } from '@/app/components/tools/types'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthHeaderPrefix, AuthType } from '@/app/components/tools/types'
import ConfigCredential from './config-credentials'
@ -14,47 +15,472 @@ describe('ConfigCredential', () => {
vi.clearAllMocks()
})
it('renders and calls onHide when cancel is pressed', async () => {
await act(async () => {
render(
<ConfigCredential
credential={baseCredential}
onChange={mockOnChange}
onHide={mockOnHide}
/>,
)
// Tests for basic rendering
describe('Rendering', () => {
it('should render without crashing', async () => {
await act(async () => {
render(
<ConfigCredential
credential={baseCredential}
onChange={mockOnChange}
onHide={mockOnHide}
/>,
)
})
expect(screen.getByText('tools.createTool.authMethod.title')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('common.operation.cancel'))
it('should render all three auth type options', async () => {
await act(async () => {
render(
<ConfigCredential
credential={baseCredential}
onChange={mockOnChange}
onHide={mockOnHide}
/>,
)
})
expect(mockOnHide).toHaveBeenCalledTimes(1)
expect(mockOnChange).not.toHaveBeenCalled()
expect(screen.getByText('tools.createTool.authMethod.types.none')).toBeInTheDocument()
expect(screen.getByText('tools.createTool.authMethod.types.api_key_header')).toBeInTheDocument()
expect(screen.getByText('tools.createTool.authMethod.types.api_key_query')).toBeInTheDocument()
})
it('should render with positionCenter prop', async () => {
await act(async () => {
render(
<ConfigCredential
positionCenter
credential={baseCredential}
onChange={mockOnChange}
onHide={mockOnHide}
/>,
)
})
expect(screen.getByText('tools.createTool.authMethod.title')).toBeInTheDocument()
})
})
it('allows selecting apiKeyHeader and submits the new credential', async () => {
await act(async () => {
render(
<ConfigCredential
credential={baseCredential}
onChange={mockOnChange}
onHide={mockOnHide}
/>,
// Tests for cancel and save buttons
describe('Cancel and Save Actions', () => {
it('should call onHide when cancel is pressed', async () => {
await act(async () => {
render(
<ConfigCredential
credential={baseCredential}
onChange={mockOnChange}
onHide={mockOnHide}
/>,
)
})
fireEvent.click(screen.getByText('common.operation.cancel'))
expect(mockOnHide).toHaveBeenCalledTimes(1)
expect(mockOnChange).not.toHaveBeenCalled()
})
it('should call both onChange and onHide when save is pressed', async () => {
await act(async () => {
render(
<ConfigCredential
credential={baseCredential}
onChange={mockOnChange}
onHide={mockOnHide}
/>,
)
})
fireEvent.click(screen.getByText('common.operation.save'))
expect(mockOnChange).toHaveBeenCalledTimes(1)
expect(mockOnHide).toHaveBeenCalledTimes(1)
})
})
// Tests for "none" auth type selection
describe('None Auth Type', () => {
it('should select none auth type and save', async () => {
const credentialWithApiKey: Credential = {
auth_type: AuthType.apiKeyHeader,
api_key_header: 'X-Api-Key',
api_key_value: 'test-value',
api_key_header_prefix: AuthHeaderPrefix.bearer,
}
await act(async () => {
render(
<ConfigCredential
credential={credentialWithApiKey}
onChange={mockOnChange}
onHide={mockOnHide}
/>,
)
})
// Switch to none auth type
fireEvent.click(screen.getByText('tools.createTool.authMethod.types.none'))
fireEvent.click(screen.getByText('common.operation.save'))
expect(mockOnChange).toHaveBeenCalledWith({
auth_type: AuthType.none,
})
})
})
// Tests for API Key Header auth type
describe('API Key Header Auth Type', () => {
it('should select apiKeyHeader and show header prefix options', async () => {
await act(async () => {
render(
<ConfigCredential
credential={baseCredential}
onChange={mockOnChange}
onHide={mockOnHide}
/>,
)
})
fireEvent.click(screen.getByText('tools.createTool.authMethod.types.api_key_header'))
// Header prefix options should appear
expect(screen.getByText('tools.createTool.authHeaderPrefix.types.basic')).toBeInTheDocument()
expect(screen.getByText('tools.createTool.authHeaderPrefix.types.bearer')).toBeInTheDocument()
expect(screen.getByText('tools.createTool.authHeaderPrefix.types.custom')).toBeInTheDocument()
})
it('should submit apiKeyHeader credential with default values', async () => {
await act(async () => {
render(
<ConfigCredential
credential={baseCredential}
onChange={mockOnChange}
onHide={mockOnHide}
/>,
)
})
fireEvent.click(screen.getByText('tools.createTool.authMethod.types.api_key_header'))
const headerInput = screen.getByPlaceholderText('tools.createTool.authMethod.types.apiKeyPlaceholder')
const valueInput = screen.getByPlaceholderText('tools.createTool.authMethod.types.apiValuePlaceholder')
fireEvent.change(headerInput, { target: { value: 'X-Auth' } })
fireEvent.change(valueInput, { target: { value: 'sEcReT' } })
fireEvent.click(screen.getByText('common.operation.save'))
expect(mockOnChange).toHaveBeenCalledWith({
auth_type: AuthType.apiKeyHeader,
api_key_header: 'X-Auth',
api_key_header_prefix: AuthHeaderPrefix.custom,
api_key_value: 'sEcReT',
})
expect(mockOnHide).toHaveBeenCalled()
})
it('should select basic header prefix', async () => {
await act(async () => {
render(
<ConfigCredential
credential={baseCredential}
onChange={mockOnChange}
onHide={mockOnHide}
/>,
)
})
fireEvent.click(screen.getByText('tools.createTool.authMethod.types.api_key_header'))
fireEvent.click(screen.getByText('tools.createTool.authHeaderPrefix.types.basic'))
fireEvent.click(screen.getByText('common.operation.save'))
expect(mockOnChange).toHaveBeenCalledWith(
expect.objectContaining({
auth_type: AuthType.apiKeyHeader,
api_key_header_prefix: AuthHeaderPrefix.basic,
}),
)
})
fireEvent.click(screen.getByText('tools.createTool.authMethod.types.api_key_header'))
const headerInput = screen.getByPlaceholderText('tools.createTool.authMethod.types.apiKeyPlaceholder')
const valueInput = screen.getByPlaceholderText('tools.createTool.authMethod.types.apiValuePlaceholder')
fireEvent.change(headerInput, { target: { value: 'X-Auth' } })
fireEvent.change(valueInput, { target: { value: 'sEcReT' } })
fireEvent.click(screen.getByText('common.operation.save'))
it('should select bearer header prefix', async () => {
await act(async () => {
render(
<ConfigCredential
credential={baseCredential}
onChange={mockOnChange}
onHide={mockOnHide}
/>,
)
})
expect(mockOnChange).toHaveBeenCalledWith({
auth_type: AuthType.apiKeyHeader,
api_key_header: 'X-Auth',
api_key_header_prefix: AuthHeaderPrefix.custom,
api_key_value: 'sEcReT',
fireEvent.click(screen.getByText('tools.createTool.authMethod.types.api_key_header'))
fireEvent.click(screen.getByText('tools.createTool.authHeaderPrefix.types.bearer'))
fireEvent.click(screen.getByText('common.operation.save'))
expect(mockOnChange).toHaveBeenCalledWith(
expect.objectContaining({
auth_type: AuthType.apiKeyHeader,
api_key_header_prefix: AuthHeaderPrefix.bearer,
}),
)
})
it('should select custom header prefix', async () => {
await act(async () => {
render(
<ConfigCredential
credential={baseCredential}
onChange={mockOnChange}
onHide={mockOnHide}
/>,
)
})
// Start with none, switch to apiKeyHeader (which defaults to custom)
fireEvent.click(screen.getByText('tools.createTool.authMethod.types.api_key_header'))
// Select bearer first, then custom to test switching
fireEvent.click(screen.getByText('tools.createTool.authHeaderPrefix.types.bearer'))
fireEvent.click(screen.getByText('tools.createTool.authHeaderPrefix.types.custom'))
fireEvent.click(screen.getByText('common.operation.save'))
expect(mockOnChange).toHaveBeenCalledWith(
expect.objectContaining({
auth_type: AuthType.apiKeyHeader,
api_key_header_prefix: AuthHeaderPrefix.custom,
}),
)
})
it('should preserve existing values when switching to apiKeyHeader', async () => {
const existingCredential: Credential = {
auth_type: AuthType.none,
api_key_header: 'Existing-Header',
api_key_value: 'existing-value',
api_key_header_prefix: AuthHeaderPrefix.bearer,
}
await act(async () => {
render(
<ConfigCredential
credential={existingCredential}
onChange={mockOnChange}
onHide={mockOnHide}
/>,
)
})
fireEvent.click(screen.getByText('tools.createTool.authMethod.types.api_key_header'))
fireEvent.click(screen.getByText('common.operation.save'))
expect(mockOnChange).toHaveBeenCalledWith(
expect.objectContaining({
auth_type: AuthType.apiKeyHeader,
api_key_header: 'Existing-Header',
api_key_value: 'existing-value',
api_key_header_prefix: AuthHeaderPrefix.bearer,
}),
)
})
})
// Tests for API Key Query auth type
describe('API Key Query Auth Type', () => {
it('should select apiKeyQuery and show query param input', async () => {
await act(async () => {
render(
<ConfigCredential
credential={baseCredential}
onChange={mockOnChange}
onHide={mockOnHide}
/>,
)
})
fireEvent.click(screen.getByText('tools.createTool.authMethod.types.api_key_query'))
// Query param input should appear
expect(screen.getByPlaceholderText('tools.createTool.authMethod.types.queryParamPlaceholder')).toBeInTheDocument()
})
it('should submit apiKeyQuery credential with default values', async () => {
await act(async () => {
render(
<ConfigCredential
credential={baseCredential}
onChange={mockOnChange}
onHide={mockOnHide}
/>,
)
})
fireEvent.click(screen.getByText('tools.createTool.authMethod.types.api_key_query'))
fireEvent.click(screen.getByText('common.operation.save'))
expect(mockOnChange).toHaveBeenCalledWith({
auth_type: AuthType.apiKeyQuery,
api_key_query_param: 'key',
api_key_value: '',
})
})
it('should edit query param name and value', async () => {
await act(async () => {
render(
<ConfigCredential
credential={baseCredential}
onChange={mockOnChange}
onHide={mockOnHide}
/>,
)
})
fireEvent.click(screen.getByText('tools.createTool.authMethod.types.api_key_query'))
const queryParamInput = screen.getByPlaceholderText('tools.createTool.authMethod.types.queryParamPlaceholder')
const valueInput = screen.getByPlaceholderText('tools.createTool.authMethod.types.apiValuePlaceholder')
fireEvent.change(queryParamInput, { target: { value: 'api_key' } })
fireEvent.change(valueInput, { target: { value: 'my-secret-key' } })
fireEvent.click(screen.getByText('common.operation.save'))
expect(mockOnChange).toHaveBeenCalledWith({
auth_type: AuthType.apiKeyQuery,
api_key_query_param: 'api_key',
api_key_value: 'my-secret-key',
})
})
it('should preserve existing values when switching to apiKeyQuery', async () => {
const existingCredential: Credential = {
auth_type: AuthType.none,
api_key_query_param: 'existing_param',
api_key_value: 'existing-value',
}
await act(async () => {
render(
<ConfigCredential
credential={existingCredential}
onChange={mockOnChange}
onHide={mockOnHide}
/>,
)
})
fireEvent.click(screen.getByText('tools.createTool.authMethod.types.api_key_query'))
fireEvent.click(screen.getByText('common.operation.save'))
expect(mockOnChange).toHaveBeenCalledWith(
expect.objectContaining({
auth_type: AuthType.apiKeyQuery,
api_key_query_param: 'existing_param',
api_key_value: 'existing-value',
}),
)
})
})
// Tests for switching between auth types
describe('Switching Auth Types', () => {
it('should switch from apiKeyHeader to apiKeyQuery', async () => {
const headerCredential: Credential = {
auth_type: AuthType.apiKeyHeader,
api_key_header: 'Authorization',
api_key_value: 'Bearer token',
api_key_header_prefix: AuthHeaderPrefix.bearer,
}
await act(async () => {
render(
<ConfigCredential
credential={headerCredential}
onChange={mockOnChange}
onHide={mockOnHide}
/>,
)
})
// Switch to query
fireEvent.click(screen.getByText('tools.createTool.authMethod.types.api_key_query'))
// Header prefix options should disappear
expect(screen.queryByText('tools.createTool.authHeaderPrefix.types.basic')).not.toBeInTheDocument()
// Query param input should appear
expect(screen.getByPlaceholderText('tools.createTool.authMethod.types.queryParamPlaceholder')).toBeInTheDocument()
})
it('should switch from apiKeyQuery to none', async () => {
const queryCredential: Credential = {
auth_type: AuthType.apiKeyQuery,
api_key_query_param: 'key',
api_key_value: 'value',
}
await act(async () => {
render(
<ConfigCredential
credential={queryCredential}
onChange={mockOnChange}
onHide={mockOnHide}
/>,
)
})
// Switch to none
fireEvent.click(screen.getByText('tools.createTool.authMethod.types.none'))
fireEvent.click(screen.getByText('common.operation.save'))
expect(mockOnChange).toHaveBeenCalledWith({
auth_type: AuthType.none,
})
})
})
// Tests for initial credential state
describe('Initial Credential State', () => {
it('should show apiKeyHeader fields when initial auth type is apiKeyHeader', async () => {
const headerCredential: Credential = {
auth_type: AuthType.apiKeyHeader,
api_key_header: 'X-Custom-Header',
api_key_value: 'secret123',
api_key_header_prefix: AuthHeaderPrefix.bearer,
}
await act(async () => {
render(
<ConfigCredential
credential={headerCredential}
onChange={mockOnChange}
onHide={mockOnHide}
/>,
)
})
// Header inputs should be visible with initial values
const headerInput = screen.getByPlaceholderText('tools.createTool.authMethod.types.apiKeyPlaceholder')
expect(headerInput).toHaveValue('X-Custom-Header')
})
it('should show apiKeyQuery fields when initial auth type is apiKeyQuery', async () => {
const queryCredential: Credential = {
auth_type: AuthType.apiKeyQuery,
api_key_query_param: 'apikey',
api_key_value: 'queryvalue',
}
await act(async () => {
render(
<ConfigCredential
credential={queryCredential}
onChange={mockOnChange}
onHide={mockOnHide}
/>,
)
})
// Query param input should be visible with initial value
const queryParamInput = screen.getByPlaceholderText('tools.createTool.authMethod.types.queryParamPlaceholder')
expect(queryParamInput).toHaveValue('apikey')
})
expect(mockOnHide).toHaveBeenCalled()
})
})

View File

@ -1,8 +1,10 @@
import type { ModalContextState } from '@/context/modal-context'
import type { ProviderContextState } from '@/context/provider-context'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import { Plan } from '@/app/components/billing/type'
import { AuthHeaderPrefix, AuthType } from '@/app/components/tools/types'
import { parseParamsSchema } from '@/service/tools'
import EditCustomCollectionModal from './index'
@ -52,6 +54,18 @@ vi.mock('@/context/i18n', async () => {
}
})
// Mock EmojiPicker
vi.mock('@/app/components/base/emoji-picker', () => ({
default: ({ onSelect, onClose }: { onSelect: (icon: string, background: string) => void, onClose: () => void }) => {
return (
<div data-testid="emoji-picker">
<button data-testid="select-emoji" onClick={() => onSelect('🚀', '#FF0000')}>Select Emoji</button>
<button data-testid="close-emoji-picker" onClick={onClose}>Close</button>
</div>
)
},
}))
describe('EditCustomCollectionModal', () => {
const mockOnHide = vi.fn()
const mockOnAdd = vi.fn()
@ -75,80 +89,490 @@ describe('EditCustomCollectionModal', () => {
} as ProviderContextState)
})
const renderModal = () => render(
const renderModal = (props?: {
payload?: {
provider: string
credentials: { auth_type: AuthType, api_key_header?: string, api_key_header_prefix?: AuthHeaderPrefix, api_key_value?: string }
schema_type: string
schema: string
icon: { content: string, background: string }
privacy_policy?: string
custom_disclaimer?: string
labels?: string[]
tools?: Array<{ operation_id: string, summary: string, method: string, server_url: string, parameters: Array<{ name: string, label: { en_US: string, zh_Hans: string } }> }>
}
positionLeft?: boolean
dialogClassName?: string
}) => render(
<EditCustomCollectionModal
payload={undefined}
payload={props?.payload}
onHide={mockOnHide}
onAdd={mockOnAdd}
onEdit={mockOnEdit}
onRemove={mockOnRemove}
positionLeft={props?.positionLeft}
dialogClassName={props?.dialogClassName}
/>,
)
it('shows an error when the provider name is missing', async () => {
renderModal()
// Tests for Add mode (no payload)
describe('Add Mode', () => {
it('should render add mode title when no payload', () => {
renderModal()
const schemaInput = screen.getByPlaceholderText('tools.createTool.schemaPlaceHolder')
fireEvent.change(schemaInput, { target: { value: '{}' } })
await waitFor(() => {
expect(parseParamsSchemaMock).toHaveBeenCalledWith('{}')
expect(screen.getByText('tools.createTool.title')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('common.operation.save'))
it('should show error when provider name is missing', async () => {
renderModal()
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith(expect.objectContaining({
message: 'common.errorMsg.fieldRequired:{"field":"tools.createTool.name"}',
type: 'error',
}))
})
expect(mockOnAdd).not.toHaveBeenCalled()
})
const schemaInput = screen.getByPlaceholderText('tools.createTool.schemaPlaceHolder')
fireEvent.change(schemaInput, { target: { value: '{}' } })
await waitFor(() => {
expect(parseParamsSchemaMock).toHaveBeenCalledWith('{}')
})
it('shows an error when the schema is missing', async () => {
renderModal()
const providerInput = screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder')
fireEvent.change(providerInput, { target: { value: 'provider' } })
fireEvent.click(screen.getByText('common.operation.save'))
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith(expect.objectContaining({
message: 'common.errorMsg.fieldRequired:{"field":"tools.createTool.schema"}',
type: 'error',
}))
})
expect(mockOnAdd).not.toHaveBeenCalled()
})
it('saves a valid custom collection', async () => {
renderModal()
const providerInput = screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder')
fireEvent.change(providerInput, { target: { value: 'provider' } })
const schemaInput = screen.getByPlaceholderText('tools.createTool.schemaPlaceHolder')
fireEvent.change(schemaInput, { target: { value: '{}' } })
await waitFor(() => {
expect(parseParamsSchemaMock).toHaveBeenCalledWith('{}')
})
await act(async () => {
fireEvent.click(screen.getByText('common.operation.save'))
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith(expect.objectContaining({
message: 'common.errorMsg.fieldRequired:{"field":"tools.createTool.name"}',
type: 'error',
}))
})
expect(mockOnAdd).not.toHaveBeenCalled()
})
await waitFor(() => {
expect(mockOnAdd).toHaveBeenCalledWith(expect.objectContaining({
provider: 'provider',
schema: '{}',
schema_type: 'openapi',
it('should show error when schema is missing', async () => {
renderModal()
const providerInput = screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder')
fireEvent.change(providerInput, { target: { value: 'provider' } })
fireEvent.click(screen.getByText('common.operation.save'))
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith(expect.objectContaining({
message: 'common.errorMsg.fieldRequired:{"field":"tools.createTool.schema"}',
type: 'error',
}))
})
expect(mockOnAdd).not.toHaveBeenCalled()
})
it('should save a valid custom collection', async () => {
renderModal()
const providerInput = screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder')
fireEvent.change(providerInput, { target: { value: 'provider' } })
const schemaInput = screen.getByPlaceholderText('tools.createTool.schemaPlaceHolder')
fireEvent.change(schemaInput, { target: { value: '{}' } })
await waitFor(() => {
expect(parseParamsSchemaMock).toHaveBeenCalledWith('{}')
})
await act(async () => {
fireEvent.click(screen.getByText('common.operation.save'))
})
await waitFor(() => {
expect(mockOnAdd).toHaveBeenCalledWith(expect.objectContaining({
provider: 'provider',
schema: '{}',
schema_type: 'openapi',
credentials: {
auth_type: 'none',
},
labels: [],
}))
expect(toastNotifySpy).not.toHaveBeenCalled()
})
})
it('should call onHide when cancel is clicked', () => {
renderModal()
fireEvent.click(screen.getByText('common.operation.cancel'))
expect(mockOnHide).toHaveBeenCalled()
})
})
// Tests for Edit mode (with payload)
describe('Edit Mode', () => {
const editPayload = {
provider: 'existing-provider',
credentials: {
auth_type: AuthType.apiKeyHeader,
api_key_header: 'X-Api-Key',
api_key_header_prefix: AuthHeaderPrefix.bearer,
api_key_value: 'secret-key',
},
schema_type: 'openapi',
schema: '{"openapi": "3.0.0"}',
icon: { content: '🔧', background: '#FFCC00' },
privacy_policy: 'https://example.com/privacy',
custom_disclaimer: 'Use at your own risk',
labels: ['api', 'tools'],
tools: [{
operation_id: 'getUsers',
summary: 'Get all users',
method: 'GET',
server_url: 'https://api.example.com/users',
parameters: [{
name: 'limit',
label: { en_US: 'Limit', zh_Hans: '限制' },
}],
}],
}
it('should render edit mode title when payload is provided', () => {
renderModal({ payload: editPayload })
expect(screen.getByText('tools.createTool.editTitle')).toBeInTheDocument()
})
it('should show delete button in edit mode', () => {
renderModal({ payload: editPayload })
expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
})
it('should call onRemove when delete button is clicked', () => {
renderModal({ payload: editPayload })
fireEvent.click(screen.getByText('common.operation.delete'))
expect(mockOnRemove).toHaveBeenCalled()
})
it('should call onEdit with original_provider when saving in edit mode', async () => {
renderModal({ payload: editPayload })
// Change the provider name
const providerInput = screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder')
fireEvent.change(providerInput, { target: { value: 'updated-provider' } })
await act(async () => {
fireEvent.click(screen.getByText('common.operation.save'))
})
await waitFor(() => {
expect(mockOnEdit).toHaveBeenCalledWith(expect.objectContaining({
provider: 'updated-provider',
original_provider: 'existing-provider',
}))
})
})
it('should display existing provider name', () => {
renderModal({ payload: editPayload })
const providerInput = screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder')
expect(providerInput).toHaveValue('existing-provider')
})
it('should display existing schema', () => {
renderModal({ payload: editPayload })
const schemaInput = screen.getByPlaceholderText('tools.createTool.schemaPlaceHolder')
expect(schemaInput).toHaveValue('{"openapi": "3.0.0"}')
})
it('should display available tools table', () => {
renderModal({ payload: editPayload })
expect(screen.getByText('getUsers')).toBeInTheDocument()
expect(screen.getByText('Get all users')).toBeInTheDocument()
expect(screen.getByText('GET')).toBeInTheDocument()
})
it('should strip credential fields when auth_type is none on save', async () => {
const payloadWithNoneAuth = {
...editPayload,
credentials: {
auth_type: 'none',
auth_type: AuthType.none,
api_key_header: 'should-be-removed',
api_key_header_prefix: AuthHeaderPrefix.bearer,
api_key_value: 'should-be-removed',
},
labels: [],
}))
expect(toastNotifySpy).not.toHaveBeenCalled()
}
renderModal({ payload: payloadWithNoneAuth })
await act(async () => {
fireEvent.click(screen.getByText('common.operation.save'))
})
await waitFor(() => {
expect(mockOnEdit).toHaveBeenCalledWith(expect.objectContaining({
credentials: {
auth_type: AuthType.none,
},
}))
// These fields should NOT be present
const callArg = mockOnEdit.mock.calls[0][0]
expect(callArg.credentials.api_key_header).toBeUndefined()
expect(callArg.credentials.api_key_header_prefix).toBeUndefined()
expect(callArg.credentials.api_key_value).toBeUndefined()
})
})
})
// Tests for Schema parsing
describe('Schema Parsing', () => {
it('should parse schema and update params when schema changes', async () => {
parseParamsSchemaMock.mockResolvedValueOnce({
parameters_schema: [{
operation_id: 'newOp',
summary: 'New operation',
method: 'POST',
server_url: 'https://api.example.com/new',
parameters: [],
}],
schema_type: 'swagger',
})
renderModal()
const schemaInput = screen.getByPlaceholderText('tools.createTool.schemaPlaceHolder')
fireEvent.change(schemaInput, { target: { value: '{"swagger": "2.0"}' } })
await waitFor(() => {
expect(parseParamsSchemaMock).toHaveBeenCalledWith('{"swagger": "2.0"}')
})
await waitFor(() => {
expect(screen.getByText('newOp')).toBeInTheDocument()
})
})
it('should handle schema parse error and reset params', async () => {
parseParamsSchemaMock.mockRejectedValueOnce(new Error('Parse error'))
renderModal()
const schemaInput = screen.getByPlaceholderText('tools.createTool.schemaPlaceHolder')
fireEvent.change(schemaInput, { target: { value: 'invalid schema' } })
await waitFor(() => {
expect(parseParamsSchemaMock).toHaveBeenCalledWith('invalid schema')
})
// The table should still be visible but empty (no tools)
expect(screen.getByText('tools.createTool.availableTools.title')).toBeInTheDocument()
})
it('should not parse schema when empty', async () => {
renderModal()
// Clear any calls from initial render
parseParamsSchemaMock.mockClear()
const schemaInput = screen.getByPlaceholderText('tools.createTool.schemaPlaceHolder')
fireEvent.change(schemaInput, { target: { value: '' } })
// Wait a bit and check that parseParamsSchema was not called with empty string
await new Promise(resolve => setTimeout(resolve, 100))
expect(parseParamsSchemaMock).not.toHaveBeenCalledWith('')
})
})
// Tests for Icon Section
describe('Icon Section', () => {
it('should render icon section', () => {
renderModal()
// The name input should be present
const nameInput = screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder')
expect(nameInput).toBeInTheDocument()
})
it('should render name input section', () => {
renderModal()
// Name label should be present
expect(screen.getByText('tools.createTool.name')).toBeInTheDocument()
})
})
// Tests for Credentials Modal
describe('Credentials Modal', () => {
it('should show auth method section title', () => {
renderModal()
expect(screen.getByText('tools.createTool.authMethod.title')).toBeInTheDocument()
})
it('should display current auth type', () => {
renderModal()
// The default auth type is 'none'
expect(screen.getByText('tools.createTool.authMethod.types.none')).toBeInTheDocument()
})
})
// Tests for Test API Modal
describe('Test API Modal', () => {
const payloadWithTools = {
provider: 'test-provider',
credentials: { auth_type: AuthType.none },
schema_type: 'openapi',
schema: '{}',
icon: { content: '🔧', background: '#FFCC00' },
tools: [{
operation_id: 'testOp',
summary: 'Test operation',
method: 'POST',
server_url: 'https://api.example.com/test',
parameters: [],
}],
}
it('should render test button in available tools table', () => {
renderModal({ payload: payloadWithTools })
// Find the test button
const testButton = screen.getByText('tools.createTool.availableTools.test')
expect(testButton).toBeInTheDocument()
})
it('should display tool information in the table', () => {
renderModal({ payload: payloadWithTools })
expect(screen.getByText('testOp')).toBeInTheDocument()
expect(screen.getByText('Test operation')).toBeInTheDocument()
expect(screen.getByText('POST')).toBeInTheDocument()
})
})
// Tests for Privacy Policy and Custom Disclaimer
describe('Privacy Policy and Custom Disclaimer', () => {
it('should update privacy policy input', () => {
renderModal()
const privacyInput = screen.getByPlaceholderText('tools.createTool.privacyPolicyPlaceholder')
fireEvent.change(privacyInput, { target: { value: 'https://example.com/privacy' } })
expect(privacyInput).toHaveValue('https://example.com/privacy')
})
it('should update custom disclaimer input', () => {
renderModal()
const disclaimerInput = screen.getByPlaceholderText('tools.createTool.customDisclaimerPlaceholder')
fireEvent.change(disclaimerInput, { target: { value: 'Custom disclaimer text' } })
expect(disclaimerInput).toHaveValue('Custom disclaimer text')
})
it('should include privacy policy and custom disclaimer in save payload', async () => {
renderModal()
const providerInput = screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder')
fireEvent.change(providerInput, { target: { value: 'test-provider' } })
const schemaInput = screen.getByPlaceholderText('tools.createTool.schemaPlaceHolder')
fireEvent.change(schemaInput, { target: { value: '{}' } })
const privacyInput = screen.getByPlaceholderText('tools.createTool.privacyPolicyPlaceholder')
fireEvent.change(privacyInput, { target: { value: 'https://privacy.example.com' } })
const disclaimerInput = screen.getByPlaceholderText('tools.createTool.customDisclaimerPlaceholder')
fireEvent.change(disclaimerInput, { target: { value: 'My disclaimer' } })
await waitFor(() => {
expect(parseParamsSchemaMock).toHaveBeenCalledWith('{}')
})
await act(async () => {
fireEvent.click(screen.getByText('common.operation.save'))
})
await waitFor(() => {
expect(mockOnAdd).toHaveBeenCalledWith(expect.objectContaining({
privacy_policy: 'https://privacy.example.com',
custom_disclaimer: 'My disclaimer',
}))
})
})
})
// Tests for Props
describe('Props', () => {
it('should render with positionLeft prop', () => {
renderModal({ positionLeft: true })
expect(screen.getByText('tools.createTool.title')).toBeInTheDocument()
})
it('should render with dialogClassName prop', () => {
renderModal({ dialogClassName: 'custom-dialog-class' })
expect(screen.getByText('tools.createTool.title')).toBeInTheDocument()
})
})
// Tests for getPath helper function
describe('URL Path Extraction', () => {
const payloadWithVariousUrls = (serverUrl: string) => ({
provider: 'test-provider',
credentials: { auth_type: AuthType.none },
schema_type: 'openapi',
schema: '{}',
icon: { content: '🔧', background: '#FFCC00' },
tools: [{
operation_id: 'testOp',
summary: 'Test',
method: 'GET',
server_url: serverUrl,
parameters: [],
}],
})
it('should extract path from full URL', () => {
renderModal({ payload: payloadWithVariousUrls('https://api.example.com/users/list') })
expect(screen.getByText('/users/list')).toBeInTheDocument()
})
it('should handle URL with encoded characters', () => {
renderModal({ payload: payloadWithVariousUrls('https://api.example.com/users%20list') })
expect(screen.getByText('/users list')).toBeInTheDocument()
})
it('should handle empty URL', () => {
renderModal({ payload: payloadWithVariousUrls('') })
// Should not crash and show the row
expect(screen.getByText('testOp')).toBeInTheDocument()
})
it('should handle invalid URL by returning the original string', () => {
renderModal({ payload: payloadWithVariousUrls('not-a-valid-url') })
// Should show the original string
expect(screen.getByText('not-a-valid-url')).toBeInTheDocument()
})
it('should handle URL with only domain', () => {
renderModal({ payload: payloadWithVariousUrls('https://api.example.com') })
// Path would be empty or "/"
expect(screen.getByText('testOp')).toBeInTheDocument()
})
})
// Tests for Schema spec link
describe('Schema Spec Link', () => {
it('should render swagger spec link', () => {
renderModal()
const link = screen.getByText('tools.createTool.viewSchemaSpec')
expect(link.closest('a')).toHaveAttribute('href', 'https://swagger.io/specification/')
expect(link.closest('a')).toHaveAttribute('target', '_blank')
})
})
})

View File

@ -1,6 +1,7 @@
import type { CustomCollectionBackend, CustomParamSchema } from '@/app/components/tools/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { AuthType } from '@/app/components/tools/types'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthHeaderPrefix, AuthType } from '@/app/components/tools/types'
import { testAPIAvailable } from '@/service/tools'
import TestApi from './test-api'
@ -28,6 +29,7 @@ describe('TestApi', () => {
id: 'test-id',
labels: [],
}
const tool: CustomParamSchema = {
operation_id: 'testOp',
summary: 'summary',
@ -39,46 +41,305 @@ describe('TestApi', () => {
en_US: 'Limit',
zh_Hans: '限制',
},
// eslint-disable-next-line ts/no-explicit-any
} as any],
} as CustomParamSchema['parameters'][0]],
}
const renderTestApi = () => {
const mockOnHide = vi.fn()
const renderTestApi = (props?: {
customCollection?: CustomCollectionBackend
tool?: CustomParamSchema
positionCenter?: boolean
}) => {
return render(
<TestApi
customCollection={customCollection}
tool={tool}
onHide={vi.fn()}
customCollection={props?.customCollection ?? customCollection}
tool={props?.tool ?? tool}
onHide={props ? mockOnHide : vi.fn()}
positionCenter={props?.positionCenter}
/>,
)
}
beforeEach(() => {
vi.clearAllMocks()
testAPIAvailableMock.mockReset()
})
it('renders parameters and runs the API test', async () => {
testAPIAvailableMock.mockResolvedValueOnce({ result: 'ok' })
renderTestApi()
// Tests for basic rendering
describe('Rendering', () => {
it('should render without crashing', async () => {
await act(async () => {
renderTestApi()
})
const parameterInput = screen.getAllByRole('textbox')[0]
fireEvent.change(parameterInput, { target: { value: '5' } })
fireEvent.click(screen.getByRole('button', { name: 'tools.test.title' }))
expect(screen.getByText('tools.test.testResult')).toBeInTheDocument()
})
await waitFor(() => {
expect(testAPIAvailableMock).toHaveBeenCalledWith({
provider_name: customCollection.provider,
tool_name: tool.operation_id,
it('should display tool name in the title', async () => {
await act(async () => {
renderTestApi()
})
expect(screen.getByText(/testOp/)).toBeInTheDocument()
})
it('should render parameters table', async () => {
await act(async () => {
renderTestApi()
})
expect(screen.getByText('tools.test.parameters')).toBeInTheDocument()
expect(screen.getByText('tools.test.value')).toBeInTheDocument()
expect(screen.getByText('Limit')).toBeInTheDocument()
})
it('should render test result placeholder', async () => {
await act(async () => {
renderTestApi()
})
expect(screen.getByText('tools.test.testResultPlaceholder')).toBeInTheDocument()
})
it('should render with positionCenter prop', async () => {
await act(async () => {
renderTestApi({ positionCenter: true })
})
expect(screen.getByText('tools.test.testResult')).toBeInTheDocument()
})
})
// Tests for API test execution
describe('API Test Execution', () => {
it('should run API test with parameters and show result', async () => {
testAPIAvailableMock.mockResolvedValueOnce({ result: 'ok' })
renderTestApi()
const parameterInput = screen.getAllByRole('textbox')[0]
fireEvent.change(parameterInput, { target: { value: '5' } })
fireEvent.click(screen.getByRole('button', { name: 'tools.test.title' }))
await waitFor(() => {
expect(testAPIAvailableMock).toHaveBeenCalledWith({
provider_name: customCollection.provider,
tool_name: tool.operation_id,
credentials: {
auth_type: AuthType.none,
},
schema_type: customCollection.schema_type,
schema: customCollection.schema,
parameters: {
limit: '5',
},
})
expect(screen.getByText('ok')).toBeInTheDocument()
})
})
it('should display error result when API returns error', async () => {
testAPIAvailableMock.mockResolvedValueOnce({ error: 'API Error occurred' })
renderTestApi()
fireEvent.click(screen.getByRole('button', { name: 'tools.test.title' }))
await waitFor(() => {
expect(screen.getByText('API Error occurred')).toBeInTheDocument()
})
})
it('should call API when test button is clicked', async () => {
testAPIAvailableMock.mockResolvedValueOnce({ result: 'test completed' })
await act(async () => {
renderTestApi()
})
// Click test button
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'tools.test.title' }))
})
// API should have been called
await waitFor(() => {
expect(testAPIAvailableMock).toHaveBeenCalledTimes(1)
expect(screen.getByText('test completed')).toBeInTheDocument()
})
})
it('should strip extra credential fields when auth_type is none', async () => {
const collectionWithExtraFields: CustomCollectionBackend = {
...customCollection,
credentials: {
auth_type: AuthType.none,
api_key_header: 'X-Api-Key',
api_key_header_prefix: AuthHeaderPrefix.bearer,
api_key_value: 'secret',
},
schema_type: customCollection.schema_type,
schema: customCollection.schema,
parameters: {
limit: '5',
},
}
testAPIAvailableMock.mockResolvedValueOnce({ result: 'success' })
renderTestApi({ customCollection: collectionWithExtraFields })
fireEvent.click(screen.getByRole('button', { name: 'tools.test.title' }))
await waitFor(() => {
expect(testAPIAvailableMock).toHaveBeenCalledWith(
expect.objectContaining({
credentials: {
auth_type: AuthType.none,
},
}),
)
})
})
})
// Tests for credentials modal
describe('Credentials Modal', () => {
it('should show auth method display text', async () => {
await act(async () => {
renderTestApi()
})
// Check that the auth method is displayed
expect(screen.getByText('tools.createTool.authMethod.types.none')).toBeInTheDocument()
})
it('should display current auth type in the button', async () => {
const collectionWithHeader: CustomCollectionBackend = {
...customCollection,
credentials: {
auth_type: AuthType.apiKeyHeader,
api_key_header: 'X-Api-Key',
api_key_header_prefix: AuthHeaderPrefix.bearer,
api_key_value: 'token',
},
}
await act(async () => {
renderTestApi({ customCollection: collectionWithHeader })
})
// Check that the auth method display shows the correct type
expect(screen.getByText('tools.createTool.authMethod.types.api_key_header')).toBeInTheDocument()
})
})
// Tests for multiple parameters
describe('Multiple Parameters', () => {
it('should handle multiple parameters', async () => {
const toolWithMultipleParams: CustomParamSchema = {
...tool,
parameters: [
{
name: 'limit',
label: { en_US: 'Limit', zh_Hans: '限制' },
} as CustomParamSchema['parameters'][0],
{
name: 'offset',
label: { en_US: 'Offset', zh_Hans: '偏移' },
} as CustomParamSchema['parameters'][0],
],
}
testAPIAvailableMock.mockResolvedValueOnce({ result: 'multi-param success' })
renderTestApi({ tool: toolWithMultipleParams })
const inputs = screen.getAllByRole('textbox')
fireEvent.change(inputs[0], { target: { value: '10' } })
fireEvent.change(inputs[1], { target: { value: '20' } })
fireEvent.click(screen.getByRole('button', { name: 'tools.test.title' }))
await waitFor(() => {
expect(testAPIAvailableMock).toHaveBeenCalledWith(
expect.objectContaining({
parameters: {
limit: '10',
offset: '20',
},
}),
)
})
})
it('should handle empty parameters', async () => {
testAPIAvailableMock.mockResolvedValueOnce({ result: 'empty params success' })
renderTestApi()
// Don't fill in any parameters
fireEvent.click(screen.getByRole('button', { name: 'tools.test.title' }))
await waitFor(() => {
expect(testAPIAvailableMock).toHaveBeenCalledWith(
expect.objectContaining({
parameters: {},
}),
)
})
})
})
// Tests for different auth types
describe('Different Auth Types', () => {
it('should pass apiKeyHeader credentials to API', async () => {
const collectionWithHeader: CustomCollectionBackend = {
...customCollection,
credentials: {
auth_type: AuthType.apiKeyHeader,
api_key_header: 'Authorization',
api_key_header_prefix: AuthHeaderPrefix.bearer,
api_key_value: 'test-token',
},
}
testAPIAvailableMock.mockResolvedValueOnce({ result: 'header auth success' })
renderTestApi({ customCollection: collectionWithHeader })
fireEvent.click(screen.getByRole('button', { name: 'tools.test.title' }))
await waitFor(() => {
expect(testAPIAvailableMock).toHaveBeenCalledWith(
expect.objectContaining({
credentials: {
auth_type: AuthType.apiKeyHeader,
api_key_header: 'Authorization',
api_key_header_prefix: AuthHeaderPrefix.bearer,
api_key_value: 'test-token',
},
}),
)
})
})
it('should pass apiKeyQuery credentials to API', async () => {
const collectionWithQuery: CustomCollectionBackend = {
...customCollection,
credentials: {
auth_type: AuthType.apiKeyQuery,
api_key_query_param: 'api_key',
api_key_value: 'query-token',
},
}
testAPIAvailableMock.mockResolvedValueOnce({ result: 'query auth success' })
renderTestApi({ customCollection: collectionWithQuery })
fireEvent.click(screen.getByRole('button', { name: 'tools.test.title' }))
await waitFor(() => {
expect(testAPIAvailableMock).toHaveBeenCalledWith(
expect.objectContaining({
credentials: {
auth_type: AuthType.apiKeyQuery,
api_key_query_param: 'api_key',
api_key_value: 'query-token',
},
}),
)
})
expect(screen.getByText('ok')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,329 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import LabelFilter from './filter'
// Mock useTags hook with controlled test data
const mockTags = [
{ name: 'agent', label: 'Agent' },
{ name: 'rag', label: 'RAG' },
{ name: 'search', label: 'Search' },
{ name: 'image', label: 'Image' },
]
vi.mock('@/app/components/plugins/hooks', () => ({
useTags: () => ({
tags: mockTags,
tagsMap: mockTags.reduce((acc, tag) => ({ ...acc, [tag.name]: tag }), {}),
getTagLabel: (name: string) => mockTags.find(t => t.name === name)?.label ?? name,
}),
}))
// Mock useDebounceFn to store the function and allow manual triggering
let debouncedFn: (() => void) | null = null
vi.mock('ahooks', () => ({
useDebounceFn: (fn: () => void) => {
debouncedFn = fn
return {
run: () => {
// Schedule to run after React state updates
setTimeout(() => debouncedFn?.(), 0)
},
cancel: vi.fn(),
}
},
}))
describe('LabelFilter', () => {
const mockOnChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
debouncedFn = null
})
afterEach(() => {
vi.useRealTimers()
})
// Rendering Tests
describe('Rendering', () => {
it('should render without crashing', () => {
render(<LabelFilter value={[]} onChange={mockOnChange} />)
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
})
it('should display placeholder when no labels selected', () => {
render(<LabelFilter value={[]} onChange={mockOnChange} />)
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
})
it('should display selected label when one label is selected', () => {
render(<LabelFilter value={['agent']} onChange={mockOnChange} />)
expect(screen.getByText('Agent')).toBeInTheDocument()
})
it('should display count badge when multiple labels are selected', () => {
render(<LabelFilter value={['agent', 'rag', 'search']} onChange={mockOnChange} />)
expect(screen.getByText('Agent')).toBeInTheDocument()
expect(screen.getByText('+2')).toBeInTheDocument()
})
})
// Dropdown Tests
describe('Dropdown', () => {
it('should open dropdown when trigger is clicked', async () => {
render(<LabelFilter value={[]} onChange={mockOnChange} />)
const trigger = screen.getByText('common.tag.placeholder')
await act(async () => {
fireEvent.click(trigger)
vi.advanceTimersByTime(10)
})
mockTags.forEach((tag) => {
expect(screen.getByText(tag.label)).toBeInTheDocument()
})
})
it('should close dropdown when trigger is clicked again', async () => {
render(<LabelFilter value={[]} onChange={mockOnChange} />)
const trigger = screen.getByText('common.tag.placeholder')
// Open
await act(async () => {
fireEvent.click(trigger)
vi.advanceTimersByTime(10)
})
expect(screen.getByText('Agent')).toBeInTheDocument()
// Close
await act(async () => {
fireEvent.click(trigger)
vi.advanceTimersByTime(10)
})
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
})
// Selection Tests
describe('Selection', () => {
it('should call onChange with selected label when clicking a label', async () => {
render(<LabelFilter value={[]} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('common.tag.placeholder'))
vi.advanceTimersByTime(10)
})
expect(screen.getByText('Agent')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByText('Agent'))
vi.advanceTimersByTime(10)
})
expect(mockOnChange).toHaveBeenCalledWith(['agent'])
})
it('should remove label from selection when clicking already selected label', async () => {
render(<LabelFilter value={['agent']} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('Agent'))
vi.advanceTimersByTime(10)
})
// Find the label item in the dropdown list
const labelItems = screen.getAllByText('Agent')
const dropdownItem = labelItems.find(el => el.closest('.hover\\:bg-state-base-hover'))
await act(async () => {
if (dropdownItem)
fireEvent.click(dropdownItem)
vi.advanceTimersByTime(10)
})
expect(mockOnChange).toHaveBeenCalledWith([])
})
it('should add label to existing selection', async () => {
render(<LabelFilter value={['agent']} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('Agent'))
vi.advanceTimersByTime(10)
})
expect(screen.getByText('RAG')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByText('RAG'))
vi.advanceTimersByTime(10)
})
expect(mockOnChange).toHaveBeenCalledWith(['agent', 'rag'])
})
})
// Clear Tests
describe('Clear', () => {
it('should clear all selections when clear button is clicked', async () => {
render(<LabelFilter value={['agent', 'rag']} onChange={mockOnChange} />)
// Find and click the clear button (XCircle icon's parent)
const clearButton = document.querySelector('.group\\/clear')
expect(clearButton).toBeInTheDocument()
fireEvent.click(clearButton!)
expect(mockOnChange).toHaveBeenCalledWith([])
})
it('should not show clear button when no labels selected', () => {
render(<LabelFilter value={[]} onChange={mockOnChange} />)
const clearButton = document.querySelector('.group\\/clear')
expect(clearButton).not.toBeInTheDocument()
})
})
// Search Tests
describe('Search', () => {
it('should filter labels based on search input by name', async () => {
render(<LabelFilter value={[]} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('common.tag.placeholder'))
vi.advanceTimersByTime(10)
})
expect(screen.getByRole('textbox')).toBeInTheDocument()
await act(async () => {
const searchInput = screen.getByRole('textbox')
// Filter by 'rag' which only matches 'rag' name
fireEvent.change(searchInput, { target: { value: 'rag' } })
vi.advanceTimersByTime(10)
})
// Only RAG should be visible (rag contains 'rag')
expect(screen.getByTitle('RAG')).toBeInTheDocument()
// Agent should not be in the dropdown list (agent doesn't contain 'rag')
expect(screen.queryByTitle('Agent')).not.toBeInTheDocument()
})
it('should show empty state when no labels match search', async () => {
render(<LabelFilter value={[]} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('common.tag.placeholder'))
vi.advanceTimersByTime(10)
})
expect(screen.getByRole('textbox')).toBeInTheDocument()
await act(async () => {
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: 'nonexistent' } })
vi.advanceTimersByTime(10)
})
expect(screen.getByText('common.tag.noTag')).toBeInTheDocument()
})
it('should show all labels when search is cleared', async () => {
render(<LabelFilter value={[]} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('common.tag.placeholder'))
vi.advanceTimersByTime(10)
})
expect(screen.getByRole('textbox')).toBeInTheDocument()
await act(async () => {
const searchInput = screen.getByRole('textbox')
// First filter to show only RAG
fireEvent.change(searchInput, { target: { value: 'rag' } })
vi.advanceTimersByTime(10)
})
expect(screen.getByTitle('RAG')).toBeInTheDocument()
expect(screen.queryByTitle('Agent')).not.toBeInTheDocument()
await act(async () => {
// Clear the input
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: '' } })
vi.advanceTimersByTime(10)
})
// All labels should be visible again
expect(screen.getByTitle('Agent')).toBeInTheDocument()
expect(screen.getByTitle('RAG')).toBeInTheDocument()
})
})
// Edge Cases
describe('Edge Cases', () => {
it('should handle empty label list', async () => {
// Temporarily mock empty tags
vi.doMock('@/app/components/plugins/hooks', () => ({
useTags: () => ({
tags: [],
tagsMap: {},
getTagLabel: (name: string) => name,
}),
}))
render(<LabelFilter value={[]} onChange={mockOnChange} />)
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
})
it('should handle value with non-existent label', () => {
render(<LabelFilter value={['nonexistent']} onChange={mockOnChange} />)
// Should still render without crashing
expect(document.querySelector('.text-text-tertiary')).toBeInTheDocument()
})
})
// Props Tests
describe('Props', () => {
it('should receive value as array of strings', () => {
render(<LabelFilter value={['agent', 'rag']} onChange={mockOnChange} />)
expect(screen.getByText('Agent')).toBeInTheDocument()
expect(screen.getByText('+1')).toBeInTheDocument()
})
it('should call onChange with updated array', async () => {
render(<LabelFilter value={[]} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('common.tag.placeholder'))
vi.advanceTimersByTime(10)
})
expect(screen.getByText('Agent')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByText('Agent'))
vi.advanceTimersByTime(10)
})
expect(mockOnChange).toHaveBeenCalledTimes(1)
expect(mockOnChange).toHaveBeenCalledWith(['agent'])
})
})
})

View File

@ -0,0 +1,319 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import LabelSelector from './selector'
// Mock useTags hook with controlled test data
const mockTags = [
{ name: 'agent', label: 'Agent' },
{ name: 'rag', label: 'RAG' },
{ name: 'search', label: 'Search' },
{ name: 'image', label: 'Image' },
]
vi.mock('@/app/components/plugins/hooks', () => ({
useTags: () => ({
tags: mockTags,
tagsMap: mockTags.reduce((acc, tag) => ({ ...acc, [tag.name]: tag }), {}),
getTagLabel: (name: string) => mockTags.find(t => t.name === name)?.label ?? name,
}),
}))
// Mock useDebounceFn to store the function and allow manual triggering
let debouncedFn: (() => void) | null = null
vi.mock('ahooks', () => ({
useDebounceFn: (fn: () => void) => {
debouncedFn = fn
return {
run: () => {
// Schedule to run after React state updates
setTimeout(() => debouncedFn?.(), 0)
},
cancel: vi.fn(),
}
},
}))
describe('LabelSelector', () => {
const mockOnChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
debouncedFn = null
})
afterEach(() => {
vi.useRealTimers()
})
// Rendering Tests
describe('Rendering', () => {
it('should render without crashing', () => {
render(<LabelSelector value={[]} onChange={mockOnChange} />)
expect(screen.getByText('tools.createTool.toolInput.labelPlaceholder')).toBeInTheDocument()
})
it('should display placeholder when no labels selected', () => {
render(<LabelSelector value={[]} onChange={mockOnChange} />)
expect(screen.getByText('tools.createTool.toolInput.labelPlaceholder')).toBeInTheDocument()
})
it('should display selected labels as comma-separated list', () => {
render(<LabelSelector value={['agent', 'rag']} onChange={mockOnChange} />)
expect(screen.getByText('Agent, RAG')).toBeInTheDocument()
})
it('should display single selected label', () => {
render(<LabelSelector value={['agent']} onChange={mockOnChange} />)
expect(screen.getByText('Agent')).toBeInTheDocument()
})
})
// Dropdown Tests
describe('Dropdown', () => {
it('should open dropdown when trigger is clicked', async () => {
render(<LabelSelector value={[]} onChange={mockOnChange} />)
const trigger = screen.getByText('tools.createTool.toolInput.labelPlaceholder')
await act(async () => {
fireEvent.click(trigger)
vi.advanceTimersByTime(10)
})
// Checkboxes should be visible
mockTags.forEach((tag) => {
expect(screen.getByText(tag.label)).toBeInTheDocument()
})
})
it('should close dropdown when trigger is clicked again', async () => {
render(<LabelSelector value={[]} onChange={mockOnChange} />)
const trigger = screen.getByText('tools.createTool.toolInput.labelPlaceholder')
// Open
await act(async () => {
fireEvent.click(trigger)
vi.advanceTimersByTime(10)
})
expect(screen.getByText('Agent')).toBeInTheDocument()
// Close
await act(async () => {
fireEvent.click(trigger)
vi.advanceTimersByTime(10)
})
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
})
// Selection Tests
describe('Selection', () => {
it('should call onChange with selected label when clicking a label', async () => {
render(<LabelSelector value={[]} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('tools.createTool.toolInput.labelPlaceholder'))
vi.advanceTimersByTime(10)
})
expect(screen.getByText('Agent')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByTitle('Agent'))
vi.advanceTimersByTime(10)
})
expect(mockOnChange).toHaveBeenCalledWith(['agent'])
})
it('should remove label from selection when clicking already selected label', async () => {
render(<LabelSelector value={['agent']} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('Agent'))
vi.advanceTimersByTime(10)
})
// Find the label item in the dropdown list and click it
// Use getAllByTitle and select the one in the dropdown (with text-sm class)
const agentElements = screen.getAllByTitle('Agent')
const dropdownItem = agentElements.find(el =>
el.classList.contains('text-sm'),
)
await act(async () => {
if (dropdownItem)
fireEvent.click(dropdownItem)
vi.advanceTimersByTime(10)
})
expect(mockOnChange).toHaveBeenCalledWith([])
})
it('should add label to existing selection', async () => {
render(<LabelSelector value={['agent']} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('Agent'))
vi.advanceTimersByTime(10)
})
expect(screen.getByTitle('RAG')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByTitle('RAG'))
vi.advanceTimersByTime(10)
})
expect(mockOnChange).toHaveBeenCalledWith(['agent', 'rag'])
})
it('should show checkboxes in dropdown', async () => {
render(<LabelSelector value={['agent']} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('Agent'))
vi.advanceTimersByTime(10)
})
// Checkboxes should be visible in the dropdown
const checkboxes = document.querySelectorAll('[data-testid^="checkbox"]')
expect(checkboxes.length).toBeGreaterThan(0)
})
})
// Search Tests
describe('Search', () => {
it('should filter labels based on search input by name', async () => {
render(<LabelSelector value={[]} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('tools.createTool.toolInput.labelPlaceholder'))
vi.advanceTimersByTime(10)
})
expect(screen.getByRole('textbox')).toBeInTheDocument()
await act(async () => {
const searchInput = screen.getByRole('textbox')
// Filter by 'rag' which only matches 'rag' name
fireEvent.change(searchInput, { target: { value: 'rag' } })
vi.advanceTimersByTime(10)
})
// Only RAG should be visible (rag contains 'rag')
expect(screen.getByTitle('RAG')).toBeInTheDocument()
// Agent should not be in the dropdown list (agent doesn't contain 'rag')
expect(screen.queryByTitle('Agent')).not.toBeInTheDocument()
})
it('should show empty state when no labels match search', async () => {
render(<LabelSelector value={[]} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('tools.createTool.toolInput.labelPlaceholder'))
vi.advanceTimersByTime(10)
})
expect(screen.getByRole('textbox')).toBeInTheDocument()
await act(async () => {
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: 'nonexistent' } })
vi.advanceTimersByTime(10)
})
expect(screen.getByText('common.tag.noTag')).toBeInTheDocument()
})
it('should show all labels when search is cleared', async () => {
render(<LabelSelector value={[]} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('tools.createTool.toolInput.labelPlaceholder'))
vi.advanceTimersByTime(10)
})
expect(screen.getByRole('textbox')).toBeInTheDocument()
await act(async () => {
const searchInput = screen.getByRole('textbox')
// First filter to show only RAG
fireEvent.change(searchInput, { target: { value: 'rag' } })
vi.advanceTimersByTime(10)
})
expect(screen.getByTitle('RAG')).toBeInTheDocument()
expect(screen.queryByTitle('Agent')).not.toBeInTheDocument()
await act(async () => {
// Clear the input
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: '' } })
vi.advanceTimersByTime(10)
})
// All labels should be visible again
expect(screen.getByTitle('Agent')).toBeInTheDocument()
expect(screen.getByTitle('RAG')).toBeInTheDocument()
})
})
// Edge Cases
describe('Edge Cases', () => {
it('should handle empty label list', () => {
render(<LabelSelector value={[]} onChange={mockOnChange} />)
expect(screen.getByText('tools.createTool.toolInput.labelPlaceholder')).toBeInTheDocument()
})
it('should handle value with non-existent label', () => {
render(<LabelSelector value={['nonexistent']} onChange={mockOnChange} />)
// Should still render without crashing, undefined label will be filtered
expect(document.querySelector('.text-text-secondary')).toBeInTheDocument()
})
it('should handle multiple labels display', () => {
render(<LabelSelector value={['agent', 'rag', 'search']} onChange={mockOnChange} />)
expect(screen.getByText('Agent, RAG, Search')).toBeInTheDocument()
})
})
// Props Tests
describe('Props', () => {
it('should receive value as array of strings', () => {
render(<LabelSelector value={['agent', 'rag']} onChange={mockOnChange} />)
expect(screen.getByText('Agent, RAG')).toBeInTheDocument()
})
it('should call onChange with updated array', async () => {
render(<LabelSelector value={[]} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('tools.createTool.toolInput.labelPlaceholder'))
vi.advanceTimersByTime(10)
})
expect(screen.getByText('Agent')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByTitle('Agent'))
vi.advanceTimersByTime(10)
})
expect(mockOnChange).toHaveBeenCalledTimes(1)
expect(mockOnChange).toHaveBeenCalledWith(['agent'])
})
})
})

Some files were not shown because too many files have changed in this diff Show More