mirror of
https://github.com/langgenius/dify.git
synced 2026-04-27 22:18:15 +08:00
test: add integration tests for dataset settings, metadata management, and pipeline data source flows
- Introduced new test files for Dataset Settings Flow, Metadata Management Flow, and Pipeline Data Source Store Composition. - Enhanced test coverage by validating cross-module interactions, data contracts, and state management across various components. - Ensured proper handling of user interactions and configuration cascades in the dataset settings and metadata management processes. These additions improve the reliability and maintainability of dataset-related features.
This commit is contained in:
451
web/__tests__/datasets/dataset-settings-flow.test.tsx
Normal file
451
web/__tests__/datasets/dataset-settings-flow.test.tsx
Normal file
@ -0,0 +1,451 @@
|
||||
/**
|
||||
* Integration Test: Dataset Settings Flow
|
||||
*
|
||||
* Tests cross-module data contracts in the dataset settings form:
|
||||
* useFormState hook ↔ index method config ↔ retrieval config ↔ permission state.
|
||||
*
|
||||
* The unit-level use-form-state.spec.ts validates the hook in isolation.
|
||||
* This integration test verifies that changing one configuration dimension
|
||||
* correctly cascades to dependent parts (index method → retrieval config,
|
||||
* permission → member list visibility, embedding model → embedding available state).
|
||||
*/
|
||||
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType, WeightedScoreEnum } from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
const mockMutateDatasets = vi.fn()
|
||||
const mockInvalidDatasetList = vi.fn()
|
||||
const mockUpdateDatasetSetting = vi.fn().mockResolvedValue({})
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: () => false,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
updateDatasetSetting: (...args: unknown[]) => mockUpdateDatasetSetting(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useInvalidDatasetList: () => mockInvalidDatasetList,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useMembers: () => ({
|
||||
data: {
|
||||
accounts: [
|
||||
{ id: 'user-1', name: 'Alice', email: 'alice@example.com', role: 'owner', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
|
||||
{ id: 'user-2', name: 'Bob', email: 'bob@example.com', role: 'admin', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
|
||||
{ id: 'user-3', name: 'Charlie', email: 'charlie@example.com', role: 'normal', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelList: () => ({ data: [] }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({
|
||||
isReRankModelSelected: () => true,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: vi.fn() },
|
||||
}))
|
||||
|
||||
// --- Dataset factory ---
|
||||
|
||||
const createMockDataset = (overrides?: Partial<DataSet>): DataSet => ({
|
||||
id: 'ds-settings-1',
|
||||
name: 'Settings Test Dataset',
|
||||
description: 'Integration test dataset',
|
||||
permission: DatasetPermission.onlyMe,
|
||||
icon_info: {
|
||||
icon_type: 'emoji',
|
||||
icon: '📙',
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
},
|
||||
indexing_technique: 'high_quality',
|
||||
indexing_status: 'completed',
|
||||
data_source_type: DataSourceType.FILE,
|
||||
doc_form: ChunkingMode.text,
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
embedding_available: true,
|
||||
app_count: 2,
|
||||
document_count: 10,
|
||||
total_document_count: 10,
|
||||
word_count: 5000,
|
||||
provider: 'vendor',
|
||||
tags: [],
|
||||
partial_member_list: [],
|
||||
external_knowledge_info: {
|
||||
external_knowledge_id: '',
|
||||
external_knowledge_api_id: '',
|
||||
external_knowledge_api_name: '',
|
||||
external_knowledge_api_endpoint: '',
|
||||
},
|
||||
external_retrieval_model: {
|
||||
top_k: 2,
|
||||
score_threshold: 0.5,
|
||||
score_threshold_enabled: false,
|
||||
},
|
||||
retrieval_model_dict: {
|
||||
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,
|
||||
} as RetrievalConfig,
|
||||
retrieval_model: {
|
||||
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,
|
||||
} as RetrievalConfig,
|
||||
built_in_field_enabled: false,
|
||||
keyword_number: 10,
|
||||
created_by: 'user-1',
|
||||
updated_by: 'user-1',
|
||||
updated_at: Date.now(),
|
||||
runtime_mode: 'general',
|
||||
enable_api: true,
|
||||
is_multimodal: false,
|
||||
...overrides,
|
||||
} as DataSet)
|
||||
|
||||
let mockDataset: DataSet = createMockDataset()
|
||||
|
||||
vi.mock('@/context/dataset-detail', () => ({
|
||||
useDatasetDetailContextWithSelector: (
|
||||
selector: (state: { dataset: DataSet | null, mutateDatasetRes: () => void }) => unknown,
|
||||
) => selector({ dataset: mockDataset, mutateDatasetRes: mockMutateDatasets }),
|
||||
}))
|
||||
|
||||
// Import after mocks are registered
|
||||
const { useFormState } = await import(
|
||||
'@/app/components/datasets/settings/form/hooks/use-form-state',
|
||||
)
|
||||
|
||||
describe('Dataset Settings Flow - Cross-Module Configuration Cascade', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUpdateDatasetSetting.mockResolvedValue({})
|
||||
mockDataset = createMockDataset()
|
||||
})
|
||||
|
||||
describe('Form State Initialization from Dataset → Index Method → Retrieval Config Chain', () => {
|
||||
it('should initialise all form dimensions from a QUALIFIED dataset', () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
expect(result.current.name).toBe('Settings Test Dataset')
|
||||
expect(result.current.description).toBe('Integration test dataset')
|
||||
expect(result.current.indexMethod).toBe('high_quality')
|
||||
expect(result.current.embeddingModel).toEqual({
|
||||
provider: 'openai',
|
||||
model: 'text-embedding-ada-002',
|
||||
})
|
||||
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.semantic)
|
||||
})
|
||||
|
||||
it('should initialise from an ECONOMICAL dataset with keyword retrieval', () => {
|
||||
mockDataset = createMockDataset({
|
||||
indexing_technique: IndexingType.ECONOMICAL,
|
||||
embedding_model: '',
|
||||
embedding_model_provider: '',
|
||||
retrieval_model_dict: {
|
||||
search_method: RETRIEVE_METHOD.keywordSearch,
|
||||
reranking_enable: false,
|
||||
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
|
||||
top_k: 5,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
} as RetrievalConfig,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL)
|
||||
expect(result.current.embeddingModel).toEqual({ provider: '', model: '' })
|
||||
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.keywordSearch)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Index Method Change → Retrieval Config Sync', () => {
|
||||
it('should allow switching index method from QUALIFIED to ECONOMICAL', () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
expect(result.current.indexMethod).toBe('high_quality')
|
||||
|
||||
act(() => {
|
||||
result.current.setIndexMethod(IndexingType.ECONOMICAL)
|
||||
})
|
||||
|
||||
expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL)
|
||||
})
|
||||
|
||||
it('should allow updating retrieval config after index method switch', () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
act(() => {
|
||||
result.current.setIndexMethod(IndexingType.ECONOMICAL)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setRetrievalConfig({
|
||||
...result.current.retrievalConfig,
|
||||
search_method: RETRIEVE_METHOD.keywordSearch,
|
||||
reranking_enable: false,
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL)
|
||||
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.keywordSearch)
|
||||
expect(result.current.retrievalConfig.reranking_enable).toBe(false)
|
||||
})
|
||||
|
||||
it('should preserve retrieval config when switching back to QUALIFIED', () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
const originalConfig = { ...result.current.retrievalConfig }
|
||||
|
||||
act(() => {
|
||||
result.current.setIndexMethod(IndexingType.ECONOMICAL)
|
||||
})
|
||||
act(() => {
|
||||
result.current.setIndexMethod(IndexingType.QUALIFIED)
|
||||
})
|
||||
|
||||
expect(result.current.indexMethod).toBe('high_quality')
|
||||
expect(result.current.retrievalConfig.search_method).toBe(originalConfig.search_method)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Permission Change → Member List Visibility Logic', () => {
|
||||
it('should start with onlyMe permission and empty member selection', () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
expect(result.current.permission).toBe(DatasetPermission.onlyMe)
|
||||
expect(result.current.selectedMemberIDs).toEqual([])
|
||||
})
|
||||
|
||||
it('should enable member selection when switching to partialMembers', () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
act(() => {
|
||||
result.current.setPermission(DatasetPermission.partialMembers)
|
||||
})
|
||||
|
||||
expect(result.current.permission).toBe(DatasetPermission.partialMembers)
|
||||
expect(result.current.memberList).toHaveLength(3)
|
||||
expect(result.current.memberList.map(m => m.id)).toEqual(['user-1', 'user-2', 'user-3'])
|
||||
})
|
||||
|
||||
it('should persist member selection through permission toggle', () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
act(() => {
|
||||
result.current.setPermission(DatasetPermission.partialMembers)
|
||||
result.current.setSelectedMemberIDs(['user-1', 'user-3'])
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setPermission(DatasetPermission.allTeamMembers)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setPermission(DatasetPermission.partialMembers)
|
||||
})
|
||||
|
||||
expect(result.current.selectedMemberIDs).toEqual(['user-1', 'user-3'])
|
||||
})
|
||||
|
||||
it('should include partial_member_list in save payload only for partialMembers', async () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
act(() => {
|
||||
result.current.setPermission(DatasetPermission.partialMembers)
|
||||
result.current.setSelectedMemberIDs(['user-2'])
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
|
||||
datasetId: 'ds-settings-1',
|
||||
body: expect.objectContaining({
|
||||
permission: DatasetPermission.partialMembers,
|
||||
partial_member_list: [
|
||||
expect.objectContaining({ user_id: 'user-2', role: 'admin' }),
|
||||
],
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should not include partial_member_list for allTeamMembers permission', async () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
act(() => {
|
||||
result.current.setPermission(DatasetPermission.allTeamMembers)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
const savedBody = mockUpdateDatasetSetting.mock.calls[0][0].body as Record<string, unknown>
|
||||
expect(savedBody).not.toHaveProperty('partial_member_list')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Submission Validation → All Fields Together', () => {
|
||||
it('should reject empty name on save', async () => {
|
||||
const Toast = await import('@/app/components/base/toast')
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
act(() => {
|
||||
result.current.setName('')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
expect(Toast.default.notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: expect.any(String),
|
||||
})
|
||||
expect(mockUpdateDatasetSetting).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should include all configuration dimensions in a successful save', async () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
act(() => {
|
||||
result.current.setName('Updated Name')
|
||||
result.current.setDescription('Updated Description')
|
||||
result.current.setIndexMethod(IndexingType.ECONOMICAL)
|
||||
result.current.setKeywordNumber(15)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
|
||||
datasetId: 'ds-settings-1',
|
||||
body: expect.objectContaining({
|
||||
name: 'Updated Name',
|
||||
description: 'Updated Description',
|
||||
indexing_technique: 'economy',
|
||||
keyword_number: 15,
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should call mutateDatasets and invalidDatasetList after successful save', async () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateDatasets).toHaveBeenCalled()
|
||||
expect(mockInvalidDatasetList).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Embedding Model Change → Retrieval Config Cascade', () => {
|
||||
it('should update embedding model independently of retrieval config', () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
const originalRetrievalConfig = { ...result.current.retrievalConfig }
|
||||
|
||||
act(() => {
|
||||
result.current.setEmbeddingModel({ provider: 'cohere', model: 'embed-english-v3.0' })
|
||||
})
|
||||
|
||||
expect(result.current.embeddingModel).toEqual({
|
||||
provider: 'cohere',
|
||||
model: 'embed-english-v3.0',
|
||||
})
|
||||
expect(result.current.retrievalConfig.search_method).toBe(originalRetrievalConfig.search_method)
|
||||
})
|
||||
|
||||
it('should propagate embedding model into weighted retrieval config on save', async () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
act(() => {
|
||||
result.current.setEmbeddingModel({ provider: 'cohere', model: 'embed-v3' })
|
||||
result.current.setRetrievalConfig({
|
||||
...result.current.retrievalConfig,
|
||||
search_method: RETRIEVE_METHOD.hybrid,
|
||||
weights: {
|
||||
weight_type: WeightedScoreEnum.Customized,
|
||||
vector_setting: {
|
||||
vector_weight: 0.6,
|
||||
embedding_provider_name: '',
|
||||
embedding_model_name: '',
|
||||
},
|
||||
keyword_setting: { keyword_weight: 0.4 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
|
||||
datasetId: 'ds-settings-1',
|
||||
body: expect.objectContaining({
|
||||
embedding_model: 'embed-v3',
|
||||
embedding_model_provider: 'cohere',
|
||||
retrieval_model: expect.objectContaining({
|
||||
weights: expect.objectContaining({
|
||||
vector_setting: expect.objectContaining({
|
||||
embedding_provider_name: 'cohere',
|
||||
embedding_model_name: 'embed-v3',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle switching from semantic to hybrid search with embedding model', () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
act(() => {
|
||||
result.current.setRetrievalConfig({
|
||||
...result.current.retrievalConfig,
|
||||
search_method: RETRIEVE_METHOD.hybrid,
|
||||
reranking_enable: true,
|
||||
reranking_model: {
|
||||
reranking_provider_name: 'cohere',
|
||||
reranking_model_name: 'rerank-english-v3.0',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.hybrid)
|
||||
expect(result.current.retrievalConfig.reranking_enable).toBe(true)
|
||||
expect(result.current.embeddingModel.model).toBe('text-embedding-ada-002')
|
||||
})
|
||||
})
|
||||
})
|
||||
337
web/__tests__/datasets/metadata-management-flow.test.tsx
Normal file
337
web/__tests__/datasets/metadata-management-flow.test.tsx
Normal file
@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Integration Test: Metadata Management Flow
|
||||
*
|
||||
* Tests the cross-module composition of metadata name validation, type constraints,
|
||||
* and duplicate detection across the metadata management hooks.
|
||||
*
|
||||
* The unit-level use-check-metadata-name.spec.ts tests the validation hook alone.
|
||||
* This integration test verifies:
|
||||
* - Name validation combined with existing metadata list (duplicate detection)
|
||||
* - Metadata type enum constraints matching expected data model
|
||||
* - Full add/rename workflow: validate name → check duplicates → allow or reject
|
||||
* - Name uniqueness logic: existing metadata keeps its own name, cannot take another's
|
||||
*/
|
||||
|
||||
import type { MetadataItemWithValueLength } from '@/app/components/datasets/metadata/types'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { DataType } from '@/app/components/datasets/metadata/types'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
const { default: useCheckMetadataName } = await import(
|
||||
'@/app/components/datasets/metadata/hooks/use-check-metadata-name',
|
||||
)
|
||||
|
||||
// --- Factory functions ---
|
||||
|
||||
const createMetadataItem = (
|
||||
id: string,
|
||||
name: string,
|
||||
type = DataType.string,
|
||||
count = 0,
|
||||
): MetadataItemWithValueLength => ({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
count,
|
||||
})
|
||||
|
||||
const createMetadataList = (): MetadataItemWithValueLength[] => [
|
||||
createMetadataItem('meta-1', 'author', DataType.string, 5),
|
||||
createMetadataItem('meta-2', 'created_date', DataType.time, 10),
|
||||
createMetadataItem('meta-3', 'page_count', DataType.number, 3),
|
||||
createMetadataItem('meta-4', 'source_url', DataType.string, 8),
|
||||
createMetadataItem('meta-5', 'version', DataType.number, 2),
|
||||
]
|
||||
|
||||
describe('Metadata Management Flow - Cross-Module Validation Composition', () => {
|
||||
describe('Name Validation Flow: Format Rules', () => {
|
||||
it('should accept valid lowercase names with underscores', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
|
||||
expect(result.current.checkName('valid_name').errorMsg).toBe('')
|
||||
expect(result.current.checkName('author').errorMsg).toBe('')
|
||||
expect(result.current.checkName('page_count').errorMsg).toBe('')
|
||||
expect(result.current.checkName('v2_field').errorMsg).toBe('')
|
||||
})
|
||||
|
||||
it('should reject empty names', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
|
||||
expect(result.current.checkName('').errorMsg).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should reject names with invalid characters', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
|
||||
expect(result.current.checkName('Author').errorMsg).toBeTruthy()
|
||||
expect(result.current.checkName('my-field').errorMsg).toBeTruthy()
|
||||
expect(result.current.checkName('field name').errorMsg).toBeTruthy()
|
||||
expect(result.current.checkName('1field').errorMsg).toBeTruthy()
|
||||
expect(result.current.checkName('_private').errorMsg).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should reject names exceeding 255 characters', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
|
||||
const longName = 'a'.repeat(256)
|
||||
expect(result.current.checkName(longName).errorMsg).toBeTruthy()
|
||||
|
||||
const maxName = 'a'.repeat(255)
|
||||
expect(result.current.checkName(maxName).errorMsg).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Metadata Type Constraints: Enum Values Match Expected Set', () => {
|
||||
it('should define exactly three data types', () => {
|
||||
const typeValues = Object.values(DataType)
|
||||
expect(typeValues).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should include string, number, and time types', () => {
|
||||
expect(DataType.string).toBe('string')
|
||||
expect(DataType.number).toBe('number')
|
||||
expect(DataType.time).toBe('time')
|
||||
})
|
||||
|
||||
it('should use consistent types in metadata items', () => {
|
||||
const metadataList = createMetadataList()
|
||||
|
||||
const stringItems = metadataList.filter(m => m.type === DataType.string)
|
||||
const numberItems = metadataList.filter(m => m.type === DataType.number)
|
||||
const timeItems = metadataList.filter(m => m.type === DataType.time)
|
||||
|
||||
expect(stringItems).toHaveLength(2)
|
||||
expect(numberItems).toHaveLength(2)
|
||||
expect(timeItems).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should enforce type-safe metadata item construction', () => {
|
||||
const item = createMetadataItem('test-1', 'test_field', DataType.number, 0)
|
||||
|
||||
expect(item.id).toBe('test-1')
|
||||
expect(item.name).toBe('test_field')
|
||||
expect(item.type).toBe(DataType.number)
|
||||
expect(item.count).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Duplicate Name Detection: Add Metadata → Check Name → Detect Duplicates', () => {
|
||||
it('should detect duplicate names against an existing metadata list', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const existingMetadata = createMetadataList()
|
||||
|
||||
const checkDuplicate = (newName: string): boolean => {
|
||||
const formatCheck = result.current.checkName(newName)
|
||||
if (formatCheck.errorMsg)
|
||||
return false
|
||||
return existingMetadata.some(m => m.name === newName)
|
||||
}
|
||||
|
||||
expect(checkDuplicate('author')).toBe(true)
|
||||
expect(checkDuplicate('created_date')).toBe(true)
|
||||
expect(checkDuplicate('page_count')).toBe(true)
|
||||
})
|
||||
|
||||
it('should allow names that do not conflict with existing metadata', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const existingMetadata = createMetadataList()
|
||||
|
||||
const isNameAvailable = (newName: string): boolean => {
|
||||
const formatCheck = result.current.checkName(newName)
|
||||
if (formatCheck.errorMsg)
|
||||
return false
|
||||
return !existingMetadata.some(m => m.name === newName)
|
||||
}
|
||||
|
||||
expect(isNameAvailable('category')).toBe(true)
|
||||
expect(isNameAvailable('file_size')).toBe(true)
|
||||
expect(isNameAvailable('language')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject names that fail format validation before duplicate check', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
|
||||
const validateAndCheckDuplicate = (newName: string): { valid: boolean, reason: string } => {
|
||||
const formatCheck = result.current.checkName(newName)
|
||||
if (formatCheck.errorMsg)
|
||||
return { valid: false, reason: 'format' }
|
||||
return { valid: true, reason: '' }
|
||||
}
|
||||
|
||||
expect(validateAndCheckDuplicate('Author').reason).toBe('format')
|
||||
expect(validateAndCheckDuplicate('').reason).toBe('format')
|
||||
expect(validateAndCheckDuplicate('valid_name').valid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Name Uniqueness Across Edits: Rename Workflow', () => {
|
||||
it('should allow an existing metadata item to keep its own name', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const existingMetadata = createMetadataList()
|
||||
|
||||
const isRenameValid = (itemId: string, newName: string): boolean => {
|
||||
const formatCheck = result.current.checkName(newName)
|
||||
if (formatCheck.errorMsg)
|
||||
return false
|
||||
// Allow keeping the same name (skip self in duplicate check)
|
||||
return !existingMetadata.some(m => m.name === newName && m.id !== itemId)
|
||||
}
|
||||
|
||||
// Author keeping its own name should be valid
|
||||
expect(isRenameValid('meta-1', 'author')).toBe(true)
|
||||
// page_count keeping its own name should be valid
|
||||
expect(isRenameValid('meta-3', 'page_count')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject renaming to another existing metadata name', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const existingMetadata = createMetadataList()
|
||||
|
||||
const isRenameValid = (itemId: string, newName: string): boolean => {
|
||||
const formatCheck = result.current.checkName(newName)
|
||||
if (formatCheck.errorMsg)
|
||||
return false
|
||||
return !existingMetadata.some(m => m.name === newName && m.id !== itemId)
|
||||
}
|
||||
|
||||
// Author trying to rename to "page_count" (taken by meta-3)
|
||||
expect(isRenameValid('meta-1', 'page_count')).toBe(false)
|
||||
// version trying to rename to "source_url" (taken by meta-4)
|
||||
expect(isRenameValid('meta-5', 'source_url')).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow renaming to a completely new valid name', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const existingMetadata = createMetadataList()
|
||||
|
||||
const isRenameValid = (itemId: string, newName: string): boolean => {
|
||||
const formatCheck = result.current.checkName(newName)
|
||||
if (formatCheck.errorMsg)
|
||||
return false
|
||||
return !existingMetadata.some(m => m.name === newName && m.id !== itemId)
|
||||
}
|
||||
|
||||
expect(isRenameValid('meta-1', 'document_author')).toBe(true)
|
||||
expect(isRenameValid('meta-2', 'publish_date')).toBe(true)
|
||||
expect(isRenameValid('meta-3', 'total_pages')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject renaming with an invalid format even if name is unique', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const existingMetadata = createMetadataList()
|
||||
|
||||
const isRenameValid = (itemId: string, newName: string): boolean => {
|
||||
const formatCheck = result.current.checkName(newName)
|
||||
if (formatCheck.errorMsg)
|
||||
return false
|
||||
return !existingMetadata.some(m => m.name === newName && m.id !== itemId)
|
||||
}
|
||||
|
||||
expect(isRenameValid('meta-1', 'New Author')).toBe(false)
|
||||
expect(isRenameValid('meta-2', '2024_date')).toBe(false)
|
||||
expect(isRenameValid('meta-3', '')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Full Metadata Management Workflow', () => {
|
||||
it('should support a complete add-validate-check-duplicate cycle', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const existingMetadata = createMetadataList()
|
||||
|
||||
const addMetadataField = (
|
||||
name: string,
|
||||
type: DataType,
|
||||
): { success: boolean, error?: string } => {
|
||||
const formatCheck = result.current.checkName(name)
|
||||
if (formatCheck.errorMsg)
|
||||
return { success: false, error: 'invalid_format' }
|
||||
|
||||
if (existingMetadata.some(m => m.name === name))
|
||||
return { success: false, error: 'duplicate_name' }
|
||||
|
||||
existingMetadata.push(createMetadataItem(`meta-${existingMetadata.length + 1}`, name, type))
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// Add a valid new field
|
||||
const result1 = addMetadataField('department', DataType.string)
|
||||
expect(result1.success).toBe(true)
|
||||
expect(existingMetadata).toHaveLength(6)
|
||||
|
||||
// Try to add a duplicate
|
||||
const result2 = addMetadataField('author', DataType.string)
|
||||
expect(result2.success).toBe(false)
|
||||
expect(result2.error).toBe('duplicate_name')
|
||||
expect(existingMetadata).toHaveLength(6)
|
||||
|
||||
// Try to add an invalid name
|
||||
const result3 = addMetadataField('Invalid Name', DataType.string)
|
||||
expect(result3.success).toBe(false)
|
||||
expect(result3.error).toBe('invalid_format')
|
||||
expect(existingMetadata).toHaveLength(6)
|
||||
|
||||
// Add another valid field
|
||||
const result4 = addMetadataField('priority_level', DataType.number)
|
||||
expect(result4.success).toBe(true)
|
||||
expect(existingMetadata).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('should support a complete rename workflow with validation chain', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const existingMetadata = createMetadataList()
|
||||
|
||||
const renameMetadataField = (
|
||||
itemId: string,
|
||||
newName: string,
|
||||
): { success: boolean, error?: string } => {
|
||||
const formatCheck = result.current.checkName(newName)
|
||||
if (formatCheck.errorMsg)
|
||||
return { success: false, error: 'invalid_format' }
|
||||
|
||||
if (existingMetadata.some(m => m.name === newName && m.id !== itemId))
|
||||
return { success: false, error: 'duplicate_name' }
|
||||
|
||||
const item = existingMetadata.find(m => m.id === itemId)
|
||||
if (!item)
|
||||
return { success: false, error: 'not_found' }
|
||||
|
||||
// Simulate the rename in-place
|
||||
const index = existingMetadata.indexOf(item)
|
||||
existingMetadata[index] = { ...item, name: newName }
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// Rename author to document_author
|
||||
expect(renameMetadataField('meta-1', 'document_author').success).toBe(true)
|
||||
expect(existingMetadata.find(m => m.id === 'meta-1')?.name).toBe('document_author')
|
||||
|
||||
// Try renaming created_date to page_count (already taken)
|
||||
expect(renameMetadataField('meta-2', 'page_count').error).toBe('duplicate_name')
|
||||
|
||||
// Rename to invalid format
|
||||
expect(renameMetadataField('meta-3', 'Page Count').error).toBe('invalid_format')
|
||||
|
||||
// Rename non-existent item
|
||||
expect(renameMetadataField('meta-999', 'something').error).toBe('not_found')
|
||||
})
|
||||
|
||||
it('should maintain validation consistency across multiple operations', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
|
||||
// Validate the same name multiple times for consistency
|
||||
const name = 'consistent_field'
|
||||
const results = Array.from({ length: 5 }, () => result.current.checkName(name))
|
||||
|
||||
expect(results.every(r => r.errorMsg === '')).toBe(true)
|
||||
|
||||
// Validate an invalid name multiple times
|
||||
const invalidResults = Array.from({ length: 5 }, () => result.current.checkName('Invalid'))
|
||||
expect(invalidResults.every(r => r.errorMsg !== '')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
477
web/__tests__/datasets/pipeline-datasource-flow.test.tsx
Normal file
477
web/__tests__/datasets/pipeline-datasource-flow.test.tsx
Normal file
@ -0,0 +1,477 @@
|
||||
/**
|
||||
* Integration Test: Pipeline Data Source Store Composition
|
||||
*
|
||||
* Tests cross-slice interactions in the pipeline data source Zustand store.
|
||||
* The unit-level slice specs test each slice in isolation.
|
||||
* This integration test verifies:
|
||||
* - Store initialization produces correct defaults across all slices
|
||||
* - Cross-slice coordination (e.g. credential shared across slices)
|
||||
* - State isolation: changes in one slice do not affect others
|
||||
* - Full workflow simulation through credential → source → data path
|
||||
*/
|
||||
|
||||
import type { NotionPage } from '@/models/common'
|
||||
import type { CrawlResultItem, FileItem } from '@/models/datasets'
|
||||
import type { OnlineDriveFile } from '@/models/pipeline'
|
||||
import { createDataSourceStore } from '@/app/components/datasets/documents/create-from-pipeline/data-source/store'
|
||||
import { CrawlStep } from '@/models/datasets'
|
||||
import { OnlineDriveFileType } from '@/models/pipeline'
|
||||
|
||||
// --- Factory functions ---
|
||||
|
||||
const createFileItem = (id: string): FileItem => ({
|
||||
fileID: id,
|
||||
file: { id, name: `${id}.txt`, size: 1024 } as FileItem['file'],
|
||||
progress: 100,
|
||||
})
|
||||
|
||||
const createCrawlResultItem = (url: string, title?: string): CrawlResultItem => ({
|
||||
title: title ?? `Page: ${url}`,
|
||||
markdown: `# ${title ?? url}\n\nContent for ${url}`,
|
||||
description: `Description for ${url}`,
|
||||
source_url: url,
|
||||
})
|
||||
|
||||
const createOnlineDriveFile = (id: string, name: string, type = OnlineDriveFileType.file): OnlineDriveFile => ({
|
||||
id,
|
||||
name,
|
||||
size: 2048,
|
||||
type,
|
||||
})
|
||||
|
||||
const createNotionPage = (pageId: string): NotionPage => ({
|
||||
page_id: pageId,
|
||||
page_name: `Page ${pageId}`,
|
||||
page_icon: null,
|
||||
is_bound: true,
|
||||
parent_id: 'parent-1',
|
||||
type: 'page',
|
||||
workspace_id: 'ws-1',
|
||||
})
|
||||
|
||||
describe('Pipeline Data Source Store Composition - Cross-Slice Integration', () => {
|
||||
describe('Store Initialization → All Slices Have Correct Defaults', () => {
|
||||
it('should create a store with all five slices combined', () => {
|
||||
const store = createDataSourceStore()
|
||||
const state = store.getState()
|
||||
|
||||
// Common slice defaults
|
||||
expect(state.currentCredentialId).toBe('')
|
||||
expect(state.currentNodeIdRef.current).toBe('')
|
||||
|
||||
// Local file slice defaults
|
||||
expect(state.localFileList).toEqual([])
|
||||
expect(state.currentLocalFile).toBeUndefined()
|
||||
|
||||
// Online document slice defaults
|
||||
expect(state.documentsData).toEqual([])
|
||||
expect(state.onlineDocuments).toEqual([])
|
||||
expect(state.searchValue).toBe('')
|
||||
expect(state.selectedPagesId).toEqual(new Set())
|
||||
|
||||
// Website crawl slice defaults
|
||||
expect(state.websitePages).toEqual([])
|
||||
expect(state.step).toBe(CrawlStep.init)
|
||||
expect(state.previewIndex).toBe(-1)
|
||||
|
||||
// Online drive slice defaults
|
||||
expect(state.breadcrumbs).toEqual([])
|
||||
expect(state.prefix).toEqual([])
|
||||
expect(state.keywords).toBe('')
|
||||
expect(state.selectedFileIds).toEqual([])
|
||||
expect(state.onlineDriveFileList).toEqual([])
|
||||
expect(state.bucket).toBe('')
|
||||
expect(state.hasBucket).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cross-Slice Coordination: Shared Credential', () => {
|
||||
it('should set credential that is accessible from the common slice', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setCurrentCredentialId('cred-abc-123')
|
||||
|
||||
expect(store.getState().currentCredentialId).toBe('cred-abc-123')
|
||||
})
|
||||
|
||||
it('should allow credential update independently of all other slices', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setLocalFileList([createFileItem('f1')])
|
||||
store.getState().setCurrentCredentialId('cred-xyz')
|
||||
|
||||
expect(store.getState().currentCredentialId).toBe('cred-xyz')
|
||||
expect(store.getState().localFileList).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Local File Workflow: Set Files → Verify List → Clear', () => {
|
||||
it('should set and retrieve local file list', () => {
|
||||
const store = createDataSourceStore()
|
||||
const files = [createFileItem('f1'), createFileItem('f2'), createFileItem('f3')]
|
||||
|
||||
store.getState().setLocalFileList(files)
|
||||
|
||||
expect(store.getState().localFileList).toHaveLength(3)
|
||||
expect(store.getState().localFileList[0].fileID).toBe('f1')
|
||||
expect(store.getState().localFileList[2].fileID).toBe('f3')
|
||||
})
|
||||
|
||||
it('should update preview ref when setting file list', () => {
|
||||
const store = createDataSourceStore()
|
||||
const files = [createFileItem('f-preview')]
|
||||
|
||||
store.getState().setLocalFileList(files)
|
||||
|
||||
expect(store.getState().previewLocalFileRef.current).toBeDefined()
|
||||
})
|
||||
|
||||
it('should clear files by setting empty list', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setLocalFileList([createFileItem('f1')])
|
||||
expect(store.getState().localFileList).toHaveLength(1)
|
||||
|
||||
store.getState().setLocalFileList([])
|
||||
expect(store.getState().localFileList).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should set and clear current local file selection', () => {
|
||||
const store = createDataSourceStore()
|
||||
const file = { id: 'current-file', name: 'current.txt' } as FileItem['file']
|
||||
|
||||
store.getState().setCurrentLocalFile(file)
|
||||
expect(store.getState().currentLocalFile).toBeDefined()
|
||||
expect(store.getState().currentLocalFile?.id).toBe('current-file')
|
||||
|
||||
store.getState().setCurrentLocalFile(undefined)
|
||||
expect(store.getState().currentLocalFile).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Online Document Workflow: Set Documents → Select Pages → Verify', () => {
|
||||
it('should set documents data and online documents', () => {
|
||||
const store = createDataSourceStore()
|
||||
const pages = [createNotionPage('page-1'), createNotionPage('page-2')]
|
||||
|
||||
store.getState().setOnlineDocuments(pages)
|
||||
|
||||
expect(store.getState().onlineDocuments).toHaveLength(2)
|
||||
expect(store.getState().onlineDocuments[0].page_id).toBe('page-1')
|
||||
})
|
||||
|
||||
it('should update preview ref when setting online documents', () => {
|
||||
const store = createDataSourceStore()
|
||||
const pages = [createNotionPage('page-preview')]
|
||||
|
||||
store.getState().setOnlineDocuments(pages)
|
||||
|
||||
expect(store.getState().previewOnlineDocumentRef.current).toBeDefined()
|
||||
expect(store.getState().previewOnlineDocumentRef.current?.page_id).toBe('page-preview')
|
||||
})
|
||||
|
||||
it('should track selected page IDs', () => {
|
||||
const store = createDataSourceStore()
|
||||
const pages = [createNotionPage('p1'), createNotionPage('p2'), createNotionPage('p3')]
|
||||
|
||||
store.getState().setOnlineDocuments(pages)
|
||||
store.getState().setSelectedPagesId(new Set(['p1', 'p3']))
|
||||
|
||||
expect(store.getState().selectedPagesId.size).toBe(2)
|
||||
expect(store.getState().selectedPagesId.has('p1')).toBe(true)
|
||||
expect(store.getState().selectedPagesId.has('p2')).toBe(false)
|
||||
expect(store.getState().selectedPagesId.has('p3')).toBe(true)
|
||||
})
|
||||
|
||||
it('should manage search value for filtering documents', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setSearchValue('meeting notes')
|
||||
|
||||
expect(store.getState().searchValue).toBe('meeting notes')
|
||||
})
|
||||
|
||||
it('should set and clear current document selection', () => {
|
||||
const store = createDataSourceStore()
|
||||
const page = createNotionPage('current-page')
|
||||
|
||||
store.getState().setCurrentDocument(page)
|
||||
expect(store.getState().currentDocument?.page_id).toBe('current-page')
|
||||
|
||||
store.getState().setCurrentDocument(undefined)
|
||||
expect(store.getState().currentDocument).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Website Crawl Workflow: Set Pages → Track Step → Preview', () => {
|
||||
it('should set website pages and update preview ref', () => {
|
||||
const store = createDataSourceStore()
|
||||
const pages = [
|
||||
createCrawlResultItem('https://example.com'),
|
||||
createCrawlResultItem('https://example.com/about'),
|
||||
]
|
||||
|
||||
store.getState().setWebsitePages(pages)
|
||||
|
||||
expect(store.getState().websitePages).toHaveLength(2)
|
||||
expect(store.getState().previewWebsitePageRef.current?.source_url).toBe('https://example.com')
|
||||
})
|
||||
|
||||
it('should manage crawl step transitions', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
expect(store.getState().step).toBe(CrawlStep.init)
|
||||
|
||||
store.getState().setStep(CrawlStep.running)
|
||||
expect(store.getState().step).toBe(CrawlStep.running)
|
||||
|
||||
store.getState().setStep(CrawlStep.finished)
|
||||
expect(store.getState().step).toBe(CrawlStep.finished)
|
||||
})
|
||||
|
||||
it('should set crawl result with data and timing', () => {
|
||||
const store = createDataSourceStore()
|
||||
const result = {
|
||||
data: [createCrawlResultItem('https://test.com')],
|
||||
time_consuming: 3.5,
|
||||
}
|
||||
|
||||
store.getState().setCrawlResult(result)
|
||||
|
||||
expect(store.getState().crawlResult?.data).toHaveLength(1)
|
||||
expect(store.getState().crawlResult?.time_consuming).toBe(3.5)
|
||||
})
|
||||
|
||||
it('should manage preview index for page navigation', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setPreviewIndex(2)
|
||||
expect(store.getState().previewIndex).toBe(2)
|
||||
|
||||
store.getState().setPreviewIndex(-1)
|
||||
expect(store.getState().previewIndex).toBe(-1)
|
||||
})
|
||||
|
||||
it('should set and clear current website selection', () => {
|
||||
const store = createDataSourceStore()
|
||||
const page = createCrawlResultItem('https://current.com')
|
||||
|
||||
store.getState().setCurrentWebsite(page)
|
||||
expect(store.getState().currentWebsite?.source_url).toBe('https://current.com')
|
||||
|
||||
store.getState().setCurrentWebsite(undefined)
|
||||
expect(store.getState().currentWebsite).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Online Drive Workflow: Breadcrumbs → File Selection → Navigation', () => {
|
||||
it('should manage breadcrumb navigation', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setBreadcrumbs(['root', 'folder-a', 'subfolder'])
|
||||
|
||||
expect(store.getState().breadcrumbs).toEqual(['root', 'folder-a', 'subfolder'])
|
||||
})
|
||||
|
||||
it('should support breadcrumb push/pop pattern', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setBreadcrumbs(['root'])
|
||||
store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'level-1'])
|
||||
store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'level-2'])
|
||||
|
||||
expect(store.getState().breadcrumbs).toEqual(['root', 'level-1', 'level-2'])
|
||||
|
||||
// Pop back one level
|
||||
store.getState().setBreadcrumbs(store.getState().breadcrumbs.slice(0, -1))
|
||||
expect(store.getState().breadcrumbs).toEqual(['root', 'level-1'])
|
||||
})
|
||||
|
||||
it('should manage file list and selection', () => {
|
||||
const store = createDataSourceStore()
|
||||
const files = [
|
||||
createOnlineDriveFile('drive-1', 'report.pdf'),
|
||||
createOnlineDriveFile('drive-2', 'data.csv'),
|
||||
createOnlineDriveFile('drive-3', 'images', OnlineDriveFileType.folder),
|
||||
]
|
||||
|
||||
store.getState().setOnlineDriveFileList(files)
|
||||
expect(store.getState().onlineDriveFileList).toHaveLength(3)
|
||||
|
||||
store.getState().setSelectedFileIds(['drive-1', 'drive-2'])
|
||||
expect(store.getState().selectedFileIds).toEqual(['drive-1', 'drive-2'])
|
||||
})
|
||||
|
||||
it('should update preview ref when selecting files', () => {
|
||||
const store = createDataSourceStore()
|
||||
const files = [
|
||||
createOnlineDriveFile('drive-a', 'file-a.txt'),
|
||||
createOnlineDriveFile('drive-b', 'file-b.txt'),
|
||||
]
|
||||
|
||||
store.getState().setOnlineDriveFileList(files)
|
||||
store.getState().setSelectedFileIds(['drive-b'])
|
||||
|
||||
expect(store.getState().previewOnlineDriveFileRef.current?.id).toBe('drive-b')
|
||||
})
|
||||
|
||||
it('should manage bucket and prefix for S3-like navigation', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setBucket('my-data-bucket')
|
||||
store.getState().setPrefix(['data', '2024'])
|
||||
store.getState().setHasBucket(true)
|
||||
|
||||
expect(store.getState().bucket).toBe('my-data-bucket')
|
||||
expect(store.getState().prefix).toEqual(['data', '2024'])
|
||||
expect(store.getState().hasBucket).toBe(true)
|
||||
})
|
||||
|
||||
it('should manage keywords for search filtering', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setKeywords('quarterly report')
|
||||
expect(store.getState().keywords).toBe('quarterly report')
|
||||
})
|
||||
})
|
||||
|
||||
describe('State Isolation: Changes to One Slice Do Not Affect Others', () => {
|
||||
it('should keep local file state independent from online document state', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setLocalFileList([createFileItem('local-1')])
|
||||
store.getState().setOnlineDocuments([createNotionPage('notion-1')])
|
||||
|
||||
expect(store.getState().localFileList).toHaveLength(1)
|
||||
expect(store.getState().onlineDocuments).toHaveLength(1)
|
||||
|
||||
// Clearing local files should not affect online documents
|
||||
store.getState().setLocalFileList([])
|
||||
expect(store.getState().localFileList).toHaveLength(0)
|
||||
expect(store.getState().onlineDocuments).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should keep website crawl state independent from online drive state', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setWebsitePages([createCrawlResultItem('https://site.com')])
|
||||
store.getState().setOnlineDriveFileList([createOnlineDriveFile('d1', 'file.txt')])
|
||||
|
||||
expect(store.getState().websitePages).toHaveLength(1)
|
||||
expect(store.getState().onlineDriveFileList).toHaveLength(1)
|
||||
|
||||
// Clearing website pages should not affect drive files
|
||||
store.getState().setWebsitePages([])
|
||||
expect(store.getState().websitePages).toHaveLength(0)
|
||||
expect(store.getState().onlineDriveFileList).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should create fully independent store instances', () => {
|
||||
const storeA = createDataSourceStore()
|
||||
const storeB = createDataSourceStore()
|
||||
|
||||
storeA.getState().setCurrentCredentialId('cred-A')
|
||||
storeA.getState().setLocalFileList([createFileItem('fa-1')])
|
||||
|
||||
expect(storeA.getState().currentCredentialId).toBe('cred-A')
|
||||
expect(storeB.getState().currentCredentialId).toBe('')
|
||||
expect(storeB.getState().localFileList).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Full Workflow Simulation: Credential → Source → Data → Verify', () => {
|
||||
it('should support a complete local file upload workflow', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
// Step 1: Set credential
|
||||
store.getState().setCurrentCredentialId('upload-cred-1')
|
||||
|
||||
// Step 2: Set file list
|
||||
const files = [createFileItem('upload-1'), createFileItem('upload-2')]
|
||||
store.getState().setLocalFileList(files)
|
||||
|
||||
// Step 3: Select current file for preview
|
||||
store.getState().setCurrentLocalFile(files[0].file)
|
||||
|
||||
// Verify all state is consistent
|
||||
expect(store.getState().currentCredentialId).toBe('upload-cred-1')
|
||||
expect(store.getState().localFileList).toHaveLength(2)
|
||||
expect(store.getState().currentLocalFile?.id).toBe('upload-1')
|
||||
expect(store.getState().previewLocalFileRef.current).toBeDefined()
|
||||
})
|
||||
|
||||
it('should support a complete website crawl workflow', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
// Step 1: Set credential
|
||||
store.getState().setCurrentCredentialId('crawl-cred-1')
|
||||
|
||||
// Step 2: Init crawl
|
||||
store.getState().setStep(CrawlStep.running)
|
||||
|
||||
// Step 3: Crawl completes with results
|
||||
const crawledPages = [
|
||||
createCrawlResultItem('https://docs.example.com/guide'),
|
||||
createCrawlResultItem('https://docs.example.com/api'),
|
||||
createCrawlResultItem('https://docs.example.com/faq'),
|
||||
]
|
||||
store.getState().setCrawlResult({ data: crawledPages, time_consuming: 12.5 })
|
||||
store.getState().setStep(CrawlStep.finished)
|
||||
|
||||
// Step 4: Set website pages from results
|
||||
store.getState().setWebsitePages(crawledPages)
|
||||
|
||||
// Step 5: Set preview
|
||||
store.getState().setPreviewIndex(1)
|
||||
|
||||
// Verify all state
|
||||
expect(store.getState().currentCredentialId).toBe('crawl-cred-1')
|
||||
expect(store.getState().step).toBe(CrawlStep.finished)
|
||||
expect(store.getState().websitePages).toHaveLength(3)
|
||||
expect(store.getState().crawlResult?.time_consuming).toBe(12.5)
|
||||
expect(store.getState().previewIndex).toBe(1)
|
||||
expect(store.getState().previewWebsitePageRef.current?.source_url).toBe('https://docs.example.com/guide')
|
||||
})
|
||||
|
||||
it('should support a complete online drive navigation workflow', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
// Step 1: Set credential
|
||||
store.getState().setCurrentCredentialId('drive-cred-1')
|
||||
|
||||
// Step 2: Set bucket
|
||||
store.getState().setBucket('company-docs')
|
||||
store.getState().setHasBucket(true)
|
||||
|
||||
// Step 3: Navigate into folders
|
||||
store.getState().setBreadcrumbs(['company-docs'])
|
||||
store.getState().setPrefix(['projects'])
|
||||
const folderFiles = [
|
||||
createOnlineDriveFile('proj-1', 'project-alpha', OnlineDriveFileType.folder),
|
||||
createOnlineDriveFile('proj-2', 'project-beta', OnlineDriveFileType.folder),
|
||||
createOnlineDriveFile('readme', 'README.md', OnlineDriveFileType.file),
|
||||
]
|
||||
store.getState().setOnlineDriveFileList(folderFiles)
|
||||
|
||||
// Step 4: Navigate deeper
|
||||
store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'project-alpha'])
|
||||
store.getState().setPrefix([...store.getState().prefix, 'project-alpha'])
|
||||
|
||||
// Step 5: Select files
|
||||
store.getState().setOnlineDriveFileList([
|
||||
createOnlineDriveFile('doc-1', 'spec.pdf'),
|
||||
createOnlineDriveFile('doc-2', 'design.fig'),
|
||||
])
|
||||
store.getState().setSelectedFileIds(['doc-1'])
|
||||
|
||||
// Verify full state
|
||||
expect(store.getState().currentCredentialId).toBe('drive-cred-1')
|
||||
expect(store.getState().bucket).toBe('company-docs')
|
||||
expect(store.getState().breadcrumbs).toEqual(['company-docs', 'project-alpha'])
|
||||
expect(store.getState().prefix).toEqual(['projects', 'project-alpha'])
|
||||
expect(store.getState().onlineDriveFileList).toHaveLength(2)
|
||||
expect(store.getState().selectedFileIds).toEqual(['doc-1'])
|
||||
expect(store.getState().previewOnlineDriveFileRef.current?.name).toBe('spec.pdf')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -10,7 +10,7 @@ import type {
|
||||
Rules,
|
||||
} from '@/models/datasets'
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
import { act, fireEvent, render, renderHook, screen } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'
|
||||
import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { ChunkingMode, DataSourceType, ProcessMode } from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
@ -2210,6 +2210,10 @@ describe('StepTwo Component', () => {
|
||||
mockCurrentDataset = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
const defaultStepTwoProps = {
|
||||
dataSourceType: DataSourceType.FILE,
|
||||
files: [createMockFile()],
|
||||
|
||||
@ -0,0 +1,313 @@
|
||||
import type { CrawlResultItem } from '@/models/datasets'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import CrawledResult from './crawled-result'
|
||||
|
||||
vi.mock('./checkbox-with-label', () => ({
|
||||
default: ({ isChecked, onChange, label, testId }: {
|
||||
isChecked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
label: string
|
||||
testId?: string
|
||||
}) => (
|
||||
<label data-testid={testId}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => onChange(!isChecked)}
|
||||
data-testid={`checkbox-${testId}`}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./crawled-result-item', () => ({
|
||||
default: ({ payload, isChecked, isPreview, onCheckChange, onPreview, testId }: {
|
||||
payload: CrawlResultItem
|
||||
isChecked: boolean
|
||||
isPreview: boolean
|
||||
onCheckChange: (checked: boolean) => void
|
||||
onPreview: () => void
|
||||
testId?: string
|
||||
}) => (
|
||||
<div data-testid={testId} data-preview={isPreview}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => onCheckChange(!isChecked)}
|
||||
data-testid={`check-${testId}`}
|
||||
/>
|
||||
<span>{payload.title}</span>
|
||||
<span>{payload.source_url}</span>
|
||||
<button onClick={onPreview} data-testid={`preview-${testId}`}>Preview</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createMockItem = (overrides: Partial<CrawlResultItem> = {}): CrawlResultItem => ({
|
||||
title: 'Test Page',
|
||||
markdown: '# Test',
|
||||
description: 'A test page',
|
||||
source_url: 'https://example.com',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockList = (): CrawlResultItem[] => [
|
||||
createMockItem({ title: 'Page 1', source_url: 'https://example.com/1' }),
|
||||
createMockItem({ title: 'Page 2', source_url: 'https://example.com/2' }),
|
||||
createMockItem({ title: 'Page 3', source_url: 'https://example.com/3' }),
|
||||
]
|
||||
|
||||
describe('CrawledResult', () => {
|
||||
const mockOnSelectedChange = vi.fn()
|
||||
const mockOnPreview = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render select all checkbox', () => {
|
||||
const list = createMockList()
|
||||
render(
|
||||
<CrawledResult
|
||||
list={list}
|
||||
checkedList={[]}
|
||||
onSelectedChange={mockOnSelectedChange}
|
||||
onPreview={mockOnPreview}
|
||||
usedTime={1.5}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('select-all')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all items from list', () => {
|
||||
const list = createMockList()
|
||||
render(
|
||||
<CrawledResult
|
||||
list={list}
|
||||
checkedList={[]}
|
||||
onSelectedChange={mockOnSelectedChange}
|
||||
onPreview={mockOnPreview}
|
||||
usedTime={1.5}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('item-0')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('item-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('item-2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render scrap time info', () => {
|
||||
const list = createMockList()
|
||||
render(
|
||||
<CrawledResult
|
||||
list={list}
|
||||
checkedList={[]}
|
||||
onSelectedChange={mockOnSelectedChange}
|
||||
onPreview={mockOnPreview}
|
||||
usedTime={1.5}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/scrapTimeInfo/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const list = createMockList()
|
||||
const { container } = render(
|
||||
<CrawledResult
|
||||
className="custom-class"
|
||||
list={list}
|
||||
checkedList={[]}
|
||||
onSelectedChange={mockOnSelectedChange}
|
||||
onPreview={mockOnPreview}
|
||||
usedTime={1.5}
|
||||
/>,
|
||||
)
|
||||
|
||||
const rootElement = container.firstChild as HTMLElement
|
||||
expect(rootElement).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Select All', () => {
|
||||
it('should call onSelectedChange with full list when not all checked', () => {
|
||||
const list = createMockList()
|
||||
render(
|
||||
<CrawledResult
|
||||
list={list}
|
||||
checkedList={[list[0]]}
|
||||
onSelectedChange={mockOnSelectedChange}
|
||||
onPreview={mockOnPreview}
|
||||
usedTime={1.5}
|
||||
/>,
|
||||
)
|
||||
|
||||
const selectAllCheckbox = screen.getByTestId('checkbox-select-all')
|
||||
fireEvent.click(selectAllCheckbox)
|
||||
|
||||
expect(mockOnSelectedChange).toHaveBeenCalledWith(list)
|
||||
})
|
||||
|
||||
it('should call onSelectedChange with empty array when all checked', () => {
|
||||
const list = createMockList()
|
||||
render(
|
||||
<CrawledResult
|
||||
list={list}
|
||||
checkedList={list}
|
||||
onSelectedChange={mockOnSelectedChange}
|
||||
onPreview={mockOnPreview}
|
||||
usedTime={1.5}
|
||||
/>,
|
||||
)
|
||||
|
||||
const selectAllCheckbox = screen.getByTestId('checkbox-select-all')
|
||||
fireEvent.click(selectAllCheckbox)
|
||||
|
||||
expect(mockOnSelectedChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('should show selectAll label when not all checked', () => {
|
||||
const list = createMockList()
|
||||
render(
|
||||
<CrawledResult
|
||||
list={list}
|
||||
checkedList={[list[0]]}
|
||||
onSelectedChange={mockOnSelectedChange}
|
||||
onPreview={mockOnPreview}
|
||||
usedTime={1.5}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/selectAll/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show resetAll label when all checked', () => {
|
||||
const list = createMockList()
|
||||
render(
|
||||
<CrawledResult
|
||||
list={list}
|
||||
checkedList={list}
|
||||
onSelectedChange={mockOnSelectedChange}
|
||||
onPreview={mockOnPreview}
|
||||
usedTime={1.5}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/resetAll/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Individual Item Check', () => {
|
||||
it('should call onSelectedChange with added item when checking', () => {
|
||||
const list = createMockList()
|
||||
const checkedList = [list[0]]
|
||||
render(
|
||||
<CrawledResult
|
||||
list={list}
|
||||
checkedList={checkedList}
|
||||
onSelectedChange={mockOnSelectedChange}
|
||||
onPreview={mockOnPreview}
|
||||
usedTime={1.5}
|
||||
/>,
|
||||
)
|
||||
|
||||
const item1Checkbox = screen.getByTestId('check-item-1')
|
||||
fireEvent.click(item1Checkbox)
|
||||
|
||||
expect(mockOnSelectedChange).toHaveBeenCalledWith([list[0], list[1]])
|
||||
})
|
||||
|
||||
it('should call onSelectedChange with removed item when unchecking', () => {
|
||||
const list = createMockList()
|
||||
const checkedList = [list[0], list[1]]
|
||||
render(
|
||||
<CrawledResult
|
||||
list={list}
|
||||
checkedList={checkedList}
|
||||
onSelectedChange={mockOnSelectedChange}
|
||||
onPreview={mockOnPreview}
|
||||
usedTime={1.5}
|
||||
/>,
|
||||
)
|
||||
|
||||
const item0Checkbox = screen.getByTestId('check-item-0')
|
||||
fireEvent.click(item0Checkbox)
|
||||
|
||||
expect(mockOnSelectedChange).toHaveBeenCalledWith([list[1]])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Preview', () => {
|
||||
it('should call onPreview with correct item when preview clicked', () => {
|
||||
const list = createMockList()
|
||||
render(
|
||||
<CrawledResult
|
||||
list={list}
|
||||
checkedList={[]}
|
||||
onSelectedChange={mockOnSelectedChange}
|
||||
onPreview={mockOnPreview}
|
||||
usedTime={1.5}
|
||||
/>,
|
||||
)
|
||||
|
||||
const previewButton = screen.getByTestId('preview-item-1')
|
||||
fireEvent.click(previewButton)
|
||||
|
||||
expect(mockOnPreview).toHaveBeenCalledWith(list[1])
|
||||
})
|
||||
|
||||
it('should update preview state when preview button is clicked', () => {
|
||||
const list = createMockList()
|
||||
render(
|
||||
<CrawledResult
|
||||
list={list}
|
||||
checkedList={[]}
|
||||
onSelectedChange={mockOnSelectedChange}
|
||||
onPreview={mockOnPreview}
|
||||
usedTime={1.5}
|
||||
/>,
|
||||
)
|
||||
|
||||
const previewButton = screen.getByTestId('preview-item-0')
|
||||
fireEvent.click(previewButton)
|
||||
|
||||
const item0 = screen.getByTestId('item-0')
|
||||
expect(item0).toHaveAttribute('data-preview', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render empty list without crashing', () => {
|
||||
render(
|
||||
<CrawledResult
|
||||
list={[]}
|
||||
checkedList={[]}
|
||||
onSelectedChange={mockOnSelectedChange}
|
||||
onPreview={mockOnPreview}
|
||||
usedTime={0}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('select-all')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle single item list', () => {
|
||||
const list = [createMockItem()]
|
||||
render(
|
||||
<CrawledResult
|
||||
list={list}
|
||||
checkedList={[]}
|
||||
onSelectedChange={mockOnSelectedChange}
|
||||
onPreview={mockOnPreview}
|
||||
usedTime={0.5}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('item-0')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
286
web/app/components/datasets/create/website/index.spec.tsx
Normal file
286
web/app/components/datasets/create/website/index.spec.tsx
Normal file
@ -0,0 +1,286 @@
|
||||
import type { DataSourceAuth } from '@/app/components/header/account-setting/data-source-page-new/types'
|
||||
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
|
||||
import Website from './index'
|
||||
|
||||
const mockSetShowAccountSettingModal = vi.fn()
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowAccountSettingModal: mockSetShowAccountSettingModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./index.module.css', () => ({
|
||||
default: {
|
||||
jinaLogo: 'jina-logo',
|
||||
watercrawlLogo: 'watercrawl-logo',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./firecrawl', () => ({
|
||||
default: (props: Record<string, unknown>) => <div data-testid="firecrawl-component" data-props={JSON.stringify(props)} />,
|
||||
}))
|
||||
|
||||
vi.mock('./jina-reader', () => ({
|
||||
default: (props: Record<string, unknown>) => <div data-testid="jina-reader-component" data-props={JSON.stringify(props)} />,
|
||||
}))
|
||||
|
||||
vi.mock('./watercrawl', () => ({
|
||||
default: (props: Record<string, unknown>) => <div data-testid="watercrawl-component" data-props={JSON.stringify(props)} />,
|
||||
}))
|
||||
|
||||
vi.mock('./no-data', () => ({
|
||||
default: ({ onConfig, provider }: { onConfig: () => void, provider: string }) => (
|
||||
<div data-testid="no-data-component" data-provider={provider}>
|
||||
<button onClick={onConfig} data-testid="no-data-config-button">Configure</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
let mockEnableJinaReader = true
|
||||
let mockEnableFirecrawl = true
|
||||
let mockEnableWatercrawl = true
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
get ENABLE_WEBSITE_JINAREADER() { return mockEnableJinaReader },
|
||||
get ENABLE_WEBSITE_FIRECRAWL() { return mockEnableFirecrawl },
|
||||
get ENABLE_WEBSITE_WATERCRAWL() { return mockEnableWatercrawl },
|
||||
}))
|
||||
|
||||
const createMockCrawlOptions = (overrides: Partial<CrawlOptions> = {}): CrawlOptions => ({
|
||||
crawl_sub_pages: true,
|
||||
limit: 10,
|
||||
max_depth: 2,
|
||||
excludes: '',
|
||||
includes: '',
|
||||
only_main_content: false,
|
||||
use_sitemap: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockDataSourceAuth = (
|
||||
provider: string,
|
||||
credentialsCount = 1,
|
||||
): DataSourceAuth => ({
|
||||
author: 'test',
|
||||
provider,
|
||||
plugin_id: `${provider}-plugin`,
|
||||
plugin_unique_identifier: `${provider}-unique`,
|
||||
icon: 'icon.png',
|
||||
name: provider,
|
||||
label: { en_US: provider, zh_Hans: provider },
|
||||
description: { en_US: `${provider} description`, zh_Hans: `${provider} description` },
|
||||
credentials_list: Array.from({ length: credentialsCount }, (_, i) => ({
|
||||
credential: {},
|
||||
type: CredentialTypeEnum.API_KEY,
|
||||
name: `cred-${i}`,
|
||||
id: `cred-${i}`,
|
||||
is_default: i === 0,
|
||||
avatar_url: '',
|
||||
})),
|
||||
})
|
||||
|
||||
type RenderProps = {
|
||||
authedDataSourceList?: DataSourceAuth[]
|
||||
enableJina?: boolean
|
||||
enableFirecrawl?: boolean
|
||||
enableWatercrawl?: boolean
|
||||
}
|
||||
|
||||
const renderWebsite = ({
|
||||
authedDataSourceList = [],
|
||||
enableJina = true,
|
||||
enableFirecrawl = true,
|
||||
enableWatercrawl = true,
|
||||
}: RenderProps = {}) => {
|
||||
mockEnableJinaReader = enableJina
|
||||
mockEnableFirecrawl = enableFirecrawl
|
||||
mockEnableWatercrawl = enableWatercrawl
|
||||
|
||||
const props = {
|
||||
onPreview: vi.fn() as (payload: CrawlResultItem) => void,
|
||||
checkedCrawlResult: [] as CrawlResultItem[],
|
||||
onCheckedCrawlResultChange: vi.fn() as (payload: CrawlResultItem[]) => void,
|
||||
onCrawlProviderChange: vi.fn(),
|
||||
onJobIdChange: vi.fn(),
|
||||
crawlOptions: createMockCrawlOptions(),
|
||||
onCrawlOptionsChange: vi.fn() as (payload: CrawlOptions) => void,
|
||||
authedDataSourceList,
|
||||
}
|
||||
|
||||
const result = render(<Website {...props} />)
|
||||
return { ...result, props }
|
||||
}
|
||||
|
||||
describe('Website', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockEnableJinaReader = true
|
||||
mockEnableFirecrawl = true
|
||||
mockEnableWatercrawl = true
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render provider selection section', () => {
|
||||
renderWebsite()
|
||||
expect(screen.getByText(/chooseProvider/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show Jina Reader button when ENABLE_WEBSITE_JINAREADER is true', () => {
|
||||
renderWebsite({ enableJina: true })
|
||||
expect(screen.getByText('Jina Reader')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show Jina Reader button when ENABLE_WEBSITE_JINAREADER is false', () => {
|
||||
renderWebsite({ enableJina: false })
|
||||
expect(screen.queryByText('Jina Reader')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show Firecrawl button when ENABLE_WEBSITE_FIRECRAWL is true', () => {
|
||||
renderWebsite({ enableFirecrawl: true })
|
||||
expect(screen.getByText(/Firecrawl/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show Firecrawl button when ENABLE_WEBSITE_FIRECRAWL is false', () => {
|
||||
renderWebsite({ enableFirecrawl: false })
|
||||
expect(screen.queryByText(/Firecrawl/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show WaterCrawl button when ENABLE_WEBSITE_WATERCRAWL is true', () => {
|
||||
renderWebsite({ enableWatercrawl: true })
|
||||
expect(screen.getByText('WaterCrawl')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show WaterCrawl button when ENABLE_WEBSITE_WATERCRAWL is false', () => {
|
||||
renderWebsite({ enableWatercrawl: false })
|
||||
expect(screen.queryByText('WaterCrawl')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Provider Selection', () => {
|
||||
it('should select Jina Reader by default', () => {
|
||||
const authedDataSourceList = [createMockDataSourceAuth('jinareader')]
|
||||
renderWebsite({ authedDataSourceList })
|
||||
|
||||
expect(screen.getByTestId('jina-reader-component')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch to Firecrawl when Firecrawl button clicked', () => {
|
||||
const authedDataSourceList = [
|
||||
createMockDataSourceAuth('jinareader'),
|
||||
createMockDataSourceAuth('firecrawl'),
|
||||
]
|
||||
renderWebsite({ authedDataSourceList })
|
||||
|
||||
const firecrawlButton = screen.getByText(/Firecrawl/)
|
||||
fireEvent.click(firecrawlButton)
|
||||
|
||||
expect(screen.getByTestId('firecrawl-component')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('jina-reader-component')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch to WaterCrawl when WaterCrawl button clicked', () => {
|
||||
const authedDataSourceList = [
|
||||
createMockDataSourceAuth('jinareader'),
|
||||
createMockDataSourceAuth('watercrawl'),
|
||||
]
|
||||
renderWebsite({ authedDataSourceList })
|
||||
|
||||
const watercrawlButton = screen.getByText('WaterCrawl')
|
||||
fireEvent.click(watercrawlButton)
|
||||
|
||||
expect(screen.getByTestId('watercrawl-component')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('jina-reader-component')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onCrawlProviderChange when provider switched', () => {
|
||||
const authedDataSourceList = [
|
||||
createMockDataSourceAuth('jinareader'),
|
||||
createMockDataSourceAuth('firecrawl'),
|
||||
]
|
||||
const { props } = renderWebsite({ authedDataSourceList })
|
||||
|
||||
const firecrawlButton = screen.getByText(/Firecrawl/)
|
||||
fireEvent.click(firecrawlButton)
|
||||
|
||||
expect(props.onCrawlProviderChange).toHaveBeenCalledWith('firecrawl')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Provider Content', () => {
|
||||
it('should show JinaReader component when selected and available', () => {
|
||||
const authedDataSourceList = [createMockDataSourceAuth('jinareader')]
|
||||
renderWebsite({ authedDataSourceList })
|
||||
|
||||
expect(screen.getByTestId('jina-reader-component')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show Firecrawl component when selected and available', () => {
|
||||
const authedDataSourceList = [
|
||||
createMockDataSourceAuth('jinareader'),
|
||||
createMockDataSourceAuth('firecrawl'),
|
||||
]
|
||||
renderWebsite({ authedDataSourceList })
|
||||
|
||||
const firecrawlButton = screen.getByText(/Firecrawl/)
|
||||
fireEvent.click(firecrawlButton)
|
||||
|
||||
expect(screen.getByTestId('firecrawl-component')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show NoData when selected provider has no credentials', () => {
|
||||
const authedDataSourceList = [createMockDataSourceAuth('jinareader', 0)]
|
||||
renderWebsite({ authedDataSourceList })
|
||||
|
||||
expect(screen.getByTestId('no-data-component')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show NoData when no data source available for selected provider', () => {
|
||||
renderWebsite({ authedDataSourceList: [] })
|
||||
|
||||
expect(screen.getByTestId('no-data-component')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('NoData Config', () => {
|
||||
it('should call setShowAccountSettingModal when NoData onConfig is triggered', () => {
|
||||
renderWebsite({ authedDataSourceList: [] })
|
||||
|
||||
const configButton = screen.getByTestId('no-data-config-button')
|
||||
fireEvent.click(configButton)
|
||||
|
||||
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
|
||||
payload: 'data-source',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle no providers enabled', () => {
|
||||
renderWebsite({
|
||||
enableJina: false,
|
||||
enableFirecrawl: false,
|
||||
enableWatercrawl: false,
|
||||
})
|
||||
|
||||
expect(screen.queryByText('Jina Reader')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/Firecrawl/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('WaterCrawl')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle only one provider enabled', () => {
|
||||
renderWebsite({
|
||||
enableJina: true,
|
||||
enableFirecrawl: false,
|
||||
enableWatercrawl: false,
|
||||
})
|
||||
|
||||
expect(screen.getByText('Jina Reader')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Firecrawl/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('WaterCrawl')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,212 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// ============================================================================
|
||||
// Component Imports (after mocks)
|
||||
// ============================================================================
|
||||
|
||||
import UrlInput from './url-input'
|
||||
|
||||
// ============================================================================
|
||||
// Mock Setup
|
||||
// ============================================================================
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: vi.fn(() => () => 'https://docs.example.com'),
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Jina Reader UrlInput Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('UrlInput (jina-reader)', () => {
|
||||
const mockOnRun = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render input and run button', () => {
|
||||
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render input with placeholder from docLink', () => {
|
||||
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveAttribute('placeholder', 'https://docs.example.com')
|
||||
})
|
||||
|
||||
it('should show run text when not running', () => {
|
||||
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveTextContent(/run/i)
|
||||
})
|
||||
|
||||
it('should hide run text when running', () => {
|
||||
render(<UrlInput isRunning={true} onRun={mockOnRun} />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).not.toHaveTextContent(/run/i)
|
||||
})
|
||||
|
||||
it('should show loading state on button when running', () => {
|
||||
render(<UrlInput isRunning={true} onRun={mockOnRun} />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveTextContent(/loading/i)
|
||||
})
|
||||
|
||||
it('should not show loading state on button when not running', () => {
|
||||
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).not.toHaveTextContent(/loading/i)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Interactions Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should update url when user types in input', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.type(input, 'https://example.com')
|
||||
|
||||
expect(input).toHaveValue('https://example.com')
|
||||
})
|
||||
|
||||
it('should call onRun with url when run button clicked and not running', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.type(input, 'https://example.com')
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
expect(mockOnRun).toHaveBeenCalledWith('https://example.com')
|
||||
expect(mockOnRun).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should NOT call onRun when isRunning is true', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<UrlInput isRunning={true} onRun={mockOnRun} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'https://example.com' } })
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
expect(mockOnRun).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onRun with empty string when button clicked with empty input', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
expect(mockOnRun).toHaveBeenCalledWith('')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Props Variations Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Props Variations', () => {
|
||||
it('should update button state when isRunning changes from false to true', () => {
|
||||
const { rerender } = render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent(/run/i)
|
||||
|
||||
rerender(<UrlInput isRunning={true} onRun={mockOnRun} />)
|
||||
|
||||
expect(screen.getByRole('button')).not.toHaveTextContent(/run/i)
|
||||
})
|
||||
|
||||
it('should preserve input value when isRunning prop changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.type(input, 'https://preserved.com')
|
||||
expect(input).toHaveValue('https://preserved.com')
|
||||
|
||||
rerender(<UrlInput isRunning={true} onRun={mockOnRun} />)
|
||||
expect(input).toHaveValue('https://preserved.com')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Edge Cases Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle special characters in url', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
const specialUrl = 'https://example.com/path?query=test¶m=value#anchor'
|
||||
await user.type(input, specialUrl)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
expect(mockOnRun).toHaveBeenCalledWith(specialUrl)
|
||||
})
|
||||
|
||||
it('should handle rapid input changes', () => {
|
||||
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'a' } })
|
||||
fireEvent.change(input, { target: { value: 'ab' } })
|
||||
fireEvent.change(input, { target: { value: 'https://final.com' } })
|
||||
|
||||
expect(input).toHaveValue('https://final.com')
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(mockOnRun).toHaveBeenCalledWith('https://final.com')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Integration Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Integration', () => {
|
||||
it('should complete full workflow: type url -> click run -> verify callback', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.type(input, 'https://mywebsite.com')
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
expect(mockOnRun).toHaveBeenCalledWith('https://mywebsite.com')
|
||||
})
|
||||
|
||||
it('should show correct states during running workflow', () => {
|
||||
const { rerender } = render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent(/run/i)
|
||||
|
||||
rerender(<UrlInput isRunning={true} onRun={mockOnRun} />)
|
||||
expect(screen.getByRole('button')).not.toHaveTextContent(/run/i)
|
||||
|
||||
rerender(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
expect(screen.getByRole('button')).toHaveTextContent(/run/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,209 @@
|
||||
import type { CrawlOptions } from '@/models/datasets'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Options from './options'
|
||||
|
||||
// ============================================================================
|
||||
// Test Data Factory
|
||||
// ============================================================================
|
||||
|
||||
const createMockCrawlOptions = (overrides: Partial<CrawlOptions> = {}): CrawlOptions => ({
|
||||
crawl_sub_pages: true,
|
||||
limit: 10,
|
||||
max_depth: 2,
|
||||
excludes: '',
|
||||
includes: '',
|
||||
only_main_content: false,
|
||||
use_sitemap: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Jina Reader Options Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Options (jina-reader)', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const getCheckboxes = (container: HTMLElement) => {
|
||||
return container.querySelectorAll('[data-testid^="checkbox-"]')
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render crawlSubPage and useSitemap checkboxes and limit field', () => {
|
||||
const payload = createMockCrawlOptions()
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
expect(screen.getByText(/crawlSubPage/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/useSitemap/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/limit/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render two checkboxes', () => {
|
||||
const payload = createMockCrawlOptions()
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
expect(checkboxes.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should render limit field with required indicator', () => {
|
||||
const payload = createMockCrawlOptions()
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const requiredIndicator = screen.getByText('*')
|
||||
expect(requiredIndicator).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with custom className', () => {
|
||||
const payload = createMockCrawlOptions()
|
||||
const { container } = render(
|
||||
<Options payload={payload} onChange={mockOnChange} className="custom-class" />,
|
||||
)
|
||||
|
||||
const rootElement = container.firstChild as HTMLElement
|
||||
expect(rootElement).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Props Display Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Props Display', () => {
|
||||
it('should display crawl_sub_pages checkbox with check icon when true', () => {
|
||||
const payload = createMockCrawlOptions({ crawl_sub_pages: true })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
expect(checkboxes[0].querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display crawl_sub_pages checkbox without check icon when false', () => {
|
||||
const payload = createMockCrawlOptions({ crawl_sub_pages: false })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
expect(checkboxes[0].querySelector('svg')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display use_sitemap checkbox with check icon when true', () => {
|
||||
const payload = createMockCrawlOptions({ use_sitemap: true })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
expect(checkboxes[1].querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display use_sitemap checkbox without check icon when false', () => {
|
||||
const payload = createMockCrawlOptions({ use_sitemap: false })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
expect(checkboxes[1].querySelector('svg')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display limit value in input', () => {
|
||||
const payload = createMockCrawlOptions({ limit: 25 })
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
expect(screen.getByDisplayValue('25')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Interactions Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange with updated crawl_sub_pages when checkbox is clicked', () => {
|
||||
const payload = createMockCrawlOptions({ crawl_sub_pages: true })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
fireEvent.click(checkboxes[0])
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
...payload,
|
||||
crawl_sub_pages: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onChange with updated use_sitemap when checkbox is clicked', () => {
|
||||
const payload = createMockCrawlOptions({ use_sitemap: false })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
fireEvent.click(checkboxes[1])
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
...payload,
|
||||
use_sitemap: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onChange with updated limit when input changes', () => {
|
||||
const payload = createMockCrawlOptions({ limit: 10 })
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const limitInput = screen.getByDisplayValue('10')
|
||||
fireEvent.change(limitInput, { target: { value: '50' } })
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
...payload,
|
||||
limit: 50,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Edge Cases Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle zero limit value', () => {
|
||||
const payload = createMockCrawlOptions({ limit: 0 })
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const zeroInputs = screen.getAllByDisplayValue('0')
|
||||
expect(zeroInputs.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should preserve other payload fields when updating one field', () => {
|
||||
const payload = createMockCrawlOptions({
|
||||
crawl_sub_pages: true,
|
||||
limit: 10,
|
||||
use_sitemap: true,
|
||||
})
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const limitInput = screen.getByDisplayValue('10')
|
||||
fireEvent.change(limitInput, { target: { value: '20' } })
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
...payload,
|
||||
limit: 20,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should re-render when payload changes', () => {
|
||||
const payload1 = createMockCrawlOptions({ limit: 10 })
|
||||
const payload2 = createMockCrawlOptions({ limit: 20 })
|
||||
|
||||
const { rerender } = render(<Options payload={payload1} onChange={mockOnChange} />)
|
||||
expect(screen.getByDisplayValue('10')).toBeInTheDocument()
|
||||
|
||||
rerender(<Options payload={payload2} onChange={mockOnChange} />)
|
||||
expect(screen.getByDisplayValue('20')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,294 @@
|
||||
import type { CrawlOptions } from '@/models/datasets'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Options from './options'
|
||||
|
||||
// ============================================================================
|
||||
// Test Data Factory
|
||||
// ============================================================================
|
||||
|
||||
const createMockCrawlOptions = (overrides: Partial<CrawlOptions> = {}): CrawlOptions => ({
|
||||
crawl_sub_pages: true,
|
||||
limit: 10,
|
||||
max_depth: 2,
|
||||
excludes: '',
|
||||
includes: '',
|
||||
only_main_content: false,
|
||||
use_sitemap: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// WaterCrawl Options Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Options (watercrawl)', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const getCheckboxes = (container: HTMLElement) => {
|
||||
return container.querySelectorAll('[data-testid^="checkbox-"]')
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render all form fields', () => {
|
||||
const payload = createMockCrawlOptions()
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
expect(screen.getByText(/crawlSubPage/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/extractOnlyMainContent/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/limit/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/maxDepth/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/excludePaths/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/includeOnlyPaths/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render two checkboxes', () => {
|
||||
const payload = createMockCrawlOptions()
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
expect(checkboxes.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should render limit field with required indicator', () => {
|
||||
const payload = createMockCrawlOptions()
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const requiredIndicator = screen.getByText('*')
|
||||
expect(requiredIndicator).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render placeholder for excludes field', () => {
|
||||
const payload = createMockCrawlOptions()
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
expect(screen.getByPlaceholderText('blog/*, /about/*')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render placeholder for includes field', () => {
|
||||
const payload = createMockCrawlOptions()
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
expect(screen.getByPlaceholderText('articles/*')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with custom className', () => {
|
||||
const payload = createMockCrawlOptions()
|
||||
const { container } = render(
|
||||
<Options payload={payload} onChange={mockOnChange} className="custom-class" />,
|
||||
)
|
||||
|
||||
const rootElement = container.firstChild as HTMLElement
|
||||
expect(rootElement).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Props Display Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Props Display', () => {
|
||||
it('should display crawl_sub_pages checkbox with check icon when true', () => {
|
||||
const payload = createMockCrawlOptions({ crawl_sub_pages: true })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
expect(checkboxes[0].querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display crawl_sub_pages checkbox without check icon when false', () => {
|
||||
const payload = createMockCrawlOptions({ crawl_sub_pages: false })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
expect(checkboxes[0].querySelector('svg')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display only_main_content checkbox with check icon when true', () => {
|
||||
const payload = createMockCrawlOptions({ only_main_content: true })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
expect(checkboxes[1].querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display only_main_content checkbox without check icon when false', () => {
|
||||
const payload = createMockCrawlOptions({ only_main_content: false })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
expect(checkboxes[1].querySelector('svg')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display limit value in input', () => {
|
||||
const payload = createMockCrawlOptions({ limit: 25 })
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
expect(screen.getByDisplayValue('25')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display max_depth value in input', () => {
|
||||
const payload = createMockCrawlOptions({ max_depth: 5 })
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
expect(screen.getByDisplayValue('5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display excludes value in input', () => {
|
||||
const payload = createMockCrawlOptions({ excludes: 'test/*' })
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
expect(screen.getByDisplayValue('test/*')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display includes value in input', () => {
|
||||
const payload = createMockCrawlOptions({ includes: 'docs/*' })
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
expect(screen.getByDisplayValue('docs/*')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Interactions Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange with updated crawl_sub_pages when checkbox is clicked', () => {
|
||||
const payload = createMockCrawlOptions({ crawl_sub_pages: true })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
fireEvent.click(checkboxes[0])
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
...payload,
|
||||
crawl_sub_pages: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onChange with updated only_main_content when checkbox is clicked', () => {
|
||||
const payload = createMockCrawlOptions({ only_main_content: false })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
fireEvent.click(checkboxes[1])
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
...payload,
|
||||
only_main_content: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onChange with updated limit when input changes', () => {
|
||||
const payload = createMockCrawlOptions({ limit: 10 })
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const limitInput = screen.getByDisplayValue('10')
|
||||
fireEvent.change(limitInput, { target: { value: '50' } })
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
...payload,
|
||||
limit: 50,
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onChange with updated max_depth when input changes', () => {
|
||||
const payload = createMockCrawlOptions({ max_depth: 2 })
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const maxDepthInput = screen.getByDisplayValue('2')
|
||||
fireEvent.change(maxDepthInput, { target: { value: '10' } })
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
...payload,
|
||||
max_depth: 10,
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onChange with updated excludes when input changes', () => {
|
||||
const payload = createMockCrawlOptions({ excludes: '' })
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const excludesInput = screen.getByPlaceholderText('blog/*, /about/*')
|
||||
fireEvent.change(excludesInput, { target: { value: 'admin/*' } })
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
...payload,
|
||||
excludes: 'admin/*',
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onChange with updated includes when input changes', () => {
|
||||
const payload = createMockCrawlOptions({ includes: '' })
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const includesInput = screen.getByPlaceholderText('articles/*')
|
||||
fireEvent.change(includesInput, { target: { value: 'public/*' } })
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
...payload,
|
||||
includes: 'public/*',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Edge Cases Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Edge Cases', () => {
|
||||
it('should preserve other payload fields when updating one field', () => {
|
||||
const payload = createMockCrawlOptions({
|
||||
crawl_sub_pages: true,
|
||||
limit: 10,
|
||||
max_depth: 2,
|
||||
excludes: 'test/*',
|
||||
includes: 'docs/*',
|
||||
only_main_content: true,
|
||||
})
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const limitInput = screen.getByDisplayValue('10')
|
||||
fireEvent.change(limitInput, { target: { value: '20' } })
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
crawl_sub_pages: true,
|
||||
limit: 20,
|
||||
max_depth: 2,
|
||||
excludes: 'test/*',
|
||||
includes: 'docs/*',
|
||||
only_main_content: true,
|
||||
use_sitemap: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle zero values', () => {
|
||||
const payload = createMockCrawlOptions({ limit: 0, max_depth: 0 })
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const zeroInputs = screen.getAllByDisplayValue('0')
|
||||
expect(zeroInputs.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should re-render when payload changes', () => {
|
||||
const payload1 = createMockCrawlOptions({ limit: 10 })
|
||||
const payload2 = createMockCrawlOptions({ limit: 20 })
|
||||
|
||||
const { rerender } = render(<Options payload={payload1} onChange={mockOnChange} />)
|
||||
expect(screen.getByDisplayValue('10')).toBeInTheDocument()
|
||||
|
||||
rerender(<Options payload={payload2} onChange={mockOnChange} />)
|
||||
expect(screen.getByDisplayValue('20')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,141 @@
|
||||
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
|
||||
import { useDatasourceIcon } from './hooks'
|
||||
|
||||
const mockTransformDataSourceToTool = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/workflow/block-selector/utils', () => ({
|
||||
transformDataSourceToTool: (...args: unknown[]) => mockTransformDataSourceToTool(...args),
|
||||
}))
|
||||
|
||||
let mockDataSourceListReturn: {
|
||||
data: Array<{
|
||||
plugin_id: string
|
||||
provider: string
|
||||
declaration: { identity: { icon: string, author: string } }
|
||||
}> | undefined
|
||||
isSuccess: boolean
|
||||
}
|
||||
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
useDataSourceList: () => mockDataSourceListReturn,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', () => ({
|
||||
basePath: '',
|
||||
}))
|
||||
|
||||
const createMockDataSourceNode = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => ({
|
||||
plugin_id: 'plugin-abc',
|
||||
provider_type: 'builtin',
|
||||
provider_name: 'web-scraper',
|
||||
datasource_name: 'scraper',
|
||||
datasource_label: 'Web Scraper',
|
||||
datasource_parameters: {},
|
||||
datasource_configurations: {},
|
||||
title: 'DataSource',
|
||||
desc: '',
|
||||
type: '' as DataSourceNodeType['type'],
|
||||
...overrides,
|
||||
} as DataSourceNodeType)
|
||||
|
||||
describe('useDatasourceIcon', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDataSourceListReturn = { data: undefined, isSuccess: false }
|
||||
mockTransformDataSourceToTool.mockReset()
|
||||
})
|
||||
|
||||
// Returns undefined when data has not loaded
|
||||
describe('Loading State', () => {
|
||||
it('should return undefined when data is not loaded (isSuccess false)', () => {
|
||||
mockDataSourceListReturn = { data: undefined, isSuccess: false }
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDatasourceIcon(createMockDataSourceNode()),
|
||||
)
|
||||
|
||||
expect(result.current).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// Returns correct icon when plugin_id matches
|
||||
describe('Icon Resolution', () => {
|
||||
it('should return correct icon when plugin_id matches', () => {
|
||||
const mockIcon = 'https://example.com/icon.svg'
|
||||
mockDataSourceListReturn = {
|
||||
data: [
|
||||
{
|
||||
plugin_id: 'plugin-abc',
|
||||
provider: 'web-scraper',
|
||||
declaration: { identity: { icon: mockIcon, author: 'dify' } },
|
||||
},
|
||||
],
|
||||
isSuccess: true,
|
||||
}
|
||||
mockTransformDataSourceToTool.mockImplementation((item: { plugin_id: string, declaration: { identity: { icon: string } } }) => ({
|
||||
plugin_id: item.plugin_id,
|
||||
icon: item.declaration.identity.icon,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDatasourceIcon(createMockDataSourceNode({ plugin_id: 'plugin-abc' })),
|
||||
)
|
||||
|
||||
expect(result.current).toBe(mockIcon)
|
||||
})
|
||||
|
||||
it('should return undefined when plugin_id does not match', () => {
|
||||
mockDataSourceListReturn = {
|
||||
data: [
|
||||
{
|
||||
plugin_id: 'plugin-xyz',
|
||||
provider: 'other',
|
||||
declaration: { identity: { icon: '/icon.svg', author: 'dify' } },
|
||||
},
|
||||
],
|
||||
isSuccess: true,
|
||||
}
|
||||
mockTransformDataSourceToTool.mockImplementation((item: { plugin_id: string, declaration: { identity: { icon: string } } }) => ({
|
||||
plugin_id: item.plugin_id,
|
||||
icon: item.declaration.identity.icon,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDatasourceIcon(createMockDataSourceNode({ plugin_id: 'plugin-abc' })),
|
||||
)
|
||||
|
||||
expect(result.current).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// basePath prepending
|
||||
describe('basePath Prepending', () => {
|
||||
it('should prepend basePath to icon URL when not already included', () => {
|
||||
// basePath is mocked as '' so prepending '' to '/icon.png' results in '/icon.png'
|
||||
// The important thing is that the forEach logic runs without error
|
||||
mockDataSourceListReturn = {
|
||||
data: [
|
||||
{
|
||||
plugin_id: 'plugin-abc',
|
||||
provider: 'web-scraper',
|
||||
declaration: { identity: { icon: '/icon.png', author: 'dify' } },
|
||||
},
|
||||
],
|
||||
isSuccess: true,
|
||||
}
|
||||
mockTransformDataSourceToTool.mockImplementation((item: { plugin_id: string, declaration: { identity: { icon: string } } }) => ({
|
||||
plugin_id: item.plugin_id,
|
||||
icon: item.declaration.identity.icon,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDatasourceIcon(createMockDataSourceNode({ plugin_id: 'plugin-abc' })),
|
||||
)
|
||||
|
||||
// With empty basePath, icon stays as '/icon.png'
|
||||
expect(result.current).toBe('/icon.png')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,110 @@
|
||||
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import OptionCard from './option-card'
|
||||
|
||||
const TEST_ICON_URL = 'https://example.com/test-icon.png'
|
||||
|
||||
vi.mock('./hooks', () => ({
|
||||
useDatasourceIcon: () => TEST_ICON_URL,
|
||||
}))
|
||||
|
||||
vi.mock('./datasource-icon', () => ({
|
||||
default: ({ iconUrl }: { iconUrl: string }) => (
|
||||
<img data-testid="datasource-icon" src={iconUrl} alt="datasource" />
|
||||
),
|
||||
}))
|
||||
|
||||
const createMockNodeData = (overrides: Partial<DataSourceNodeType> = {}): DataSourceNodeType => ({
|
||||
title: 'Test Node',
|
||||
desc: '',
|
||||
type: {} as DataSourceNodeType['type'],
|
||||
plugin_id: 'test-plugin',
|
||||
provider_type: 'builtin',
|
||||
provider_name: 'test-provider',
|
||||
datasource_name: 'test-ds',
|
||||
datasource_label: 'Test DS',
|
||||
datasource_parameters: {},
|
||||
datasource_configurations: {},
|
||||
...overrides,
|
||||
} as DataSourceNodeType)
|
||||
|
||||
describe('OptionCard', () => {
|
||||
const defaultProps = {
|
||||
label: 'Google Drive',
|
||||
selected: false,
|
||||
nodeData: createMockNodeData(),
|
||||
onClick: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering: label text and icon
|
||||
describe('Rendering', () => {
|
||||
it('should render label text', () => {
|
||||
render(<OptionCard {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('Google Drive')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render datasource icon with correct URL', () => {
|
||||
render(<OptionCard {...defaultProps} />)
|
||||
|
||||
const icon = screen.getByTestId('datasource-icon')
|
||||
expect(icon).toHaveAttribute('src', TEST_ICON_URL)
|
||||
})
|
||||
|
||||
it('should set title attribute on label element', () => {
|
||||
render(<OptionCard {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTitle('Google Drive')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User interactions: clicking the card
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClick when clicked', () => {
|
||||
render(<OptionCard {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Google Drive'))
|
||||
|
||||
expect(defaultProps.onClick).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('should not throw when onClick is undefined', () => {
|
||||
expect(() => {
|
||||
const { container } = render(
|
||||
<OptionCard {...defaultProps} onClick={undefined} />,
|
||||
)
|
||||
fireEvent.click(container.firstElementChild!)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// Props: selected state applies different styles
|
||||
describe('Props', () => {
|
||||
it('should apply selected styles when selected is true', () => {
|
||||
const { container } = render(<OptionCard {...defaultProps} selected />)
|
||||
|
||||
const card = container.firstElementChild
|
||||
expect(card?.className).toContain('border-components-option-card-option-selected-border')
|
||||
expect(card?.className).toContain('bg-components-option-card-option-selected-bg')
|
||||
})
|
||||
|
||||
it('should apply default styles when selected is false', () => {
|
||||
const { container } = render(<OptionCard {...defaultProps} selected={false} />)
|
||||
|
||||
const card = container.firstElementChild
|
||||
expect(card?.className).not.toContain('border-components-option-card-option-selected-border')
|
||||
})
|
||||
|
||||
it('should apply text-text-primary class to label when selected', () => {
|
||||
render(<OptionCard {...defaultProps} selected />)
|
||||
|
||||
const labelEl = screen.getByTitle('Google Drive')
|
||||
expect(labelEl.className).toContain('text-text-primary')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,61 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Drive from './drive'
|
||||
|
||||
describe('Drive', () => {
|
||||
const defaultProps = {
|
||||
breadcrumbs: [] as string[],
|
||||
handleBackToRoot: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering: button text and separator visibility
|
||||
describe('Rendering', () => {
|
||||
it('should render "All Files" button text', () => {
|
||||
render(<Drive {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('datasetPipeline.onlineDrive.breadcrumbs.allFiles')
|
||||
})
|
||||
|
||||
it('should show separator "/" when breadcrumbs has items', () => {
|
||||
render(<Drive {...defaultProps} breadcrumbs={['Folder A']} />)
|
||||
|
||||
expect(screen.getByText('/')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide separator when breadcrumbs is empty', () => {
|
||||
render(<Drive {...defaultProps} breadcrumbs={[]} />)
|
||||
|
||||
expect(screen.queryByText('/')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Props: disabled state depends on breadcrumbs length
|
||||
describe('Props', () => {
|
||||
it('should disable button when breadcrumbs is empty', () => {
|
||||
render(<Drive {...defaultProps} breadcrumbs={[]} />)
|
||||
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable button when breadcrumbs has items', () => {
|
||||
render(<Drive {...defaultProps} breadcrumbs={['Folder A', 'Folder B']} />)
|
||||
|
||||
expect(screen.getByRole('button')).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// User interactions: clicking the root button
|
||||
describe('User Interactions', () => {
|
||||
it('should call handleBackToRoot on click when enabled', () => {
|
||||
render(<Drive {...defaultProps} breadcrumbs={['Folder A']} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(defaultProps.handleBackToRoot).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,44 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Item from './item'
|
||||
|
||||
describe('Item', () => {
|
||||
const defaultProps = {
|
||||
name: 'Documents',
|
||||
index: 2,
|
||||
onBreadcrumbClick: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering: verify the breadcrumb name is displayed
|
||||
describe('Rendering', () => {
|
||||
it('should render breadcrumb name', () => {
|
||||
render(<Item {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('Documents')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User interactions: clicking triggers callback with correct index
|
||||
describe('User Interactions', () => {
|
||||
it('should call onBreadcrumbClick with correct index on click', () => {
|
||||
render(<Item {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Documents'))
|
||||
|
||||
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledOnce()
|
||||
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('should pass different index values correctly', () => {
|
||||
render(<Item {...defaultProps} index={5} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Documents'))
|
||||
|
||||
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(5)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,79 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Menu from './menu'
|
||||
|
||||
describe('Menu', () => {
|
||||
const defaultProps = {
|
||||
breadcrumbs: ['Folder A', 'Folder B', 'Folder C'],
|
||||
startIndex: 1,
|
||||
onBreadcrumbClick: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering: verify all breadcrumb items are displayed
|
||||
describe('Rendering', () => {
|
||||
it('should render all breadcrumb items', () => {
|
||||
render(<Menu {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('Folder A')).toBeInTheDocument()
|
||||
expect(screen.getByText('Folder B')).toBeInTheDocument()
|
||||
expect(screen.getByText('Folder C')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render empty list when no breadcrumbs provided', () => {
|
||||
const { container } = render(
|
||||
<Menu breadcrumbs={[]} startIndex={0} onBreadcrumbClick={vi.fn()} />,
|
||||
)
|
||||
|
||||
const menuContainer = container.firstElementChild
|
||||
expect(menuContainer?.children).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// Index mapping: startIndex offsets are applied correctly
|
||||
describe('Index Mapping', () => {
|
||||
it('should pass correct index (startIndex + offset) to each item', () => {
|
||||
render(<Menu {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Folder A'))
|
||||
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(1)
|
||||
|
||||
fireEvent.click(screen.getByText('Folder B'))
|
||||
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(2)
|
||||
|
||||
fireEvent.click(screen.getByText('Folder C'))
|
||||
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(3)
|
||||
})
|
||||
|
||||
it('should offset from startIndex of zero', () => {
|
||||
render(
|
||||
<Menu
|
||||
breadcrumbs={['First', 'Second']}
|
||||
startIndex={0}
|
||||
onBreadcrumbClick={defaultProps.onBreadcrumbClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('First'))
|
||||
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(0)
|
||||
|
||||
fireEvent.click(screen.getByText('Second'))
|
||||
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(1)
|
||||
})
|
||||
})
|
||||
|
||||
// User interactions: clicking items triggers the callback
|
||||
describe('User Interactions', () => {
|
||||
it('should call onBreadcrumbClick with correct index when item clicked', () => {
|
||||
render(<Menu {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Folder B'))
|
||||
|
||||
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledOnce()
|
||||
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,89 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useContext } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import DataSourceProvider, { DataSourceContext } from './provider'
|
||||
|
||||
const mockStore = { getState: vi.fn(), setState: vi.fn(), subscribe: vi.fn() }
|
||||
|
||||
vi.mock('./', () => ({
|
||||
createDataSourceStore: () => mockStore,
|
||||
}))
|
||||
|
||||
// Test consumer component that reads from context
|
||||
function ContextConsumer() {
|
||||
const store = useContext(DataSourceContext)
|
||||
return (
|
||||
<div data-testid="context-value" data-has-store={store !== null}>
|
||||
{store ? 'has-store' : 'no-store'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('DataSourceProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering: verifies children are passed through
|
||||
describe('Rendering', () => {
|
||||
it('should render children', () => {
|
||||
render(
|
||||
<DataSourceProvider>
|
||||
<span data-testid="child">Hello</span>
|
||||
</DataSourceProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('child')).toBeInTheDocument()
|
||||
expect(screen.getByText('Hello')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Context: verifies the store is provided to consumers
|
||||
describe('Context', () => {
|
||||
it('should provide store value to context consumers', () => {
|
||||
render(
|
||||
<DataSourceProvider>
|
||||
<ContextConsumer />
|
||||
</DataSourceProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('context-value')).toHaveTextContent('has-store')
|
||||
expect(screen.getByTestId('context-value')).toHaveAttribute('data-has-store', 'true')
|
||||
})
|
||||
|
||||
it('should provide null when no provider wraps the consumer', () => {
|
||||
render(<ContextConsumer />)
|
||||
|
||||
expect(screen.getByTestId('context-value')).toHaveTextContent('no-store')
|
||||
expect(screen.getByTestId('context-value')).toHaveAttribute('data-has-store', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
// Stability: verifies the store reference is stable across re-renders
|
||||
describe('Store Stability', () => {
|
||||
it('should reuse same store on re-render (stable reference)', () => {
|
||||
const storeValues: Array<typeof mockStore | null> = []
|
||||
|
||||
function StoreCapture() {
|
||||
const store = useContext(DataSourceContext)
|
||||
storeValues.push(store as typeof mockStore | null)
|
||||
return null
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
<DataSourceProvider>
|
||||
<StoreCapture />
|
||||
</DataSourceProvider>,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<DataSourceProvider>
|
||||
<StoreCapture />
|
||||
</DataSourceProvider>,
|
||||
)
|
||||
|
||||
expect(storeValues).toHaveLength(2)
|
||||
expect(storeValues[0]).toBe(storeValues[1])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,218 @@
|
||||
import type { CrawlResultItem } from '@/models/datasets'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
|
||||
import CrawledResult from './crawled-result'
|
||||
|
||||
vi.mock('./checkbox-with-label', () => ({
|
||||
default: ({ isChecked, onChange, label }: { isChecked: boolean, onChange: () => void, label: string }) => (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={onChange}
|
||||
data-testid="check-all-checkbox"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./crawled-result-item', () => ({
|
||||
default: ({
|
||||
payload,
|
||||
isChecked,
|
||||
onCheckChange,
|
||||
onPreview,
|
||||
}: {
|
||||
payload: CrawlResultItem
|
||||
isChecked: boolean
|
||||
onCheckChange: (checked: boolean) => void
|
||||
onPreview: () => void
|
||||
}) => (
|
||||
<div data-testid={`crawled-item-${payload.source_url}`}>
|
||||
<span data-testid="item-url">{payload.source_url}</span>
|
||||
<button data-testid={`check-${payload.source_url}`} onClick={() => onCheckChange(!isChecked)}>
|
||||
{isChecked ? 'uncheck' : 'check'}
|
||||
</button>
|
||||
<button data-testid={`preview-${payload.source_url}`} onClick={onPreview}>
|
||||
preview
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createItem = (url: string): CrawlResultItem => ({
|
||||
source_url: url,
|
||||
title: `Title for ${url}`,
|
||||
markdown: `# ${url}`,
|
||||
description: `Desc for ${url}`,
|
||||
})
|
||||
|
||||
const defaultList: CrawlResultItem[] = [
|
||||
createItem('https://example.com/a'),
|
||||
createItem('https://example.com/b'),
|
||||
createItem('https://example.com/c'),
|
||||
]
|
||||
|
||||
describe('CrawledResult', () => {
|
||||
const defaultProps = {
|
||||
list: defaultList,
|
||||
checkedList: [] as CrawlResultItem[],
|
||||
onSelectedChange: vi.fn(),
|
||||
usedTime: 12.345,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render scrap time info with correct total and time', () => {
|
||||
render(<CrawledResult {...defaultProps} />)
|
||||
|
||||
expect(
|
||||
screen.getByText(/scrapTimeInfo/),
|
||||
).toBeInTheDocument()
|
||||
// The global i18n mock serialises params, so verify total and time appear
|
||||
expect(screen.getByText(/"total":3/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/"time":"12.3"/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all items from list', () => {
|
||||
render(<CrawledResult {...defaultProps} />)
|
||||
|
||||
for (const item of defaultList) {
|
||||
expect(screen.getByTestId(`crawled-item-${item.source_url}`)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(
|
||||
<CrawledResult {...defaultProps} className="my-custom-class" />,
|
||||
)
|
||||
|
||||
expect(container.firstChild).toHaveClass('my-custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
// Check-all checkbox visibility
|
||||
describe('Check All Checkbox', () => {
|
||||
it('should show check-all checkbox in multiple choice mode', () => {
|
||||
render(<CrawledResult {...defaultProps} isMultipleChoice={true} />)
|
||||
|
||||
expect(screen.getByTestId('check-all-checkbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide check-all checkbox in single choice mode', () => {
|
||||
render(<CrawledResult {...defaultProps} isMultipleChoice={false} />)
|
||||
|
||||
expect(screen.queryByTestId('check-all-checkbox')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Toggle all items
|
||||
describe('Toggle All', () => {
|
||||
it('should select all when not all checked', () => {
|
||||
const onSelectedChange = vi.fn()
|
||||
render(
|
||||
<CrawledResult
|
||||
{...defaultProps}
|
||||
checkedList={[defaultList[0]]}
|
||||
onSelectedChange={onSelectedChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('check-all-checkbox'))
|
||||
|
||||
expect(onSelectedChange).toHaveBeenCalledWith(defaultList)
|
||||
})
|
||||
|
||||
it('should deselect all when all checked', () => {
|
||||
const onSelectedChange = vi.fn()
|
||||
render(
|
||||
<CrawledResult
|
||||
{...defaultProps}
|
||||
checkedList={[...defaultList]}
|
||||
onSelectedChange={onSelectedChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('check-all-checkbox'))
|
||||
|
||||
expect(onSelectedChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
|
||||
// Individual item check
|
||||
describe('Individual Item Check', () => {
|
||||
it('should add item to selection in multiple choice mode', () => {
|
||||
const onSelectedChange = vi.fn()
|
||||
render(
|
||||
<CrawledResult
|
||||
{...defaultProps}
|
||||
checkedList={[defaultList[0]]}
|
||||
onSelectedChange={onSelectedChange}
|
||||
isMultipleChoice={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Click check on unchecked second item
|
||||
fireEvent.click(screen.getByTestId(`check-${defaultList[1].source_url}`))
|
||||
|
||||
expect(onSelectedChange).toHaveBeenCalledWith([defaultList[0], defaultList[1]])
|
||||
})
|
||||
|
||||
it('should replace selection in single choice mode', () => {
|
||||
const onSelectedChange = vi.fn()
|
||||
render(
|
||||
<CrawledResult
|
||||
{...defaultProps}
|
||||
checkedList={[defaultList[0]]}
|
||||
onSelectedChange={onSelectedChange}
|
||||
isMultipleChoice={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Click check on unchecked second item
|
||||
fireEvent.click(screen.getByTestId(`check-${defaultList[1].source_url}`))
|
||||
|
||||
expect(onSelectedChange).toHaveBeenCalledWith([defaultList[1]])
|
||||
})
|
||||
|
||||
it('should remove item from selection when unchecked', () => {
|
||||
const onSelectedChange = vi.fn()
|
||||
render(
|
||||
<CrawledResult
|
||||
{...defaultProps}
|
||||
checkedList={[defaultList[0], defaultList[1]]}
|
||||
onSelectedChange={onSelectedChange}
|
||||
isMultipleChoice={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Click uncheck on checked first item
|
||||
fireEvent.click(screen.getByTestId(`check-${defaultList[0].source_url}`))
|
||||
|
||||
expect(onSelectedChange).toHaveBeenCalledWith([defaultList[1]])
|
||||
})
|
||||
})
|
||||
|
||||
// Preview
|
||||
describe('Preview', () => {
|
||||
it('should call onPreview with correct item and index', () => {
|
||||
const onPreview = vi.fn()
|
||||
render(
|
||||
<CrawledResult
|
||||
{...defaultProps}
|
||||
onPreview={onPreview}
|
||||
showPreview={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId(`preview-${defaultList[1].source_url}`))
|
||||
|
||||
expect(onPreview).toHaveBeenCalledWith(defaultList[1], 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,115 @@
|
||||
import type { Step } from './step-indicator'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import LeftHeader from './left-header'
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ datasetId: 'test-ds-id' }),
|
||||
}))
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode, href: string }) => (
|
||||
<a href={href} data-testid="back-link">{children}</a>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiArrowLeftLine: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="arrow-left-icon" {...props} />,
|
||||
}))
|
||||
|
||||
vi.mock('./step-indicator', () => ({
|
||||
default: ({ steps, currentStep }: { steps: Step[], currentStep: number }) => (
|
||||
<div data-testid="step-indicator" data-steps={steps.length} data-current={currentStep} />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/effect', () => ({
|
||||
default: ({ className }: { className?: string }) => (
|
||||
<div data-testid="effect" className={className} />
|
||||
),
|
||||
}))
|
||||
|
||||
const createSteps = (): Step[] => [
|
||||
{ label: 'Data Source', value: 'data-source' },
|
||||
{ label: 'Processing', value: 'processing' },
|
||||
{ label: 'Complete', value: 'complete' },
|
||||
]
|
||||
|
||||
describe('LeftHeader', () => {
|
||||
const steps = createSteps()
|
||||
|
||||
const defaultProps = {
|
||||
steps,
|
||||
title: 'Add Documents',
|
||||
currentStep: 1,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering: title, step label, and step indicator
|
||||
describe('Rendering', () => {
|
||||
it('should render title text', () => {
|
||||
render(<LeftHeader {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('Add Documents')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render current step label (steps[currentStep-1].label)', () => {
|
||||
render(<LeftHeader {...defaultProps} currentStep={2} />)
|
||||
|
||||
expect(screen.getByText('Processing')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render step indicator component', () => {
|
||||
render(<LeftHeader {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('step-indicator')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render separator between title and step indicator', () => {
|
||||
render(<LeftHeader {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('/')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Back button visibility depends on currentStep vs total steps
|
||||
describe('Back Button', () => {
|
||||
it('should show back button when currentStep !== steps.length', () => {
|
||||
render(<LeftHeader {...defaultProps} currentStep={1} />)
|
||||
|
||||
expect(screen.getByTestId('back-link')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('arrow-left-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide back button when currentStep === steps.length', () => {
|
||||
render(<LeftHeader {...defaultProps} currentStep={steps.length} />)
|
||||
|
||||
expect(screen.queryByTestId('back-link')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should link to correct URL using datasetId from params', () => {
|
||||
render(<LeftHeader {...defaultProps} currentStep={1} />)
|
||||
|
||||
const link = screen.getByTestId('back-link')
|
||||
expect(link).toHaveAttribute('href', '/datasets/test-ds-id/documents')
|
||||
})
|
||||
})
|
||||
|
||||
// Edge case: step label for boundary values
|
||||
describe('Edge Cases', () => {
|
||||
it('should render first step label when currentStep is 1', () => {
|
||||
render(<LeftHeader {...defaultProps} currentStep={1} />)
|
||||
|
||||
expect(screen.getByText('Data Source')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render last step label when currentStep equals steps.length', () => {
|
||||
render(<LeftHeader {...defaultProps} currentStep={3} />)
|
||||
|
||||
expect(screen.getByText('Complete')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,73 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Actions from './actions'
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiArrowLeftLine: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="arrow-left-icon" {...props} />,
|
||||
}))
|
||||
|
||||
describe('Actions', () => {
|
||||
const defaultProps = {
|
||||
onBack: vi.fn(),
|
||||
onProcess: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering: verify both action buttons render with correct labels
|
||||
describe('Rendering', () => {
|
||||
it('should render back button and process button', () => {
|
||||
render(<Actions {...defaultProps} />)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(2)
|
||||
expect(screen.getByText('datasetPipeline.operations.dataSource')).toBeInTheDocument()
|
||||
expect(screen.getByText('datasetPipeline.operations.saveAndProcess')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User interactions: clicking back and process buttons
|
||||
describe('User Interactions', () => {
|
||||
it('should call onBack when back button clicked', () => {
|
||||
render(<Actions {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByText('datasetPipeline.operations.dataSource'))
|
||||
|
||||
expect(defaultProps.onBack).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('should call onProcess when process button clicked', () => {
|
||||
render(<Actions {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByText('datasetPipeline.operations.saveAndProcess'))
|
||||
|
||||
expect(defaultProps.onProcess).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
// Props: disabled state for the process button
|
||||
describe('Props', () => {
|
||||
it('should disable process button when runDisabled is true', () => {
|
||||
render(<Actions {...defaultProps} runDisabled />)
|
||||
|
||||
const processButton = screen.getByText('datasetPipeline.operations.saveAndProcess').closest('button')
|
||||
expect(processButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable process button when runDisabled is false', () => {
|
||||
render(<Actions {...defaultProps} runDisabled={false} />)
|
||||
|
||||
const processButton = screen.getByText('datasetPipeline.operations.saveAndProcess').closest('button')
|
||||
expect(processButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable process button when runDisabled is undefined', () => {
|
||||
render(<Actions {...defaultProps} />)
|
||||
|
||||
const processButton = screen.getByText('datasetPipeline.operations.saveAndProcess').closest('button')
|
||||
expect(processButton).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,135 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
import Drawer from './drawer'
|
||||
|
||||
// Capture the useKeyPress callback so tests can invoke it
|
||||
let capturedKeyPressCallback: ((e: KeyboardEvent) => void) | undefined
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useKeyPress: vi.fn((_key: string, cb: (e: KeyboardEvent) => void) => {
|
||||
capturedKeyPressCallback = cb
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('..', () => ({
|
||||
useSegmentListContext: (selector: (state: {
|
||||
currSegment: { showModal: boolean }
|
||||
currChildChunk: { showModal: boolean }
|
||||
}) => unknown) =>
|
||||
selector({
|
||||
currSegment: { showModal: false },
|
||||
currChildChunk: { showModal: false },
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('Drawer', () => {
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
capturedKeyPressCallback = undefined
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should return null when open is false', () => {
|
||||
const { container } = render(
|
||||
<Drawer open={false} onClose={vi.fn()}>
|
||||
<span>Content</span>
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
expect(screen.queryByText('Content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render children in portal when open is true', () => {
|
||||
render(
|
||||
<Drawer {...defaultProps}>
|
||||
<span>Drawer content</span>
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Drawer content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dialog with role="dialog"', () => {
|
||||
render(
|
||||
<Drawer {...defaultProps}>
|
||||
<span>Content</span>
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Overlay visibility
|
||||
describe('Overlay', () => {
|
||||
it('should show overlay when showOverlay is true', () => {
|
||||
render(
|
||||
<Drawer {...defaultProps} showOverlay={true}>
|
||||
<span>Content</span>
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
const overlay = document.querySelector('[aria-hidden="true"]')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide overlay when showOverlay is false', () => {
|
||||
render(
|
||||
<Drawer {...defaultProps} showOverlay={false}>
|
||||
<span>Content</span>
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
const overlay = document.querySelector('[aria-hidden="true"]')
|
||||
expect(overlay).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// aria-modal attribute
|
||||
describe('aria-modal', () => {
|
||||
it('should set aria-modal="true" when modal is true', () => {
|
||||
render(
|
||||
<Drawer {...defaultProps} modal={true}>
|
||||
<span>Content</span>
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true')
|
||||
})
|
||||
|
||||
it('should set aria-modal="false" when modal is false', () => {
|
||||
render(
|
||||
<Drawer {...defaultProps} modal={false}>
|
||||
<span>Content</span>
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
// ESC key handling
|
||||
describe('ESC Key', () => {
|
||||
it('should call onClose when ESC is pressed and drawer is open', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<Drawer open={true} onClose={onClose}>
|
||||
<span>Content</span>
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
expect(capturedKeyPressCallback).toBeDefined()
|
||||
const fakeEvent = { preventDefault: vi.fn() } as unknown as KeyboardEvent
|
||||
capturedKeyPressCallback!(fakeEvent)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,70 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
|
||||
import { useInputVariables } from './hooks'
|
||||
|
||||
let mockPipelineId: string | undefined
|
||||
|
||||
vi.mock('@/context/dataset-detail', () => ({
|
||||
useDatasetDetailContextWithSelector: (selector: (state: { dataset: { pipeline_id?: string } | null }) => unknown) =>
|
||||
selector({ dataset: mockPipelineId ? { pipeline_id: mockPipelineId } : null }),
|
||||
}))
|
||||
|
||||
let mockParamsReturn: {
|
||||
data: Record<string, unknown> | undefined
|
||||
isFetching: boolean
|
||||
}
|
||||
|
||||
const mockUsePublishedPipelineProcessingParams = vi.fn(
|
||||
(_params: { pipeline_id: string, node_id: string }) => mockParamsReturn,
|
||||
)
|
||||
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
usePublishedPipelineProcessingParams: (params: { pipeline_id: string, node_id: string }) =>
|
||||
mockUsePublishedPipelineProcessingParams(params),
|
||||
}))
|
||||
|
||||
describe('useInputVariables', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPipelineId = 'pipeline-123'
|
||||
mockParamsReturn = {
|
||||
data: undefined,
|
||||
isFetching: false,
|
||||
}
|
||||
})
|
||||
|
||||
// Returns paramsConfig from API
|
||||
describe('Data Retrieval', () => {
|
||||
it('should return paramsConfig from API', () => {
|
||||
const mockConfig = { variables: [{ name: 'var1', type: 'string' }] }
|
||||
mockParamsReturn = { data: mockConfig, isFetching: false }
|
||||
|
||||
const { result } = renderHook(() => useInputVariables('node-456'))
|
||||
|
||||
expect(result.current.paramsConfig).toEqual(mockConfig)
|
||||
})
|
||||
|
||||
it('should return isFetchingParams loading state', () => {
|
||||
mockParamsReturn = { data: undefined, isFetching: true }
|
||||
|
||||
const { result } = renderHook(() => useInputVariables('node-456'))
|
||||
|
||||
expect(result.current.isFetchingParams).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Passes correct parameters to API hook
|
||||
describe('Parameter Passing', () => {
|
||||
it('should pass correct pipeline_id and node_id to API hook', () => {
|
||||
mockPipelineId = 'pipeline-789'
|
||||
mockParamsReturn = { data: undefined, isFetching: false }
|
||||
|
||||
renderHook(() => useInputVariables('node-abc'))
|
||||
|
||||
expect(mockUsePublishedPipelineProcessingParams).toHaveBeenCalledWith({
|
||||
pipeline_id: 'pipeline-789',
|
||||
node_id: 'node-abc',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user