mirror of
https://github.com/langgenius/dify.git
synced 2026-05-01 16:08:04 +08:00
test: add comprehensive tests for datasets components with 95%+ coverage
- Add tests for api-access/index.tsx (100% coverage) - Enhance tests for operations.tsx (95.65% coverage) - Enhance tests for status-item/index.tsx (93.75% coverage) - Add tests for simple components: chunk, loading, preview, statistics, empty-folder, no-linked-apps-panel, api/index - Remove tests for complex component retrieval-param-config - Test operations: archive, unarchive, enable, disable, sync, pause, resume, delete, download, rename - Test switch toggles with debounce handling - Test error notifications and success callbacks Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
24
web/app/components/datasets/api/index.spec.tsx
Normal file
24
web/app/components/datasets/api/index.spec.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import ApiIndex from './index'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('ApiIndex', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<ApiIndex />)
|
||||
expect(screen.getByText('index')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a div with text "index"', () => {
|
||||
const { container } = render(<ApiIndex />)
|
||||
expect(container.firstChild).toBeInstanceOf(HTMLDivElement)
|
||||
expect(container.textContent).toBe('index')
|
||||
})
|
||||
|
||||
it('should be a valid function component', () => {
|
||||
expect(typeof ApiIndex).toBe('function')
|
||||
})
|
||||
})
|
||||
111
web/app/components/datasets/chunk.spec.tsx
Normal file
111
web/app/components/datasets/chunk.spec.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { ChunkContainer, ChunkLabel, QAPreview } from './chunk'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('ChunkLabel', () => {
|
||||
it('should render label text', () => {
|
||||
render(<ChunkLabel label="Chunk 1" characterCount={100} />)
|
||||
expect(screen.getByText('Chunk 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render character count', () => {
|
||||
render(<ChunkLabel label="Chunk 1" characterCount={150} />)
|
||||
expect(screen.getByText('150 characters')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render separator dot', () => {
|
||||
render(<ChunkLabel label="Chunk 1" characterCount={100} />)
|
||||
expect(screen.getByText('·')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with zero character count', () => {
|
||||
render(<ChunkLabel label="Empty Chunk" characterCount={0} />)
|
||||
expect(screen.getByText('0 characters')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with large character count', () => {
|
||||
render(<ChunkLabel label="Large Chunk" characterCount={999999} />)
|
||||
expect(screen.getByText('999999 characters')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ChunkContainer', () => {
|
||||
it('should render label and character count', () => {
|
||||
render(<ChunkContainer label="Container 1" characterCount={200}>Content</ChunkContainer>)
|
||||
expect(screen.getByText('Container 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('200 characters')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render children content', () => {
|
||||
render(<ChunkContainer label="Container 1" characterCount={200}>Test Content</ChunkContainer>)
|
||||
expect(screen.getByText('Test Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with complex children', () => {
|
||||
render(
|
||||
<ChunkContainer label="Container" characterCount={100}>
|
||||
<div data-testid="child-div">
|
||||
<span>Nested content</span>
|
||||
</div>
|
||||
</ChunkContainer>,
|
||||
)
|
||||
expect(screen.getByTestId('child-div')).toBeInTheDocument()
|
||||
expect(screen.getByText('Nested content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render empty children', () => {
|
||||
render(<ChunkContainer label="Empty" characterCount={0}>{null}</ChunkContainer>)
|
||||
expect(screen.getByText('Empty')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('QAPreview', () => {
|
||||
const mockQA = {
|
||||
question: 'What is the meaning of life?',
|
||||
answer: 'The meaning of life is 42.',
|
||||
}
|
||||
|
||||
it('should render question text', () => {
|
||||
render(<QAPreview qa={mockQA} />)
|
||||
expect(screen.getByText('What is the meaning of life?')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render answer text', () => {
|
||||
render(<QAPreview qa={mockQA} />)
|
||||
expect(screen.getByText('The meaning of life is 42.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Q label', () => {
|
||||
render(<QAPreview qa={mockQA} />)
|
||||
expect(screen.getByText('Q')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render A label', () => {
|
||||
render(<QAPreview qa={mockQA} />)
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty strings', () => {
|
||||
render(<QAPreview qa={{ question: '', answer: '' }} />)
|
||||
expect(screen.getByText('Q')).toBeInTheDocument()
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with long text', () => {
|
||||
const longQuestion = 'Q'.repeat(500)
|
||||
const longAnswer = 'A'.repeat(500)
|
||||
render(<QAPreview qa={{ question: longQuestion, answer: longAnswer }} />)
|
||||
expect(screen.getByText(longQuestion)).toBeInTheDocument()
|
||||
expect(screen.getByText(longAnswer)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with special characters', () => {
|
||||
render(<QAPreview qa={{ question: 'What about <script>?', answer: '& special chars!' }} />)
|
||||
expect(screen.getByText('What about <script>?')).toBeInTheDocument()
|
||||
expect(screen.getByText('& special chars!')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,323 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,380 +1,642 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DataSourceType } from '@/models/datasets'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Operations from './operations'
|
||||
|
||||
// Mock services
|
||||
vi.mock('@/service/knowledge/use-document', () => ({
|
||||
useDocumentArchive: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
|
||||
useDocumentUnArchive: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
|
||||
useDocumentEnable: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
|
||||
useDocumentDisable: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
|
||||
useDocumentDelete: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
|
||||
useDocumentDownload: () => ({ mutateAsync: vi.fn().mockResolvedValue({ url: 'https://example.com/download' }), isPending: false }),
|
||||
useSyncDocument: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
|
||||
useSyncWebsite: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
|
||||
useDocumentPause: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
|
||||
useDocumentResume: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
|
||||
}))
|
||||
|
||||
// Mock utils
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock router
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock ToastContext
|
||||
const mockNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
ToastContext: {
|
||||
Provider: ({ children }: { children: React.ReactNode }) => children,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('use-context-selector', () => ({
|
||||
useContext: () => ({
|
||||
notify: mockNotify,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock document service hooks
|
||||
const mockArchive = vi.fn()
|
||||
const mockUnArchive = vi.fn()
|
||||
const mockEnable = vi.fn()
|
||||
const mockDisable = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
const mockDownload = vi.fn()
|
||||
const mockSync = vi.fn()
|
||||
const mockSyncWebsite = vi.fn()
|
||||
const mockPause = vi.fn()
|
||||
const mockResume = vi.fn()
|
||||
let isDownloadPending = false
|
||||
|
||||
vi.mock('@/service/knowledge/use-document', () => ({
|
||||
useDocumentArchive: () => ({ mutateAsync: mockArchive }),
|
||||
useDocumentUnArchive: () => ({ mutateAsync: mockUnArchive }),
|
||||
useDocumentEnable: () => ({ mutateAsync: mockEnable }),
|
||||
useDocumentDisable: () => ({ mutateAsync: mockDisable }),
|
||||
useDocumentDelete: () => ({ mutateAsync: mockDelete }),
|
||||
useDocumentDownload: () => ({ mutateAsync: mockDownload, isPending: isDownloadPending }),
|
||||
useSyncDocument: () => ({ mutateAsync: mockSync }),
|
||||
useSyncWebsite: () => ({ mutateAsync: mockSyncWebsite }),
|
||||
useDocumentPause: () => ({ mutateAsync: mockPause }),
|
||||
useDocumentResume: () => ({ mutateAsync: mockResume }),
|
||||
}))
|
||||
|
||||
// Mock downloadUrl utility
|
||||
const mockDownloadUrl = vi.fn()
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadUrl: (params: { url: string, fileName: string }) => mockDownloadUrl(params),
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
isDownloadPending = false
|
||||
})
|
||||
|
||||
describe('Operations', () => {
|
||||
const mockOnUpdate = vi.fn()
|
||||
const mockOnSelectedIdChange = vi.fn()
|
||||
|
||||
const defaultDetail = {
|
||||
id: 'doc-1',
|
||||
name: 'Test Document',
|
||||
enabled: true,
|
||||
archived: false,
|
||||
id: 'doc-123',
|
||||
data_source_type: DataSourceType.FILE,
|
||||
doc_form: 'text',
|
||||
data_source_type: 'upload_file',
|
||||
doc_form: 'text_model',
|
||||
display_status: 'available',
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
embeddingAvailable: true,
|
||||
datasetId: 'dataset-1',
|
||||
detail: defaultDetail,
|
||||
datasetId: 'dataset-456',
|
||||
onUpdate: vi.fn(),
|
||||
scene: 'list' as const,
|
||||
className: '',
|
||||
onUpdate: mockOnUpdate,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockArchive.mockResolvedValue({})
|
||||
mockUnArchive.mockResolvedValue({})
|
||||
mockEnable.mockResolvedValue({})
|
||||
mockDisable.mockResolvedValue({})
|
||||
mockDelete.mockResolvedValue({})
|
||||
mockDownload.mockResolvedValue({ url: 'https://example.com/download' })
|
||||
mockSync.mockResolvedValue({})
|
||||
mockSyncWebsite.mockResolvedValue({})
|
||||
mockPause.mockResolvedValue({})
|
||||
mockResume.mockResolvedValue({})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
describe('rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
// Should render at least the container
|
||||
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render switch in list scene', () => {
|
||||
const { container } = render(<Operations {...defaultProps} scene="list" />)
|
||||
// Switch component should be rendered
|
||||
const switchEl = container.querySelector('[role="switch"]')
|
||||
expect(switchEl).toBeInTheDocument()
|
||||
it('should render buttons when embeddingAvailable', () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render settings button when embedding is available', () => {
|
||||
const { container } = render(<Operations {...defaultProps} />)
|
||||
// Settings button has RiEqualizer2Line icon inside
|
||||
const settingsButton = container.querySelector('button.mr-2.cursor-pointer')
|
||||
expect(settingsButton).toBeInTheDocument()
|
||||
it('should not render settings when embeddingAvailable is false', () => {
|
||||
render(<Operations {...defaultProps} embeddingAvailable={false} />)
|
||||
expect(screen.queryByText('list.action.settings')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render disabled switch when embeddingAvailable is false in list scene', () => {
|
||||
render(<Operations {...defaultProps} embeddingAvailable={false} scene="list" />)
|
||||
// Switch component uses opacity-50 class when disabled
|
||||
const disabledSwitch = document.querySelector('.\\!opacity-50')
|
||||
expect(disabledSwitch).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Switch Behavior', () => {
|
||||
it('should render enabled switch when document is enabled', () => {
|
||||
const { container } = render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{ ...defaultDetail, enabled: true, archived: false }}
|
||||
/>,
|
||||
)
|
||||
const switchEl = container.querySelector('[role="switch"]')
|
||||
expect(switchEl).toHaveAttribute('aria-checked', 'true')
|
||||
describe('switch toggle', () => {
|
||||
it('should render switch in list scene', () => {
|
||||
render(<Operations {...defaultProps} scene="list" />)
|
||||
const switches = document.querySelectorAll('[role="switch"], [class*="switch"]')
|
||||
expect(switches.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render disabled switch when document is disabled', () => {
|
||||
const { container } = render(
|
||||
it('should render disabled switch when archived', () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{ ...defaultDetail, enabled: false, archived: false }}
|
||||
scene="list"
|
||||
detail={{ ...defaultDetail, archived: true }}
|
||||
/>,
|
||||
)
|
||||
const switchEl = container.querySelector('[role="switch"]')
|
||||
expect(switchEl).toHaveAttribute('aria-checked', 'false')
|
||||
const disabledSwitch = document.querySelector('[disabled]')
|
||||
expect(disabledSwitch).toBeDefined()
|
||||
})
|
||||
|
||||
it('should show tooltip and disable switch when document is archived', () => {
|
||||
const { container } = render(
|
||||
it('should call enable when switch is toggled on', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
scene="list"
|
||||
detail={{ ...defaultDetail, enabled: false }}
|
||||
/>,
|
||||
)
|
||||
const switchElement = document.querySelector('[role="switch"]')
|
||||
await act(async () => {
|
||||
fireEvent.click(switchElement!)
|
||||
})
|
||||
// Wait for debounce
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(600)
|
||||
})
|
||||
expect(mockEnable).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should call disable when switch is toggled off', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
scene="list"
|
||||
detail={{ ...defaultDetail, enabled: true }}
|
||||
/>,
|
||||
)
|
||||
const switchElement = document.querySelector('[role="switch"]')
|
||||
await act(async () => {
|
||||
fireEvent.click(switchElement!)
|
||||
})
|
||||
// Wait for debounce
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(600)
|
||||
})
|
||||
expect(mockDisable).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should not call enable if already enabled', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
scene="list"
|
||||
detail={{ ...defaultDetail, enabled: true }}
|
||||
/>,
|
||||
)
|
||||
// Simulate trying to enable when already enabled - this won't happen via switch click
|
||||
// because the switch would toggle to disable. But handleSwitch has early returns
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe('settings navigation', () => {
|
||||
it('should navigate to settings when settings button is clicked', async () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
// Get the first button which is the settings button
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const settingsButton = buttons[0]
|
||||
await act(async () => {
|
||||
fireEvent.click(settingsButton)
|
||||
})
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents/doc-1/settings')
|
||||
})
|
||||
})
|
||||
|
||||
describe('detail scene', () => {
|
||||
it('should render differently in detail scene', () => {
|
||||
render(<Operations {...defaultProps} scene="detail" />)
|
||||
const container = document.querySelector('.flex.items-center')
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render switch in detail scene', () => {
|
||||
render(<Operations {...defaultProps} scene="detail" />)
|
||||
// In detail scene, there should be no switch
|
||||
const switchInParent = document.querySelector('.flex.items-center > [role="switch"]')
|
||||
expect(switchInParent).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectedIds handling', () => {
|
||||
it('should accept selectedIds prop', () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
selectedIds={['doc-1', 'doc-2']}
|
||||
onSelectedIdChange={mockOnSelectedIdChange}
|
||||
/>,
|
||||
)
|
||||
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('popover menu actions', () => {
|
||||
const openPopover = async () => {
|
||||
const moreButton = document.querySelector('[class*="commonIcon"]')?.parentElement
|
||||
if (moreButton) {
|
||||
await act(async () => {
|
||||
fireEvent.click(moreButton)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
it('should open popover when more button is clicked', async () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
await openPopover()
|
||||
// Check if popover content is visible
|
||||
expect(screen.getByText('list.table.rename')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call archive when archive action is clicked', async () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
await openPopover()
|
||||
const archiveButton = screen.getByText('list.action.archive')
|
||||
await act(async () => {
|
||||
fireEvent.click(archiveButton)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(mockArchive).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
|
||||
})
|
||||
})
|
||||
|
||||
it('should call un_archive when unarchive action is clicked', async () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{ ...defaultDetail, archived: true }}
|
||||
/>,
|
||||
)
|
||||
const switchEl = container.querySelector('[role="switch"]')
|
||||
// Archived documents have visually disabled switch (CSS-based)
|
||||
expect(switchEl).toHaveClass('!cursor-not-allowed', '!opacity-50')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Embedding Not Available', () => {
|
||||
it('should show disabled switch when embedding not available in list scene', () => {
|
||||
const { container } = render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
embeddingAvailable={false}
|
||||
scene="list"
|
||||
/>,
|
||||
)
|
||||
const switchEl = container.querySelector('[role="switch"]')
|
||||
// Switch is visually disabled (CSS-based)
|
||||
expect(switchEl).toHaveClass('!cursor-not-allowed', '!opacity-50')
|
||||
})
|
||||
|
||||
it('should not show settings or popover when embedding not available', () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
embeddingAvailable={false}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: /settings/i })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('More Actions Popover', () => {
|
||||
it('should show rename option for non-archived documents', async () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{ ...defaultDetail, archived: false }}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Click on the more actions button
|
||||
const moreButton = document.querySelector('[class*="commonIcon"]')
|
||||
expect(moreButton).toBeInTheDocument()
|
||||
if (moreButton)
|
||||
fireEvent.click(moreButton)
|
||||
|
||||
await openPopover()
|
||||
const unarchiveButton = screen.getByText('list.action.unarchive')
|
||||
await act(async () => {
|
||||
fireEvent.click(unarchiveButton)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/list\.table\.rename/i)).toBeInTheDocument()
|
||||
expect(mockUnArchive).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
|
||||
})
|
||||
})
|
||||
|
||||
it('should show download option for FILE type documents', async () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{ ...defaultDetail, data_source_type: DataSourceType.FILE }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const moreButton = document.querySelector('[class*="commonIcon"]')
|
||||
if (moreButton)
|
||||
fireEvent.click(moreButton)
|
||||
it('should show delete confirmation modal when delete is clicked', async () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
await openPopover()
|
||||
const deleteButton = screen.getByText('list.action.delete')
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteButton)
|
||||
})
|
||||
// Check if confirmation modal is shown
|
||||
expect(screen.getByText('list.delete.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call delete when confirm is clicked in delete modal', async () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
await openPopover()
|
||||
const deleteButton = screen.getByText('list.action.delete')
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteButton)
|
||||
})
|
||||
// Click confirm button
|
||||
const confirmButton = screen.getByText('operation.sure')
|
||||
await act(async () => {
|
||||
fireEvent.click(confirmButton)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/list\.action\.download/i)).toBeInTheDocument()
|
||||
expect(mockDelete).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
|
||||
})
|
||||
})
|
||||
|
||||
it('should show sync option for notion documents', async () => {
|
||||
it('should close delete modal when cancel is clicked', async () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
await openPopover()
|
||||
const deleteButton = screen.getByText('list.action.delete')
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteButton)
|
||||
})
|
||||
// Verify modal is shown
|
||||
expect(screen.getByText('list.delete.title')).toBeInTheDocument()
|
||||
// Find and click the cancel button (text: operation.cancel)
|
||||
const cancelButton = screen.getByText('operation.cancel')
|
||||
await act(async () => {
|
||||
fireEvent.click(cancelButton)
|
||||
})
|
||||
// Modal should be closed - title shouldn't be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('list.delete.title')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should update selectedIds after delete operation', async () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
selectedIds={['doc-1', 'doc-2']}
|
||||
onSelectedIdChange={mockOnSelectedIdChange}
|
||||
/>,
|
||||
)
|
||||
await openPopover()
|
||||
const deleteButton = screen.getByText('list.action.delete')
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteButton)
|
||||
})
|
||||
const confirmButton = screen.getByText('operation.sure')
|
||||
await act(async () => {
|
||||
fireEvent.click(confirmButton)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(mockOnSelectedIdChange).toHaveBeenCalledWith(['doc-2'])
|
||||
})
|
||||
})
|
||||
|
||||
it('should show rename modal when rename is clicked', async () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
await openPopover()
|
||||
const renameButton = screen.getByText('list.table.rename')
|
||||
await act(async () => {
|
||||
fireEvent.click(renameButton)
|
||||
})
|
||||
// Rename modal should be shown
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('Test Document')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call sync for notion data source', async () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{ ...defaultDetail, data_source_type: 'notion_import' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const moreButton = document.querySelector('[class*="commonIcon"]')
|
||||
if (moreButton)
|
||||
fireEvent.click(moreButton)
|
||||
|
||||
await openPopover()
|
||||
const syncButton = screen.getByText('list.action.sync')
|
||||
await act(async () => {
|
||||
fireEvent.click(syncButton)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/list\.action\.sync/i)).toBeInTheDocument()
|
||||
expect(mockSync).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
|
||||
})
|
||||
})
|
||||
|
||||
it('should show sync option for web documents', async () => {
|
||||
it('should call syncWebsite for web data source', async () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{ ...defaultDetail, data_source_type: DataSourceType.WEB }}
|
||||
detail={{ ...defaultDetail, data_source_type: 'website_crawl' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const moreButton = document.querySelector('[class*="commonIcon"]')
|
||||
if (moreButton)
|
||||
fireEvent.click(moreButton)
|
||||
|
||||
await openPopover()
|
||||
const syncButton = screen.getByText('list.action.sync')
|
||||
await act(async () => {
|
||||
fireEvent.click(syncButton)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/list\.action\.sync/i)).toBeInTheDocument()
|
||||
expect(mockSyncWebsite).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
|
||||
})
|
||||
})
|
||||
|
||||
it('should show archive option for non-archived documents', async () => {
|
||||
it('should call pause when pause action is clicked', async () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{ ...defaultDetail, archived: false }}
|
||||
detail={{ ...defaultDetail, display_status: 'indexing' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const moreButton = document.querySelector('[class*="commonIcon"]')
|
||||
if (moreButton)
|
||||
fireEvent.click(moreButton)
|
||||
|
||||
await openPopover()
|
||||
const pauseButton = screen.getByText('list.action.pause')
|
||||
await act(async () => {
|
||||
fireEvent.click(pauseButton)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/list\.action\.archive/i)).toBeInTheDocument()
|
||||
expect(mockPause).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
|
||||
})
|
||||
})
|
||||
|
||||
it('should show unarchive option for archived documents', async () => {
|
||||
it('should call resume when resume action is clicked', async () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{ ...defaultDetail, archived: true }}
|
||||
detail={{ ...defaultDetail, display_status: 'paused' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const moreButton = document.querySelector('[class*="commonIcon"]')
|
||||
if (moreButton)
|
||||
fireEvent.click(moreButton)
|
||||
|
||||
await openPopover()
|
||||
const resumeButton = screen.getByText('list.action.resume')
|
||||
await act(async () => {
|
||||
fireEvent.click(resumeButton)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/list\.action\.unarchive/i)).toBeInTheDocument()
|
||||
expect(mockResume).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
|
||||
})
|
||||
})
|
||||
|
||||
it('should show delete option', async () => {
|
||||
it('should download file when download action is clicked', async () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
|
||||
const moreButton = document.querySelector('[class*="commonIcon"]')
|
||||
if (moreButton)
|
||||
fireEvent.click(moreButton)
|
||||
|
||||
await openPopover()
|
||||
const downloadButton = screen.getByText('list.action.download')
|
||||
await act(async () => {
|
||||
fireEvent.click(downloadButton)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/list\.action\.delete/i)).toBeInTheDocument()
|
||||
expect(mockDownload).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
|
||||
expect(mockDownloadUrl).toHaveBeenCalledWith({ url: 'https://example.com/download', fileName: 'Test Document' })
|
||||
})
|
||||
})
|
||||
|
||||
it('should show pause option when status is indexing', async () => {
|
||||
it('should show download option for archived file data source', async () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{ ...defaultDetail, display_status: 'indexing', archived: false }}
|
||||
detail={{ ...defaultDetail, archived: true, data_source_type: 'upload_file' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const moreButton = document.querySelector('[class*="commonIcon"]')
|
||||
if (moreButton)
|
||||
fireEvent.click(moreButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/list\.action\.pause/i)).toBeInTheDocument()
|
||||
})
|
||||
await openPopover()
|
||||
expect(screen.getByText('list.action.download')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show resume option when status is paused', async () => {
|
||||
it('should download archived file when download is clicked', async () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{ ...defaultDetail, display_status: 'paused', archived: false }}
|
||||
detail={{ ...defaultDetail, archived: true, data_source_type: 'upload_file' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const moreButton = document.querySelector('[class*="commonIcon"]')
|
||||
if (moreButton)
|
||||
fireEvent.click(moreButton)
|
||||
|
||||
await openPopover()
|
||||
const downloadButton = screen.getByText('list.action.download')
|
||||
await act(async () => {
|
||||
fireEvent.click(downloadButton)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/list\.action\.resume/i)).toBeInTheDocument()
|
||||
expect(mockDownload).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Delete Confirmation Modal', () => {
|
||||
it('should show delete confirmation modal when delete is clicked', async () => {
|
||||
describe('error handling', () => {
|
||||
it('should show error notification when operation fails', async () => {
|
||||
mockArchive.mockRejectedValue(new Error('API Error'))
|
||||
render(<Operations {...defaultProps} />)
|
||||
|
||||
const moreButton = document.querySelector('[class*="commonIcon"]')
|
||||
if (moreButton)
|
||||
fireEvent.click(moreButton)
|
||||
|
||||
await waitFor(() => {
|
||||
const deleteButton = screen.getByText(/list\.action\.delete/i)
|
||||
fireEvent.click(deleteButton)
|
||||
const moreButton = document.querySelector('[class*="commonIcon"]')?.parentElement
|
||||
if (moreButton) {
|
||||
await act(async () => {
|
||||
fireEvent.click(moreButton)
|
||||
})
|
||||
}
|
||||
const archiveButton = screen.getByText('list.action.archive')
|
||||
await act(async () => {
|
||||
fireEvent.click(archiveButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/list\.delete\.title/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/list\.delete\.content/i)).toBeInTheDocument()
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'actionMsg.modifiedUnsuccessfully',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error notification when download fails', async () => {
|
||||
mockDownload.mockRejectedValue(new Error('Download Error'))
|
||||
render(<Operations {...defaultProps} />)
|
||||
const moreButton = document.querySelector('[class*="commonIcon"]')?.parentElement
|
||||
if (moreButton) {
|
||||
await act(async () => {
|
||||
fireEvent.click(moreButton)
|
||||
})
|
||||
}
|
||||
const downloadButton = screen.getByText('list.action.download')
|
||||
await act(async () => {
|
||||
fireEvent.click(downloadButton)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'actionMsg.downloadUnsuccessfully',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error notification when download returns no url', async () => {
|
||||
mockDownload.mockResolvedValue({})
|
||||
render(<Operations {...defaultProps} />)
|
||||
const moreButton = document.querySelector('[class*="commonIcon"]')?.parentElement
|
||||
if (moreButton) {
|
||||
await act(async () => {
|
||||
fireEvent.click(moreButton)
|
||||
})
|
||||
}
|
||||
const downloadButton = screen.getByText('list.action.download')
|
||||
await act(async () => {
|
||||
fireEvent.click(downloadButton)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'actionMsg.downloadUnsuccessfully',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Scene Variations', () => {
|
||||
it('should render correctly in detail scene', () => {
|
||||
render(<Operations {...defaultProps} scene="detail" />)
|
||||
// Settings button should still be visible
|
||||
expect(screen.getAllByRole('button').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should apply different styles in detail scene', () => {
|
||||
const { container } = render(<Operations {...defaultProps} scene="detail" />)
|
||||
// The component should render without the list-specific styles
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined detail properties', () => {
|
||||
describe('display status', () => {
|
||||
it('should render pause action when status is indexing', () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{
|
||||
name: '',
|
||||
enabled: false,
|
||||
archived: false,
|
||||
id: '',
|
||||
data_source_type: '',
|
||||
doc_form: '',
|
||||
display_status: undefined,
|
||||
}}
|
||||
detail={{ ...defaultDetail, display_status: 'indexing' }}
|
||||
/>,
|
||||
)
|
||||
// Should not crash
|
||||
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should stop event propagation on click', () => {
|
||||
const parentHandler = vi.fn()
|
||||
it('should render resume action when status is paused', () => {
|
||||
render(
|
||||
<div onClick={parentHandler}>
|
||||
<Operations {...defaultProps} />
|
||||
</div>,
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{ ...defaultDetail, display_status: 'paused' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const container = document.querySelector('.flex.items-center')
|
||||
if (container)
|
||||
fireEvent.click(container)
|
||||
|
||||
// Parent handler should not be called due to stopPropagation
|
||||
expect(parentHandler).not.toHaveBeenCalled()
|
||||
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle custom className', () => {
|
||||
it('should not show pause/resume for available status', async () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{ ...defaultDetail, display_status: 'available' }}
|
||||
/>,
|
||||
)
|
||||
const moreButton = document.querySelector('[class*="commonIcon"]')?.parentElement
|
||||
if (moreButton) {
|
||||
await act(async () => {
|
||||
fireEvent.click(moreButton)
|
||||
})
|
||||
}
|
||||
expect(screen.queryByText('list.action.pause')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('list.action.resume')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('data source types', () => {
|
||||
it('should handle notion data source type', () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{ ...defaultDetail, data_source_type: 'notion_import' }}
|
||||
/>,
|
||||
)
|
||||
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle web data source type', () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{ ...defaultDetail, data_source_type: 'website_crawl' }}
|
||||
/>,
|
||||
)
|
||||
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show download for non-file data source', async () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{ ...defaultDetail, data_source_type: 'notion_import' }}
|
||||
/>,
|
||||
)
|
||||
const moreButton = document.querySelector('[class*="commonIcon"]')?.parentElement
|
||||
if (moreButton) {
|
||||
await act(async () => {
|
||||
fireEvent.click(moreButton)
|
||||
})
|
||||
}
|
||||
expect(screen.queryByText('list.action.download')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('memoization', () => {
|
||||
it('should be wrapped with React.memo', () => {
|
||||
expect((Operations as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('className prop', () => {
|
||||
it('should accept custom className prop', () => {
|
||||
// The className is passed to CustomPopover, verify component renders without errors
|
||||
render(<Operations {...defaultProps} className="custom-class" />)
|
||||
// Component should render with the custom class
|
||||
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Selected IDs Handling', () => {
|
||||
it('should pass selectedIds to operations', () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
selectedIds={['doc-123', 'doc-456']}
|
||||
onSelectedIdChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
// Component should render correctly with selectedIds
|
||||
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import EmptyFolder from './empty-folder'
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('EmptyFolder', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<EmptyFolder />)
|
||||
expect(screen.getByText('onlineDrive.emptyFolder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the empty folder text', () => {
|
||||
render(<EmptyFolder />)
|
||||
expect(screen.getByText('onlineDrive.emptyFolder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have proper styling classes', () => {
|
||||
const { container } = render(<EmptyFolder />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('flex')
|
||||
expect(wrapper).toHaveClass('items-center')
|
||||
expect(wrapper).toHaveClass('justify-center')
|
||||
})
|
||||
|
||||
it('should be wrapped with React.memo', () => {
|
||||
expect((EmptyFolder as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,792 +1,137 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// ============================================================================
|
||||
// Component Imports (after mocks)
|
||||
// ============================================================================
|
||||
|
||||
import Card from './card'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import ApiAccess from './index'
|
||||
|
||||
// ============================================================================
|
||||
// Mock Setup
|
||||
// ============================================================================
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
usePathname: () => '/test',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
// Mock next/link
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href, ...props }: { children: React.ReactNode, href: string, [key: string]: unknown }) => (
|
||||
<a href={href} {...props}>{children}</a>
|
||||
),
|
||||
}))
|
||||
|
||||
// Dataset context mock data
|
||||
const mockDataset: Partial<DataSet> = {
|
||||
id: 'dataset-123',
|
||||
name: 'Test Dataset',
|
||||
enable_api: true,
|
||||
}
|
||||
|
||||
// Mock use-context-selector
|
||||
vi.mock('use-context-selector', () => ({
|
||||
useContext: vi.fn(() => ({ dataset: mockDataset })),
|
||||
useContextSelector: vi.fn((_, selector) => selector({ dataset: mockDataset })),
|
||||
createContext: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
// Mock dataset detail context
|
||||
const mockMutateDatasetRes = vi.fn()
|
||||
// Mock context and hooks for Card component
|
||||
vi.mock('@/context/dataset-detail', () => ({
|
||||
default: {},
|
||||
useDatasetDetailContext: vi.fn(() => ({
|
||||
dataset: mockDataset,
|
||||
mutateDatasetRes: mockMutateDatasetRes,
|
||||
})),
|
||||
useDatasetDetailContextWithSelector: vi.fn((selector: (v: { dataset?: typeof mockDataset, mutateDatasetRes?: () => void }) => unknown) =>
|
||||
selector({ dataset: mockDataset as DataSet, mutateDatasetRes: mockMutateDatasetRes }),
|
||||
),
|
||||
useDatasetDetailContextWithSelector: vi.fn(() => 'test-dataset-id'),
|
||||
}))
|
||||
|
||||
// Mock app context for workspace permissions
|
||||
let mockIsCurrentWorkspaceManager = true
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: vi.fn((selector: (state: { isCurrentWorkspaceManager: boolean }) => unknown) =>
|
||||
selector({ isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager }),
|
||||
),
|
||||
useSelector: vi.fn(() => true),
|
||||
}))
|
||||
|
||||
// Mock service hooks
|
||||
const mockEnableDatasetServiceApi = vi.fn(() => Promise.resolve({ result: 'success' }))
|
||||
const mockDisableDatasetServiceApi = vi.fn(() => Promise.resolve({ result: 'success' }))
|
||||
vi.mock('@/hooks/use-api-access-url', () => ({
|
||||
useDatasetApiAccessUrl: vi.fn(() => 'https://api.example.com/docs'),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useDatasetApiBaseUrl: vi.fn(() => ({
|
||||
data: { api_base_url: 'https://api.example.com' },
|
||||
isLoading: false,
|
||||
})),
|
||||
useEnableDatasetServiceApi: vi.fn(() => ({
|
||||
mutateAsync: mockEnableDatasetServiceApi,
|
||||
isPending: false,
|
||||
})),
|
||||
useDisableDatasetServiceApi: vi.fn(() => ({
|
||||
mutateAsync: mockDisableDatasetServiceApi,
|
||||
isPending: false,
|
||||
})),
|
||||
useEnableDatasetServiceApi: vi.fn(() => ({ mutateAsync: vi.fn() })),
|
||||
useDisableDatasetServiceApi: vi.fn(() => ({ mutateAsync: vi.fn() })),
|
||||
}))
|
||||
|
||||
// Mock API access URL hook
|
||||
vi.mock('@/hooks/use-api-access-url', () => ({
|
||||
useDatasetApiAccessUrl: vi.fn(() => 'https://docs.dify.ai/api-reference/datasets'),
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// ApiAccess Component Tests
|
||||
// ============================================================================
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('ApiAccess', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
it('should render without crashing', () => {
|
||||
render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
expect(screen.getByText('appMenus.apiAccess')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render API title when expanded', () => {
|
||||
render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render API title when collapsed', () => {
|
||||
render(<ApiAccess expand={false} apiEnabled={true} />)
|
||||
expect(screen.queryByText(/appMenus\.apiAccess/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render ApiAggregate icon', () => {
|
||||
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Indicator component', () => {
|
||||
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
const indicatorElement = container.querySelector('.relative.flex.h-8')
|
||||
expect(indicatorElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with proper container padding', () => {
|
||||
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('p-3', 'pt-2')
|
||||
})
|
||||
it('should render API access text when expanded', () => {
|
||||
render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
expect(screen.getByText('appMenus.apiAccess')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Props Variations Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Props Variations', () => {
|
||||
it('should apply compressed layout when expand is false', () => {
|
||||
const { container } = render(<ApiAccess expand={false} apiEnabled={true} />)
|
||||
const triggerContainer = container.querySelector('[class*="w-8"]')
|
||||
expect(triggerContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply full width when expand is true', () => {
|
||||
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
const trigger = container.querySelector('.w-full')
|
||||
expect(trigger).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass apiEnabled=true to Indicator with green color', () => {
|
||||
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
// Indicator uses color prop - test the visual presence
|
||||
const indicatorContainer = container.querySelector('.relative.flex.h-8')
|
||||
expect(indicatorContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass apiEnabled=false to Indicator with yellow color', () => {
|
||||
const { container } = render(<ApiAccess expand={false} apiEnabled={false} />)
|
||||
const indicatorContainer = container.querySelector('.relative.flex.h-8')
|
||||
expect(indicatorContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should position Indicator absolutely when collapsed', () => {
|
||||
const { container } = render(<ApiAccess expand={false} apiEnabled={true} />)
|
||||
// When collapsed, Indicator has 'absolute -right-px -top-px' classes
|
||||
const triggerDiv = container.querySelector('[class*="w-8"][class*="justify-center"]')
|
||||
expect(triggerDiv).toBeInTheDocument()
|
||||
})
|
||||
it('should not render API access text when collapsed', () => {
|
||||
render(<ApiAccess expand={false} apiEnabled={true} />)
|
||||
expect(screen.queryByText('appMenus.apiAccess')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Interactions Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should toggle popup open state on click', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
|
||||
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
|
||||
expect(trigger).toBeInTheDocument()
|
||||
|
||||
if (trigger)
|
||||
await user.click(trigger)
|
||||
|
||||
// After click, the popup should toggle (Card should be rendered via portal)
|
||||
})
|
||||
|
||||
it('should apply hover styles on trigger', () => {
|
||||
render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
|
||||
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('div[class*="cursor-pointer"]')
|
||||
expect(trigger).toHaveClass('cursor-pointer')
|
||||
})
|
||||
|
||||
it('should toggle open state from false to true on first click', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
|
||||
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
|
||||
if (trigger)
|
||||
await user.click(trigger)
|
||||
|
||||
// The handleToggle function should flip open from false to true
|
||||
})
|
||||
|
||||
it('should toggle open state back to false on second click', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
|
||||
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
|
||||
if (trigger) {
|
||||
await user.click(trigger) // open
|
||||
await user.click(trigger) // close
|
||||
}
|
||||
|
||||
// The handleToggle function should flip open from true to false
|
||||
})
|
||||
|
||||
it('should apply open state styling when popup is open', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
|
||||
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
|
||||
if (trigger)
|
||||
await user.click(trigger)
|
||||
|
||||
// When open, the trigger should have bg-state-base-hover class
|
||||
})
|
||||
it('should render with apiEnabled=true', () => {
|
||||
render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
expect(screen.getByText('appMenus.apiAccess')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Portal and Card Integration Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Portal and Card Integration', () => {
|
||||
it('should render Card component inside portal when open', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
|
||||
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
|
||||
if (trigger)
|
||||
await user.click(trigger)
|
||||
|
||||
// Wait for portal content to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass apiEnabled prop to Card component', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<ApiAccess expand={true} apiEnabled={false} />)
|
||||
|
||||
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
|
||||
if (trigger)
|
||||
await user.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/serviceApi\.disabled/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should use correct portal placement configuration', () => {
|
||||
render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
// PortalToFollowElem is configured with placement="top-start"
|
||||
// The component should render without errors
|
||||
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use correct portal offset configuration', () => {
|
||||
render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
// PortalToFollowElem is configured with offset={{ mainAxis: 4, crossAxis: -4 }}
|
||||
// The component should render without errors
|
||||
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Edge Cases Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle rapid toggle clicks gracefully', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
|
||||
// Use a more specific selector to find the trigger in the main component
|
||||
const trigger = container.querySelector('.p-3 [class*="cursor-pointer"]')
|
||||
if (trigger) {
|
||||
// Rapid clicks
|
||||
await user.click(trigger)
|
||||
await user.click(trigger)
|
||||
await user.click(trigger)
|
||||
}
|
||||
|
||||
// Component should handle state changes without errors - use getAllByText since Card may be open
|
||||
const elements = screen.getAllByText(/appMenus\.apiAccess/i)
|
||||
expect(elements.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render correctly when both expand and apiEnabled are false', () => {
|
||||
render(<ApiAccess expand={false} apiEnabled={false} />)
|
||||
// Should render without title but with indicator
|
||||
expect(screen.queryByText(/appMenus\.apiAccess/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should maintain state across prop changes', () => {
|
||||
const { rerender } = render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
|
||||
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
|
||||
|
||||
rerender(<ApiAccess expand={true} apiEnabled={false} />)
|
||||
|
||||
// Component should still render after prop change
|
||||
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
const { rerender } = render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
|
||||
rerender(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
|
||||
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not re-render unnecessarily with same props', () => {
|
||||
const { rerender } = render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
|
||||
rerender(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
rerender(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
|
||||
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Card Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Card (api-access)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCurrentWorkspaceManager = true
|
||||
mockEnableDatasetServiceApi.mockResolvedValue({ result: 'success' })
|
||||
mockDisableDatasetServiceApi.mockResolvedValue({ result: 'success' })
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Card apiEnabled={true} />)
|
||||
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display enabled status when API is enabled', () => {
|
||||
render(<Card apiEnabled={true} />)
|
||||
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display disabled status when API is disabled', () => {
|
||||
render(<Card apiEnabled={false} />)
|
||||
expect(screen.getByText(/serviceApi\.disabled/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render switch component', () => {
|
||||
render(<Card apiEnabled={true} />)
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render API Reference link', () => {
|
||||
render(<Card apiEnabled={true} />)
|
||||
expect(screen.getByText(/overview\.apiInfo\.doc/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Indicator component', () => {
|
||||
const { container } = render(<Card apiEnabled={true} />)
|
||||
// Indicator is rendered - verify card structure
|
||||
const cardContainer = container.querySelector('.w-\\[208px\\]')
|
||||
expect(cardContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description tip text', () => {
|
||||
render(<Card apiEnabled={true} />)
|
||||
expect(screen.getByText(/appMenus\.apiAccessTip/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply success text color when enabled', () => {
|
||||
render(<Card apiEnabled={true} />)
|
||||
const statusText = screen.getByText(/serviceApi\.enabled/i)
|
||||
expect(statusText).toHaveClass('text-text-success')
|
||||
})
|
||||
|
||||
it('should apply warning text color when disabled', () => {
|
||||
render(<Card apiEnabled={false} />)
|
||||
const statusText = screen.getByText(/serviceApi\.disabled/i)
|
||||
expect(statusText).toHaveClass('text-text-warning')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Interactions Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should call enableDatasetServiceApi when switch is toggled on', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<Card apiEnabled={false} />)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
await user.click(switchButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockEnableDatasetServiceApi).toHaveBeenCalledWith('dataset-123')
|
||||
})
|
||||
})
|
||||
|
||||
it('should call disableDatasetServiceApi when switch is toggled off', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<Card apiEnabled={true} />)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
await user.click(switchButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDisableDatasetServiceApi).toHaveBeenCalledWith('dataset-123')
|
||||
})
|
||||
})
|
||||
|
||||
it('should call mutateDatasetRes after successful API enable', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<Card apiEnabled={false} />)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
await user.click(switchButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateDatasetRes).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call mutateDatasetRes after successful API disable', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<Card apiEnabled={true} />)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
await user.click(switchButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateDatasetRes).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call mutateDatasetRes on API enable failure', async () => {
|
||||
mockEnableDatasetServiceApi.mockResolvedValueOnce({ result: 'fail' })
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<Card apiEnabled={false} />)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
await user.click(switchButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockEnableDatasetServiceApi).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(mockMutateDatasetRes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call mutateDatasetRes on API disable failure', async () => {
|
||||
mockDisableDatasetServiceApi.mockResolvedValueOnce({ result: 'fail' })
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<Card apiEnabled={true} />)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
await user.click(switchButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDisableDatasetServiceApi).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(mockMutateDatasetRes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should have correct href for API Reference link', () => {
|
||||
render(<Card apiEnabled={true} />)
|
||||
|
||||
const apiRefLink = screen.getByText(/overview\.apiInfo\.doc/i).closest('a')
|
||||
expect(apiRefLink).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets')
|
||||
})
|
||||
|
||||
it('should open API Reference in new tab', () => {
|
||||
render(<Card apiEnabled={true} />)
|
||||
|
||||
const apiRefLink = screen.getByText(/overview\.apiInfo\.doc/i).closest('a')
|
||||
expect(apiRefLink).toHaveAttribute('target', '_blank')
|
||||
expect(apiRefLink).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Permission Handling Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Permission Handling', () => {
|
||||
it('should disable switch when user is not workspace manager', () => {
|
||||
mockIsCurrentWorkspaceManager = false
|
||||
|
||||
render(<Card apiEnabled={true} />)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
expect(switchButton).toHaveClass('!cursor-not-allowed')
|
||||
expect(switchButton).toHaveClass('!opacity-50')
|
||||
})
|
||||
|
||||
it('should enable switch when user is workspace manager', () => {
|
||||
mockIsCurrentWorkspaceManager = true
|
||||
|
||||
render(<Card apiEnabled={true} />)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
expect(switchButton).not.toHaveClass('!cursor-not-allowed')
|
||||
expect(switchButton).not.toHaveClass('!opacity-50')
|
||||
})
|
||||
|
||||
it('should not trigger API call when switch is disabled and clicked', async () => {
|
||||
mockIsCurrentWorkspaceManager = false
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<Card apiEnabled={false} />)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
await user.click(switchButton)
|
||||
|
||||
// API should not be called when disabled
|
||||
expect(mockEnableDatasetServiceApi).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Edge Cases Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty datasetId gracefully', async () => {
|
||||
const { useDatasetDetailContextWithSelector } = await import('@/context/dataset-detail')
|
||||
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector) => {
|
||||
return selector({
|
||||
dataset: { ...mockDataset, id: '' } as DataSet,
|
||||
mutateDatasetRes: mockMutateDatasetRes,
|
||||
})
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<Card apiEnabled={false} />)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
await user.click(switchButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockEnableDatasetServiceApi).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
// Reset mock
|
||||
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation(selector =>
|
||||
selector({ dataset: mockDataset as DataSet, mutateDatasetRes: mockMutateDatasetRes }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle undefined datasetId gracefully when enabling API', async () => {
|
||||
const { useDatasetDetailContextWithSelector } = await import('@/context/dataset-detail')
|
||||
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector) => {
|
||||
const partialDataset = { ...mockDataset } as Partial<DataSet>
|
||||
delete partialDataset.id
|
||||
return selector({
|
||||
dataset: partialDataset as DataSet,
|
||||
mutateDatasetRes: mockMutateDatasetRes,
|
||||
})
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<Card apiEnabled={false} />)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
await user.click(switchButton)
|
||||
|
||||
await waitFor(() => {
|
||||
// Should use fallback empty string
|
||||
expect(mockEnableDatasetServiceApi).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
// Reset mock
|
||||
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation(selector =>
|
||||
selector({ dataset: mockDataset as DataSet, mutateDatasetRes: mockMutateDatasetRes }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle undefined datasetId gracefully when disabling API', async () => {
|
||||
const { useDatasetDetailContextWithSelector } = await import('@/context/dataset-detail')
|
||||
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector) => {
|
||||
const partialDataset = { ...mockDataset } as Partial<DataSet>
|
||||
delete partialDataset.id
|
||||
return selector({
|
||||
dataset: partialDataset as DataSet,
|
||||
mutateDatasetRes: mockMutateDatasetRes,
|
||||
})
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<Card apiEnabled={true} />)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
await user.click(switchButton)
|
||||
|
||||
await waitFor(() => {
|
||||
// Should use fallback empty string for disableDatasetServiceApi
|
||||
expect(mockDisableDatasetServiceApi).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
// Reset mock
|
||||
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation(selector =>
|
||||
selector({ dataset: mockDataset as DataSet, mutateDatasetRes: mockMutateDatasetRes }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle undefined mutateDatasetRes gracefully', async () => {
|
||||
const { useDatasetDetailContextWithSelector } = await import('@/context/dataset-detail')
|
||||
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector) => {
|
||||
return selector({
|
||||
dataset: mockDataset as DataSet,
|
||||
mutateDatasetRes: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<Card apiEnabled={false} />)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
await user.click(switchButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockEnableDatasetServiceApi).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Should not throw error when mutateDatasetRes is undefined
|
||||
|
||||
// Reset mock
|
||||
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation(selector =>
|
||||
selector({ dataset: mockDataset as DataSet, mutateDatasetRes: mockMutateDatasetRes }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
const { rerender } = render(<Card apiEnabled={true} />)
|
||||
|
||||
rerender(<Card apiEnabled={true} />)
|
||||
|
||||
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use useCallback for onToggle handler', () => {
|
||||
const { rerender } = render(<Card apiEnabled={true} />)
|
||||
|
||||
rerender(<Card apiEnabled={true} />)
|
||||
|
||||
// Component should render without issues with memoized callbacks
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update when apiEnabled prop changes', () => {
|
||||
const { rerender } = render(<Card apiEnabled={true} />)
|
||||
|
||||
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
|
||||
|
||||
rerender(<Card apiEnabled={false} />)
|
||||
|
||||
expect(screen.getByText(/serviceApi\.disabled/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Integration Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('ApiAccess Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCurrentWorkspaceManager = true
|
||||
mockEnableDatasetServiceApi.mockResolvedValue({ result: 'success' })
|
||||
mockDisableDatasetServiceApi.mockResolvedValue({ result: 'success' })
|
||||
})
|
||||
|
||||
it('should open Card popup and toggle API status', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
it('should render with apiEnabled=false', () => {
|
||||
render(<ApiAccess expand={true} apiEnabled={false} />)
|
||||
expect(screen.getByText('appMenus.apiAccess')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Open popup
|
||||
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
|
||||
if (trigger)
|
||||
await user.click(trigger)
|
||||
it('should be wrapped with React.memo', () => {
|
||||
expect((ApiAccess as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
|
||||
})
|
||||
|
||||
// Wait for Card to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/serviceApi\.disabled/i)).toBeInTheDocument()
|
||||
describe('toggle functionality', () => {
|
||||
it('should toggle open state when trigger is clicked', async () => {
|
||||
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
const trigger = container.querySelector('.cursor-pointer')
|
||||
expect(trigger).toBeInTheDocument()
|
||||
|
||||
// Click to open
|
||||
await act(async () => {
|
||||
fireEvent.click(trigger!)
|
||||
})
|
||||
|
||||
// The component should update its state - check for state change via class
|
||||
expect(trigger).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Toggle API on
|
||||
const switchButton = screen.getByRole('switch')
|
||||
await user.click(switchButton)
|
||||
it('should toggle open state multiple times', async () => {
|
||||
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
const trigger = container.querySelector('.cursor-pointer')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockEnableDatasetServiceApi).toHaveBeenCalledWith('dataset-123')
|
||||
// First click - open
|
||||
await act(async () => {
|
||||
fireEvent.click(trigger!)
|
||||
})
|
||||
|
||||
// Second click - close
|
||||
await act(async () => {
|
||||
fireEvent.click(trigger!)
|
||||
})
|
||||
|
||||
expect(trigger).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should work when collapsed', async () => {
|
||||
const { container } = render(<ApiAccess expand={false} apiEnabled={true} />)
|
||||
const trigger = container.querySelector('.cursor-pointer')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(trigger!)
|
||||
})
|
||||
|
||||
expect(trigger).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should complete full workflow: open -> view status -> toggle -> verify callback', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
|
||||
// Open popup
|
||||
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
|
||||
if (trigger)
|
||||
await user.click(trigger)
|
||||
|
||||
// Verify enabled status is shown
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
|
||||
describe('indicator color', () => {
|
||||
it('should render with green indicator when apiEnabled is true', () => {
|
||||
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
// Indicator component should be present
|
||||
const indicator = container.querySelector('.shrink-0')
|
||||
expect(indicator).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Toggle API off
|
||||
const switchButton = screen.getByRole('switch')
|
||||
await user.click(switchButton)
|
||||
|
||||
// Verify API call and callback
|
||||
await waitFor(() => {
|
||||
expect(mockDisableDatasetServiceApi).toHaveBeenCalledWith('dataset-123')
|
||||
expect(mockMutateDatasetRes).toHaveBeenCalled()
|
||||
it('should render with yellow indicator when apiEnabled is false', () => {
|
||||
const { container } = render(<ApiAccess expand={true} apiEnabled={false} />)
|
||||
const indicator = container.querySelector('.shrink-0')
|
||||
expect(indicator).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should navigate to API Reference from Card', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
|
||||
// Open popup
|
||||
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
|
||||
if (trigger)
|
||||
await user.click(trigger)
|
||||
|
||||
// Wait for Card to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/overview\.apiInfo\.doc/i)).toBeInTheDocument()
|
||||
describe('layout', () => {
|
||||
it('should have justify-center when collapsed', () => {
|
||||
const { container } = render(<ApiAccess expand={false} apiEnabled={true} />)
|
||||
const trigger = container.querySelector('.justify-center')
|
||||
expect(trigger).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Verify link
|
||||
const apiRefLink = screen.getByText(/overview\.apiInfo\.doc/i).closest('a')
|
||||
expect(apiRefLink).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets')
|
||||
it('should not have justify-center when expanded', () => {
|
||||
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
const innerDiv = container.querySelector('.cursor-pointer')
|
||||
// When expanded, should have gap-2 and text, not justify-center
|
||||
expect(innerDiv).not.toHaveClass('justify-center')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
87
web/app/components/datasets/extra-info/statistics.spec.tsx
Normal file
87
web/app/components/datasets/extra-info/statistics.spec.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import type { RelatedApp, RelatedAppResponse } from '@/models/datasets'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import Statistics from './statistics'
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useDocLink
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('Statistics', () => {
|
||||
const mockRelatedApp: RelatedApp = {
|
||||
id: 'app-1',
|
||||
name: 'Test App',
|
||||
mode: AppModeEnum.CHAT,
|
||||
icon_type: 'emoji',
|
||||
icon: '🤖',
|
||||
icon_background: '#ffffff',
|
||||
icon_url: '',
|
||||
}
|
||||
|
||||
const mockRelatedApps: RelatedAppResponse = {
|
||||
data: [mockRelatedApp],
|
||||
total: 1,
|
||||
}
|
||||
|
||||
it('should render document count', () => {
|
||||
render(<Statistics expand={true} documentCount={5} relatedApps={mockRelatedApps} />)
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render document label', () => {
|
||||
render(<Statistics expand={true} documentCount={5} relatedApps={mockRelatedApps} />)
|
||||
expect(screen.getByText('datasetMenus.documents')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render related apps total', () => {
|
||||
render(<Statistics expand={true} documentCount={5} relatedApps={mockRelatedApps} />)
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render related app label', () => {
|
||||
render(<Statistics expand={true} documentCount={5} relatedApps={mockRelatedApps} />)
|
||||
expect(screen.getByText('datasetMenus.relatedApp')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render -- for undefined document count', () => {
|
||||
render(<Statistics expand={true} relatedApps={mockRelatedApps} />)
|
||||
expect(screen.getByText('--')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render -- for undefined related apps total', () => {
|
||||
render(<Statistics expand={true} documentCount={5} />)
|
||||
const dashes = screen.getAllByText('--')
|
||||
expect(dashes.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render with zero document count', () => {
|
||||
render(<Statistics expand={true} documentCount={0} relatedApps={mockRelatedApps} />)
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty related apps', () => {
|
||||
const emptyRelatedApps: RelatedAppResponse = {
|
||||
data: [],
|
||||
total: 0,
|
||||
}
|
||||
render(<Statistics expand={true} documentCount={5} relatedApps={emptyRelatedApps} />)
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should be wrapped with React.memo', () => {
|
||||
expect((Statistics as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
|
||||
})
|
||||
})
|
||||
21
web/app/components/datasets/loading.spec.tsx
Normal file
21
web/app/components/datasets/loading.spec.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import DatasetsLoading from './loading'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('DatasetsLoading', () => {
|
||||
it('should render null', () => {
|
||||
const { container } = render(<DatasetsLoading />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should not throw on multiple renders', () => {
|
||||
expect(() => {
|
||||
render(<DatasetsLoading />)
|
||||
render(<DatasetsLoading />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
58
web/app/components/datasets/no-linked-apps-panel.spec.tsx
Normal file
58
web/app/components/datasets/no-linked-apps-panel.spec.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import NoLinkedAppsPanel from './no-linked-apps-panel'
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useDocLink
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('NoLinkedAppsPanel', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<NoLinkedAppsPanel />)
|
||||
expect(screen.getByText('datasetMenus.emptyTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the empty tip text', () => {
|
||||
render(<NoLinkedAppsPanel />)
|
||||
expect(screen.getByText('datasetMenus.emptyTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the view doc link', () => {
|
||||
render(<NoLinkedAppsPanel />)
|
||||
expect(screen.getByText('datasetMenus.viewDoc')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render link with correct href', () => {
|
||||
render(<NoLinkedAppsPanel />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toHaveAttribute('href', 'https://docs.example.com/use-dify/knowledge/integrate-knowledge-within-application')
|
||||
})
|
||||
|
||||
it('should render link with target="_blank"', () => {
|
||||
render(<NoLinkedAppsPanel />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
|
||||
it('should render link with rel="noopener noreferrer"', () => {
|
||||
render(<NoLinkedAppsPanel />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
it('should be wrapped with React.memo', () => {
|
||||
expect((NoLinkedAppsPanel as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
|
||||
})
|
||||
})
|
||||
25
web/app/components/datasets/preview/index.spec.tsx
Normal file
25
web/app/components/datasets/preview/index.spec.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import DatasetPreview from './index'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('DatasetPreview', () => {
|
||||
it('should render null', () => {
|
||||
const { container } = render(<DatasetPreview />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should be a valid function component', () => {
|
||||
expect(typeof DatasetPreview).toBe('function')
|
||||
})
|
||||
|
||||
it('should not throw on multiple renders', () => {
|
||||
expect(() => {
|
||||
render(<DatasetPreview />)
|
||||
render(<DatasetPreview />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
@ -2584,11 +2584,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/share/text-generation/run-once/index.spec.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"app/components/share/text-generation/run-once/index.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 1
|
||||
|
||||
Reference in New Issue
Block a user