test: add tests for dataset document detail (#31274)

Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
Coding On Star
2026-01-27 15:43:27 +08:00
committed by GitHub
parent eca26a9b9b
commit c8abe1c306
105 changed files with 28225 additions and 686 deletions

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