From faefb9874699dfeb9cbe9bf8db954a45786a03c1 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Wed, 11 Feb 2026 14:50:32 +0800 Subject: [PATCH] test: add integration tests for chunk preview formatting and input field editor flow --- .../chunk-preview-formatting.test.ts | 210 +++++++++ .../input-field-editor-flow.test.ts | 199 +++++++++ .../input-field/editor/form/hooks.spec.ts | 383 +++++++++++++++++ .../input-field/editor/form/schema.spec.ts | 269 ++++++++++++ .../input-field/field-list/hooks.spec.ts | 397 ++++++++++++++++++ .../input-field/field-list/index.spec.tsx | 2 +- .../panel/test-run/preparation/hooks.spec.ts | 243 +++++++++++ .../rag-pipeline/hooks/index.spec.ts | 5 +- .../hooks/use-pipeline-run.spec.ts | 12 +- .../rag-pipeline/hooks/use-pipeline.spec.ts | 339 +++++++++++++++ .../hooks/use-rag-pipeline-search.spec.tsx | 231 ++++++++++ .../components/rag-pipeline/index.spec.tsx | 16 +- .../rag-pipeline/store/index.spec.ts | 110 ++--- web/eslint-suppressions.json | 10 - 14 files changed, 2350 insertions(+), 76 deletions(-) create mode 100644 web/__tests__/rag-pipeline/chunk-preview-formatting.test.ts create mode 100644 web/__tests__/rag-pipeline/input-field-editor-flow.test.ts create mode 100644 web/app/components/rag-pipeline/components/panel/input-field/editor/form/hooks.spec.ts create mode 100644 web/app/components/rag-pipeline/components/panel/input-field/editor/form/schema.spec.ts create mode 100644 web/app/components/rag-pipeline/components/panel/input-field/field-list/hooks.spec.ts create mode 100644 web/app/components/rag-pipeline/components/panel/test-run/preparation/hooks.spec.ts create mode 100644 web/app/components/rag-pipeline/hooks/use-pipeline.spec.ts create mode 100644 web/app/components/rag-pipeline/hooks/use-rag-pipeline-search.spec.tsx diff --git a/web/__tests__/rag-pipeline/chunk-preview-formatting.test.ts b/web/__tests__/rag-pipeline/chunk-preview-formatting.test.ts new file mode 100644 index 0000000000..c4cafbc1c5 --- /dev/null +++ b/web/__tests__/rag-pipeline/chunk-preview-formatting.test.ts @@ -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() + }) + }) +}) diff --git a/web/__tests__/rag-pipeline/input-field-editor-flow.test.ts b/web/__tests__/rag-pipeline/input-field-editor-flow.test.ts new file mode 100644 index 0000000000..5af27ee535 --- /dev/null +++ b/web/__tests__/rag-pipeline/input-field-editor-flow.test.ts @@ -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']) + }) + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/hooks.spec.ts b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/hooks.spec.ts new file mode 100644 index 0000000000..ada939a4a6 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/hooks.spec.ts @@ -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 unknown>> + let mockSetFieldValue: ReturnType 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() + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/schema.spec.ts b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/schema.spec.ts new file mode 100644 index 0000000000..55d1de5d81 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/schema.spec.ts @@ -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) + }) + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/field-list/hooks.spec.ts b/web/app/components/rag-pipeline/components/panel/input-field/field-list/hooks.spec.ts new file mode 100644 index 0000000000..d06842e2bb --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/input-field/field-list/hooks.spec.ts @@ -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 { + return { + type: 'text-input', + variable: 'test_var', + label: 'Test Var', + required: false, + ...overrides, + } as InputVar +} + +function createDefaultProps(overrides?: Partial[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) + }) + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/field-list/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/field-list/index.spec.tsx index f28173d2f1..1df31dd1f8 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/field-list/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/field-list/index.spec.tsx @@ -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() diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/hooks.spec.ts b/web/app/components/rag-pipeline/components/panel/test-run/preparation/hooks.spec.ts new file mode 100644 index 0000000000..26af210ddf --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/hooks.spec.ts @@ -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 & { 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([]) + }) +}) diff --git a/web/app/components/rag-pipeline/hooks/index.spec.ts b/web/app/components/rag-pipeline/hooks/index.spec.ts index 7917275c18..3f1dccf4aa 100644 --- a/web/app/components/rag-pipeline/hooks/index.spec.ts +++ b/web/app/components/rag-pipeline/hooks/index.spec.ts @@ -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) diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline-run.spec.ts b/web/app/components/rag-pipeline/hooks/use-pipeline-run.spec.ts index 2b21001839..49527e3b90 100644 --- a/web/app/components/rag-pipeline/hooks/use-pipeline-run.spec.ts +++ b/web/app/components/rag-pipeline/hooks/use-pipeline-run.spec.ts @@ -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[1]) }) expect(capturedCallbacks.onData).toBeDefined() diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline.spec.ts b/web/app/components/rag-pipeline/hooks/use-pipeline.spec.ts new file mode 100644 index 0000000000..364969b686 --- /dev/null +++ b/web/app/components/rag-pipeline/hooks/use-pipeline.spec.ts @@ -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() + 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) + }) + }) +}) diff --git a/web/app/components/rag-pipeline/hooks/use-rag-pipeline-search.spec.tsx b/web/app/components/rag-pipeline/hooks/use-rag-pipeline-search.spec.tsx new file mode 100644 index 0000000000..575e0e460d --- /dev/null +++ b/web/app/components/rag-pipeline/hooks/use-rag-pipeline-search.spec.tsx @@ -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 }> = [] +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') + }) + }) +}) diff --git a/web/app/components/rag-pipeline/index.spec.tsx b/web/app/components/rag-pipeline/index.spec.tsx index 5adfc828cf..ed7ef0c367 100644 --- a/web/app/components/rag-pipeline/index.spec.tsx +++ b/web/app/components/rag-pipeline/index.spec.tsx @@ -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 } }) => (
{nodes?.length ?? 0} {edges?.length ?? 0} @@ -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) => 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['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 }) diff --git a/web/app/components/rag-pipeline/store/index.spec.ts b/web/app/components/rag-pipeline/store/index.spec.ts index c8c0a35330..4d9c3fde27 100644 --- a/web/app/components/rag-pipeline/store/index.spec.ts +++ b/web/app/components/rag-pipeline/store/index.spec.ts @@ -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 +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 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 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 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 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 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 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 = { node1: { key: 'value' } } slice.setNodesDefaultConfigs(configs) - const setterFn = mockSet.mock.calls[0][0] + const setterFn = mockSet.mock.calls[0][0] as () => Partial 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 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 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 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 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 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 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 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') diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 9293446c0c..dc6befc106 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -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