diff --git a/web/__tests__/rag-pipeline/dsl-export-import-flow.test.ts b/web/__tests__/rag-pipeline/dsl-export-import-flow.test.ts new file mode 100644 index 0000000000..578552840d --- /dev/null +++ b/web/__tests__/rag-pipeline/dsl-export-import-flow.test.ts @@ -0,0 +1,179 @@ +/** + * Integration test: DSL export/import flow + * + * Validates DSL export logic (sync draft → check secrets → download) + * and DSL import modal state management. + */ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +const mockDoSyncWorkflowDraft = vi.fn().mockResolvedValue(undefined) +const mockExportPipelineConfig = vi.fn().mockResolvedValue({ data: 'yaml-content' }) +const mockNotify = vi.fn() +const mockEventEmitter = { emit: vi.fn() } +const mockDownloadBlob = vi.fn() + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/app/components/base/toast', () => ({ + useToastContext: () => ({ notify: mockNotify }), +})) + +vi.mock('@/app/components/workflow/constants', () => ({ + DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK', +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useWorkflowStore: () => ({ + getState: () => ({ + pipelineId: 'pipeline-abc', + knowledgeName: 'My Pipeline', + }), + }), +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: mockEventEmitter, + }), +})) + +vi.mock('@/service/use-pipeline', () => ({ + useExportPipelineDSL: () => ({ + mutateAsync: mockExportPipelineConfig, + }), +})) + +vi.mock('@/service/workflow', () => ({ + fetchWorkflowDraft: vi.fn(), +})) + +vi.mock('@/utils/download', () => ({ + downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args), +})) + +vi.mock('@/app/components/rag-pipeline/hooks/use-nodes-sync-draft', () => ({ + useNodesSyncDraft: () => ({ + doSyncWorkflowDraft: mockDoSyncWorkflowDraft, + }), +})) + +describe('DSL Export/Import Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Export Flow', () => { + it('should sync draft then export then download', async () => { + const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL') + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.handleExportDSL() + }) + + expect(mockDoSyncWorkflowDraft).toHaveBeenCalled() + expect(mockExportPipelineConfig).toHaveBeenCalledWith({ + pipelineId: 'pipeline-abc', + include: false, + }) + expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({ + fileName: 'My Pipeline.pipeline', + })) + }) + + it('should export with include flag when specified', async () => { + const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL') + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.handleExportDSL(true) + }) + + expect(mockExportPipelineConfig).toHaveBeenCalledWith({ + pipelineId: 'pipeline-abc', + include: true, + }) + }) + + it('should notify on export error', async () => { + mockDoSyncWorkflowDraft.mockRejectedValueOnce(new Error('sync failed')) + const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL') + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.handleExportDSL() + }) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + }) + + describe('Export Check Flow', () => { + it('should export directly when no secret environment variables', async () => { + const { fetchWorkflowDraft } = await import('@/service/workflow') + vi.mocked(fetchWorkflowDraft).mockResolvedValueOnce({ + environment_variables: [ + { value_type: 'string', key: 'API_URL', value: 'https://api.example.com' }, + ], + } as unknown as Awaited>) + + const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL') + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.exportCheck() + }) + + // Should proceed to export directly (no secret vars) + expect(mockDoSyncWorkflowDraft).toHaveBeenCalled() + }) + + it('should emit DSL_EXPORT_CHECK event when secret variables exist', async () => { + const { fetchWorkflowDraft } = await import('@/service/workflow') + vi.mocked(fetchWorkflowDraft).mockResolvedValueOnce({ + environment_variables: [ + { value_type: 'secret', key: 'API_KEY', value: '***' }, + ], + } as unknown as Awaited>) + + const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL') + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.exportCheck() + }) + + expect(mockEventEmitter.emit).toHaveBeenCalledWith(expect.objectContaining({ + type: 'DSL_EXPORT_CHECK', + payload: expect.objectContaining({ + data: expect.arrayContaining([ + expect.objectContaining({ value_type: 'secret' }), + ]), + }), + })) + }) + + it('should notify on export check error', async () => { + const { fetchWorkflowDraft } = await import('@/service/workflow') + vi.mocked(fetchWorkflowDraft).mockRejectedValueOnce(new Error('fetch failed')) + + const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL') + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.exportCheck() + }) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + }) +}) diff --git a/web/__tests__/rag-pipeline/input-field-crud-flow.test.ts b/web/__tests__/rag-pipeline/input-field-crud-flow.test.ts new file mode 100644 index 0000000000..233c9a288a --- /dev/null +++ b/web/__tests__/rag-pipeline/input-field-crud-flow.test.ts @@ -0,0 +1,278 @@ +/** + * Integration test: Input field CRUD complete flow + * + * Validates the full lifecycle of input fields: + * creation, editing, renaming, removal, and data conversion round-trip. + */ +import type { FormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/types' +import type { InputVar } from '@/models/pipeline' +import { describe, expect, it, vi } from 'vitest' +import { SupportUploadFileTypes } from '@/app/components/workflow/types' +import { PipelineInputVarType } from '@/models/pipeline' +import { TransferMethod } from '@/types/app' + +vi.mock('@/config', () => ({ + VAR_ITEM_TEMPLATE_IN_PIPELINE: { + type: 'text-input', + label: '', + variable: '', + max_length: 48, + default_value: undefined, + required: true, + tooltips: undefined, + options: [], + placeholder: undefined, + unit: undefined, + allowed_file_upload_methods: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + }, +})) + +describe('Input Field CRUD Flow', () => { + describe('Create → Edit → Convert Round-trip', () => { + it('should create a text field and roundtrip through form data', async () => { + const { convertToInputFieldFormData, convertFormDataToINputField } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', + ) + + // Create new field from template (no data passed) + const newFormData = convertToInputFieldFormData() + expect(newFormData.type).toBe('text-input') + expect(newFormData.variable).toBe('') + expect(newFormData.label).toBe('') + expect(newFormData.required).toBe(true) + + // Simulate user editing form data + const editedFormData: FormData = { + ...newFormData, + variable: 'user_name', + label: 'User Name', + maxLength: 100, + default: 'John', + tooltips: 'Enter your name', + placeholder: 'Type here...', + allowedTypesAndExtensions: {}, + } + + // Convert back to InputVar + const inputVar = convertFormDataToINputField(editedFormData) + + expect(inputVar.variable).toBe('user_name') + expect(inputVar.label).toBe('User Name') + expect(inputVar.max_length).toBe(100) + expect(inputVar.default_value).toBe('John') + expect(inputVar.tooltips).toBe('Enter your name') + expect(inputVar.placeholder).toBe('Type here...') + expect(inputVar.required).toBe(true) + }) + + it('should handle file field with upload settings', async () => { + const { convertToInputFieldFormData, convertFormDataToINputField } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', + ) + + const fileInputVar: InputVar = { + type: PipelineInputVarType.singleFile, + label: 'Upload Document', + variable: 'doc_file', + max_length: 1, + default_value: undefined, + required: true, + tooltips: 'Upload a PDF', + options: [], + placeholder: undefined, + unit: undefined, + allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], + allowed_file_types: [SupportUploadFileTypes.document], + allowed_file_extensions: ['.pdf', '.docx'], + } + + // Convert to form data + const formData = convertToInputFieldFormData(fileInputVar) + expect(formData.allowedFileUploadMethods).toEqual([TransferMethod.local_file, TransferMethod.remote_url]) + expect(formData.allowedTypesAndExtensions).toEqual({ + allowedFileTypes: [SupportUploadFileTypes.document], + allowedFileExtensions: ['.pdf', '.docx'], + }) + + // Round-trip back + const restored = convertFormDataToINputField(formData) + expect(restored.allowed_file_upload_methods).toEqual([TransferMethod.local_file, TransferMethod.remote_url]) + expect(restored.allowed_file_types).toEqual([SupportUploadFileTypes.document]) + expect(restored.allowed_file_extensions).toEqual(['.pdf', '.docx']) + }) + + it('should handle select field with options', async () => { + const { convertToInputFieldFormData, convertFormDataToINputField } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', + ) + + const selectVar: InputVar = { + type: PipelineInputVarType.select, + label: 'Priority', + variable: 'priority', + max_length: 0, + default_value: 'medium', + required: false, + tooltips: 'Select priority level', + options: ['low', 'medium', 'high'], + placeholder: 'Choose...', + unit: undefined, + allowed_file_upload_methods: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + } + + const formData = convertToInputFieldFormData(selectVar) + expect(formData.options).toEqual(['low', 'medium', 'high']) + expect(formData.default).toBe('medium') + + const restored = convertFormDataToINputField(formData) + expect(restored.options).toEqual(['low', 'medium', 'high']) + expect(restored.default_value).toBe('medium') + }) + + it('should handle number field with unit', async () => { + const { convertToInputFieldFormData, convertFormDataToINputField } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', + ) + + const numberVar: InputVar = { + type: PipelineInputVarType.number, + label: 'Max Tokens', + variable: 'max_tokens', + max_length: 0, + default_value: '1024', + required: true, + tooltips: undefined, + options: [], + placeholder: undefined, + unit: 'tokens', + allowed_file_upload_methods: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + } + + const formData = convertToInputFieldFormData(numberVar) + expect(formData.unit).toBe('tokens') + expect(formData.default).toBe('1024') + + const restored = convertFormDataToINputField(formData) + expect(restored.unit).toBe('tokens') + expect(restored.default_value).toBe('1024') + }) + }) + + describe('Omit optional fields', () => { + it('should not include tooltips when undefined', async () => { + const { convertToInputFieldFormData } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', + ) + + const inputVar: InputVar = { + type: PipelineInputVarType.textInput, + label: 'Test', + variable: 'test', + max_length: 48, + default_value: undefined, + required: true, + tooltips: undefined, + options: [], + placeholder: undefined, + unit: undefined, + allowed_file_upload_methods: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + } + + const formData = convertToInputFieldFormData(inputVar) + + // Optional fields should not be present + expect('tooltips' in formData).toBe(false) + expect('placeholder' in formData).toBe(false) + expect('unit' in formData).toBe(false) + expect('default' in formData).toBe(false) + }) + + it('should include optional fields when explicitly set to empty string', async () => { + const { convertToInputFieldFormData } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', + ) + + const inputVar: InputVar = { + type: PipelineInputVarType.textInput, + label: 'Test', + variable: 'test', + max_length: 48, + default_value: '', + required: true, + tooltips: '', + options: [], + placeholder: '', + unit: '', + allowed_file_upload_methods: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + } + + const formData = convertToInputFieldFormData(inputVar) + + expect(formData.default).toBe('') + expect(formData.tooltips).toBe('') + expect(formData.placeholder).toBe('') + expect(formData.unit).toBe('') + }) + }) + + describe('Multiple fields workflow', () => { + it('should process multiple fields independently', async () => { + const { convertToInputFieldFormData, convertFormDataToINputField } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', + ) + + const fields: InputVar[] = [ + { + type: PipelineInputVarType.textInput, + label: 'Name', + variable: 'name', + max_length: 48, + default_value: 'Alice', + required: true, + tooltips: undefined, + options: [], + placeholder: undefined, + unit: undefined, + allowed_file_upload_methods: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + }, + { + type: PipelineInputVarType.number, + label: 'Count', + variable: 'count', + max_length: 0, + default_value: '10', + required: false, + tooltips: undefined, + options: [], + placeholder: undefined, + unit: 'items', + allowed_file_upload_methods: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + }, + ] + + const formDataList = fields.map(f => convertToInputFieldFormData(f)) + const restoredFields = formDataList.map(fd => convertFormDataToINputField(fd)) + + expect(restoredFields).toHaveLength(2) + expect(restoredFields[0].variable).toBe('name') + expect(restoredFields[0].default_value).toBe('Alice') + expect(restoredFields[1].variable).toBe('count') + expect(restoredFields[1].default_value).toBe('10') + expect(restoredFields[1].unit).toBe('items') + }) + }) +}) diff --git a/web/__tests__/rag-pipeline/input-field-editor-flow.test.ts b/web/__tests__/rag-pipeline/input-field-editor-flow.test.ts index 5af27ee535..0fc4699aa8 100644 --- a/web/__tests__/rag-pipeline/input-field-editor-flow.test.ts +++ b/web/__tests__/rag-pipeline/input-field-editor-flow.test.ts @@ -178,7 +178,7 @@ describe('Input Field Editor Data Flow', () => { expect(restored.type).toBe('number') expect(restored.unit).toBe('°C') - expect(restored.default_value).toBe(0.7) + expect(restored.default_value).toBe('0.7') }) it('should preserve select options through roundtrip', () => { diff --git a/web/__tests__/rag-pipeline/test-run-flow.test.ts b/web/__tests__/rag-pipeline/test-run-flow.test.ts new file mode 100644 index 0000000000..9625e1c096 --- /dev/null +++ b/web/__tests__/rag-pipeline/test-run-flow.test.ts @@ -0,0 +1,282 @@ +/** + * Integration test: Test run end-to-end flow + * + * Validates the data flow through test-run preparation hooks: + * step navigation, datasource filtering, and data clearing. + */ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +// Use string literals inside vi.hoisted to avoid import-before-init +// BlockEnum.DataSource = 'datasource', BlockEnum.KnowledgeBase = 'knowledge-base' +const mockNodes = vi.hoisted(() => [ + { + id: 'ds-1', + data: { + type: 'datasource', + title: 'Local Files', + datasource_type: 'upload_file', + datasource_configurations: { datasource_label: 'Upload', upload_file_config: {} }, + }, + }, + { + id: 'ds-2', + data: { + type: 'datasource', + title: 'Web Crawl', + datasource_type: 'website_crawl', + datasource_configurations: { datasource_label: 'Crawl' }, + }, + }, + { + id: 'kb-1', + data: { + type: 'knowledge-base', + title: 'Knowledge Base', + }, + }, +]) + +vi.mock('reactflow', () => ({ + useNodes: () => mockNodes, +})) + +// Mock the Zustand store used by the hooks +const mockSetDocumentsData = vi.fn() +const mockSetSearchValue = vi.fn() +const mockSetSelectedPagesId = vi.fn() +const mockSetOnlineDocuments = vi.fn() +const mockSetCurrentDocument = vi.fn() +const mockSetStep = vi.fn() +const mockSetCrawlResult = vi.fn() +const mockSetWebsitePages = vi.fn() +const mockSetPreviewIndex = vi.fn() +const mockSetCurrentWebsite = vi.fn() +const mockSetOnlineDriveFileList = vi.fn() +const mockSetBucket = vi.fn() +const mockSetPrefix = vi.fn() +const mockSetKeywords = vi.fn() +const mockSetSelectedFileIds = vi.fn() + +vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/store', () => ({ + useDataSourceStore: () => ({ + getState: () => ({ + setDocumentsData: mockSetDocumentsData, + setSearchValue: mockSetSearchValue, + setSelectedPagesId: mockSetSelectedPagesId, + setOnlineDocuments: mockSetOnlineDocuments, + setCurrentDocument: mockSetCurrentDocument, + setStep: mockSetStep, + setCrawlResult: mockSetCrawlResult, + setWebsitePages: mockSetWebsitePages, + setPreviewIndex: mockSetPreviewIndex, + setCurrentWebsite: mockSetCurrentWebsite, + setOnlineDriveFileList: mockSetOnlineDriveFileList, + setBucket: mockSetBucket, + setPrefix: mockSetPrefix, + setKeywords: mockSetKeywords, + setSelectedFileIds: mockSetSelectedFileIds, + }), + }), +})) + +vi.mock('@/app/components/rag-pipeline/components/panel/test-run/types', () => ({ + TestRunStep: { + dataSource: 'data_source', + documentProcessing: 'document_processing', + }, +})) + +vi.mock('@/models/datasets', () => ({ + CrawlStep: { + init: 'init', + }, +})) + +describe('Test Run Flow Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Step Navigation', () => { + it('should start at step 1 and navigate forward', async () => { + const { useTestRunSteps } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useTestRunSteps()) + + expect(result.current.currentStep).toBe(1) + + act(() => { + result.current.handleNextStep() + }) + + expect(result.current.currentStep).toBe(2) + }) + + it('should navigate back from step 2 to step 1', async () => { + const { useTestRunSteps } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useTestRunSteps()) + + act(() => { + result.current.handleNextStep() + }) + expect(result.current.currentStep).toBe(2) + + act(() => { + result.current.handleBackStep() + }) + expect(result.current.currentStep).toBe(1) + }) + + it('should provide labeled steps', async () => { + const { useTestRunSteps } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useTestRunSteps()) + + expect(result.current.steps).toHaveLength(2) + expect(result.current.steps[0].value).toBe('data_source') + expect(result.current.steps[1].value).toBe('document_processing') + }) + }) + + describe('Datasource Options', () => { + it('should filter nodes to only DataSource type', async () => { + const { useDatasourceOptions } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useDatasourceOptions()) + + // Should only include DataSource nodes, not KnowledgeBase + expect(result.current).toHaveLength(2) + expect(result.current[0].value).toBe('ds-1') + expect(result.current[1].value).toBe('ds-2') + }) + + it('should include node data in options', async () => { + const { useDatasourceOptions } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useDatasourceOptions()) + + expect(result.current[0].label).toBe('Local Files') + expect(result.current[0].data.type).toBe('datasource') + }) + }) + + describe('Data Clearing Flow', () => { + it('should clear online document data', async () => { + const { useOnlineDocument } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useOnlineDocument()) + + act(() => { + result.current.clearOnlineDocumentData() + }) + + expect(mockSetDocumentsData).toHaveBeenCalledWith([]) + expect(mockSetSearchValue).toHaveBeenCalledWith('') + expect(mockSetSelectedPagesId).toHaveBeenCalledWith(expect.any(Set)) + expect(mockSetOnlineDocuments).toHaveBeenCalledWith([]) + expect(mockSetCurrentDocument).toHaveBeenCalledWith(undefined) + }) + + it('should clear website crawl data', async () => { + const { useWebsiteCrawl } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useWebsiteCrawl()) + + act(() => { + result.current.clearWebsiteCrawlData() + }) + + expect(mockSetStep).toHaveBeenCalledWith('init') + expect(mockSetCrawlResult).toHaveBeenCalledWith(undefined) + expect(mockSetCurrentWebsite).toHaveBeenCalledWith(undefined) + expect(mockSetWebsitePages).toHaveBeenCalledWith([]) + expect(mockSetPreviewIndex).toHaveBeenCalledWith(-1) + }) + + it('should clear online drive data', async () => { + const { useOnlineDrive } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useOnlineDrive()) + + act(() => { + result.current.clearOnlineDriveData() + }) + + expect(mockSetOnlineDriveFileList).toHaveBeenCalledWith([]) + expect(mockSetBucket).toHaveBeenCalledWith('') + expect(mockSetPrefix).toHaveBeenCalledWith([]) + expect(mockSetKeywords).toHaveBeenCalledWith('') + expect(mockSetSelectedFileIds).toHaveBeenCalledWith([]) + }) + }) + + describe('Full Flow Simulation', () => { + it('should support complete step navigation cycle', async () => { + const { useTestRunSteps } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useTestRunSteps()) + + // Start at step 1 + expect(result.current.currentStep).toBe(1) + + // Move to step 2 + act(() => { + result.current.handleNextStep() + }) + expect(result.current.currentStep).toBe(2) + + // Go back to step 1 + act(() => { + result.current.handleBackStep() + }) + expect(result.current.currentStep).toBe(1) + + // Move forward again + act(() => { + result.current.handleNextStep() + }) + expect(result.current.currentStep).toBe(2) + }) + + it('should not regress when clearing all data sources in sequence', async () => { + const { + useOnlineDocument, + useWebsiteCrawl, + useOnlineDrive, + } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result: docResult } = renderHook(() => useOnlineDocument()) + const { result: crawlResult } = renderHook(() => useWebsiteCrawl()) + const { result: driveResult } = renderHook(() => useOnlineDrive()) + + // Clear all data sources + act(() => { + docResult.current.clearOnlineDocumentData() + crawlResult.current.clearWebsiteCrawlData() + driveResult.current.clearOnlineDriveData() + }) + + expect(mockSetDocumentsData).toHaveBeenCalledWith([]) + expect(mockSetStep).toHaveBeenCalledWith('init') + expect(mockSetOnlineDriveFileList).toHaveBeenCalledWith([]) + }) + }) +}) diff --git a/web/app/components/rag-pipeline/components/chunk-card-list/chunk-card.spec.tsx b/web/app/components/rag-pipeline/components/chunk-card-list/chunk-card.spec.tsx new file mode 100644 index 0000000000..5db52237dd --- /dev/null +++ b/web/app/components/rag-pipeline/components/chunk-card-list/chunk-card.spec.tsx @@ -0,0 +1,218 @@ +import type { ParentChildChunk } from './types' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { ChunkingMode } from '@/models/datasets' + +import ChunkCard from './chunk-card' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => `${key}${opts?.count !== undefined ? `:${opts.count}` : ''}`, + }), +})) + +vi.mock('@/app/components/datasets/documents/detail/completed/common/dot', () => ({ + default: () => , +})) + +vi.mock('@/app/components/datasets/documents/detail/completed/common/segment-index-tag', () => ({ + default: ({ positionId, labelPrefix }: { positionId?: string | number, labelPrefix: string }) => ( + + {labelPrefix} + - + {positionId} + + ), +})) + +vi.mock('@/app/components/datasets/documents/detail/completed/common/summary-label', () => ({ + default: ({ summary }: { summary: string }) => {summary}, +})) + +vi.mock('@/app/components/datasets/formatted-text/flavours/preview-slice', () => ({ + PreviewSlice: ({ label, text }: { label: string, text: string }) => ( + + {label} + : + {' '} + {text} + + ), +})) + +vi.mock('@/models/datasets', () => ({ + ChunkingMode: { + text: 'text', + parentChild: 'parent-child', + qa: 'qa', + }, +})) + +vi.mock('@/utils/format', () => ({ + formatNumber: (n: number) => String(n), +})) + +vi.mock('./q-a-item', () => ({ + default: ({ type, text }: { type: string, text: string }) => ( + {text} + ), +})) + +vi.mock('./types', () => ({ + QAItemType: { + Question: 'question', + Answer: 'answer', + }, +})) + +const makeParentChildContent = (overrides: Partial = {}): ParentChildChunk => ({ + child_contents: ['Child'], + parent_content: '', + parent_summary: '', + parent_mode: 'paragraph', + ...overrides, +}) + +describe('ChunkCard', () => { + describe('Text mode', () => { + it('should render text content', () => { + render( + , + ) + + expect(screen.getByText('Hello world')).toBeInTheDocument() + }) + + it('should render segment index tag with Chunk prefix', () => { + render( + , + ) + + expect(screen.getByText('Chunk-5')).toBeInTheDocument() + }) + + it('should render word count', () => { + render( + , + ) + + expect(screen.getByText(/100/)).toBeInTheDocument() + }) + + it('should render summary when available', () => { + render( + , + ) + + expect(screen.getByTestId('summary')).toHaveTextContent('A summary') + }) + }) + + describe('Parent-Child mode (paragraph)', () => { + it('should render child contents as preview slices', () => { + render( + , + ) + + const slices = screen.getAllByTestId('preview-slice') + expect(slices).toHaveLength(2) + expect(slices[0]).toHaveTextContent('C-1: Child 1') + expect(slices[1]).toHaveTextContent('C-2: Child 2') + }) + + it('should render Parent-Chunk prefix for paragraph mode', () => { + render( + , + ) + + expect(screen.getByText('Parent-Chunk-2')).toBeInTheDocument() + }) + + it('should render parent summary', () => { + render( + , + ) + + expect(screen.getByTestId('summary')).toHaveTextContent('Overview') + }) + }) + + describe('Parent-Child mode (full-doc)', () => { + it('should hide segment tag in full-doc mode', () => { + render( + , + ) + + expect(screen.queryByTestId('segment-tag')).not.toBeInTheDocument() + }) + }) + + describe('QA mode', () => { + it('should render question and answer items', () => { + render( + , + ) + + expect(screen.getByTestId('qa-question')).toHaveTextContent('What is X?') + expect(screen.getByTestId('qa-answer')).toHaveTextContent('X is Y') + }) + }) +}) diff --git a/web/app/components/rag-pipeline/components/conversion.spec.tsx b/web/app/components/rag-pipeline/components/conversion.spec.tsx new file mode 100644 index 0000000000..8dac542428 --- /dev/null +++ b/web/app/components/rag-pipeline/components/conversion.spec.tsx @@ -0,0 +1,189 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import Conversion from './conversion' + +const mockConvert = vi.fn() +const mockInvalidDatasetDetail = vi.fn() + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('next/navigation', () => ({ + useParams: () => ({ datasetId: 'ds-123' }), +})) + +vi.mock('@/service/use-pipeline', () => ({ + useConvertDatasetToPipeline: () => ({ + mutateAsync: mockConvert, + isPending: false, + }), +})) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + datasetDetailQueryKeyPrefix: ['dataset-detail'], +})) + +vi.mock('@/service/use-base', () => ({ + useInvalid: () => mockInvalidDatasetDetail, +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { + notify: vi.fn(), + }, +})) + +vi.mock('@/app/components/base/button', () => ({ + default: ({ children, onClick, ...props }: Record) => ( + + ), +})) + +vi.mock('@/app/components/base/confirm', () => ({ + default: ({ + isShow, + onConfirm, + onCancel, + title, + }: { + isShow: boolean + onConfirm: () => void + onCancel: () => void + title: string + }) => + isShow + ? ( +
+ {title} + + +
+ ) + : null, +})) + +vi.mock('./screenshot', () => ({ + default: () =>
, +})) + +describe('Conversion', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should render conversion title and description', () => { + render() + + expect(screen.getByText('conversion.title')).toBeInTheDocument() + expect(screen.getByText('conversion.descriptionChunk1')).toBeInTheDocument() + expect(screen.getByText('conversion.descriptionChunk2')).toBeInTheDocument() + }) + + it('should render convert button', () => { + render() + + expect(screen.getByText('operations.convert')).toBeInTheDocument() + }) + + it('should render warning text', () => { + render() + + expect(screen.getByText('conversion.warning')).toBeInTheDocument() + }) + + it('should render screenshot component', () => { + render() + + expect(screen.getByTestId('screenshot')).toBeInTheDocument() + }) + + it('should show confirm modal when convert button clicked', () => { + render() + + expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument() + + fireEvent.click(screen.getByText('operations.convert')) + + expect(screen.getByTestId('confirm-modal')).toBeInTheDocument() + expect(screen.getByText('conversion.confirm.title')).toBeInTheDocument() + }) + + it('should hide confirm modal when cancel is clicked', () => { + render() + + fireEvent.click(screen.getByText('operations.convert')) + expect(screen.getByTestId('confirm-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('cancel-btn')) + expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument() + }) + + it('should call convert when confirm is clicked', () => { + render() + + fireEvent.click(screen.getByText('operations.convert')) + fireEvent.click(screen.getByTestId('confirm-btn')) + + expect(mockConvert).toHaveBeenCalledWith('ds-123', expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + })) + }) + + it('should handle successful conversion', async () => { + const Toast = await import('@/app/components/base/toast') + mockConvert.mockImplementation((_id: string, opts: { onSuccess: (res: { status: string }) => void }) => { + opts.onSuccess({ status: 'success' }) + }) + + render() + + fireEvent.click(screen.getByText('operations.convert')) + fireEvent.click(screen.getByTestId('confirm-btn')) + + expect(Toast.default.notify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + })) + expect(mockInvalidDatasetDetail).toHaveBeenCalled() + }) + + it('should handle failed conversion', async () => { + const Toast = await import('@/app/components/base/toast') + mockConvert.mockImplementation((_id: string, opts: { onSuccess: (res: { status: string }) => void }) => { + opts.onSuccess({ status: 'failed' }) + }) + + render() + + fireEvent.click(screen.getByText('operations.convert')) + fireEvent.click(screen.getByTestId('confirm-btn')) + + expect(Toast.default.notify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + + it('should handle conversion error', async () => { + const Toast = await import('@/app/components/base/toast') + mockConvert.mockImplementation((_id: string, opts: { onError: () => void }) => { + opts.onError() + }) + + render() + + fireEvent.click(screen.getByText('operations.convert')) + fireEvent.click(screen.getByTestId('confirm-btn')) + + expect(Toast.default.notify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) +}) diff --git a/web/app/components/rag-pipeline/components/publish-as-knowledge-pipeline-modal.spec.tsx b/web/app/components/rag-pipeline/components/publish-as-knowledge-pipeline-modal.spec.tsx new file mode 100644 index 0000000000..51b0e1d1b2 --- /dev/null +++ b/web/app/components/rag-pipeline/components/publish-as-knowledge-pipeline-modal.spec.tsx @@ -0,0 +1,251 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import PublishAsKnowledgePipelineModal from './publish-as-knowledge-pipeline-modal' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useWorkflowStore: () => ({ + getState: () => ({ + knowledgeName: 'Test Pipeline', + knowledgeIcon: { + icon_type: 'emoji', + icon: '🔧', + icon_background: '#fff', + icon_url: '', + }, + }), + }), +})) + +vi.mock('@/app/components/base/modal', () => ({ + default: ({ children, isShow }: { children: React.ReactNode, isShow: boolean }) => + isShow ?
{children}
: null, +})) + +vi.mock('@/app/components/base/button', () => ({ + default: ({ children, onClick, disabled, ...props }: Record) => ( + + ), +})) + +vi.mock('@/app/components/base/input', () => ({ + default: ({ value, onChange, ...props }: Record) => ( + void} + {...props} + /> + ), +})) + +vi.mock('@/app/components/base/textarea', () => ({ + default: ({ value, onChange, ...props }: Record) => ( +