Compare commits

...

11 Commits

Author SHA1 Message Date
CodingOnStar
a9f56716fc test: update unit tests to use Vitest framework
- Refactored test files for data source options, drawer, and pipeline settings to utilize Vitest for improved testing capabilities.
- Ensured consistent testing practices across components by importing necessary Vitest functions.
2026-02-10 21:01:53 +08:00
CodingOnStar
94eefaaee2 test: enhance unit tests for StepTwo and segment list components
- Added new tests for the StepTwo component, covering user interactions with the QA checkbox and file picker functionality.
- Improved test coverage for the segment list content, ensuring proper handling of click events on segment cards.
- Introduced tests for the useChildSegmentData hook, validating cache updates and scroll behavior based on child segment changes.

These enhancements improve the reliability and maintainability of dataset creation and document management features.
2026-02-10 20:58:07 +08:00
CodingOnStar
59f3acb021 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.
2026-02-10 20:12:50 +08:00
CodingOnStar
4655a6a244 test: refactor and enhance unit tests for dataset creation components
- Moved unit tests for child components (usePreviewState, DataSourceTypeSelector, NextStepButton, PreviewPanel) to dedicated spec files for better organization.
- Added new tests for the StepTwo component, covering rendering, user interactions, and state management.
- Improved test coverage for CrawledResultItem, ensuring proper handling of checkbox interactions.
- Updated tests for MenuBar and other components to validate user interactions and rendering.

These changes enhance the maintainability and reliability of the dataset creation and processing features.
2026-02-10 18:41:23 +08:00
CodingOnStar
5006a5e804 test: add unit tests for website crawl and document preview components
- Introduced new test files for CheckboxWithLabel, CrawledResultItem, ErrorMessage, and various components related to website crawling and document preview.
- Enhanced test coverage by validating rendering, user interactions, and edge cases for each component.
- Ensured proper functionality of user interactions such as checkbox selections, button clicks, and rendering of dynamic content.

These additions improve the reliability and maintainability of the website crawl and document preview features.
2026-02-10 17:31:40 +08:00
CodingOnStar
5e6e8a16ce test: add unit tests for embedding process and website components
- Introduced new test files for DocumentList, IndexingProgressItem, RuleDetail, UpgradeBanner, and various utility functions related to the embedding process.
- Enhanced test coverage by validating rendering, user interactions, and edge cases for each component.
- Ensured proper functionality of user interactions, such as button clicks and state changes, as well as validation of parameters in hooks and utilities.

These additions improve the reliability and maintainability of the embedding process and website features.
2026-02-10 16:54:35 +08:00
CodingOnStar
0301d6b690 test: add unit tests for dataset creation and processing components
- Introduced new test files for DataSourceTypeSelector, NextStepButton, PreviewPanel, and various hooks related to document creation and indexing.
- Enhanced test coverage by validating rendering, user interactions, and edge cases for each component.
- Ensured proper functionality of user interactions such as button clicks and state changes, as well as validation of parameters in hooks.

These additions improve the reliability and maintainability of the dataset creation and processing features.
2026-02-10 15:53:18 +08:00
CodingOnStar
755c9b0c15 delete some useless comment 2026-02-10 15:02:56 +08:00
CodingOnStar
f1cce53bc2 test: add integration tests for dataset flows
- Introduced new test files for Create Dataset Flow, Document Management Flow, External Knowledge Base Creation Flow, Hit Testing Flow, and Segment CRUD Flow.
- Validated cross-module interactions, data contracts, and API calls for dataset creation, document management, and hit testing functionalities.
- Enhanced test coverage by ensuring proper handling of user interactions, query submissions, and state management across various components.

These additions improve the reliability and maintainability of the dataset-related features.
2026-02-10 14:58:31 +08:00
CodingOnStar
a29e74422e test: add unit tests for dataset creation components
- Introduced new test files for GeneralChunkingOptions, IndexingModeSection, Inputs, OptionCard, ParentChildOptions, and SummaryIndexSetting components.
- Enhanced test coverage by validating rendering, user interactions, and edge cases for each component.
- Ensured proper functionality of user interactions such as button clicks and state changes.

These additions improve the reliability and maintainability of the dataset creation feature.
2026-02-10 14:33:57 +08:00
CodingOnStar
83ef687d00 test: enhance unit tests for various components including chat, datasets, and documents
- Updated tests for  to ensure proper async behavior.
- Added comprehensive tests for , , and  components, covering rendering, user interactions, and edge cases.
- Introduced new tests for , , and  components, validating rendering and user interactions.
- Implemented tests for status filtering and document list query state to ensure correct functionality.

These changes improve test coverage and reliability across multiple components.
2026-02-10 13:59:54 +08:00
154 changed files with 20703 additions and 4588 deletions

View File

@@ -0,0 +1,300 @@
/**
* Integration Test: Create Dataset Flow
*
* Tests cross-module data flow: step-one data → step-two hooks → creation params → API call
* Validates data contracts between steps.
*/
import type { CustomFile } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { act, renderHook } from '@testing-library/react'
import { ChunkingMode, DataSourceType, ProcessMode } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
const mockCreateFirstDocument = vi.fn()
const mockCreateDocument = vi.fn()
vi.mock('@/service/knowledge/use-create-dataset', () => ({
useCreateFirstDocument: () => ({ mutateAsync: mockCreateFirstDocument, isPending: false }),
useCreateDocument: () => ({ mutateAsync: mockCreateDocument, isPending: false }),
getNotionInfo: (pages: { page_id: string }[], credentialId: string) => ({
workspace_id: 'ws-1',
pages: pages.map(p => p.page_id),
notion_credential_id: credentialId,
}),
getWebsiteInfo: (opts: { websitePages: { url: string }[], websiteCrawlProvider: string }) => ({
urls: opts.websitePages.map(p => p.url),
only_main_content: true,
provider: opts.websiteCrawlProvider,
}),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useInvalidDatasetList: () => vi.fn(),
}))
vi.mock('@/app/components/base/toast', () => ({
default: { notify: vi.fn() },
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
// Import hooks after mocks
const { useSegmentationState, DEFAULT_SEGMENT_IDENTIFIER, DEFAULT_MAXIMUM_CHUNK_LENGTH, DEFAULT_OVERLAP }
= await import('@/app/components/datasets/create/step-two/hooks')
const { useDocumentCreation, IndexingType }
= await import('@/app/components/datasets/create/step-two/hooks')
const createMockFile = (overrides?: Partial<CustomFile>): CustomFile => ({
id: 'file-1',
name: 'test.txt',
type: 'text/plain',
size: 1024,
extension: '.txt',
mime_type: 'text/plain',
created_at: 0,
created_by: '',
...overrides,
} as CustomFile)
describe('Create Dataset Flow - Cross-Step Data Contract', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Step-One → Step-Two: Segmentation Defaults', () => {
it('should initialise with correct default segmentation values', () => {
const { result } = renderHook(() => useSegmentationState())
expect(result.current.segmentIdentifier).toBe(DEFAULT_SEGMENT_IDENTIFIER)
expect(result.current.maxChunkLength).toBe(DEFAULT_MAXIMUM_CHUNK_LENGTH)
expect(result.current.overlap).toBe(DEFAULT_OVERLAP)
expect(result.current.segmentationType).toBe(ProcessMode.general) // 'custom'
})
it('should produce valid process rule for general chunking', () => {
const { result } = renderHook(() => useSegmentationState())
const processRule = result.current.getProcessRule(ChunkingMode.text)
// mode should be segmentationType = ProcessMode.general = 'custom'
expect(processRule.mode).toBe('custom')
expect(processRule.rules.segmentation).toEqual({
separator: '\n\n', // unescaped from \\n\\n
max_tokens: DEFAULT_MAXIMUM_CHUNK_LENGTH,
chunk_overlap: DEFAULT_OVERLAP,
})
// rules is empty initially since no default config loaded
expect(processRule.rules.pre_processing_rules).toEqual([])
})
it('should produce valid process rule for parent-child chunking', () => {
const { result } = renderHook(() => useSegmentationState())
const processRule = result.current.getProcessRule(ChunkingMode.parentChild)
expect(processRule.mode).toBe('hierarchical')
expect(processRule.rules.parent_mode).toBe('paragraph')
expect(processRule.rules.segmentation).toEqual({
separator: '\n\n',
max_tokens: 1024,
})
expect(processRule.rules.subchunk_segmentation).toEqual({
separator: '\n',
max_tokens: 512,
})
})
})
describe('Step-Two → Creation API: Params Building', () => {
it('should build valid creation params for file upload workflow', () => {
const files = [createMockFile()]
const { result: segResult } = renderHook(() => useSegmentationState())
const { result: creationResult } = renderHook(() =>
useDocumentCreation({
dataSourceType: DataSourceType.FILE,
files,
notionPages: [],
notionCredentialId: '',
websitePages: [],
}),
)
const processRule = segResult.current.getProcessRule(ChunkingMode.text)
const retrievalConfig: RetrievalConfig = {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0,
}
const params = creationResult.current.buildCreationParams(
ChunkingMode.text,
'English',
processRule,
retrievalConfig,
{ provider: 'openai', model: 'text-embedding-ada-002' },
IndexingType.QUALIFIED,
)
expect(params).not.toBeNull()
// File IDs come from file.id (not file.file.id)
expect(params!.data_source.type).toBe(DataSourceType.FILE)
expect(params!.data_source.info_list.file_info_list?.file_ids).toContain('file-1')
expect(params!.indexing_technique).toBe(IndexingType.QUALIFIED)
expect(params!.doc_form).toBe(ChunkingMode.text)
expect(params!.doc_language).toBe('English')
expect(params!.embedding_model).toBe('text-embedding-ada-002')
expect(params!.embedding_model_provider).toBe('openai')
expect(params!.process_rule.mode).toBe('custom')
})
it('should validate params: overlap must not exceed maxChunkLength', () => {
const { result } = renderHook(() =>
useDocumentCreation({
dataSourceType: DataSourceType.FILE,
files: [createMockFile()],
notionPages: [],
notionCredentialId: '',
websitePages: [],
}),
)
// validateParams returns false (invalid) when overlap > maxChunkLength for general mode
const isValid = result.current.validateParams({
segmentationType: 'general',
maxChunkLength: 100,
limitMaxChunkLength: 4000,
overlap: 200, // overlap > maxChunkLength
indexType: IndexingType.QUALIFIED,
embeddingModel: { provider: 'openai', model: 'text-embedding-ada-002' },
rerankModelList: [],
retrievalConfig: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0,
},
})
expect(isValid).toBe(false)
})
it('should validate params: maxChunkLength must not exceed limit', () => {
const { result } = renderHook(() =>
useDocumentCreation({
dataSourceType: DataSourceType.FILE,
files: [createMockFile()],
notionPages: [],
notionCredentialId: '',
websitePages: [],
}),
)
const isValid = result.current.validateParams({
segmentationType: 'general',
maxChunkLength: 5000,
limitMaxChunkLength: 4000, // limit < maxChunkLength
overlap: 50,
indexType: IndexingType.QUALIFIED,
embeddingModel: { provider: 'openai', model: 'text-embedding-ada-002' },
rerankModelList: [],
retrievalConfig: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0,
},
})
expect(isValid).toBe(false)
})
})
describe('Full Flow: Segmentation State → Process Rule → Creation Params Consistency', () => {
it('should keep segmentation values consistent across getProcessRule and buildCreationParams', () => {
const files = [createMockFile()]
const { result: segResult } = renderHook(() => useSegmentationState())
const { result: creationResult } = renderHook(() =>
useDocumentCreation({
dataSourceType: DataSourceType.FILE,
files,
notionPages: [],
notionCredentialId: '',
websitePages: [],
}),
)
// Change segmentation settings
act(() => {
segResult.current.setMaxChunkLength(2048)
segResult.current.setOverlap(100)
})
const processRule = segResult.current.getProcessRule(ChunkingMode.text)
expect(processRule.rules.segmentation.max_tokens).toBe(2048)
expect(processRule.rules.segmentation.chunk_overlap).toBe(100)
const params = creationResult.current.buildCreationParams(
ChunkingMode.text,
'Chinese',
processRule,
{
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,
},
{ provider: 'openai', model: 'text-embedding-ada-002' },
IndexingType.QUALIFIED,
)
expect(params).not.toBeNull()
expect(params!.process_rule.rules.segmentation.max_tokens).toBe(2048)
expect(params!.process_rule.rules.segmentation.chunk_overlap).toBe(100)
expect(params!.doc_language).toBe('Chinese')
})
it('should support parent-child mode through the full pipeline', () => {
const files = [createMockFile()]
const { result: segResult } = renderHook(() => useSegmentationState())
const { result: creationResult } = renderHook(() =>
useDocumentCreation({
dataSourceType: DataSourceType.FILE,
files,
notionPages: [],
notionCredentialId: '',
websitePages: [],
}),
)
const processRule = segResult.current.getProcessRule(ChunkingMode.parentChild)
const params = creationResult.current.buildCreationParams(
ChunkingMode.parentChild,
'English',
processRule,
{
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,
},
{ provider: 'openai', model: 'text-embedding-ada-002' },
IndexingType.QUALIFIED,
)
expect(params).not.toBeNull()
expect(params!.doc_form).toBe(ChunkingMode.parentChild)
expect(params!.process_rule.mode).toBe('hierarchical')
expect(params!.process_rule.rules.parent_mode).toBe('paragraph')
expect(params!.process_rule.rules.subchunk_segmentation).toBeDefined()
})
})
})

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

View File

@@ -0,0 +1,334 @@
/**
* Integration Test: Document Management Flow
*
* Tests cross-module interactions: query state (URL-based) → document list sorting →
* document selection → status filter utilities.
* Validates the data contract between documents page hooks and list component hooks.
*/
import type { SimpleDocumentDetail } from '@/models/datasets'
import { act, renderHook } from '@testing-library/react'
import { DataSourceType } from '@/models/datasets'
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useSearchParams: () => new URLSearchParams(''),
useRouter: () => ({ push: mockPush }),
usePathname: () => '/datasets/ds-1/documents',
}))
const { sanitizeStatusValue, normalizeStatusForQuery } = await import(
'@/app/components/datasets/documents/status-filter',
)
const { useDocumentSort } = await import(
'@/app/components/datasets/documents/components/document-list/hooks/use-document-sort',
)
const { useDocumentSelection } = await import(
'@/app/components/datasets/documents/components/document-list/hooks/use-document-selection',
)
const { default: useDocumentListQueryState } = await import(
'@/app/components/datasets/documents/hooks/use-document-list-query-state',
)
type LocalDoc = SimpleDocumentDetail & { percent?: number }
const createDoc = (overrides?: Partial<LocalDoc>): LocalDoc => ({
id: `doc-${Math.random().toString(36).slice(2, 8)}`,
name: 'test-doc.txt',
word_count: 500,
hit_count: 10,
created_at: Date.now() / 1000,
data_source_type: DataSourceType.FILE,
display_status: 'available',
indexing_status: 'completed',
enabled: true,
archived: false,
doc_type: null,
doc_metadata: null,
position: 1,
dataset_process_rule_id: 'rule-1',
...overrides,
} as LocalDoc)
describe('Document Management Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Status Filter Utilities', () => {
it('should sanitize valid status values', () => {
expect(sanitizeStatusValue('all')).toBe('all')
expect(sanitizeStatusValue('available')).toBe('available')
expect(sanitizeStatusValue('error')).toBe('error')
})
it('should fallback to "all" for invalid values', () => {
expect(sanitizeStatusValue(null)).toBe('all')
expect(sanitizeStatusValue(undefined)).toBe('all')
expect(sanitizeStatusValue('')).toBe('all')
expect(sanitizeStatusValue('nonexistent')).toBe('all')
})
it('should handle URL aliases', () => {
// 'active' is aliased to 'available'
expect(sanitizeStatusValue('active')).toBe('available')
})
it('should normalize status for API query', () => {
expect(normalizeStatusForQuery('all')).toBe('all')
// 'enabled' normalized to 'available' for query
expect(normalizeStatusForQuery('enabled')).toBe('available')
})
})
describe('URL-based Query State', () => {
it('should parse default query from empty URL params', () => {
const { result } = renderHook(() => useDocumentListQueryState())
expect(result.current.query).toEqual({
page: 1,
limit: 10,
keyword: '',
status: 'all',
sort: '-created_at',
})
})
it('should update query and push to router', () => {
const { result } = renderHook(() => useDocumentListQueryState())
act(() => {
result.current.updateQuery({ keyword: 'test', page: 2 })
})
expect(mockPush).toHaveBeenCalled()
// The push call should contain the updated query params
const pushUrl = mockPush.mock.calls[0][0] as string
expect(pushUrl).toContain('keyword=test')
expect(pushUrl).toContain('page=2')
})
it('should reset query to defaults', () => {
const { result } = renderHook(() => useDocumentListQueryState())
act(() => {
result.current.resetQuery()
})
expect(mockPush).toHaveBeenCalled()
// Default query omits default values from URL
const pushUrl = mockPush.mock.calls[0][0] as string
expect(pushUrl).toBe('/datasets/ds-1/documents')
})
})
describe('Document Sort Integration', () => {
it('should return documents unsorted when no sort field set', () => {
const docs = [
createDoc({ id: 'doc-1', name: 'Banana.txt', word_count: 300 }),
createDoc({ id: 'doc-2', name: 'Apple.txt', word_count: 100 }),
createDoc({ id: 'doc-3', name: 'Cherry.txt', word_count: 200 }),
]
const { result } = renderHook(() => useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '-created_at',
}))
expect(result.current.sortField).toBeNull()
expect(result.current.sortedDocuments).toHaveLength(3)
})
it('should sort by name descending', () => {
const docs = [
createDoc({ id: 'doc-1', name: 'Banana.txt' }),
createDoc({ id: 'doc-2', name: 'Apple.txt' }),
createDoc({ id: 'doc-3', name: 'Cherry.txt' }),
]
const { result } = renderHook(() => useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '-created_at',
}))
act(() => {
result.current.handleSort('name')
})
expect(result.current.sortField).toBe('name')
expect(result.current.sortOrder).toBe('desc')
const names = result.current.sortedDocuments.map(d => d.name)
expect(names).toEqual(['Cherry.txt', 'Banana.txt', 'Apple.txt'])
})
it('should toggle sort order on same field click', () => {
const docs = [createDoc({ id: 'doc-1', name: 'A.txt' }), createDoc({ id: 'doc-2', name: 'B.txt' })]
const { result } = renderHook(() => useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '-created_at',
}))
act(() => result.current.handleSort('name'))
expect(result.current.sortOrder).toBe('desc')
act(() => result.current.handleSort('name'))
expect(result.current.sortOrder).toBe('asc')
})
it('should filter by status before sorting', () => {
const docs = [
createDoc({ id: 'doc-1', name: 'A.txt', display_status: 'available' }),
createDoc({ id: 'doc-2', name: 'B.txt', display_status: 'error' }),
createDoc({ id: 'doc-3', name: 'C.txt', display_status: 'available' }),
]
const { result } = renderHook(() => useDocumentSort({
documents: docs,
statusFilterValue: 'available',
remoteSortValue: '-created_at',
}))
// Only 'available' documents should remain
expect(result.current.sortedDocuments).toHaveLength(2)
expect(result.current.sortedDocuments.every(d => d.display_status === 'available')).toBe(true)
})
})
describe('Document Selection Integration', () => {
it('should manage selection state externally', () => {
const docs = [
createDoc({ id: 'doc-1' }),
createDoc({ id: 'doc-2' }),
createDoc({ id: 'doc-3' }),
]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() => useDocumentSelection({
documents: docs,
selectedIds: [],
onSelectedIdChange,
}))
expect(result.current.isAllSelected).toBe(false)
expect(result.current.isSomeSelected).toBe(false)
})
it('should select all documents', () => {
const docs = [
createDoc({ id: 'doc-1' }),
createDoc({ id: 'doc-2' }),
]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() => useDocumentSelection({
documents: docs,
selectedIds: [],
onSelectedIdChange,
}))
act(() => {
result.current.onSelectAll()
})
expect(onSelectedIdChange).toHaveBeenCalledWith(
expect.arrayContaining(['doc-1', 'doc-2']),
)
})
it('should detect all-selected state', () => {
const docs = [
createDoc({ id: 'doc-1' }),
createDoc({ id: 'doc-2' }),
]
const { result } = renderHook(() => useDocumentSelection({
documents: docs,
selectedIds: ['doc-1', 'doc-2'],
onSelectedIdChange: vi.fn(),
}))
expect(result.current.isAllSelected).toBe(true)
})
it('should detect partial selection', () => {
const docs = [
createDoc({ id: 'doc-1' }),
createDoc({ id: 'doc-2' }),
createDoc({ id: 'doc-3' }),
]
const { result } = renderHook(() => useDocumentSelection({
documents: docs,
selectedIds: ['doc-1'],
onSelectedIdChange: vi.fn(),
}))
expect(result.current.isSomeSelected).toBe(true)
expect(result.current.isAllSelected).toBe(false)
})
it('should identify downloadable selected documents (FILE type only)', () => {
const docs = [
createDoc({ id: 'doc-1', data_source_type: DataSourceType.FILE }),
createDoc({ id: 'doc-2', data_source_type: DataSourceType.NOTION }),
]
const { result } = renderHook(() => useDocumentSelection({
documents: docs,
selectedIds: ['doc-1', 'doc-2'],
onSelectedIdChange: vi.fn(),
}))
expect(result.current.downloadableSelectedIds).toEqual(['doc-1'])
})
it('should clear selection', () => {
const onSelectedIdChange = vi.fn()
const docs = [createDoc({ id: 'doc-1' })]
const { result } = renderHook(() => useDocumentSelection({
documents: docs,
selectedIds: ['doc-1'],
onSelectedIdChange,
}))
act(() => {
result.current.clearSelection()
})
expect(onSelectedIdChange).toHaveBeenCalledWith([])
})
})
describe('Cross-Module: Query State → Sort → Selection Pipeline', () => {
it('should maintain consistent default state across all hooks', () => {
const docs = [createDoc({ id: 'doc-1' })]
const { result: queryResult } = renderHook(() => useDocumentListQueryState())
const { result: sortResult } = renderHook(() => useDocumentSort({
documents: docs,
statusFilterValue: queryResult.current.query.status,
remoteSortValue: queryResult.current.query.sort,
}))
const { result: selResult } = renderHook(() => useDocumentSelection({
documents: sortResult.current.sortedDocuments,
selectedIds: [],
onSelectedIdChange: vi.fn(),
}))
// Query defaults
expect(queryResult.current.query.sort).toBe('-created_at')
expect(queryResult.current.query.status).toBe('all')
// Sort inherits 'all' status → no filtering applied
expect(sortResult.current.sortedDocuments).toHaveLength(1)
// Selection starts empty
expect(selResult.current.isAllSelected).toBe(false)
})
})
})

View File

@@ -0,0 +1,214 @@
/**
* Integration Test: External Knowledge Base Creation Flow
*
* Tests the data contract, validation logic, and API interaction
* for external knowledge base creation.
*/
import type { CreateKnowledgeBaseReq } from '@/app/components/datasets/external-knowledge-base/create/declarations'
// --- Factory ---
const createFormData = (overrides?: Partial<CreateKnowledgeBaseReq>): CreateKnowledgeBaseReq => ({
name: 'My External KB',
description: 'A test external knowledge base',
external_knowledge_api_id: 'api-1',
external_knowledge_id: 'ext-kb-123',
external_retrieval_model: {
top_k: 4,
score_threshold: 0.5,
score_threshold_enabled: false,
},
provider: 'external',
...overrides,
})
describe('External Knowledge Base Creation Flow', () => {
describe('Data Contract: CreateKnowledgeBaseReq', () => {
it('should define a complete form structure', () => {
const form = createFormData()
expect(form).toHaveProperty('name')
expect(form).toHaveProperty('external_knowledge_api_id')
expect(form).toHaveProperty('external_knowledge_id')
expect(form).toHaveProperty('external_retrieval_model')
expect(form).toHaveProperty('provider')
expect(form.provider).toBe('external')
})
it('should include retrieval model settings', () => {
const form = createFormData()
expect(form.external_retrieval_model).toEqual({
top_k: 4,
score_threshold: 0.5,
score_threshold_enabled: false,
})
})
it('should allow partial overrides', () => {
const form = createFormData({
name: 'Custom Name',
external_retrieval_model: {
top_k: 10,
score_threshold: 0.8,
score_threshold_enabled: true,
},
})
expect(form.name).toBe('Custom Name')
expect(form.external_retrieval_model.top_k).toBe(10)
expect(form.external_retrieval_model.score_threshold_enabled).toBe(true)
})
})
describe('Form Validation Logic', () => {
const isFormValid = (form: CreateKnowledgeBaseReq): boolean => {
return (
form.name.trim() !== ''
&& form.external_knowledge_api_id !== ''
&& form.external_knowledge_id !== ''
&& form.external_retrieval_model.top_k !== undefined
&& form.external_retrieval_model.score_threshold !== undefined
)
}
it('should validate a complete form', () => {
const form = createFormData()
expect(isFormValid(form)).toBe(true)
})
it('should reject empty name', () => {
const form = createFormData({ name: '' })
expect(isFormValid(form)).toBe(false)
})
it('should reject whitespace-only name', () => {
const form = createFormData({ name: ' ' })
expect(isFormValid(form)).toBe(false)
})
it('should reject empty external_knowledge_api_id', () => {
const form = createFormData({ external_knowledge_api_id: '' })
expect(isFormValid(form)).toBe(false)
})
it('should reject empty external_knowledge_id', () => {
const form = createFormData({ external_knowledge_id: '' })
expect(isFormValid(form)).toBe(false)
})
})
describe('Form State Transitions', () => {
it('should start with empty default state', () => {
const defaultForm: CreateKnowledgeBaseReq = {
name: '',
description: '',
external_knowledge_api_id: '',
external_knowledge_id: '',
external_retrieval_model: {
top_k: 4,
score_threshold: 0.5,
score_threshold_enabled: false,
},
provider: 'external',
}
// Verify default state matches component's initial useState
expect(defaultForm.name).toBe('')
expect(defaultForm.external_knowledge_api_id).toBe('')
expect(defaultForm.external_knowledge_id).toBe('')
expect(defaultForm.provider).toBe('external')
})
it('should support immutable form updates', () => {
const form = createFormData({ name: '' })
const updated = { ...form, name: 'Updated Name' }
expect(form.name).toBe('')
expect(updated.name).toBe('Updated Name')
// Other fields should remain unchanged
expect(updated.external_knowledge_api_id).toBe(form.external_knowledge_api_id)
})
it('should support retrieval model updates', () => {
const form = createFormData()
const updated = {
...form,
external_retrieval_model: {
...form.external_retrieval_model,
top_k: 10,
score_threshold_enabled: true,
},
}
expect(updated.external_retrieval_model.top_k).toBe(10)
expect(updated.external_retrieval_model.score_threshold_enabled).toBe(true)
// Unchanged field
expect(updated.external_retrieval_model.score_threshold).toBe(0.5)
})
})
describe('API Call Data Contract', () => {
it('should produce a valid API payload from form data', () => {
const form = createFormData()
// The API expects the full CreateKnowledgeBaseReq
expect(form.name).toBeTruthy()
expect(form.external_knowledge_api_id).toBeTruthy()
expect(form.external_knowledge_id).toBeTruthy()
expect(form.provider).toBe('external')
expect(typeof form.external_retrieval_model.top_k).toBe('number')
expect(typeof form.external_retrieval_model.score_threshold).toBe('number')
expect(typeof form.external_retrieval_model.score_threshold_enabled).toBe('boolean')
})
it('should support optional description', () => {
const formWithDesc = createFormData({ description: 'Some description' })
const formWithoutDesc = createFormData({ description: '' })
expect(formWithDesc.description).toBe('Some description')
expect(formWithoutDesc.description).toBe('')
})
it('should validate retrieval model bounds', () => {
const form = createFormData({
external_retrieval_model: {
top_k: 0,
score_threshold: 0,
score_threshold_enabled: false,
},
})
expect(form.external_retrieval_model.top_k).toBe(0)
expect(form.external_retrieval_model.score_threshold).toBe(0)
})
})
describe('External API List Integration', () => {
it('should validate API item structure', () => {
const apiItem = {
id: 'api-1',
name: 'Production API',
settings: {
endpoint: 'https://api.example.com',
api_key: 'key-123',
},
}
expect(apiItem).toHaveProperty('id')
expect(apiItem).toHaveProperty('name')
expect(apiItem).toHaveProperty('settings')
expect(apiItem.settings).toHaveProperty('endpoint')
expect(apiItem.settings).toHaveProperty('api_key')
})
it('should link API selection to form data', () => {
const selectedApi = { id: 'api-2', name: 'Staging API' }
const form = createFormData({
external_knowledge_api_id: selectedApi.id,
})
expect(form.external_knowledge_api_id).toBe('api-2')
})
})
})

View File

@@ -0,0 +1,404 @@
/**
* Integration Test: Hit Testing Flow
*
* Tests the query submission → API response → callback chain flow
* by rendering the actual QueryInput component and triggering user interactions.
* Validates that the production onSubmit logic correctly constructs payloads
* and invokes callbacks on success/failure.
*/
import type {
HitTestingResponse,
Query,
} from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import QueryInput from '@/app/components/datasets/hit-testing/components/query-input'
import { RETRIEVE_METHOD } from '@/types/app'
// --- Mocks ---
vi.mock('@/context/dataset-detail', () => ({
default: {},
useDatasetDetailContext: vi.fn(() => ({ dataset: undefined })),
useDatasetDetailContextWithSelector: vi.fn(() => false),
}))
vi.mock('use-context-selector', () => ({
useContext: vi.fn(() => ({})),
useContextSelector: vi.fn(() => false),
createContext: vi.fn(() => ({})),
}))
vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', () => ({
default: ({ textArea, actionButton }: { textArea: React.ReactNode, actionButton: React.ReactNode }) => (
<div data-testid="image-uploader-mock">
{textArea}
{actionButton}
</div>
),
}))
// --- Factories ---
const createRetrievalConfig = (overrides = {}): RetrievalConfig => ({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_mode: undefined,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
weights: undefined,
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
...overrides,
} as RetrievalConfig)
const createHitTestingResponse = (numResults: number): HitTestingResponse => ({
query: {
content: 'What is Dify?',
tsne_position: { x: 0, y: 0 },
},
records: Array.from({ length: numResults }, (_, i) => ({
segment: {
id: `seg-${i}`,
document: {
id: `doc-${i}`,
data_source_type: 'upload_file',
name: `document-${i}.txt`,
doc_type: null as unknown as import('@/models/datasets').DocType,
},
content: `Result content ${i}`,
sign_content: `Result content ${i}`,
position: i + 1,
word_count: 100 + i * 50,
tokens: 50 + i * 25,
keywords: ['test', 'dify'],
hit_count: i * 5,
index_node_hash: `hash-${i}`,
answer: '',
},
content: {
id: `seg-${i}`,
document: {
id: `doc-${i}`,
data_source_type: 'upload_file',
name: `document-${i}.txt`,
doc_type: null as unknown as import('@/models/datasets').DocType,
},
content: `Result content ${i}`,
sign_content: `Result content ${i}`,
position: i + 1,
word_count: 100 + i * 50,
tokens: 50 + i * 25,
keywords: ['test', 'dify'],
hit_count: i * 5,
index_node_hash: `hash-${i}`,
answer: '',
},
score: 0.95 - i * 0.1,
tsne_position: { x: 0, y: 0 },
child_chunks: null,
files: [],
})),
})
const createTextQuery = (content: string): Query[] => [
{ content, content_type: 'text_query', file_info: null },
]
// --- Helpers ---
const findSubmitButton = () => {
const buttons = screen.getAllByRole('button')
const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]'))
expect(submitButton).toBeTruthy()
return submitButton!
}
// --- Tests ---
describe('Hit Testing Flow', () => {
const mockHitTestingMutation = vi.fn()
const mockExternalMutation = vi.fn()
const mockSetHitResult = vi.fn()
const mockSetExternalHitResult = vi.fn()
const mockOnUpdateList = vi.fn()
const mockSetQueries = vi.fn()
const mockOnClickRetrievalMethod = vi.fn()
const mockOnSubmit = vi.fn()
const createDefaultProps = (overrides: Record<string, unknown> = {}) => ({
onUpdateList: mockOnUpdateList,
setHitResult: mockSetHitResult,
setExternalHitResult: mockSetExternalHitResult,
loading: false,
queries: [] as Query[],
setQueries: mockSetQueries,
isExternal: false,
onClickRetrievalMethod: mockOnClickRetrievalMethod,
retrievalConfig: createRetrievalConfig(),
isEconomy: false,
onSubmit: mockOnSubmit,
hitTestingMutation: mockHitTestingMutation,
externalKnowledgeBaseHitTestingMutation: mockExternalMutation,
...overrides,
})
beforeEach(() => {
vi.clearAllMocks()
})
describe('Query Submission → API Call', () => {
it('should call hitTestingMutation with correct payload including retrieval model', async () => {
const retrievalConfig = createRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
top_k: 3,
score_threshold_enabled: false,
})
mockHitTestingMutation.mockResolvedValue(createHitTestingResponse(3))
render(
<QueryInput {...createDefaultProps({
queries: createTextQuery('How does RAG work?'),
retrievalConfig,
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockHitTestingMutation).toHaveBeenCalledWith(
expect.objectContaining({
query: 'How does RAG work?',
attachment_ids: [],
retrieval_model: expect.objectContaining({
search_method: RETRIEVE_METHOD.semantic,
top_k: 3,
score_threshold_enabled: false,
}),
}),
expect.objectContaining({
onSuccess: expect.any(Function),
}),
)
})
})
it('should override search_method to keywordSearch when isEconomy is true', async () => {
const retrievalConfig = createRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic })
mockHitTestingMutation.mockResolvedValue(createHitTestingResponse(1))
render(
<QueryInput {...createDefaultProps({
queries: createTextQuery('test query'),
retrievalConfig,
isEconomy: true,
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockHitTestingMutation).toHaveBeenCalledWith(
expect.objectContaining({
retrieval_model: expect.objectContaining({
search_method: RETRIEVE_METHOD.keywordSearch,
}),
}),
expect.anything(),
)
})
})
it('should handle empty results by calling setHitResult with empty records', async () => {
const emptyResponse = createHitTestingResponse(0)
mockHitTestingMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => {
options?.onSuccess?.(emptyResponse)
return emptyResponse
})
render(
<QueryInput {...createDefaultProps({
queries: createTextQuery('nonexistent topic'),
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockSetHitResult).toHaveBeenCalledWith(
expect.objectContaining({ records: [] }),
)
})
})
it('should not call success callbacks when mutation resolves without onSuccess', async () => {
// Simulate a mutation that resolves but does not invoke the onSuccess callback
mockHitTestingMutation.mockResolvedValue(undefined)
render(
<QueryInput {...createDefaultProps({
queries: createTextQuery('test'),
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockHitTestingMutation).toHaveBeenCalled()
})
// Success callbacks should not fire when onSuccess is not invoked
expect(mockSetHitResult).not.toHaveBeenCalled()
expect(mockOnUpdateList).not.toHaveBeenCalled()
expect(mockOnSubmit).not.toHaveBeenCalled()
})
})
describe('API Response → Results Data Contract', () => {
it('should produce results with required segment fields for rendering', () => {
const response = createHitTestingResponse(3)
// Validate each result has the fields needed by ResultItem component
response.records.forEach((record) => {
expect(record.segment).toHaveProperty('id')
expect(record.segment).toHaveProperty('content')
expect(record.segment).toHaveProperty('position')
expect(record.segment).toHaveProperty('word_count')
expect(record.segment).toHaveProperty('document')
expect(record.segment.document).toHaveProperty('name')
expect(record.score).toBeGreaterThanOrEqual(0)
expect(record.score).toBeLessThanOrEqual(1)
})
})
it('should maintain correct score ordering', () => {
const response = createHitTestingResponse(5)
for (let i = 1; i < response.records.length; i++) {
expect(response.records[i - 1].score).toBeGreaterThanOrEqual(response.records[i].score)
}
})
it('should include document metadata for result item display', () => {
const response = createHitTestingResponse(1)
const record = response.records[0]
expect(record.segment.document.name).toBeTruthy()
expect(record.segment.document.data_source_type).toBeTruthy()
})
})
describe('Successful Submission → Callback Chain', () => {
it('should call setHitResult, onUpdateList, and onSubmit after successful submission', async () => {
const response = createHitTestingResponse(3)
mockHitTestingMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => {
options?.onSuccess?.(response)
return response
})
render(
<QueryInput {...createDefaultProps({
queries: createTextQuery('Test query'),
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockSetHitResult).toHaveBeenCalledWith(response)
expect(mockOnUpdateList).toHaveBeenCalledTimes(1)
expect(mockOnSubmit).toHaveBeenCalledTimes(1)
})
})
it('should trigger records list refresh via onUpdateList after query', async () => {
const response = createHitTestingResponse(1)
mockHitTestingMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => {
options?.onSuccess?.(response)
return response
})
render(
<QueryInput {...createDefaultProps({
queries: createTextQuery('new query'),
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockOnUpdateList).toHaveBeenCalledTimes(1)
})
})
})
describe('External KB Hit Testing', () => {
it('should use external mutation with correct payload for external datasets', async () => {
mockExternalMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: { records: never[] }) => void }) => {
const response = { records: [] }
options?.onSuccess?.(response)
return response
})
render(
<QueryInput {...createDefaultProps({
queries: createTextQuery('test'),
isExternal: true,
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockExternalMutation).toHaveBeenCalledWith(
expect.objectContaining({
query: 'test',
external_retrieval_model: expect.objectContaining({
top_k: 4,
score_threshold: 0.5,
score_threshold_enabled: false,
}),
}),
expect.objectContaining({
onSuccess: expect.any(Function),
}),
)
// Internal mutation should NOT be called
expect(mockHitTestingMutation).not.toHaveBeenCalled()
})
})
it('should call setExternalHitResult and onUpdateList on successful external submission', async () => {
const externalResponse = { records: [] }
mockExternalMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: { records: never[] }) => void }) => {
options?.onSuccess?.(externalResponse)
return externalResponse
})
render(
<QueryInput {...createDefaultProps({
queries: createTextQuery('external query'),
isExternal: true,
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockSetExternalHitResult).toHaveBeenCalledWith(externalResponse)
expect(mockOnUpdateList).toHaveBeenCalledTimes(1)
})
})
})
})

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

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

View File

@@ -0,0 +1,300 @@
/**
* Integration Test: Segment CRUD Flow
*
* Tests segment selection, search/filter, and modal state management across hooks.
* Validates cross-hook data contracts in the completed segment module.
*/
import type { SegmentDetailModel } from '@/models/datasets'
import { act, renderHook } from '@testing-library/react'
import { useModalState } from '@/app/components/datasets/documents/detail/completed/hooks/use-modal-state'
import { useSearchFilter } from '@/app/components/datasets/documents/detail/completed/hooks/use-search-filter'
import { useSegmentSelection } from '@/app/components/datasets/documents/detail/completed/hooks/use-segment-selection'
const createSegment = (id: string, content = 'Test segment content'): SegmentDetailModel => ({
id,
position: 1,
document_id: 'doc-1',
content,
sign_content: content,
answer: '',
word_count: 50,
tokens: 25,
keywords: ['test'],
index_node_id: 'idx-1',
index_node_hash: 'hash-1',
hit_count: 0,
enabled: true,
disabled_at: 0,
disabled_by: '',
status: 'completed',
created_by: 'user-1',
created_at: Date.now(),
indexing_at: Date.now(),
completed_at: Date.now(),
error: null,
stopped_at: 0,
updated_at: Date.now(),
attachments: [],
} as SegmentDetailModel)
describe('Segment CRUD Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Search and Filter → Segment List Query', () => {
it('should manage search input with debounce', () => {
vi.useFakeTimers()
const onPageChange = vi.fn()
const { result } = renderHook(() => useSearchFilter({ onPageChange }))
act(() => {
result.current.handleInputChange('keyword')
})
expect(result.current.inputValue).toBe('keyword')
expect(result.current.searchValue).toBe('')
act(() => {
vi.advanceTimersByTime(500)
})
expect(result.current.searchValue).toBe('keyword')
expect(onPageChange).toHaveBeenCalledWith(1)
vi.useRealTimers()
})
it('should manage status filter state', () => {
const onPageChange = vi.fn()
const { result } = renderHook(() => useSearchFilter({ onPageChange }))
// status value 1 maps to !!1 = true (enabled)
act(() => {
result.current.onChangeStatus({ value: 1, name: 'enabled' })
})
// onChangeStatus converts: value === 'all' ? 'all' : !!value
expect(result.current.selectedStatus).toBe(true)
act(() => {
result.current.onClearFilter()
})
expect(result.current.selectedStatus).toBe('all')
expect(result.current.inputValue).toBe('')
})
it('should provide status list for filter dropdown', () => {
const { result } = renderHook(() => useSearchFilter({ onPageChange: vi.fn() }))
expect(result.current.statusList).toBeInstanceOf(Array)
expect(result.current.statusList.length).toBe(3) // all, disabled, enabled
})
it('should compute selectDefaultValue based on selectedStatus', () => {
const { result } = renderHook(() => useSearchFilter({ onPageChange: vi.fn() }))
// Initial state: 'all'
expect(result.current.selectDefaultValue).toBe('all')
// Set to enabled (true)
act(() => {
result.current.onChangeStatus({ value: 1, name: 'enabled' })
})
expect(result.current.selectDefaultValue).toBe(1)
// Set to disabled (false)
act(() => {
result.current.onChangeStatus({ value: 0, name: 'disabled' })
})
expect(result.current.selectDefaultValue).toBe(0)
})
})
describe('Segment Selection → Batch Operations', () => {
const segments = [
createSegment('seg-1'),
createSegment('seg-2'),
createSegment('seg-3'),
]
it('should manage individual segment selection', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
act(() => {
result.current.onSelected('seg-1')
})
expect(result.current.selectedSegmentIds).toContain('seg-1')
act(() => {
result.current.onSelected('seg-2')
})
expect(result.current.selectedSegmentIds).toContain('seg-1')
expect(result.current.selectedSegmentIds).toContain('seg-2')
expect(result.current.selectedSegmentIds).toHaveLength(2)
})
it('should toggle selection on repeated click', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
act(() => {
result.current.onSelected('seg-1')
})
expect(result.current.selectedSegmentIds).toContain('seg-1')
act(() => {
result.current.onSelected('seg-1')
})
expect(result.current.selectedSegmentIds).not.toContain('seg-1')
})
it('should support select all toggle', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
act(() => {
result.current.onSelectedAll()
})
expect(result.current.selectedSegmentIds).toHaveLength(3)
expect(result.current.isAllSelected).toBe(true)
act(() => {
result.current.onSelectedAll()
})
expect(result.current.selectedSegmentIds).toHaveLength(0)
expect(result.current.isAllSelected).toBe(false)
})
it('should detect partial selection via isSomeSelected', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
act(() => {
result.current.onSelected('seg-1')
})
// After selecting one of three, isSomeSelected should be true
expect(result.current.selectedSegmentIds).toEqual(['seg-1'])
expect(result.current.isSomeSelected).toBe(true)
expect(result.current.isAllSelected).toBe(false)
})
it('should clear selection via onCancelBatchOperation', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
act(() => {
result.current.onSelected('seg-1')
result.current.onSelected('seg-2')
})
expect(result.current.selectedSegmentIds).toHaveLength(2)
act(() => {
result.current.onCancelBatchOperation()
})
expect(result.current.selectedSegmentIds).toHaveLength(0)
})
})
describe('Modal State Management', () => {
const onNewSegmentModalChange = vi.fn()
it('should open segment detail modal on card click', () => {
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
const segment = createSegment('seg-detail-1', 'Detail content')
act(() => {
result.current.onClickCard(segment)
})
expect(result.current.currSegment.showModal).toBe(true)
expect(result.current.currSegment.segInfo).toBeDefined()
expect(result.current.currSegment.segInfo!.id).toBe('seg-detail-1')
})
it('should close segment detail modal', () => {
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
const segment = createSegment('seg-1')
act(() => {
result.current.onClickCard(segment)
})
expect(result.current.currSegment.showModal).toBe(true)
act(() => {
result.current.onCloseSegmentDetail()
})
expect(result.current.currSegment.showModal).toBe(false)
})
it('should manage full screen toggle', () => {
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
expect(result.current.fullScreen).toBe(false)
act(() => {
result.current.toggleFullScreen()
})
expect(result.current.fullScreen).toBe(true)
act(() => {
result.current.toggleFullScreen()
})
expect(result.current.fullScreen).toBe(false)
})
it('should manage collapsed state', () => {
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
expect(result.current.isCollapsed).toBe(true)
act(() => {
result.current.toggleCollapsed()
})
expect(result.current.isCollapsed).toBe(false)
})
it('should manage new child segment modal', () => {
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
expect(result.current.showNewChildSegmentModal).toBe(false)
act(() => {
result.current.handleAddNewChildChunk('chunk-parent-1')
})
expect(result.current.showNewChildSegmentModal).toBe(true)
expect(result.current.currChunkId).toBe('chunk-parent-1')
act(() => {
result.current.onCloseNewChildChunkModal()
})
expect(result.current.showNewChildSegmentModal).toBe(false)
})
})
describe('Cross-Hook Data Flow: Search → Selection → Modal', () => {
it('should maintain independent state across all three hooks', () => {
const segments = [createSegment('seg-1'), createSegment('seg-2')]
const { result: filterResult } = renderHook(() =>
useSearchFilter({ onPageChange: vi.fn() }),
)
const { result: selectionResult } = renderHook(() =>
useSegmentSelection(segments),
)
const { result: modalResult } = renderHook(() =>
useModalState({ onNewSegmentModalChange: vi.fn() }),
)
// Set search filter to enabled
act(() => {
filterResult.current.onChangeStatus({ value: 1, name: 'enabled' })
})
// Select a segment
act(() => {
selectionResult.current.onSelected('seg-1')
})
// Open detail modal
act(() => {
modalResult.current.onClickCard(segments[0])
})
// All states should be independent
expect(filterResult.current.selectedStatus).toBe(true) // !!1
expect(selectionResult.current.selectedSegmentIds).toContain('seg-1')
expect(modalResult.current.currSegment.showModal).toBe(true)
})
})
})

View File

@@ -162,8 +162,10 @@ describe('useEmbeddedChatbot', () => {
await waitFor(() => {
expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', AppSourceType.webApp, 'app-1')
})
expect(result.current.pinnedConversationList).toEqual(pinnedData.data)
expect(result.current.conversationList).toEqual(listData.data)
await waitFor(() => {
expect(result.current.pinnedConversationList).toEqual(pinnedData.data)
expect(result.current.conversationList).toEqual(listData.data)
})
})
})

View File

@@ -1,111 +1,309 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type { QA } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkContainer, ChunkLabel, QAPreview } from './chunk'
afterEach(() => {
cleanup()
})
vi.mock('../base/icons/src/public/knowledge', () => ({
SelectionMod: (props: React.ComponentProps<'svg'>) => (
<svg data-testid="selection-mod-icon" {...props} />
),
}))
function createQA(overrides: Partial<QA> = {}): QA {
return {
question: 'What is Dify?',
answer: 'Dify is an open-source LLM app development platform.',
...overrides,
}
}
describe('ChunkLabel', () => {
it('should render label text', () => {
render(<ChunkLabel label="Chunk 1" characterCount={100} />)
expect(screen.getByText('Chunk 1')).toBeInTheDocument()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render character count', () => {
render(<ChunkLabel label="Chunk 1" characterCount={150} />)
expect(screen.getByText('150 characters')).toBeInTheDocument()
describe('Rendering', () => {
it('should render the label text', () => {
render(<ChunkLabel label="Chunk #1" characterCount={100} />)
expect(screen.getByText('Chunk #1')).toBeInTheDocument()
})
it('should render the character count with unit', () => {
render(<ChunkLabel label="Chunk #1" characterCount={256} />)
expect(screen.getByText('256 characters')).toBeInTheDocument()
})
it('should render the SelectionMod icon', () => {
render(<ChunkLabel label="Chunk" characterCount={10} />)
expect(screen.getByTestId('selection-mod-icon')).toBeInTheDocument()
})
it('should render a middle dot separator between label and count', () => {
render(<ChunkLabel label="Chunk" characterCount={10} />)
expect(screen.getByText('·')).toBeInTheDocument()
})
})
it('should render separator dot', () => {
render(<ChunkLabel label="Chunk 1" characterCount={100} />)
expect(screen.getByText('·')).toBeInTheDocument()
describe('Props', () => {
it('should display zero character count', () => {
render(<ChunkLabel label="Empty Chunk" characterCount={0} />)
expect(screen.getByText('0 characters')).toBeInTheDocument()
})
it('should display large character counts', () => {
render(<ChunkLabel label="Large" characterCount={999999} />)
expect(screen.getByText('999999 characters')).toBeInTheDocument()
})
})
it('should render with zero character count', () => {
render(<ChunkLabel label="Empty Chunk" characterCount={0} />)
expect(screen.getByText('0 characters')).toBeInTheDocument()
})
describe('Edge Cases', () => {
it('should render with empty label', () => {
render(<ChunkLabel label="" characterCount={50} />)
it('should render with large character count', () => {
render(<ChunkLabel label="Large Chunk" characterCount={999999} />)
expect(screen.getByText('999999 characters')).toBeInTheDocument()
expect(screen.getByText('50 characters')).toBeInTheDocument()
})
it('should render with special characters in label', () => {
render(<ChunkLabel label="Chunk <#1> & 'test'" characterCount={10} />)
expect(screen.getByText('Chunk <#1> & \'test\'')).toBeInTheDocument()
})
})
})
// Tests for ChunkContainer - wraps ChunkLabel with children content area
describe('ChunkContainer', () => {
it('should render label and character count', () => {
render(<ChunkContainer label="Container 1" characterCount={200}>Content</ChunkContainer>)
expect(screen.getByText('Container 1')).toBeInTheDocument()
expect(screen.getByText('200 characters')).toBeInTheDocument()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render children content', () => {
render(<ChunkContainer label="Container 1" characterCount={200}>Test Content</ChunkContainer>)
expect(screen.getByText('Test Content')).toBeInTheDocument()
describe('Rendering', () => {
it('should render ChunkLabel with correct props', () => {
render(
<ChunkContainer label="Chunk #1" characterCount={200}>
Content here
</ChunkContainer>,
)
expect(screen.getByText('Chunk #1')).toBeInTheDocument()
expect(screen.getByText('200 characters')).toBeInTheDocument()
})
it('should render children in the content area', () => {
render(
<ChunkContainer label="Chunk" characterCount={50}>
<p>Paragraph content</p>
</ChunkContainer>,
)
expect(screen.getByText('Paragraph content')).toBeInTheDocument()
})
it('should render the SelectionMod icon via ChunkLabel', () => {
render(
<ChunkContainer label="Chunk" characterCount={10}>
Content
</ChunkContainer>,
)
expect(screen.getByTestId('selection-mod-icon')).toBeInTheDocument()
})
})
it('should render with complex children', () => {
render(
<ChunkContainer label="Container" characterCount={100}>
<div data-testid="child-div">
<span>Nested content</span>
</div>
</ChunkContainer>,
)
expect(screen.getByTestId('child-div')).toBeInTheDocument()
expect(screen.getByText('Nested content')).toBeInTheDocument()
describe('Structure', () => {
it('should have space-y-2 on the outer container', () => {
const { container } = render(
<ChunkContainer label="Chunk" characterCount={10}>Content</ChunkContainer>,
)
expect(container.firstElementChild).toHaveClass('space-y-2')
})
it('should render children inside a styled content div', () => {
render(
<ChunkContainer label="Chunk" characterCount={10}>
<span>Test child</span>
</ChunkContainer>,
)
const contentDiv = screen.getByText('Test child').parentElement
expect(contentDiv).toHaveClass('body-md-regular', 'text-text-secondary')
})
})
it('should render empty children', () => {
render(<ChunkContainer label="Empty" characterCount={0}>{null}</ChunkContainer>)
expect(screen.getByText('Empty')).toBeInTheDocument()
describe('Edge Cases', () => {
it('should render without children', () => {
const { container } = render(
<ChunkContainer label="Empty" characterCount={0} />,
)
expect(container.firstElementChild).toBeInTheDocument()
expect(screen.getByText('Empty')).toBeInTheDocument()
})
it('should render multiple children', () => {
render(
<ChunkContainer label="Multi" characterCount={100}>
<span>First</span>
<span>Second</span>
</ChunkContainer>,
)
expect(screen.getByText('First')).toBeInTheDocument()
expect(screen.getByText('Second')).toBeInTheDocument()
})
it('should render with string children', () => {
render(
<ChunkContainer label="Text" characterCount={5}>
Plain text content
</ChunkContainer>,
)
expect(screen.getByText('Plain text content')).toBeInTheDocument()
})
})
})
// Tests for QAPreview - displays question and answer pair
describe('QAPreview', () => {
const mockQA = {
question: 'What is the meaning of life?',
answer: 'The meaning of life is 42.',
}
it('should render question text', () => {
render(<QAPreview qa={mockQA} />)
expect(screen.getByText('What is the meaning of life?')).toBeInTheDocument()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render answer text', () => {
render(<QAPreview qa={mockQA} />)
expect(screen.getByText('The meaning of life is 42.')).toBeInTheDocument()
describe('Rendering', () => {
it('should render the question text', () => {
const qa = createQA()
render(<QAPreview qa={qa} />)
expect(screen.getByText('What is Dify?')).toBeInTheDocument()
})
it('should render the answer text', () => {
const qa = createQA()
render(<QAPreview qa={qa} />)
expect(screen.getByText('Dify is an open-source LLM app development platform.')).toBeInTheDocument()
})
it('should render Q and A labels', () => {
const qa = createQA()
render(<QAPreview qa={qa} />)
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
})
})
it('should render Q label', () => {
render(<QAPreview qa={mockQA} />)
expect(screen.getByText('Q')).toBeInTheDocument()
describe('Structure', () => {
it('should render Q label as a label element', () => {
const qa = createQA()
render(<QAPreview qa={qa} />)
const qLabel = screen.getByText('Q')
expect(qLabel.tagName).toBe('LABEL')
})
it('should render A label as a label element', () => {
const qa = createQA()
render(<QAPreview qa={qa} />)
const aLabel = screen.getByText('A')
expect(aLabel.tagName).toBe('LABEL')
})
it('should render question in a p element', () => {
const qa = createQA()
render(<QAPreview qa={qa} />)
const questionEl = screen.getByText(qa.question)
expect(questionEl.tagName).toBe('P')
})
it('should render answer in a p element', () => {
const qa = createQA()
render(<QAPreview qa={qa} />)
const answerEl = screen.getByText(qa.answer)
expect(answerEl.tagName).toBe('P')
})
it('should have the outer container with flex column layout', () => {
const qa = createQA()
const { container } = render(<QAPreview qa={qa} />)
expect(container.firstElementChild).toHaveClass('flex', 'flex-col', 'gap-y-2')
})
it('should apply text styling classes to question paragraph', () => {
const qa = createQA()
render(<QAPreview qa={qa} />)
const questionEl = screen.getByText(qa.question)
expect(questionEl).toHaveClass('body-md-regular', 'text-text-secondary')
})
it('should apply text styling classes to answer paragraph', () => {
const qa = createQA()
render(<QAPreview qa={qa} />)
const answerEl = screen.getByText(qa.answer)
expect(answerEl).toHaveClass('body-md-regular', 'text-text-secondary')
})
})
it('should render A label', () => {
render(<QAPreview qa={mockQA} />)
expect(screen.getByText('A')).toBeInTheDocument()
})
describe('Edge Cases', () => {
it('should render with empty question', () => {
const qa = createQA({ question: '' })
render(<QAPreview qa={qa} />)
it('should render with empty strings', () => {
render(<QAPreview qa={{ question: '', answer: '' }} />)
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
})
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
})
it('should render with long text', () => {
const longQuestion = 'Q'.repeat(500)
const longAnswer = 'A'.repeat(500)
render(<QAPreview qa={{ question: longQuestion, answer: longAnswer }} />)
expect(screen.getByText(longQuestion)).toBeInTheDocument()
expect(screen.getByText(longAnswer)).toBeInTheDocument()
})
it('should render with empty answer', () => {
const qa = createQA({ answer: '' })
render(<QAPreview qa={qa} />)
it('should render with special characters', () => {
render(<QAPreview qa={{ question: 'What about <script>?', answer: '& special chars!' }} />)
expect(screen.getByText('What about <script>?')).toBeInTheDocument()
expect(screen.getByText('& special chars!')).toBeInTheDocument()
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText(qa.question)).toBeInTheDocument()
})
it('should render with long text', () => {
const longText = 'x'.repeat(1000)
const qa = createQA({ question: longText, answer: longText })
render(<QAPreview qa={qa} />)
const elements = screen.getAllByText(longText)
expect(elements).toHaveLength(2)
})
it('should render with special characters in question and answer', () => {
const qa = createQA({
question: 'What about <html> & "quotes"?',
answer: 'It handles \'single\' & "double" quotes.',
})
render(<QAPreview qa={qa} />)
expect(screen.getByText('What about <html> & "quotes"?')).toBeInTheDocument()
expect(screen.getByText('It handles \'single\' & "double" quotes.')).toBeInTheDocument()
})
it('should render with multiline text', () => {
const qa = createQA({
question: 'Line1\nLine2',
answer: 'Answer1\nAnswer2',
})
render(<QAPreview qa={qa} />)
expect(screen.getByText(/Line1/)).toBeInTheDocument()
expect(screen.getByText(/Answer1/)).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,49 @@
import type { DocumentItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DocumentList from './document-list'
vi.mock('../document-file-icon', () => ({
default: ({ name, extension }: { name?: string, extension?: string }) => (
<span data-testid="file-icon">
{name}
.
{extension}
</span>
),
}))
describe('DocumentList', () => {
const mockList = [
{ id: 'doc-1', name: 'report', extension: 'pdf' },
{ id: 'doc-2', name: 'data', extension: 'csv' },
] as DocumentItem[]
const onChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render all documents', () => {
render(<DocumentList list={mockList} onChange={onChange} />)
expect(screen.getByText('report')).toBeInTheDocument()
expect(screen.getByText('data')).toBeInTheDocument()
})
it('should render file icons', () => {
render(<DocumentList list={mockList} onChange={onChange} />)
expect(screen.getAllByTestId('file-icon')).toHaveLength(2)
})
it('should call onChange with document on click', () => {
render(<DocumentList list={mockList} onChange={onChange} />)
fireEvent.click(screen.getByText('report'))
expect(onChange).toHaveBeenCalledWith(mockList[0])
})
it('should render empty list without errors', () => {
const { container } = render(<DocumentList list={[]} onChange={onChange} />)
expect(container.firstChild).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,145 @@
/* eslint-disable next/no-img-element */
import type { ReactNode } from 'react'
import type { IndexingStatusResponse } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DataSourceType } from '@/models/datasets'
import IndexingProgressItem from './indexing-progress-item'
vi.mock('next/image', () => ({
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
}))
vi.mock('@/app/components/billing/priority-label', () => ({
default: () => <span data-testid="priority-label">Priority</span>,
}))
vi.mock('../../common/document-file-icon', () => ({
default: ({ name }: { name?: string }) => <span data-testid="file-icon">{name}</span>,
}))
vi.mock('@/app/components/base/notion-icon', () => ({
default: ({ src }: { src?: string }) => <span data-testid="notion-icon">{src}</span>,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children, popupContent }: { children?: ReactNode, popupContent?: ReactNode }) => (
<div data-testid="tooltip" data-content={popupContent}>{children}</div>
),
}))
describe('IndexingProgressItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const makeDetail = (overrides: Partial<IndexingStatusResponse> = {}): IndexingStatusResponse => ({
id: 'doc-1',
indexing_status: 'indexing',
processing_started_at: 0,
parsing_completed_at: 0,
cleaning_completed_at: 0,
splitting_completed_at: 0,
completed_at: null,
paused_at: null,
error: null,
stopped_at: null,
completed_segments: 50,
total_segments: 100,
...overrides,
})
it('should render name and progress for embedding status', () => {
render(
<IndexingProgressItem
detail={makeDetail()}
name="test.pdf"
sourceType={DataSourceType.FILE}
/>,
)
// Name appears in both the file-icon mock and the display div; verify at least one
expect(screen.getAllByText('test.pdf').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('50%')).toBeInTheDocument()
})
it('should render file icon for FILE source type', () => {
render(
<IndexingProgressItem
detail={makeDetail()}
name="report.docx"
sourceType={DataSourceType.FILE}
/>,
)
expect(screen.getByTestId('file-icon')).toBeInTheDocument()
})
it('should render notion icon for NOTION source type', () => {
render(
<IndexingProgressItem
detail={makeDetail()}
name="My Page"
sourceType={DataSourceType.NOTION}
notionIcon="notion-icon-url"
/>,
)
expect(screen.getByTestId('notion-icon')).toBeInTheDocument()
})
it('should render success icon for completed status', () => {
render(
<IndexingProgressItem
detail={makeDetail({ indexing_status: 'completed' })}
name="done.pdf"
/>,
)
// No progress percentage should be shown for completed
expect(screen.queryByText('%')).not.toBeInTheDocument()
})
it('should render error icon with tooltip for error status', () => {
render(
<IndexingProgressItem
detail={makeDetail({ indexing_status: 'error', error: 'Parse failed' })}
name="broken.pdf"
/>,
)
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'Parse failed')
})
it('should show priority label when billing is enabled', () => {
render(
<IndexingProgressItem
detail={makeDetail()}
name="test.pdf"
enableBilling={true}
/>,
)
expect(screen.getByTestId('priority-label')).toBeInTheDocument()
})
it('should not show priority label when billing is disabled', () => {
render(
<IndexingProgressItem
detail={makeDetail()}
name="test.pdf"
enableBilling={false}
/>,
)
expect(screen.queryByTestId('priority-label')).not.toBeInTheDocument()
})
it('should apply error styling for error status', () => {
const { container } = render(
<IndexingProgressItem
detail={makeDetail({ indexing_status: 'error' })}
name="error.pdf"
/>,
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('bg-state-destructive-hover-alt')
})
})

View File

@@ -0,0 +1,154 @@
/* eslint-disable next/no-img-element */
import type { ProcessRuleResponse } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ProcessMode } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import RuleDetail from './rule-detail'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) => `${opts?.ns ? `${opts.ns}.` : ''}${key}`,
}),
}))
vi.mock('next/image', () => ({
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
}))
vi.mock('@/app/components/datasets/documents/detail/metadata', () => ({
FieldInfo: ({ label, displayedValue }: { label: string, displayedValue: string }) => (
<div data-testid="field-info">
<span data-testid="field-label">{label}</span>
<span data-testid="field-value">{displayedValue}</span>
</div>
),
}))
vi.mock('../icons', () => ({
indexMethodIcon: { economical: '/icons/economical.svg', high_quality: '/icons/hq.svg' },
retrievalIcon: { fullText: '/icons/ft.svg', hybrid: '/icons/hy.svg', vector: '/icons/vec.svg' },
}))
describe('RuleDetail', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const makeSourceData = (overrides: Partial<ProcessRuleResponse> = {}): ProcessRuleResponse => ({
mode: ProcessMode.general,
rules: {
segmentation: { separator: '\n', max_tokens: 500, chunk_overlap: 50 },
pre_processing_rules: [
{ id: 'remove_extra_spaces', enabled: true },
{ id: 'remove_urls_emails', enabled: false },
],
},
...overrides,
} as ProcessRuleResponse)
it('should render mode, segment length, text cleaning, index mode, and retrieval fields', () => {
render(
<RuleDetail
sourceData={makeSourceData()}
indexingType="high_quality"
retrievalMethod={RETRIEVE_METHOD.semantic}
/>,
)
const fieldInfos = screen.getAllByTestId('field-info')
// mode, segmentLength, textCleaning, indexMode, retrievalSetting = 5
expect(fieldInfos.length).toBe(5)
})
it('should display "custom" for general mode', () => {
render(
<RuleDetail
sourceData={makeSourceData({ mode: ProcessMode.general })}
indexingType="high_quality"
/>,
)
const values = screen.getAllByTestId('field-value')
expect(values[0].textContent).toContain('embedding.custom')
})
it('should display hierarchical mode with parent mode label', () => {
render(
<RuleDetail
sourceData={makeSourceData({
mode: ProcessMode.parentChild,
rules: {
parent_mode: 'paragraph',
segmentation: { separator: '\n', max_tokens: 1000, chunk_overlap: 50 },
subchunk_segmentation: { max_tokens: 200 },
pre_processing_rules: [],
} as unknown as ProcessRuleResponse['rules'],
})}
indexingType="high_quality"
/>,
)
const values = screen.getAllByTestId('field-value')
expect(values[0].textContent).toContain('embedding.hierarchical')
})
it('should display "-" when no sourceData mode', () => {
render(
<RuleDetail
sourceData={makeSourceData({ mode: undefined as unknown as ProcessMode })}
indexingType="high_quality"
/>,
)
const values = screen.getAllByTestId('field-value')
expect(values[0].textContent).toBe('-')
})
it('should display segment length for general mode', () => {
render(
<RuleDetail
sourceData={makeSourceData()}
indexingType="high_quality"
/>,
)
const values = screen.getAllByTestId('field-value')
expect(values[1].textContent).toBe('500')
})
it('should display enabled pre-processing rules', () => {
render(
<RuleDetail
sourceData={makeSourceData()}
indexingType="high_quality"
/>,
)
const values = screen.getAllByTestId('field-value')
// Only remove_extra_spaces is enabled
expect(values[2].textContent).toContain('stepTwo.removeExtraSpaces')
})
it('should display economical index mode', () => {
render(
<RuleDetail
sourceData={makeSourceData()}
indexingType="economy"
/>,
)
const values = screen.getAllByTestId('field-value')
// Index mode field is 4th (index 3)
expect(values[3].textContent).toContain('stepTwo.economical')
})
it('should display qualified index mode for high_quality', () => {
render(
<RuleDetail
sourceData={makeSourceData()}
indexingType="high_quality"
/>,
)
const values = screen.getAllByTestId('field-value')
expect(values[3].textContent).toContain('stepTwo.qualified')
})
})

View File

@@ -0,0 +1,34 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import UpgradeBanner from './upgrade-banner'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/base/icons/src/vender/solid/general', () => ({
ZapFast: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="zap-icon" {...props} />,
}))
vi.mock('@/app/components/billing/upgrade-btn', () => ({
default: ({ loc }: { loc: string }) => <button data-testid="upgrade-btn" data-loc={loc}>Upgrade</button>,
}))
describe('UpgradeBanner', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the banner with icon, text, and upgrade button', () => {
render(<UpgradeBanner />)
expect(screen.getByTestId('zap-icon')).toBeInTheDocument()
expect(screen.getByText('plansCommon.documentProcessingPriorityUpgrade')).toBeInTheDocument()
expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument()
})
it('should pass correct loc to UpgradeBtn', () => {
render(<UpgradeBanner />)
expect(screen.getByTestId('upgrade-btn')).toHaveAttribute('data-loc', 'knowledge-speed-up')
})
})

View File

@@ -0,0 +1,179 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useIndexingStatusPolling } from './use-indexing-status-polling'
const mockFetchIndexingStatusBatch = vi.fn()
vi.mock('@/service/datasets', () => ({
fetchIndexingStatusBatch: (...args: unknown[]) => mockFetchIndexingStatusBatch(...args),
}))
describe('useIndexingStatusPolling', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
})
afterEach(() => {
vi.useRealTimers()
})
const defaultParams = { datasetId: 'ds-1', batchId: 'batch-1' }
it('should initialize with empty status list', async () => {
mockFetchIndexingStatusBatch.mockReturnValue(new Promise(() => {}))
const { result } = renderHook(() => useIndexingStatusPolling(defaultParams))
expect(result.current.statusList).toEqual([])
expect(result.current.isEmbedding).toBe(false)
expect(result.current.isEmbeddingCompleted).toBe(false)
})
it('should fetch status on mount and update state', async () => {
mockFetchIndexingStatusBatch.mockResolvedValue({
data: [{ indexing_status: 'indexing', completed_segments: 5, total_segments: 10 }],
})
const { result } = renderHook(() => useIndexingStatusPolling(defaultParams))
// Flush the resolved promise
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(mockFetchIndexingStatusBatch).toHaveBeenCalledWith({
datasetId: 'ds-1',
batchId: 'batch-1',
})
expect(result.current.statusList).toHaveLength(1)
expect(result.current.isEmbedding).toBe(true)
expect(result.current.isEmbeddingCompleted).toBe(false)
})
it('should stop polling when all completed', async () => {
mockFetchIndexingStatusBatch.mockResolvedValue({
data: [{ indexing_status: 'completed' }],
})
const { result } = renderHook(() => useIndexingStatusPolling(defaultParams))
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(result.current.isEmbeddingCompleted).toBe(true)
expect(result.current.isEmbedding).toBe(false)
// Should not schedule another poll
const callCount = mockFetchIndexingStatusBatch.mock.calls.length
await act(async () => {
await vi.advanceTimersByTimeAsync(5000)
})
expect(mockFetchIndexingStatusBatch).toHaveBeenCalledTimes(callCount)
})
it('should continue polling on fetch error', async () => {
mockFetchIndexingStatusBatch
.mockRejectedValueOnce(new Error('network'))
.mockResolvedValueOnce({
data: [{ indexing_status: 'completed' }],
})
const { result } = renderHook(() => useIndexingStatusPolling(defaultParams))
// First call: rejects
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
// Advance past polling interval for retry
await act(async () => {
await vi.advanceTimersByTimeAsync(2500)
})
expect(result.current.isEmbeddingCompleted).toBe(true)
})
it('should detect embedding statuses', async () => {
mockFetchIndexingStatusBatch.mockResolvedValue({
data: [
{ indexing_status: 'splitting' },
{ indexing_status: 'parsing' },
],
})
const { result } = renderHook(() => useIndexingStatusPolling(defaultParams))
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(result.current.isEmbedding).toBe(true)
expect(result.current.isEmbeddingCompleted).toBe(false)
})
it('should detect mixed statuses (some completed, some embedding)', async () => {
mockFetchIndexingStatusBatch.mockResolvedValue({
data: [
{ indexing_status: 'completed' },
{ indexing_status: 'indexing' },
],
})
const { result } = renderHook(() => useIndexingStatusPolling(defaultParams))
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(result.current.statusList).toHaveLength(2)
expect(result.current.isEmbedding).toBe(true)
expect(result.current.isEmbeddingCompleted).toBe(false)
})
it('should cleanup on unmount', async () => {
mockFetchIndexingStatusBatch.mockResolvedValue({
data: [{ indexing_status: 'indexing' }],
})
const { unmount } = renderHook(() => useIndexingStatusPolling(defaultParams))
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
const callCount = mockFetchIndexingStatusBatch.mock.calls.length
unmount()
await act(async () => {
await vi.advanceTimersByTimeAsync(5000)
})
expect(mockFetchIndexingStatusBatch).toHaveBeenCalledTimes(callCount)
})
it('should treat error and paused as completed statuses', async () => {
mockFetchIndexingStatusBatch.mockResolvedValue({
data: [
{ indexing_status: 'error' },
{ indexing_status: 'paused' },
],
})
const { result } = renderHook(() => useIndexingStatusPolling(defaultParams))
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(result.current.isEmbeddingCompleted).toBe(true)
expect(result.current.isEmbedding).toBe(false)
})
it('should poll at 2500ms intervals', async () => {
mockFetchIndexingStatusBatch.mockResolvedValue({
data: [{ indexing_status: 'indexing' }],
})
renderHook(() => useIndexingStatusPolling(defaultParams))
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(mockFetchIndexingStatusBatch).toHaveBeenCalledTimes(1)
await act(async () => {
await vi.advanceTimersByTimeAsync(2500)
})
expect(mockFetchIndexingStatusBatch).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,140 @@
import type { DataSourceInfo, FullDocumentDetail, IndexingStatusResponse } from '@/models/datasets'
import { describe, expect, it } from 'vitest'
import { createDocumentLookup, getFileType, getSourcePercent, isLegacyDataSourceInfo, isSourceEmbedding } from './utils'
describe('isLegacyDataSourceInfo', () => {
it('should return true when upload_file object exists', () => {
const info = { upload_file: { id: '1', name: 'test.pdf' } } as unknown as DataSourceInfo
expect(isLegacyDataSourceInfo(info)).toBe(true)
})
it('should return false when upload_file is absent', () => {
const info = { notion_page_icon: 'icon' } as unknown as DataSourceInfo
expect(isLegacyDataSourceInfo(info)).toBe(false)
})
it('should return false for null', () => {
expect(isLegacyDataSourceInfo(null as unknown as DataSourceInfo)).toBe(false)
})
it('should return false when upload_file is a string', () => {
const info = { upload_file: 'not-an-object' } as unknown as DataSourceInfo
expect(isLegacyDataSourceInfo(info)).toBe(false)
})
})
describe('isSourceEmbedding', () => {
const embeddingStatuses = ['indexing', 'splitting', 'parsing', 'cleaning', 'waiting']
const nonEmbeddingStatuses = ['completed', 'error', 'paused', 'unknown']
it.each(embeddingStatuses)('should return true for status "%s"', (status) => {
expect(isSourceEmbedding({ indexing_status: status } as IndexingStatusResponse)).toBe(true)
})
it.each(nonEmbeddingStatuses)('should return false for status "%s"', (status) => {
expect(isSourceEmbedding({ indexing_status: status } as IndexingStatusResponse)).toBe(false)
})
})
describe('getSourcePercent', () => {
it('should calculate correct percentage', () => {
expect(getSourcePercent({ completed_segments: 50, total_segments: 100 } as IndexingStatusResponse)).toBe(50)
})
it('should return 0 when total is 0', () => {
expect(getSourcePercent({ completed_segments: 0, total_segments: 0 } as IndexingStatusResponse)).toBe(0)
})
it('should cap at 100', () => {
expect(getSourcePercent({ completed_segments: 150, total_segments: 100 } as IndexingStatusResponse)).toBe(100)
})
it('should round to nearest integer', () => {
expect(getSourcePercent({ completed_segments: 1, total_segments: 3 } as IndexingStatusResponse)).toBe(33)
})
it('should handle undefined segments as 0', () => {
expect(getSourcePercent({} as IndexingStatusResponse)).toBe(0)
})
})
describe('getFileType', () => {
it('should extract extension from filename', () => {
expect(getFileType('document.pdf')).toBe('pdf')
})
it('should return last extension for multi-dot names', () => {
expect(getFileType('archive.tar.gz')).toBe('gz')
})
it('should default to "txt" for undefined', () => {
expect(getFileType(undefined)).toBe('txt')
})
it('should default to "txt" for empty string', () => {
expect(getFileType('')).toBe('txt')
})
})
describe('createDocumentLookup', () => {
const documents = [
{
id: 'doc-1',
name: 'test.pdf',
data_source_type: 'upload_file',
data_source_info: {
upload_file: { id: 'f1', name: 'test.pdf' },
notion_page_icon: undefined,
},
},
{
id: 'doc-2',
name: 'notion-page',
data_source_type: 'notion_import',
data_source_info: {
upload_file: { id: 'f2', name: '' },
notion_page_icon: 'https://icon.url',
},
},
] as unknown as FullDocumentDetail[]
it('should get document by id', () => {
const lookup = createDocumentLookup(documents)
expect(lookup.getDocument('doc-1')).toBe(documents[0])
})
it('should return undefined for non-existent id', () => {
const lookup = createDocumentLookup(documents)
expect(lookup.getDocument('non-existent')).toBeUndefined()
})
it('should get name by id', () => {
const lookup = createDocumentLookup(documents)
expect(lookup.getName('doc-1')).toBe('test.pdf')
})
it('should get source type by id', () => {
const lookup = createDocumentLookup(documents)
expect(lookup.getSourceType('doc-1')).toBe('upload_file')
})
it('should get notion icon for legacy data source', () => {
const lookup = createDocumentLookup(documents)
expect(lookup.getNotionIcon('doc-2')).toBe('https://icon.url')
})
it('should return undefined notion icon for non-legacy info', () => {
const docs = [{
id: 'doc-3',
data_source_info: { some_other: 'field' },
}] as unknown as FullDocumentDetail[]
const lookup = createDocumentLookup(docs)
expect(lookup.getNotionIcon('doc-3')).toBeUndefined()
})
it('should handle empty documents list', () => {
const lookup = createDocumentLookup([])
expect(lookup.getDocument('any')).toBeUndefined()
expect(lookup.getName('any')).toBeUndefined()
})
})

View File

@@ -0,0 +1,66 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DataSourceType } from '@/models/datasets'
// Mock config to control web crawl feature flags
vi.mock('@/config', () => ({
ENABLE_WEBSITE_FIRECRAWL: true,
ENABLE_WEBSITE_JINAREADER: true,
ENABLE_WEBSITE_WATERCRAWL: false,
}))
// Mock CSS module
vi.mock('../../index.module.css', () => ({
default: {
dataSourceItem: 'ds-item',
active: 'active',
disabled: 'disabled',
datasetIcon: 'icon',
notion: 'notion-icon',
web: 'web-icon',
},
}))
const { default: DataSourceTypeSelector } = await import('./data-source-type-selector')
describe('DataSourceTypeSelector', () => {
const defaultProps = {
currentType: DataSourceType.FILE,
disabled: false,
onChange: vi.fn(),
onClearPreviews: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('should render file, notion, and web options', () => {
render(<DataSourceTypeSelector {...defaultProps} />)
expect(screen.getByText('datasetCreation.stepOne.dataSourceType.file')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepOne.dataSourceType.web')).toBeInTheDocument()
})
it('should render as a 3-column grid', () => {
const { container } = render(<DataSourceTypeSelector {...defaultProps} />)
expect(container.firstElementChild).toHaveClass('grid-cols-3')
})
})
describe('interactions', () => {
it('should call onChange and onClearPreviews on type click', () => {
render(<DataSourceTypeSelector {...defaultProps} />)
fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion'))
expect(defaultProps.onChange).toHaveBeenCalledWith(DataSourceType.NOTION)
expect(defaultProps.onClearPreviews).toHaveBeenCalledWith(DataSourceType.NOTION)
})
it('should not call onChange when disabled', () => {
render(<DataSourceTypeSelector {...defaultProps} disabled />)
fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion'))
expect(defaultProps.onChange).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,48 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import NextStepButton from './next-step-button'
describe('NextStepButton', () => {
const defaultProps = {
disabled: false,
onClick: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render button text', () => {
render(<NextStepButton {...defaultProps} />)
expect(screen.getByText('datasetCreation.stepOne.button')).toBeInTheDocument()
})
it('should render a primary variant button', () => {
render(<NextStepButton {...defaultProps} />)
const btn = screen.getByRole('button')
expect(btn).toBeInTheDocument()
})
it('should call onClick when clicked', () => {
render(<NextStepButton {...defaultProps} />)
fireEvent.click(screen.getByRole('button'))
expect(defaultProps.onClick).toHaveBeenCalledOnce()
})
it('should be disabled when disabled prop is true', () => {
render(<NextStepButton disabled onClick={defaultProps.onClick} />)
expect(screen.getByRole('button')).toBeDisabled()
})
it('should not call onClick when disabled', () => {
render(<NextStepButton disabled onClick={defaultProps.onClick} />)
fireEvent.click(screen.getByRole('button'))
expect(defaultProps.onClick).not.toHaveBeenCalled()
})
it('should render arrow icon', () => {
const { container } = render(<NextStepButton {...defaultProps} />)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,119 @@
import type { NotionPage } from '@/models/common'
import type { CrawlResultItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Mock child components - paths must match source file's imports (relative to source)
vi.mock('../../file-preview', () => ({
default: ({ file, hidePreview }: { file: { name: string }, hidePreview: () => void }) => (
<div data-testid="file-preview">
<span>{file.name}</span>
<button data-testid="close-file" onClick={hidePreview}>close-file</button>
</div>
),
}))
vi.mock('../../notion-page-preview', () => ({
default: ({ currentPage, hidePreview }: { currentPage: { page_name: string }, hidePreview: () => void }) => (
<div data-testid="notion-preview">
<span>{currentPage.page_name}</span>
<button data-testid="close-notion" onClick={hidePreview}>close-notion</button>
</div>
),
}))
vi.mock('../../website/preview', () => ({
default: ({ payload, hidePreview }: { payload: { title: string }, hidePreview: () => void }) => (
<div data-testid="website-preview">
<span>{payload.title}</span>
<button data-testid="close-website" onClick={hidePreview}>close-website</button>
</div>
),
}))
vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({
default: ({ show, onClose, title }: { show: boolean, onClose: () => void, title: string }) => show
? (
<div data-testid="plan-upgrade-modal">
<span>{title}</span>
<button data-testid="close-modal" onClick={onClose}>close-modal</button>
</div>
)
: null,
}))
const { default: PreviewPanel } = await import('./preview-panel')
describe('PreviewPanel', () => {
const defaultProps = {
currentFile: undefined,
currentNotionPage: undefined,
currentWebsite: undefined,
notionCredentialId: 'cred-1',
isShowPlanUpgradeModal: false,
hideFilePreview: vi.fn(),
hideNotionPagePreview: vi.fn(),
hideWebsitePreview: vi.fn(),
hidePlanUpgradeModal: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('should render nothing when no preview is active', () => {
const { container } = render(<PreviewPanel {...defaultProps} />)
expect(container.querySelector('[data-testid]')).toBeNull()
})
it('should render file preview when currentFile is set', () => {
render(<PreviewPanel {...defaultProps} currentFile={{ name: 'test.pdf' } as unknown as File} />)
expect(screen.getByTestId('file-preview')).toBeInTheDocument()
expect(screen.getByText('test.pdf')).toBeInTheDocument()
})
it('should render notion preview when currentNotionPage is set', () => {
render(<PreviewPanel {...defaultProps} currentNotionPage={{ page_name: 'My Page' } as unknown as NotionPage} />)
expect(screen.getByTestId('notion-preview')).toBeInTheDocument()
expect(screen.getByText('My Page')).toBeInTheDocument()
})
it('should render website preview when currentWebsite is set', () => {
render(<PreviewPanel {...defaultProps} currentWebsite={{ title: 'My Site' } as unknown as CrawlResultItem} />)
expect(screen.getByTestId('website-preview')).toBeInTheDocument()
expect(screen.getByText('My Site')).toBeInTheDocument()
})
it('should render plan upgrade modal when isShowPlanUpgradeModal is true', () => {
render(<PreviewPanel {...defaultProps} isShowPlanUpgradeModal />)
expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument()
})
})
describe('interactions', () => {
it('should call hideFilePreview when file preview close clicked', () => {
render(<PreviewPanel {...defaultProps} currentFile={{ name: 'test.pdf' } as unknown as File} />)
fireEvent.click(screen.getByTestId('close-file'))
expect(defaultProps.hideFilePreview).toHaveBeenCalledOnce()
})
it('should call hidePlanUpgradeModal when modal close clicked', () => {
render(<PreviewPanel {...defaultProps} isShowPlanUpgradeModal />)
fireEvent.click(screen.getByTestId('close-modal'))
expect(defaultProps.hidePlanUpgradeModal).toHaveBeenCalledOnce()
})
it('should call hideNotionPagePreview when notion preview close clicked', () => {
render(<PreviewPanel {...defaultProps} currentNotionPage={{ page_name: 'My Page' } as unknown as NotionPage} />)
fireEvent.click(screen.getByTestId('close-notion'))
expect(defaultProps.hideNotionPagePreview).toHaveBeenCalledOnce()
})
it('should call hideWebsitePreview when website preview close clicked', () => {
render(<PreviewPanel {...defaultProps} currentWebsite={{ title: 'My Site' } as unknown as CrawlResultItem} />)
fireEvent.click(screen.getByTestId('close-website'))
expect(defaultProps.hideWebsitePreview).toHaveBeenCalledOnce()
})
})
})

View File

@@ -0,0 +1,60 @@
import type { NotionPage } from '@/models/common'
import type { CrawlResultItem } from '@/models/datasets'
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import usePreviewState from './use-preview-state'
describe('usePreviewState', () => {
it('should initialize with all previews undefined', () => {
const { result } = renderHook(() => usePreviewState())
expect(result.current.currentFile).toBeUndefined()
expect(result.current.currentNotionPage).toBeUndefined()
expect(result.current.currentWebsite).toBeUndefined()
})
it('should show and hide file preview', () => {
const { result } = renderHook(() => usePreviewState())
const file = new File(['content'], 'test.pdf')
act(() => {
result.current.showFilePreview(file)
})
expect(result.current.currentFile).toBe(file)
act(() => {
result.current.hideFilePreview()
})
expect(result.current.currentFile).toBeUndefined()
})
it('should show and hide notion page preview', () => {
const { result } = renderHook(() => usePreviewState())
const page = { page_id: 'p1', page_name: 'Test' } as unknown as NotionPage
act(() => {
result.current.showNotionPagePreview(page)
})
expect(result.current.currentNotionPage).toBe(page)
act(() => {
result.current.hideNotionPagePreview()
})
expect(result.current.currentNotionPage).toBeUndefined()
})
it('should show and hide website preview', () => {
const { result } = renderHook(() => usePreviewState())
const website = { title: 'Example', source_url: 'https://example.com' } as unknown as CrawlResultItem
act(() => {
result.current.showWebsitePreview(website)
})
expect(result.current.currentWebsite).toBe(website)
act(() => {
result.current.hideWebsitePreview()
})
expect(result.current.currentWebsite).toBeUndefined()
})
})

View File

@@ -1,11 +1,9 @@
import type { DataSourceAuth } from '@/app/components/header/account-setting/data-source-page-new/types'
import type { NotionPage } from '@/models/common'
import type { CrawlOptions, CrawlResultItem, DataSet, FileItem } from '@/models/datasets'
import { act, fireEvent, render, renderHook, screen } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { Plan } from '@/app/components/billing/type'
import { DataSourceType } from '@/models/datasets'
import { DataSourceTypeSelector, NextStepButton, PreviewPanel } from './components'
import { usePreviewState } from './hooks'
import StepOne from './index'
// ==========================================
@@ -211,541 +209,14 @@ const defaultProps = {
}
// ==========================================
// usePreviewState Hook Tests
// NOTE: Child component unit tests (usePreviewState, DataSourceTypeSelector,
// NextStepButton, PreviewPanel) have been moved to their own dedicated spec files:
// - ./hooks/use-preview-state.spec.ts
// - ./components/data-source-type-selector.spec.tsx
// - ./components/next-step-button.spec.tsx
// - ./components/preview-panel.spec.tsx
// This file now focuses exclusively on StepOne parent component tests.
// ==========================================
describe('usePreviewState Hook', () => {
// --------------------------------------------------------------------------
// Initial State Tests
// --------------------------------------------------------------------------
describe('Initial State', () => {
it('should initialize with all preview states undefined', () => {
// Arrange & Act
const { result } = renderHook(() => usePreviewState())
// Assert
expect(result.current.currentFile).toBeUndefined()
expect(result.current.currentNotionPage).toBeUndefined()
expect(result.current.currentWebsite).toBeUndefined()
})
})
// --------------------------------------------------------------------------
// File Preview Tests
// --------------------------------------------------------------------------
describe('File Preview', () => {
it('should show file preview when showFilePreview is called', () => {
// Arrange
const { result } = renderHook(() => usePreviewState())
const mockFile = new File(['test'], 'test.txt')
// Act
act(() => {
result.current.showFilePreview(mockFile)
})
// Assert
expect(result.current.currentFile).toBe(mockFile)
})
it('should hide file preview when hideFilePreview is called', () => {
// Arrange
const { result } = renderHook(() => usePreviewState())
const mockFile = new File(['test'], 'test.txt')
act(() => {
result.current.showFilePreview(mockFile)
})
// Act
act(() => {
result.current.hideFilePreview()
})
// Assert
expect(result.current.currentFile).toBeUndefined()
})
})
// --------------------------------------------------------------------------
// Notion Page Preview Tests
// --------------------------------------------------------------------------
describe('Notion Page Preview', () => {
it('should show notion page preview when showNotionPagePreview is called', () => {
// Arrange
const { result } = renderHook(() => usePreviewState())
const mockPage = createMockNotionPage()
// Act
act(() => {
result.current.showNotionPagePreview(mockPage)
})
// Assert
expect(result.current.currentNotionPage).toBe(mockPage)
})
it('should hide notion page preview when hideNotionPagePreview is called', () => {
// Arrange
const { result } = renderHook(() => usePreviewState())
const mockPage = createMockNotionPage()
act(() => {
result.current.showNotionPagePreview(mockPage)
})
// Act
act(() => {
result.current.hideNotionPagePreview()
})
// Assert
expect(result.current.currentNotionPage).toBeUndefined()
})
})
// --------------------------------------------------------------------------
// Website Preview Tests
// --------------------------------------------------------------------------
describe('Website Preview', () => {
it('should show website preview when showWebsitePreview is called', () => {
// Arrange
const { result } = renderHook(() => usePreviewState())
const mockWebsite = createMockCrawlResult()
// Act
act(() => {
result.current.showWebsitePreview(mockWebsite)
})
// Assert
expect(result.current.currentWebsite).toBe(mockWebsite)
})
it('should hide website preview when hideWebsitePreview is called', () => {
// Arrange
const { result } = renderHook(() => usePreviewState())
const mockWebsite = createMockCrawlResult()
act(() => {
result.current.showWebsitePreview(mockWebsite)
})
// Act
act(() => {
result.current.hideWebsitePreview()
})
// Assert
expect(result.current.currentWebsite).toBeUndefined()
})
})
// --------------------------------------------------------------------------
// Callback Stability Tests (Memoization)
// --------------------------------------------------------------------------
describe('Callback Stability', () => {
it('should maintain stable showFilePreview callback reference', () => {
// Arrange
const { result, rerender } = renderHook(() => usePreviewState())
const initialCallback = result.current.showFilePreview
// Act
rerender()
// Assert
expect(result.current.showFilePreview).toBe(initialCallback)
})
it('should maintain stable hideFilePreview callback reference', () => {
// Arrange
const { result, rerender } = renderHook(() => usePreviewState())
const initialCallback = result.current.hideFilePreview
// Act
rerender()
// Assert
expect(result.current.hideFilePreview).toBe(initialCallback)
})
it('should maintain stable showNotionPagePreview callback reference', () => {
// Arrange
const { result, rerender } = renderHook(() => usePreviewState())
const initialCallback = result.current.showNotionPagePreview
// Act
rerender()
// Assert
expect(result.current.showNotionPagePreview).toBe(initialCallback)
})
it('should maintain stable hideNotionPagePreview callback reference', () => {
// Arrange
const { result, rerender } = renderHook(() => usePreviewState())
const initialCallback = result.current.hideNotionPagePreview
// Act
rerender()
// Assert
expect(result.current.hideNotionPagePreview).toBe(initialCallback)
})
it('should maintain stable showWebsitePreview callback reference', () => {
// Arrange
const { result, rerender } = renderHook(() => usePreviewState())
const initialCallback = result.current.showWebsitePreview
// Act
rerender()
// Assert
expect(result.current.showWebsitePreview).toBe(initialCallback)
})
it('should maintain stable hideWebsitePreview callback reference', () => {
// Arrange
const { result, rerender } = renderHook(() => usePreviewState())
const initialCallback = result.current.hideWebsitePreview
// Act
rerender()
// Assert
expect(result.current.hideWebsitePreview).toBe(initialCallback)
})
})
})
// ==========================================
// DataSourceTypeSelector Component Tests
// ==========================================
describe('DataSourceTypeSelector', () => {
const defaultSelectorProps = {
currentType: DataSourceType.FILE,
disabled: false,
onChange: vi.fn(),
onClearPreviews: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render all data source options when web is enabled', () => {
// Arrange & Act
render(<DataSourceTypeSelector {...defaultSelectorProps} />)
// Assert
expect(screen.getByText('datasetCreation.stepOne.dataSourceType.file')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepOne.dataSourceType.web')).toBeInTheDocument()
})
it('should highlight active type', () => {
// Arrange & Act
const { container } = render(
<DataSourceTypeSelector {...defaultSelectorProps} currentType={DataSourceType.NOTION} />,
)
// Assert - The active item should have the active class
const items = container.querySelectorAll('[class*="dataSourceItem"]')
expect(items.length).toBeGreaterThan(0)
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onChange when a type is clicked', () => {
// Arrange
const onChange = vi.fn()
render(<DataSourceTypeSelector {...defaultSelectorProps} onChange={onChange} />)
// Act
fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion'))
// Assert
expect(onChange).toHaveBeenCalledWith(DataSourceType.NOTION)
})
it('should call onClearPreviews when a type is clicked', () => {
// Arrange
const onClearPreviews = vi.fn()
render(<DataSourceTypeSelector {...defaultSelectorProps} onClearPreviews={onClearPreviews} />)
// Act
fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.web'))
// Assert
expect(onClearPreviews).toHaveBeenCalledWith(DataSourceType.WEB)
})
it('should not call onChange when disabled', () => {
// Arrange
const onChange = vi.fn()
render(<DataSourceTypeSelector {...defaultSelectorProps} disabled onChange={onChange} />)
// Act
fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion'))
// Assert
expect(onChange).not.toHaveBeenCalled()
})
it('should not call onClearPreviews when disabled', () => {
// Arrange
const onClearPreviews = vi.fn()
render(<DataSourceTypeSelector {...defaultSelectorProps} disabled onClearPreviews={onClearPreviews} />)
// Act
fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion'))
// Assert
expect(onClearPreviews).not.toHaveBeenCalled()
})
})
})
// ==========================================
// NextStepButton Component Tests
// ==========================================
describe('NextStepButton', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render with correct label', () => {
// Arrange & Act
render(<NextStepButton disabled={false} onClick={vi.fn()} />)
// Assert
expect(screen.getByText('datasetCreation.stepOne.button')).toBeInTheDocument()
})
it('should render with arrow icon', () => {
// Arrange & Act
const { container } = render(<NextStepButton disabled={false} onClick={vi.fn()} />)
// Assert
const svgIcon = container.querySelector('svg')
expect(svgIcon).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Props Tests
// --------------------------------------------------------------------------
describe('Props', () => {
it('should be disabled when disabled prop is true', () => {
// Arrange & Act
render(<NextStepButton disabled onClick={vi.fn()} />)
// Assert
expect(screen.getByRole('button')).toBeDisabled()
})
it('should be enabled when disabled prop is false', () => {
// Arrange & Act
render(<NextStepButton disabled={false} onClick={vi.fn()} />)
// Assert
expect(screen.getByRole('button')).not.toBeDisabled()
})
it('should call onClick when clicked and not disabled', () => {
// Arrange
const onClick = vi.fn()
render(<NextStepButton disabled={false} onClick={onClick} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
expect(onClick).toHaveBeenCalledTimes(1)
})
it('should not call onClick when clicked and disabled', () => {
// Arrange
const onClick = vi.fn()
render(<NextStepButton disabled onClick={onClick} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
expect(onClick).not.toHaveBeenCalled()
})
})
})
// ==========================================
// PreviewPanel Component Tests
// ==========================================
describe('PreviewPanel', () => {
const defaultPreviewProps = {
currentFile: undefined as File | undefined,
currentNotionPage: undefined as NotionPage | undefined,
currentWebsite: undefined as CrawlResultItem | undefined,
notionCredentialId: 'cred-1',
isShowPlanUpgradeModal: false,
hideFilePreview: vi.fn(),
hideNotionPagePreview: vi.fn(),
hideWebsitePreview: vi.fn(),
hidePlanUpgradeModal: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Conditional Rendering Tests
// --------------------------------------------------------------------------
describe('Conditional Rendering', () => {
it('should not render FilePreview when currentFile is undefined', () => {
// Arrange & Act
render(<PreviewPanel {...defaultPreviewProps} />)
// Assert
expect(screen.queryByTestId('file-preview')).not.toBeInTheDocument()
})
it('should render FilePreview when currentFile is defined', () => {
// Arrange
const file = new File(['test'], 'test.txt')
// Act
render(<PreviewPanel {...defaultPreviewProps} currentFile={file} />)
// Assert
expect(screen.getByTestId('file-preview')).toBeInTheDocument()
})
it('should not render NotionPagePreview when currentNotionPage is undefined', () => {
// Arrange & Act
render(<PreviewPanel {...defaultPreviewProps} />)
// Assert
expect(screen.queryByTestId('notion-page-preview')).not.toBeInTheDocument()
})
it('should render NotionPagePreview when currentNotionPage is defined', () => {
// Arrange
const page = createMockNotionPage()
// Act
render(<PreviewPanel {...defaultPreviewProps} currentNotionPage={page} />)
// Assert
expect(screen.getByTestId('notion-page-preview')).toBeInTheDocument()
})
it('should not render WebsitePreview when currentWebsite is undefined', () => {
// Arrange & Act
render(<PreviewPanel {...defaultPreviewProps} />)
// Assert - pagePreview is the title shown in WebsitePreview
expect(screen.queryByText('datasetCreation.stepOne.pagePreview')).not.toBeInTheDocument()
})
it('should render WebsitePreview when currentWebsite is defined', () => {
// Arrange
const website = createMockCrawlResult()
// Act
render(<PreviewPanel {...defaultPreviewProps} currentWebsite={website} />)
// Assert - Check for the preview title and source URL
expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument()
expect(screen.getByText(website.source_url)).toBeInTheDocument()
})
it('should not render PlanUpgradeModal when isShowPlanUpgradeModal is false', () => {
// Arrange & Act
render(<PreviewPanel {...defaultPreviewProps} isShowPlanUpgradeModal={false} />)
// Assert
expect(screen.queryByTestId('plan-upgrade-modal')).not.toBeInTheDocument()
})
it('should render PlanUpgradeModal when isShowPlanUpgradeModal is true', () => {
// Arrange & Act
render(<PreviewPanel {...defaultPreviewProps} isShowPlanUpgradeModal />)
// Assert
expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Event Handler Tests
// --------------------------------------------------------------------------
describe('Event Handlers', () => {
it('should call hideFilePreview when file preview close is clicked', () => {
// Arrange
const hideFilePreview = vi.fn()
const file = new File(['test'], 'test.txt')
render(<PreviewPanel {...defaultPreviewProps} currentFile={file} hideFilePreview={hideFilePreview} />)
// Act
fireEvent.click(screen.getByTestId('hide-file-preview'))
// Assert
expect(hideFilePreview).toHaveBeenCalledTimes(1)
})
it('should call hideNotionPagePreview when notion preview close is clicked', () => {
// Arrange
const hideNotionPagePreview = vi.fn()
const page = createMockNotionPage()
render(<PreviewPanel {...defaultPreviewProps} currentNotionPage={page} hideNotionPagePreview={hideNotionPagePreview} />)
// Act
fireEvent.click(screen.getByTestId('hide-notion-preview'))
// Assert
expect(hideNotionPagePreview).toHaveBeenCalledTimes(1)
})
it('should call hideWebsitePreview when website preview close is clicked', () => {
// Arrange
const hideWebsitePreview = vi.fn()
const website = createMockCrawlResult()
const { container } = render(<PreviewPanel {...defaultPreviewProps} currentWebsite={website} hideWebsitePreview={hideWebsitePreview} />)
// Act - Find the close button (div with cursor-pointer class containing the XMarkIcon)
const closeButton = container.querySelector('.cursor-pointer')
expect(closeButton).toBeInTheDocument()
fireEvent.click(closeButton!)
// Assert
expect(hideWebsitePreview).toHaveBeenCalledTimes(1)
})
it('should call hidePlanUpgradeModal when modal close is clicked', () => {
// Arrange
const hidePlanUpgradeModal = vi.fn()
render(<PreviewPanel {...defaultPreviewProps} isShowPlanUpgradeModal hidePlanUpgradeModal={hidePlanUpgradeModal} />)
// Act
fireEvent.click(screen.getByTestId('close-upgrade-modal'))
// Assert
expect(hidePlanUpgradeModal).toHaveBeenCalledTimes(1)
})
})
})
// ==========================================
// StepOne Component Tests

View File

@@ -0,0 +1,107 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import UpgradeCard from './upgrade-card'
const mockSetShowPricingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowPricingModal: mockSetShowPricingModal,
}),
}))
vi.mock('@/app/components/billing/upgrade-btn', () => ({
default: ({ onClick, className }: { onClick?: () => void, className?: string }) => (
<button type="button" className={className} onClick={onClick} data-testid="upgrade-btn">
upgrade
</button>
),
}))
describe('UpgradeCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
render(<UpgradeCard />)
// Assert - title and description i18n keys are rendered
expect(screen.getByText(/uploadMultipleFiles\.title/i)).toBeInTheDocument()
})
it('should render the upgrade title text', () => {
// Arrange & Act
render(<UpgradeCard />)
// Assert
expect(screen.getByText(/uploadMultipleFiles\.title/i)).toBeInTheDocument()
})
it('should render the upgrade description text', () => {
// Arrange & Act
render(<UpgradeCard />)
// Assert
expect(screen.getByText(/uploadMultipleFiles\.description/i)).toBeInTheDocument()
})
it('should render the upgrade button', () => {
// Arrange & Act
render(<UpgradeCard />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call setShowPricingModal when upgrade button is clicked', () => {
// Arrange
render(<UpgradeCard />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
it('should not call setShowPricingModal without user interaction', () => {
// Arrange & Act
render(<UpgradeCard />)
// Assert
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
})
it('should call setShowPricingModal on each button click', () => {
// Arrange
render(<UpgradeCard />)
// Act
const button = screen.getByRole('button')
fireEvent.click(button)
fireEvent.click(button)
// Assert
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(2)
})
})
describe('Memoization', () => {
it('should maintain rendering after rerender with same props', () => {
// Arrange
const { rerender } = render(<UpgradeCard />)
// Act
rerender(<UpgradeCard />)
// Assert
expect(screen.getByText(/uploadMultipleFiles\.title/i)).toBeInTheDocument()
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,174 @@
import type { PreProcessingRule } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import { GeneralChunkingOptions } from './general-chunking-options'
vi.mock('next/image', () => ({
default: ({ alt, ...props }: { alt?: string, src?: string, width?: number, height?: number }) => (
<img alt={alt} {...props} />
),
}))
vi.mock('@/app/components/datasets/settings/summary-index-setting', () => ({
default: ({ onSummaryIndexSettingChange }: { onSummaryIndexSettingChange?: (val: Record<string, unknown>) => void }) => (
<div data-testid="summary-index-setting">
<button data-testid="summary-toggle" onClick={() => onSummaryIndexSettingChange?.({ enable: true })}>Toggle</button>
</div>
),
}))
vi.mock('@/config', () => ({
IS_CE_EDITION: true,
}))
const ns = 'datasetCreation'
const createRules = (): PreProcessingRule[] => [
{ id: 'remove_extra_spaces', enabled: true },
{ id: 'remove_urls_emails', enabled: false },
]
const defaultProps = {
segmentIdentifier: '\\n',
maxChunkLength: 500,
overlap: 50,
rules: createRules(),
currentDocForm: ChunkingMode.text,
docLanguage: 'English',
isActive: true,
isInUpload: false,
isNotUploadInEmptyDataset: false,
hasCurrentDatasetDocForm: false,
onSegmentIdentifierChange: vi.fn(),
onMaxChunkLengthChange: vi.fn(),
onOverlapChange: vi.fn(),
onRuleToggle: vi.fn(),
onDocFormChange: vi.fn(),
onDocLanguageChange: vi.fn(),
onPreview: vi.fn(),
onReset: vi.fn(),
locale: 'en',
}
describe('GeneralChunkingOptions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render general chunking title', () => {
render(<GeneralChunkingOptions {...defaultProps} />)
expect(screen.getByText(`${ns}.stepTwo.general`)).toBeInTheDocument()
})
it('should render delimiter, max length and overlap inputs when active', () => {
render(<GeneralChunkingOptions {...defaultProps} />)
expect(screen.getByText(`${ns}.stepTwo.separator`)).toBeInTheDocument()
expect(screen.getByText(`${ns}.stepTwo.maxLength`)).toBeInTheDocument()
expect(screen.getAllByText(new RegExp(`${ns}.stepTwo.overlap`)).length).toBeGreaterThan(0)
})
it('should render preprocessing rules as checkboxes', () => {
render(<GeneralChunkingOptions {...defaultProps} />)
expect(screen.getByText(`${ns}.stepTwo.removeExtraSpaces`)).toBeInTheDocument()
expect(screen.getByText(`${ns}.stepTwo.removeUrlEmails`)).toBeInTheDocument()
})
it('should render preview and reset buttons when active', () => {
render(<GeneralChunkingOptions {...defaultProps} />)
expect(screen.getByText(`${ns}.stepTwo.previewChunk`)).toBeInTheDocument()
expect(screen.getByText(`${ns}.stepTwo.reset`)).toBeInTheDocument()
})
it('should not render body when not active', () => {
render(<GeneralChunkingOptions {...defaultProps} isActive={false} />)
expect(screen.queryByText(`${ns}.stepTwo.separator`)).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onPreview when preview button clicked', () => {
const onPreview = vi.fn()
render(<GeneralChunkingOptions {...defaultProps} onPreview={onPreview} />)
fireEvent.click(screen.getByText(`${ns}.stepTwo.previewChunk`))
expect(onPreview).toHaveBeenCalledOnce()
})
it('should call onReset when reset button clicked', () => {
const onReset = vi.fn()
render(<GeneralChunkingOptions {...defaultProps} onReset={onReset} />)
fireEvent.click(screen.getByText(`${ns}.stepTwo.reset`))
expect(onReset).toHaveBeenCalledOnce()
})
it('should call onRuleToggle when rule clicked', () => {
const onRuleToggle = vi.fn()
render(<GeneralChunkingOptions {...defaultProps} onRuleToggle={onRuleToggle} />)
fireEvent.click(screen.getByText(`${ns}.stepTwo.removeUrlEmails`))
expect(onRuleToggle).toHaveBeenCalledWith('remove_urls_emails')
})
it('should call onDocFormChange with text mode when card switched', () => {
const onDocFormChange = vi.fn()
render(<GeneralChunkingOptions {...defaultProps} isActive={false} onDocFormChange={onDocFormChange} />)
// OptionCard fires onSwitched which calls onDocFormChange(ChunkingMode.text)
// Since isActive=false, clicking the card triggers the switch
const titleEl = screen.getByText(`${ns}.stepTwo.general`)
fireEvent.click(titleEl.closest('[class*="rounded-xl"]')!)
expect(onDocFormChange).toHaveBeenCalledWith(ChunkingMode.text)
})
})
describe('QA Mode (CE Edition)', () => {
it('should render QA language checkbox', () => {
render(<GeneralChunkingOptions {...defaultProps} />)
expect(screen.getByText(`${ns}.stepTwo.useQALanguage`)).toBeInTheDocument()
})
it('should toggle QA mode when checkbox clicked', () => {
const onDocFormChange = vi.fn()
render(<GeneralChunkingOptions {...defaultProps} onDocFormChange={onDocFormChange} />)
fireEvent.click(screen.getByText(`${ns}.stepTwo.useQALanguage`))
expect(onDocFormChange).toHaveBeenCalledWith(ChunkingMode.qa)
})
it('should toggle back to text mode from QA mode', () => {
const onDocFormChange = vi.fn()
render(<GeneralChunkingOptions {...defaultProps} currentDocForm={ChunkingMode.qa} onDocFormChange={onDocFormChange} />)
fireEvent.click(screen.getByText(`${ns}.stepTwo.useQALanguage`))
expect(onDocFormChange).toHaveBeenCalledWith(ChunkingMode.text)
})
it('should not toggle QA mode when hasCurrentDatasetDocForm is true', () => {
const onDocFormChange = vi.fn()
render(<GeneralChunkingOptions {...defaultProps} hasCurrentDatasetDocForm onDocFormChange={onDocFormChange} />)
fireEvent.click(screen.getByText(`${ns}.stepTwo.useQALanguage`))
expect(onDocFormChange).not.toHaveBeenCalled()
})
it('should show QA warning tip when in QA mode', () => {
render(<GeneralChunkingOptions {...defaultProps} currentDocForm={ChunkingMode.qa} />)
expect(screen.getAllByText(`${ns}.stepTwo.QATip`).length).toBeGreaterThan(0)
})
})
describe('Summary Index Setting', () => {
it('should render SummaryIndexSetting when showSummaryIndexSetting is true', () => {
render(<GeneralChunkingOptions {...defaultProps} showSummaryIndexSetting />)
expect(screen.getByTestId('summary-index-setting')).toBeInTheDocument()
})
it('should not render SummaryIndexSetting when showSummaryIndexSetting is false', () => {
render(<GeneralChunkingOptions {...defaultProps} showSummaryIndexSetting={false} />)
expect(screen.queryByTestId('summary-index-setting')).not.toBeInTheDocument()
})
it('should call onSummaryIndexSettingChange', () => {
const onSummaryIndexSettingChange = vi.fn()
render(<GeneralChunkingOptions {...defaultProps} showSummaryIndexSetting onSummaryIndexSettingChange={onSummaryIndexSettingChange} />)
fireEvent.click(screen.getByTestId('summary-toggle'))
expect(onSummaryIndexSettingChange).toHaveBeenCalledWith({ enable: true })
})
})
})

View File

@@ -0,0 +1,219 @@
import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { RetrievalConfig } from '@/types/app'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import { IndexingType } from '../hooks'
import { IndexingModeSection } from './indexing-mode-section'
vi.mock('next/image', () => ({
default: ({ alt, ...props }: { alt?: string, src?: string, width?: number, height?: number }) => (
<img alt={alt} {...props} />
),
}))
vi.mock('next/link', () => ({
default: ({ children, href, ...props }: { children?: React.ReactNode, href?: string, className?: string }) => <a href={href} {...props}>{children}</a>,
}))
// Mock external domain components
vi.mock('@/app/components/datasets/common/retrieval-method-config', () => ({
default: ({ onChange, disabled }: { value?: RetrievalConfig, onChange?: (val: Record<string, unknown>) => void, disabled?: boolean }) => (
<div data-testid="retrieval-method-config" data-disabled={disabled}>
<button onClick={() => onChange?.({ search_method: 'updated' })}>Change Retrieval</button>
</div>
),
}))
vi.mock('@/app/components/datasets/common/economical-retrieval-method-config', () => ({
default: ({ disabled }: { value?: RetrievalConfig, onChange?: (val: Record<string, unknown>) => void, disabled?: boolean }) => (
<div data-testid="economical-retrieval-config" data-disabled={disabled}>
Economical Config
</div>
),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
default: ({ onSelect, readonly }: { onSelect?: (val: Record<string, string>) => void, readonly?: boolean }) => (
<div data-testid="model-selector" data-readonly={readonly}>
<button onClick={() => onSelect?.({ provider: 'openai', model: 'text-embedding-3-small' })}>Select Model</button>
</div>
),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
}))
const ns = 'datasetCreation'
const createDefaultModel = (overrides?: Partial<DefaultModel>): DefaultModel => ({
provider: 'openai',
model: 'text-embedding-ada-002',
...overrides,
})
const createRetrievalConfig = (): RetrievalConfig => ({
search_method: 'semantic_search' as RetrievalConfig['search_method'],
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0,
})
const defaultProps = {
indexType: IndexingType.QUALIFIED,
hasSetIndexType: false,
docForm: ChunkingMode.text,
embeddingModel: createDefaultModel(),
embeddingModelList: [],
retrievalConfig: createRetrievalConfig(),
showMultiModalTip: false,
isModelAndRetrievalConfigDisabled: false,
isQAConfirmDialogOpen: false,
onIndexTypeChange: vi.fn(),
onEmbeddingModelChange: vi.fn(),
onRetrievalConfigChange: vi.fn(),
onQAConfirmDialogClose: vi.fn(),
onQAConfirmDialogConfirm: vi.fn(),
}
describe('IndexingModeSection', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render index mode title', () => {
render(<IndexingModeSection {...defaultProps} />)
expect(screen.getByText(`${ns}.stepTwo.indexMode`)).toBeInTheDocument()
})
it('should render qualified option when not locked to economical', () => {
render(<IndexingModeSection {...defaultProps} />)
expect(screen.getByText(`${ns}.stepTwo.qualified`)).toBeInTheDocument()
})
it('should render economical option when not locked to qualified', () => {
render(<IndexingModeSection {...defaultProps} />)
expect(screen.getByText(`${ns}.stepTwo.economical`)).toBeInTheDocument()
})
it('should only show qualified option when hasSetIndexType and type is qualified', () => {
render(<IndexingModeSection {...defaultProps} hasSetIndexType indexType={IndexingType.QUALIFIED} />)
expect(screen.getByText(`${ns}.stepTwo.qualified`)).toBeInTheDocument()
expect(screen.queryByText(`${ns}.stepTwo.economical`)).not.toBeInTheDocument()
})
it('should only show economical option when hasSetIndexType and type is economical', () => {
render(<IndexingModeSection {...defaultProps} hasSetIndexType indexType={IndexingType.ECONOMICAL} />)
expect(screen.getByText(`${ns}.stepTwo.economical`)).toBeInTheDocument()
expect(screen.queryByText(`${ns}.stepTwo.qualified`)).not.toBeInTheDocument()
})
})
describe('Embedding Model', () => {
it('should show model selector when indexType is qualified', () => {
render(<IndexingModeSection {...defaultProps} indexType={IndexingType.QUALIFIED} />)
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
})
it('should not show model selector when indexType is economical', () => {
render(<IndexingModeSection {...defaultProps} indexType={IndexingType.ECONOMICAL} />)
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
})
it('should mark model selector as readonly when disabled', () => {
render(<IndexingModeSection {...defaultProps} isModelAndRetrievalConfigDisabled />)
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-readonly', 'true')
})
it('should call onEmbeddingModelChange when model selected', () => {
const onEmbeddingModelChange = vi.fn()
render(<IndexingModeSection {...defaultProps} onEmbeddingModelChange={onEmbeddingModelChange} />)
fireEvent.click(screen.getByText('Select Model'))
expect(onEmbeddingModelChange).toHaveBeenCalledWith({ provider: 'openai', model: 'text-embedding-3-small' })
})
})
describe('Retrieval Config', () => {
it('should show RetrievalMethodConfig when qualified', () => {
render(<IndexingModeSection {...defaultProps} indexType={IndexingType.QUALIFIED} />)
expect(screen.getByTestId('retrieval-method-config')).toBeInTheDocument()
})
it('should show EconomicalRetrievalMethodConfig when economical', () => {
render(<IndexingModeSection {...defaultProps} indexType={IndexingType.ECONOMICAL} />)
expect(screen.getByTestId('economical-retrieval-config')).toBeInTheDocument()
})
it('should call onRetrievalConfigChange from qualified config', () => {
const onRetrievalConfigChange = vi.fn()
render(<IndexingModeSection {...defaultProps} onRetrievalConfigChange={onRetrievalConfigChange} />)
fireEvent.click(screen.getByText('Change Retrieval'))
expect(onRetrievalConfigChange).toHaveBeenCalledWith({ search_method: 'updated' })
})
})
describe('Index Type Switching', () => {
it('should call onIndexTypeChange when switching to qualified', () => {
const onIndexTypeChange = vi.fn()
render(<IndexingModeSection {...defaultProps} indexType={IndexingType.ECONOMICAL} onIndexTypeChange={onIndexTypeChange} />)
const qualifiedCard = screen.getByText(`${ns}.stepTwo.qualified`).closest('[class*="rounded-xl"]')!
fireEvent.click(qualifiedCard)
expect(onIndexTypeChange).toHaveBeenCalledWith(IndexingType.QUALIFIED)
})
it('should disable economical when docForm is QA', () => {
render(<IndexingModeSection {...defaultProps} docForm={ChunkingMode.qa} />)
// The economical option card should have disabled styling
const economicalText = screen.getByText(`${ns}.stepTwo.economical`)
const card = economicalText.closest('[class*="rounded-xl"]')
expect(card).toHaveClass('pointer-events-none')
})
})
describe('High Quality Tip', () => {
it('should show high quality tip when qualified is selected and not locked', () => {
render(<IndexingModeSection {...defaultProps} indexType={IndexingType.QUALIFIED} hasSetIndexType={false} />)
expect(screen.getByText(`${ns}.stepTwo.highQualityTip`)).toBeInTheDocument()
})
it('should not show high quality tip when index type is locked', () => {
render(<IndexingModeSection {...defaultProps} indexType={IndexingType.QUALIFIED} hasSetIndexType />)
expect(screen.queryByText(`${ns}.stepTwo.highQualityTip`)).not.toBeInTheDocument()
})
})
describe('QA Confirm Dialog', () => {
it('should call onQAConfirmDialogClose when cancel clicked', () => {
const onClose = vi.fn()
render(<IndexingModeSection {...defaultProps} isQAConfirmDialogOpen onQAConfirmDialogClose={onClose} />)
const cancelBtns = screen.getAllByText(`${ns}.stepTwo.cancel`)
fireEvent.click(cancelBtns[0])
expect(onClose).toHaveBeenCalled()
})
it('should call onQAConfirmDialogConfirm when confirm clicked', () => {
const onConfirm = vi.fn()
render(<IndexingModeSection {...defaultProps} isQAConfirmDialogOpen onQAConfirmDialogConfirm={onConfirm} />)
fireEvent.click(screen.getByText(`${ns}.stepTwo.switch`))
expect(onConfirm).toHaveBeenCalled()
})
})
describe('Dataset Settings Link', () => {
it('should show settings link when economical and hasSetIndexType', () => {
render(<IndexingModeSection {...defaultProps} hasSetIndexType indexType={IndexingType.ECONOMICAL} datasetId="ds-123" />)
expect(screen.getByText(`${ns}.stepTwo.datasetSettingLink`)).toBeInTheDocument()
expect(screen.getByText(`${ns}.stepTwo.datasetSettingLink`).closest('a')).toHaveAttribute('href', '/datasets/ds-123/settings')
})
it('should show settings link under model selector when disabled', () => {
render(<IndexingModeSection {...defaultProps} isModelAndRetrievalConfigDisabled datasetId="ds-456" />)
const links = screen.getAllByText(`${ns}.stepTwo.datasetSettingLink`)
expect(links.length).toBeGreaterThan(0)
})
})
})

View File

@@ -0,0 +1,92 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DelimiterInput, MaxLengthInput, OverlapInput } from './inputs'
// i18n mock returns namespaced keys like "datasetCreation.stepTwo.separator"
const ns = 'datasetCreation'
describe('DelimiterInput', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render separator label', () => {
render(<DelimiterInput />)
expect(screen.getByText(`${ns}.stepTwo.separator`)).toBeInTheDocument()
})
it('should render text input with placeholder', () => {
render(<DelimiterInput />)
const input = screen.getByPlaceholderText(`${ns}.stepTwo.separatorPlaceholder`)
expect(input).toBeInTheDocument()
expect(input).toHaveAttribute('type', 'text')
})
it('should pass through value and onChange props', () => {
const onChange = vi.fn()
render(<DelimiterInput value="test-val" onChange={onChange} />)
expect(screen.getByDisplayValue('test-val')).toBeInTheDocument()
})
it('should render tooltip content', () => {
render(<DelimiterInput />)
// Tooltip triggers render; component mounts without error
expect(screen.getByText(`${ns}.stepTwo.separator`)).toBeInTheDocument()
})
})
describe('MaxLengthInput', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render max length label', () => {
render(<MaxLengthInput onChange={vi.fn()} />)
expect(screen.getByText(`${ns}.stepTwo.maxLength`)).toBeInTheDocument()
})
it('should render number input', () => {
render(<MaxLengthInput onChange={vi.fn()} />)
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
})
it('should accept value prop', () => {
render(<MaxLengthInput value={500} onChange={vi.fn()} />)
expect(screen.getByDisplayValue('500')).toBeInTheDocument()
})
it('should have min of 1', () => {
render(<MaxLengthInput onChange={vi.fn()} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveAttribute('min', '1')
})
})
describe('OverlapInput', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render overlap label', () => {
render(<OverlapInput onChange={vi.fn()} />)
expect(screen.getAllByText(new RegExp(`${ns}.stepTwo.overlap`)).length).toBeGreaterThan(0)
})
it('should render number input', () => {
render(<OverlapInput onChange={vi.fn()} />)
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
})
it('should accept value prop', () => {
render(<OverlapInput value={50} onChange={vi.fn()} />)
expect(screen.getByDisplayValue('50')).toBeInTheDocument()
})
it('should have min of 1', () => {
render(<OverlapInput onChange={vi.fn()} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveAttribute('min', '1')
})
})

View File

@@ -0,0 +1,159 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { OptionCard, OptionCardHeader } from './option-card'
vi.mock('next/image', () => ({
default: ({ src, alt, ...props }: { src?: string, alt?: string, width?: number, height?: number }) => (
<img src={src} alt={alt} {...props} />
),
}))
describe('OptionCardHeader', () => {
const defaultProps = {
icon: <span data-testid="icon">icon</span>,
title: <span>Test Title</span>,
description: 'Test description',
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render icon, title and description', () => {
render(<OptionCardHeader {...defaultProps} />)
expect(screen.getByTestId('icon')).toBeInTheDocument()
expect(screen.getByText('Test Title')).toBeInTheDocument()
expect(screen.getByText('Test description')).toBeInTheDocument()
})
it('should show effect image when active and effectImg provided', () => {
const { container } = render(
<OptionCardHeader {...defaultProps} isActive effectImg="/effect.png" />,
)
const img = container.querySelector('img')
expect(img).toBeInTheDocument()
})
it('should not show effect image when not active', () => {
const { container } = render(
<OptionCardHeader {...defaultProps} isActive={false} effectImg="/effect.png" />,
)
expect(container.querySelector('img')).not.toBeInTheDocument()
})
it('should apply cursor-pointer when not disabled', () => {
const { container } = render(<OptionCardHeader {...defaultProps} />)
expect(container.firstChild).toHaveClass('cursor-pointer')
})
it('should not apply cursor-pointer when disabled', () => {
const { container } = render(<OptionCardHeader {...defaultProps} disabled />)
expect(container.firstChild).not.toHaveClass('cursor-pointer')
})
it('should apply activeClassName when active', () => {
const { container } = render(
<OptionCardHeader {...defaultProps} isActive activeClassName="custom-active" />,
)
expect(container.firstChild).toHaveClass('custom-active')
})
it('should not apply activeClassName when not active', () => {
const { container } = render(
<OptionCardHeader {...defaultProps} isActive={false} activeClassName="custom-active" />,
)
expect(container.firstChild).not.toHaveClass('custom-active')
})
})
describe('OptionCard', () => {
const defaultProps = {
icon: <span data-testid="icon">icon</span>,
title: <span>Card Title</span> as React.ReactNode,
description: 'Card description',
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render header content', () => {
render(<OptionCard {...defaultProps} />)
expect(screen.getByText('Card Title')).toBeInTheDocument()
expect(screen.getByText('Card description')).toBeInTheDocument()
})
it('should call onSwitched when clicked while not active and not disabled', () => {
const onSwitched = vi.fn()
const { container } = render(
<OptionCard {...defaultProps} isActive={false} onSwitched={onSwitched} />,
)
fireEvent.click(container.firstChild!)
expect(onSwitched).toHaveBeenCalledOnce()
})
it('should not call onSwitched when already active', () => {
const onSwitched = vi.fn()
const { container } = render(
<OptionCard {...defaultProps} isActive onSwitched={onSwitched} />,
)
fireEvent.click(container.firstChild!)
expect(onSwitched).not.toHaveBeenCalled()
})
it('should not call onSwitched when disabled', () => {
const onSwitched = vi.fn()
const { container } = render(
<OptionCard {...defaultProps} disabled onSwitched={onSwitched} />,
)
fireEvent.click(container.firstChild!)
expect(onSwitched).not.toHaveBeenCalled()
})
it('should show children and actions when active', () => {
render(
<OptionCard {...defaultProps} isActive actions={<button>Action</button>}>
<div>Body Content</div>
</OptionCard>,
)
expect(screen.getByText('Body Content')).toBeInTheDocument()
expect(screen.getByText('Action')).toBeInTheDocument()
})
it('should not show children when not active', () => {
render(
<OptionCard {...defaultProps} isActive={false}>
<div>Body Content</div>
</OptionCard>,
)
expect(screen.queryByText('Body Content')).not.toBeInTheDocument()
})
it('should apply selected border style when active and not noHighlight', () => {
const { container } = render(<OptionCard {...defaultProps} isActive />)
expect(container.firstChild).toHaveClass('border-components-option-card-option-selected-border')
})
it('should not apply selected border when noHighlight is true', () => {
const { container } = render(<OptionCard {...defaultProps} isActive noHighlight />)
expect(container.firstChild).not.toHaveClass('border-components-option-card-option-selected-border')
})
it('should apply disabled opacity and pointer-events styles', () => {
const { container } = render(<OptionCard {...defaultProps} disabled />)
expect(container.firstChild).toHaveClass('pointer-events-none')
expect(container.firstChild).toHaveClass('opacity-50')
})
it('should forward custom className', () => {
const { container } = render(<OptionCard {...defaultProps} className="custom-class" />)
expect(container.firstChild).toHaveClass('custom-class')
})
it('should forward custom style', () => {
const { container } = render(
<OptionCard {...defaultProps} style={{ maxWidth: '300px' }} />,
)
expect((container.firstChild as HTMLElement).style.maxWidth).toBe('300px')
})
})

View File

@@ -0,0 +1,156 @@
import type { ParentChildConfig } from '../hooks'
import type { PreProcessingRule } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import { ParentChildOptions } from './parent-child-options'
vi.mock('next/image', () => ({
default: ({ alt, ...props }: { alt?: string, src?: string, width?: number, height?: number }) => (
<img alt={alt} {...props} />
),
}))
vi.mock('@/app/components/datasets/settings/summary-index-setting', () => ({
default: ({ onSummaryIndexSettingChange }: { onSummaryIndexSettingChange?: (val: Record<string, unknown>) => void }) => (
<div data-testid="summary-index-setting">
<button data-testid="summary-toggle" onClick={() => onSummaryIndexSettingChange?.({ enable: true })}>Toggle</button>
</div>
),
}))
vi.mock('@/config', () => ({
IS_CE_EDITION: true,
}))
const ns = 'datasetCreation'
const createRules = (): PreProcessingRule[] => [
{ id: 'remove_extra_spaces', enabled: true },
{ id: 'remove_urls_emails', enabled: false },
]
const createParentChildConfig = (overrides?: Partial<ParentChildConfig>): ParentChildConfig => ({
chunkForContext: 'paragraph',
parent: { delimiter: '\\n\\n', maxLength: 2000 },
child: { delimiter: '\\n', maxLength: 500 },
...overrides,
})
const defaultProps = {
parentChildConfig: createParentChildConfig(),
rules: createRules(),
currentDocForm: ChunkingMode.parentChild,
isActive: true,
isInUpload: false,
isNotUploadInEmptyDataset: false,
onDocFormChange: vi.fn(),
onChunkForContextChange: vi.fn(),
onParentDelimiterChange: vi.fn(),
onParentMaxLengthChange: vi.fn(),
onChildDelimiterChange: vi.fn(),
onChildMaxLengthChange: vi.fn(),
onRuleToggle: vi.fn(),
onPreview: vi.fn(),
onReset: vi.fn(),
}
describe('ParentChildOptions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render parent-child title', () => {
render(<ParentChildOptions {...defaultProps} />)
expect(screen.getByText(`${ns}.stepTwo.parentChild`)).toBeInTheDocument()
})
it('should render parent chunk context section when active', () => {
render(<ParentChildOptions {...defaultProps} />)
expect(screen.getByText(`${ns}.stepTwo.parentChunkForContext`)).toBeInTheDocument()
expect(screen.getByText(`${ns}.stepTwo.paragraph`)).toBeInTheDocument()
expect(screen.getByText(`${ns}.stepTwo.fullDoc`)).toBeInTheDocument()
})
it('should render child chunk retrieval section when active', () => {
render(<ParentChildOptions {...defaultProps} />)
expect(screen.getByText(`${ns}.stepTwo.childChunkForRetrieval`)).toBeInTheDocument()
})
it('should render rules section when active', () => {
render(<ParentChildOptions {...defaultProps} />)
expect(screen.getByText(`${ns}.stepTwo.removeExtraSpaces`)).toBeInTheDocument()
expect(screen.getByText(`${ns}.stepTwo.removeUrlEmails`)).toBeInTheDocument()
})
it('should render preview and reset buttons when active', () => {
render(<ParentChildOptions {...defaultProps} />)
expect(screen.getByText(`${ns}.stepTwo.previewChunk`)).toBeInTheDocument()
expect(screen.getByText(`${ns}.stepTwo.reset`)).toBeInTheDocument()
})
it('should not render body when not active', () => {
render(<ParentChildOptions {...defaultProps} isActive={false} />)
expect(screen.queryByText(`${ns}.stepTwo.parentChunkForContext`)).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onPreview when preview button clicked', () => {
const onPreview = vi.fn()
render(<ParentChildOptions {...defaultProps} onPreview={onPreview} />)
fireEvent.click(screen.getByText(`${ns}.stepTwo.previewChunk`))
expect(onPreview).toHaveBeenCalledOnce()
})
it('should call onReset when reset button clicked', () => {
const onReset = vi.fn()
render(<ParentChildOptions {...defaultProps} onReset={onReset} />)
fireEvent.click(screen.getByText(`${ns}.stepTwo.reset`))
expect(onReset).toHaveBeenCalledOnce()
})
it('should call onRuleToggle when rule clicked', () => {
const onRuleToggle = vi.fn()
render(<ParentChildOptions {...defaultProps} onRuleToggle={onRuleToggle} />)
fireEvent.click(screen.getByText(`${ns}.stepTwo.removeUrlEmails`))
expect(onRuleToggle).toHaveBeenCalledWith('remove_urls_emails')
})
it('should call onDocFormChange with parentChild when card switched', () => {
const onDocFormChange = vi.fn()
render(<ParentChildOptions {...defaultProps} isActive={false} onDocFormChange={onDocFormChange} />)
const titleEl = screen.getByText(`${ns}.stepTwo.parentChild`)
fireEvent.click(titleEl.closest('[class*="rounded-xl"]')!)
expect(onDocFormChange).toHaveBeenCalledWith(ChunkingMode.parentChild)
})
it('should call onChunkForContextChange when full-doc chosen', () => {
const onChunkForContextChange = vi.fn()
render(<ParentChildOptions {...defaultProps} onChunkForContextChange={onChunkForContextChange} />)
fireEvent.click(screen.getByText(`${ns}.stepTwo.fullDoc`))
expect(onChunkForContextChange).toHaveBeenCalledWith('full-doc')
})
it('should call onChunkForContextChange when paragraph chosen', () => {
const onChunkForContextChange = vi.fn()
const config = createParentChildConfig({ chunkForContext: 'full-doc' })
render(<ParentChildOptions {...defaultProps} parentChildConfig={config} onChunkForContextChange={onChunkForContextChange} />)
fireEvent.click(screen.getByText(`${ns}.stepTwo.paragraph`))
expect(onChunkForContextChange).toHaveBeenCalledWith('paragraph')
})
})
describe('Summary Index Setting', () => {
it('should render SummaryIndexSetting when showSummaryIndexSetting is true', () => {
render(<ParentChildOptions {...defaultProps} showSummaryIndexSetting />)
expect(screen.getByTestId('summary-index-setting')).toBeInTheDocument()
})
it('should not render SummaryIndexSetting when showSummaryIndexSetting is false', () => {
render(<ParentChildOptions {...defaultProps} showSummaryIndexSetting={false} />)
expect(screen.queryByTestId('summary-index-setting')).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,176 @@
import type { ParentChildConfig } from '../hooks'
import type { FileIndexingEstimateResponse } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode, DataSourceType } from '@/models/datasets'
import { PreviewPanel } from './preview-panel'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: { count?: number }) => opts?.count !== undefined ? `${key}-${opts.count}` : key,
}),
}))
vi.mock('@remixicon/react', () => ({
RiSearchEyeLine: () => <span data-testid="search-icon" />,
}))
vi.mock('@/app/components/base/float-right-container', () => ({
default: ({ children }: { children: React.ReactNode }) => <div data-testid="float-container">{children}</div>,
}))
vi.mock('@/app/components/base/badge', () => ({
default: ({ text }: { text: string }) => <span data-testid="badge">{text}</span>,
}))
vi.mock('@/app/components/base/skeleton', () => ({
SkeletonContainer: ({ children }: { children: React.ReactNode }) => <div data-testid="skeleton">{children}</div>,
SkeletonPoint: () => <span />,
SkeletonRectangle: () => <span />,
SkeletonRow: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('../../../chunk', () => ({
ChunkContainer: ({ children, label }: { children: React.ReactNode, label: string }) => (
<div data-testid="chunk-container">
{label}
:
{' '}
{children}
</div>
),
QAPreview: ({ qa }: { qa: { question: string } }) => <div data-testid="qa-preview">{qa.question}</div>,
}))
vi.mock('../../../common/document-picker/preview-document-picker', () => ({
default: () => <div data-testid="doc-picker" />,
}))
vi.mock('../../../documents/detail/completed/common/summary-label', () => ({
default: ({ summary }: { summary: string }) => <span data-testid="summary">{summary}</span>,
}))
vi.mock('../../../formatted-text/flavours/preview-slice', () => ({
PreviewSlice: ({ label, text }: { label: string, text: string }) => (
<span data-testid="preview-slice">
{label}
:
{' '}
{text}
</span>
),
}))
vi.mock('../../../formatted-text/formatted', () => ({
FormattedText: ({ children }: { children: React.ReactNode }) => <p data-testid="formatted-text">{children}</p>,
}))
vi.mock('../../../preview/container', () => ({
default: ({ children, header }: { children: React.ReactNode, header: React.ReactNode }) => (
<div data-testid="preview-container">
{header}
{children}
</div>
),
}))
vi.mock('../../../preview/header', () => ({
PreviewHeader: ({ children, title }: { children: React.ReactNode, title: string }) => (
<div data-testid="preview-header">
{title}
{children}
</div>
),
}))
vi.mock('@/config', () => ({
FULL_DOC_PREVIEW_LENGTH: 3,
}))
describe('PreviewPanel', () => {
const defaultProps = {
isMobile: false,
dataSourceType: DataSourceType.FILE,
currentDocForm: ChunkingMode.text,
parentChildConfig: { chunkForContext: 'paragraph' } as ParentChildConfig,
pickerFiles: [{ id: '1', name: 'file.pdf', extension: 'pdf' }],
pickerValue: { id: '1', name: 'file.pdf', extension: 'pdf' },
isIdle: false,
isPending: false,
onPickerChange: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render preview header with title', () => {
render(<PreviewPanel {...defaultProps} />)
expect(screen.getByTestId('preview-header')).toHaveTextContent('stepTwo.preview')
})
it('should render document picker', () => {
render(<PreviewPanel {...defaultProps} />)
expect(screen.getByTestId('doc-picker')).toBeInTheDocument()
})
it('should show idle state when isIdle is true', () => {
render(<PreviewPanel {...defaultProps} isIdle={true} />)
expect(screen.getByText('stepTwo.previewChunkTip')).toBeInTheDocument()
})
it('should show loading skeletons when isPending', () => {
render(<PreviewPanel {...defaultProps} isPending={true} />)
expect(screen.getAllByTestId('skeleton')).toHaveLength(10)
})
it('should render text preview chunks', () => {
const estimate: Partial<FileIndexingEstimateResponse> = {
total_segments: 2,
preview: [
{ content: 'chunk 1 text', child_chunks: [], summary: '' },
{ content: 'chunk 2 text', child_chunks: [], summary: 'summary text' },
],
}
render(<PreviewPanel {...defaultProps} estimate={estimate as FileIndexingEstimateResponse} />)
expect(screen.getAllByTestId('chunk-container')).toHaveLength(2)
})
it('should render QA preview', () => {
const estimate: Partial<FileIndexingEstimateResponse> = {
qa_preview: [
{ question: 'Q1', answer: 'A1' },
],
}
render(
<PreviewPanel
{...defaultProps}
currentDocForm={ChunkingMode.qa}
estimate={estimate as FileIndexingEstimateResponse}
/>,
)
expect(screen.getByTestId('qa-preview')).toHaveTextContent('Q1')
})
it('should render parent-child preview', () => {
const estimate: Partial<FileIndexingEstimateResponse> = {
preview: [
{ content: 'parent chunk', child_chunks: ['child1', 'child2'], summary: '' },
],
}
render(
<PreviewPanel
{...defaultProps}
currentDocForm={ChunkingMode.parentChild}
estimate={estimate as FileIndexingEstimateResponse}
/>,
)
expect(screen.getAllByTestId('preview-slice')).toHaveLength(2)
})
it('should show badge with chunk count for non-QA mode', () => {
const estimate: Partial<FileIndexingEstimateResponse> = { total_segments: 5, preview: [] }
render(<PreviewPanel {...defaultProps} estimate={estimate as FileIndexingEstimateResponse} />)
expect(screen.getByTestId('badge')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,53 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { StepTwoFooter } from './step-two-footer'
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('@remixicon/react', () => ({
RiArrowLeftLine: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="arrow-left" {...props} />,
}))
describe('StepTwoFooter', () => {
const defaultProps = {
isCreating: false,
onPrevious: vi.fn(),
onCreate: vi.fn(),
onCancel: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render previous and next buttons when not isSetting', () => {
render(<StepTwoFooter {...defaultProps} />)
expect(screen.getByText('stepTwo.previousStep')).toBeInTheDocument()
expect(screen.getByText('stepTwo.nextStep')).toBeInTheDocument()
})
it('should render save and cancel buttons when isSetting', () => {
render(<StepTwoFooter {...defaultProps} isSetting />)
expect(screen.getByText('stepTwo.save')).toBeInTheDocument()
expect(screen.getByText('stepTwo.cancel')).toBeInTheDocument()
})
it('should call onPrevious on previous button click', () => {
render(<StepTwoFooter {...defaultProps} />)
fireEvent.click(screen.getByText('stepTwo.previousStep'))
expect(defaultProps.onPrevious).toHaveBeenCalledOnce()
})
it('should call onCreate on next button click', () => {
render(<StepTwoFooter {...defaultProps} />)
fireEvent.click(screen.getByText('stepTwo.nextStep'))
expect(defaultProps.onCreate).toHaveBeenCalledOnce()
})
it('should call onCancel on cancel button click in settings mode', () => {
render(<StepTwoFooter {...defaultProps} isSetting />)
fireEvent.click(screen.getByText('stepTwo.cancel'))
expect(defaultProps.onCancel).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import escape from './escape'
describe('escape', () => {
// Basic special character escaping
it('should escape null character', () => {
expect(escape('\0')).toBe('\\0')
})
it('should escape backspace', () => {
expect(escape('\b')).toBe('\\b')
})
it('should escape form feed', () => {
expect(escape('\f')).toBe('\\f')
})
it('should escape newline', () => {
expect(escape('\n')).toBe('\\n')
})
it('should escape carriage return', () => {
expect(escape('\r')).toBe('\\r')
})
it('should escape tab', () => {
expect(escape('\t')).toBe('\\t')
})
it('should escape vertical tab', () => {
expect(escape('\v')).toBe('\\v')
})
it('should escape single quote', () => {
expect(escape('\'')).toBe('\\\'')
})
// Multiple special characters in one string
it('should escape multiple special characters', () => {
expect(escape('line1\nline2\ttab')).toBe('line1\\nline2\\ttab')
})
it('should escape mixed special characters', () => {
expect(escape('\n\r\t')).toBe('\\n\\r\\t')
})
// Edge cases
it('should return empty string for null input', () => {
expect(escape(null as unknown as string)).toBe('')
})
it('should return empty string for undefined input', () => {
expect(escape(undefined as unknown as string)).toBe('')
})
it('should return empty string for empty string input', () => {
expect(escape('')).toBe('')
})
it('should return empty string for non-string input', () => {
expect(escape(123 as unknown as string)).toBe('')
})
// Pass-through for normal strings
it('should leave normal text unchanged', () => {
expect(escape('hello world')).toBe('hello world')
})
it('should leave special regex characters unchanged', () => {
expect(escape('a.b*c+d')).toBe('a.b*c+d')
})
it('should handle strings with no special characters', () => {
expect(escape('abc123')).toBe('abc123')
})
})

View File

@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest'
import unescape from './unescape'
describe('unescape', () => {
// Basic escape sequences
it('should unescape \\n to newline', () => {
expect(unescape('\\n')).toBe('\n')
})
it('should unescape \\t to tab', () => {
expect(unescape('\\t')).toBe('\t')
})
it('should unescape \\r to carriage return', () => {
expect(unescape('\\r')).toBe('\r')
})
it('should unescape \\b to backspace', () => {
expect(unescape('\\b')).toBe('\b')
})
it('should unescape \\f to form feed', () => {
expect(unescape('\\f')).toBe('\f')
})
it('should unescape \\v to vertical tab', () => {
expect(unescape('\\v')).toBe('\v')
})
it('should unescape \\0 to null character', () => {
expect(unescape('\\0')).toBe('\0')
})
it('should unescape \\\\ to backslash', () => {
expect(unescape('\\\\')).toBe('\\')
})
it('should unescape \\\' to single quote', () => {
expect(unescape('\\\'')).toBe('\'')
})
it('should unescape \\" to double quote', () => {
expect(unescape('\\"')).toBe('"')
})
// Hex escape sequences (\\xNN)
it('should unescape 2-digit hex sequences', () => {
expect(unescape('\\x41')).toBe('A')
expect(unescape('\\x61')).toBe('a')
})
// Unicode escape sequences (\\uNNNN)
it('should unescape 4-digit unicode sequences', () => {
expect(unescape('\\u0041')).toBe('A')
expect(unescape('\\u4e2d')).toBe('中')
})
// Variable-length unicode (\\u{NNNN})
it('should unescape variable-length unicode sequences', () => {
expect(unescape('\\u{41}')).toBe('A')
expect(unescape('\\u{1F600}')).toBe('😀')
})
// Octal escape sequences
it('should unescape octal sequences', () => {
expect(unescape('\\101')).toBe('A') // 0o101 = 65 = 'A'
expect(unescape('\\12')).toBe('\n') // 0o12 = 10 = '\n'
})
// Python-style 8-digit unicode (\\UNNNNNNNN)
it('should unescape Python-style 8-digit unicode', () => {
expect(unescape('\\U0001F3B5')).toBe('🎵')
})
// Multiple escape sequences
it('should unescape multiple sequences in one string', () => {
expect(unescape('line1\\nline2\\ttab')).toBe('line1\nline2\ttab')
})
// Mixed content
it('should leave non-escape content unchanged', () => {
expect(unescape('hello world')).toBe('hello world')
})
it('should handle mixed escaped and non-escaped content', () => {
expect(unescape('before\\nafter')).toBe('before\nafter')
})
// Edge cases
it('should handle empty string', () => {
expect(unescape('')).toBe('')
})
it('should handle string with no escape sequences', () => {
expect(unescape('abc123')).toBe('abc123')
})
})

View File

@@ -0,0 +1,186 @@
import type { CustomFile, FullDocumentDetail, ProcessRule } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode, DataSourceType } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
// Hoisted mocks
const mocks = vi.hoisted(() => ({
toastNotify: vi.fn(),
mutateAsync: vi.fn(),
isReRankModelSelected: vi.fn(() => true),
trackEvent: vi.fn(),
invalidDatasetList: vi.fn(),
}))
vi.mock('@/app/components/base/toast', () => ({
default: { notify: mocks.toastNotify },
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: mocks.trackEvent,
}))
vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({
isReRankModelSelected: mocks.isReRankModelSelected,
}))
vi.mock('@/service/knowledge/use-create-dataset', () => ({
useCreateFirstDocument: () => ({ mutateAsync: mocks.mutateAsync, isPending: false }),
useCreateDocument: () => ({ mutateAsync: mocks.mutateAsync, isPending: false }),
getNotionInfo: vi.fn(() => []),
getWebsiteInfo: vi.fn(() => ({})),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useInvalidDatasetList: () => mocks.invalidDatasetList,
}))
const { useDocumentCreation } = await import('./use-document-creation')
const { IndexingType } = await import('./use-indexing-config')
describe('useDocumentCreation', () => {
const defaultOptions = {
dataSourceType: DataSourceType.FILE,
files: [{ id: 'f-1', name: 'test.txt' }] as CustomFile[],
notionPages: [],
notionCredentialId: '',
websitePages: [],
}
const defaultValidationParams = {
segmentationType: 'general',
maxChunkLength: 1024,
limitMaxChunkLength: 4000,
overlap: 50,
indexType: IndexingType.QUALIFIED,
embeddingModel: { provider: 'openai', model: 'text-embedding-3-small' },
rerankModelList: [],
retrievalConfig: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
} as RetrievalConfig,
}
beforeEach(() => {
vi.clearAllMocks()
mocks.isReRankModelSelected.mockReturnValue(true)
})
describe('validateParams', () => {
it('should return true for valid params', () => {
const { result } = renderHook(() => useDocumentCreation(defaultOptions))
expect(result.current.validateParams(defaultValidationParams)).toBe(true)
})
it('should return false when overlap > maxChunkLength', () => {
const { result } = renderHook(() => useDocumentCreation(defaultOptions))
const invalid = { ...defaultValidationParams, overlap: 2000, maxChunkLength: 1000 }
expect(result.current.validateParams(invalid)).toBe(false)
expect(mocks.toastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
it('should return false when maxChunkLength > limitMaxChunkLength', () => {
const { result } = renderHook(() => useDocumentCreation(defaultOptions))
const invalid = { ...defaultValidationParams, maxChunkLength: 5000, limitMaxChunkLength: 4000 }
expect(result.current.validateParams(invalid)).toBe(false)
})
it('should return false when qualified but no embedding model', () => {
const { result } = renderHook(() => useDocumentCreation(defaultOptions))
const invalid = {
...defaultValidationParams,
indexType: IndexingType.QUALIFIED,
embeddingModel: { provider: '', model: '' },
}
expect(result.current.validateParams(invalid)).toBe(false)
})
it('should return false when rerank model not selected', () => {
mocks.isReRankModelSelected.mockReturnValue(false)
const { result } = renderHook(() => useDocumentCreation(defaultOptions))
expect(result.current.validateParams(defaultValidationParams)).toBe(false)
})
it('should skip embedding/rerank checks when isSetting is true', () => {
mocks.isReRankModelSelected.mockReturnValue(false)
const { result } = renderHook(() =>
useDocumentCreation({ ...defaultOptions, isSetting: true }),
)
const params = {
...defaultValidationParams,
embeddingModel: { provider: '', model: '' },
}
expect(result.current.validateParams(params)).toBe(true)
})
})
describe('buildCreationParams', () => {
it('should build params for FILE data source', () => {
const { result } = renderHook(() => useDocumentCreation(defaultOptions))
const processRule = { mode: 'custom', rules: {} } as unknown as ProcessRule
const retrievalConfig = defaultValidationParams.retrievalConfig
const embeddingModel = { provider: 'openai', model: 'text-embedding-3-small' }
const params = result.current.buildCreationParams(
ChunkingMode.text,
'English',
processRule,
retrievalConfig,
embeddingModel,
'high_quality',
)
expect(params).not.toBeNull()
expect(params!.data_source!.type).toBe(DataSourceType.FILE)
expect(params!.data_source!.info_list.file_info_list?.file_ids).toContain('f-1')
expect(params!.embedding_model).toBe('text-embedding-3-small')
expect(params!.embedding_model_provider).toBe('openai')
})
it('should build params for isSetting mode', () => {
const detail = { id: 'doc-1' } as FullDocumentDetail
const { result } = renderHook(() =>
useDocumentCreation({ ...defaultOptions, isSetting: true, documentDetail: detail }),
)
const params = result.current.buildCreationParams(
ChunkingMode.text,
'English',
{ mode: 'custom', rules: {} } as unknown as ProcessRule,
defaultValidationParams.retrievalConfig,
{ provider: 'openai', model: 'text-embedding-3-small' },
'high_quality',
)
expect(params!.original_document_id).toBe('doc-1')
expect(params!.data_source).toBeUndefined()
})
})
describe('validatePreviewParams', () => {
it('should return true when maxChunkLength is within limit', () => {
const { result } = renderHook(() => useDocumentCreation(defaultOptions))
expect(result.current.validatePreviewParams(1024)).toBe(true)
})
it('should return false when maxChunkLength exceeds limit', () => {
const { result } = renderHook(() => useDocumentCreation(defaultOptions))
expect(result.current.validatePreviewParams(999999)).toBe(false)
expect(mocks.toastNotify).toHaveBeenCalled()
})
})
describe('isCreating', () => {
it('should reflect mutation pending state', () => {
const { result } = renderHook(() => useDocumentCreation(defaultOptions))
expect(result.current.isCreating).toBe(false)
})
})
})

View File

@@ -0,0 +1,161 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { RETRIEVE_METHOD } from '@/types/app'
// Hoisted mock state
const mocks = vi.hoisted(() => ({
rerankModelList: [] as Array<{ provider: { provider: string }, model: string }>,
rerankDefaultModel: null as { provider: { provider: string }, model: string } | null,
isRerankDefaultModelValid: null as { provider: { provider: string }, model: string } | null,
embeddingModelList: [] as Array<{ provider: { provider: string }, model: string }>,
defaultEmbeddingModel: null as { provider: { provider: string }, model: string } | null,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
modelList: mocks.rerankModelList,
defaultModel: mocks.rerankDefaultModel,
currentModel: mocks.isRerankDefaultModelValid,
}),
useModelList: () => ({ data: mocks.embeddingModelList }),
useDefaultModel: () => ({ data: mocks.defaultEmbeddingModel }),
}))
vi.mock('@/app/components/datasets/settings/utils', () => ({
checkShowMultiModalTip: vi.fn(() => false),
}))
const { IndexingType, useIndexingConfig } = await import('./use-indexing-config')
describe('useIndexingConfig', () => {
const defaultOptions = {
isAPIKeySet: true,
hasSetIndexType: false,
}
beforeEach(() => {
vi.clearAllMocks()
mocks.rerankModelList = []
mocks.rerankDefaultModel = null
mocks.isRerankDefaultModelValid = null
mocks.embeddingModelList = []
mocks.defaultEmbeddingModel = null
})
describe('initial state', () => {
it('should default to QUALIFIED when API key is set', () => {
const { result } = renderHook(() => useIndexingConfig(defaultOptions))
expect(result.current.indexType).toBe(IndexingType.QUALIFIED)
})
it('should default to ECONOMICAL when API key is not set', () => {
const { result } = renderHook(() =>
useIndexingConfig({ ...defaultOptions, isAPIKeySet: false }),
)
expect(result.current.indexType).toBe(IndexingType.ECONOMICAL)
})
it('should use initial index type when provided', () => {
const { result } = renderHook(() =>
useIndexingConfig({
...defaultOptions,
initialIndexType: IndexingType.ECONOMICAL,
}),
)
expect(result.current.indexType).toBe(IndexingType.ECONOMICAL)
})
it('should use initial embedding model when provided', () => {
const { result } = renderHook(() =>
useIndexingConfig({
...defaultOptions,
initialEmbeddingModel: { provider: 'openai', model: 'text-embedding-3-small' },
}),
)
expect(result.current.embeddingModel).toEqual({
provider: 'openai',
model: 'text-embedding-3-small',
})
})
it('should use initial retrieval config when provided', () => {
const config = {
search_method: RETRIEVE_METHOD.fullText,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 5,
score_threshold_enabled: true,
score_threshold: 0.8,
}
const { result } = renderHook(() =>
useIndexingConfig({ ...defaultOptions, initialRetrievalConfig: config }),
)
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.fullText)
expect(result.current.retrievalConfig.top_k).toBe(5)
})
})
describe('setters', () => {
it('should update index type', () => {
const { result } = renderHook(() => useIndexingConfig(defaultOptions))
act(() => {
result.current.setIndexType(IndexingType.ECONOMICAL)
})
expect(result.current.indexType).toBe(IndexingType.ECONOMICAL)
})
it('should update embedding model', () => {
const { result } = renderHook(() => useIndexingConfig(defaultOptions))
act(() => {
result.current.setEmbeddingModel({ provider: 'cohere', model: 'embed-v3' })
})
expect(result.current.embeddingModel).toEqual({ provider: 'cohere', model: 'embed-v3' })
})
it('should update retrieval config', () => {
const { result } = renderHook(() => useIndexingConfig(defaultOptions))
const newConfig = {
...result.current.retrievalConfig,
top_k: 10,
}
act(() => {
result.current.setRetrievalConfig(newConfig)
})
expect(result.current.retrievalConfig.top_k).toBe(10)
})
})
describe('getIndexingTechnique', () => {
it('should return initialIndexType when provided', () => {
const { result } = renderHook(() =>
useIndexingConfig({
...defaultOptions,
initialIndexType: IndexingType.ECONOMICAL,
}),
)
expect(result.current.getIndexingTechnique()).toBe(IndexingType.ECONOMICAL)
})
it('should return current indexType when no initialIndexType', () => {
const { result } = renderHook(() => useIndexingConfig(defaultOptions))
expect(result.current.getIndexingTechnique()).toBe(IndexingType.QUALIFIED)
})
})
describe('computed properties', () => {
it('should expose hasSetIndexType from options', () => {
const { result } = renderHook(() =>
useIndexingConfig({ ...defaultOptions, hasSetIndexType: true }),
)
expect(result.current.hasSetIndexType).toBe(true)
})
it('should expose showMultiModalTip as boolean', () => {
const { result } = renderHook(() => useIndexingConfig(defaultOptions))
expect(typeof result.current.showMultiModalTip).toBe('boolean')
})
})
})

View File

@@ -0,0 +1,127 @@
import type { IndexingType } from './use-indexing-config'
import type { NotionPage } from '@/models/common'
import type { ChunkingMode, CrawlResultItem, CustomFile, ProcessRule } from '@/models/datasets'
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DataSourceType } from '@/models/datasets'
// Hoisted mocks
const mocks = vi.hoisted(() => ({
fileMutate: vi.fn(),
fileReset: vi.fn(),
notionMutate: vi.fn(),
notionReset: vi.fn(),
webMutate: vi.fn(),
webReset: vi.fn(),
}))
vi.mock('@/service/knowledge/use-create-dataset', () => ({
useFetchFileIndexingEstimateForFile: () => ({
mutate: mocks.fileMutate,
reset: mocks.fileReset,
data: { tokens: 100, total_segments: 5 },
isIdle: true,
isPending: false,
}),
useFetchFileIndexingEstimateForNotion: () => ({
mutate: mocks.notionMutate,
reset: mocks.notionReset,
data: null,
isIdle: true,
isPending: false,
}),
useFetchFileIndexingEstimateForWeb: () => ({
mutate: mocks.webMutate,
reset: mocks.webReset,
data: null,
isIdle: true,
isPending: false,
}),
}))
const { useIndexingEstimate } = await import('./use-indexing-estimate')
describe('useIndexingEstimate', () => {
const defaultOptions = {
dataSourceType: DataSourceType.FILE,
currentDocForm: 'text_model' as ChunkingMode,
docLanguage: 'English',
files: [{ id: 'f-1', name: 'test.txt' }] as unknown as CustomFile[],
previewNotionPage: {} as unknown as NotionPage,
notionCredentialId: '',
previewWebsitePage: {} as unknown as CrawlResultItem,
indexingTechnique: 'high_quality' as unknown as IndexingType,
processRule: { mode: 'custom', rules: {} } as unknown as ProcessRule,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('currentMutation selection', () => {
it('should select file mutation for FILE type', () => {
const { result } = renderHook(() => useIndexingEstimate(defaultOptions))
expect(result.current.estimate).toEqual({ tokens: 100, total_segments: 5 })
})
it('should select notion mutation for NOTION type', () => {
const { result } = renderHook(() => useIndexingEstimate({
...defaultOptions,
dataSourceType: DataSourceType.NOTION,
}))
expect(result.current.estimate).toBeNull()
})
it('should select web mutation for WEB type', () => {
const { result } = renderHook(() => useIndexingEstimate({
...defaultOptions,
dataSourceType: DataSourceType.WEB,
}))
expect(result.current.estimate).toBeNull()
})
})
describe('fetchEstimate', () => {
it('should call file mutate for FILE type', () => {
const { result } = renderHook(() => useIndexingEstimate(defaultOptions))
result.current.fetchEstimate()
expect(mocks.fileMutate).toHaveBeenCalledOnce()
})
it('should call notion mutate for NOTION type', () => {
const { result } = renderHook(() => useIndexingEstimate({
...defaultOptions,
dataSourceType: DataSourceType.NOTION,
}))
result.current.fetchEstimate()
expect(mocks.notionMutate).toHaveBeenCalledOnce()
})
it('should call web mutate for WEB type', () => {
const { result } = renderHook(() => useIndexingEstimate({
...defaultOptions,
dataSourceType: DataSourceType.WEB,
}))
result.current.fetchEstimate()
expect(mocks.webMutate).toHaveBeenCalledOnce()
})
})
describe('state properties', () => {
it('should expose isIdle', () => {
const { result } = renderHook(() => useIndexingEstimate(defaultOptions))
expect(result.current.isIdle).toBe(true)
})
it('should expose isPending', () => {
const { result } = renderHook(() => useIndexingEstimate(defaultOptions))
expect(result.current.isPending).toBe(false)
})
it('should expose reset function', () => {
const { result } = renderHook(() => useIndexingEstimate(defaultOptions))
result.current.reset()
expect(mocks.fileReset).toHaveBeenCalledOnce()
})
})
})

View File

@@ -0,0 +1,198 @@
import type { NotionPage } from '@/models/common'
import type { CrawlResultItem, CustomFile } from '@/models/datasets'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DataSourceType } from '@/models/datasets'
import { usePreviewState } from './use-preview-state'
// Factory functions
const createFile = (id: string, name: string): CustomFile => ({
id,
name,
size: 1024,
type: 'text/plain',
extension: 'txt',
created_by: 'user',
created_at: Date.now(),
} as unknown as CustomFile)
const createNotionPage = (pageId: string, pageName: string): NotionPage => ({
page_id: pageId,
page_name: pageName,
page_icon: null,
parent_id: '',
type: 'page',
is_bound: true,
} as unknown as NotionPage)
const createWebsitePage = (url: string, title: string): CrawlResultItem => ({
source_url: url,
title,
markdown: '',
description: '',
} as unknown as CrawlResultItem)
describe('usePreviewState', () => {
const files = [createFile('f-1', 'file1.txt'), createFile('f-2', 'file2.txt')]
const notionPages = [createNotionPage('np-1', 'Page 1'), createNotionPage('np-2', 'Page 2')]
const websitePages = [createWebsitePage('https://a.com', 'Site A'), createWebsitePage('https://b.com', 'Site B')]
beforeEach(() => {
vi.clearAllMocks()
})
describe('initial state for FILE', () => {
it('should set first file as preview', () => {
const { result } = renderHook(() => usePreviewState({
dataSourceType: DataSourceType.FILE,
files,
notionPages: [],
websitePages: [],
}))
expect(result.current.previewFile).toBe(files[0])
})
})
describe('initial state for NOTION', () => {
it('should set first notion page as preview', () => {
const { result } = renderHook(() => usePreviewState({
dataSourceType: DataSourceType.NOTION,
files: [],
notionPages,
websitePages: [],
}))
expect(result.current.previewNotionPage).toBe(notionPages[0])
})
})
describe('initial state for WEB', () => {
it('should set first website page as preview', () => {
const { result } = renderHook(() => usePreviewState({
dataSourceType: DataSourceType.WEB,
files: [],
notionPages: [],
websitePages,
}))
expect(result.current.previewWebsitePage).toBe(websitePages[0])
})
})
describe('getPreviewPickerItems', () => {
it('should return files for FILE type', () => {
const { result } = renderHook(() => usePreviewState({
dataSourceType: DataSourceType.FILE,
files,
notionPages: [],
websitePages: [],
}))
const items = result.current.getPreviewPickerItems()
expect(items).toHaveLength(2)
})
it('should return mapped notion pages for NOTION type', () => {
const { result } = renderHook(() => usePreviewState({
dataSourceType: DataSourceType.NOTION,
files: [],
notionPages,
websitePages: [],
}))
const items = result.current.getPreviewPickerItems()
expect(items).toHaveLength(2)
expect(items[0]).toEqual({ id: 'np-1', name: 'Page 1', extension: 'md' })
})
it('should return mapped website pages for WEB type', () => {
const { result } = renderHook(() => usePreviewState({
dataSourceType: DataSourceType.WEB,
files: [],
notionPages: [],
websitePages,
}))
const items = result.current.getPreviewPickerItems()
expect(items).toHaveLength(2)
expect(items[0]).toEqual({ id: 'https://a.com', name: 'Site A', extension: 'md' })
})
})
describe('getPreviewPickerValue', () => {
it('should return current preview file for FILE type', () => {
const { result } = renderHook(() => usePreviewState({
dataSourceType: DataSourceType.FILE,
files,
notionPages: [],
websitePages: [],
}))
const value = result.current.getPreviewPickerValue()
expect(value).toBe(files[0])
})
it('should return mapped notion page value for NOTION type', () => {
const { result } = renderHook(() => usePreviewState({
dataSourceType: DataSourceType.NOTION,
files: [],
notionPages,
websitePages: [],
}))
const value = result.current.getPreviewPickerValue()
expect(value).toEqual({ id: 'np-1', name: 'Page 1', extension: 'md' })
})
})
describe('handlePreviewChange', () => {
it('should change preview file for FILE type', () => {
const { result } = renderHook(() => usePreviewState({
dataSourceType: DataSourceType.FILE,
files,
notionPages: [],
websitePages: [],
}))
act(() => {
result.current.handlePreviewChange({ id: 'f-2', name: 'file2.txt' })
})
expect(result.current.previewFile).toEqual({ id: 'f-2', name: 'file2.txt' })
})
it('should change preview notion page for NOTION type', () => {
const { result } = renderHook(() => usePreviewState({
dataSourceType: DataSourceType.NOTION,
files: [],
notionPages,
websitePages: [],
}))
act(() => {
result.current.handlePreviewChange({ id: 'np-2', name: 'Page 2' })
})
expect(result.current.previewNotionPage).toBe(notionPages[1])
})
it('should change preview website page for WEB type', () => {
const { result } = renderHook(() => usePreviewState({
dataSourceType: DataSourceType.WEB,
files: [],
notionPages: [],
websitePages,
}))
act(() => {
result.current.handlePreviewChange({ id: 'https://b.com', name: 'Site B' })
})
expect(result.current.previewWebsitePage).toBe(websitePages[1])
})
it('should not change if selected page not found (NOTION)', () => {
const { result } = renderHook(() => usePreviewState({
dataSourceType: DataSourceType.NOTION,
files: [],
notionPages,
websitePages: [],
}))
act(() => {
result.current.handlePreviewChange({ id: 'non-existent', name: 'x' })
})
expect(result.current.previewNotionPage).toBe(notionPages[0])
})
})
})

View File

@@ -0,0 +1,373 @@
import type { PreProcessingRule, Rules } from '@/models/datasets'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode, ProcessMode } from '@/models/datasets'
import {
DEFAULT_MAXIMUM_CHUNK_LENGTH,
DEFAULT_OVERLAP,
DEFAULT_SEGMENT_IDENTIFIER,
defaultParentChildConfig,
useSegmentationState,
} from './use-segmentation-state'
describe('useSegmentationState', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// --- Default state ---
describe('default state', () => {
it('should initialize with default values', () => {
const { result } = renderHook(() => useSegmentationState())
expect(result.current.segmentationType).toBe(ProcessMode.general)
expect(result.current.segmentIdentifier).toBe(DEFAULT_SEGMENT_IDENTIFIER)
expect(result.current.maxChunkLength).toBe(DEFAULT_MAXIMUM_CHUNK_LENGTH)
expect(result.current.overlap).toBe(DEFAULT_OVERLAP)
expect(result.current.rules).toEqual([])
expect(result.current.parentChildConfig).toEqual(defaultParentChildConfig)
})
it('should accept initial segmentation type', () => {
const { result } = renderHook(() =>
useSegmentationState({ initialSegmentationType: ProcessMode.parentChild }),
)
expect(result.current.segmentationType).toBe(ProcessMode.parentChild)
})
it('should accept initial summary index setting', () => {
const setting = { enable: true }
const { result } = renderHook(() =>
useSegmentationState({ initialSummaryIndexSetting: setting }),
)
expect(result.current.summaryIndexSetting).toEqual(setting)
})
})
// --- Setters ---
describe('setters', () => {
it('should update segmentation type', () => {
const { result } = renderHook(() => useSegmentationState())
act(() => {
result.current.setSegmentationType(ProcessMode.parentChild)
})
expect(result.current.segmentationType).toBe(ProcessMode.parentChild)
})
it('should update max chunk length', () => {
const { result } = renderHook(() => useSegmentationState())
act(() => {
result.current.setMaxChunkLength(2048)
})
expect(result.current.maxChunkLength).toBe(2048)
})
it('should update overlap', () => {
const { result } = renderHook(() => useSegmentationState())
act(() => {
result.current.setOverlap(100)
})
expect(result.current.overlap).toBe(100)
})
it('should update rules', () => {
const newRules: PreProcessingRule[] = [
{ id: 'remove_extra_spaces', enabled: true },
{ id: 'remove_urls_emails', enabled: false },
]
const { result } = renderHook(() => useSegmentationState())
act(() => {
result.current.setRules(newRules)
})
expect(result.current.rules).toEqual(newRules)
})
})
// --- Segment identifier with escaping ---
describe('setSegmentIdentifier', () => {
it('should escape the value when setting', () => {
const { result } = renderHook(() => useSegmentationState())
act(() => {
result.current.setSegmentIdentifier('\n\n')
})
expect(result.current.segmentIdentifier).toBe('\\n\\n')
})
it('should reset to default when empty and canEmpty is false', () => {
const { result } = renderHook(() => useSegmentationState())
act(() => {
result.current.setSegmentIdentifier('')
})
expect(result.current.segmentIdentifier).toBe(DEFAULT_SEGMENT_IDENTIFIER)
})
it('should allow empty value when canEmpty is true', () => {
const { result } = renderHook(() => useSegmentationState())
act(() => {
result.current.setSegmentIdentifier('', true)
})
expect(result.current.segmentIdentifier).toBe('')
})
})
// --- Toggle rule ---
describe('toggleRule', () => {
it('should toggle a rule enabled state', () => {
const { result } = renderHook(() => useSegmentationState())
const rules: PreProcessingRule[] = [
{ id: 'remove_extra_spaces', enabled: true },
{ id: 'remove_urls_emails', enabled: false },
]
act(() => {
result.current.setRules(rules)
})
act(() => {
result.current.toggleRule('remove_extra_spaces')
})
expect(result.current.rules[0].enabled).toBe(false)
expect(result.current.rules[1].enabled).toBe(false)
})
it('should toggle second rule without affecting first', () => {
const { result } = renderHook(() => useSegmentationState())
const rules: PreProcessingRule[] = [
{ id: 'remove_extra_spaces', enabled: true },
{ id: 'remove_urls_emails', enabled: false },
]
act(() => {
result.current.setRules(rules)
})
act(() => {
result.current.toggleRule('remove_urls_emails')
})
expect(result.current.rules[0].enabled).toBe(true)
expect(result.current.rules[1].enabled).toBe(true)
})
})
// --- Parent-child config ---
describe('parent-child config', () => {
it('should update parent delimiter with escaping', () => {
const { result } = renderHook(() => useSegmentationState())
act(() => {
result.current.updateParentConfig('delimiter', '\n')
})
expect(result.current.parentChildConfig.parent.delimiter).toBe('\\n')
})
it('should update parent maxLength', () => {
const { result } = renderHook(() => useSegmentationState())
act(() => {
result.current.updateParentConfig('maxLength', 2048)
})
expect(result.current.parentChildConfig.parent.maxLength).toBe(2048)
})
it('should update child delimiter with escaping', () => {
const { result } = renderHook(() => useSegmentationState())
act(() => {
result.current.updateChildConfig('delimiter', '\t')
})
expect(result.current.parentChildConfig.child.delimiter).toBe('\\t')
})
it('should update child maxLength', () => {
const { result } = renderHook(() => useSegmentationState())
act(() => {
result.current.updateChildConfig('maxLength', 256)
})
expect(result.current.parentChildConfig.child.maxLength).toBe(256)
})
it('should set empty delimiter when value is empty', () => {
const { result } = renderHook(() => useSegmentationState())
act(() => {
result.current.updateParentConfig('delimiter', '')
})
expect(result.current.parentChildConfig.parent.delimiter).toBe('')
})
it('should set chunk for context mode', () => {
const { result } = renderHook(() => useSegmentationState())
act(() => {
result.current.setChunkForContext('full-doc')
})
expect(result.current.parentChildConfig.chunkForContext).toBe('full-doc')
})
})
// --- Reset to defaults ---
describe('resetToDefaults', () => {
it('should reset to default config when defaults are set', () => {
const { result } = renderHook(() => useSegmentationState())
const defaultRules: Rules = {
pre_processing_rules: [{ id: 'remove_extra_spaces', enabled: true }],
segmentation: {
separator: '---',
max_tokens: 500,
chunk_overlap: 25,
},
parent_mode: 'paragraph',
subchunk_segmentation: {
separator: '\n',
max_tokens: 200,
},
}
act(() => {
result.current.setDefaultConfig(defaultRules)
})
// Change values
act(() => {
result.current.setMaxChunkLength(2048)
result.current.setOverlap(200)
})
// Reset
act(() => {
result.current.resetToDefaults()
})
expect(result.current.maxChunkLength).toBe(500)
expect(result.current.overlap).toBe(25)
expect(result.current.parentChildConfig).toEqual(defaultParentChildConfig)
})
it('should reset parent-child config even without default config', () => {
const { result } = renderHook(() => useSegmentationState())
act(() => {
result.current.updateParentConfig('maxLength', 9999)
})
act(() => {
result.current.resetToDefaults()
})
expect(result.current.parentChildConfig).toEqual(defaultParentChildConfig)
})
})
// --- applyConfigFromRules ---
describe('applyConfigFromRules', () => {
it('should apply general config from rules', () => {
const { result } = renderHook(() => useSegmentationState())
const rulesConfig: Rules = {
pre_processing_rules: [{ id: 'remove_extra_spaces', enabled: true }],
segmentation: {
separator: '|||',
max_tokens: 800,
chunk_overlap: 30,
},
parent_mode: 'paragraph',
subchunk_segmentation: {
separator: '\n',
max_tokens: 200,
},
}
act(() => {
result.current.applyConfigFromRules(rulesConfig, false)
})
expect(result.current.maxChunkLength).toBe(800)
expect(result.current.overlap).toBe(30)
expect(result.current.rules).toEqual(rulesConfig.pre_processing_rules)
})
it('should apply hierarchical config from rules', () => {
const { result } = renderHook(() => useSegmentationState())
const rulesConfig: Rules = {
pre_processing_rules: [],
segmentation: {
separator: '\n\n',
max_tokens: 1024,
chunk_overlap: 50,
},
parent_mode: 'full-doc',
subchunk_segmentation: {
separator: '\n',
max_tokens: 256,
},
}
act(() => {
result.current.applyConfigFromRules(rulesConfig, true)
})
expect(result.current.parentChildConfig.chunkForContext).toBe('full-doc')
expect(result.current.parentChildConfig.child.maxLength).toBe(256)
})
})
// --- getProcessRule ---
describe('getProcessRule', () => {
it('should build general process rule', () => {
const { result } = renderHook(() => useSegmentationState())
const rule = result.current.getProcessRule(ChunkingMode.text)
expect(rule.mode).toBe(ProcessMode.general)
expect(rule.rules!.segmentation.max_tokens).toBe(DEFAULT_MAXIMUM_CHUNK_LENGTH)
expect(rule.rules!.segmentation.chunk_overlap).toBe(DEFAULT_OVERLAP)
})
it('should build parent-child process rule', () => {
const { result } = renderHook(() => useSegmentationState())
const rule = result.current.getProcessRule(ChunkingMode.parentChild)
expect(rule.mode).toBe('hierarchical')
expect(rule.rules!.parent_mode).toBe('paragraph')
expect(rule.rules!.subchunk_segmentation).toBeDefined()
})
it('should include summary index setting in process rule', () => {
const setting = { enable: true }
const { result } = renderHook(() =>
useSegmentationState({ initialSummaryIndexSetting: setting }),
)
const rule = result.current.getProcessRule(ChunkingMode.text)
expect(rule.summary_index_setting).toEqual(setting)
})
})
// --- Summary index setting ---
describe('handleSummaryIndexSettingChange', () => {
it('should update summary index setting', () => {
const { result } = renderHook(() =>
useSegmentationState({ initialSummaryIndexSetting: { enable: false } }),
)
act(() => {
result.current.handleSummaryIndexSettingChange({ enable: true })
})
expect(result.current.summaryIndexSetting).toEqual({ enable: true })
})
it('should merge with existing setting', () => {
const { result } = renderHook(() =>
useSegmentationState({ initialSummaryIndexSetting: { enable: true } }),
)
act(() => {
result.current.handleSummaryIndexSettingChange({ enable: false })
})
expect(result.current.summaryIndexSetting?.enable).toBe(false)
})
})
})

View File

@@ -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'
@@ -30,12 +30,8 @@ import {
} from './hooks'
import escape from './hooks/escape'
import unescape from './hooks/unescape'
import StepTwo from './index'
// ============================================
// Mock external dependencies
// ============================================
// Mock dataset detail context
const mockDataset = {
id: 'test-dataset-id',
doc_form: ChunkingMode.text,
@@ -60,10 +56,6 @@ vi.mock('@/context/dataset-detail', () => ({
selector({ dataset: mockCurrentDataset, mutateDatasetRes: mockMutateDatasetRes }),
}))
// Note: @/context/i18n is globally mocked in vitest.setup.ts, no need to mock here
// Note: @/hooks/use-breakpoints uses real import
// Mock model hooks
const mockEmbeddingModelList = [
{ provider: 'openai', model: 'text-embedding-ada-002' },
{ provider: 'cohere', model: 'embed-english-v3.0' },
@@ -170,18 +162,55 @@ vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
// Note: @/app/components/base/toast - uses real import (base component)
// Note: @/app/components/datasets/common/check-rerank-model - uses real import
// Note: @/app/components/base/float-right-container - uses real import (base component)
// Enable IS_CE_EDITION to show QA checkbox in tests
vi.mock('@/config', async () => {
const actual = await vi.importActual('@/config')
return { ...actual, IS_CE_EDITION: true }
})
// Mock PreviewDocumentPicker to allow testing handlePickerChange
vi.mock('@/app/components/datasets/common/document-picker/preview-document-picker', () => ({
// eslint-disable-next-line ts/no-explicit-any
default: ({ onChange, value, files }: { onChange: (item: any) => void, value: any, files: any[] }) => (
<div data-testid="preview-picker">
<span>{value?.name}</span>
{files?.map((f: { id: string, name: string }) => (
<button key={f.id} data-testid={`picker-${f.id}`} onClick={() => onChange(f)}>
{f.name}
</button>
))}
</div>
),
}))
// Mock checkShowMultiModalTip - requires complex model list structure
vi.mock('@/app/components/datasets/settings/utils', () => ({
checkShowMultiModalTip: () => false,
}))
// ============================================
// Test data factories
// ============================================
// Mock complex child components to avoid deep dependency chains when rendering StepTwo
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
default: ({ onSelect, readonly }: { onSelect?: (val: Record<string, string>) => void, readonly?: boolean }) => (
<div data-testid="model-selector" data-readonly={readonly}>
<button onClick={() => onSelect?.({ provider: 'openai', model: 'text-embedding-3-small' })}>Select Model</button>
</div>
),
}))
vi.mock('@/app/components/datasets/common/retrieval-method-config', () => ({
default: ({ disabled }: { disabled?: boolean }) => (
<div data-testid="retrieval-method-config" data-disabled={disabled}>
Retrieval Config
</div>
),
}))
vi.mock('@/app/components/datasets/common/economical-retrieval-method-config', () => ({
default: ({ disabled }: { disabled?: boolean }) => (
<div data-testid="economical-retrieval-config" data-disabled={disabled}>
Economical Config
</div>
),
}))
const createMockFile = (overrides?: Partial<CustomFile>): CustomFile => ({
id: 'file-1',
@@ -371,10 +400,6 @@ describe('unescape utility', () => {
})
})
// ============================================
// useSegmentationState Hook Tests
// ============================================
describe('useSegmentationState', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -2195,3 +2220,364 @@ describe('Integration Scenarios', () => {
})
})
})
// ============================================
// StepTwo Component Tests
// ============================================
describe('StepTwo Component', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCurrentDataset = null
})
afterEach(() => {
cleanup()
})
const defaultStepTwoProps = {
dataSourceType: DataSourceType.FILE,
files: [createMockFile()],
isAPIKeySet: true,
onSetting: vi.fn(),
notionCredentialId: '',
onStepChange: vi.fn(),
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<StepTwo {...defaultStepTwoProps} />)
expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument()
})
it('should show general chunking options when not in upload', () => {
render(<StepTwo {...defaultStepTwoProps} />)
// Should render the segmentation section
expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument()
})
it('should show footer with Previous and Next buttons', () => {
render(<StepTwo {...defaultStepTwoProps} />)
expect(screen.getByText(/stepTwo\.previousStep/i)).toBeInTheDocument()
expect(screen.getByText(/stepTwo\.nextStep/i)).toBeInTheDocument()
})
})
describe('Initialization', () => {
it('should fetch default process rule when not in setting mode', () => {
render(<StepTwo {...defaultStepTwoProps} />)
expect(mockFetchDefaultProcessRuleMutate).toHaveBeenCalledWith('/datasets/process-rule')
})
it('should apply config from rules when in setting mode with document detail', () => {
const docDetail = createMockDocumentDetail()
render(
<StepTwo
{...defaultStepTwoProps}
isSetting={true}
documentDetail={docDetail}
datasetId="test-id"
/>,
)
// Should not fetch default rule when isSetting
expect(mockFetchDefaultProcessRuleMutate).not.toHaveBeenCalled()
})
})
describe('User Interactions', () => {
it('should call onStepChange(-1) when Previous button is clicked', () => {
const onStepChange = vi.fn()
render(<StepTwo {...defaultStepTwoProps} onStepChange={onStepChange} />)
fireEvent.click(screen.getByText(/stepTwo\.previousStep/i))
expect(onStepChange).toHaveBeenCalledWith(-1)
})
it('should trigger handleCreate when Next Step button is clicked', async () => {
const onStepChange = vi.fn()
render(<StepTwo {...defaultStepTwoProps} onStepChange={onStepChange} />)
await act(async () => {
fireEvent.click(screen.getByText(/stepTwo\.nextStep/i))
})
// handleCreate validates, builds params, and calls executeCreation
// which calls onStepChange(1) on success
expect(onStepChange).toHaveBeenCalledWith(1)
})
it('should trigger updatePreview when preview button is clicked', () => {
render(<StepTwo {...defaultStepTwoProps} />)
// GeneralChunkingOptions renders a "Preview Chunk" button
const previewButtons = screen.getAllByText(/stepTwo\.previewChunk/i)
fireEvent.click(previewButtons[0])
// updatePreview calls estimateHook.fetchEstimate()
// No error means the handler executed successfully
})
it('should trigger handleDocFormChange through parent-child option switch', () => {
render(<StepTwo {...defaultStepTwoProps} />)
// ParentChildOptions renders an OptionCard; find the title element and click its parent card
const parentChildTitles = screen.getAllByText(/stepTwo\.parentChild/i)
// The first match is the title; click it to trigger onDocFormChange
fireEvent.click(parentChildTitles[0])
// handleDocFormChange sets docForm, segmentationType, and resets estimate
})
})
describe('Conditional Rendering', () => {
it('should show options based on currentDataset doc_form', () => {
mockCurrentDataset = { ...mockDataset, doc_form: ChunkingMode.parentChild }
render(
<StepTwo
{...defaultStepTwoProps}
datasetId="test-id"
/>,
)
// When currentDataset has parentChild doc_form, should show parent-child option
expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument()
})
it('should render setting mode with Save/Cancel buttons', () => {
const docDetail = createMockDocumentDetail()
render(
<StepTwo
{...defaultStepTwoProps}
isSetting={true}
documentDetail={docDetail}
datasetId="test-id"
/>,
)
expect(screen.getByText(/stepTwo\.save/i)).toBeInTheDocument()
expect(screen.getByText(/stepTwo\.cancel/i)).toBeInTheDocument()
})
it('should call onCancel when Cancel button is clicked in setting mode', () => {
const onCancel = vi.fn()
const docDetail = createMockDocumentDetail()
render(
<StepTwo
{...defaultStepTwoProps}
isSetting={true}
documentDetail={docDetail}
datasetId="test-id"
onCancel={onCancel}
/>,
)
fireEvent.click(screen.getByText(/stepTwo\.cancel/i))
expect(onCancel).toHaveBeenCalled()
})
it('should trigger handleCreate (Save) in setting mode', async () => {
const onSave = vi.fn()
const docDetail = createMockDocumentDetail()
render(
<StepTwo
{...defaultStepTwoProps}
isSetting={true}
documentDetail={docDetail}
datasetId="test-id"
onSave={onSave}
/>,
)
await act(async () => {
fireEvent.click(screen.getByText(/stepTwo\.save/i))
})
// handleCreate → validateParams → buildCreationParams → executeCreation → onSave
expect(onSave).toHaveBeenCalled()
})
it('should show both general and parent-child options in create page', () => {
render(<StepTwo {...defaultStepTwoProps} />)
// When isInInit (no datasetId, no isSetting), both options should show
expect(screen.getByText('datasetCreation.stepTwo.general')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepTwo.parentChild')).toBeInTheDocument()
})
it('should only show parent-child option when dataset has parentChild doc_form', () => {
mockCurrentDataset = { ...mockDataset, doc_form: ChunkingMode.parentChild }
render(
<StepTwo
{...defaultStepTwoProps}
datasetId="test-id"
/>,
)
// showGeneralOption should be false (parentChild not in [text, qa])
// showParentChildOption should be true
expect(screen.getByText('datasetCreation.stepTwo.parentChild')).toBeInTheDocument()
})
it('should show general option only when dataset has text doc_form', () => {
mockCurrentDataset = { ...mockDataset, doc_form: ChunkingMode.text }
render(
<StepTwo
{...defaultStepTwoProps}
datasetId="test-id"
/>,
)
// showGeneralOption should be true (text is in [text, qa])
expect(screen.getByText('datasetCreation.stepTwo.general')).toBeInTheDocument()
})
})
describe('Upload in Dataset', () => {
it('should show general option when in upload with text doc_form', () => {
mockCurrentDataset = { ...mockDataset, doc_form: ChunkingMode.text }
render(
<StepTwo
{...defaultStepTwoProps}
datasetId="test-id"
/>,
)
expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument()
})
it('should show general option for empty dataset (no doc_form)', () => {
// eslint-disable-next-line ts/no-explicit-any
mockCurrentDataset = { ...mockDataset, doc_form: undefined as any }
render(
<StepTwo
{...defaultStepTwoProps}
datasetId="test-id"
/>,
)
expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument()
})
it('should show both options in empty dataset upload', () => {
// eslint-disable-next-line ts/no-explicit-any
mockCurrentDataset = { ...mockDataset, doc_form: undefined as any }
render(
<StepTwo
{...defaultStepTwoProps}
datasetId="test-id"
/>,
)
// isUploadInEmptyDataset=true shows both options
expect(screen.getByText('datasetCreation.stepTwo.general')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepTwo.parentChild')).toBeInTheDocument()
})
})
describe('Indexing Mode', () => {
it('should render indexing mode section', () => {
render(<StepTwo {...defaultStepTwoProps} />)
// IndexingModeSection renders the index mode title
expect(screen.getByText(/stepTwo\.indexMode/i)).toBeInTheDocument()
})
it('should render embedding model selector when QUALIFIED', () => {
render(<StepTwo {...defaultStepTwoProps} />)
// ModelSelector is mocked and rendered with data-testid
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
})
it('should render retrieval method config', () => {
render(<StepTwo {...defaultStepTwoProps} />)
// RetrievalMethodConfig is mocked with data-testid
expect(screen.getByTestId('retrieval-method-config')).toBeInTheDocument()
})
it('should disable model and retrieval config when datasetId has existing data source', () => {
mockCurrentDataset = { ...mockDataset, data_source_type: DataSourceType.FILE }
render(
<StepTwo
{...defaultStepTwoProps}
datasetId="test-id"
/>,
)
// isModelAndRetrievalConfigDisabled should be true
const modelSelector = screen.getByTestId('model-selector')
expect(modelSelector).toHaveAttribute('data-readonly', 'true')
})
})
describe('Preview Panel', () => {
it('should render preview panel', () => {
render(<StepTwo {...defaultStepTwoProps} />)
expect(screen.getByText('datasetCreation.stepTwo.preview')).toBeInTheDocument()
})
it('should hide document picker in setting mode', () => {
const docDetail = createMockDocumentDetail()
render(
<StepTwo
{...defaultStepTwoProps}
isSetting={true}
documentDetail={docDetail}
datasetId="test-id"
/>,
)
// Preview panel should still render
expect(screen.getByText('datasetCreation.stepTwo.preview')).toBeInTheDocument()
})
})
describe('Handler Functions - Uncovered Paths', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCurrentDataset = null
})
afterEach(() => {
cleanup()
})
it('should switch to QUALIFIED when selecting parentChild in ECONOMICAL mode', async () => {
render(<StepTwo {...defaultStepTwoProps} isAPIKeySet={false} />)
await vi.waitFor(() => {
expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument()
})
// Click parentChild option to trigger handleDocFormChange(ChunkingMode.parentChild) with ECONOMICAL
const parentChildTitles = screen.getAllByText(/stepTwo\.parentChild/i)
fireEvent.click(parentChildTitles[0])
})
it('should open QA confirm dialog and confirm switch when QA selected in ECONOMICAL mode', async () => {
render(<StepTwo {...defaultStepTwoProps} isAPIKeySet={false} />)
await vi.waitFor(() => {
expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument()
})
// Click QA checkbox (visible because IS_CE_EDITION is mocked as true)
const qaCheckbox = screen.getByText(/stepTwo\.useQALanguage/i)
fireEvent.click(qaCheckbox)
// Dialog should open → click Switch to confirm (triggers handleQAConfirm)
const switchButton = await screen.findByText(/stepTwo\.switch/i)
expect(switchButton).toBeInTheDocument()
fireEvent.click(switchButton)
})
it('should close QA confirm dialog when cancel is clicked', async () => {
render(<StepTwo {...defaultStepTwoProps} isAPIKeySet={false} />)
await vi.waitFor(() => {
expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument()
})
// Open QA confirm dialog
const qaCheckbox = screen.getByText(/stepTwo\.useQALanguage/i)
fireEvent.click(qaCheckbox)
// Click the dialog cancel button (onQAConfirmDialogClose)
const dialogCancelButtons = await screen.findAllByText(/stepTwo\.cancel/i)
fireEvent.click(dialogCancelButtons[0])
})
it('should handle picker change when selecting a different file', () => {
const files = [
createMockFile({ id: 'file-1', name: 'first.pdf', extension: 'pdf' }),
createMockFile({ id: 'file-2', name: 'second.pdf', extension: 'pdf' }),
]
render(<StepTwo {...defaultStepTwoProps} files={files} />)
// Click on the second file in the mocked picker (triggers handlePickerChange)
const pickerButton = screen.getByTestId('picker-file-2')
fireEvent.click(pickerButton)
})
it('should show error toast when preview is clicked with maxChunkLength exceeding limit', () => {
// Set a high maxChunkLength via the DOM attribute
document.body.setAttribute('data-public-indexing-max-segmentation-tokens-length', '100')
render(<StepTwo {...defaultStepTwoProps} />)
// The default maxChunkLength (1024) now exceeds the limit (100)
// Click preview button to trigger updatePreview error path
const previewButtons = screen.getAllByText(/stepTwo\.previewChunk/i)
fireEvent.click(previewButtons[0])
// Restore
document.body.removeAttribute('data-public-indexing-max-segmentation-tokens-length')
})
})
})

View File

@@ -0,0 +1,32 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { StepperStep } from './step'
describe('StepperStep', () => {
it('should render step name', () => {
render(<StepperStep name="Configure" index={0} activeIndex={0} />)
expect(screen.getByText('Configure')).toBeInTheDocument()
})
it('should show "STEP N" label for active step', () => {
render(<StepperStep name="Configure" index={1} activeIndex={1} />)
expect(screen.getByText('STEP 2')).toBeInTheDocument()
})
it('should show just number for non-active step', () => {
render(<StepperStep name="Configure" index={1} activeIndex={0} />)
expect(screen.getByText('2')).toBeInTheDocument()
})
it('should apply accent style for active step', () => {
render(<StepperStep name="Step A" index={0} activeIndex={0} />)
const nameEl = screen.getByText('Step A')
expect(nameEl.className).toContain('text-text-accent')
})
it('should apply disabled style for future step', () => {
render(<StepperStep name="Step C" index={2} activeIndex={0} />)
const nameEl = screen.getByText('Step C')
expect(nameEl.className).toContain('text-text-quaternary')
})
})

View File

@@ -0,0 +1,43 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CheckboxWithLabel from './checkbox-with-label'
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent?: React.ReactNode }) => <div data-testid="tooltip">{popupContent}</div>,
}))
describe('CheckboxWithLabel', () => {
const onChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render label', () => {
render(<CheckboxWithLabel isChecked={false} onChange={onChange} label="Accept terms" />)
expect(screen.getByText('Accept terms')).toBeInTheDocument()
})
it('should render tooltip when provided', () => {
render(
<CheckboxWithLabel
isChecked={false}
onChange={onChange}
label="Option"
tooltip="Help text"
/>,
)
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
})
it('should not render tooltip when not provided', () => {
render(<CheckboxWithLabel isChecked={false} onChange={onChange} label="Option" />)
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument()
})
it('should toggle checked state on checkbox click', () => {
render(<CheckboxWithLabel isChecked={false} onChange={onChange} label="Toggle" testId="my-check" />)
fireEvent.click(screen.getByTestId('checkbox-my-check'))
expect(onChange).toHaveBeenCalledWith(true)
})
})

View File

@@ -0,0 +1,47 @@
import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CrawledResultItem from './crawled-result-item'
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
describe('CrawledResultItem', () => {
const defaultProps = {
payload: { title: 'Example Page', source_url: 'https://example.com/page' } as CrawlResultItemType,
isChecked: false,
isPreview: false,
onCheckChange: vi.fn(),
onPreview: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render title and url', () => {
render(<CrawledResultItem {...defaultProps} />)
expect(screen.getByText('Example Page')).toBeInTheDocument()
expect(screen.getByText('https://example.com/page')).toBeInTheDocument()
})
it('should apply active styling when isPreview', () => {
const { container } = render(<CrawledResultItem {...defaultProps} isPreview={true} />)
expect((container.firstChild as HTMLElement).className).toContain('bg-state-base-active')
})
it('should call onCheckChange with true when unchecked checkbox is clicked', () => {
render(<CrawledResultItem {...defaultProps} isChecked={false} testId="crawl-item" />)
const checkbox = screen.getByTestId('checkbox-crawl-item')
fireEvent.click(checkbox)
expect(defaultProps.onCheckChange).toHaveBeenCalledWith(true)
})
it('should call onCheckChange with false when checked checkbox is clicked', () => {
render(<CrawledResultItem {...defaultProps} isChecked={true} testId="crawl-item" />)
const checkbox = screen.getByTestId('checkbox-crawl-item')
fireEvent.click(checkbox)
expect(defaultProps.onCheckChange).toHaveBeenCalledWith(false)
})
})

View File

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

View File

@@ -0,0 +1,23 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import Crawling from './crawling'
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('@/app/components/base/icons/src/public/other', () => ({
RowStruct: (props: React.HTMLAttributes<HTMLDivElement>) => <div data-testid="row-struct" {...props} />,
}))
describe('Crawling', () => {
it('should render crawled count and total', () => {
render(<Crawling crawledNum={3} totalNum={10} />)
expect(screen.getByText(/3/)).toBeInTheDocument()
expect(screen.getByText(/10/)).toBeInTheDocument()
})
it('should render skeleton rows', () => {
render(<Crawling crawledNum={0} totalNum={5} />)
expect(screen.getAllByTestId('row-struct')).toHaveLength(4)
})
})

View File

@@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ErrorMessage from './error-message'
vi.mock('@/app/components/base/icons/src/vender/solid/alertsAndFeedback', () => ({
AlertTriangle: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="alert-icon" {...props} />,
}))
describe('ErrorMessage', () => {
it('should render title', () => {
render(<ErrorMessage title="Something went wrong" />)
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
})
it('should render error message when provided', () => {
render(<ErrorMessage title="Error" errorMsg="Detailed error info" />)
expect(screen.getByText('Detailed error info')).toBeInTheDocument()
})
it('should not render error message when not provided', () => {
render(<ErrorMessage title="Error" />)
expect(screen.queryByText('Detailed error info')).not.toBeInTheDocument()
})
it('should render alert icon', () => {
render(<ErrorMessage title="Error" />)
expect(screen.getByTestId('alert-icon')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,46 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Field from './field'
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent?: React.ReactNode }) => <div data-testid="tooltip">{popupContent}</div>,
}))
describe('WebsiteField', () => {
const onChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render label', () => {
render(<Field label="URL" value="" onChange={onChange} />)
expect(screen.getByText('URL')).toBeInTheDocument()
})
it('should render required asterisk when isRequired', () => {
render(<Field label="URL" value="" onChange={onChange} isRequired />)
expect(screen.getByText('*')).toBeInTheDocument()
})
it('should not render required asterisk by default', () => {
render(<Field label="URL" value="" onChange={onChange} />)
expect(screen.queryByText('*')).not.toBeInTheDocument()
})
it('should render tooltip when provided', () => {
render(<Field label="URL" value="" onChange={onChange} tooltip="Enter full URL" />)
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
})
it('should pass value and onChange to Input', () => {
render(<Field label="URL" value="https://example.com" onChange={onChange} />)
expect(screen.getByDisplayValue('https://example.com')).toBeInTheDocument()
})
it('should call onChange when input changes', () => {
render(<Field label="URL" value="" onChange={onChange} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new' } })
expect(onChange).toHaveBeenCalledWith('new')
})
})

View File

@@ -0,0 +1,50 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Header from './header'
vi.mock('@remixicon/react', () => ({
RiBookOpenLine: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="book-icon" {...props} />,
RiEqualizer2Line: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="config-icon" {...props} />,
}))
describe('WebsiteHeader', () => {
const defaultProps = {
title: 'Jina Reader',
docTitle: 'Documentation',
docLink: 'https://docs.example.com',
onClickConfiguration: vi.fn(),
buttonText: 'Config',
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render title', () => {
render(<Header {...defaultProps} />)
expect(screen.getByText('Jina Reader')).toBeInTheDocument()
})
it('should render doc link with correct href', () => {
render(<Header {...defaultProps} />)
const link = screen.getByText('Documentation').closest('a')
expect(link).toHaveAttribute('href', 'https://docs.example.com')
expect(link).toHaveAttribute('target', '_blank')
})
it('should render configuration button with text when not in pipeline', () => {
render(<Header {...defaultProps} />)
expect(screen.getByText('Config')).toBeInTheDocument()
})
it('should call onClickConfiguration on button click', () => {
render(<Header {...defaultProps} />)
fireEvent.click(screen.getByText('Config').closest('button')!)
expect(defaultProps.onClickConfiguration).toHaveBeenCalledOnce()
})
it('should hide button text when isInPipeline', () => {
render(<Header {...defaultProps} isInPipeline={true} />)
expect(screen.queryByText('Config')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,52 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Input from './input'
describe('WebsiteInput', () => {
const onChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render text input by default', () => {
render(<Input value="hello" onChange={onChange} />)
const input = screen.getByDisplayValue('hello')
expect(input).toHaveAttribute('type', 'text')
})
it('should render number input when isNumber is true', () => {
render(<Input value={42} onChange={onChange} isNumber />)
const input = screen.getByDisplayValue('42')
expect(input).toHaveAttribute('type', 'number')
})
it('should call onChange with string value for text input', () => {
render(<Input value="" onChange={onChange} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new value' } })
expect(onChange).toHaveBeenCalledWith('new value')
})
it('should call onChange with parsed integer for number input', () => {
render(<Input value={0} onChange={onChange} isNumber />)
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '10' } })
expect(onChange).toHaveBeenCalledWith(10)
})
it('should call onChange with empty string for NaN number input', () => {
render(<Input value={0} onChange={onChange} isNumber />)
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: 'abc' } })
expect(onChange).toHaveBeenCalledWith('')
})
it('should clamp negative numbers to 0', () => {
render(<Input value={0} onChange={onChange} isNumber />)
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '-5' } })
expect(onChange).toHaveBeenCalledWith(0)
})
it('should render placeholder', () => {
render(<Input value="" onChange={onChange} placeholder="Enter URL" />)
expect(screen.getByPlaceholderText('Enter URL')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,51 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import OptionsWrap from './options-wrap'
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('@remixicon/react', () => ({
RiEqualizer2Line: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="config-icon" {...props} />,
}))
vi.mock('@/app/components/base/icons/src/vender/line/arrows', () => ({
ChevronRight: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="chevron-icon" {...props} />,
}))
describe('OptionsWrap', () => {
it('should render children when not folded', () => {
render(
<OptionsWrap>
<div data-testid="child-content">Options here</div>
</OptionsWrap>,
)
expect(screen.getByTestId('child-content')).toBeInTheDocument()
})
it('should toggle fold on click', () => {
render(
<OptionsWrap>
<div data-testid="child-content">Options here</div>
</OptionsWrap>,
)
// Initially visible
expect(screen.getByTestId('child-content')).toBeInTheDocument()
// Click to fold
fireEvent.click(screen.getByText('stepOne.website.options'))
expect(screen.queryByTestId('child-content')).not.toBeInTheDocument()
// Click to unfold
fireEvent.click(screen.getByText('stepOne.website.options'))
expect(screen.getByTestId('child-content')).toBeInTheDocument()
})
it('should render options label', () => {
render(
<OptionsWrap>
<div>Content</div>
</OptionsWrap>,
)
expect(screen.getByText('stepOne.website.options')).toBeInTheDocument()
})
})

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

View File

@@ -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&param=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)
})
})
})

View File

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

View File

@@ -0,0 +1,230 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DataSourceProvider } from '@/models/common'
import NoData from './no-data'
// ============================================================================
// Mock Setup
// ============================================================================
// Mock CSS module
vi.mock('./index.module.css', () => ({
default: {
jinaLogo: 'jinaLogo',
watercrawlLogo: 'watercrawlLogo',
},
}))
// Feature flags - default all enabled
let mockEnableFirecrawl = true
let mockEnableJinaReader = true
let mockEnableWaterCrawl = true
vi.mock('@/config', () => ({
get ENABLE_WEBSITE_FIRECRAWL() { return mockEnableFirecrawl },
get ENABLE_WEBSITE_JINAREADER() { return mockEnableJinaReader },
get ENABLE_WEBSITE_WATERCRAWL() { return mockEnableWaterCrawl },
}))
// ============================================================================
// NoData Component Tests
// ============================================================================
describe('NoData', () => {
const mockOnConfig = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockEnableFirecrawl = true
mockEnableJinaReader = true
mockEnableWaterCrawl = true
})
// --------------------------------------------------------------------------
// Rendering Tests - Per Provider
// --------------------------------------------------------------------------
describe('Rendering per provider', () => {
it('should render fireCrawl provider with emoji and not-configured message', () => {
// Arrange & Act
render(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.fireCrawl} />)
// Assert
expect(screen.getByText('🔥')).toBeInTheDocument()
const titleAndDesc = screen.getAllByText(/fireCrawlNotConfigured/i)
expect(titleAndDesc).toHaveLength(2)
})
it('should render jinaReader provider with jina logo and not-configured message', () => {
// Arrange & Act
render(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.jinaReader} />)
// Assert
const titleAndDesc = screen.getAllByText(/jinaReaderNotConfigured/i)
expect(titleAndDesc).toHaveLength(2)
})
it('should render waterCrawl provider with emoji and not-configured message', () => {
// Arrange & Act
render(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.waterCrawl} />)
// Assert
expect(screen.getByText('💧')).toBeInTheDocument()
const titleAndDesc = screen.getAllByText(/waterCrawlNotConfigured/i)
expect(titleAndDesc).toHaveLength(2)
})
it('should render configure button for each provider', () => {
// Arrange & Act
render(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.fireCrawl} />)
// Assert
expect(screen.getByRole('button', { name: /configure/i })).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// User Interactions
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onConfig when configure button is clicked', () => {
// Arrange
render(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.fireCrawl} />)
// Act
fireEvent.click(screen.getByRole('button', { name: /configure/i }))
// Assert
expect(mockOnConfig).toHaveBeenCalledTimes(1)
})
it('should call onConfig for jinaReader provider', () => {
// Arrange
render(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.jinaReader} />)
// Act
fireEvent.click(screen.getByRole('button', { name: /configure/i }))
// Assert
expect(mockOnConfig).toHaveBeenCalledTimes(1)
})
it('should call onConfig for waterCrawl provider', () => {
// Arrange
render(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.waterCrawl} />)
// Act
fireEvent.click(screen.getByRole('button', { name: /configure/i }))
// Assert
expect(mockOnConfig).toHaveBeenCalledTimes(1)
})
})
// --------------------------------------------------------------------------
// Feature Flag Disabled - Returns null
// --------------------------------------------------------------------------
describe('Disabled providers (feature flag off)', () => {
it('should fall back to jinaReader when fireCrawl is disabled but jinaReader enabled', () => {
// Arrange — fireCrawl config is null, falls back to providerConfig.jinareader
mockEnableFirecrawl = false
// Act
const { container } = render(
<NoData onConfig={mockOnConfig} provider={DataSourceProvider.fireCrawl} />,
)
// Assert — renders the jinaReader fallback (not null)
expect(container.innerHTML).not.toBe('')
expect(screen.getAllByText(/jinaReaderNotConfigured/).length).toBeGreaterThan(0)
})
it('should return null when jinaReader is disabled', () => {
// Arrange — jinaReader is the only provider without a fallback
mockEnableJinaReader = false
// Act
const { container } = render(
<NoData onConfig={mockOnConfig} provider={DataSourceProvider.jinaReader} />,
)
// Assert
expect(container.innerHTML).toBe('')
})
it('should fall back to jinaReader when waterCrawl is disabled but jinaReader enabled', () => {
// Arrange — waterCrawl config is null, falls back to providerConfig.jinareader
mockEnableWaterCrawl = false
// Act
const { container } = render(
<NoData onConfig={mockOnConfig} provider={DataSourceProvider.waterCrawl} />,
)
// Assert — renders the jinaReader fallback (not null)
expect(container.innerHTML).not.toBe('')
expect(screen.getAllByText(/jinaReaderNotConfigured/).length).toBeGreaterThan(0)
})
})
// --------------------------------------------------------------------------
// Fallback behavior
// --------------------------------------------------------------------------
describe('Fallback behavior', () => {
it('should fall back to jinaReader config for unknown provider value', () => {
// Arrange - the || fallback goes to providerConfig.jinareader
// Since DataSourceProvider only has 3 values, we test the fallback
// by checking that jinaReader is the fallback when provider doesn't match
mockEnableJinaReader = true
// Act
render(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.jinaReader} />)
// Assert
expect(screen.getAllByText(/jinaReaderNotConfigured/i).length).toBeGreaterThan(0)
})
})
// --------------------------------------------------------------------------
// Edge Cases
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should not call onConfig without user interaction', () => {
// Arrange & Act
render(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.fireCrawl} />)
// Assert
expect(mockOnConfig).not.toHaveBeenCalled()
})
it('should render correctly when all providers are enabled', () => {
// Arrange - all flags are true by default
// Act
const { rerender } = render(
<NoData onConfig={mockOnConfig} provider={DataSourceProvider.fireCrawl} />,
)
expect(screen.getByText('🔥')).toBeInTheDocument()
rerender(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.jinaReader} />)
expect(screen.getAllByText(/jinaReaderNotConfigured/i).length).toBeGreaterThan(0)
rerender(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.waterCrawl} />)
expect(screen.getByText('💧')).toBeInTheDocument()
})
it('should return null when all providers are disabled and fireCrawl is selected', () => {
// Arrange
mockEnableFirecrawl = false
mockEnableJinaReader = false
mockEnableWaterCrawl = false
// Act
const { container } = render(
<NoData onConfig={mockOnConfig} provider={DataSourceProvider.fireCrawl} />,
)
// Assert
expect(container.innerHTML).toBe('')
})
})
})

View File

@@ -0,0 +1,256 @@
import type { CrawlResultItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import WebsitePreview from './preview'
// ============================================================================
// Mock Setup
// ============================================================================
// Mock the CSS module import - returns class names as-is
vi.mock('../file-preview/index.module.css', () => ({
default: {
filePreview: 'filePreview',
previewHeader: 'previewHeader',
title: 'title',
previewContent: 'previewContent',
fileContent: 'fileContent',
},
}))
// ============================================================================
// Test Data Factory
// ============================================================================
const createPayload = (overrides: Partial<CrawlResultItem> = {}): CrawlResultItem => ({
title: 'Test Page Title',
markdown: 'This is **markdown** content',
description: 'A test description',
source_url: 'https://example.com/page',
...overrides,
})
// ============================================================================
// WebsitePreview Component Tests
// ============================================================================
describe('WebsitePreview', () => {
const mockHidePreview = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const payload = createPayload()
// Act
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
// Assert
expect(screen.getByText('Test Page Title')).toBeInTheDocument()
})
it('should render the page preview header text', () => {
// Arrange
const payload = createPayload()
// Act
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
// Assert - i18n returns the key path
expect(screen.getByText(/pagePreview/i)).toBeInTheDocument()
})
it('should render the payload title', () => {
// Arrange
const payload = createPayload({ title: 'My Custom Page' })
// Act
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
// Assert
expect(screen.getByText('My Custom Page')).toBeInTheDocument()
})
it('should render the payload source_url', () => {
// Arrange
const payload = createPayload({ source_url: 'https://docs.dify.ai/intro' })
// Act
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
// Assert
const urlElement = screen.getByText('https://docs.dify.ai/intro')
expect(urlElement).toBeInTheDocument()
expect(urlElement).toHaveAttribute('title', 'https://docs.dify.ai/intro')
})
it('should render the payload markdown content', () => {
// Arrange
const payload = createPayload({ markdown: 'Hello world markdown' })
// Act
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
// Assert
expect(screen.getByText('Hello world markdown')).toBeInTheDocument()
})
it('should render the close button (XMarkIcon)', () => {
// Arrange
const payload = createPayload()
// Act
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
// Assert - the close button container is a div with cursor-pointer
const closeButton = screen.getByText(/pagePreview/i).parentElement?.querySelector('.cursor-pointer')
expect(closeButton).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// User Interactions
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call hidePreview when close button is clicked', () => {
// Arrange
const payload = createPayload()
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
// Act - find the close button div with cursor-pointer class
const closeButton = screen.getByText(/pagePreview/i)
.closest('[class*="title"]')!
.querySelector('.cursor-pointer') as HTMLElement
fireEvent.click(closeButton)
// Assert
expect(mockHidePreview).toHaveBeenCalledTimes(1)
})
it('should call hidePreview exactly once per click', () => {
// Arrange
const payload = createPayload()
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
// Act
const closeButton = screen.getByText(/pagePreview/i)
.closest('[class*="title"]')!
.querySelector('.cursor-pointer') as HTMLElement
fireEvent.click(closeButton)
fireEvent.click(closeButton)
// Assert
expect(mockHidePreview).toHaveBeenCalledTimes(2)
})
})
// --------------------------------------------------------------------------
// Props Display Tests
// --------------------------------------------------------------------------
describe('Props Display', () => {
it('should display all payload fields simultaneously', () => {
// Arrange
const payload = createPayload({
title: 'Full Title',
source_url: 'https://full.example.com',
markdown: 'Full markdown text',
})
// Act
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
// Assert
expect(screen.getByText('Full Title')).toBeInTheDocument()
expect(screen.getByText('https://full.example.com')).toBeInTheDocument()
expect(screen.getByText('Full markdown text')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Edge Cases
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should render with empty title', () => {
// Arrange
const payload = createPayload({ title: '' })
// Act
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
// Assert - component still renders, url is visible
expect(screen.getByText('https://example.com/page')).toBeInTheDocument()
})
it('should render with empty markdown', () => {
// Arrange
const payload = createPayload({ markdown: '' })
// Act
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
// Assert
expect(screen.getByText('Test Page Title')).toBeInTheDocument()
})
it('should render with empty source_url', () => {
// Arrange
const payload = createPayload({ source_url: '' })
// Act
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
// Assert
expect(screen.getByText('Test Page Title')).toBeInTheDocument()
})
it('should render with very long content', () => {
// Arrange
const longMarkdown = 'A'.repeat(5000)
const payload = createPayload({ markdown: longMarkdown })
// Act
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
// Assert
expect(screen.getByText(longMarkdown)).toBeInTheDocument()
})
it('should render with special characters in title', () => {
// Arrange
const payload = createPayload({ title: '<script>alert("xss")</script>' })
// Act
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
// Assert - React escapes HTML by default
expect(screen.getByText('<script>alert("xss")</script>')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// CSS Module Classes
// --------------------------------------------------------------------------
describe('CSS Module Classes', () => {
it('should apply filePreview class to root container', () => {
// Arrange
const payload = createPayload()
// Act
const { container } = render(
<WebsitePreview payload={payload} hidePreview={mockHidePreview} />,
)
// Assert
const root = container.firstElementChild
expect(root?.className).toContain('filePreview')
expect(root?.className).toContain('h-full')
})
})
})

View File

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

View File

@@ -0,0 +1,237 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DocumentActionType } from '@/models/datasets'
import { useDocumentActions } from './use-document-actions'
const mockArchive = vi.fn()
const mockSummary = vi.fn()
const mockEnable = vi.fn()
const mockDisable = vi.fn()
const mockDelete = vi.fn()
const mockRetryIndex = vi.fn()
const mockDownloadZip = vi.fn()
let mockIsDownloadingZip = false
vi.mock('@/service/knowledge/use-document', () => ({
useDocumentArchive: () => ({ mutateAsync: mockArchive }),
useDocumentSummary: () => ({ mutateAsync: mockSummary }),
useDocumentEnable: () => ({ mutateAsync: mockEnable }),
useDocumentDisable: () => ({ mutateAsync: mockDisable }),
useDocumentDelete: () => ({ mutateAsync: mockDelete }),
useDocumentBatchRetryIndex: () => ({ mutateAsync: mockRetryIndex }),
useDocumentDownloadZip: () => ({ mutateAsync: mockDownloadZip, isPending: mockIsDownloadingZip }),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
const mockToastNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
default: { notify: (...args: unknown[]) => mockToastNotify(...args) },
}))
const mockDownloadBlob = vi.fn()
vi.mock('@/utils/download', () => ({
downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args),
}))
describe('useDocumentActions', () => {
const defaultOptions = {
datasetId: 'ds-1',
selectedIds: ['doc-1', 'doc-2'],
downloadableSelectedIds: ['doc-1'],
onUpdate: vi.fn(),
onClearSelection: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
mockIsDownloadingZip = false
})
it('should return expected functions and state', () => {
const { result } = renderHook(() => useDocumentActions(defaultOptions))
expect(result.current.handleAction).toBeInstanceOf(Function)
expect(result.current.handleBatchReIndex).toBeInstanceOf(Function)
expect(result.current.handleBatchDownload).toBeInstanceOf(Function)
expect(typeof result.current.isDownloadingZip).toBe('boolean')
})
describe('handleAction', () => {
it('should call archive API and show success toast', async () => {
mockArchive.mockResolvedValue({ result: 'success' })
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleAction(DocumentActionType.archive)()
})
expect(mockArchive).toHaveBeenCalledWith({
datasetId: 'ds-1',
documentIds: ['doc-1', 'doc-2'],
})
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'success' }),
)
expect(defaultOptions.onUpdate).toHaveBeenCalled()
})
it('should call enable API on enable action', async () => {
mockEnable.mockResolvedValue({ result: 'success' })
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleAction(DocumentActionType.enable)()
})
expect(mockEnable).toHaveBeenCalledWith({
datasetId: 'ds-1',
documentIds: ['doc-1', 'doc-2'],
})
expect(defaultOptions.onUpdate).toHaveBeenCalled()
})
it('should call disable API on disable action', async () => {
mockDisable.mockResolvedValue({ result: 'success' })
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleAction(DocumentActionType.disable)()
})
expect(mockDisable).toHaveBeenCalled()
})
it('should call summary API on summary action', async () => {
mockSummary.mockResolvedValue({ result: 'success' })
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleAction(DocumentActionType.summary)()
})
expect(mockSummary).toHaveBeenCalled()
})
it('should call onClearSelection on delete action success', async () => {
mockDelete.mockResolvedValue({ result: 'success' })
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleAction(DocumentActionType.delete)()
})
expect(mockDelete).toHaveBeenCalled()
expect(defaultOptions.onClearSelection).toHaveBeenCalled()
expect(defaultOptions.onUpdate).toHaveBeenCalled()
})
it('should not call onClearSelection on non-delete action success', async () => {
mockArchive.mockResolvedValue({ result: 'success' })
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleAction(DocumentActionType.archive)()
})
expect(defaultOptions.onClearSelection).not.toHaveBeenCalled()
})
it('should show error toast on action failure', async () => {
mockArchive.mockRejectedValue(new Error('fail'))
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleAction(DocumentActionType.archive)()
})
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
expect(defaultOptions.onUpdate).not.toHaveBeenCalled()
})
})
describe('handleBatchReIndex', () => {
it('should call retry index API and show success toast', async () => {
mockRetryIndex.mockResolvedValue({ result: 'success' })
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleBatchReIndex()
})
expect(mockRetryIndex).toHaveBeenCalledWith({
datasetId: 'ds-1',
documentIds: ['doc-1', 'doc-2'],
})
expect(defaultOptions.onClearSelection).toHaveBeenCalled()
expect(defaultOptions.onUpdate).toHaveBeenCalled()
})
it('should show error toast on reindex failure', async () => {
mockRetryIndex.mockRejectedValue(new Error('fail'))
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleBatchReIndex()
})
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
})
describe('handleBatchDownload', () => {
it('should download blob on success', async () => {
const blob = new Blob(['test'])
mockDownloadZip.mockResolvedValue(blob)
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleBatchDownload()
})
expect(mockDownloadZip).toHaveBeenCalledWith({
datasetId: 'ds-1',
documentIds: ['doc-1'],
})
expect(mockDownloadBlob).toHaveBeenCalledWith(
expect.objectContaining({
data: blob,
fileName: expect.stringContaining('-docs.zip'),
}),
)
})
it('should show error toast on download failure', async () => {
mockDownloadZip.mockRejectedValue(new Error('fail'))
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleBatchDownload()
})
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
it('should show error toast when blob is null', async () => {
mockDownloadZip.mockResolvedValue(null)
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleBatchDownload()
})
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
})
})

View File

@@ -0,0 +1,33 @@
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import DatasourceIcon from './datasource-icon'
describe('DatasourceIcon', () => {
it('should render icon with background image', () => {
const { container } = render(<DatasourceIcon iconUrl="https://example.com/icon.png" />)
const iconDiv = container.querySelector('[style*="background-image"]')
expect(iconDiv).not.toBeNull()
expect(iconDiv?.getAttribute('style')).toContain('https://example.com/icon.png')
})
it('should apply size class for sm', () => {
const { container } = render(<DatasourceIcon iconUrl="/icon.png" size="sm" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('w-5')
expect(wrapper.className).toContain('h-5')
})
it('should apply size class for md', () => {
const { container } = render(<DatasourceIcon iconUrl="/icon.png" size="md" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('w-6')
expect(wrapper.className).toContain('h-6')
})
it('should apply size class for xs', () => {
const { container } = render(<DatasourceIcon iconUrl="/icon.png" size="xs" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('w-4')
expect(wrapper.className).toContain('h-4')
})
})

View File

@@ -0,0 +1,141 @@
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
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')
})
})
})

View File

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

View File

@@ -0,0 +1,45 @@
import type { DataSourceCredential } from '@/types/pipeline'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Item from './item'
vi.mock('@remixicon/react', () => ({
RiCheckLine: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="check-icon" {...props} />,
}))
vi.mock('@/app/components/datasets/common/credential-icon', () => ({
CredentialIcon: () => <span data-testid="credential-icon" />,
}))
describe('CredentialSelectorItem', () => {
const defaultProps = {
credential: { id: 'cred-1', name: 'My Account', avatar_url: 'https://example.com/avatar.png' } as DataSourceCredential,
isSelected: false,
onCredentialChange: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render credential name and icon', () => {
render(<Item {...defaultProps} />)
expect(screen.getByText('My Account')).toBeInTheDocument()
expect(screen.getByTestId('credential-icon')).toBeInTheDocument()
})
it('should show check icon when selected', () => {
render(<Item {...defaultProps} isSelected={true} />)
expect(screen.getByTestId('check-icon')).toBeInTheDocument()
})
it('should not show check icon when not selected', () => {
render(<Item {...defaultProps} isSelected={false} />)
expect(screen.queryByTestId('check-icon')).not.toBeInTheDocument()
})
it('should call onCredentialChange with credential id on click', () => {
render(<Item {...defaultProps} />)
fireEvent.click(screen.getByText('My Account'))
expect(defaultProps.onCredentialChange).toHaveBeenCalledWith('cred-1')
})
})

View File

@@ -0,0 +1,46 @@
import type { DataSourceCredential } from '@/types/pipeline'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import List from './list'
vi.mock('@remixicon/react', () => ({
RiCheckLine: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="check-icon" {...props} />,
}))
vi.mock('@/app/components/datasets/common/credential-icon', () => ({
CredentialIcon: () => <span data-testid="credential-icon" />,
}))
describe('CredentialSelectorList', () => {
const mockCredentials: DataSourceCredential[] = [
{ id: 'cred-1', name: 'Account A', avatar_url: '' } as DataSourceCredential,
{ id: 'cred-2', name: 'Account B', avatar_url: '' } as DataSourceCredential,
]
const defaultProps = {
currentCredentialId: 'cred-1',
credentials: mockCredentials,
onCredentialChange: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render all credentials', () => {
render(<List {...defaultProps} />)
expect(screen.getByText('Account A')).toBeInTheDocument()
expect(screen.getByText('Account B')).toBeInTheDocument()
})
it('should mark selected credential with check icon', () => {
render(<List {...defaultProps} />)
const checkIcons = screen.getAllByTestId('check-icon')
expect(checkIcons).toHaveLength(1)
})
it('should call onCredentialChange on item click', () => {
render(<List {...defaultProps} />)
fireEvent.click(screen.getByText('Account B'))
expect(defaultProps.onCredentialChange).toHaveBeenCalledWith('cred-2')
})
})

View File

@@ -0,0 +1,49 @@
import type { DataSourceCredential } from '@/types/pipeline'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import Trigger from './trigger'
vi.mock('@remixicon/react', () => ({
RiArrowDownSLine: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="arrow-icon" {...props} />,
}))
vi.mock('@/app/components/datasets/common/credential-icon', () => ({
CredentialIcon: () => <span data-testid="credential-icon" />,
}))
describe('CredentialSelectorTrigger', () => {
it('should render credential name when provided', () => {
render(
<Trigger
currentCredential={{ id: 'cred-1', name: 'Account A', avatar_url: '' } as DataSourceCredential}
isOpen={false}
/>,
)
expect(screen.getByText('Account A')).toBeInTheDocument()
})
it('should render empty name when no credential', () => {
render(<Trigger currentCredential={undefined} isOpen={false} />)
expect(screen.getByTestId('credential-icon')).toBeInTheDocument()
})
it('should render arrow icon', () => {
render(
<Trigger
currentCredential={{ id: 'cred-1', name: 'A', avatar_url: '' } as DataSourceCredential}
isOpen={false}
/>,
)
expect(screen.getByTestId('arrow-icon')).toBeInTheDocument()
})
it('should apply hover style when open', () => {
const { container } = render(
<Trigger
currentCredential={{ id: 'cred-1', name: 'A', avatar_url: '' } as DataSourceCredential}
isOpen={true}
/>,
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('bg-state-base-hover')
})
})

View File

@@ -1,658 +1,64 @@
import type { DataSourceCredential } from '@/types/pipeline'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import Header from './header'
// Mock CredentialTypeEnum to avoid deep import chain issues
enum MockCredentialTypeEnum {
OAUTH2 = 'oauth2',
API_KEY = 'api_key',
}
// Mock plugin-auth module to avoid deep import chain issues
vi.mock('@/app/components/plugins/plugin-auth', () => ({
CredentialTypeEnum: {
OAUTH2: 'oauth2',
API_KEY: 'api_key',
},
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: { pluginName?: string }) => opts?.pluginName ? `${key}-${opts.pluginName}` : key,
}),
}))
// Mock portal-to-follow-elem - required for CredentialSelector
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
const MockPortalToFollowElem = ({ children, open }: any) => {
return (
<div data-testid="portal-root" data-open={open}>
{React.Children.map(children, (child: any) => {
if (!child)
return null
return React.cloneElement(child, { __portalOpen: open })
})}
</div>
)
}
vi.mock('@remixicon/react', () => ({
RiBookOpenLine: () => <span data-testid="book-icon" />,
RiEqualizer2Line: ({ onClick }: { onClick?: () => void }) => <span data-testid="config-icon" onClick={onClick} />,
}))
const MockPortalToFollowElemTrigger = ({ children, onClick, className, __portalOpen }: any) => (
<div data-testid="portal-trigger" onClick={onClick} className={className} data-open={__portalOpen}>
{children}
</div>
)
vi.mock('@/app/components/base/button', () => ({
default: ({ children }: { children: React.ReactNode }) => <button>{children}</button>,
}))
const MockPortalToFollowElemContent = ({ children, className, __portalOpen }: any) => {
if (!__portalOpen)
return null
return (
<div data-testid="portal-content" className={className}>
{children}
</div>
)
}
vi.mock('@/app/components/base/divider', () => ({
default: () => <span data-testid="divider" />,
}))
return {
PortalToFollowElem: MockPortalToFollowElem,
PortalToFollowElemTrigger: MockPortalToFollowElemTrigger,
PortalToFollowElemContent: MockPortalToFollowElemContent,
}
})
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children }: { children: React.ReactNode }) => <div data-testid="tooltip">{children}</div>,
}))
// ==========================================
// Test Data Builders
// ==========================================
const createMockCredential = (overrides?: Partial<DataSourceCredential>): DataSourceCredential => ({
id: 'cred-1',
name: 'Test Credential',
avatar_url: 'https://example.com/avatar.png',
credential: { key: 'value' },
is_default: false,
type: MockCredentialTypeEnum.OAUTH2 as unknown as DataSourceCredential['type'],
...overrides,
})
const createMockCredentials = (count: number = 3): DataSourceCredential[] =>
Array.from({ length: count }, (_, i) =>
createMockCredential({
id: `cred-${i + 1}`,
name: `Credential ${i + 1}`,
avatar_url: `https://example.com/avatar-${i + 1}.png`,
is_default: i === 0,
}))
type HeaderProps = React.ComponentProps<typeof Header>
const createDefaultProps = (overrides?: Partial<HeaderProps>): HeaderProps => ({
docTitle: 'Documentation',
docLink: 'https://docs.example.com',
pluginName: 'Test Plugin',
currentCredentialId: 'cred-1',
onCredentialChange: vi.fn(),
credentials: createMockCredentials(),
...overrides,
})
vi.mock('./credential-selector', () => ({
default: () => <div data-testid="credential-selector" />,
}))
describe('Header', () => {
beforeEach(() => {
vi.clearAllMocks()
const defaultProps = {
docTitle: 'Documentation',
docLink: 'https://docs.example.com',
onClickConfiguration: vi.fn(),
pluginName: 'TestPlugin',
credentials: [],
currentCredentialId: '',
onCredentialChange: vi.fn(),
}
it('should render doc link with title', () => {
render(<Header {...defaultProps} />)
expect(screen.getByText('Documentation')).toBeInTheDocument()
})
// ==========================================
// Rendering Tests
// ==========================================
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText('Documentation')).toBeInTheDocument()
})
it('should render documentation link with correct attributes', () => {
// Arrange
const props = createDefaultProps({
docTitle: 'API Docs',
docLink: 'https://api.example.com/docs',
})
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link', { name: /API Docs/i })
expect(link).toHaveAttribute('href', 'https://api.example.com/docs')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
it('should render document title with title attribute', () => {
// Arrange
const props = createDefaultProps({ docTitle: 'My Documentation' })
// Act
render(<Header {...props} />)
// Assert
const titleSpan = screen.getByText('My Documentation')
expect(titleSpan).toHaveAttribute('title', 'My Documentation')
})
it('should render CredentialSelector with correct props', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert - CredentialSelector should render current credential name
expect(screen.getByText('Credential 1')).toBeInTheDocument()
})
it('should render configuration button', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render book icon in documentation link', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert - RiBookOpenLine renders as SVG
const link = screen.getByRole('link')
const svg = link.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should render divider between credential selector and configuration button', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Header {...props} />)
// Assert - Divider component should be rendered
// Divider typically renders as a div with specific styling
const divider = container.querySelector('[class*="divider"]') || container.querySelector('.mx-1.h-3\\.5')
expect(divider).toBeInTheDocument()
})
it('should render credential selector', () => {
render(<Header {...defaultProps} />)
expect(screen.getByTestId('credential-selector')).toBeInTheDocument()
})
// ==========================================
// Props Testing
// ==========================================
describe('Props', () => {
describe('docTitle prop', () => {
it('should display the document title', () => {
// Arrange
const props = createDefaultProps({ docTitle: 'Getting Started Guide' })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText('Getting Started Guide')).toBeInTheDocument()
})
it.each([
'Quick Start',
'API Reference',
'Configuration Guide',
'Plugin Documentation',
])('should display "%s" as document title', (title) => {
// Arrange
const props = createDefaultProps({ docTitle: title })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText(title)).toBeInTheDocument()
})
})
describe('docLink prop', () => {
it('should set correct href on documentation link', () => {
// Arrange
const props = createDefaultProps({ docLink: 'https://custom.docs.com/guide' })
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link')
expect(link).toHaveAttribute('href', 'https://custom.docs.com/guide')
})
it.each([
'https://docs.dify.ai',
'https://example.com/api',
'/local/docs',
])('should accept "%s" as docLink', (link) => {
// Arrange
const props = createDefaultProps({ docLink: link })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByRole('link')).toHaveAttribute('href', link)
})
})
describe('pluginName prop', () => {
it('should pass pluginName to translation function', () => {
// Arrange
const props = createDefaultProps({ pluginName: 'MyPlugin' })
// Act
render(<Header {...props} />)
// Assert - The translation mock returns the key with options
// Tooltip uses the translated content
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('onClickConfiguration prop', () => {
it('should call onClickConfiguration when configuration icon is clicked', () => {
// Arrange
const mockOnClick = vi.fn()
const props = createDefaultProps({ onClickConfiguration: mockOnClick })
render(<Header {...props} />)
// Act - Find the configuration button and click the icon inside
// The button contains the RiEqualizer2Line icon with onClick handler
const configButton = screen.getByRole('button')
const configIcon = configButton.querySelector('svg')
expect(configIcon).toBeInTheDocument()
fireEvent.click(configIcon!)
// Assert
expect(mockOnClick).toHaveBeenCalledTimes(1)
})
it('should not crash when onClickConfiguration is undefined', () => {
// Arrange
const props = createDefaultProps({ onClickConfiguration: undefined })
render(<Header {...props} />)
// Act - Find the configuration button and click the icon inside
const configButton = screen.getByRole('button')
const configIcon = configButton.querySelector('svg')
expect(configIcon).toBeInTheDocument()
fireEvent.click(configIcon!)
// Assert - Component should still be rendered (no crash)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('CredentialSelector props passthrough', () => {
it('should pass currentCredentialId to CredentialSelector', () => {
// Arrange
const props = createDefaultProps({ currentCredentialId: 'cred-2' })
// Act
render(<Header {...props} />)
// Assert - Should display the second credential
expect(screen.getByText('Credential 2')).toBeInTheDocument()
})
it('should pass credentials to CredentialSelector', () => {
// Arrange
const customCredentials = [
createMockCredential({ id: 'custom-1', name: 'Custom Credential' }),
]
const props = createDefaultProps({
credentials: customCredentials,
currentCredentialId: 'custom-1',
})
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText('Custom Credential')).toBeInTheDocument()
})
it('should pass onCredentialChange to CredentialSelector', () => {
// Arrange
const mockOnChange = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange })
render(<Header {...props} />)
// Act - Open dropdown and select a credential
// Use getAllByTestId and select the first one (CredentialSelector's trigger)
const triggers = screen.getAllByTestId('portal-trigger')
fireEvent.click(triggers[0])
const credential2 = screen.getByText('Credential 2')
fireEvent.click(credential2)
// Assert
expect(mockOnChange).toHaveBeenCalledWith('cred-2')
})
})
it('should render configuration button', () => {
render(<Header {...defaultProps} />)
expect(screen.getByTestId('config-icon')).toBeInTheDocument()
})
// ==========================================
// User Interactions
// ==========================================
describe('User Interactions', () => {
it('should open external link in new tab when clicking documentation link', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert - Link has target="_blank" for new tab
const link = screen.getByRole('link')
expect(link).toHaveAttribute('target', '_blank')
})
it('should allow credential selection through CredentialSelector', () => {
// Arrange
const mockOnChange = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange })
render(<Header {...props} />)
// Act - Open dropdown (use first trigger which is CredentialSelector's)
const triggers = screen.getAllByTestId('portal-trigger')
fireEvent.click(triggers[0])
// Assert - Dropdown should be open
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
})
it('should trigger configuration callback when clicking config icon', () => {
// Arrange
const mockOnConfig = vi.fn()
const props = createDefaultProps({ onClickConfiguration: mockOnConfig })
const { container } = render(<Header {...props} />)
// Act
const configIcon = container.querySelector('.h-4.w-4')
fireEvent.click(configIcon!)
// Assert
expect(mockOnConfig).toHaveBeenCalled()
})
})
// ==========================================
// Component Memoization
// ==========================================
describe('Component Memoization', () => {
it('should be wrapped with React.memo', () => {
// Assert
expect(Header.$$typeof).toBe(Symbol.for('react.memo'))
})
it('should not re-render when props remain the same', () => {
// Arrange
const props = createDefaultProps()
const renderSpy = vi.fn()
const TrackedHeader: React.FC<HeaderProps> = (trackedProps) => {
renderSpy()
return <Header {...trackedProps} />
}
const MemoizedTracked = React.memo(TrackedHeader)
// Act
const { rerender } = render(<MemoizedTracked {...props} />)
rerender(<MemoizedTracked {...props} />)
// Assert - Should only render once due to same props
expect(renderSpy).toHaveBeenCalledTimes(1)
})
it('should re-render when docTitle changes', () => {
// Arrange
const props = createDefaultProps({ docTitle: 'Original Title' })
const { rerender } = render(<Header {...props} />)
// Assert initial
expect(screen.getByText('Original Title')).toBeInTheDocument()
// Act
rerender(<Header {...props} docTitle="Updated Title" />)
// Assert
expect(screen.getByText('Updated Title')).toBeInTheDocument()
})
it('should re-render when currentCredentialId changes', () => {
// Arrange
const props = createDefaultProps({ currentCredentialId: 'cred-1' })
const { rerender } = render(<Header {...props} />)
// Assert initial
expect(screen.getByText('Credential 1')).toBeInTheDocument()
// Act
rerender(<Header {...props} currentCredentialId="cred-2" />)
// Assert
expect(screen.getByText('Credential 2')).toBeInTheDocument()
})
})
// ==========================================
// Edge Cases
// ==========================================
describe('Edge Cases', () => {
it('should handle empty docTitle', () => {
// Arrange
const props = createDefaultProps({ docTitle: '' })
// Act
render(<Header {...props} />)
// Assert - Should render without crashing
const link = screen.getByRole('link')
expect(link).toBeInTheDocument()
})
it('should handle very long docTitle', () => {
// Arrange
const longTitle = 'A'.repeat(200)
const props = createDefaultProps({ docTitle: longTitle })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText(longTitle)).toBeInTheDocument()
})
it('should handle special characters in docTitle', () => {
// Arrange
const specialTitle = 'Docs & Guide <v2> "Special"'
const props = createDefaultProps({ docTitle: specialTitle })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText(specialTitle)).toBeInTheDocument()
})
it('should handle empty credentials array', () => {
// Arrange
const props = createDefaultProps({
credentials: [],
currentCredentialId: '',
})
// Act
render(<Header {...props} />)
// Assert - Should render without crashing
expect(screen.getByRole('link')).toBeInTheDocument()
})
it('should handle special characters in pluginName', () => {
// Arrange
const props = createDefaultProps({ pluginName: 'Plugin & Tool <v1>' })
// Act
render(<Header {...props} />)
// Assert - Should render without crashing
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle unicode characters in docTitle', () => {
// Arrange
const props = createDefaultProps({ docTitle: '文档说明 📚' })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText('文档说明 📚')).toBeInTheDocument()
})
})
// ==========================================
// Styling
// ==========================================
describe('Styling', () => {
it('should apply correct classes to container', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Header {...props} />)
// Assert
const rootDiv = container.firstChild as HTMLElement
expect(rootDiv).toHaveClass('flex', 'items-center', 'justify-between', 'gap-x-2')
})
it('should apply correct classes to documentation link', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link')
expect(link).toHaveClass('system-xs-medium', 'text-text-accent')
})
it('should apply shrink-0 to documentation link', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link')
expect(link).toHaveClass('shrink-0')
})
})
// ==========================================
// Integration Tests
// ==========================================
describe('Integration', () => {
it('should work with full credential workflow', () => {
// Arrange
const mockOnCredentialChange = vi.fn()
const props = createDefaultProps({
onCredentialChange: mockOnCredentialChange,
currentCredentialId: 'cred-1',
})
render(<Header {...props} />)
// Assert initial state
expect(screen.getByText('Credential 1')).toBeInTheDocument()
// Act - Open dropdown and select different credential
// Use first trigger which is CredentialSelector's
const triggers = screen.getAllByTestId('portal-trigger')
fireEvent.click(triggers[0])
const credential3 = screen.getByText('Credential 3')
fireEvent.click(credential3)
// Assert
expect(mockOnCredentialChange).toHaveBeenCalledWith('cred-3')
})
it('should display all components together correctly', () => {
// Arrange
const mockOnConfig = vi.fn()
const props = createDefaultProps({
docTitle: 'Integration Test Docs',
docLink: 'https://test.com/docs',
pluginName: 'TestPlugin',
onClickConfiguration: mockOnConfig,
})
// Act
render(<Header {...props} />)
// Assert - All main elements present
expect(screen.getByText('Credential 1')).toBeInTheDocument() // CredentialSelector
expect(screen.getByRole('button')).toBeInTheDocument() // Config button
expect(screen.getByText('Integration Test Docs')).toBeInTheDocument() // Doc link
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://test.com/docs')
})
})
// ==========================================
// Accessibility
// ==========================================
describe('Accessibility', () => {
it('should have accessible link', () => {
// Arrange
const props = createDefaultProps({ docTitle: 'Accessible Docs' })
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link', { name: /Accessible Docs/i })
expect(link).toBeInTheDocument()
})
it('should have accessible button for configuration', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})
it('should have noopener noreferrer for security on external links', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
it('should link to external doc', () => {
render(<Header {...defaultProps} />)
const link = screen.getByText('Documentation').closest('a')
expect(link).toHaveAttribute('href', 'https://docs.example.com')
expect(link).toHaveAttribute('target', '_blank')
})
})

View File

@@ -0,0 +1,100 @@
import type { NotionPageTreeItem, NotionPageTreeMap } from './index'
import type { DataSourceNotionPageMap } from '@/models/common'
import { describe, expect, it } from 'vitest'
import { recursivePushInParentDescendants } from './utils'
const makePageEntry = (overrides: Partial<NotionPageTreeItem>): NotionPageTreeItem => ({
page_icon: null,
page_id: '',
page_name: '',
parent_id: '',
type: 'page',
is_bound: false,
children: new Set(),
descendants: new Set(),
depth: 0,
ancestors: [],
...overrides,
})
describe('recursivePushInParentDescendants', () => {
it('should add child to parent descendants', () => {
const pagesMap = {
parent1: { page_id: 'parent1', parent_id: 'root', page_name: 'Parent' },
child1: { page_id: 'child1', parent_id: 'parent1', page_name: 'Child' },
} as unknown as DataSourceNotionPageMap
const listTreeMap: NotionPageTreeMap = {
child1: makePageEntry({ page_id: 'child1', parent_id: 'parent1', page_name: 'Child' }),
}
recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap.child1, listTreeMap.child1)
expect(listTreeMap.parent1).toBeDefined()
expect(listTreeMap.parent1.children.has('child1')).toBe(true)
expect(listTreeMap.parent1.descendants.has('child1')).toBe(true)
})
it('should recursively populate ancestors for deeply nested items', () => {
const pagesMap = {
grandparent: { page_id: 'grandparent', parent_id: 'root', page_name: 'Grandparent' },
parent: { page_id: 'parent', parent_id: 'grandparent', page_name: 'Parent' },
child: { page_id: 'child', parent_id: 'parent', page_name: 'Child' },
} as unknown as DataSourceNotionPageMap
const listTreeMap: NotionPageTreeMap = {
parent: makePageEntry({ page_id: 'parent', parent_id: 'grandparent', page_name: 'Parent' }),
child: makePageEntry({ page_id: 'child', parent_id: 'parent', page_name: 'Child' }),
}
recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap.child, listTreeMap.child)
expect(listTreeMap.child.depth).toBe(2)
expect(listTreeMap.child.ancestors).toContain('Grandparent')
expect(listTreeMap.child.ancestors).toContain('Parent')
})
it('should do nothing for root parent', () => {
const pagesMap = {
root_child: { page_id: 'root_child', parent_id: 'root', page_name: 'Root Child' },
} as unknown as DataSourceNotionPageMap
const listTreeMap: NotionPageTreeMap = {
root_child: makePageEntry({ page_id: 'root_child', parent_id: 'root', page_name: 'Root Child' }),
}
recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap.root_child, listTreeMap.root_child)
// No new entries should be added since parent is root
expect(Object.keys(listTreeMap)).toEqual(['root_child'])
})
it('should handle missing parent_id gracefully', () => {
const pagesMap = {} as DataSourceNotionPageMap
const current = makePageEntry({ page_id: 'orphan', parent_id: undefined as unknown as string })
const listTreeMap: NotionPageTreeMap = { orphan: current }
// Should not throw
recursivePushInParentDescendants(pagesMap, listTreeMap, current, current)
expect(listTreeMap.orphan.depth).toBe(0)
})
it('should add to existing parent entry when parent already in tree', () => {
const pagesMap = {
parent: { page_id: 'parent', parent_id: 'root', page_name: 'Parent' },
child1: { page_id: 'child1', parent_id: 'parent', page_name: 'Child1' },
child2: { page_id: 'child2', parent_id: 'parent', page_name: 'Child2' },
} as unknown as DataSourceNotionPageMap
const listTreeMap: NotionPageTreeMap = {
parent: makePageEntry({ page_id: 'parent', parent_id: 'root', children: new Set(['child1']), descendants: new Set(['child1']), page_name: 'Parent' }),
child2: makePageEntry({ page_id: 'child2', parent_id: 'parent', page_name: 'Child2' }),
}
recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap.child2, listTreeMap.child2)
expect(listTreeMap.parent.children.has('child2')).toBe(true)
expect(listTreeMap.parent.descendants.has('child2')).toBe(true)
expect(listTreeMap.parent.children.has('child1')).toBe(true)
})
})

View File

@@ -0,0 +1,16 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import Title from './title'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) => `${key}:${(opts?.name as string) || ''}`,
}),
}))
describe('OnlineDocumentTitle', () => {
it('should render title with name prop', () => {
render(<Title name="Notion Workspace" />)
expect(screen.getByText('onlineDocument.pageSelectorTitle:Notion Workspace')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,60 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Bucket from './bucket'
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('@/app/components/base/icons/src/public/knowledge/online-drive', () => ({
BucketsGray: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="buckets-gray" {...props} />,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
}))
describe('Bucket', () => {
const defaultProps = {
bucketName: 'my-bucket',
handleBackToBucketList: vi.fn(),
handleClickBucketName: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render bucket name', () => {
render(<Bucket {...defaultProps} />)
expect(screen.getByText('my-bucket')).toBeInTheDocument()
})
it('should render bucket icon', () => {
render(<Bucket {...defaultProps} />)
expect(screen.getByTestId('buckets-gray')).toBeInTheDocument()
})
it('should call handleBackToBucketList on icon button click', () => {
render(<Bucket {...defaultProps} />)
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[0])
expect(defaultProps.handleBackToBucketList).toHaveBeenCalledOnce()
})
it('should call handleClickBucketName on name click', () => {
render(<Bucket {...defaultProps} />)
fireEvent.click(screen.getByText('my-bucket'))
expect(defaultProps.handleClickBucketName).toHaveBeenCalledOnce()
})
it('should not call handleClickBucketName when disabled', () => {
render(<Bucket {...defaultProps} disabled={true} />)
fireEvent.click(screen.getByText('my-bucket'))
expect(defaultProps.handleClickBucketName).not.toHaveBeenCalled()
})
it('should show separator by default', () => {
render(<Bucket {...defaultProps} />)
const separators = screen.getAllByText('/')
expect(separators.length).toBeGreaterThanOrEqual(2) // One after icon, one after name
})
})

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,48 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import BreadcrumbItem from './item'
describe('BreadcrumbItem', () => {
const defaultProps = {
name: 'Documents',
index: 2,
handleClick: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render name', () => {
render(<BreadcrumbItem {...defaultProps} />)
expect(screen.getByText('Documents')).toBeInTheDocument()
})
it('should show separator by default', () => {
render(<BreadcrumbItem {...defaultProps} />)
expect(screen.getByText('/')).toBeInTheDocument()
})
it('should hide separator when showSeparator is false', () => {
render(<BreadcrumbItem {...defaultProps} showSeparator={false} />)
expect(screen.queryByText('/')).not.toBeInTheDocument()
})
it('should call handleClick with index on click', () => {
render(<BreadcrumbItem {...defaultProps} />)
fireEvent.click(screen.getByText('Documents'))
expect(defaultProps.handleClick).toHaveBeenCalledWith(2)
})
it('should not call handleClick when disabled', () => {
render(<BreadcrumbItem {...defaultProps} disabled={true} />)
fireEvent.click(screen.getByText('Documents'))
expect(defaultProps.handleClick).not.toHaveBeenCalled()
})
it('should apply active styling', () => {
render(<BreadcrumbItem {...defaultProps} isActive={true} />)
const btn = screen.getByRole('button')
expect(btn.className).toContain('system-sm-medium')
})
})

View File

@@ -1,38 +1,16 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import EmptyFolder from './empty-folder'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
afterEach(() => {
cleanup()
})
describe('EmptyFolder', () => {
it('should render without crashing', () => {
it('should render empty folder message', () => {
render(<EmptyFolder />)
expect(screen.getByText('onlineDrive.emptyFolder')).toBeInTheDocument()
})
it('should render the empty folder text', () => {
render(<EmptyFolder />)
expect(screen.getByText('onlineDrive.emptyFolder')).toBeInTheDocument()
})
it('should have proper styling classes', () => {
const { container } = render(<EmptyFolder />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex')
expect(wrapper).toHaveClass('items-center')
expect(wrapper).toHaveClass('justify-center')
})
it('should be wrapped with React.memo', () => {
expect((EmptyFolder as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
})
})

View File

@@ -0,0 +1,34 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import EmptySearchResult from './empty-search-result'
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('@/app/components/base/icons/src/vender/knowledge', () => ({
SearchMenu: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="search-icon" {...props} />,
}))
describe('EmptySearchResult', () => {
const onResetKeywords = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render empty state message', () => {
render(<EmptySearchResult onResetKeywords={onResetKeywords} />)
expect(screen.getByText('onlineDrive.emptySearchResult')).toBeInTheDocument()
})
it('should render reset button', () => {
render(<EmptySearchResult onResetKeywords={onResetKeywords} />)
expect(screen.getByText('onlineDrive.resetKeywords')).toBeInTheDocument()
})
it('should call onResetKeywords when reset button clicked', () => {
render(<EmptySearchResult onResetKeywords={onResetKeywords} />)
fireEvent.click(screen.getByText('onlineDrive.resetKeywords'))
expect(onResetKeywords).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { OnlineDriveFileType } from '@/models/pipeline'
import FileIcon from './file-icon'
vi.mock('@/app/components/base/file-uploader/file-type-icon', () => ({
default: ({ type }: { type: string }) => <span data-testid="file-type-icon">{type}</span>,
}))
vi.mock('@/app/components/base/icons/src/public/knowledge/online-drive', () => ({
BucketsBlue: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="bucket-icon" {...props} />,
Folder: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="folder-icon" {...props} />,
}))
describe('FileIcon', () => {
it('should render bucket icon for bucket type', () => {
render(<FileIcon type={OnlineDriveFileType.bucket} fileName="" />)
expect(screen.getByTestId('bucket-icon')).toBeInTheDocument()
})
it('should render folder icon for folder type', () => {
render(<FileIcon type={OnlineDriveFileType.folder} fileName="" />)
expect(screen.getByTestId('folder-icon')).toBeInTheDocument()
})
it('should render file type icon for file type', () => {
render(<FileIcon type={OnlineDriveFileType.file} fileName="doc.pdf" />)
expect(screen.getByTestId('file-type-icon')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,96 @@
import type { OnlineDriveFile } from '@/models/pipeline'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Item from './item'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/base/checkbox', () => ({
default: ({ checked, onCheck, disabled }: { checked: boolean, onCheck: () => void, disabled?: boolean }) => (
<input type="checkbox" data-testid="checkbox" checked={checked} onChange={onCheck} disabled={disabled} />
),
}))
vi.mock('@/app/components/base/radio/ui', () => ({
default: ({ isChecked, onCheck }: { isChecked: boolean, onCheck: () => void }) => (
<input type="radio" data-testid="radio" checked={isChecked} onChange={onCheck} />
),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
<div data-testid="tooltip" title={popupContent}>{children}</div>
),
}))
vi.mock('./file-icon', () => ({
default: () => <span data-testid="file-icon" />,
}))
describe('Item', () => {
const makeFile = (type: string, name = 'test.pdf', size = 1024): OnlineDriveFile => ({
id: 'f-1',
name,
type: type as OnlineDriveFile['type'],
size,
})
const defaultProps = {
file: makeFile('file'),
isSelected: false,
onSelect: vi.fn(),
onOpen: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render file name', () => {
render(<Item {...defaultProps} />)
expect(screen.getByText('test.pdf')).toBeInTheDocument()
})
it('should render checkbox for file type in multiple choice mode', () => {
render(<Item {...defaultProps} />)
expect(screen.getByTestId('checkbox')).toBeInTheDocument()
})
it('should render radio for file type in single choice mode', () => {
render(<Item {...defaultProps} isMultipleChoice={false} />)
expect(screen.getByTestId('radio')).toBeInTheDocument()
})
it('should not render checkbox for bucket type', () => {
render(<Item {...defaultProps} file={makeFile('bucket', 'my-bucket')} />)
expect(screen.queryByTestId('checkbox')).not.toBeInTheDocument()
})
it('should call onOpen for folder click', () => {
const file = makeFile('folder', 'my-folder')
render(<Item {...defaultProps} file={file} />)
fireEvent.click(screen.getByText('my-folder'))
expect(defaultProps.onOpen).toHaveBeenCalledWith(file)
})
it('should call onSelect for file click', () => {
render(<Item {...defaultProps} />)
fireEvent.click(screen.getByText('test.pdf'))
expect(defaultProps.onSelect).toHaveBeenCalledWith(defaultProps.file)
})
it('should not call handlers when disabled', () => {
render(<Item {...defaultProps} disabled={true} />)
fireEvent.click(screen.getByText('test.pdf'))
expect(defaultProps.onSelect).not.toHaveBeenCalled()
})
it('should render file icon', () => {
render(<Item {...defaultProps} />)
expect(screen.getByTestId('file-icon')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest'
import { FileAppearanceTypeEnum } from '@/app/components/base/file-uploader/types'
import { getFileExtension, getFileType } from './utils'
describe('getFileExtension', () => {
it('should return extension for normal file', () => {
expect(getFileExtension('test.pdf')).toBe('pdf')
})
it('should return lowercase extension', () => {
expect(getFileExtension('test.PDF')).toBe('pdf')
})
it('should return last extension for multiple dots', () => {
expect(getFileExtension('my.file.name.txt')).toBe('txt')
})
it('should return empty string for no extension', () => {
expect(getFileExtension('noext')).toBe('')
})
it('should return empty string for empty string', () => {
expect(getFileExtension('')).toBe('')
})
it('should return empty string for dotfile with no extension', () => {
expect(getFileExtension('.gitignore')).toBe('')
})
})
describe('getFileType', () => {
it('should return pdf for .pdf files', () => {
expect(getFileType('doc.pdf')).toBe(FileAppearanceTypeEnum.pdf)
})
it('should return markdown for .md files', () => {
expect(getFileType('readme.md')).toBe(FileAppearanceTypeEnum.markdown)
})
it('should return markdown for .mdx files', () => {
expect(getFileType('page.mdx')).toBe(FileAppearanceTypeEnum.markdown)
})
it('should return excel for .xlsx files', () => {
expect(getFileType('data.xlsx')).toBe(FileAppearanceTypeEnum.excel)
})
it('should return excel for .csv files', () => {
expect(getFileType('data.csv')).toBe(FileAppearanceTypeEnum.excel)
})
it('should return word for .docx files', () => {
expect(getFileType('doc.docx')).toBe(FileAppearanceTypeEnum.word)
})
it('should return ppt for .pptx files', () => {
expect(getFileType('slides.pptx')).toBe(FileAppearanceTypeEnum.ppt)
})
it('should return code for .html files', () => {
expect(getFileType('page.html')).toBe(FileAppearanceTypeEnum.code)
})
it('should return code for .json files', () => {
expect(getFileType('config.json')).toBe(FileAppearanceTypeEnum.code)
})
it('should return gif for .gif files', () => {
expect(getFileType('animation.gif')).toBe(FileAppearanceTypeEnum.gif)
})
it('should return custom for unknown extension', () => {
expect(getFileType('file.xyz')).toBe(FileAppearanceTypeEnum.custom)
})
it('should return custom for no extension', () => {
expect(getFileType('noext')).toBe(FileAppearanceTypeEnum.custom)
})
})

View File

@@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Header from './header'
vi.mock('@remixicon/react', () => ({
RiBookOpenLine: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="book-icon" {...props} />,
RiEqualizer2Line: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="config-icon" {...props} />,
}))
describe('OnlineDriveHeader', () => {
const defaultProps = {
docTitle: 'S3 Guide',
docLink: 'https://docs.aws.com/s3',
onClickConfiguration: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render doc link with title', () => {
render(<Header {...defaultProps} />)
const link = screen.getByText('S3 Guide').closest('a')
expect(link).toHaveAttribute('href', 'https://docs.aws.com/s3')
expect(link).toHaveAttribute('target', '_blank')
})
it('should render book and config icons', () => {
render(<Header {...defaultProps} />)
expect(screen.getByTestId('book-icon')).toBeInTheDocument()
expect(screen.getByTestId('config-icon')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,105 @@
import type { OnlineDriveData } from '@/types/pipeline'
import { describe, expect, it } from 'vitest'
import { OnlineDriveFileType } from '@/models/pipeline'
import { convertOnlineDriveData, isBucketListInitiation, isFile } from './utils'
describe('online-drive utils', () => {
describe('isFile', () => {
it('should return true for file type', () => {
expect(isFile('file')).toBe(true)
})
it('should return false for folder type', () => {
expect(isFile('folder')).toBe(false)
})
})
describe('isBucketListInitiation', () => {
it('should return true when data has buckets and no prefix/bucket set', () => {
const data = [
{ bucket: 'bucket-1', files: [], is_truncated: false, next_page_parameters: {} },
{ bucket: 'bucket-2', files: [], is_truncated: false, next_page_parameters: {} },
] as OnlineDriveData[]
expect(isBucketListInitiation(data, [], '')).toBe(true)
})
it('should return false when bucket is already set', () => {
const data = [
{ bucket: 'bucket-1', files: [], is_truncated: false, next_page_parameters: {} },
] as OnlineDriveData[]
expect(isBucketListInitiation(data, [], 'bucket-1')).toBe(false)
})
it('should return false when prefix is set', () => {
const data = [
{ bucket: 'bucket-1', files: [], is_truncated: false, next_page_parameters: {} },
] as OnlineDriveData[]
expect(isBucketListInitiation(data, ['folder/'], '')).toBe(false)
})
it('should return false when single bucket has files', () => {
const data = [
{
bucket: 'bucket-1',
files: [{ id: 'f1', name: 'test.txt', size: 100, type: 'file' as const }],
is_truncated: false,
next_page_parameters: {},
},
] as OnlineDriveData[]
expect(isBucketListInitiation(data, [], '')).toBe(false)
})
})
describe('convertOnlineDriveData', () => {
it('should return empty result for empty data', () => {
const result = convertOnlineDriveData([], [], '')
expect(result.fileList).toEqual([])
expect(result.isTruncated).toBe(false)
expect(result.hasBucket).toBe(false)
})
it('should convert bucket list initiation to bucket items', () => {
const data = [
{ bucket: 'bucket-1', files: [], is_truncated: false, next_page_parameters: {} },
{ bucket: 'bucket-2', files: [], is_truncated: false, next_page_parameters: {} },
] as OnlineDriveData[]
const result = convertOnlineDriveData(data, [], '')
expect(result.fileList).toHaveLength(2)
expect(result.fileList[0]).toEqual({
id: 'bucket-1',
name: 'bucket-1',
type: OnlineDriveFileType.bucket,
})
expect(result.hasBucket).toBe(true)
})
it('should convert files when not bucket list', () => {
const data = [
{
bucket: 'bucket-1',
files: [
{ id: 'f1', name: 'test.txt', size: 100, type: 'file' as const },
{ id: 'f2', name: 'folder', size: 0, type: 'folder' as const },
],
is_truncated: true,
next_page_parameters: { token: 'next' },
},
] as OnlineDriveData[]
const result = convertOnlineDriveData(data, [], 'bucket-1')
expect(result.fileList).toHaveLength(2)
expect(result.fileList[0].type).toBe(OnlineDriveFileType.file)
expect(result.fileList[0].size).toBe(100)
expect(result.fileList[1].type).toBe(OnlineDriveFileType.folder)
expect(result.fileList[1].size).toBeUndefined()
expect(result.isTruncated).toBe(true)
expect(result.nextPageParameters).toEqual({ token: 'next' })
expect(result.hasBucket).toBe(true)
})
})
})

View File

@@ -0,0 +1,96 @@
import type { FileItem } from '@/models/datasets'
import { render, renderHook } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it } from 'vitest'
import { createDataSourceStore, useDataSourceStore, useDataSourceStoreWithSelector } from './'
import DataSourceProvider from './provider'
describe('createDataSourceStore', () => {
it('should create a store with all slices combined', () => {
const store = createDataSourceStore()
const state = store.getState()
// Common slice
expect(state.currentCredentialId).toBe('')
expect(typeof state.setCurrentCredentialId).toBe('function')
// LocalFile slice
expect(state.localFileList).toEqual([])
expect(typeof state.setLocalFileList).toBe('function')
// OnlineDocument slice
expect(state.documentsData).toEqual([])
expect(typeof state.setDocumentsData).toBe('function')
// WebsiteCrawl slice
expect(state.websitePages).toEqual([])
expect(typeof state.setWebsitePages).toBe('function')
// OnlineDrive slice
expect(state.breadcrumbs).toEqual([])
expect(typeof state.setBreadcrumbs).toBe('function')
})
it('should allow cross-slice state updates', () => {
const store = createDataSourceStore()
store.getState().setCurrentCredentialId('cred-1')
store.getState().setLocalFileList([{ file: { id: 'f1' } }] as unknown as FileItem[])
expect(store.getState().currentCredentialId).toBe('cred-1')
expect(store.getState().localFileList).toHaveLength(1)
})
it('should create independent store instances', () => {
const store1 = createDataSourceStore()
const store2 = createDataSourceStore()
store1.getState().setCurrentCredentialId('cred-1')
expect(store2.getState().currentCredentialId).toBe('')
})
})
describe('useDataSourceStoreWithSelector', () => {
it('should throw when used outside provider', () => {
expect(() => {
renderHook(() => useDataSourceStoreWithSelector(s => s.currentCredentialId))
}).toThrow('Missing DataSourceContext.Provider in the tree')
})
it('should return selected state when used inside provider', () => {
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(DataSourceProvider, null, children)
const { result } = renderHook(
() => useDataSourceStoreWithSelector(s => s.currentCredentialId),
{ wrapper },
)
expect(result.current).toBe('')
})
})
describe('useDataSourceStore', () => {
it('should throw when used outside provider', () => {
expect(() => {
renderHook(() => useDataSourceStore())
}).toThrow('Missing DataSourceContext.Provider in the tree')
})
it('should return store when used inside provider', () => {
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(DataSourceProvider, null, children)
const { result } = renderHook(
() => useDataSourceStore(),
{ wrapper },
)
expect(result.current).toBeDefined()
expect(typeof result.current.getState).toBe('function')
})
})
describe('DataSourceProvider', () => {
it('should render children', () => {
const child = React.createElement('div', null, 'Child Content')
const { getByText } = render(React.createElement(DataSourceProvider, null, child))
expect(getByText('Child Content')).toBeInTheDocument()
})
})

View File

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

View File

@@ -0,0 +1,29 @@
import type { CommonShape } from './common'
import { describe, expect, it } from 'vitest'
import { createStore } from 'zustand'
import { createCommonSlice } from './common'
const createTestStore = () => createStore<CommonShape>((...args) => createCommonSlice(...args))
describe('createCommonSlice', () => {
it('should initialize with default values', () => {
const state = createTestStore().getState()
expect(state.currentCredentialId).toBe('')
expect(state.currentNodeIdRef.current).toBe('')
expect(state.currentCredentialIdRef.current).toBe('')
})
it('should update currentCredentialId', () => {
const store = createTestStore()
store.getState().setCurrentCredentialId('cred-123')
expect(store.getState().currentCredentialId).toBe('cred-123')
})
it('should update currentCredentialId multiple times', () => {
const store = createTestStore()
store.getState().setCurrentCredentialId('cred-1')
store.getState().setCurrentCredentialId('cred-2')
expect(store.getState().currentCredentialId).toBe('cred-2')
})
})

View File

@@ -0,0 +1,49 @@
import type { LocalFileSliceShape } from './local-file'
import type { CustomFile as File, FileItem } from '@/models/datasets'
import { describe, expect, it } from 'vitest'
import { createStore } from 'zustand'
import { createLocalFileSlice } from './local-file'
const createTestStore = () => createStore<LocalFileSliceShape>((...args) => createLocalFileSlice(...args))
describe('createLocalFileSlice', () => {
it('should initialize with default values', () => {
const state = createTestStore().getState()
expect(state.localFileList).toEqual([])
expect(state.currentLocalFile).toBeUndefined()
expect(state.previewLocalFileRef.current).toBeUndefined()
})
it('should set local file list and update preview ref to first file', () => {
const store = createTestStore()
const files = [
{ file: { id: 'f1', name: 'a.pdf' } },
{ file: { id: 'f2', name: 'b.pdf' } },
] as unknown as FileItem[]
store.getState().setLocalFileList(files)
expect(store.getState().localFileList).toEqual(files)
expect(store.getState().previewLocalFileRef.current).toEqual({ id: 'f1', name: 'a.pdf' })
})
it('should set preview ref to undefined for empty file list', () => {
const store = createTestStore()
store.getState().setLocalFileList([])
expect(store.getState().previewLocalFileRef.current).toBeUndefined()
})
it('should set current local file', () => {
const store = createTestStore()
const file = { id: 'f1', name: 'test.pdf' } as unknown as File
store.getState().setCurrentLocalFile(file)
expect(store.getState().currentLocalFile).toEqual(file)
})
it('should clear current local file with undefined', () => {
const store = createTestStore()
store.getState().setCurrentLocalFile({ id: 'f1' } as unknown as File)
store.getState().setCurrentLocalFile(undefined)
expect(store.getState().currentLocalFile).toBeUndefined()
})
})

View File

@@ -0,0 +1,55 @@
import type { OnlineDocumentSliceShape } from './online-document'
import type { DataSourceNotionWorkspace, NotionPage } from '@/models/common'
import { describe, expect, it } from 'vitest'
import { createStore } from 'zustand'
import { createOnlineDocumentSlice } from './online-document'
const createTestStore = () => createStore<OnlineDocumentSliceShape>((...args) => createOnlineDocumentSlice(...args))
describe('createOnlineDocumentSlice', () => {
it('should initialize with default values', () => {
const state = createTestStore().getState()
expect(state.documentsData).toEqual([])
expect(state.searchValue).toBe('')
expect(state.onlineDocuments).toEqual([])
expect(state.currentDocument).toBeUndefined()
expect(state.selectedPagesId).toEqual(new Set())
expect(state.previewOnlineDocumentRef.current).toBeUndefined()
})
it('should set documents data', () => {
const store = createTestStore()
const data = [{ workspace_id: 'w1', pages: [] }] as unknown as DataSourceNotionWorkspace[]
store.getState().setDocumentsData(data)
expect(store.getState().documentsData).toEqual(data)
})
it('should set search value', () => {
const store = createTestStore()
store.getState().setSearchValue('hello')
expect(store.getState().searchValue).toBe('hello')
})
it('should set online documents and update preview ref', () => {
const store = createTestStore()
const pages = [{ page_id: 'p1' }, { page_id: 'p2' }] as unknown as NotionPage[]
store.getState().setOnlineDocuments(pages)
expect(store.getState().onlineDocuments).toEqual(pages)
expect(store.getState().previewOnlineDocumentRef.current).toEqual({ page_id: 'p1' })
})
it('should set current document', () => {
const store = createTestStore()
const doc = { page_id: 'p1' } as unknown as NotionPage
store.getState().setCurrentDocument(doc)
expect(store.getState().currentDocument).toEqual(doc)
})
it('should set selected pages id', () => {
const store = createTestStore()
const ids = new Set(['p1', 'p2'])
store.getState().setSelectedPagesId(ids)
expect(store.getState().selectedPagesId).toEqual(ids)
})
})

View File

@@ -0,0 +1,79 @@
import type { OnlineDriveSliceShape } from './online-drive'
import type { OnlineDriveFile } from '@/models/pipeline'
import { describe, expect, it } from 'vitest'
import { createStore } from 'zustand'
import { createOnlineDriveSlice } from './online-drive'
const createTestStore = () => createStore<OnlineDriveSliceShape>((...args) => createOnlineDriveSlice(...args))
describe('createOnlineDriveSlice', () => {
it('should initialize with default values', () => {
const state = createTestStore().getState()
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.nextPageParameters).toEqual({})
expect(state.isTruncated.current).toBe(false)
expect(state.previewOnlineDriveFileRef.current).toBeUndefined()
expect(state.hasBucket).toBe(false)
})
it('should set breadcrumbs', () => {
const store = createTestStore()
store.getState().setBreadcrumbs(['root', 'folder'])
expect(store.getState().breadcrumbs).toEqual(['root', 'folder'])
})
it('should set prefix', () => {
const store = createTestStore()
store.getState().setPrefix(['a', 'b'])
expect(store.getState().prefix).toEqual(['a', 'b'])
})
it('should set keywords', () => {
const store = createTestStore()
store.getState().setKeywords('search term')
expect(store.getState().keywords).toBe('search term')
})
it('should set selected file ids and update preview ref', () => {
const store = createTestStore()
const files = [
{ id: 'file-1', name: 'a.pdf', type: 'file' },
{ id: 'file-2', name: 'b.pdf', type: 'file' },
] as unknown as OnlineDriveFile[]
store.getState().setOnlineDriveFileList(files)
store.getState().setSelectedFileIds(['file-1'])
expect(store.getState().selectedFileIds).toEqual(['file-1'])
expect(store.getState().previewOnlineDriveFileRef.current).toEqual(files[0])
})
it('should set preview ref to undefined when selected id not found', () => {
const store = createTestStore()
store.getState().setSelectedFileIds(['non-existent'])
expect(store.getState().previewOnlineDriveFileRef.current).toBeUndefined()
})
it('should set bucket', () => {
const store = createTestStore()
store.getState().setBucket('my-bucket')
expect(store.getState().bucket).toBe('my-bucket')
})
it('should set next page parameters', () => {
const store = createTestStore()
store.getState().setNextPageParameters({ cursor: 'abc' })
expect(store.getState().nextPageParameters).toEqual({ cursor: 'abc' })
})
it('should set hasBucket', () => {
const store = createTestStore()
store.getState().setHasBucket(true)
expect(store.getState().hasBucket).toBe(true)
})
})

View File

@@ -0,0 +1,65 @@
import type { WebsiteCrawlSliceShape } from './website-crawl'
import type { CrawlResult, CrawlResultItem } from '@/models/datasets'
import { describe, expect, it } from 'vitest'
import { createStore } from 'zustand'
import { CrawlStep } from '@/models/datasets'
import { createWebsiteCrawlSlice } from './website-crawl'
const createTestStore = () => createStore<WebsiteCrawlSliceShape>((...args) => createWebsiteCrawlSlice(...args))
describe('createWebsiteCrawlSlice', () => {
it('should initialize with default values', () => {
const state = createTestStore().getState()
expect(state.websitePages).toEqual([])
expect(state.currentWebsite).toBeUndefined()
expect(state.crawlResult).toBeUndefined()
expect(state.step).toBe(CrawlStep.init)
expect(state.previewIndex).toBe(-1)
expect(state.previewWebsitePageRef.current).toBeUndefined()
})
it('should set website pages and update preview ref', () => {
const store = createTestStore()
const pages = [
{ title: 'Page 1', source_url: 'https://a.com' },
{ title: 'Page 2', source_url: 'https://b.com' },
] as unknown as CrawlResultItem[]
store.getState().setWebsitePages(pages)
expect(store.getState().websitePages).toEqual(pages)
expect(store.getState().previewWebsitePageRef.current).toEqual(pages[0])
})
it('should set current website', () => {
const store = createTestStore()
const website = { title: 'Page 1' } as unknown as CrawlResultItem
store.getState().setCurrentWebsite(website)
expect(store.getState().currentWebsite).toEqual(website)
})
it('should set crawl result', () => {
const store = createTestStore()
const result = { data: { count: 5 } } as unknown as CrawlResult
store.getState().setCrawlResult(result)
expect(store.getState().crawlResult).toEqual(result)
})
it('should set step', () => {
const store = createTestStore()
store.getState().setStep(CrawlStep.running)
expect(store.getState().step).toBe(CrawlStep.running)
})
it('should set preview index', () => {
const store = createTestStore()
store.getState().setPreviewIndex(3)
expect(store.getState().previewIndex).toBe(3)
})
it('should clear current website with undefined', () => {
const store = createTestStore()
store.getState().setCurrentWebsite({ title: 'X' } as unknown as CrawlResultItem)
store.getState().setCurrentWebsite(undefined)
expect(store.getState().currentWebsite).toBeUndefined()
})
})

View File

@@ -0,0 +1,50 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CheckboxWithLabel from './checkbox-with-label'
vi.mock('@/app/components/base/checkbox', () => ({
default: ({ checked, onCheck }: { checked: boolean, onCheck: () => void }) => (
<input type="checkbox" data-testid="checkbox" checked={checked} onChange={onCheck} />
),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent: string }) => <div data-testid="tooltip">{popupContent}</div>,
}))
describe('CheckboxWithLabel', () => {
const defaultProps = {
isChecked: false,
onChange: vi.fn(),
label: 'Test Label',
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render label text', () => {
render(<CheckboxWithLabel {...defaultProps} />)
expect(screen.getByText('Test Label')).toBeInTheDocument()
})
it('should render checkbox', () => {
render(<CheckboxWithLabel {...defaultProps} />)
expect(screen.getByTestId('checkbox')).toBeInTheDocument()
})
it('should render tooltip when provided', () => {
render(<CheckboxWithLabel {...defaultProps} tooltip="Help text" />)
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
})
it('should not render tooltip when not provided', () => {
render(<CheckboxWithLabel {...defaultProps} />)
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument()
})
it('should apply custom className', () => {
const { container } = render(<CheckboxWithLabel {...defaultProps} className="custom-cls" />)
expect(container.querySelector('.custom-cls')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,75 @@
import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CrawledResultItem from './crawled-result-item'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/base/button', () => ({
default: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<button data-testid="preview-button" onClick={onClick}>{children}</button>
),
}))
vi.mock('@/app/components/base/checkbox', () => ({
default: ({ checked, onCheck }: { checked: boolean, onCheck: () => void }) => (
<input type="checkbox" data-testid="checkbox" checked={checked} onChange={onCheck} />
),
}))
vi.mock('@/app/components/base/radio/ui', () => ({
default: ({ isChecked, onCheck }: { isChecked: boolean, onCheck: () => void }) => (
<input type="radio" data-testid="radio" checked={isChecked} onChange={onCheck} />
),
}))
describe('CrawledResultItem', () => {
const defaultProps = {
payload: {
title: 'Test Page',
source_url: 'https://example.com/page',
markdown: '',
description: '',
} satisfies CrawlResultItemType,
isChecked: false,
onCheckChange: vi.fn(),
isPreview: false,
showPreview: true,
onPreview: vi.fn(),
isMultipleChoice: true,
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render title and URL', () => {
render(<CrawledResultItem {...defaultProps} />)
expect(screen.getByText('Test Page')).toBeInTheDocument()
expect(screen.getByText('https://example.com/page')).toBeInTheDocument()
})
it('should render checkbox in multiple choice mode', () => {
render(<CrawledResultItem {...defaultProps} />)
expect(screen.getByTestId('checkbox')).toBeInTheDocument()
})
it('should render radio in single choice mode', () => {
render(<CrawledResultItem {...defaultProps} isMultipleChoice={false} />)
expect(screen.getByTestId('radio')).toBeInTheDocument()
})
it('should show preview button when showPreview is true', () => {
render(<CrawledResultItem {...defaultProps} />)
expect(screen.getByTestId('preview-button')).toBeInTheDocument()
})
it('should not show preview button when showPreview is false', () => {
render(<CrawledResultItem {...defaultProps} showPreview={false} />)
expect(screen.queryByTestId('preview-button')).not.toBeInTheDocument()
})
})

View File

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

View File

@@ -0,0 +1,27 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import Crawling from './crawling'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('Crawling', () => {
it('should render crawl progress', () => {
render(<Crawling crawledNum={5} totalNum={10} />)
expect(screen.getByText(/5/)).toBeInTheDocument()
expect(screen.getByText(/10/)).toBeInTheDocument()
})
it('should render total page scraped label', () => {
render(<Crawling crawledNum={0} totalNum={0} />)
expect(screen.getByText(/stepOne\.website\.totalPageScraped/)).toBeInTheDocument()
})
it('should apply custom className', () => {
const { container } = render(<Crawling crawledNum={1} totalNum={5} className="custom" />)
expect(container.querySelector('.custom')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,35 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ErrorMessage from './error-message'
vi.mock('@remixicon/react', () => ({
RiErrorWarningFill: () => <span data-testid="error-icon" />,
}))
describe('ErrorMessage', () => {
it('should render title', () => {
render(<ErrorMessage title="Something went wrong" />)
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
})
it('should render error icon', () => {
render(<ErrorMessage title="Error" />)
expect(screen.getByTestId('error-icon')).toBeInTheDocument()
})
it('should render error message when provided', () => {
render(<ErrorMessage title="Error" errorMsg="Detailed error info" />)
expect(screen.getByText('Detailed error info')).toBeInTheDocument()
})
it('should not render error message when not provided', () => {
const { container } = render(<ErrorMessage title="Error" />)
const textElements = container.querySelectorAll('.system-xs-regular')
expect(textElements).toHaveLength(0)
})
it('should apply custom className', () => {
const { container } = render(<ErrorMessage title="Error" className="custom-cls" />)
expect(container.querySelector('.custom-cls')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,50 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAddDocumentsSteps } from './use-add-documents-steps'
describe('useAddDocumentsSteps', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should initialize with step 1', () => {
const { result } = renderHook(() => useAddDocumentsSteps())
expect(result.current.currentStep).toBe(1)
})
it('should return 3 steps', () => {
const { result } = renderHook(() => useAddDocumentsSteps())
expect(result.current.steps).toHaveLength(3)
})
it('should have correct step labels', () => {
const { result } = renderHook(() => useAddDocumentsSteps())
const labels = result.current.steps.map(s => s.label)
expect(labels[0]).toContain('chooseDatasource')
expect(labels[1]).toContain('processDocuments')
expect(labels[2]).toContain('processingDocuments')
})
it('should increment step on handleNextStep', () => {
const { result } = renderHook(() => useAddDocumentsSteps())
act(() => {
result.current.handleNextStep()
})
expect(result.current.currentStep).toBe(2)
})
it('should decrement step on handleBackStep', () => {
const { result } = renderHook(() => useAddDocumentsSteps())
act(() => {
result.current.handleNextStep()
})
act(() => {
result.current.handleNextStep()
})
expect(result.current.currentStep).toBe(3)
act(() => {
result.current.handleBackStep()
})
expect(result.current.currentStep).toBe(2)
})
})

View File

@@ -0,0 +1,204 @@
import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types'
import type { DataSourceNotionPageMap, NotionPage } from '@/models/common'
import type { CrawlResultItem, DocumentItem, FileItem } from '@/models/datasets'
import type { OnlineDriveFile } from '@/models/pipeline'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DatasourceType } from '@/models/pipeline'
import { createDataSourceStore } from '../data-source/store'
import { useDatasourceActions } from './use-datasource-actions'
const mockRunPublishedPipeline = vi.fn()
vi.mock('@/service/use-pipeline', () => ({
useRunPublishedPipeline: () => ({
mutateAsync: mockRunPublishedPipeline,
isIdle: true,
isPending: false,
}),
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
describe('useDatasourceActions', () => {
let store: ReturnType<typeof createDataSourceStore>
const defaultParams = () => ({
datasource: { nodeId: 'node-1', nodeData: { provider_type: DatasourceType.localFile } } as unknown as Datasource,
datasourceType: DatasourceType.localFile,
pipelineId: 'pipeline-1',
dataSourceStore: store,
setEstimateData: vi.fn(),
setBatchId: vi.fn(),
setDocuments: vi.fn(),
handleNextStep: vi.fn(),
PagesMapAndSelectedPagesId: {},
currentWorkspacePages: undefined as { page_id: string }[] | undefined,
clearOnlineDocumentData: vi.fn(),
clearWebsiteCrawlData: vi.fn(),
clearOnlineDriveData: vi.fn(),
setDatasource: vi.fn(),
})
beforeEach(() => {
vi.clearAllMocks()
store = createDataSourceStore()
})
it('should return all action functions', () => {
const params = defaultParams()
const { result } = renderHook(() => useDatasourceActions(params))
expect(typeof result.current.onClickProcess).toBe('function')
expect(typeof result.current.onClickPreview).toBe('function')
expect(typeof result.current.handleSubmit).toBe('function')
expect(typeof result.current.handlePreviewFileChange).toBe('function')
expect(typeof result.current.handlePreviewOnlineDocumentChange).toBe('function')
expect(typeof result.current.handlePreviewWebsiteChange).toBe('function')
expect(typeof result.current.handlePreviewOnlineDriveFileChange).toBe('function')
expect(typeof result.current.handleSelectAll).toBe('function')
expect(typeof result.current.handleSwitchDataSource).toBe('function')
expect(typeof result.current.handleCredentialChange).toBe('function')
expect(result.current.isIdle).toBe(true)
expect(result.current.isPending).toBe(false)
})
it('should handle credential change by clearing data and setting new credential', () => {
const params = defaultParams()
const { result } = renderHook(() => useDatasourceActions(params))
act(() => {
result.current.handleCredentialChange('cred-new')
})
expect(store.getState().currentCredentialId).toBe('cred-new')
})
it('should handle switch data source', () => {
const params = defaultParams()
const newDatasource = {
nodeId: 'node-2',
nodeData: { provider_type: DatasourceType.onlineDocument },
} as unknown as Datasource
const { result } = renderHook(() => useDatasourceActions(params))
act(() => {
result.current.handleSwitchDataSource(newDatasource)
})
expect(store.getState().currentCredentialId).toBe('')
expect(store.getState().currentNodeIdRef.current).toBe('node-2')
expect(params.setDatasource).toHaveBeenCalledWith(newDatasource)
})
it('should handle preview file change by updating ref', () => {
const params = defaultParams()
params.dataSourceStore = store
const { result } = renderHook(() => useDatasourceActions(params))
// Set up formRef to prevent null error
result.current.formRef.current = { submit: vi.fn() }
const file = { id: 'f1', name: 'test.pdf' } as unknown as DocumentItem
act(() => {
result.current.handlePreviewFileChange(file)
})
expect(store.getState().previewLocalFileRef.current).toEqual(file)
})
it('should handle preview online document change', () => {
const params = defaultParams()
const { result } = renderHook(() => useDatasourceActions(params))
result.current.formRef.current = { submit: vi.fn() }
const page = { page_id: 'p1', page_name: 'My Page' } as unknown as NotionPage
act(() => {
result.current.handlePreviewOnlineDocumentChange(page)
})
expect(store.getState().previewOnlineDocumentRef.current).toEqual(page)
})
it('should handle preview website change', () => {
const params = defaultParams()
const { result } = renderHook(() => useDatasourceActions(params))
result.current.formRef.current = { submit: vi.fn() }
const website = { title: 'Page', source_url: 'https://example.com' } as unknown as CrawlResultItem
act(() => {
result.current.handlePreviewWebsiteChange(website)
})
expect(store.getState().previewWebsitePageRef.current).toEqual(website)
})
it('should handle select all for online documents', () => {
const params = defaultParams()
params.datasourceType = DatasourceType.onlineDocument
params.currentWorkspacePages = [{ page_id: 'p1' }, { page_id: 'p2' }] as unknown as NotionPage[]
params.PagesMapAndSelectedPagesId = {
p1: { page_id: 'p1', page_name: 'A', workspace_id: 'w1' },
p2: { page_id: 'p2', page_name: 'B', workspace_id: 'w1' },
} as unknown as DataSourceNotionPageMap
const { result } = renderHook(() => useDatasourceActions(params))
// First call: select all
act(() => {
result.current.handleSelectAll()
})
expect(store.getState().onlineDocuments).toHaveLength(2)
// Second call: deselect all
act(() => {
result.current.handleSelectAll()
})
expect(store.getState().onlineDocuments).toEqual([])
})
it('should handle select all for online drive', () => {
const params = defaultParams()
params.datasourceType = DatasourceType.onlineDrive
store.getState().setOnlineDriveFileList([
{ id: 'f1', type: 'file' },
{ id: 'f2', type: 'file' },
{ id: 'b1', type: 'bucket' },
] as unknown as OnlineDriveFile[])
const { result } = renderHook(() => useDatasourceActions(params))
act(() => {
result.current.handleSelectAll()
})
// Should select f1, f2 but not b1 (bucket)
expect(store.getState().selectedFileIds).toEqual(['f1', 'f2'])
})
it('should handle submit with preview mode', async () => {
const params = defaultParams()
store.getState().setLocalFileList([{ file: { id: 'f1', name: 'test.pdf' } }] as unknown as FileItem[])
store.getState().previewLocalFileRef.current = { id: 'f1', name: 'test.pdf' } as unknown as DocumentItem
mockRunPublishedPipeline.mockResolvedValue({ data: { outputs: { tokens: 100 } } })
const { result } = renderHook(() => useDatasourceActions(params))
// Set preview mode
result.current.isPreview.current = true
await act(async () => {
await result.current.handleSubmit({ query: 'test' })
})
expect(mockRunPublishedPipeline).toHaveBeenCalledWith(
expect.objectContaining({
pipeline_id: 'pipeline-1',
is_preview: true,
start_node_id: 'node-1',
}),
expect.anything(),
)
})
})

View File

@@ -0,0 +1,58 @@
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import type { Node } from '@/app/components/workflow/types'
import { renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/app/components/workflow/types', async () => {
const actual = await vi.importActual<Record<string, unknown>>('@/app/components/workflow/types')
const blockEnum = actual.BlockEnum as Record<string, string>
return {
...actual,
BlockEnum: {
...blockEnum,
DataSource: 'data-source',
},
}
})
const { useDatasourceOptions } = await import('./use-datasource-options')
describe('useDatasourceOptions', () => {
const createNode = (id: string, title: string, type: string): Node<DataSourceNodeType> => ({
id,
position: { x: 0, y: 0 },
data: {
type,
title,
provider_type: 'local_file',
},
} as unknown as Node<DataSourceNodeType>)
it('should return empty array for no datasource nodes', () => {
const nodes = [
createNode('n1', 'LLM Node', 'llm'),
]
const { result } = renderHook(() => useDatasourceOptions(nodes))
expect(result.current).toEqual([])
})
it('should return options for datasource nodes', () => {
const nodes = [
createNode('n1', 'File Upload', 'data-source'),
createNode('n2', 'Web Crawl', 'data-source'),
createNode('n3', 'LLM Node', 'llm'),
]
const { result } = renderHook(() => useDatasourceOptions(nodes))
expect(result.current).toHaveLength(2)
expect(result.current[0]).toEqual({
label: 'File Upload',
value: 'n1',
data: expect.objectContaining({ title: 'File Upload' }),
})
expect(result.current[1]).toEqual({
label: 'Web Crawl',
value: 'n2',
data: expect.objectContaining({ title: 'Web Crawl' }),
})
})
})

View File

@@ -0,0 +1,207 @@
import type { ReactNode } from 'react'
import type { DataSourceNotionWorkspace, NotionPage } from '@/models/common'
import type { CrawlResultItem, CustomFile as File, FileItem } from '@/models/datasets'
import type { OnlineDriveFile } from '@/models/pipeline'
import { act, renderHook } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CrawlStep } from '@/models/datasets'
import { createDataSourceStore } from '../data-source/store'
import { DataSourceContext } from '../data-source/store/provider'
import { useLocalFile, useOnlineDocument, useOnlineDrive, useWebsiteCrawl } from './use-datasource-store'
const createWrapper = (store: ReturnType<typeof createDataSourceStore>) => {
return ({ children }: { children: ReactNode }) =>
React.createElement(DataSourceContext.Provider, { value: store }, children)
}
describe('useLocalFile', () => {
let store: ReturnType<typeof createDataSourceStore>
beforeEach(() => {
vi.clearAllMocks()
store = createDataSourceStore()
})
it('should return local file list and initial state', () => {
const { result } = renderHook(() => useLocalFile(), { wrapper: createWrapper(store) })
expect(result.current.localFileList).toEqual([])
expect(result.current.allFileLoaded).toBe(false)
expect(result.current.currentLocalFile).toBeUndefined()
})
it('should compute allFileLoaded when all files have ids', () => {
store.getState().setLocalFileList([
{ file: { id: 'f1', name: 'a.pdf' } },
{ file: { id: 'f2', name: 'b.pdf' } },
] as unknown as FileItem[])
const { result } = renderHook(() => useLocalFile(), { wrapper: createWrapper(store) })
expect(result.current.allFileLoaded).toBe(true)
})
it('should compute allFileLoaded as false when some files lack ids', () => {
store.getState().setLocalFileList([
{ file: { id: 'f1', name: 'a.pdf' } },
{ file: { id: '', name: 'b.pdf' } },
] as unknown as FileItem[])
const { result } = renderHook(() => useLocalFile(), { wrapper: createWrapper(store) })
expect(result.current.allFileLoaded).toBe(false)
})
it('should hide preview local file', () => {
store.getState().setCurrentLocalFile({ id: 'f1' } as unknown as File)
const { result } = renderHook(() => useLocalFile(), { wrapper: createWrapper(store) })
act(() => {
result.current.hidePreviewLocalFile()
})
expect(store.getState().currentLocalFile).toBeUndefined()
})
})
describe('useOnlineDocument', () => {
let store: ReturnType<typeof createDataSourceStore>
beforeEach(() => {
vi.clearAllMocks()
store = createDataSourceStore()
})
it('should return initial state', () => {
const { result } = renderHook(() => useOnlineDocument(), { wrapper: createWrapper(store) })
expect(result.current.onlineDocuments).toEqual([])
expect(result.current.currentDocument).toBeUndefined()
expect(result.current.currentWorkspace).toBeUndefined()
})
it('should build PagesMapAndSelectedPagesId from documentsData', () => {
store.getState().setDocumentsData([
{ workspace_id: 'w1', pages: [{ page_id: 'p1', page_name: 'Page 1' }] },
] as unknown as DataSourceNotionWorkspace[])
const { result } = renderHook(() => useOnlineDocument(), { wrapper: createWrapper(store) })
expect(result.current.PagesMapAndSelectedPagesId).toHaveProperty('p1')
expect(result.current.PagesMapAndSelectedPagesId.p1.workspace_id).toBe('w1')
})
it('should hide preview online document', () => {
store.getState().setCurrentDocument({ page_id: 'p1' } as unknown as NotionPage)
const { result } = renderHook(() => useOnlineDocument(), { wrapper: createWrapper(store) })
act(() => {
result.current.hidePreviewOnlineDocument()
})
expect(store.getState().currentDocument).toBeUndefined()
})
it('should clear online document data', () => {
store.getState().setDocumentsData([{ workspace_id: 'w1', pages: [] }] as unknown as DataSourceNotionWorkspace[])
store.getState().setSearchValue('test')
store.getState().setOnlineDocuments([{ page_id: 'p1' }] as unknown as NotionPage[])
const { result } = renderHook(() => useOnlineDocument(), { wrapper: createWrapper(store) })
act(() => {
result.current.clearOnlineDocumentData()
})
expect(store.getState().documentsData).toEqual([])
expect(store.getState().searchValue).toBe('')
expect(store.getState().onlineDocuments).toEqual([])
})
})
describe('useWebsiteCrawl', () => {
let store: ReturnType<typeof createDataSourceStore>
beforeEach(() => {
vi.clearAllMocks()
store = createDataSourceStore()
})
it('should return initial state', () => {
const { result } = renderHook(() => useWebsiteCrawl(), { wrapper: createWrapper(store) })
expect(result.current.websitePages).toEqual([])
expect(result.current.currentWebsite).toBeUndefined()
})
it('should hide website preview', () => {
store.getState().setCurrentWebsite({ title: 'Test' } as unknown as CrawlResultItem)
store.getState().setPreviewIndex(2)
const { result } = renderHook(() => useWebsiteCrawl(), { wrapper: createWrapper(store) })
act(() => {
result.current.hideWebsitePreview()
})
expect(store.getState().currentWebsite).toBeUndefined()
expect(store.getState().previewIndex).toBe(-1)
})
it('should clear website crawl data', () => {
store.getState().setStep(CrawlStep.running)
store.getState().setWebsitePages([{ title: 'Test' }] as unknown as CrawlResultItem[])
const { result } = renderHook(() => useWebsiteCrawl(), { wrapper: createWrapper(store) })
act(() => {
result.current.clearWebsiteCrawlData()
})
expect(store.getState().step).toBe(CrawlStep.init)
expect(store.getState().websitePages).toEqual([])
expect(store.getState().currentWebsite).toBeUndefined()
})
})
describe('useOnlineDrive', () => {
let store: ReturnType<typeof createDataSourceStore>
beforeEach(() => {
vi.clearAllMocks()
store = createDataSourceStore()
})
it('should return initial state', () => {
const { result } = renderHook(() => useOnlineDrive(), { wrapper: createWrapper(store) })
expect(result.current.onlineDriveFileList).toEqual([])
expect(result.current.selectedFileIds).toEqual([])
expect(result.current.selectedOnlineDriveFileList).toEqual([])
})
it('should compute selected online drive file list', () => {
const files = [
{ id: 'f1', name: 'a.pdf' },
{ id: 'f2', name: 'b.pdf' },
{ id: 'f3', name: 'c.pdf' },
] as unknown as OnlineDriveFile[]
store.getState().setOnlineDriveFileList(files)
store.getState().setSelectedFileIds(['f1', 'f3'])
const { result } = renderHook(() => useOnlineDrive(), { wrapper: createWrapper(store) })
expect(result.current.selectedOnlineDriveFileList).toEqual([files[0], files[2]])
})
it('should clear online drive data', () => {
store.getState().setOnlineDriveFileList([{ id: 'f1' }] as unknown as OnlineDriveFile[])
store.getState().setBucket('b1')
store.getState().setPrefix(['p1'])
store.getState().setKeywords('kw')
store.getState().setSelectedFileIds(['f1'])
const { result } = renderHook(() => useOnlineDrive(), { wrapper: createWrapper(store) })
act(() => {
result.current.clearOnlineDriveData()
})
expect(store.getState().onlineDriveFileList).toEqual([])
expect(store.getState().bucket).toBe('')
expect(store.getState().prefix).toEqual([])
expect(store.getState().keywords).toBe('')
expect(store.getState().selectedFileIds).toEqual([])
})
})

View File

@@ -0,0 +1,205 @@
import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types'
import type { OnlineDriveFile } from '@/models/pipeline'
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DatasourceType, OnlineDriveFileType } from '@/models/pipeline'
import { useDatasourceUIState } from './use-datasource-ui-state'
describe('useDatasourceUIState', () => {
const defaultParams = {
datasource: { nodeData: { provider_type: DatasourceType.localFile } } as unknown as Datasource,
allFileLoaded: true,
localFileListLength: 3,
onlineDocumentsLength: 0,
websitePagesLength: 0,
selectedFileIdsLength: 0,
onlineDriveFileList: [] as OnlineDriveFile[],
isVectorSpaceFull: false,
enableBilling: false,
currentWorkspacePagesLength: 0,
fileUploadConfig: { file_size_limit: 50, batch_count_limit: 20 },
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('datasourceType', () => {
it('should return provider_type from datasource', () => {
const { result } = renderHook(() => useDatasourceUIState(defaultParams))
expect(result.current.datasourceType).toBe(DatasourceType.localFile)
})
it('should return undefined when no datasource', () => {
const { result } = renderHook(() =>
useDatasourceUIState({ ...defaultParams, datasource: undefined }),
)
expect(result.current.datasourceType).toBeUndefined()
})
})
describe('isShowVectorSpaceFull', () => {
it('should be false when billing disabled', () => {
const { result } = renderHook(() =>
useDatasourceUIState({ ...defaultParams, isVectorSpaceFull: true }),
)
expect(result.current.isShowVectorSpaceFull).toBe(false)
})
it('should be true when billing enabled and space is full for local file', () => {
const { result } = renderHook(() =>
useDatasourceUIState({
...defaultParams,
isVectorSpaceFull: true,
enableBilling: true,
allFileLoaded: true,
}),
)
expect(result.current.isShowVectorSpaceFull).toBe(true)
})
it('should be false when no datasource', () => {
const { result } = renderHook(() =>
useDatasourceUIState({
...defaultParams,
datasource: undefined,
isVectorSpaceFull: true,
enableBilling: true,
}),
)
expect(result.current.isShowVectorSpaceFull).toBe(false)
})
})
describe('nextBtnDisabled', () => {
it('should be true when no datasource', () => {
const { result } = renderHook(() =>
useDatasourceUIState({ ...defaultParams, datasource: undefined }),
)
expect(result.current.nextBtnDisabled).toBe(true)
})
it('should be false when local files loaded', () => {
const { result } = renderHook(() => useDatasourceUIState(defaultParams))
expect(result.current.nextBtnDisabled).toBe(false)
})
it('should be true when local file list empty', () => {
const { result } = renderHook(() =>
useDatasourceUIState({ ...defaultParams, localFileListLength: 0 }),
)
expect(result.current.nextBtnDisabled).toBe(true)
})
it('should be true when files not all loaded', () => {
const { result } = renderHook(() =>
useDatasourceUIState({ ...defaultParams, allFileLoaded: false }),
)
expect(result.current.nextBtnDisabled).toBe(true)
})
it('should be false for online document with documents selected', () => {
const { result } = renderHook(() =>
useDatasourceUIState({
...defaultParams,
datasource: { nodeData: { provider_type: DatasourceType.onlineDocument } } as unknown as Datasource,
onlineDocumentsLength: 2,
}),
)
expect(result.current.nextBtnDisabled).toBe(false)
})
it('should be true for online document with no documents', () => {
const { result } = renderHook(() =>
useDatasourceUIState({
...defaultParams,
datasource: { nodeData: { provider_type: DatasourceType.onlineDocument } } as unknown as Datasource,
onlineDocumentsLength: 0,
}),
)
expect(result.current.nextBtnDisabled).toBe(true)
})
})
describe('showSelect', () => {
it('should be false for local file type', () => {
const { result } = renderHook(() => useDatasourceUIState(defaultParams))
expect(result.current.showSelect).toBe(false)
})
it('should be true for online document with workspace pages', () => {
const { result } = renderHook(() =>
useDatasourceUIState({
...defaultParams,
datasource: { nodeData: { provider_type: DatasourceType.onlineDocument } } as unknown as Datasource,
currentWorkspacePagesLength: 5,
}),
)
expect(result.current.showSelect).toBe(true)
})
it('should be true for online drive with non-bucket files', () => {
const { result } = renderHook(() =>
useDatasourceUIState({
...defaultParams,
datasource: { nodeData: { provider_type: DatasourceType.onlineDrive } } as unknown as Datasource,
onlineDriveFileList: [
{ id: '1', name: 'file.txt', type: OnlineDriveFileType.file },
],
}),
)
expect(result.current.showSelect).toBe(true)
})
it('should be false for online drive showing only buckets', () => {
const { result } = renderHook(() =>
useDatasourceUIState({
...defaultParams,
datasource: { nodeData: { provider_type: DatasourceType.onlineDrive } } as unknown as Datasource,
onlineDriveFileList: [
{ id: '1', name: 'bucket-1', type: OnlineDriveFileType.bucket },
],
}),
)
expect(result.current.showSelect).toBe(false)
})
})
describe('totalOptions and selectedOptions', () => {
it('should return workspace pages count for online document', () => {
const { result } = renderHook(() =>
useDatasourceUIState({
...defaultParams,
datasource: { nodeData: { provider_type: DatasourceType.onlineDocument } } as unknown as Datasource,
currentWorkspacePagesLength: 10,
onlineDocumentsLength: 3,
}),
)
expect(result.current.totalOptions).toBe(10)
expect(result.current.selectedOptions).toBe(3)
})
it('should return undefined for local file type', () => {
const { result } = renderHook(() => useDatasourceUIState(defaultParams))
expect(result.current.totalOptions).toBeUndefined()
expect(result.current.selectedOptions).toBeUndefined()
})
})
describe('tip', () => {
it('should return empty string for local file', () => {
const { result } = renderHook(() => useDatasourceUIState(defaultParams))
expect(result.current.tip).toBe('')
})
it('should return tip for online document', () => {
const { result } = renderHook(() =>
useDatasourceUIState({
...defaultParams,
datasource: { nodeData: { provider_type: DatasourceType.onlineDocument } } as unknown as Datasource,
}),
)
expect(result.current.tip).toContain('selectOnlineDocumentTip')
})
})
})

View File

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

View File

@@ -1,320 +1,77 @@
import type { CustomFile as File } from '@/models/datasets'
import type { CustomFile } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import FilePreview from './file-preview'
// Uses global react-i18next mock from web/vitest.setup.ts
// Mock useFilePreview hook - needs to be mocked to control return values
const mockUseFilePreview = vi.fn()
vi.mock('@/service/use-common', () => ({
useFilePreview: (fileID: string) => mockUseFilePreview(fileID),
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Test data factory
const createMockFile = (overrides?: Partial<File>): File => ({
id: 'file-123',
name: 'test-document.pdf',
size: 2048,
type: 'application/pdf',
extension: 'pdf',
lastModified: Date.now(),
webkitRelativePath: '',
arrayBuffer: vi.fn() as () => Promise<ArrayBuffer>,
bytes: vi.fn() as () => Promise<Uint8Array>,
slice: vi.fn() as (start?: number, end?: number, contentType?: string) => Blob,
stream: vi.fn() as () => ReadableStream<Uint8Array>,
text: vi.fn() as () => Promise<string>,
...overrides,
} as File)
vi.mock('@remixicon/react', () => ({
RiCloseLine: () => <span data-testid="close-icon" />,
}))
const createMockFilePreviewData = (content: string = 'This is the file content') => ({
content,
})
const mockFileData = { content: 'file content here with some text' }
let mockIsFetching = false
const defaultProps = {
file: createMockFile(),
hidePreview: vi.fn(),
}
vi.mock('@/service/use-common', () => ({
useFilePreview: () => ({
data: mockIsFetching ? undefined : mockFileData,
isFetching: mockIsFetching,
}),
}))
vi.mock('../../../common/document-file-icon', () => ({
default: () => <span data-testid="file-icon" />,
}))
vi.mock('./loading', () => ({
default: () => <div data-testid="loading" />,
}))
describe('FilePreview', () => {
const defaultProps = {
file: {
id: 'file-1',
name: 'document.pdf',
extension: 'pdf',
size: 1024,
} as CustomFile,
hidePreview: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
mockUseFilePreview.mockReturnValue({
data: undefined,
isFetching: false,
})
mockIsFetching = false
})
describe('Rendering', () => {
it('should render the component with file information', () => {
render(<FilePreview {...defaultProps} />)
// i18n mock returns key by default
expect(screen.getByText('datasetPipeline.addDocuments.stepOne.preview')).toBeInTheDocument()
expect(screen.getByText('test-document.pdf')).toBeInTheDocument()
})
it('should display file extension in uppercase via CSS class', () => {
render(<FilePreview {...defaultProps} />)
// The extension is displayed in the info section (as uppercase via CSS class)
const extensionElement = screen.getByText('pdf')
expect(extensionElement).toBeInTheDocument()
expect(extensionElement).toHaveClass('uppercase')
})
it('should display formatted file size', () => {
render(<FilePreview {...defaultProps} />)
// Real formatFileSize: 2048 bytes => "2.00 KB"
expect(screen.getByText('2.00 KB')).toBeInTheDocument()
})
it('should render close button', () => {
render(<FilePreview {...defaultProps} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should call useFilePreview with correct fileID', () => {
const file = createMockFile({ id: 'specific-file-id' })
render(<FilePreview {...defaultProps} file={file} />)
expect(mockUseFilePreview).toHaveBeenCalledWith('specific-file-id')
})
it('should render preview label', () => {
render(<FilePreview {...defaultProps} />)
expect(screen.getByText('addDocuments.stepOne.preview')).toBeInTheDocument()
})
describe('File Name Processing', () => {
it('should extract file name without extension', () => {
const file = createMockFile({ name: 'my-document.pdf', extension: 'pdf' })
render(<FilePreview {...defaultProps} file={file} />)
// The displayed text is `${fileName}.${extension}`, where fileName is name without ext
// my-document.pdf -> fileName = 'my-document', displayed as 'my-document.pdf'
expect(screen.getByText('my-document.pdf')).toBeInTheDocument()
})
it('should handle file name with multiple dots', () => {
const file = createMockFile({ name: 'my.file.name.pdf', extension: 'pdf' })
render(<FilePreview {...defaultProps} file={file} />)
// fileName = arr.slice(0, -1).join() = 'my,file,name', then displayed as 'my,file,name.pdf'
expect(screen.getByText('my,file,name.pdf')).toBeInTheDocument()
})
it('should handle empty file name', () => {
const file = createMockFile({ name: '', extension: '' })
render(<FilePreview {...defaultProps} file={file} />)
// fileName = '', displayed as '.'
expect(screen.getByText('.')).toBeInTheDocument()
})
it('should handle file without extension in name', () => {
const file = createMockFile({ name: 'noextension', extension: '' })
render(<FilePreview {...defaultProps} file={file} />)
// fileName = '' (slice returns empty for single element array), displayed as '.'
expect(screen.getByText('.')).toBeInTheDocument()
})
it('should render file name', () => {
render(<FilePreview {...defaultProps} />)
expect(screen.getByText('document.pdf')).toBeInTheDocument()
})
describe('Loading State', () => {
it('should render loading component when fetching', () => {
mockUseFilePreview.mockReturnValue({
data: undefined,
isFetching: true,
})
render(<FilePreview {...defaultProps} />)
// Loading component renders skeleton
expect(document.querySelector('.overflow-hidden')).toBeInTheDocument()
})
it('should not render content when loading', () => {
mockUseFilePreview.mockReturnValue({
data: createMockFilePreviewData('Some content'),
isFetching: true,
})
render(<FilePreview {...defaultProps} />)
expect(screen.queryByText('Some content')).not.toBeInTheDocument()
})
it('should render file content when loaded', () => {
render(<FilePreview {...defaultProps} />)
expect(screen.getByText('file content here with some text')).toBeInTheDocument()
})
describe('Content Display', () => {
it('should render file content when loaded', () => {
mockUseFilePreview.mockReturnValue({
data: createMockFilePreviewData('This is the file content'),
isFetching: false,
})
render(<FilePreview {...defaultProps} />)
expect(screen.getByText('This is the file content')).toBeInTheDocument()
})
it('should display character count when data is available', () => {
mockUseFilePreview.mockReturnValue({
data: createMockFilePreviewData('Hello'), // 5 characters
isFetching: false,
})
render(<FilePreview {...defaultProps} />)
// Real formatNumberAbbreviated returns "5" for numbers < 1000
expect(screen.getByText(/5/)).toBeInTheDocument()
})
it('should format large character counts', () => {
const longContent = 'a'.repeat(2500)
mockUseFilePreview.mockReturnValue({
data: createMockFilePreviewData(longContent),
isFetching: false,
})
render(<FilePreview {...defaultProps} />)
// Real formatNumberAbbreviated uses lowercase 'k': "2.5k"
expect(screen.getByText(/2\.5k/)).toBeInTheDocument()
})
it('should not display character count when data is not available', () => {
mockUseFilePreview.mockReturnValue({
data: undefined,
isFetching: false,
})
render(<FilePreview {...defaultProps} />)
// No character text shown
expect(screen.queryByText(/datasetPipeline\.addDocuments\.characters/)).not.toBeInTheDocument()
})
it('should render loading state', () => {
mockIsFetching = true
render(<FilePreview {...defaultProps} />)
expect(screen.getByTestId('loading')).toBeInTheDocument()
})
describe('User Interactions', () => {
it('should call hidePreview when close button is clicked', () => {
const hidePreview = vi.fn()
render(<FilePreview {...defaultProps} hidePreview={hidePreview} />)
const closeButton = screen.getByRole('button')
fireEvent.click(closeButton)
expect(hidePreview).toHaveBeenCalledTimes(1)
})
})
describe('File Size Formatting', () => {
it('should format small file sizes in bytes', () => {
const file = createMockFile({ size: 500 })
render(<FilePreview {...defaultProps} file={file} />)
// Real formatFileSize: 500 => "500.00 bytes"
expect(screen.getByText('500.00 bytes')).toBeInTheDocument()
})
it('should format kilobyte file sizes', () => {
const file = createMockFile({ size: 5120 })
render(<FilePreview {...defaultProps} file={file} />)
// Real formatFileSize: 5120 => "5.00 KB"
expect(screen.getByText('5.00 KB')).toBeInTheDocument()
})
it('should format megabyte file sizes', () => {
const file = createMockFile({ size: 2097152 })
render(<FilePreview {...defaultProps} file={file} />)
// Real formatFileSize: 2097152 => "2.00 MB"
expect(screen.getByText('2.00 MB')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle undefined file id', () => {
const file = createMockFile({ id: undefined })
render(<FilePreview {...defaultProps} file={file} />)
expect(mockUseFilePreview).toHaveBeenCalledWith('')
})
it('should handle empty extension', () => {
const file = createMockFile({ extension: undefined })
render(<FilePreview {...defaultProps} file={file} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle zero file size', () => {
const file = createMockFile({ size: 0 })
render(<FilePreview {...defaultProps} file={file} />)
// Real formatFileSize returns 0 for falsy values
// The component still renders
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle very long file content', () => {
const veryLongContent = 'a'.repeat(1000000)
mockUseFilePreview.mockReturnValue({
data: createMockFilePreviewData(veryLongContent),
isFetching: false,
})
render(<FilePreview {...defaultProps} />)
// Real formatNumberAbbreviated: 1000000 => "1M"
expect(screen.getByText(/1M/)).toBeInTheDocument()
})
it('should handle empty content', () => {
mockUseFilePreview.mockReturnValue({
data: createMockFilePreviewData(''),
isFetching: false,
})
render(<FilePreview {...defaultProps} />)
// Real formatNumberAbbreviated: 0 => "0"
// Find the element that contains character count info
expect(screen.getByText(/0 datasetPipeline/)).toBeInTheDocument()
})
})
describe('useMemo for fileName', () => {
it('should extract file name when file exists', () => {
// When file exists, it should extract the name without extension
const file = createMockFile({ name: 'document.txt', extension: 'txt' })
render(<FilePreview {...defaultProps} file={file} />)
expect(screen.getByText('document.txt')).toBeInTheDocument()
})
it('should memoize fileName based on file prop', () => {
const file = createMockFile({ name: 'test.pdf', extension: 'pdf' })
const { rerender } = render(<FilePreview {...defaultProps} file={file} />)
// Same file should produce same result
rerender(<FilePreview {...defaultProps} file={file} />)
expect(screen.getByText('test.pdf')).toBeInTheDocument()
})
it('should call hidePreview when close button clicked', () => {
render(<FilePreview {...defaultProps} />)
const closeBtn = screen.getByTestId('close-icon').closest('button')!
fireEvent.click(closeBtn)
expect(defaultProps.hidePreview).toHaveBeenCalled()
})
})

View File

@@ -1,256 +1,58 @@
import type { CrawlResultItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import WebsitePreview from './web-preview'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import WebPreview from './web-preview'
// Uses global react-i18next mock from web/vitest.setup.ts
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Test data factory
const createMockCrawlResult = (overrides?: Partial<CrawlResultItem>): CrawlResultItem => ({
title: 'Test Website Title',
markdown: 'This is the **markdown** content of the website.',
description: 'Test description',
source_url: 'https://example.com/page',
...overrides,
})
vi.mock('@remixicon/react', () => ({
RiCloseLine: () => <span data-testid="close-icon" />,
RiGlobalLine: () => <span data-testid="global-icon" />,
}))
const defaultProps = {
currentWebsite: createMockCrawlResult(),
hidePreview: vi.fn(),
}
describe('WebPreview', () => {
const defaultProps = {
currentWebsite: {
title: 'Test Page',
source_url: 'https://example.com',
markdown: 'Hello **markdown** content',
description: '',
} satisfies CrawlResultItem,
hidePreview: vi.fn(),
}
describe('WebsitePreview', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render the component with website information', () => {
render(<WebsitePreview {...defaultProps} />)
// i18n mock returns key by default
expect(screen.getByText('datasetPipeline.addDocuments.stepOne.preview')).toBeInTheDocument()
expect(screen.getByText('Test Website Title')).toBeInTheDocument()
})
it('should display the source URL', () => {
render(<WebsitePreview {...defaultProps} />)
expect(screen.getByText('https://example.com/page')).toBeInTheDocument()
})
it('should render close button', () => {
render(<WebsitePreview {...defaultProps} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render the markdown content', () => {
render(<WebsitePreview {...defaultProps} />)
expect(screen.getByText('This is the **markdown** content of the website.')).toBeInTheDocument()
})
it('should render preview label', () => {
render(<WebPreview {...defaultProps} />)
expect(screen.getByText('addDocuments.stepOne.preview')).toBeInTheDocument()
})
describe('Character Count', () => {
it('should display character count for small content', () => {
const currentWebsite = createMockCrawlResult({ markdown: 'Hello' }) // 5 characters
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
// Real formatNumberAbbreviated returns "5" for numbers < 1000
expect(screen.getByText(/5/)).toBeInTheDocument()
})
it('should format character count in thousands', () => {
const longContent = 'a'.repeat(2500)
const currentWebsite = createMockCrawlResult({ markdown: longContent })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
// Real formatNumberAbbreviated uses lowercase 'k': "2.5k"
expect(screen.getByText(/2\.5k/)).toBeInTheDocument()
})
it('should format character count in millions', () => {
const veryLongContent = 'a'.repeat(1500000)
const currentWebsite = createMockCrawlResult({ markdown: veryLongContent })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByText(/1\.5M/)).toBeInTheDocument()
})
it('should show 0 characters for empty markdown', () => {
const currentWebsite = createMockCrawlResult({ markdown: '' })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByText(/0/)).toBeInTheDocument()
})
it('should render page title', () => {
render(<WebPreview {...defaultProps} />)
expect(screen.getByText('Test Page')).toBeInTheDocument()
})
describe('User Interactions', () => {
it('should call hidePreview when close button is clicked', () => {
const hidePreview = vi.fn()
render(<WebsitePreview {...defaultProps} hidePreview={hidePreview} />)
const closeButton = screen.getByRole('button')
fireEvent.click(closeButton)
expect(hidePreview).toHaveBeenCalledTimes(1)
})
it('should render source URL', () => {
render(<WebPreview {...defaultProps} />)
expect(screen.getByText('https://example.com')).toBeInTheDocument()
})
describe('URL Display', () => {
it('should display long URLs', () => {
const longUrl = 'https://example.com/very/long/path/to/page/with/many/segments'
const currentWebsite = createMockCrawlResult({ source_url: longUrl })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
const urlElement = screen.getByTitle(longUrl)
expect(urlElement).toBeInTheDocument()
expect(urlElement).toHaveTextContent(longUrl)
})
it('should display URL with title attribute', () => {
const currentWebsite = createMockCrawlResult({ source_url: 'https://test.com' })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByTitle('https://test.com')).toBeInTheDocument()
})
it('should render markdown content', () => {
render(<WebPreview {...defaultProps} />)
expect(screen.getByText('Hello **markdown** content')).toBeInTheDocument()
})
describe('Content Display', () => {
it('should display the markdown content in content area', () => {
const currentWebsite = createMockCrawlResult({
markdown: 'Content with **bold** and *italic* text.',
})
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByText('Content with **bold** and *italic* text.')).toBeInTheDocument()
})
it('should handle multiline content', () => {
const multilineContent = 'Line 1\nLine 2\nLine 3'
const currentWebsite = createMockCrawlResult({ markdown: multilineContent })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
// Multiline content is rendered as-is
expect(screen.getByText((content) => {
return content.includes('Line 1') && content.includes('Line 2') && content.includes('Line 3')
})).toBeInTheDocument()
})
it('should handle special characters in content', () => {
const specialContent = '<script>alert("xss")</script> & < > " \''
const currentWebsite = createMockCrawlResult({ markdown: specialContent })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByText(specialContent)).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty title', () => {
const currentWebsite = createMockCrawlResult({ title: '' })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle empty source URL', () => {
const currentWebsite = createMockCrawlResult({ source_url: '' })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle very long title', () => {
const longTitle = 'A'.repeat(500)
const currentWebsite = createMockCrawlResult({ title: longTitle })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByText(longTitle)).toBeInTheDocument()
})
it('should handle unicode characters in content', () => {
const unicodeContent = '你好世界 🌍 مرحبا こんにちは'
const currentWebsite = createMockCrawlResult({ markdown: unicodeContent })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByText(unicodeContent)).toBeInTheDocument()
})
it('should handle URL with query parameters', () => {
const urlWithParams = 'https://example.com/page?query=test&param=value'
const currentWebsite = createMockCrawlResult({ source_url: urlWithParams })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByTitle(urlWithParams)).toBeInTheDocument()
})
it('should handle URL with hash fragment', () => {
const urlWithHash = 'https://example.com/page#section-1'
const currentWebsite = createMockCrawlResult({ source_url: urlWithHash })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByTitle(urlWithHash)).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should apply container styles', () => {
const { container } = render(<WebsitePreview {...defaultProps} />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toHaveClass('flex', 'h-full', 'w-full', 'flex-col')
})
})
describe('Multiple Renders', () => {
it('should update when currentWebsite changes', () => {
const website1 = createMockCrawlResult({ title: 'Website 1', markdown: 'Content 1' })
const website2 = createMockCrawlResult({ title: 'Website 2', markdown: 'Content 2' })
const { rerender } = render(<WebsitePreview {...defaultProps} currentWebsite={website1} />)
expect(screen.getByText('Website 1')).toBeInTheDocument()
expect(screen.getByText('Content 1')).toBeInTheDocument()
rerender(<WebsitePreview {...defaultProps} currentWebsite={website2} />)
expect(screen.getByText('Website 2')).toBeInTheDocument()
expect(screen.getByText('Content 2')).toBeInTheDocument()
})
it('should call new hidePreview when prop changes', () => {
const hidePreview1 = vi.fn()
const hidePreview2 = vi.fn()
const { rerender } = render(<WebsitePreview {...defaultProps} hidePreview={hidePreview1} />)
const closeButton = screen.getByRole('button')
fireEvent.click(closeButton)
expect(hidePreview1).toHaveBeenCalledTimes(1)
rerender(<WebsitePreview {...defaultProps} hidePreview={hidePreview2} />)
fireEvent.click(closeButton)
expect(hidePreview2).toHaveBeenCalledTimes(1)
expect(hidePreview1).toHaveBeenCalledTimes(1)
})
it('should call hidePreview when close button clicked', () => {
render(<WebPreview {...defaultProps} />)
const closeBtn = screen.getByTestId('close-icon').closest('button')!
fireEvent.click(closeBtn)
expect(defaultProps.hidePreview).toHaveBeenCalled()
})
})

View File

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

View File

@@ -0,0 +1,67 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Header from './header'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@remixicon/react', () => ({
RiSearchEyeLine: () => <span data-testid="search-icon" />,
}))
vi.mock('@/app/components/base/button', () => ({
default: ({ children, onClick, disabled, variant }: { children: React.ReactNode, onClick: () => void, disabled?: boolean, variant: string }) => (
<button data-testid={`btn-${variant}`} onClick={onClick} disabled={disabled}>
{children}
</button>
),
}))
describe('Header', () => {
const defaultProps = {
onReset: vi.fn(),
resetDisabled: false,
previewDisabled: false,
onPreview: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render chunk settings title', () => {
render(<Header {...defaultProps} />)
expect(screen.getByText('addDocuments.stepTwo.chunkSettings')).toBeInTheDocument()
})
it('should render reset and preview buttons', () => {
render(<Header {...defaultProps} />)
expect(screen.getByTestId('btn-ghost')).toBeInTheDocument()
expect(screen.getByTestId('btn-secondary-accent')).toBeInTheDocument()
})
it('should call onReset when reset clicked', () => {
render(<Header {...defaultProps} />)
fireEvent.click(screen.getByTestId('btn-ghost'))
expect(defaultProps.onReset).toHaveBeenCalled()
})
it('should call onPreview when preview clicked', () => {
render(<Header {...defaultProps} />)
fireEvent.click(screen.getByTestId('btn-secondary-accent'))
expect(defaultProps.onPreview).toHaveBeenCalled()
})
it('should disable reset button when resetDisabled is true', () => {
render(<Header {...defaultProps} resetDisabled={true} />)
expect(screen.getByTestId('btn-ghost')).toBeDisabled()
})
it('should disable preview button when previewDisabled is true', () => {
render(<Header {...defaultProps} previewDisabled={true} />)
expect(screen.getByTestId('btn-secondary-accent')).toBeDisabled()
})
})

View File

@@ -0,0 +1,52 @@
import type { PipelineProcessingParamsRequest } from '@/models/pipeline'
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useInputVariables } from './hooks'
const mockUseDatasetDetailContextWithSelector = vi.fn()
const mockUsePublishedPipelineProcessingParams = vi.fn()
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (value: unknown) => unknown) => mockUseDatasetDetailContextWithSelector(selector),
}))
vi.mock('@/service/use-pipeline', () => ({
usePublishedPipelineProcessingParams: (params: PipelineProcessingParamsRequest) => mockUsePublishedPipelineProcessingParams(params),
}))
describe('useInputVariables', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseDatasetDetailContextWithSelector.mockReturnValue('pipeline-123')
mockUsePublishedPipelineProcessingParams.mockReturnValue({
data: { inputs: [{ name: 'query', type: 'string' }] },
isFetching: false,
})
})
it('should return paramsConfig and isFetchingParams', () => {
const { result } = renderHook(() => useInputVariables('node-1'))
expect(result.current.paramsConfig).toEqual({ inputs: [{ name: 'query', type: 'string' }] })
expect(result.current.isFetchingParams).toBe(false)
})
it('should call usePublishedPipelineProcessingParams with pipeline_id and node_id', () => {
renderHook(() => useInputVariables('node-1'))
expect(mockUsePublishedPipelineProcessingParams).toHaveBeenCalledWith({
pipeline_id: 'pipeline-123',
node_id: 'node-1',
})
})
it('should return isFetchingParams true when loading', () => {
mockUsePublishedPipelineProcessingParams.mockReturnValue({
data: undefined,
isFetching: true,
})
const { result } = renderHook(() => useInputVariables('node-1'))
expect(result.current.isFetchingParams).toBe(true)
expect(result.current.paramsConfig).toBeUndefined()
})
})

View File

@@ -0,0 +1,32 @@
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import StepIndicator from './step-indicator'
describe('StepIndicator', () => {
const steps = [
{ label: 'Data Source', value: 'data-source' },
{ label: 'Process', value: 'process' },
{ label: 'Embedding', value: 'embedding' },
]
it('should render dots for each step', () => {
const { container } = render(<StepIndicator steps={steps} currentStep={1} />)
const dots = container.querySelectorAll('.rounded-lg')
expect(dots).toHaveLength(3)
})
it('should apply active style to current step', () => {
const { container } = render(<StepIndicator steps={steps} currentStep={2} />)
const dots = container.querySelectorAll('.rounded-lg')
// Second step (index 1) should be active
expect(dots[1].className).toContain('bg-state-accent-solid')
expect(dots[1].className).toContain('w-2')
})
it('should not apply active style to non-current steps', () => {
const { container } = render(<StepIndicator steps={steps} currentStep={1} />)
const dots = container.querySelectorAll('.rounded-lg')
expect(dots[1].className).toContain('bg-divider-solid')
expect(dots[2].className).toContain('bg-divider-solid')
})
})

View File

@@ -0,0 +1,104 @@
import type { NotionPage } from '@/models/common'
import type { CrawlResultItem, CustomFile } from '@/models/datasets'
import type { OnlineDriveFile } from '@/models/pipeline'
import { describe, expect, it } from 'vitest'
import { OnlineDriveFileType } from '@/models/pipeline'
import { TransferMethod } from '@/types/app'
import {
buildLocalFileDatasourceInfo,
buildOnlineDocumentDatasourceInfo,
buildOnlineDriveDatasourceInfo,
buildWebsiteCrawlDatasourceInfo,
} from './datasource-info-builder'
describe('datasource-info-builder', () => {
describe('buildLocalFileDatasourceInfo', () => {
const file: CustomFile = {
id: 'file-1',
name: 'test.pdf',
type: 'application/pdf',
size: 1024,
extension: 'pdf',
mime_type: 'application/pdf',
} as unknown as CustomFile
it('should build local file datasource info', () => {
const result = buildLocalFileDatasourceInfo(file, 'cred-1')
expect(result).toEqual({
related_id: 'file-1',
name: 'test.pdf',
type: 'application/pdf',
size: 1024,
extension: 'pdf',
mime_type: 'application/pdf',
url: '',
transfer_method: TransferMethod.local_file,
credential_id: 'cred-1',
})
})
it('should use empty credential when not provided', () => {
const result = buildLocalFileDatasourceInfo(file, '')
expect(result.credential_id).toBe('')
})
})
describe('buildOnlineDocumentDatasourceInfo', () => {
const page = {
page_id: 'page-1',
page_name: 'My Page',
workspace_id: 'ws-1',
parent_id: 'root',
type: 'page',
} as NotionPage & { workspace_id: string }
it('should build online document info with workspace_id separated', () => {
const result = buildOnlineDocumentDatasourceInfo(page, 'cred-2')
expect(result.workspace_id).toBe('ws-1')
expect(result.credential_id).toBe('cred-2')
expect((result.page as unknown as Record<string, unknown>).page_id).toBe('page-1')
// workspace_id should not be in the page object
expect((result.page as unknown as Record<string, unknown>).workspace_id).toBeUndefined()
})
})
describe('buildWebsiteCrawlDatasourceInfo', () => {
const crawlResult: CrawlResultItem = {
source_url: 'https://example.com',
title: 'Example',
markdown: '# Hello',
description: 'desc',
} as unknown as CrawlResultItem
it('should spread crawl result and add credential_id', () => {
const result = buildWebsiteCrawlDatasourceInfo(crawlResult, 'cred-3')
expect(result.source_url).toBe('https://example.com')
expect(result.title).toBe('Example')
expect(result.credential_id).toBe('cred-3')
})
})
describe('buildOnlineDriveDatasourceInfo', () => {
const file: OnlineDriveFile = {
id: 'drive-1',
name: 'doc.xlsx',
type: OnlineDriveFileType.file,
}
it('should build online drive info with bucket', () => {
const result = buildOnlineDriveDatasourceInfo(file, 'my-bucket', 'cred-4')
expect(result).toEqual({
bucket: 'my-bucket',
id: 'drive-1',
name: 'doc.xlsx',
type: 'file',
credential_id: 'cred-4',
})
})
it('should handle empty bucket', () => {
const result = buildOnlineDriveDatasourceInfo(file, '', 'cred-4')
expect(result.bucket).toBe('')
})
})
})

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