mirror of
https://github.com/langgenius/dify.git
synced 2026-03-29 09:59:59 +08:00
test: add integration tests for chunk preview formatting and input field editor flow
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,383 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import { useConfigurations, useHiddenConfigurations, useHiddenFieldNames } from './hooks'
|
||||
|
||||
// ============================================================================
|
||||
// Mocks
|
||||
// ============================================================================
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/file-uploader/hooks', () => ({
|
||||
useFileSizeLimit: () => ({
|
||||
imgSizeLimit: 10 * 1024 * 1024,
|
||||
docSizeLimit: 15 * 1024 * 1024,
|
||||
audioSizeLimit: 50 * 1024 * 1024,
|
||||
videoSizeLimit: 100 * 1024 * 1024,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useFileUploadConfig: () => ({ data: {} }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/constants', () => ({
|
||||
DEFAULT_FILE_UPLOAD_SETTING: {
|
||||
allowed_file_upload_methods: ['local_file', 'remote_url'],
|
||||
allowed_file_types: ['image', 'document'],
|
||||
allowed_file_extensions: ['.jpg', '.png', '.pdf'],
|
||||
max_length: 5,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./schema', () => ({
|
||||
TEXT_MAX_LENGTH: 256,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/format', () => ({
|
||||
formatFileSize: (size: number) => `${Math.round(size / 1024 / 1024)}MB`,
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('useHiddenFieldNames', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return field names for textInput type', () => {
|
||||
const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.textInput))
|
||||
|
||||
expect(result.current).toContain('variableconfig.defaultvalue')
|
||||
expect(result.current).toContain('variableconfig.placeholder')
|
||||
expect(result.current).toContain('variableconfig.tooltips')
|
||||
})
|
||||
|
||||
it('should return field names for paragraph type', () => {
|
||||
const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.paragraph))
|
||||
|
||||
expect(result.current).toContain('variableconfig.defaultvalue')
|
||||
expect(result.current).toContain('variableconfig.placeholder')
|
||||
expect(result.current).toContain('variableconfig.tooltips')
|
||||
})
|
||||
|
||||
it('should return field names for number type including unit', () => {
|
||||
const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.number))
|
||||
|
||||
expect(result.current).toContain('variableconfig.defaultvalue')
|
||||
expect(result.current).toContain('variableconfig.unit')
|
||||
expect(result.current).toContain('variableconfig.placeholder')
|
||||
expect(result.current).toContain('variableconfig.tooltips')
|
||||
})
|
||||
|
||||
it('should return field names for select type', () => {
|
||||
const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.select))
|
||||
|
||||
expect(result.current).toContain('variableconfig.defaultvalue')
|
||||
expect(result.current).toContain('variableconfig.tooltips')
|
||||
})
|
||||
|
||||
it('should return field names for singleFile type', () => {
|
||||
const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.singleFile))
|
||||
|
||||
expect(result.current).toContain('variableconfig.uploadmethod')
|
||||
expect(result.current).toContain('variableconfig.tooltips')
|
||||
})
|
||||
|
||||
it('should return field names for multiFiles type including max number', () => {
|
||||
const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.multiFiles))
|
||||
|
||||
expect(result.current).toContain('variableconfig.uploadmethod')
|
||||
expect(result.current).toContain('variableconfig.maxnumberofuploads')
|
||||
expect(result.current).toContain('variableconfig.tooltips')
|
||||
})
|
||||
|
||||
it('should return field names for checkbox type', () => {
|
||||
const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.checkbox))
|
||||
|
||||
expect(result.current).toContain('variableconfig.startchecked')
|
||||
expect(result.current).toContain('variableconfig.tooltips')
|
||||
})
|
||||
|
||||
it('should return only tooltips for unknown type', () => {
|
||||
const { result } = renderHook(() => useHiddenFieldNames('unknown-type' as PipelineInputVarType))
|
||||
|
||||
expect(result.current).toBe('variableconfig.tooltips')
|
||||
})
|
||||
|
||||
it('should return comma-separated lowercase string', () => {
|
||||
const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.textInput))
|
||||
|
||||
// The result should be comma-separated
|
||||
expect(result.current).toMatch(/,/)
|
||||
// All lowercase
|
||||
expect(result.current).toBe(result.current.toLowerCase())
|
||||
})
|
||||
})
|
||||
|
||||
describe('useConfigurations', () => {
|
||||
let mockGetFieldValue: ReturnType<typeof vi.fn<(...args: unknown[]) => unknown>>
|
||||
let mockSetFieldValue: ReturnType<typeof vi.fn<(...args: unknown[]) => void>>
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetFieldValue = vi.fn()
|
||||
mockSetFieldValue = vi.fn()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return array of configurations', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConfigurations({
|
||||
getFieldValue: mockGetFieldValue,
|
||||
setFieldValue: mockSetFieldValue,
|
||||
supportFile: true,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(Array.isArray(result.current)).toBe(true)
|
||||
expect(result.current.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should include field type select configuration', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConfigurations({
|
||||
getFieldValue: mockGetFieldValue,
|
||||
setFieldValue: mockSetFieldValue,
|
||||
supportFile: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const typeConfig = result.current.find(c => c.variable === 'type')
|
||||
expect(typeConfig).toBeDefined()
|
||||
expect(typeConfig?.required).toBe(true)
|
||||
})
|
||||
|
||||
it('should include variable name configuration', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConfigurations({
|
||||
getFieldValue: mockGetFieldValue,
|
||||
setFieldValue: mockSetFieldValue,
|
||||
supportFile: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const varConfig = result.current.find(c => c.variable === 'variable')
|
||||
expect(varConfig).toBeDefined()
|
||||
expect(varConfig?.required).toBe(true)
|
||||
})
|
||||
|
||||
it('should include display name configuration', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConfigurations({
|
||||
getFieldValue: mockGetFieldValue,
|
||||
setFieldValue: mockSetFieldValue,
|
||||
supportFile: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const labelConfig = result.current.find(c => c.variable === 'label')
|
||||
expect(labelConfig).toBeDefined()
|
||||
expect(labelConfig?.required).toBe(false)
|
||||
})
|
||||
|
||||
it('should include required checkbox configuration', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConfigurations({
|
||||
getFieldValue: mockGetFieldValue,
|
||||
setFieldValue: mockSetFieldValue,
|
||||
supportFile: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const requiredConfig = result.current.find(c => c.variable === 'required')
|
||||
expect(requiredConfig).toBeDefined()
|
||||
})
|
||||
|
||||
it('should set file defaults when type changes to singleFile', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConfigurations({
|
||||
getFieldValue: mockGetFieldValue,
|
||||
setFieldValue: mockSetFieldValue,
|
||||
supportFile: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const typeConfig = result.current.find(c => c.variable === 'type')
|
||||
typeConfig?.listeners?.onChange?.({ value: PipelineInputVarType.singleFile, fieldApi: {} as never })
|
||||
|
||||
expect(mockSetFieldValue).toHaveBeenCalledWith('allowedFileUploadMethods', ['local_file', 'remote_url'])
|
||||
expect(mockSetFieldValue).toHaveBeenCalledWith('allowedTypesAndExtensions', {
|
||||
allowedFileTypes: ['image', 'document'],
|
||||
allowedFileExtensions: ['.jpg', '.png', '.pdf'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should set maxLength when type changes to multiFiles', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConfigurations({
|
||||
getFieldValue: mockGetFieldValue,
|
||||
setFieldValue: mockSetFieldValue,
|
||||
supportFile: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const typeConfig = result.current.find(c => c.variable === 'type')
|
||||
typeConfig?.listeners?.onChange?.({ value: PipelineInputVarType.multiFiles, fieldApi: {} as never })
|
||||
|
||||
expect(mockSetFieldValue).toHaveBeenCalledWith('maxLength', 5)
|
||||
})
|
||||
|
||||
it('should not set file defaults when type changes to text', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConfigurations({
|
||||
getFieldValue: mockGetFieldValue,
|
||||
setFieldValue: mockSetFieldValue,
|
||||
supportFile: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const typeConfig = result.current.find(c => c.variable === 'type')
|
||||
typeConfig?.listeners?.onChange?.({ value: PipelineInputVarType.textInput, fieldApi: {} as never })
|
||||
|
||||
expect(mockSetFieldValue).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should auto-fill label from variable name on blur', () => {
|
||||
mockGetFieldValue.mockReturnValue('')
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useConfigurations({
|
||||
getFieldValue: mockGetFieldValue,
|
||||
setFieldValue: mockSetFieldValue,
|
||||
supportFile: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const varConfig = result.current.find(c => c.variable === 'variable')
|
||||
varConfig?.listeners?.onBlur?.({ value: 'myVariable', fieldApi: {} as never })
|
||||
|
||||
expect(mockSetFieldValue).toHaveBeenCalledWith('label', 'myVariable')
|
||||
})
|
||||
|
||||
it('should not auto-fill label if label already exists', () => {
|
||||
mockGetFieldValue.mockReturnValue('Existing Label')
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useConfigurations({
|
||||
getFieldValue: mockGetFieldValue,
|
||||
setFieldValue: mockSetFieldValue,
|
||||
supportFile: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const varConfig = result.current.find(c => c.variable === 'variable')
|
||||
varConfig?.listeners?.onBlur?.({ value: 'myVariable', fieldApi: {} as never })
|
||||
|
||||
expect(mockSetFieldValue).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reset label to variable name when display name is cleared', () => {
|
||||
mockGetFieldValue.mockReturnValue('existingVar')
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useConfigurations({
|
||||
getFieldValue: mockGetFieldValue,
|
||||
setFieldValue: mockSetFieldValue,
|
||||
supportFile: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const labelConfig = result.current.find(c => c.variable === 'label')
|
||||
labelConfig?.listeners?.onBlur?.({ value: '', fieldApi: {} as never })
|
||||
|
||||
expect(mockSetFieldValue).toHaveBeenCalledWith('label', 'existingVar')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useHiddenConfigurations', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return array of hidden configurations', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useHiddenConfigurations({ options: undefined }),
|
||||
)
|
||||
|
||||
expect(Array.isArray(result.current)).toBe(true)
|
||||
expect(result.current.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should include default value config for textInput', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useHiddenConfigurations({ options: undefined }),
|
||||
)
|
||||
|
||||
const defaultConfigs = result.current.filter(c => c.variable === 'default')
|
||||
expect(defaultConfigs.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should include tooltips configuration for all types', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useHiddenConfigurations({ options: undefined }),
|
||||
)
|
||||
|
||||
const tooltipsConfig = result.current.find(c => c.variable === 'tooltips')
|
||||
expect(tooltipsConfig).toBeDefined()
|
||||
expect(tooltipsConfig?.showConditions).toEqual([])
|
||||
})
|
||||
|
||||
it('should build select options from provided options', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useHiddenConfigurations({ options: ['opt1', 'opt2'] }),
|
||||
)
|
||||
|
||||
const selectDefault = result.current.find(
|
||||
c => c.variable === 'default' && c.showConditions?.some(sc => sc.value === PipelineInputVarType.select),
|
||||
)
|
||||
expect(selectDefault?.options).toBeDefined()
|
||||
// First option should be "no default selected"
|
||||
expect(selectDefault?.options?.[0]?.value).toBe('')
|
||||
expect(selectDefault?.options?.[1]?.value).toBe('opt1')
|
||||
expect(selectDefault?.options?.[2]?.value).toBe('opt2')
|
||||
})
|
||||
|
||||
it('should return empty options when options prop is undefined', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useHiddenConfigurations({ options: undefined }),
|
||||
)
|
||||
|
||||
const selectDefault = result.current.find(
|
||||
c => c.variable === 'default' && c.showConditions?.some(sc => sc.value === PipelineInputVarType.select),
|
||||
)
|
||||
expect(selectDefault?.options).toEqual([])
|
||||
})
|
||||
|
||||
it('should include upload method configs for file types', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useHiddenConfigurations({ options: undefined }),
|
||||
)
|
||||
|
||||
const uploadMethods = result.current.filter(c => c.variable === 'allowedFileUploadMethods')
|
||||
expect(uploadMethods.length).toBe(2) // singleFile + multiFiles
|
||||
})
|
||||
|
||||
it('should include maxLength slider for multiFiles', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useHiddenConfigurations({ options: undefined }),
|
||||
)
|
||||
|
||||
const maxLength = result.current.find(
|
||||
c => c.variable === 'maxLength' && c.showConditions?.some(sc => sc.value === PipelineInputVarType.multiFiles),
|
||||
)
|
||||
expect(maxLength).toBeDefined()
|
||||
expect(maxLength?.description).toBeDefined()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,269 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import { createInputFieldSchema, TEXT_MAX_LENGTH } from './schema'
|
||||
|
||||
// ============================================================================
|
||||
// Mocks
|
||||
// ============================================================================
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
MAX_VAR_KEY_LENGTH: 30,
|
||||
}))
|
||||
|
||||
// Minimal t function for testing
|
||||
const t: TFunction = ((key: string) => key) as unknown as TFunction
|
||||
|
||||
const defaultOptions = { maxFileUploadLimit: 10 }
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('TEXT_MAX_LENGTH', () => {
|
||||
it('should be 256', () => {
|
||||
expect(TEXT_MAX_LENGTH).toBe(256)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createInputFieldSchema', () => {
|
||||
describe('common schema validation', () => {
|
||||
it('should reject empty variable name', () => {
|
||||
const schema = createInputFieldSchema(PipelineInputVarType.textInput, t, defaultOptions)
|
||||
const result = schema.safeParse({
|
||||
type: 'text-input',
|
||||
variable: '',
|
||||
label: 'Test',
|
||||
required: false,
|
||||
maxLength: 48,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject variable starting with number', () => {
|
||||
const schema = createInputFieldSchema(PipelineInputVarType.textInput, t, defaultOptions)
|
||||
const result = schema.safeParse({
|
||||
type: 'text-input',
|
||||
variable: '123abc',
|
||||
label: 'Test',
|
||||
required: false,
|
||||
maxLength: 48,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should accept valid variable name', () => {
|
||||
const schema = createInputFieldSchema(PipelineInputVarType.textInput, t, defaultOptions)
|
||||
const result = schema.safeParse({
|
||||
type: 'text-input',
|
||||
variable: 'valid_var',
|
||||
label: 'Test',
|
||||
required: false,
|
||||
maxLength: 48,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject empty label', () => {
|
||||
const schema = createInputFieldSchema(PipelineInputVarType.textInput, t, defaultOptions)
|
||||
const result = schema.safeParse({
|
||||
type: 'text-input',
|
||||
variable: 'my_var',
|
||||
label: '',
|
||||
required: false,
|
||||
maxLength: 48,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('text input type', () => {
|
||||
it('should validate maxLength within range', () => {
|
||||
const schema = createInputFieldSchema(PipelineInputVarType.textInput, t, defaultOptions)
|
||||
|
||||
const valid = schema.safeParse({
|
||||
type: 'text-input',
|
||||
variable: 'text_var',
|
||||
label: 'Text',
|
||||
required: false,
|
||||
maxLength: 100,
|
||||
})
|
||||
expect(valid.success).toBe(true)
|
||||
|
||||
const tooLow = schema.safeParse({
|
||||
type: 'text-input',
|
||||
variable: 'text_var',
|
||||
label: 'Text',
|
||||
required: false,
|
||||
maxLength: 0,
|
||||
})
|
||||
expect(tooLow.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow optional default and tooltips', () => {
|
||||
const schema = createInputFieldSchema(PipelineInputVarType.textInput, t, defaultOptions)
|
||||
const result = schema.safeParse({
|
||||
type: 'text-input',
|
||||
variable: 'text_var',
|
||||
label: 'Text',
|
||||
required: false,
|
||||
maxLength: 48,
|
||||
default: 'default value',
|
||||
tooltips: 'Some help text',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('paragraph type', () => {
|
||||
it('should use same schema as text input', () => {
|
||||
const schema = createInputFieldSchema(PipelineInputVarType.paragraph, t, defaultOptions)
|
||||
const result = schema.safeParse({
|
||||
type: 'paragraph',
|
||||
variable: 'para_var',
|
||||
label: 'Paragraph',
|
||||
required: false,
|
||||
maxLength: 100,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('number type', () => {
|
||||
it('should allow optional unit and placeholder', () => {
|
||||
const schema = createInputFieldSchema(PipelineInputVarType.number, t, defaultOptions)
|
||||
const result = schema.safeParse({
|
||||
type: 'number',
|
||||
variable: 'num_var',
|
||||
label: 'Number',
|
||||
required: false,
|
||||
default: 42,
|
||||
unit: 'kg',
|
||||
placeholder: 'Enter weight',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('select type', () => {
|
||||
it('should require non-empty options array', () => {
|
||||
const schema = createInputFieldSchema(PipelineInputVarType.select, t, defaultOptions)
|
||||
|
||||
const empty = schema.safeParse({
|
||||
type: 'select',
|
||||
variable: 'sel_var',
|
||||
label: 'Select',
|
||||
required: false,
|
||||
options: [],
|
||||
})
|
||||
expect(empty.success).toBe(false)
|
||||
|
||||
const valid = schema.safeParse({
|
||||
type: 'select',
|
||||
variable: 'sel_var',
|
||||
label: 'Select',
|
||||
required: false,
|
||||
options: ['opt1', 'opt2'],
|
||||
})
|
||||
expect(valid.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject duplicate options', () => {
|
||||
const schema = createInputFieldSchema(PipelineInputVarType.select, t, defaultOptions)
|
||||
const result = schema.safeParse({
|
||||
type: 'select',
|
||||
variable: 'sel_var',
|
||||
label: 'Select',
|
||||
required: false,
|
||||
options: ['opt1', 'opt1'],
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('singleFile type', () => {
|
||||
it('should require file upload methods and types', () => {
|
||||
const schema = createInputFieldSchema(PipelineInputVarType.singleFile, t, defaultOptions)
|
||||
const result = schema.safeParse({
|
||||
type: 'file',
|
||||
variable: 'file_var',
|
||||
label: 'File',
|
||||
required: false,
|
||||
allowedFileUploadMethods: ['local_file'],
|
||||
allowedTypesAndExtensions: {
|
||||
allowedFileTypes: ['document'],
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('multiFiles type', () => {
|
||||
it('should validate maxLength against maxFileUploadLimit', () => {
|
||||
const schema = createInputFieldSchema(PipelineInputVarType.multiFiles, t, { maxFileUploadLimit: 5 })
|
||||
|
||||
const valid = schema.safeParse({
|
||||
type: 'file-list',
|
||||
variable: 'files_var',
|
||||
label: 'Files',
|
||||
required: false,
|
||||
allowedFileUploadMethods: ['local_file'],
|
||||
allowedTypesAndExtensions: {
|
||||
allowedFileTypes: ['image'],
|
||||
},
|
||||
maxLength: 3,
|
||||
})
|
||||
expect(valid.success).toBe(true)
|
||||
|
||||
const tooMany = schema.safeParse({
|
||||
type: 'file-list',
|
||||
variable: 'files_var',
|
||||
label: 'Files',
|
||||
required: false,
|
||||
allowedFileUploadMethods: ['local_file'],
|
||||
allowedTypesAndExtensions: {
|
||||
allowedFileTypes: ['image'],
|
||||
},
|
||||
maxLength: 10,
|
||||
})
|
||||
expect(tooMany.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkbox / default type', () => {
|
||||
it('should use common schema for checkbox type', () => {
|
||||
const schema = createInputFieldSchema(PipelineInputVarType.checkbox, t, defaultOptions)
|
||||
const result = schema.safeParse({
|
||||
type: 'checkbox',
|
||||
variable: 'check_var',
|
||||
label: 'Agree',
|
||||
required: true,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should allow passthrough of extra fields', () => {
|
||||
const schema = createInputFieldSchema(PipelineInputVarType.checkbox, t, defaultOptions)
|
||||
const result = schema.safeParse({
|
||||
type: 'checkbox',
|
||||
variable: 'check_var',
|
||||
label: 'Agree',
|
||||
required: true,
|
||||
default: true,
|
||||
extraField: 'should pass through',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,397 @@
|
||||
import type { InputVar } from '@/models/pipeline'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useFieldList } from './hooks'
|
||||
|
||||
// ============================================================================
|
||||
// Mocks
|
||||
// ============================================================================
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockToggleInputFieldEditPanel = vi.fn()
|
||||
vi.mock('@/app/components/rag-pipeline/hooks', () => ({
|
||||
useInputFieldPanel: () => ({
|
||||
toggleInputFieldEditPanel: mockToggleInputFieldEditPanel,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockHandleInputVarRename = vi.fn()
|
||||
const mockIsVarUsedInNodes = vi.fn()
|
||||
const mockRemoveUsedVarInNodes = vi.fn()
|
||||
vi.mock('../../../../hooks/use-pipeline', () => ({
|
||||
usePipeline: () => ({
|
||||
handleInputVarRename: mockHandleInputVarRename,
|
||||
isVarUsedInNodes: mockIsVarUsedInNodes,
|
||||
removeUsedVarInNodes: mockRemoveUsedVarInNodes,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockToastNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: (...args: unknown[]) => mockToastNotify(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/types', () => ({
|
||||
ChangeType: {
|
||||
changeVarName: 'changeVarName',
|
||||
remove: 'remove',
|
||||
},
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Test data helpers
|
||||
// ============================================================================
|
||||
|
||||
function createInputVar(overrides?: Partial<InputVar>): InputVar {
|
||||
return {
|
||||
type: 'text-input',
|
||||
variable: 'test_var',
|
||||
label: 'Test Var',
|
||||
required: false,
|
||||
...overrides,
|
||||
} as InputVar
|
||||
}
|
||||
|
||||
function createDefaultProps(overrides?: Partial<Parameters<typeof useFieldList>[0]>) {
|
||||
return {
|
||||
initialInputFields: [] as InputVar[],
|
||||
onInputFieldsChange: vi.fn(),
|
||||
nodeId: 'node-1',
|
||||
allVariableNames: [] as string[],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('useFieldList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsVarUsedInNodes.mockReturnValue(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should return inputFields from initialInputFields', () => {
|
||||
const fields = [createInputVar({ variable: 'var1' })]
|
||||
const { result } = renderHook(() => useFieldList(createDefaultProps({ initialInputFields: fields })))
|
||||
|
||||
expect(result.current.inputFields).toEqual(fields)
|
||||
})
|
||||
|
||||
it('should return empty inputFields when initialized with empty array', () => {
|
||||
const { result } = renderHook(() => useFieldList(createDefaultProps()))
|
||||
|
||||
expect(result.current.inputFields).toEqual([])
|
||||
})
|
||||
|
||||
it('should return all expected functions', () => {
|
||||
const { result } = renderHook(() => useFieldList(createDefaultProps()))
|
||||
|
||||
expect(typeof result.current.handleListSortChange).toBe('function')
|
||||
expect(typeof result.current.handleRemoveField).toBe('function')
|
||||
expect(typeof result.current.handleOpenInputFieldEditor).toBe('function')
|
||||
expect(typeof result.current.hideRemoveVarConfirm).toBe('function')
|
||||
expect(typeof result.current.onRemoveVarConfirm).toBe('function')
|
||||
})
|
||||
|
||||
it('should have isShowRemoveVarConfirm as false initially', () => {
|
||||
const { result } = renderHook(() => useFieldList(createDefaultProps()))
|
||||
|
||||
expect(result.current.isShowRemoveVarConfirm).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleListSortChange', () => {
|
||||
it('should reorder input fields and notify parent', () => {
|
||||
const var1 = createInputVar({ variable: 'var1', label: 'V1' })
|
||||
const var2 = createInputVar({ variable: 'var2', label: 'V2' })
|
||||
const onInputFieldsChange = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useFieldList(createDefaultProps({
|
||||
initialInputFields: [var1, var2],
|
||||
onInputFieldsChange,
|
||||
})),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleListSortChange([
|
||||
{ ...var2, id: '1', chosen: false, selected: false },
|
||||
{ ...var1, id: '0', chosen: false, selected: false },
|
||||
])
|
||||
})
|
||||
|
||||
expect(onInputFieldsChange).toHaveBeenCalledWith([var2, var1])
|
||||
})
|
||||
|
||||
it('should strip sortable metadata (id, chosen, selected) from items', () => {
|
||||
const var1 = createInputVar({ variable: 'var1' })
|
||||
const onInputFieldsChange = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useFieldList(createDefaultProps({
|
||||
initialInputFields: [var1],
|
||||
onInputFieldsChange,
|
||||
})),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleListSortChange([
|
||||
{ ...var1, id: '0', chosen: true, selected: true },
|
||||
])
|
||||
})
|
||||
|
||||
const updatedFields = onInputFieldsChange.mock.calls[0][0]
|
||||
expect(updatedFields[0]).not.toHaveProperty('id')
|
||||
expect(updatedFields[0]).not.toHaveProperty('chosen')
|
||||
expect(updatedFields[0]).not.toHaveProperty('selected')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleRemoveField', () => {
|
||||
it('should remove field when variable is not used in nodes', () => {
|
||||
const var1 = createInputVar({ variable: 'var1' })
|
||||
const var2 = createInputVar({ variable: 'var2' })
|
||||
const onInputFieldsChange = vi.fn()
|
||||
mockIsVarUsedInNodes.mockReturnValue(false)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFieldList(createDefaultProps({
|
||||
initialInputFields: [var1, var2],
|
||||
onInputFieldsChange,
|
||||
})),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleRemoveField(0)
|
||||
})
|
||||
|
||||
expect(onInputFieldsChange).toHaveBeenCalledWith([var2])
|
||||
})
|
||||
|
||||
it('should show confirmation when variable is used in other nodes', () => {
|
||||
const var1 = createInputVar({ variable: 'used_var' })
|
||||
const onInputFieldsChange = vi.fn()
|
||||
mockIsVarUsedInNodes.mockReturnValue(true)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFieldList(createDefaultProps({
|
||||
initialInputFields: [var1],
|
||||
onInputFieldsChange,
|
||||
})),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleRemoveField(0)
|
||||
})
|
||||
|
||||
expect(result.current.isShowRemoveVarConfirm).toBe(true)
|
||||
expect(onInputFieldsChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('onRemoveVarConfirm', () => {
|
||||
it('should remove field and clean up variable references after confirmation', () => {
|
||||
const var1 = createInputVar({ variable: 'used_var' })
|
||||
const onInputFieldsChange = vi.fn()
|
||||
mockIsVarUsedInNodes.mockReturnValue(true)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFieldList(createDefaultProps({
|
||||
initialInputFields: [var1],
|
||||
onInputFieldsChange,
|
||||
nodeId: 'node-1',
|
||||
})),
|
||||
)
|
||||
|
||||
// Trigger remove to set up confirmation state
|
||||
act(() => {
|
||||
result.current.handleRemoveField(0)
|
||||
})
|
||||
|
||||
expect(result.current.isShowRemoveVarConfirm).toBe(true)
|
||||
|
||||
// Confirm removal
|
||||
act(() => {
|
||||
result.current.onRemoveVarConfirm()
|
||||
})
|
||||
|
||||
expect(onInputFieldsChange).toHaveBeenCalledWith([])
|
||||
expect(mockRemoveUsedVarInNodes).toHaveBeenCalledWith(['rag', 'node-1', 'used_var'])
|
||||
expect(result.current.isShowRemoveVarConfirm).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleOpenInputFieldEditor', () => {
|
||||
it('should open editor with existing field data when id matches', () => {
|
||||
const var1 = createInputVar({ variable: 'var1', label: 'Label 1' })
|
||||
const { result } = renderHook(() =>
|
||||
useFieldList(createDefaultProps({ initialInputFields: [var1] })),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleOpenInputFieldEditor('var1')
|
||||
})
|
||||
|
||||
expect(mockToggleInputFieldEditPanel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initialData: var1,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should open editor for new field when id does not match', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFieldList(createDefaultProps()),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleOpenInputFieldEditor('non-existent')
|
||||
})
|
||||
|
||||
expect(mockToggleInputFieldEditPanel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initialData: undefined,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should open editor for new field when no id provided', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFieldList(createDefaultProps()),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleOpenInputFieldEditor()
|
||||
})
|
||||
|
||||
expect(mockToggleInputFieldEditPanel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initialData: undefined,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('field submission (via editor)', () => {
|
||||
it('should add new field when editingFieldIndex is -1', () => {
|
||||
const onInputFieldsChange = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useFieldList(createDefaultProps({ onInputFieldsChange })),
|
||||
)
|
||||
|
||||
// Open editor for new field
|
||||
act(() => {
|
||||
result.current.handleOpenInputFieldEditor()
|
||||
})
|
||||
|
||||
// Get the onSubmit callback from editor
|
||||
const editorProps = mockToggleInputFieldEditPanel.mock.calls[0][0]
|
||||
const newField = createInputVar({ variable: 'new_var', label: 'New' })
|
||||
|
||||
act(() => {
|
||||
editorProps.onSubmit(newField)
|
||||
})
|
||||
|
||||
expect(onInputFieldsChange).toHaveBeenCalledWith([newField])
|
||||
})
|
||||
|
||||
it('should show error toast for duplicate variable names', () => {
|
||||
const var1 = createInputVar({ variable: 'existing_var' })
|
||||
const onInputFieldsChange = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useFieldList(createDefaultProps({
|
||||
initialInputFields: [var1],
|
||||
onInputFieldsChange,
|
||||
allVariableNames: ['existing_var'],
|
||||
})),
|
||||
)
|
||||
|
||||
// Open editor for new field (not editing existing)
|
||||
act(() => {
|
||||
result.current.handleOpenInputFieldEditor()
|
||||
})
|
||||
|
||||
const editorProps = mockToggleInputFieldEditPanel.mock.calls[0][0]
|
||||
const duplicateField = createInputVar({ variable: 'existing_var' })
|
||||
|
||||
act(() => {
|
||||
editorProps.onSubmit(duplicateField)
|
||||
})
|
||||
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
expect(onInputFieldsChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should trigger variable rename when ChangeType is changeVarName', () => {
|
||||
const var1 = createInputVar({ variable: 'old_name' })
|
||||
const onInputFieldsChange = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useFieldList(createDefaultProps({
|
||||
initialInputFields: [var1],
|
||||
onInputFieldsChange,
|
||||
nodeId: 'node-1',
|
||||
allVariableNames: ['old_name'],
|
||||
})),
|
||||
)
|
||||
|
||||
// Open editor for existing field
|
||||
act(() => {
|
||||
result.current.handleOpenInputFieldEditor('old_name')
|
||||
})
|
||||
|
||||
const editorProps = mockToggleInputFieldEditPanel.mock.calls[0][0]
|
||||
const updatedField = createInputVar({ variable: 'new_name' })
|
||||
|
||||
act(() => {
|
||||
editorProps.onSubmit(updatedField, {
|
||||
type: 'changeVarName',
|
||||
payload: { beforeKey: 'old_name', afterKey: 'new_name' },
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockHandleInputVarRename).toHaveBeenCalledWith(
|
||||
'node-1',
|
||||
['rag', 'node-1', 'old_name'],
|
||||
['rag', 'node-1', 'new_name'],
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hideRemoveVarConfirm', () => {
|
||||
it('should hide the confirmation dialog', () => {
|
||||
const var1 = createInputVar({ variable: 'used_var' })
|
||||
mockIsVarUsedInNodes.mockReturnValue(true)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFieldList(createDefaultProps({ initialInputFields: [var1] })),
|
||||
)
|
||||
|
||||
// Show confirmation
|
||||
act(() => {
|
||||
result.current.handleRemoveField(0)
|
||||
})
|
||||
expect(result.current.isShowRemoveVarConfirm).toBe(true)
|
||||
|
||||
// Hide confirmation
|
||||
act(() => {
|
||||
result.current.hideRemoveVarConfirm()
|
||||
})
|
||||
expect(result.current.isShowRemoveVarConfirm).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -2185,7 +2185,7 @@ describe('handleSubmitField', () => {
|
||||
|
||||
// Simulate form submission with moreInfo but different type
|
||||
const updatedFieldData = createInputVar({ variable: 'var_0', label: 'Updated Label' })
|
||||
editorProps.onSubmit(updatedFieldData, { type: 'otherType' as any })
|
||||
editorProps.onSubmit(updatedFieldData, { type: 'otherType' as never })
|
||||
|
||||
// Assert - handleInputVarRename should NOT be called
|
||||
expect(mockHandleInputVarRename).not.toHaveBeenCalled()
|
||||
|
||||
@ -0,0 +1,243 @@
|
||||
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useDatasourceOptions, useOnlineDocument, useOnlineDrive, useTestRunSteps, useWebsiteCrawl } from './hooks'
|
||||
|
||||
// ============================================================================
|
||||
// Mocks
|
||||
// ============================================================================
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockNodes: Array<{ id: string, data: Partial<DataSourceNodeType> & { type: string } }> = []
|
||||
vi.mock('reactflow', () => ({
|
||||
useNodes: () => mockNodes,
|
||||
}))
|
||||
|
||||
const mockDataSourceStoreGetState = vi.fn()
|
||||
vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/store', () => ({
|
||||
useDataSourceStore: () => ({
|
||||
getState: mockDataSourceStoreGetState,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/types', () => ({
|
||||
BlockEnum: {
|
||||
DataSource: 'data-source',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../types', () => ({
|
||||
TestRunStep: {
|
||||
dataSource: 'dataSource',
|
||||
documentProcessing: 'documentProcessing',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/models/datasets', () => ({
|
||||
CrawlStep: {
|
||||
init: 'init',
|
||||
},
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('useTestRunSteps', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should initialize with step 1', () => {
|
||||
const { result } = renderHook(() => useTestRunSteps())
|
||||
|
||||
expect(result.current.currentStep).toBe(1)
|
||||
})
|
||||
|
||||
it('should return 2 steps (dataSource and documentProcessing)', () => {
|
||||
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')
|
||||
})
|
||||
|
||||
it('should increment step on handleNextStep', () => {
|
||||
const { result } = renderHook(() => useTestRunSteps())
|
||||
|
||||
act(() => {
|
||||
result.current.handleNextStep()
|
||||
})
|
||||
|
||||
expect(result.current.currentStep).toBe(2)
|
||||
})
|
||||
|
||||
it('should decrement step on handleBackStep', () => {
|
||||
const { result } = renderHook(() => useTestRunSteps())
|
||||
|
||||
// Go to step 2 first
|
||||
act(() => {
|
||||
result.current.handleNextStep()
|
||||
})
|
||||
expect(result.current.currentStep).toBe(2)
|
||||
|
||||
// Go back
|
||||
act(() => {
|
||||
result.current.handleBackStep()
|
||||
})
|
||||
expect(result.current.currentStep).toBe(1)
|
||||
})
|
||||
|
||||
it('should have translated step labels', () => {
|
||||
const { result } = renderHook(() => useTestRunSteps())
|
||||
|
||||
expect(result.current.steps[0].label).toBeDefined()
|
||||
expect(typeof result.current.steps[0].label).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDatasourceOptions', () => {
|
||||
beforeEach(() => {
|
||||
mockNodes.length = 0
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return empty options when no DataSource nodes', () => {
|
||||
mockNodes.push({ id: 'n1', data: { type: BlockEnum.LLM, title: 'LLM' } })
|
||||
|
||||
const { result } = renderHook(() => useDatasourceOptions())
|
||||
|
||||
expect(result.current).toEqual([])
|
||||
})
|
||||
|
||||
it('should return options from DataSource nodes', () => {
|
||||
mockNodes.push(
|
||||
{ id: 'ds-1', data: { type: BlockEnum.DataSource, title: 'Source A' } },
|
||||
{ id: 'ds-2', data: { type: BlockEnum.DataSource, title: 'Source B' } },
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useDatasourceOptions())
|
||||
|
||||
expect(result.current).toHaveLength(2)
|
||||
expect(result.current[0]).toEqual({
|
||||
label: 'Source A',
|
||||
value: 'ds-1',
|
||||
data: expect.objectContaining({ type: 'data-source' }),
|
||||
})
|
||||
expect(result.current[1]).toEqual({
|
||||
label: 'Source B',
|
||||
value: 'ds-2',
|
||||
data: expect.objectContaining({ type: 'data-source' }),
|
||||
})
|
||||
})
|
||||
|
||||
it('should filter out non-DataSource nodes', () => {
|
||||
mockNodes.push(
|
||||
{ id: 'ds-1', data: { type: BlockEnum.DataSource, title: 'Source' } },
|
||||
{ id: 'llm-1', data: { type: BlockEnum.LLM, title: 'LLM' } },
|
||||
{ id: 'end-1', data: { type: BlockEnum.End, title: 'End' } },
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useDatasourceOptions())
|
||||
|
||||
expect(result.current).toHaveLength(1)
|
||||
expect(result.current[0].value).toBe('ds-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useOnlineDocument', () => {
|
||||
it('should clear all online document data', () => {
|
||||
const mockSetDocumentsData = vi.fn()
|
||||
const mockSetSearchValue = vi.fn()
|
||||
const mockSetSelectedPagesId = vi.fn()
|
||||
const mockSetOnlineDocuments = vi.fn()
|
||||
const mockSetCurrentDocument = vi.fn()
|
||||
|
||||
mockDataSourceStoreGetState.mockReturnValue({
|
||||
setDocumentsData: mockSetDocumentsData,
|
||||
setSearchValue: mockSetSearchValue,
|
||||
setSelectedPagesId: mockSetSelectedPagesId,
|
||||
setOnlineDocuments: mockSetOnlineDocuments,
|
||||
setCurrentDocument: mockSetCurrentDocument,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useOnlineDocument())
|
||||
|
||||
act(() => {
|
||||
result.current.clearOnlineDocumentData()
|
||||
})
|
||||
|
||||
expect(mockSetDocumentsData).toHaveBeenCalledWith([])
|
||||
expect(mockSetSearchValue).toHaveBeenCalledWith('')
|
||||
expect(mockSetSelectedPagesId).toHaveBeenCalledWith(new Set())
|
||||
expect(mockSetOnlineDocuments).toHaveBeenCalledWith([])
|
||||
expect(mockSetCurrentDocument).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWebsiteCrawl', () => {
|
||||
it('should clear all website crawl data', () => {
|
||||
const mockSetStep = vi.fn()
|
||||
const mockSetCrawlResult = vi.fn()
|
||||
const mockSetWebsitePages = vi.fn()
|
||||
const mockSetPreviewIndex = vi.fn()
|
||||
const mockSetCurrentWebsite = vi.fn()
|
||||
|
||||
mockDataSourceStoreGetState.mockReturnValue({
|
||||
setStep: mockSetStep,
|
||||
setCrawlResult: mockSetCrawlResult,
|
||||
setWebsitePages: mockSetWebsitePages,
|
||||
setPreviewIndex: mockSetPreviewIndex,
|
||||
setCurrentWebsite: mockSetCurrentWebsite,
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useOnlineDrive', () => {
|
||||
it('should clear all online drive data', () => {
|
||||
const mockSetOnlineDriveFileList = vi.fn()
|
||||
const mockSetBucket = vi.fn()
|
||||
const mockSetPrefix = vi.fn()
|
||||
const mockSetKeywords = vi.fn()
|
||||
const mockSetSelectedFileIds = vi.fn()
|
||||
|
||||
mockDataSourceStoreGetState.mockReturnValue({
|
||||
setOnlineDriveFileList: mockSetOnlineDriveFileList,
|
||||
setBucket: mockSetBucket,
|
||||
setPrefix: mockSetPrefix,
|
||||
setKeywords: mockSetKeywords,
|
||||
setSelectedFileIds: mockSetSelectedFileIds,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useOnlineDrive())
|
||||
|
||||
act(() => {
|
||||
result.current.clearOnlineDriveData()
|
||||
})
|
||||
|
||||
expect(mockSetOnlineDriveFileList).toHaveBeenCalledWith([])
|
||||
expect(mockSetBucket).toHaveBeenCalledWith('')
|
||||
expect(mockSetPrefix).toHaveBeenCalledWith([])
|
||||
expect(mockSetKeywords).toHaveBeenCalledWith('')
|
||||
expect(mockSetSelectedFileIds).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
@ -307,11 +307,10 @@ describe('useInputFieldPanel', () => {
|
||||
|
||||
it('should set edit panel props when toggleInputFieldEditPanel is called', () => {
|
||||
const { result } = renderHook(() => useInputFieldPanel())
|
||||
const editContent = { type: 'edit', data: {} }
|
||||
const editContent = { onClose: vi.fn(), onSubmit: vi.fn() }
|
||||
|
||||
act(() => {
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
result.current.toggleInputFieldEditPanel(editContent as any)
|
||||
result.current.toggleInputFieldEditPanel(editContent)
|
||||
})
|
||||
|
||||
expect(mockSetInputFieldEditPanelProps).toHaveBeenCalledWith(editContent)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import type { VersionHistory } from '@/types/workflow'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@ -316,7 +316,7 @@ describe('usePipelineRun', () => {
|
||||
const { result } = renderHook(() => usePipelineRun())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as any)
|
||||
result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as unknown as VersionHistory)
|
||||
})
|
||||
|
||||
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
|
||||
@ -340,7 +340,7 @@ describe('usePipelineRun', () => {
|
||||
const { result } = renderHook(() => usePipelineRun())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as any)
|
||||
result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as unknown as VersionHistory)
|
||||
})
|
||||
|
||||
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([{ key: 'ENV', value: 'value' }])
|
||||
@ -360,7 +360,7 @@ describe('usePipelineRun', () => {
|
||||
const { result } = renderHook(() => usePipelineRun())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as any)
|
||||
result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as unknown as VersionHistory)
|
||||
})
|
||||
|
||||
expect(mockSetRagPipelineVariables).toHaveBeenCalledWith([{ variable: 'query', type: 'text-input' }])
|
||||
@ -380,7 +380,7 @@ describe('usePipelineRun', () => {
|
||||
const { result } = renderHook(() => usePipelineRun())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as any)
|
||||
result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as unknown as VersionHistory)
|
||||
})
|
||||
|
||||
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([])
|
||||
@ -779,7 +779,7 @@ describe('usePipelineRun', () => {
|
||||
const { result } = renderHook(() => usePipelineRun())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRun({ inputs: {} }, { onData: customCallback } as any)
|
||||
await result.current.handleRun({ inputs: {} }, { onData: customCallback } as unknown as Parameters<typeof result.current.handleRun>[1])
|
||||
})
|
||||
|
||||
expect(capturedCallbacks.onData).toBeDefined()
|
||||
|
||||
339
web/app/components/rag-pipeline/hooks/use-pipeline.spec.ts
Normal file
339
web/app/components/rag-pipeline/hooks/use-pipeline.spec.ts
Normal file
@ -0,0 +1,339 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { usePipeline } from './use-pipeline'
|
||||
|
||||
// ============================================================================
|
||||
// Mocks
|
||||
// ============================================================================
|
||||
|
||||
const mockGetNodes = vi.fn()
|
||||
const mockSetNodes = vi.fn()
|
||||
const mockEdges: Array<{ id: string, source: string, target: string }> = []
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useStoreApi: () => ({
|
||||
getState: () => ({
|
||||
getNodes: mockGetNodes,
|
||||
setNodes: mockSetNodes,
|
||||
edges: mockEdges,
|
||||
}),
|
||||
}),
|
||||
getOutgoers: (node: { id: string }, nodes: Array<{ id: string }>, edges: Array<{ source: string, target: string }>) => {
|
||||
return nodes.filter(n => edges.some(e => e.source === node.id && e.target === n.id))
|
||||
},
|
||||
}))
|
||||
|
||||
const mockFindUsedVarNodes = vi.fn()
|
||||
const mockUpdateNodeVars = vi.fn()
|
||||
vi.mock('../../workflow/nodes/_base/components/variable/utils', () => ({
|
||||
findUsedVarNodes: (...args: unknown[]) => mockFindUsedVarNodes(...args),
|
||||
updateNodeVars: (...args: unknown[]) => mockUpdateNodeVars(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../../workflow/types', () => ({
|
||||
BlockEnum: {
|
||||
DataSource: 'data-source',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('es-toolkit/compat', () => ({
|
||||
uniqBy: (arr: Array<{ id: string }>, key: string) => {
|
||||
const seen = new Set<string>()
|
||||
return arr.filter((item) => {
|
||||
const val = item[key as keyof typeof item] as string
|
||||
if (seen.has(val))
|
||||
return false
|
||||
seen.add(val)
|
||||
return true
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Test data helpers
|
||||
// ============================================================================
|
||||
|
||||
function createNode(id: string, type: string) {
|
||||
return { id, data: { type }, position: { x: 0, y: 0 } }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('usePipeline', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockEdges.length = 0
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('hook initialization', () => {
|
||||
it('should return handleInputVarRename function', () => {
|
||||
mockGetNodes.mockReturnValue([])
|
||||
const { result } = renderHook(() => usePipeline())
|
||||
|
||||
expect(result.current.handleInputVarRename).toBeDefined()
|
||||
expect(typeof result.current.handleInputVarRename).toBe('function')
|
||||
})
|
||||
|
||||
it('should return isVarUsedInNodes function', () => {
|
||||
mockGetNodes.mockReturnValue([])
|
||||
const { result } = renderHook(() => usePipeline())
|
||||
|
||||
expect(result.current.isVarUsedInNodes).toBeDefined()
|
||||
expect(typeof result.current.isVarUsedInNodes).toBe('function')
|
||||
})
|
||||
|
||||
it('should return removeUsedVarInNodes function', () => {
|
||||
mockGetNodes.mockReturnValue([])
|
||||
const { result } = renderHook(() => usePipeline())
|
||||
|
||||
expect(result.current.removeUsedVarInNodes).toBeDefined()
|
||||
expect(typeof result.current.removeUsedVarInNodes).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isVarUsedInNodes', () => {
|
||||
it('should return true when variable is used in downstream nodes', () => {
|
||||
const dsNode = createNode('ds-1', 'data-source')
|
||||
const downstreamNode = createNode('node-2', 'llm')
|
||||
mockGetNodes.mockReturnValue([dsNode, downstreamNode])
|
||||
mockEdges.push({ id: 'e1', source: 'ds-1', target: 'node-2' })
|
||||
mockFindUsedVarNodes.mockReturnValue([downstreamNode])
|
||||
|
||||
const { result } = renderHook(() => usePipeline())
|
||||
|
||||
const isUsed = result.current.isVarUsedInNodes(['rag', 'ds-1', 'var1'])
|
||||
expect(isUsed).toBe(true)
|
||||
expect(mockFindUsedVarNodes).toHaveBeenCalledWith(
|
||||
['rag', 'ds-1', 'var1'],
|
||||
expect.any(Array),
|
||||
)
|
||||
})
|
||||
|
||||
it('should return false when variable is not used', () => {
|
||||
const dsNode = createNode('ds-1', 'data-source')
|
||||
mockGetNodes.mockReturnValue([dsNode])
|
||||
mockFindUsedVarNodes.mockReturnValue([])
|
||||
|
||||
const { result } = renderHook(() => usePipeline())
|
||||
|
||||
const isUsed = result.current.isVarUsedInNodes(['rag', 'ds-1', 'var1'])
|
||||
expect(isUsed).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle shared nodeId by collecting all datasource nodes', () => {
|
||||
const ds1 = createNode('ds-1', 'data-source')
|
||||
const ds2 = createNode('ds-2', 'data-source')
|
||||
const node3 = createNode('node-3', 'llm')
|
||||
mockGetNodes.mockReturnValue([ds1, ds2, node3])
|
||||
mockEdges.push({ id: 'e1', source: 'ds-1', target: 'node-3' })
|
||||
mockFindUsedVarNodes.mockReturnValue([node3])
|
||||
|
||||
const { result } = renderHook(() => usePipeline())
|
||||
|
||||
const isUsed = result.current.isVarUsedInNodes(['rag', 'shared', 'var1'])
|
||||
expect(isUsed).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for shared nodeId when no datasource nodes exist', () => {
|
||||
mockGetNodes.mockReturnValue([createNode('node-1', 'llm')])
|
||||
mockFindUsedVarNodes.mockReturnValue([])
|
||||
|
||||
const { result } = renderHook(() => usePipeline())
|
||||
|
||||
const isUsed = result.current.isVarUsedInNodes(['rag', 'shared', 'var1'])
|
||||
expect(isUsed).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleInputVarRename', () => {
|
||||
it('should rename variable in affected nodes', () => {
|
||||
const dsNode = createNode('ds-1', 'data-source')
|
||||
const node2 = createNode('node-2', 'llm')
|
||||
const updatedNode2 = { ...node2, data: { ...node2.data, renamed: true } }
|
||||
mockGetNodes.mockReturnValue([dsNode, node2])
|
||||
mockEdges.push({ id: 'e1', source: 'ds-1', target: 'node-2' })
|
||||
mockFindUsedVarNodes.mockReturnValue([node2])
|
||||
mockUpdateNodeVars.mockReturnValue(updatedNode2)
|
||||
|
||||
const { result } = renderHook(() => usePipeline())
|
||||
|
||||
act(() => {
|
||||
result.current.handleInputVarRename(
|
||||
'ds-1',
|
||||
['rag', 'ds-1', 'oldVar'],
|
||||
['rag', 'ds-1', 'newVar'],
|
||||
)
|
||||
})
|
||||
|
||||
expect(mockFindUsedVarNodes).toHaveBeenCalledWith(
|
||||
['rag', 'ds-1', 'oldVar'],
|
||||
expect.any(Array),
|
||||
)
|
||||
expect(mockUpdateNodeVars).toHaveBeenCalledWith(
|
||||
node2,
|
||||
['rag', 'ds-1', 'oldVar'],
|
||||
['rag', 'ds-1', 'newVar'],
|
||||
)
|
||||
expect(mockSetNodes).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call setNodes when no nodes are affected', () => {
|
||||
const dsNode = createNode('ds-1', 'data-source')
|
||||
mockGetNodes.mockReturnValue([dsNode])
|
||||
mockFindUsedVarNodes.mockReturnValue([])
|
||||
|
||||
const { result } = renderHook(() => usePipeline())
|
||||
|
||||
act(() => {
|
||||
result.current.handleInputVarRename(
|
||||
'ds-1',
|
||||
['rag', 'ds-1', 'oldVar'],
|
||||
['rag', 'ds-1', 'newVar'],
|
||||
)
|
||||
})
|
||||
|
||||
expect(mockSetNodes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should only update affected nodes, leave others unchanged', () => {
|
||||
const dsNode = createNode('ds-1', 'data-source')
|
||||
const node2 = createNode('node-2', 'llm')
|
||||
const node3 = createNode('node-3', 'end')
|
||||
mockGetNodes.mockReturnValue([dsNode, node2, node3])
|
||||
mockEdges.push(
|
||||
{ id: 'e1', source: 'ds-1', target: 'node-2' },
|
||||
{ id: 'e2', source: 'node-2', target: 'node-3' },
|
||||
)
|
||||
// Only node2 uses the variable
|
||||
mockFindUsedVarNodes.mockReturnValue([node2])
|
||||
const updatedNode2 = { ...node2, updated: true }
|
||||
mockUpdateNodeVars.mockReturnValue(updatedNode2)
|
||||
|
||||
const { result } = renderHook(() => usePipeline())
|
||||
|
||||
act(() => {
|
||||
result.current.handleInputVarRename(
|
||||
'ds-1',
|
||||
['rag', 'ds-1', 'var1'],
|
||||
['rag', 'ds-1', 'var2'],
|
||||
)
|
||||
})
|
||||
|
||||
const setNodesArg = mockSetNodes.mock.calls[0][0]
|
||||
// node2 should be updated, dsNode and node3 should remain the same
|
||||
expect(setNodesArg).toContain(dsNode)
|
||||
expect(setNodesArg).toContain(updatedNode2)
|
||||
expect(setNodesArg).toContain(node3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeUsedVarInNodes', () => {
|
||||
it('should remove variable references from affected nodes', () => {
|
||||
const dsNode = createNode('ds-1', 'data-source')
|
||||
const node2 = createNode('node-2', 'llm')
|
||||
const cleanedNode2 = { ...node2, data: { ...node2.data, cleaned: true } }
|
||||
mockGetNodes.mockReturnValue([dsNode, node2])
|
||||
mockEdges.push({ id: 'e1', source: 'ds-1', target: 'node-2' })
|
||||
mockFindUsedVarNodes.mockReturnValue([node2])
|
||||
mockUpdateNodeVars.mockReturnValue(cleanedNode2)
|
||||
|
||||
const { result } = renderHook(() => usePipeline())
|
||||
|
||||
act(() => {
|
||||
result.current.removeUsedVarInNodes(['rag', 'ds-1', 'var1'])
|
||||
})
|
||||
|
||||
expect(mockUpdateNodeVars).toHaveBeenCalledWith(
|
||||
node2,
|
||||
['rag', 'ds-1', 'var1'],
|
||||
[], // Empty array removes the variable
|
||||
)
|
||||
expect(mockSetNodes).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call setNodes when no nodes use the variable', () => {
|
||||
const dsNode = createNode('ds-1', 'data-source')
|
||||
mockGetNodes.mockReturnValue([dsNode])
|
||||
mockFindUsedVarNodes.mockReturnValue([])
|
||||
|
||||
const { result } = renderHook(() => usePipeline())
|
||||
|
||||
act(() => {
|
||||
result.current.removeUsedVarInNodes(['rag', 'ds-1', 'var1'])
|
||||
})
|
||||
|
||||
expect(mockSetNodes).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAllNodesInSameBranch — edge cases', () => {
|
||||
it('should traverse multi-level downstream nodes', () => {
|
||||
const ds = createNode('ds-1', 'data-source')
|
||||
const n2 = createNode('node-2', 'llm')
|
||||
const n3 = createNode('node-3', 'end')
|
||||
mockGetNodes.mockReturnValue([ds, n2, n3])
|
||||
mockEdges.push(
|
||||
{ id: 'e1', source: 'ds-1', target: 'node-2' },
|
||||
{ id: 'e2', source: 'node-2', target: 'node-3' },
|
||||
)
|
||||
mockFindUsedVarNodes.mockReturnValue([n3])
|
||||
mockUpdateNodeVars.mockReturnValue(n3)
|
||||
|
||||
const { result } = renderHook(() => usePipeline())
|
||||
|
||||
// isVarUsedInNodes triggers getAllNodesInSameBranch internally
|
||||
const isUsed = result.current.isVarUsedInNodes(['rag', 'ds-1', 'var1'])
|
||||
expect(isUsed).toBe(true)
|
||||
|
||||
// Verify findUsedVarNodes was called with all downstream nodes (ds-1, node-2, node-3)
|
||||
const nodesArg = mockFindUsedVarNodes.mock.calls[0][1] as Array<{ id: string }>
|
||||
const nodeIds = nodesArg.map(n => n.id)
|
||||
expect(nodeIds).toContain('ds-1')
|
||||
expect(nodeIds).toContain('node-2')
|
||||
expect(nodeIds).toContain('node-3')
|
||||
})
|
||||
|
||||
it('should return empty array for non-existent node', () => {
|
||||
mockGetNodes.mockReturnValue([createNode('ds-1', 'data-source')])
|
||||
mockFindUsedVarNodes.mockReturnValue([])
|
||||
|
||||
const { result } = renderHook(() => usePipeline())
|
||||
|
||||
const isUsed = result.current.isVarUsedInNodes(['rag', 'non-existent', 'var1'])
|
||||
expect(isUsed).toBe(false)
|
||||
})
|
||||
|
||||
it('should deduplicate nodes when traversal finds shared nodes', () => {
|
||||
// Diamond graph: ds-1 -> n2, ds-1 -> n3, n2 -> n4, n3 -> n4
|
||||
const ds = createNode('ds-1', 'data-source')
|
||||
const n2 = createNode('node-2', 'llm')
|
||||
const n3 = createNode('node-3', 'llm')
|
||||
const n4 = createNode('node-4', 'end')
|
||||
mockGetNodes.mockReturnValue([ds, n2, n3, n4])
|
||||
mockEdges.push(
|
||||
{ id: 'e1', source: 'ds-1', target: 'node-2' },
|
||||
{ id: 'e2', source: 'ds-1', target: 'node-3' },
|
||||
{ id: 'e3', source: 'node-2', target: 'node-4' },
|
||||
{ id: 'e4', source: 'node-3', target: 'node-4' },
|
||||
)
|
||||
mockFindUsedVarNodes.mockReturnValue([])
|
||||
|
||||
const { result } = renderHook(() => usePipeline())
|
||||
|
||||
result.current.isVarUsedInNodes(['rag', 'ds-1', 'var1'])
|
||||
|
||||
// findUsedVarNodes should receive unique nodes (no duplicates of node-4)
|
||||
const nodesArg = mockFindUsedVarNodes.mock.calls[0][1] as Array<{ id: string }>
|
||||
const nodeIds = nodesArg.map(n => n.id)
|
||||
const uniqueIds = [...new Set(nodeIds)]
|
||||
expect(nodeIds.length).toBe(uniqueIds.length)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,231 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useRagPipelineSearch } from './use-rag-pipeline-search'
|
||||
|
||||
// ============================================================================
|
||||
// Mocks
|
||||
// ============================================================================
|
||||
|
||||
const mockNodes: Array<{ id: string, data: Record<string, unknown> }> = []
|
||||
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
|
||||
default: () => mockNodes,
|
||||
}))
|
||||
|
||||
const mockHandleNodeSelect = vi.fn()
|
||||
vi.mock('@/app/components/workflow/hooks/use-nodes-interactions', () => ({
|
||||
useNodesInteractions: () => ({
|
||||
handleNodeSelect: mockHandleNodeSelect,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks/use-tool-icon', () => ({
|
||||
useGetToolIcon: () => () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/block-icon', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
type MockSearchResult = {
|
||||
title: string
|
||||
type: string
|
||||
description?: string
|
||||
metadata?: { nodeId: string }
|
||||
}
|
||||
|
||||
const mockRagPipelineNodesAction = vi.hoisted(() => {
|
||||
return { searchFn: undefined as undefined | ((query: string) => MockSearchResult[]) }
|
||||
})
|
||||
vi.mock('@/app/components/goto-anything/actions/rag-pipeline-nodes', () => ({
|
||||
ragPipelineNodesAction: mockRagPipelineNodesAction,
|
||||
}))
|
||||
|
||||
const mockCleanupListener = vi.fn()
|
||||
vi.mock('@/app/components/workflow/utils/node-navigation', () => ({
|
||||
setupNodeSelectionListener: () => mockCleanupListener,
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('useRagPipelineSearch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockNodes.length = 0
|
||||
mockRagPipelineNodesAction.searchFn = undefined
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('hook lifecycle', () => {
|
||||
it('should return null', () => {
|
||||
const { result } = renderHook(() => useRagPipelineSearch())
|
||||
expect(result.current).toBeNull()
|
||||
})
|
||||
|
||||
it('should register search function when nodes exist', () => {
|
||||
mockNodes.push({
|
||||
id: 'node-1',
|
||||
data: { type: BlockEnum.LLM, title: 'LLM Node', desc: '' },
|
||||
})
|
||||
|
||||
renderHook(() => useRagPipelineSearch())
|
||||
|
||||
expect(mockRagPipelineNodesAction.searchFn).toBeDefined()
|
||||
})
|
||||
|
||||
it('should not register search function when no nodes', () => {
|
||||
renderHook(() => useRagPipelineSearch())
|
||||
|
||||
expect(mockRagPipelineNodesAction.searchFn).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should cleanup search function on unmount', () => {
|
||||
mockNodes.push({
|
||||
id: 'node-1',
|
||||
data: { type: BlockEnum.Start, title: 'Start', desc: '' },
|
||||
})
|
||||
|
||||
const { unmount } = renderHook(() => useRagPipelineSearch())
|
||||
|
||||
expect(mockRagPipelineNodesAction.searchFn).toBeDefined()
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockRagPipelineNodesAction.searchFn).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should setup node selection listener', () => {
|
||||
const { unmount } = renderHook(() => useRagPipelineSearch())
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockCleanupListener).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('search functionality', () => {
|
||||
beforeEach(() => {
|
||||
mockNodes.push(
|
||||
{
|
||||
id: 'node-1',
|
||||
data: { type: BlockEnum.LLM, title: 'GPT Model', desc: 'Language model' },
|
||||
},
|
||||
{
|
||||
id: 'node-2',
|
||||
data: { type: BlockEnum.KnowledgeRetrieval, title: 'Knowledge Base', desc: 'Search knowledge', dataset_ids: ['ds1', 'ds2'] },
|
||||
},
|
||||
{
|
||||
id: 'node-3',
|
||||
data: { type: BlockEnum.Tool, title: 'Web Search', desc: '', tool_description: 'Search the web', tool_label: 'WebSearch' },
|
||||
},
|
||||
{
|
||||
id: 'node-4',
|
||||
data: { type: BlockEnum.Start, title: 'Start Node', desc: 'Pipeline entry' },
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('should find nodes by title', () => {
|
||||
renderHook(() => useRagPipelineSearch())
|
||||
|
||||
const searchFn = mockRagPipelineNodesAction.searchFn!
|
||||
const results = searchFn('GPT')
|
||||
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
expect(results[0].title).toBe('GPT Model')
|
||||
})
|
||||
|
||||
it('should find nodes by type', () => {
|
||||
renderHook(() => useRagPipelineSearch())
|
||||
|
||||
const searchFn = mockRagPipelineNodesAction.searchFn!
|
||||
const results = searchFn(BlockEnum.LLM)
|
||||
|
||||
expect(results.some(r => r.title === 'GPT Model')).toBe(true)
|
||||
})
|
||||
|
||||
it('should find nodes by description', () => {
|
||||
renderHook(() => useRagPipelineSearch())
|
||||
|
||||
const searchFn = mockRagPipelineNodesAction.searchFn!
|
||||
const results = searchFn('knowledge')
|
||||
|
||||
expect(results.some(r => r.title === 'Knowledge Base')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return all nodes when search term is empty', () => {
|
||||
renderHook(() => useRagPipelineSearch())
|
||||
|
||||
const searchFn = mockRagPipelineNodesAction.searchFn!
|
||||
const results = searchFn('')
|
||||
|
||||
expect(results.length).toBe(4)
|
||||
})
|
||||
|
||||
it('should sort by alphabetical order when no search term', () => {
|
||||
renderHook(() => useRagPipelineSearch())
|
||||
|
||||
const searchFn = mockRagPipelineNodesAction.searchFn!
|
||||
const results = searchFn('')
|
||||
const titles = results.map(r => r.title)
|
||||
|
||||
// Should be alphabetically sorted
|
||||
const sortedTitles = [...titles].sort((a, b) => a.localeCompare(b))
|
||||
expect(titles).toEqual(sortedTitles)
|
||||
})
|
||||
|
||||
it('should sort by relevance score when search term provided', () => {
|
||||
renderHook(() => useRagPipelineSearch())
|
||||
|
||||
const searchFn = mockRagPipelineNodesAction.searchFn!
|
||||
const results = searchFn('Search')
|
||||
|
||||
// "Web Search" has title match (score 10), "Knowledge Base" has desc match (score 5)
|
||||
expect(results[0].title).toBe('Web Search')
|
||||
})
|
||||
|
||||
it('should return empty array when no nodes match', () => {
|
||||
renderHook(() => useRagPipelineSearch())
|
||||
|
||||
const searchFn = mockRagPipelineNodesAction.searchFn!
|
||||
const results = searchFn('nonexistent-xyz-12345')
|
||||
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
it('should enhance Tool node description from tool_description', () => {
|
||||
renderHook(() => useRagPipelineSearch())
|
||||
|
||||
const searchFn = mockRagPipelineNodesAction.searchFn!
|
||||
const results = searchFn('web')
|
||||
|
||||
const toolResult = results.find(r => r.title === 'Web Search')
|
||||
expect(toolResult).toBeDefined()
|
||||
expect(toolResult?.description).toContain('Search the web')
|
||||
})
|
||||
|
||||
it('should include metadata with nodeId', () => {
|
||||
renderHook(() => useRagPipelineSearch())
|
||||
|
||||
const searchFn = mockRagPipelineNodesAction.searchFn!
|
||||
const results = searchFn('Start')
|
||||
|
||||
const startResult = results.find(r => r.title === 'Start Node')
|
||||
expect(startResult?.metadata?.nodeId).toBe('node-4')
|
||||
})
|
||||
|
||||
it('should set result type as workflow-node', () => {
|
||||
renderHook(() => useRagPipelineSearch())
|
||||
|
||||
const searchFn = mockRagPipelineNodesAction.searchFn!
|
||||
const results = searchFn('Start')
|
||||
|
||||
expect(results[0].type).toBe('workflow-node')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -41,7 +41,7 @@ vi.mock('./components/conversion', () => ({
|
||||
|
||||
// Mock: Complex component with many hooks and workflow dependencies
|
||||
vi.mock('./components/rag-pipeline-main', () => ({
|
||||
default: ({ nodes, edges, viewport }: any) => (
|
||||
default: ({ nodes, edges, viewport }: { nodes?: unknown[], edges?: unknown[], viewport?: { zoom?: number } }) => (
|
||||
<div data-testid="rag-pipeline-main">
|
||||
<span data-testid="nodes-count">{nodes?.length ?? 0}</span>
|
||||
<span data-testid="edges-count">{edges?.length ?? 0}</span>
|
||||
@ -72,7 +72,7 @@ const mockProcessNodesWithoutDataSource = vi.mocked(processNodesWithoutDataSourc
|
||||
// Helper to mock selector with actual execution (increases function coverage)
|
||||
// This executes the real selector function: s => s.dataset?.pipeline_id
|
||||
const mockSelectorWithDataset = (pipelineId: string | null | undefined) => {
|
||||
mockUseDatasetDetailContextWithSelector.mockImplementation((selector: (state: any) => any) => {
|
||||
mockUseDatasetDetailContextWithSelector.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => {
|
||||
const mockState = { dataset: pipelineId ? { pipeline_id: pipelineId } : null }
|
||||
return selector(mockState)
|
||||
})
|
||||
@ -327,7 +327,7 @@ describe('RagPipeline', () => {
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: undefined as any,
|
||||
viewport: undefined as never,
|
||||
},
|
||||
})
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
@ -342,7 +342,7 @@ describe('RagPipeline', () => {
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: null as any,
|
||||
viewport: null as never,
|
||||
},
|
||||
})
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
@ -438,7 +438,7 @@ describe('processNodesWithoutDataSource utility integration', () => {
|
||||
const mockData = createMockWorkflowData()
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
mockProcessNodesWithoutDataSource.mockReturnValue({
|
||||
nodes: [{ id: 'processed-node', type: 'custom', data: { type: BlockEnum.Start, title: 'Processed', desc: '' }, position: { x: 0, y: 0 } }] as any,
|
||||
nodes: [{ id: 'processed-node', type: 'custom', data: { type: BlockEnum.Start, title: 'Processed', desc: '' }, position: { x: 0, y: 0 } }] as unknown as ReturnType<typeof processNodesWithoutDataSource>['nodes'],
|
||||
viewport: { x: 0, y: 0, zoom: 2 },
|
||||
})
|
||||
|
||||
@ -510,13 +510,13 @@ describe('Error Handling', () => {
|
||||
it('should throw when graph nodes is null', () => {
|
||||
const mockData = {
|
||||
graph: {
|
||||
nodes: null as any,
|
||||
edges: null as any,
|
||||
nodes: null,
|
||||
edges: null,
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
hash: 'test',
|
||||
updated_at: 123,
|
||||
} as FetchWorkflowDraftResponse
|
||||
} as unknown as FetchWorkflowDraftResponse
|
||||
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import type { InputFieldEditorProps } from '../components/panel/input-field/editor'
|
||||
import type { RagPipelineSliceShape } from './index'
|
||||
import type { DataSourceItem } from '@/app/components/workflow/block-selector/types'
|
||||
import type { RAGPipelineVariables } from '@/models/pipeline'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
|
||||
import { createRagPipelineSliceSlice } from './index'
|
||||
|
||||
// Mock the transformDataSourceToTool function
|
||||
@ -11,60 +15,70 @@ vi.mock('@/app/components/workflow/block-selector/utils', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Type-safe stubs for unused Zustand StateCreator params
|
||||
type SliceCreatorParams = Parameters<typeof createRagPipelineSliceSlice>
|
||||
const unusedGet = vi.fn() as unknown as SliceCreatorParams[1]
|
||||
const unusedApi = vi.fn() as unknown as SliceCreatorParams[2]
|
||||
|
||||
// Helper to create a slice with a given mockSet
|
||||
function createSlice(mockSet = vi.fn()) {
|
||||
return createRagPipelineSliceSlice(mockSet as unknown as SliceCreatorParams[0], unusedGet, unusedApi)
|
||||
}
|
||||
|
||||
describe('createRagPipelineSliceSlice', () => {
|
||||
const mockSet = vi.fn()
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should have empty pipelineId', () => {
|
||||
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
|
||||
const slice = createSlice(mockSet)
|
||||
|
||||
expect(slice.pipelineId).toBe('')
|
||||
})
|
||||
|
||||
it('should have empty knowledgeName', () => {
|
||||
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
|
||||
const slice = createSlice(mockSet)
|
||||
|
||||
expect(slice.knowledgeName).toBe('')
|
||||
})
|
||||
|
||||
it('should have showInputFieldPanel as false', () => {
|
||||
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
|
||||
const slice = createSlice(mockSet)
|
||||
|
||||
expect(slice.showInputFieldPanel).toBe(false)
|
||||
})
|
||||
|
||||
it('should have showInputFieldPreviewPanel as false', () => {
|
||||
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
|
||||
const slice = createSlice(mockSet)
|
||||
|
||||
expect(slice.showInputFieldPreviewPanel).toBe(false)
|
||||
})
|
||||
|
||||
it('should have inputFieldEditPanelProps as null', () => {
|
||||
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
|
||||
const slice = createSlice(mockSet)
|
||||
|
||||
expect(slice.inputFieldEditPanelProps).toBeNull()
|
||||
})
|
||||
|
||||
it('should have empty nodesDefaultConfigs', () => {
|
||||
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
|
||||
const slice = createSlice(mockSet)
|
||||
|
||||
expect(slice.nodesDefaultConfigs).toEqual({})
|
||||
})
|
||||
|
||||
it('should have empty ragPipelineVariables', () => {
|
||||
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
|
||||
const slice = createSlice(mockSet)
|
||||
|
||||
expect(slice.ragPipelineVariables).toEqual([])
|
||||
})
|
||||
|
||||
it('should have empty dataSourceList', () => {
|
||||
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
|
||||
const slice = createSlice(mockSet)
|
||||
|
||||
expect(slice.dataSourceList).toEqual([])
|
||||
})
|
||||
|
||||
it('should have isPreparingDataSource as false', () => {
|
||||
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
|
||||
const slice = createSlice(mockSet)
|
||||
|
||||
expect(slice.isPreparingDataSource).toBe(false)
|
||||
})
|
||||
@ -72,25 +86,25 @@ describe('createRagPipelineSliceSlice', () => {
|
||||
|
||||
describe('setShowInputFieldPanel', () => {
|
||||
it('should call set with showInputFieldPanel true', () => {
|
||||
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
|
||||
const slice = createSlice(mockSet)
|
||||
|
||||
slice.setShowInputFieldPanel(true)
|
||||
|
||||
expect(mockSet).toHaveBeenCalledWith(expect.any(Function))
|
||||
|
||||
// Get the setter function and execute it
|
||||
const setterFn = mockSet.mock.calls[0][0]
|
||||
const setterFn = mockSet.mock.calls[0][0] as () => Partial<RagPipelineSliceShape>
|
||||
const result = setterFn()
|
||||
expect(result).toEqual({ showInputFieldPanel: true })
|
||||
})
|
||||
|
||||
it('should call set with showInputFieldPanel false', () => {
|
||||
mockSet.mockClear()
|
||||
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
|
||||
const slice = createSlice(mockSet)
|
||||
|
||||
slice.setShowInputFieldPanel(false)
|
||||
|
||||
const setterFn = mockSet.mock.calls[0][0]
|
||||
const setterFn = mockSet.mock.calls[0][0] as () => Partial<RagPipelineSliceShape>
|
||||
const result = setterFn()
|
||||
expect(result).toEqual({ showInputFieldPanel: false })
|
||||
})
|
||||
@ -99,22 +113,22 @@ describe('createRagPipelineSliceSlice', () => {
|
||||
describe('setShowInputFieldPreviewPanel', () => {
|
||||
it('should call set with showInputFieldPreviewPanel true', () => {
|
||||
mockSet.mockClear()
|
||||
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
|
||||
const slice = createSlice(mockSet)
|
||||
|
||||
slice.setShowInputFieldPreviewPanel(true)
|
||||
|
||||
const setterFn = mockSet.mock.calls[0][0]
|
||||
const setterFn = mockSet.mock.calls[0][0] as () => Partial<RagPipelineSliceShape>
|
||||
const result = setterFn()
|
||||
expect(result).toEqual({ showInputFieldPreviewPanel: true })
|
||||
})
|
||||
|
||||
it('should call set with showInputFieldPreviewPanel false', () => {
|
||||
mockSet.mockClear()
|
||||
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
|
||||
const slice = createSlice(mockSet)
|
||||
|
||||
slice.setShowInputFieldPreviewPanel(false)
|
||||
|
||||
const setterFn = mockSet.mock.calls[0][0]
|
||||
const setterFn = mockSet.mock.calls[0][0] as () => Partial<RagPipelineSliceShape>
|
||||
const result = setterFn()
|
||||
expect(result).toEqual({ showInputFieldPreviewPanel: false })
|
||||
})
|
||||
@ -123,23 +137,23 @@ describe('createRagPipelineSliceSlice', () => {
|
||||
describe('setInputFieldEditPanelProps', () => {
|
||||
it('should call set with inputFieldEditPanelProps object', () => {
|
||||
mockSet.mockClear()
|
||||
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
|
||||
const props = { type: 'create' as const }
|
||||
const slice = createSlice(mockSet)
|
||||
const props = { onClose: vi.fn(), onSubmit: vi.fn() } as unknown as InputFieldEditorProps
|
||||
|
||||
slice.setInputFieldEditPanelProps(props as any)
|
||||
slice.setInputFieldEditPanelProps(props)
|
||||
|
||||
const setterFn = mockSet.mock.calls[0][0]
|
||||
const setterFn = mockSet.mock.calls[0][0] as () => Partial<RagPipelineSliceShape>
|
||||
const result = setterFn()
|
||||
expect(result).toEqual({ inputFieldEditPanelProps: props })
|
||||
})
|
||||
|
||||
it('should call set with inputFieldEditPanelProps null', () => {
|
||||
mockSet.mockClear()
|
||||
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
|
||||
const slice = createSlice(mockSet)
|
||||
|
||||
slice.setInputFieldEditPanelProps(null)
|
||||
|
||||
const setterFn = mockSet.mock.calls[0][0]
|
||||
const setterFn = mockSet.mock.calls[0][0] as () => Partial<RagPipelineSliceShape>
|
||||
const result = setterFn()
|
||||
expect(result).toEqual({ inputFieldEditPanelProps: null })
|
||||
})
|
||||
@ -148,23 +162,23 @@ describe('createRagPipelineSliceSlice', () => {
|
||||
describe('setNodesDefaultConfigs', () => {
|
||||
it('should call set with nodesDefaultConfigs', () => {
|
||||
mockSet.mockClear()
|
||||
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
|
||||
const configs = { node1: { key: 'value' } }
|
||||
const slice = createSlice(mockSet)
|
||||
const configs: Record<string, unknown> = { node1: { key: 'value' } }
|
||||
|
||||
slice.setNodesDefaultConfigs(configs)
|
||||
|
||||
const setterFn = mockSet.mock.calls[0][0]
|
||||
const setterFn = mockSet.mock.calls[0][0] as () => Partial<RagPipelineSliceShape>
|
||||
const result = setterFn()
|
||||
expect(result).toEqual({ nodesDefaultConfigs: configs })
|
||||
})
|
||||
|
||||
it('should call set with empty nodesDefaultConfigs', () => {
|
||||
mockSet.mockClear()
|
||||
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
|
||||
const slice = createSlice(mockSet)
|
||||
|
||||
slice.setNodesDefaultConfigs({})
|
||||
|
||||
const setterFn = mockSet.mock.calls[0][0]
|
||||
const setterFn = mockSet.mock.calls[0][0] as () => Partial<RagPipelineSliceShape>
|
||||
const result = setterFn()
|
||||
expect(result).toEqual({ nodesDefaultConfigs: {} })
|
||||
})
|
||||
@ -173,25 +187,25 @@ describe('createRagPipelineSliceSlice', () => {
|
||||
describe('setRagPipelineVariables', () => {
|
||||
it('should call set with ragPipelineVariables', () => {
|
||||
mockSet.mockClear()
|
||||
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
|
||||
const variables = [
|
||||
{ type: 'text-input', variable: 'var1', label: 'Var 1', required: true },
|
||||
const slice = createSlice(mockSet)
|
||||
const variables: RAGPipelineVariables = [
|
||||
{ type: PipelineInputVarType.textInput, variable: 'var1', label: 'Var 1', required: true, belong_to_node_id: 'node-1' },
|
||||
]
|
||||
|
||||
slice.setRagPipelineVariables(variables as any)
|
||||
slice.setRagPipelineVariables(variables)
|
||||
|
||||
const setterFn = mockSet.mock.calls[0][0]
|
||||
const setterFn = mockSet.mock.calls[0][0] as () => Partial<RagPipelineSliceShape>
|
||||
const result = setterFn()
|
||||
expect(result).toEqual({ ragPipelineVariables: variables })
|
||||
})
|
||||
|
||||
it('should call set with empty ragPipelineVariables', () => {
|
||||
mockSet.mockClear()
|
||||
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
|
||||
const slice = createSlice(mockSet)
|
||||
|
||||
slice.setRagPipelineVariables([])
|
||||
|
||||
const setterFn = mockSet.mock.calls[0][0]
|
||||
const setterFn = mockSet.mock.calls[0][0] as () => Partial<RagPipelineSliceShape>
|
||||
const result = setterFn()
|
||||
expect(result).toEqual({ ragPipelineVariables: [] })
|
||||
})
|
||||
@ -200,7 +214,7 @@ describe('createRagPipelineSliceSlice', () => {
|
||||
describe('setDataSourceList', () => {
|
||||
it('should transform and set dataSourceList', () => {
|
||||
mockSet.mockClear()
|
||||
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
|
||||
const slice = createSlice(mockSet)
|
||||
const dataSourceList: DataSourceItem[] = [
|
||||
{ name: 'source1', key: 'key1' } as unknown as DataSourceItem,
|
||||
{ name: 'source2', key: 'key2' } as unknown as DataSourceItem,
|
||||
@ -208,20 +222,20 @@ describe('createRagPipelineSliceSlice', () => {
|
||||
|
||||
slice.setDataSourceList(dataSourceList)
|
||||
|
||||
const setterFn = mockSet.mock.calls[0][0]
|
||||
const setterFn = mockSet.mock.calls[0][0] as () => Partial<RagPipelineSliceShape>
|
||||
const result = setterFn()
|
||||
expect(result.dataSourceList).toHaveLength(2)
|
||||
expect(result.dataSourceList[0]).toEqual({ name: 'source1', key: 'key1', transformed: true })
|
||||
expect(result.dataSourceList[1]).toEqual({ name: 'source2', key: 'key2', transformed: true })
|
||||
expect(result.dataSourceList![0]).toEqual({ name: 'source1', key: 'key1', transformed: true })
|
||||
expect(result.dataSourceList![1]).toEqual({ name: 'source2', key: 'key2', transformed: true })
|
||||
})
|
||||
|
||||
it('should set empty dataSourceList', () => {
|
||||
mockSet.mockClear()
|
||||
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
|
||||
const slice = createSlice(mockSet)
|
||||
|
||||
slice.setDataSourceList([])
|
||||
|
||||
const setterFn = mockSet.mock.calls[0][0]
|
||||
const setterFn = mockSet.mock.calls[0][0] as () => Partial<RagPipelineSliceShape>
|
||||
const result = setterFn()
|
||||
expect(result.dataSourceList).toEqual([])
|
||||
})
|
||||
@ -230,22 +244,22 @@ describe('createRagPipelineSliceSlice', () => {
|
||||
describe('setIsPreparingDataSource', () => {
|
||||
it('should call set with isPreparingDataSource true', () => {
|
||||
mockSet.mockClear()
|
||||
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
|
||||
const slice = createSlice(mockSet)
|
||||
|
||||
slice.setIsPreparingDataSource(true)
|
||||
|
||||
const setterFn = mockSet.mock.calls[0][0]
|
||||
const setterFn = mockSet.mock.calls[0][0] as () => Partial<RagPipelineSliceShape>
|
||||
const result = setterFn()
|
||||
expect(result).toEqual({ isPreparingDataSource: true })
|
||||
})
|
||||
|
||||
it('should call set with isPreparingDataSource false', () => {
|
||||
mockSet.mockClear()
|
||||
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
|
||||
const slice = createSlice(mockSet)
|
||||
|
||||
slice.setIsPreparingDataSource(false)
|
||||
|
||||
const setterFn = mockSet.mock.calls[0][0]
|
||||
const setterFn = mockSet.mock.calls[0][0] as () => Partial<RagPipelineSliceShape>
|
||||
const result = setterFn()
|
||||
expect(result).toEqual({ isPreparingDataSource: false })
|
||||
})
|
||||
@ -254,7 +268,7 @@ describe('createRagPipelineSliceSlice', () => {
|
||||
|
||||
describe('RagPipelineSliceShape type', () => {
|
||||
it('should define all required properties', () => {
|
||||
const slice = createRagPipelineSliceSlice(vi.fn(), vi.fn() as any, vi.fn() as any)
|
||||
const slice = createSlice()
|
||||
|
||||
// Check all properties exist
|
||||
expect(slice).toHaveProperty('pipelineId')
|
||||
@ -276,7 +290,7 @@ describe('RagPipelineSliceShape type', () => {
|
||||
})
|
||||
|
||||
it('should have all setters as functions', () => {
|
||||
const slice = createRagPipelineSliceSlice(vi.fn(), vi.fn() as any, vi.fn() as any)
|
||||
const slice = createSlice()
|
||||
|
||||
expect(typeof slice.setShowInputFieldPanel).toBe('function')
|
||||
expect(typeof slice.setShowInputFieldPreviewPanel).toBe('function')
|
||||
|
||||
@ -5525,11 +5525,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/rag-pipeline/components/panel/input-field/field-list/index.spec.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/rag-pipeline/components/panel/input-field/hooks.ts": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 1
|
||||
@ -5689,11 +5684,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/rag-pipeline/index.spec.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 8
|
||||
}
|
||||
},
|
||||
"app/components/rag-pipeline/store/index.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
|
||||
Reference in New Issue
Block a user