mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 17:38:04 +08:00
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:
426
web/app/components/datasets/common/check-rerank-model.spec.ts
Normal file
426
web/app/components/datasets/common/check-rerank-model.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
136
web/app/components/datasets/common/credential-icon.spec.tsx
Normal file
136
web/app/components/datasets/common/credential-icon.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
115
web/app/components/datasets/common/document-file-icon.spec.tsx
Normal file
115
web/app/components/datasets/common/document-file-icon.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
252
web/app/components/datasets/common/image-list/index.spec.tsx
Normal file
252
web/app/components/datasets/common/image-list/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
144
web/app/components/datasets/common/image-list/more.spec.tsx
Normal file
144
web/app/components/datasets/common/image-list/more.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
305
web/app/components/datasets/common/image-uploader/store.spec.tsx
Normal file
305
web/app/components/datasets/common/image-uploader/store.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
310
web/app/components/datasets/common/image-uploader/utils.spec.ts
Normal file
310
web/app/components/datasets/common/image-uploader/utils.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user