mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 02:18:08 +08:00
test: add comprehensive unit and integration tests for RAG Pipeline components (#32237)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
210
web/__tests__/rag-pipeline/chunk-preview-formatting.test.ts
Normal file
210
web/__tests__/rag-pipeline/chunk-preview-formatting.test.ts
Normal file
@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Integration test: Chunk preview formatting pipeline
|
||||
*
|
||||
* Tests the formatPreviewChunks utility across all chunking modes
|
||||
* (text, parentChild, QA) with real data structures.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
RAG_PIPELINE_PREVIEW_CHUNK_NUM: 3,
|
||||
}))
|
||||
|
||||
vi.mock('@/models/datasets', () => ({
|
||||
ChunkingMode: {
|
||||
text: 'text',
|
||||
parentChild: 'parent-child',
|
||||
qa: 'qa',
|
||||
},
|
||||
}))
|
||||
|
||||
const { formatPreviewChunks } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/result/result-preview/utils',
|
||||
)
|
||||
|
||||
describe('Chunk Preview Formatting', () => {
|
||||
describe('general text chunks', () => {
|
||||
it('should format text chunks correctly', () => {
|
||||
const outputs = {
|
||||
chunk_structure: 'text',
|
||||
preview: [
|
||||
{ content: 'Chunk 1 content', summary: 'Summary 1' },
|
||||
{ content: 'Chunk 2 content' },
|
||||
],
|
||||
}
|
||||
|
||||
const result = formatPreviewChunks(outputs)
|
||||
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
const chunks = result as Array<{ content: string, summary?: string }>
|
||||
expect(chunks).toHaveLength(2)
|
||||
expect(chunks[0].content).toBe('Chunk 1 content')
|
||||
expect(chunks[0].summary).toBe('Summary 1')
|
||||
expect(chunks[1].content).toBe('Chunk 2 content')
|
||||
})
|
||||
|
||||
it('should limit chunks to RAG_PIPELINE_PREVIEW_CHUNK_NUM', () => {
|
||||
const outputs = {
|
||||
chunk_structure: 'text',
|
||||
preview: Array.from({ length: 10 }, (_, i) => ({
|
||||
content: `Chunk ${i + 1}`,
|
||||
})),
|
||||
}
|
||||
|
||||
const result = formatPreviewChunks(outputs)
|
||||
const chunks = result as Array<{ content: string }>
|
||||
|
||||
expect(chunks).toHaveLength(3) // Mocked limit
|
||||
})
|
||||
})
|
||||
|
||||
describe('parent-child chunks — paragraph mode', () => {
|
||||
it('should format paragraph parent-child chunks', () => {
|
||||
const outputs = {
|
||||
chunk_structure: 'parent-child',
|
||||
parent_mode: 'paragraph',
|
||||
preview: [
|
||||
{
|
||||
content: 'Parent paragraph',
|
||||
child_chunks: ['Child 1', 'Child 2'],
|
||||
summary: 'Parent summary',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = formatPreviewChunks(outputs) as {
|
||||
parent_child_chunks: Array<{
|
||||
parent_content: string
|
||||
parent_summary?: string
|
||||
child_contents: string[]
|
||||
parent_mode: string
|
||||
}>
|
||||
parent_mode: string
|
||||
}
|
||||
|
||||
expect(result.parent_mode).toBe('paragraph')
|
||||
expect(result.parent_child_chunks).toHaveLength(1)
|
||||
expect(result.parent_child_chunks[0].parent_content).toBe('Parent paragraph')
|
||||
expect(result.parent_child_chunks[0].parent_summary).toBe('Parent summary')
|
||||
expect(result.parent_child_chunks[0].child_contents).toEqual(['Child 1', 'Child 2'])
|
||||
})
|
||||
|
||||
it('should limit parent chunks in paragraph mode', () => {
|
||||
const outputs = {
|
||||
chunk_structure: 'parent-child',
|
||||
parent_mode: 'paragraph',
|
||||
preview: Array.from({ length: 10 }, (_, i) => ({
|
||||
content: `Parent ${i + 1}`,
|
||||
child_chunks: [`Child of ${i + 1}`],
|
||||
})),
|
||||
}
|
||||
|
||||
const result = formatPreviewChunks(outputs) as {
|
||||
parent_child_chunks: unknown[]
|
||||
}
|
||||
|
||||
expect(result.parent_child_chunks).toHaveLength(3) // Mocked limit
|
||||
})
|
||||
})
|
||||
|
||||
describe('parent-child chunks — full-doc mode', () => {
|
||||
it('should format full-doc parent-child chunks', () => {
|
||||
const outputs = {
|
||||
chunk_structure: 'parent-child',
|
||||
parent_mode: 'full-doc',
|
||||
preview: [
|
||||
{
|
||||
content: 'Full document content',
|
||||
child_chunks: ['Section 1', 'Section 2', 'Section 3'],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = formatPreviewChunks(outputs) as {
|
||||
parent_child_chunks: Array<{
|
||||
parent_content: string
|
||||
child_contents: string[]
|
||||
parent_mode: string
|
||||
}>
|
||||
}
|
||||
|
||||
expect(result.parent_child_chunks).toHaveLength(1)
|
||||
expect(result.parent_child_chunks[0].parent_content).toBe('Full document content')
|
||||
expect(result.parent_child_chunks[0].parent_mode).toBe('full-doc')
|
||||
})
|
||||
|
||||
it('should limit child chunks in full-doc mode', () => {
|
||||
const outputs = {
|
||||
chunk_structure: 'parent-child',
|
||||
parent_mode: 'full-doc',
|
||||
preview: [
|
||||
{
|
||||
content: 'Document',
|
||||
child_chunks: Array.from({ length: 20 }, (_, i) => `Section ${i + 1}`),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = formatPreviewChunks(outputs) as {
|
||||
parent_child_chunks: Array<{ child_contents: string[] }>
|
||||
}
|
||||
|
||||
expect(result.parent_child_chunks[0].child_contents).toHaveLength(3) // Mocked limit
|
||||
})
|
||||
})
|
||||
|
||||
describe('QA chunks', () => {
|
||||
it('should format QA chunks correctly', () => {
|
||||
const outputs = {
|
||||
chunk_structure: 'qa',
|
||||
qa_preview: [
|
||||
{ question: 'What is AI?', answer: 'Artificial Intelligence is...' },
|
||||
{ question: 'What is ML?', answer: 'Machine Learning is...' },
|
||||
],
|
||||
}
|
||||
|
||||
const result = formatPreviewChunks(outputs) as {
|
||||
qa_chunks: Array<{ question: string, answer: string }>
|
||||
}
|
||||
|
||||
expect(result.qa_chunks).toHaveLength(2)
|
||||
expect(result.qa_chunks[0].question).toBe('What is AI?')
|
||||
expect(result.qa_chunks[0].answer).toBe('Artificial Intelligence is...')
|
||||
})
|
||||
|
||||
it('should limit QA chunks', () => {
|
||||
const outputs = {
|
||||
chunk_structure: 'qa',
|
||||
qa_preview: Array.from({ length: 10 }, (_, i) => ({
|
||||
question: `Q${i + 1}`,
|
||||
answer: `A${i + 1}`,
|
||||
})),
|
||||
}
|
||||
|
||||
const result = formatPreviewChunks(outputs) as {
|
||||
qa_chunks: unknown[]
|
||||
}
|
||||
|
||||
expect(result.qa_chunks).toHaveLength(3) // Mocked limit
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return undefined for null outputs', () => {
|
||||
expect(formatPreviewChunks(null)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined for undefined outputs', () => {
|
||||
expect(formatPreviewChunks(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined for unknown chunk_structure', () => {
|
||||
const outputs = {
|
||||
chunk_structure: 'unknown-type',
|
||||
preview: [],
|
||||
}
|
||||
|
||||
expect(formatPreviewChunks(outputs)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
179
web/__tests__/rag-pipeline/dsl-export-import-flow.test.ts
Normal file
179
web/__tests__/rag-pipeline/dsl-export-import-flow.test.ts
Normal file
@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Integration test: DSL export/import flow
|
||||
*
|
||||
* Validates DSL export logic (sync draft → check secrets → download)
|
||||
* and DSL import modal state management.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockDoSyncWorkflowDraft = vi.fn().mockResolvedValue(undefined)
|
||||
const mockExportPipelineConfig = vi.fn().mockResolvedValue({ data: 'yaml-content' })
|
||||
const mockNotify = vi.fn()
|
||||
const mockEventEmitter = { emit: vi.fn() }
|
||||
const mockDownloadBlob = vi.fn()
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
useToastContext: () => ({ notify: mockNotify }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/constants', () => ({
|
||||
DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
pipelineId: 'pipeline-abc',
|
||||
knowledgeName: 'My Pipeline',
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: mockEventEmitter,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
useExportPipelineDSL: () => ({
|
||||
mutateAsync: mockExportPipelineConfig,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
fetchWorkflowDraft: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/rag-pipeline/hooks/use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('DSL Export/Import Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Export Flow', () => {
|
||||
it('should sync draft then export then download', async () => {
|
||||
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportDSL()
|
||||
})
|
||||
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
|
||||
expect(mockExportPipelineConfig).toHaveBeenCalledWith({
|
||||
pipelineId: 'pipeline-abc',
|
||||
include: false,
|
||||
})
|
||||
expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({
|
||||
fileName: 'My Pipeline.pipeline',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should export with include flag when specified', async () => {
|
||||
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportDSL(true)
|
||||
})
|
||||
|
||||
expect(mockExportPipelineConfig).toHaveBeenCalledWith({
|
||||
pipelineId: 'pipeline-abc',
|
||||
include: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should notify on export error', async () => {
|
||||
mockDoSyncWorkflowDraft.mockRejectedValueOnce(new Error('sync failed'))
|
||||
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportDSL()
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'error',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Export Check Flow', () => {
|
||||
it('should export directly when no secret environment variables', async () => {
|
||||
const { fetchWorkflowDraft } = await import('@/service/workflow')
|
||||
vi.mocked(fetchWorkflowDraft).mockResolvedValueOnce({
|
||||
environment_variables: [
|
||||
{ value_type: 'string', key: 'API_URL', value: 'https://api.example.com' },
|
||||
],
|
||||
} as unknown as Awaited<ReturnType<typeof fetchWorkflowDraft>>)
|
||||
|
||||
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportCheck()
|
||||
})
|
||||
|
||||
// Should proceed to export directly (no secret vars)
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should emit DSL_EXPORT_CHECK event when secret variables exist', async () => {
|
||||
const { fetchWorkflowDraft } = await import('@/service/workflow')
|
||||
vi.mocked(fetchWorkflowDraft).mockResolvedValueOnce({
|
||||
environment_variables: [
|
||||
{ value_type: 'secret', key: 'API_KEY', value: '***' },
|
||||
],
|
||||
} as unknown as Awaited<ReturnType<typeof fetchWorkflowDraft>>)
|
||||
|
||||
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportCheck()
|
||||
})
|
||||
|
||||
expect(mockEventEmitter.emit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'DSL_EXPORT_CHECK',
|
||||
payload: expect.objectContaining({
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({ value_type: 'secret' }),
|
||||
]),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should notify on export check error', async () => {
|
||||
const { fetchWorkflowDraft } = await import('@/service/workflow')
|
||||
vi.mocked(fetchWorkflowDraft).mockRejectedValueOnce(new Error('fetch failed'))
|
||||
|
||||
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportCheck()
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'error',
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
278
web/__tests__/rag-pipeline/input-field-crud-flow.test.ts
Normal file
278
web/__tests__/rag-pipeline/input-field-crud-flow.test.ts
Normal file
@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Integration test: Input field CRUD complete flow
|
||||
*
|
||||
* Validates the full lifecycle of input fields:
|
||||
* creation, editing, renaming, removal, and data conversion round-trip.
|
||||
*/
|
||||
import type { FormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/types'
|
||||
import type { InputVar } from '@/models/pipeline'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
VAR_ITEM_TEMPLATE_IN_PIPELINE: {
|
||||
type: 'text-input',
|
||||
label: '',
|
||||
variable: '',
|
||||
max_length: 48,
|
||||
default_value: undefined,
|
||||
required: true,
|
||||
tooltips: undefined,
|
||||
options: [],
|
||||
placeholder: undefined,
|
||||
unit: undefined,
|
||||
allowed_file_upload_methods: undefined,
|
||||
allowed_file_types: undefined,
|
||||
allowed_file_extensions: undefined,
|
||||
},
|
||||
}))
|
||||
|
||||
describe('Input Field CRUD Flow', () => {
|
||||
describe('Create → Edit → Convert Round-trip', () => {
|
||||
it('should create a text field and roundtrip through form data', async () => {
|
||||
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
|
||||
)
|
||||
|
||||
// Create new field from template (no data passed)
|
||||
const newFormData = convertToInputFieldFormData()
|
||||
expect(newFormData.type).toBe('text-input')
|
||||
expect(newFormData.variable).toBe('')
|
||||
expect(newFormData.label).toBe('')
|
||||
expect(newFormData.required).toBe(true)
|
||||
|
||||
// Simulate user editing form data
|
||||
const editedFormData: FormData = {
|
||||
...newFormData,
|
||||
variable: 'user_name',
|
||||
label: 'User Name',
|
||||
maxLength: 100,
|
||||
default: 'John',
|
||||
tooltips: 'Enter your name',
|
||||
placeholder: 'Type here...',
|
||||
allowedTypesAndExtensions: {},
|
||||
}
|
||||
|
||||
// Convert back to InputVar
|
||||
const inputVar = convertFormDataToINputField(editedFormData)
|
||||
|
||||
expect(inputVar.variable).toBe('user_name')
|
||||
expect(inputVar.label).toBe('User Name')
|
||||
expect(inputVar.max_length).toBe(100)
|
||||
expect(inputVar.default_value).toBe('John')
|
||||
expect(inputVar.tooltips).toBe('Enter your name')
|
||||
expect(inputVar.placeholder).toBe('Type here...')
|
||||
expect(inputVar.required).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle file field with upload settings', async () => {
|
||||
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
|
||||
)
|
||||
|
||||
const fileInputVar: InputVar = {
|
||||
type: PipelineInputVarType.singleFile,
|
||||
label: 'Upload Document',
|
||||
variable: 'doc_file',
|
||||
max_length: 1,
|
||||
default_value: undefined,
|
||||
required: true,
|
||||
tooltips: 'Upload a PDF',
|
||||
options: [],
|
||||
placeholder: undefined,
|
||||
unit: undefined,
|
||||
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
|
||||
allowed_file_types: [SupportUploadFileTypes.document],
|
||||
allowed_file_extensions: ['.pdf', '.docx'],
|
||||
}
|
||||
|
||||
// Convert to form data
|
||||
const formData = convertToInputFieldFormData(fileInputVar)
|
||||
expect(formData.allowedFileUploadMethods).toEqual([TransferMethod.local_file, TransferMethod.remote_url])
|
||||
expect(formData.allowedTypesAndExtensions).toEqual({
|
||||
allowedFileTypes: [SupportUploadFileTypes.document],
|
||||
allowedFileExtensions: ['.pdf', '.docx'],
|
||||
})
|
||||
|
||||
// Round-trip back
|
||||
const restored = convertFormDataToINputField(formData)
|
||||
expect(restored.allowed_file_upload_methods).toEqual([TransferMethod.local_file, TransferMethod.remote_url])
|
||||
expect(restored.allowed_file_types).toEqual([SupportUploadFileTypes.document])
|
||||
expect(restored.allowed_file_extensions).toEqual(['.pdf', '.docx'])
|
||||
})
|
||||
|
||||
it('should handle select field with options', async () => {
|
||||
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
|
||||
)
|
||||
|
||||
const selectVar: InputVar = {
|
||||
type: PipelineInputVarType.select,
|
||||
label: 'Priority',
|
||||
variable: 'priority',
|
||||
max_length: 0,
|
||||
default_value: 'medium',
|
||||
required: false,
|
||||
tooltips: 'Select priority level',
|
||||
options: ['low', 'medium', 'high'],
|
||||
placeholder: 'Choose...',
|
||||
unit: undefined,
|
||||
allowed_file_upload_methods: undefined,
|
||||
allowed_file_types: undefined,
|
||||
allowed_file_extensions: undefined,
|
||||
}
|
||||
|
||||
const formData = convertToInputFieldFormData(selectVar)
|
||||
expect(formData.options).toEqual(['low', 'medium', 'high'])
|
||||
expect(formData.default).toBe('medium')
|
||||
|
||||
const restored = convertFormDataToINputField(formData)
|
||||
expect(restored.options).toEqual(['low', 'medium', 'high'])
|
||||
expect(restored.default_value).toBe('medium')
|
||||
})
|
||||
|
||||
it('should handle number field with unit', async () => {
|
||||
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
|
||||
)
|
||||
|
||||
const numberVar: InputVar = {
|
||||
type: PipelineInputVarType.number,
|
||||
label: 'Max Tokens',
|
||||
variable: 'max_tokens',
|
||||
max_length: 0,
|
||||
default_value: '1024',
|
||||
required: true,
|
||||
tooltips: undefined,
|
||||
options: [],
|
||||
placeholder: undefined,
|
||||
unit: 'tokens',
|
||||
allowed_file_upload_methods: undefined,
|
||||
allowed_file_types: undefined,
|
||||
allowed_file_extensions: undefined,
|
||||
}
|
||||
|
||||
const formData = convertToInputFieldFormData(numberVar)
|
||||
expect(formData.unit).toBe('tokens')
|
||||
expect(formData.default).toBe('1024')
|
||||
|
||||
const restored = convertFormDataToINputField(formData)
|
||||
expect(restored.unit).toBe('tokens')
|
||||
expect(restored.default_value).toBe('1024')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Omit optional fields', () => {
|
||||
it('should not include tooltips when undefined', async () => {
|
||||
const { convertToInputFieldFormData } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
|
||||
)
|
||||
|
||||
const inputVar: InputVar = {
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'Test',
|
||||
variable: 'test',
|
||||
max_length: 48,
|
||||
default_value: undefined,
|
||||
required: true,
|
||||
tooltips: undefined,
|
||||
options: [],
|
||||
placeholder: undefined,
|
||||
unit: undefined,
|
||||
allowed_file_upload_methods: undefined,
|
||||
allowed_file_types: undefined,
|
||||
allowed_file_extensions: undefined,
|
||||
}
|
||||
|
||||
const formData = convertToInputFieldFormData(inputVar)
|
||||
|
||||
// Optional fields should not be present
|
||||
expect('tooltips' in formData).toBe(false)
|
||||
expect('placeholder' in formData).toBe(false)
|
||||
expect('unit' in formData).toBe(false)
|
||||
expect('default' in formData).toBe(false)
|
||||
})
|
||||
|
||||
it('should include optional fields when explicitly set to empty string', async () => {
|
||||
const { convertToInputFieldFormData } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
|
||||
)
|
||||
|
||||
const inputVar: InputVar = {
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'Test',
|
||||
variable: 'test',
|
||||
max_length: 48,
|
||||
default_value: '',
|
||||
required: true,
|
||||
tooltips: '',
|
||||
options: [],
|
||||
placeholder: '',
|
||||
unit: '',
|
||||
allowed_file_upload_methods: undefined,
|
||||
allowed_file_types: undefined,
|
||||
allowed_file_extensions: undefined,
|
||||
}
|
||||
|
||||
const formData = convertToInputFieldFormData(inputVar)
|
||||
|
||||
expect(formData.default).toBe('')
|
||||
expect(formData.tooltips).toBe('')
|
||||
expect(formData.placeholder).toBe('')
|
||||
expect(formData.unit).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multiple fields workflow', () => {
|
||||
it('should process multiple fields independently', async () => {
|
||||
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
|
||||
)
|
||||
|
||||
const fields: InputVar[] = [
|
||||
{
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'Name',
|
||||
variable: 'name',
|
||||
max_length: 48,
|
||||
default_value: 'Alice',
|
||||
required: true,
|
||||
tooltips: undefined,
|
||||
options: [],
|
||||
placeholder: undefined,
|
||||
unit: undefined,
|
||||
allowed_file_upload_methods: undefined,
|
||||
allowed_file_types: undefined,
|
||||
allowed_file_extensions: undefined,
|
||||
},
|
||||
{
|
||||
type: PipelineInputVarType.number,
|
||||
label: 'Count',
|
||||
variable: 'count',
|
||||
max_length: 0,
|
||||
default_value: '10',
|
||||
required: false,
|
||||
tooltips: undefined,
|
||||
options: [],
|
||||
placeholder: undefined,
|
||||
unit: 'items',
|
||||
allowed_file_upload_methods: undefined,
|
||||
allowed_file_types: undefined,
|
||||
allowed_file_extensions: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
const formDataList = fields.map(f => convertToInputFieldFormData(f))
|
||||
const restoredFields = formDataList.map(fd => convertFormDataToINputField(fd))
|
||||
|
||||
expect(restoredFields).toHaveLength(2)
|
||||
expect(restoredFields[0].variable).toBe('name')
|
||||
expect(restoredFields[0].default_value).toBe('Alice')
|
||||
expect(restoredFields[1].variable).toBe('count')
|
||||
expect(restoredFields[1].default_value).toBe('10')
|
||||
expect(restoredFields[1].unit).toBe('items')
|
||||
})
|
||||
})
|
||||
})
|
||||
199
web/__tests__/rag-pipeline/input-field-editor-flow.test.ts
Normal file
199
web/__tests__/rag-pipeline/input-field-editor-flow.test.ts
Normal file
@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Integration test: Input field editor data conversion flow
|
||||
*
|
||||
* Tests the full pipeline: InputVar -> FormData -> InputVar roundtrip
|
||||
* and schema validation for various input types.
|
||||
*/
|
||||
import type { InputVar } from '@/models/pipeline'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
|
||||
// Mock the config module for VAR_ITEM_TEMPLATE_IN_PIPELINE
|
||||
vi.mock('@/config', () => ({
|
||||
VAR_ITEM_TEMPLATE_IN_PIPELINE: {
|
||||
type: 'text-input',
|
||||
label: '',
|
||||
variable: '',
|
||||
max_length: 48,
|
||||
required: false,
|
||||
options: [],
|
||||
allowed_file_upload_methods: [],
|
||||
allowed_file_types: [],
|
||||
allowed_file_extensions: [],
|
||||
},
|
||||
MAX_VAR_KEY_LENGTH: 30,
|
||||
RAG_PIPELINE_PREVIEW_CHUNK_NUM: 10,
|
||||
}))
|
||||
|
||||
// Import real functions (not mocked)
|
||||
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
|
||||
)
|
||||
|
||||
describe('Input Field Editor Data Flow', () => {
|
||||
describe('convertToInputFieldFormData', () => {
|
||||
it('should convert a text input InputVar to FormData', () => {
|
||||
const inputVar: InputVar = {
|
||||
type: 'text-input',
|
||||
label: 'Name',
|
||||
variable: 'user_name',
|
||||
max_length: 100,
|
||||
required: true,
|
||||
default_value: 'John',
|
||||
tooltips: 'Enter your name',
|
||||
placeholder: 'Type here...',
|
||||
options: [],
|
||||
} as InputVar
|
||||
|
||||
const formData = convertToInputFieldFormData(inputVar)
|
||||
|
||||
expect(formData.type).toBe('text-input')
|
||||
expect(formData.label).toBe('Name')
|
||||
expect(formData.variable).toBe('user_name')
|
||||
expect(formData.maxLength).toBe(100)
|
||||
expect(formData.required).toBe(true)
|
||||
expect(formData.default).toBe('John')
|
||||
expect(formData.tooltips).toBe('Enter your name')
|
||||
expect(formData.placeholder).toBe('Type here...')
|
||||
})
|
||||
|
||||
it('should handle file input with upload settings', () => {
|
||||
const inputVar: InputVar = {
|
||||
type: 'file',
|
||||
label: 'Document',
|
||||
variable: 'doc',
|
||||
required: false,
|
||||
allowed_file_upload_methods: ['local_file', 'remote_url'],
|
||||
allowed_file_types: ['document', 'image'],
|
||||
allowed_file_extensions: ['.pdf', '.jpg'],
|
||||
options: [],
|
||||
} as InputVar
|
||||
|
||||
const formData = convertToInputFieldFormData(inputVar)
|
||||
|
||||
expect(formData.allowedFileUploadMethods).toEqual(['local_file', 'remote_url'])
|
||||
expect(formData.allowedTypesAndExtensions).toEqual({
|
||||
allowedFileTypes: ['document', 'image'],
|
||||
allowedFileExtensions: ['.pdf', '.jpg'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should use template defaults when no data provided', () => {
|
||||
const formData = convertToInputFieldFormData(undefined)
|
||||
|
||||
expect(formData.type).toBe('text-input')
|
||||
expect(formData.maxLength).toBe(48)
|
||||
expect(formData.required).toBe(false)
|
||||
})
|
||||
|
||||
it('should omit undefined/null optional fields', () => {
|
||||
const inputVar: InputVar = {
|
||||
type: 'text-input',
|
||||
label: 'Simple',
|
||||
variable: 'simple_var',
|
||||
max_length: 50,
|
||||
required: false,
|
||||
options: [],
|
||||
} as InputVar
|
||||
|
||||
const formData = convertToInputFieldFormData(inputVar)
|
||||
|
||||
expect(formData.default).toBeUndefined()
|
||||
expect(formData.tooltips).toBeUndefined()
|
||||
expect(formData.placeholder).toBeUndefined()
|
||||
expect(formData.unit).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertFormDataToINputField', () => {
|
||||
it('should convert FormData back to InputVar', () => {
|
||||
const formData = {
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'Name',
|
||||
variable: 'user_name',
|
||||
maxLength: 100,
|
||||
required: true,
|
||||
default: 'John',
|
||||
tooltips: 'Enter your name',
|
||||
options: [],
|
||||
placeholder: 'Type here...',
|
||||
allowedTypesAndExtensions: {
|
||||
allowedFileTypes: undefined,
|
||||
allowedFileExtensions: undefined,
|
||||
},
|
||||
}
|
||||
|
||||
const inputVar = convertFormDataToINputField(formData)
|
||||
|
||||
expect(inputVar.type).toBe('text-input')
|
||||
expect(inputVar.label).toBe('Name')
|
||||
expect(inputVar.variable).toBe('user_name')
|
||||
expect(inputVar.max_length).toBe(100)
|
||||
expect(inputVar.required).toBe(true)
|
||||
expect(inputVar.default_value).toBe('John')
|
||||
expect(inputVar.tooltips).toBe('Enter your name')
|
||||
})
|
||||
})
|
||||
|
||||
describe('roundtrip conversion', () => {
|
||||
it('should preserve text input data through roundtrip', () => {
|
||||
const original: InputVar = {
|
||||
type: 'text-input',
|
||||
label: 'Question',
|
||||
variable: 'question',
|
||||
max_length: 200,
|
||||
required: true,
|
||||
default_value: 'What is AI?',
|
||||
tooltips: 'Enter your question',
|
||||
placeholder: 'Ask something...',
|
||||
options: [],
|
||||
} as InputVar
|
||||
|
||||
const formData = convertToInputFieldFormData(original)
|
||||
const restored = convertFormDataToINputField(formData)
|
||||
|
||||
expect(restored.type).toBe(original.type)
|
||||
expect(restored.label).toBe(original.label)
|
||||
expect(restored.variable).toBe(original.variable)
|
||||
expect(restored.max_length).toBe(original.max_length)
|
||||
expect(restored.required).toBe(original.required)
|
||||
expect(restored.default_value).toBe(original.default_value)
|
||||
expect(restored.tooltips).toBe(original.tooltips)
|
||||
expect(restored.placeholder).toBe(original.placeholder)
|
||||
})
|
||||
|
||||
it('should preserve number input data through roundtrip', () => {
|
||||
const original = {
|
||||
type: 'number',
|
||||
label: 'Temperature',
|
||||
variable: 'temp',
|
||||
required: false,
|
||||
default_value: '0.7',
|
||||
unit: '°C',
|
||||
options: [],
|
||||
} as InputVar
|
||||
|
||||
const formData = convertToInputFieldFormData(original)
|
||||
const restored = convertFormDataToINputField(formData)
|
||||
|
||||
expect(restored.type).toBe('number')
|
||||
expect(restored.unit).toBe('°C')
|
||||
expect(restored.default_value).toBe('0.7')
|
||||
})
|
||||
|
||||
it('should preserve select options through roundtrip', () => {
|
||||
const original: InputVar = {
|
||||
type: 'select',
|
||||
label: 'Mode',
|
||||
variable: 'mode',
|
||||
required: true,
|
||||
options: ['fast', 'balanced', 'quality'],
|
||||
} as InputVar
|
||||
|
||||
const formData = convertToInputFieldFormData(original)
|
||||
const restored = convertFormDataToINputField(formData)
|
||||
|
||||
expect(restored.options).toEqual(['fast', 'balanced', 'quality'])
|
||||
})
|
||||
})
|
||||
})
|
||||
277
web/__tests__/rag-pipeline/test-run-flow.test.ts
Normal file
277
web/__tests__/rag-pipeline/test-run-flow.test.ts
Normal file
@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Integration test: Test run end-to-end flow
|
||||
*
|
||||
* Validates the data flow through test-run preparation hooks:
|
||||
* step navigation, datasource filtering, and data clearing.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mutable holder so mock data can reference BlockEnum after imports
|
||||
const mockNodesHolder = vi.hoisted(() => ({ value: [] as Record<string, unknown>[] }))
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useNodes: () => mockNodesHolder.value,
|
||||
}))
|
||||
|
||||
mockNodesHolder.value = [
|
||||
{
|
||||
id: 'ds-1',
|
||||
data: {
|
||||
type: BlockEnum.DataSource,
|
||||
title: 'Local Files',
|
||||
datasource_type: 'upload_file',
|
||||
datasource_configurations: { datasource_label: 'Upload', upload_file_config: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ds-2',
|
||||
data: {
|
||||
type: BlockEnum.DataSource,
|
||||
title: 'Web Crawl',
|
||||
datasource_type: 'website_crawl',
|
||||
datasource_configurations: { datasource_label: 'Crawl' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'kb-1',
|
||||
data: {
|
||||
type: BlockEnum.KnowledgeBase,
|
||||
title: 'Knowledge Base',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// Mock the Zustand store used by the hooks
|
||||
const mockSetDocumentsData = vi.fn()
|
||||
const mockSetSearchValue = vi.fn()
|
||||
const mockSetSelectedPagesId = vi.fn()
|
||||
const mockSetOnlineDocuments = vi.fn()
|
||||
const mockSetCurrentDocument = vi.fn()
|
||||
const mockSetStep = vi.fn()
|
||||
const mockSetCrawlResult = vi.fn()
|
||||
const mockSetWebsitePages = vi.fn()
|
||||
const mockSetPreviewIndex = vi.fn()
|
||||
const mockSetCurrentWebsite = vi.fn()
|
||||
const mockSetOnlineDriveFileList = vi.fn()
|
||||
const mockSetBucket = vi.fn()
|
||||
const mockSetPrefix = vi.fn()
|
||||
const mockSetKeywords = vi.fn()
|
||||
const mockSetSelectedFileIds = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/store', () => ({
|
||||
useDataSourceStore: () => ({
|
||||
getState: () => ({
|
||||
setDocumentsData: mockSetDocumentsData,
|
||||
setSearchValue: mockSetSearchValue,
|
||||
setSelectedPagesId: mockSetSelectedPagesId,
|
||||
setOnlineDocuments: mockSetOnlineDocuments,
|
||||
setCurrentDocument: mockSetCurrentDocument,
|
||||
setStep: mockSetStep,
|
||||
setCrawlResult: mockSetCrawlResult,
|
||||
setWebsitePages: mockSetWebsitePages,
|
||||
setPreviewIndex: mockSetPreviewIndex,
|
||||
setCurrentWebsite: mockSetCurrentWebsite,
|
||||
setOnlineDriveFileList: mockSetOnlineDriveFileList,
|
||||
setBucket: mockSetBucket,
|
||||
setPrefix: mockSetPrefix,
|
||||
setKeywords: mockSetKeywords,
|
||||
setSelectedFileIds: mockSetSelectedFileIds,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/models/datasets', () => ({
|
||||
CrawlStep: {
|
||||
init: 'init',
|
||||
},
|
||||
}))
|
||||
|
||||
describe('Test Run Flow Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Step Navigation', () => {
|
||||
it('should start at step 1 and navigate forward', async () => {
|
||||
const { useTestRunSteps } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result } = renderHook(() => useTestRunSteps())
|
||||
|
||||
expect(result.current.currentStep).toBe(1)
|
||||
|
||||
act(() => {
|
||||
result.current.handleNextStep()
|
||||
})
|
||||
|
||||
expect(result.current.currentStep).toBe(2)
|
||||
})
|
||||
|
||||
it('should navigate back from step 2 to step 1', async () => {
|
||||
const { useTestRunSteps } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result } = renderHook(() => useTestRunSteps())
|
||||
|
||||
act(() => {
|
||||
result.current.handleNextStep()
|
||||
})
|
||||
expect(result.current.currentStep).toBe(2)
|
||||
|
||||
act(() => {
|
||||
result.current.handleBackStep()
|
||||
})
|
||||
expect(result.current.currentStep).toBe(1)
|
||||
})
|
||||
|
||||
it('should provide labeled steps', async () => {
|
||||
const { useTestRunSteps } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result } = renderHook(() => useTestRunSteps())
|
||||
|
||||
expect(result.current.steps).toHaveLength(2)
|
||||
expect(result.current.steps[0].value).toBe('dataSource')
|
||||
expect(result.current.steps[1].value).toBe('documentProcessing')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Datasource Options', () => {
|
||||
it('should filter nodes to only DataSource type', async () => {
|
||||
const { useDatasourceOptions } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result } = renderHook(() => useDatasourceOptions())
|
||||
|
||||
// Should only include DataSource nodes, not KnowledgeBase
|
||||
expect(result.current).toHaveLength(2)
|
||||
expect(result.current[0].value).toBe('ds-1')
|
||||
expect(result.current[1].value).toBe('ds-2')
|
||||
})
|
||||
|
||||
it('should include node data in options', async () => {
|
||||
const { useDatasourceOptions } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result } = renderHook(() => useDatasourceOptions())
|
||||
|
||||
expect(result.current[0].label).toBe('Local Files')
|
||||
expect(result.current[0].data.type).toBe(BlockEnum.DataSource)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Data Clearing Flow', () => {
|
||||
it('should clear online document data', async () => {
|
||||
const { useOnlineDocument } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result } = renderHook(() => useOnlineDocument())
|
||||
|
||||
act(() => {
|
||||
result.current.clearOnlineDocumentData()
|
||||
})
|
||||
|
||||
expect(mockSetDocumentsData).toHaveBeenCalledWith([])
|
||||
expect(mockSetSearchValue).toHaveBeenCalledWith('')
|
||||
expect(mockSetSelectedPagesId).toHaveBeenCalledWith(expect.any(Set))
|
||||
expect(mockSetOnlineDocuments).toHaveBeenCalledWith([])
|
||||
expect(mockSetCurrentDocument).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('should clear website crawl data', async () => {
|
||||
const { useWebsiteCrawl } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result } = renderHook(() => useWebsiteCrawl())
|
||||
|
||||
act(() => {
|
||||
result.current.clearWebsiteCrawlData()
|
||||
})
|
||||
|
||||
expect(mockSetStep).toHaveBeenCalledWith('init')
|
||||
expect(mockSetCrawlResult).toHaveBeenCalledWith(undefined)
|
||||
expect(mockSetCurrentWebsite).toHaveBeenCalledWith(undefined)
|
||||
expect(mockSetWebsitePages).toHaveBeenCalledWith([])
|
||||
expect(mockSetPreviewIndex).toHaveBeenCalledWith(-1)
|
||||
})
|
||||
|
||||
it('should clear online drive data', async () => {
|
||||
const { useOnlineDrive } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result } = renderHook(() => useOnlineDrive())
|
||||
|
||||
act(() => {
|
||||
result.current.clearOnlineDriveData()
|
||||
})
|
||||
|
||||
expect(mockSetOnlineDriveFileList).toHaveBeenCalledWith([])
|
||||
expect(mockSetBucket).toHaveBeenCalledWith('')
|
||||
expect(mockSetPrefix).toHaveBeenCalledWith([])
|
||||
expect(mockSetKeywords).toHaveBeenCalledWith('')
|
||||
expect(mockSetSelectedFileIds).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Full Flow Simulation', () => {
|
||||
it('should support complete step navigation cycle', async () => {
|
||||
const { useTestRunSteps } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result } = renderHook(() => useTestRunSteps())
|
||||
|
||||
// Start at step 1
|
||||
expect(result.current.currentStep).toBe(1)
|
||||
|
||||
// Move to step 2
|
||||
act(() => {
|
||||
result.current.handleNextStep()
|
||||
})
|
||||
expect(result.current.currentStep).toBe(2)
|
||||
|
||||
// Go back to step 1
|
||||
act(() => {
|
||||
result.current.handleBackStep()
|
||||
})
|
||||
expect(result.current.currentStep).toBe(1)
|
||||
|
||||
// Move forward again
|
||||
act(() => {
|
||||
result.current.handleNextStep()
|
||||
})
|
||||
expect(result.current.currentStep).toBe(2)
|
||||
})
|
||||
|
||||
it('should not regress when clearing all data sources in sequence', async () => {
|
||||
const {
|
||||
useOnlineDocument,
|
||||
useWebsiteCrawl,
|
||||
useOnlineDrive,
|
||||
} = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result: docResult } = renderHook(() => useOnlineDocument())
|
||||
const { result: crawlResult } = renderHook(() => useWebsiteCrawl())
|
||||
const { result: driveResult } = renderHook(() => useOnlineDrive())
|
||||
|
||||
// Clear all data sources
|
||||
act(() => {
|
||||
docResult.current.clearOnlineDocumentData()
|
||||
crawlResult.current.clearWebsiteCrawlData()
|
||||
driveResult.current.clearOnlineDriveData()
|
||||
})
|
||||
|
||||
expect(mockSetDocumentsData).toHaveBeenCalledWith([])
|
||||
expect(mockSetStep).toHaveBeenCalledWith('init')
|
||||
expect(mockSetOnlineDriveFileList).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user