From c904c58c432b25d804607790f2aea86f537e8866 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Tue, 16 Dec 2025 13:06:50 +0800 Subject: [PATCH] =?UTF-8?q?test:=20add=20unit=20tests=20for=20DocumentPick?= =?UTF-8?q?er,=20PreviewDocumentPicker,=20and=20R=E2=80=A6=20(#29695)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: CodingOnStar --- .../common/document-picker/index.spec.tsx | 1101 +++++++++++++++++ .../preview-document-picker.spec.tsx | 641 ++++++++++ .../retrieval-method-config/index.spec.tsx | 912 ++++++++++++++ 3 files changed, 2654 insertions(+) create mode 100644 web/app/components/datasets/common/document-picker/index.spec.tsx create mode 100644 web/app/components/datasets/common/document-picker/preview-document-picker.spec.tsx create mode 100644 web/app/components/datasets/common/retrieval-method-config/index.spec.tsx diff --git a/web/app/components/datasets/common/document-picker/index.spec.tsx b/web/app/components/datasets/common/document-picker/index.spec.tsx new file mode 100644 index 0000000000..3caa3d655b --- /dev/null +++ b/web/app/components/datasets/common/document-picker/index.spec.tsx @@ -0,0 +1,1101 @@ +import React from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen } from '@testing-library/react' +import type { ParentMode, SimpleDocumentDetail } from '@/models/datasets' +import { ChunkingMode, DataSourceType } from '@/models/datasets' +import DocumentPicker from './index' + +// Mock react-i18next +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +// Mock portal-to-follow-elem - always render content for testing +jest.mock('@/app/components/base/portal-to-follow-elem', () => ({ + PortalToFollowElem: ({ children, open }: { + children: React.ReactNode + open?: boolean + }) => ( +
+ {children} +
+ ), + PortalToFollowElemTrigger: ({ children, onClick }: { + children: React.ReactNode + onClick?: () => void + }) => ( +
+ {children} +
+ ), + // Always render content to allow testing document selection + PortalToFollowElemContent: ({ children, className }: { + children: React.ReactNode + className?: string + }) => ( +
+ {children} +
+ ), +})) + +// Mock useDocumentList hook with controllable return value +let mockDocumentListData: { data: SimpleDocumentDetail[] } | undefined +let mockDocumentListLoading = false + +jest.mock('@/service/knowledge/use-document', () => ({ + useDocumentList: jest.fn(() => ({ + data: mockDocumentListLoading ? undefined : mockDocumentListData, + isLoading: mockDocumentListLoading, + })), +})) + +// Mock icons - mock all remixicon components used in the component tree +jest.mock('@remixicon/react', () => ({ + RiArrowDownSLine: () => , + RiFile3Fill: () => 📄, + RiFileCodeFill: () => 📄, + RiFileExcelFill: () => 📄, + RiFileGifFill: () => 📄, + RiFileImageFill: () => 📄, + RiFileMusicFill: () => 📄, + RiFilePdf2Fill: () => 📄, + RiFilePpt2Fill: () => 📄, + RiFileTextFill: () => 📄, + RiFileVideoFill: () => 📄, + RiFileWordFill: () => 📄, + RiMarkdownFill: () => 📄, + RiSearchLine: () => 🔍, + RiCloseLine: () => , +})) + +jest.mock('@/app/components/base/icons/src/vender/knowledge', () => ({ + GeneralChunk: () => General, + ParentChildChunk: () => ParentChild, +})) + +// Factory function to create mock SimpleDocumentDetail +const createMockDocument = (overrides: Partial = {}): SimpleDocumentDetail => ({ + id: `doc-${Math.random().toString(36).substr(2, 9)}`, + batch: 'batch-1', + position: 1, + dataset_id: 'dataset-1', + data_source_type: DataSourceType.FILE, + data_source_info: { + upload_file: { + id: 'file-1', + name: 'test-file.txt', + size: 1024, + extension: 'txt', + mime_type: 'text/plain', + created_by: 'user-1', + created_at: Date.now(), + }, + // Required fields for LegacyDataSourceInfo + job_id: 'job-1', + url: '', + }, + dataset_process_rule_id: 'rule-1', + name: 'Test Document', + created_from: 'web', + created_by: 'user-1', + created_at: Date.now(), + indexing_status: 'completed', + display_status: 'enabled', + doc_form: ChunkingMode.text, + doc_language: 'en', + enabled: true, + word_count: 1000, + archived: false, + updated_at: Date.now(), + hit_count: 0, + data_source_detail_dict: { + upload_file: { + name: 'test-file.txt', + extension: 'txt', + }, + }, + ...overrides, +}) + +// Factory function to create multiple documents +const createMockDocumentList = (count: number): SimpleDocumentDetail[] => { + return Array.from({ length: count }, (_, index) => + createMockDocument({ + id: `doc-${index + 1}`, + name: `Document ${index + 1}`, + data_source_detail_dict: { + upload_file: { + name: `document-${index + 1}.pdf`, + extension: 'pdf', + }, + }, + }), + ) +} + +// Factory function to create props +const createDefaultProps = (overrides: Partial> = {}) => ({ + datasetId: 'dataset-1', + value: { + name: 'Test Document', + extension: 'txt', + chunkingMode: ChunkingMode.text, + parentMode: undefined as ParentMode | undefined, + }, + onChange: jest.fn(), + ...overrides, +}) + +// Create a new QueryClient for each test +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + staleTime: 0, + }, + }, + }) + +// Helper to render component with providers +const renderComponent = (props: Partial> = {}) => { + const queryClient = createTestQueryClient() + const defaultProps = createDefaultProps(props) + + return { + ...render( + + + , + ), + queryClient, + props: defaultProps, + } +} + +describe('DocumentPicker', () => { + beforeEach(() => { + jest.clearAllMocks() + // Reset mock state + mockDocumentListData = { data: createMockDocumentList(5) } + mockDocumentListLoading = false + }) + + // Tests for basic rendering + describe('Rendering', () => { + it('should render without crashing', () => { + renderComponent() + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should render document name when provided', () => { + renderComponent({ + value: { + name: 'My Document', + extension: 'pdf', + chunkingMode: ChunkingMode.text, + }, + }) + + expect(screen.getByText('My Document')).toBeInTheDocument() + }) + + it('should render placeholder when name is not provided', () => { + renderComponent({ + value: { + name: undefined, + extension: 'pdf', + chunkingMode: ChunkingMode.text, + }, + }) + + expect(screen.getByText('--')).toBeInTheDocument() + }) + + it('should render arrow icon', () => { + renderComponent() + + expect(screen.getByTestId('arrow-icon')).toBeInTheDocument() + }) + + it('should render GeneralChunk icon for text mode', () => { + renderComponent({ + value: { + name: 'Test', + extension: 'txt', + chunkingMode: ChunkingMode.text, + }, + }) + + expect(screen.getByTestId('general-chunk-icon')).toBeInTheDocument() + }) + + it('should render ParentChildChunk icon for parentChild mode', () => { + renderComponent({ + value: { + name: 'Test', + extension: 'txt', + chunkingMode: ChunkingMode.parentChild, + }, + }) + + expect(screen.getByTestId('parent-child-chunk-icon')).toBeInTheDocument() + }) + + it('should render GeneralChunk icon for QA mode', () => { + renderComponent({ + value: { + name: 'Test', + extension: 'txt', + chunkingMode: ChunkingMode.qa, + }, + }) + + expect(screen.getByTestId('general-chunk-icon')).toBeInTheDocument() + }) + + it('should render general mode label', () => { + renderComponent({ + value: { + name: 'Test', + extension: 'txt', + chunkingMode: ChunkingMode.text, + }, + }) + + expect(screen.getByText('dataset.chunkingMode.general')).toBeInTheDocument() + }) + + it('should render QA mode label', () => { + renderComponent({ + value: { + name: 'Test', + extension: 'txt', + chunkingMode: ChunkingMode.qa, + }, + }) + + expect(screen.getByText('dataset.chunkingMode.qa')).toBeInTheDocument() + }) + + it('should render parentChild mode label with paragraph parent mode', () => { + renderComponent({ + value: { + name: 'Test', + extension: 'txt', + chunkingMode: ChunkingMode.parentChild, + parentMode: 'paragraph', + }, + }) + + expect(screen.getByText(/dataset.chunkingMode.parentChild/)).toBeInTheDocument() + expect(screen.getByText(/dataset.parentMode.paragraph/)).toBeInTheDocument() + }) + + it('should render parentChild mode label with full-doc parent mode', () => { + renderComponent({ + value: { + name: 'Test', + extension: 'txt', + chunkingMode: ChunkingMode.parentChild, + parentMode: 'full-doc', + }, + }) + + expect(screen.getByText(/dataset.chunkingMode.parentChild/)).toBeInTheDocument() + expect(screen.getByText(/dataset.parentMode.fullDoc/)).toBeInTheDocument() + }) + + it('should render placeholder for parentMode when not provided', () => { + renderComponent({ + value: { + name: 'Test', + extension: 'txt', + chunkingMode: ChunkingMode.parentChild, + parentMode: undefined, + }, + }) + + // parentModeLabel should be '--' when parentMode is not provided + expect(screen.getByText(/--/)).toBeInTheDocument() + }) + }) + + // Tests for props handling + describe('Props', () => { + it('should accept required props', () => { + const onChange = jest.fn() + renderComponent({ + datasetId: 'test-dataset', + value: { + name: 'Test', + extension: 'txt', + chunkingMode: ChunkingMode.text, + }, + onChange, + }) + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should handle value with all fields', () => { + renderComponent({ + value: { + name: 'Full Document', + extension: 'docx', + chunkingMode: ChunkingMode.parentChild, + parentMode: 'paragraph', + }, + }) + + expect(screen.getByText('Full Document')).toBeInTheDocument() + }) + + it('should handle value with minimal fields', () => { + renderComponent({ + value: { + name: undefined, + extension: undefined, + chunkingMode: undefined, + parentMode: undefined, + }, + }) + + expect(screen.getByText('--')).toBeInTheDocument() + }) + + it('should pass datasetId to useDocumentList hook', () => { + const { useDocumentList } = jest.requireMock('@/service/knowledge/use-document') + + renderComponent({ datasetId: 'custom-dataset-id' }) + + expect(useDocumentList).toHaveBeenCalledWith( + expect.objectContaining({ + datasetId: 'custom-dataset-id', + }), + ) + }) + }) + + // Tests for state management and updates + describe('State Management', () => { + it('should initialize with popup closed', () => { + renderComponent() + + expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false') + }) + + it('should open popup when trigger is clicked', () => { + renderComponent() + + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + // Verify click handler is called + expect(trigger).toBeInTheDocument() + }) + + it('should maintain search query state', async () => { + renderComponent() + + const { useDocumentList } = jest.requireMock('@/service/knowledge/use-document') + + // Initial call should have empty keyword + expect(useDocumentList).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ + keyword: '', + }), + }), + ) + }) + + it('should update query when search input changes', () => { + renderComponent() + + // Verify the component uses useDocumentList with query parameter + const { useDocumentList } = jest.requireMock('@/service/knowledge/use-document') + expect(useDocumentList).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ + keyword: '', + }), + }), + ) + }) + }) + + // Tests for callback stability and memoization + describe('Callback Stability', () => { + it('should maintain stable onChange callback when value changes', () => { + const onChange = jest.fn() + const value1 = { + name: 'Doc 1', + extension: 'txt', + chunkingMode: ChunkingMode.text, + } + const value2 = { + name: 'Doc 2', + extension: 'pdf', + chunkingMode: ChunkingMode.text, + } + + const queryClient = createTestQueryClient() + const { rerender } = render( + + + , + ) + + rerender( + + + , + ) + + // Component should still render correctly after rerender + expect(screen.getByText('Doc 2')).toBeInTheDocument() + }) + + it('should use updated onChange callback after rerender', () => { + const onChange1 = jest.fn() + const onChange2 = jest.fn() + const value = { + name: 'Test Doc', + extension: 'txt', + chunkingMode: ChunkingMode.text, + } + + const queryClient = createTestQueryClient() + const { rerender } = render( + + + , + ) + + rerender( + + + , + ) + + // The component should use the new callback + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should memoize handleChange callback with useCallback', () => { + // The handleChange callback is created with useCallback and depends on + // documentsList, onChange, and setOpen + const onChange = jest.fn() + renderComponent({ onChange }) + + // Verify component renders correctly, callback memoization is internal + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + }) + + // Tests for memoization logic and dependencies + describe('Memoization Logic', () => { + it('should be wrapped with React.memo', () => { + // React.memo components have a $$typeof property + expect((DocumentPicker as any).$$typeof).toBeDefined() + }) + + it('should compute parentModeLabel correctly with useMemo', () => { + // Test paragraph mode + renderComponent({ + value: { + name: 'Test', + extension: 'txt', + chunkingMode: ChunkingMode.parentChild, + parentMode: 'paragraph', + }, + }) + + expect(screen.getByText(/dataset.parentMode.paragraph/)).toBeInTheDocument() + }) + + it('should update parentModeLabel when parentMode changes', () => { + // Test full-doc mode + renderComponent({ + value: { + name: 'Test', + extension: 'txt', + chunkingMode: ChunkingMode.parentChild, + parentMode: 'full-doc', + }, + }) + + expect(screen.getByText(/dataset.parentMode.fullDoc/)).toBeInTheDocument() + }) + + it('should not re-render when props are the same', () => { + const onChange = jest.fn() + const value = { + name: 'Stable Doc', + extension: 'txt', + chunkingMode: ChunkingMode.text, + } + + const queryClient = createTestQueryClient() + const { rerender } = render( + + + , + ) + + // Rerender with same props reference + rerender( + + + , + ) + + expect(screen.getByText('Stable Doc')).toBeInTheDocument() + }) + }) + + // Tests for user interactions and event handlers + describe('User Interactions', () => { + it('should toggle popup when trigger is clicked', () => { + renderComponent() + + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + // Trigger click should be handled + expect(trigger).toBeInTheDocument() + }) + + it('should handle document selection when popup is open', () => { + // Test the handleChange callback logic + const onChange = jest.fn() + const mockDocs = createMockDocumentList(3) + mockDocumentListData = { data: mockDocs } + + renderComponent({ onChange }) + + // The handleChange callback should find the document and call onChange + // We can verify this by checking that useDocumentList was called + const { useDocumentList } = jest.requireMock('@/service/knowledge/use-document') + expect(useDocumentList).toHaveBeenCalled() + }) + + it('should handle search input change', () => { + renderComponent() + + // The search input is only visible when popup is open + // We verify that the component initializes with empty query + const { useDocumentList } = jest.requireMock('@/service/knowledge/use-document') + expect(useDocumentList).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ + keyword: '', + }), + }), + ) + }) + + it('should initialize with default query parameters', () => { + renderComponent() + + const { useDocumentList } = jest.requireMock('@/service/knowledge/use-document') + expect(useDocumentList).toHaveBeenCalledWith( + expect.objectContaining({ + query: { + keyword: '', + page: 1, + limit: 20, + }, + }), + ) + }) + }) + + // Tests for API calls + describe('API Calls', () => { + it('should call useDocumentList with correct parameters', () => { + const { useDocumentList } = jest.requireMock('@/service/knowledge/use-document') + + renderComponent({ datasetId: 'test-dataset-123' }) + + expect(useDocumentList).toHaveBeenCalledWith({ + datasetId: 'test-dataset-123', + query: { + keyword: '', + page: 1, + limit: 20, + }, + }) + }) + + it('should handle loading state', () => { + mockDocumentListLoading = true + mockDocumentListData = undefined + + renderComponent() + + // When loading, component should still render without crashing + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should fetch documents on mount', () => { + mockDocumentListLoading = false + mockDocumentListData = { data: createMockDocumentList(3) } + + renderComponent() + + // Verify the hook was called + const { useDocumentList } = jest.requireMock('@/service/knowledge/use-document') + expect(useDocumentList).toHaveBeenCalled() + }) + + it('should handle empty document list', () => { + mockDocumentListData = { data: [] } + + renderComponent() + + // Component should render without crashing + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should handle undefined data response', () => { + mockDocumentListData = undefined + + renderComponent() + + // Should not crash + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + }) + + // Tests for component memoization + describe('Component Memoization', () => { + it('should export as React.memo wrapped component', () => { + // Check that the component is memoized + expect(DocumentPicker).toBeDefined() + expect(typeof DocumentPicker).toBe('object') // React.memo returns an object + }) + + it('should preserve render output when datasetId is the same', () => { + const queryClient = createTestQueryClient() + const value = { + name: 'Memo Test', + extension: 'txt', + chunkingMode: ChunkingMode.text, + } + const onChange = jest.fn() + + const { rerender } = render( + + + , + ) + + expect(screen.getByText('Memo Test')).toBeInTheDocument() + + rerender( + + + , + ) + + expect(screen.getByText('Memo Test')).toBeInTheDocument() + }) + }) + + // Tests for edge cases and error handling + describe('Edge Cases', () => { + it('should handle null name', () => { + renderComponent({ + value: { + name: undefined, + extension: 'txt', + chunkingMode: ChunkingMode.text, + }, + }) + + expect(screen.getByText('--')).toBeInTheDocument() + }) + + it('should handle empty string name', () => { + renderComponent({ + value: { + name: '', + extension: 'txt', + chunkingMode: ChunkingMode.text, + }, + }) + + // Empty string is falsy, so should show '--' + expect(screen.queryByText('--')).toBeInTheDocument() + }) + + it('should handle undefined extension', () => { + renderComponent({ + value: { + name: 'Test Doc', + extension: undefined, + chunkingMode: ChunkingMode.text, + }, + }) + + // Should not crash + expect(screen.getByText('Test Doc')).toBeInTheDocument() + }) + + it('should handle undefined chunkingMode', () => { + renderComponent({ + value: { + name: 'Test Doc', + extension: 'txt', + chunkingMode: undefined, + }, + }) + + // When chunkingMode is undefined, none of the mode conditions are true + expect(screen.getByText('Test Doc')).toBeInTheDocument() + }) + + it('should handle document without data_source_detail_dict', () => { + const docWithoutDetail = createMockDocument({ + id: 'doc-no-detail', + name: 'Doc Without Detail', + data_source_detail_dict: undefined, + }) + mockDocumentListData = { data: [docWithoutDetail] } + + // Component should handle mapping documents even without data_source_detail_dict + renderComponent() + + // Should not crash + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should handle rapid toggle clicks', () => { + renderComponent() + + const trigger = screen.getByTestId('portal-trigger') + + // Rapid clicks + fireEvent.click(trigger) + fireEvent.click(trigger) + fireEvent.click(trigger) + fireEvent.click(trigger) + + // Should not crash + expect(trigger).toBeInTheDocument() + }) + + it('should handle very long document names in trigger', () => { + const longName = 'A'.repeat(500) + renderComponent({ + value: { + name: longName, + extension: 'txt', + chunkingMode: ChunkingMode.text, + }, + }) + + // Should render long name without crashing + expect(screen.getByText(longName)).toBeInTheDocument() + }) + + it('should handle special characters in document name', () => { + const specialName = '' + renderComponent({ + value: { + name: specialName, + extension: 'txt', + chunkingMode: ChunkingMode.text, + }, + }) + + // React should escape the text + expect(screen.getByText(specialName)).toBeInTheDocument() + }) + + it('should handle documents with missing extension in data_source_detail_dict', () => { + const docWithEmptyExtension = createMockDocument({ + id: 'doc-empty-ext', + name: 'Doc Empty Ext', + data_source_detail_dict: { + upload_file: { + name: 'file-no-ext', + extension: '', + }, + }, + }) + mockDocumentListData = { data: [docWithEmptyExtension] } + + // Component should handle mapping documents with empty extension + renderComponent() + + // Should not crash + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should handle document list mapping with various data_source_detail_dict states', () => { + // Test the mapping logic: d.data_source_detail_dict?.upload_file?.extension || '' + const docs = [ + createMockDocument({ + id: 'doc-1', + name: 'With Extension', + data_source_detail_dict: { + upload_file: { name: 'file.pdf', extension: 'pdf' }, + }, + }), + createMockDocument({ + id: 'doc-2', + name: 'Without Detail Dict', + data_source_detail_dict: undefined, + }), + ] + mockDocumentListData = { data: docs } + + renderComponent() + + // Should not crash during mapping + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + }) + + // Tests for all prop variations + describe('Prop Variations', () => { + describe('datasetId variations', () => { + it('should handle empty datasetId', () => { + renderComponent({ datasetId: '' }) + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should handle UUID format datasetId', () => { + renderComponent({ datasetId: '123e4567-e89b-12d3-a456-426614174000' }) + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + }) + + describe('value.chunkingMode variations', () => { + const chunkingModes = [ + { mode: ChunkingMode.text, label: 'dataset.chunkingMode.general' }, + { mode: ChunkingMode.qa, label: 'dataset.chunkingMode.qa' }, + { mode: ChunkingMode.parentChild, label: 'dataset.chunkingMode.parentChild' }, + ] + + test.each(chunkingModes)( + 'should display correct label for $mode mode', + ({ mode, label }) => { + renderComponent({ + value: { + name: 'Test', + extension: 'txt', + chunkingMode: mode, + parentMode: mode === ChunkingMode.parentChild ? 'paragraph' : undefined, + }, + }) + + expect(screen.getByText(new RegExp(label))).toBeInTheDocument() + }, + ) + }) + + describe('value.parentMode variations', () => { + const parentModes: Array<{ mode: ParentMode; label: string }> = [ + { mode: 'paragraph', label: 'dataset.parentMode.paragraph' }, + { mode: 'full-doc', label: 'dataset.parentMode.fullDoc' }, + ] + + test.each(parentModes)( + 'should display correct label for $mode parentMode', + ({ mode, label }) => { + renderComponent({ + value: { + name: 'Test', + extension: 'txt', + chunkingMode: ChunkingMode.parentChild, + parentMode: mode, + }, + }) + + expect(screen.getByText(new RegExp(label))).toBeInTheDocument() + }, + ) + }) + + describe('value.extension variations', () => { + const extensions = ['txt', 'pdf', 'docx', 'xlsx', 'csv', 'md', 'html'] + + test.each(extensions)('should handle %s extension', (ext) => { + renderComponent({ + value: { + name: `File.${ext}`, + extension: ext, + chunkingMode: ChunkingMode.text, + }, + }) + + expect(screen.getByText(`File.${ext}`)).toBeInTheDocument() + }) + }) + }) + + // Tests for document selection + describe('Document Selection', () => { + it('should fetch documents list via useDocumentList', () => { + const mockDoc = createMockDocument({ + id: 'selected-doc', + name: 'Selected Document', + }) + mockDocumentListData = { data: [mockDoc] } + const onChange = jest.fn() + + renderComponent({ onChange }) + + // Verify the hook was called + const { useDocumentList } = jest.requireMock('@/service/knowledge/use-document') + expect(useDocumentList).toHaveBeenCalled() + }) + + it('should call onChange when document is selected', () => { + const docs = createMockDocumentList(3) + mockDocumentListData = { data: docs } + const onChange = jest.fn() + + renderComponent({ onChange }) + + // Click on a document in the list + fireEvent.click(screen.getByText('Document 2')) + + // handleChange should find the document and call onChange with full document + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith(docs[1]) + }) + + it('should map document list items correctly', () => { + const docs = createMockDocumentList(3) + mockDocumentListData = { data: docs } + + renderComponent() + + // Documents should be rendered in the list + expect(screen.getByText('Document 1')).toBeInTheDocument() + expect(screen.getByText('Document 2')).toBeInTheDocument() + expect(screen.getByText('Document 3')).toBeInTheDocument() + }) + }) + + // Tests for integration with child components + describe('Child Component Integration', () => { + it('should pass correct data to DocumentList when popup is open', () => { + const docs = createMockDocumentList(3) + mockDocumentListData = { data: docs } + + renderComponent() + + // DocumentList receives mapped documents: { id, name, extension } + // We verify the data is fetched + const { useDocumentList } = jest.requireMock('@/service/knowledge/use-document') + expect(useDocumentList).toHaveBeenCalled() + }) + + it('should map document data_source_detail_dict extension correctly', () => { + const doc = createMockDocument({ + id: 'mapped-doc', + name: 'Mapped Document', + data_source_detail_dict: { + upload_file: { + name: 'mapped.pdf', + extension: 'pdf', + }, + }, + }) + mockDocumentListData = { data: [doc] } + + renderComponent() + + // The mapping: d.data_source_detail_dict?.upload_file?.extension || '' + // Should extract 'pdf' from the document + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should render trigger with SearchInput integration', () => { + renderComponent() + + // The trigger is always rendered + expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + }) + + it('should integrate FileIcon component', () => { + // Use empty document list to avoid duplicate icons from list + mockDocumentListData = { data: [] } + + renderComponent({ + value: { + name: 'test.pdf', + extension: 'pdf', + chunkingMode: ChunkingMode.text, + }, + }) + + // FileIcon should be rendered via DocumentFileIcon - pdf renders pdf icon + expect(screen.getByTestId('file-pdf-icon')).toBeInTheDocument() + }) + }) + + // Tests for visual states + describe('Visual States', () => { + it('should apply hover styles on trigger', () => { + renderComponent() + + const trigger = screen.getByTestId('portal-trigger') + const clickableDiv = trigger.querySelector('div') + + expect(clickableDiv).toHaveClass('hover:bg-state-base-hover') + }) + + it('should render portal content for document selection', () => { + renderComponent() + + // Portal content is rendered in our mock for testing + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/common/document-picker/preview-document-picker.spec.tsx b/web/app/components/datasets/common/document-picker/preview-document-picker.spec.tsx new file mode 100644 index 0000000000..e6900d23db --- /dev/null +++ b/web/app/components/datasets/common/document-picker/preview-document-picker.spec.tsx @@ -0,0 +1,641 @@ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import type { DocumentItem } from '@/models/datasets' +import PreviewDocumentPicker from './preview-document-picker' + +// Mock react-i18next +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => { + if (key === 'dataset.preprocessDocument' && params?.num) + return `${params.num} files` + + return key + }, + }), +})) + +// Mock portal-to-follow-elem - always render content for testing +jest.mock('@/app/components/base/portal-to-follow-elem', () => ({ + PortalToFollowElem: ({ children, open }: { + children: React.ReactNode + open?: boolean + }) => ( +
+ {children} +
+ ), + PortalToFollowElemTrigger: ({ children, onClick }: { + children: React.ReactNode + onClick?: () => void + }) => ( +
+ {children} +
+ ), + // Always render content to allow testing document selection + PortalToFollowElemContent: ({ children, className }: { + children: React.ReactNode + className?: string + }) => ( +
+ {children} +
+ ), +})) + +// Mock icons +jest.mock('@remixicon/react', () => ({ + RiArrowDownSLine: () => , + RiFile3Fill: () => 📄, + RiFileCodeFill: () => 📄, + RiFileExcelFill: () => 📄, + RiFileGifFill: () => 📄, + RiFileImageFill: () => 📄, + RiFileMusicFill: () => 📄, + RiFilePdf2Fill: () => 📄, + RiFilePpt2Fill: () => 📄, + RiFileTextFill: () => 📄, + RiFileVideoFill: () => 📄, + RiFileWordFill: () => 📄, + RiMarkdownFill: () => 📄, +})) + +// Factory function to create mock DocumentItem +const createMockDocumentItem = (overrides: Partial = {}): DocumentItem => ({ + id: `doc-${Math.random().toString(36).substr(2, 9)}`, + name: 'Test Document', + extension: 'txt', + ...overrides, +}) + +// Factory function to create multiple document items +const createMockDocumentList = (count: number): DocumentItem[] => { + return Array.from({ length: count }, (_, index) => + createMockDocumentItem({ + id: `doc-${index + 1}`, + name: `Document ${index + 1}`, + extension: index % 2 === 0 ? 'pdf' : 'txt', + }), + ) +} + +// Factory function to create default props +const createDefaultProps = (overrides: Partial> = {}) => ({ + value: createMockDocumentItem({ id: 'selected-doc', name: 'Selected Document' }), + files: createMockDocumentList(3), + onChange: jest.fn(), + ...overrides, +}) + +// Helper to render component with default props +const renderComponent = (props: Partial> = {}) => { + const defaultProps = createDefaultProps(props) + return { + ...render(), + props: defaultProps, + } +} + +describe('PreviewDocumentPicker', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + // Tests for basic rendering + describe('Rendering', () => { + it('should render without crashing', () => { + renderComponent() + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should render document name from value prop', () => { + renderComponent({ + value: createMockDocumentItem({ name: 'My Document' }), + }) + + expect(screen.getByText('My Document')).toBeInTheDocument() + }) + + it('should render placeholder when name is empty', () => { + renderComponent({ + value: createMockDocumentItem({ name: '' }), + }) + + expect(screen.getByText('--')).toBeInTheDocument() + }) + + it('should render placeholder when name is undefined', () => { + renderComponent({ + value: { id: 'doc-1', extension: 'txt' } as DocumentItem, + }) + + expect(screen.getByText('--')).toBeInTheDocument() + }) + + it('should render arrow icon', () => { + renderComponent() + + expect(screen.getByTestId('arrow-icon')).toBeInTheDocument() + }) + + it('should render file icon', () => { + renderComponent({ + value: createMockDocumentItem({ extension: 'txt' }), + files: [], // Use empty files to avoid duplicate icons + }) + + expect(screen.getByTestId('file-text-icon')).toBeInTheDocument() + }) + + it('should render pdf icon for pdf extension', () => { + renderComponent({ + value: createMockDocumentItem({ extension: 'pdf' }), + files: [], // Use empty files to avoid duplicate icons + }) + + expect(screen.getByTestId('file-pdf-icon')).toBeInTheDocument() + }) + }) + + // Tests for props handling + describe('Props', () => { + it('should accept required props', () => { + const props = createDefaultProps() + render() + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should apply className to trigger element', () => { + renderComponent({ className: 'custom-class' }) + + const trigger = screen.getByTestId('portal-trigger') + const innerDiv = trigger.querySelector('.custom-class') + expect(innerDiv).toBeInTheDocument() + }) + + it('should handle empty files array', () => { + // Component should render without crashing with empty files + renderComponent({ files: [] }) + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should handle single file', () => { + // Component should accept single file + renderComponent({ + files: [createMockDocumentItem({ id: 'single-doc', name: 'Single File' })], + }) + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should handle multiple files', () => { + // Component should accept multiple files + renderComponent({ + files: createMockDocumentList(5), + }) + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should use value.extension for file icon', () => { + renderComponent({ + value: createMockDocumentItem({ name: 'test.docx', extension: 'docx' }), + }) + + expect(screen.getByTestId('file-word-icon')).toBeInTheDocument() + }) + }) + + // Tests for state management + describe('State Management', () => { + it('should initialize with popup closed', () => { + renderComponent() + + expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false') + }) + + it('should toggle popup when trigger is clicked', () => { + renderComponent() + + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + expect(trigger).toBeInTheDocument() + }) + + it('should render portal content for document selection', () => { + renderComponent() + + // Portal content is always rendered in our mock for testing + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + }) + + // Tests for callback stability and memoization + describe('Callback Stability', () => { + it('should maintain stable onChange callback when value changes', () => { + const onChange = jest.fn() + const value1 = createMockDocumentItem({ id: 'doc-1', name: 'Doc 1' }) + const value2 = createMockDocumentItem({ id: 'doc-2', name: 'Doc 2' }) + + const { rerender } = render( + , + ) + + rerender( + , + ) + + expect(screen.getByText('Doc 2')).toBeInTheDocument() + }) + + it('should use updated onChange callback after rerender', () => { + const onChange1 = jest.fn() + const onChange2 = jest.fn() + const value = createMockDocumentItem() + const files = createMockDocumentList(3) + + const { rerender } = render( + , + ) + + rerender( + , + ) + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + }) + + // Tests for component memoization + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + expect((PreviewDocumentPicker as any).$$typeof).toBeDefined() + }) + + it('should not re-render when props are the same', () => { + const onChange = jest.fn() + const value = createMockDocumentItem() + const files = createMockDocumentList(3) + + const { rerender } = render( + , + ) + + rerender( + , + ) + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + }) + + // Tests for user interactions + describe('User Interactions', () => { + it('should toggle popup when trigger is clicked', () => { + renderComponent() + + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + expect(trigger).toBeInTheDocument() + }) + + it('should render document list with files', () => { + const files = createMockDocumentList(3) + renderComponent({ files }) + + // Documents should be visible in the list + expect(screen.getByText('Document 1')).toBeInTheDocument() + expect(screen.getByText('Document 2')).toBeInTheDocument() + expect(screen.getByText('Document 3')).toBeInTheDocument() + }) + + it('should call onChange when document is selected', () => { + const onChange = jest.fn() + const files = createMockDocumentList(3) + + renderComponent({ files, onChange }) + + // Click on a document + fireEvent.click(screen.getByText('Document 2')) + + // handleChange should call onChange with the selected item + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith(files[1]) + }) + + it('should handle rapid toggle clicks', () => { + renderComponent() + + const trigger = screen.getByTestId('portal-trigger') + + // Rapid clicks + fireEvent.click(trigger) + fireEvent.click(trigger) + fireEvent.click(trigger) + fireEvent.click(trigger) + + expect(trigger).toBeInTheDocument() + }) + }) + + // Tests for edge cases + describe('Edge Cases', () => { + it('should handle null value properties gracefully', () => { + renderComponent({ + value: { id: 'doc-1', name: '', extension: '' }, + }) + + expect(screen.getByText('--')).toBeInTheDocument() + }) + + it('should handle empty files array', () => { + renderComponent({ files: [] }) + + // Component should render without crashing + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should handle very long document names', () => { + const longName = 'A'.repeat(500) + renderComponent({ + value: createMockDocumentItem({ name: longName }), + }) + + expect(screen.getByText(longName)).toBeInTheDocument() + }) + + it('should handle special characters in document name', () => { + const specialName = '' + renderComponent({ + value: createMockDocumentItem({ name: specialName }), + }) + + expect(screen.getByText(specialName)).toBeInTheDocument() + }) + + it('should handle undefined files prop', () => { + // Test edge case where files might be undefined at runtime + const props = createDefaultProps() + // @ts-expect-error - Testing runtime edge case + props.files = undefined + + render() + + // Component should render without crashing + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should handle large number of files', () => { + const manyFiles = createMockDocumentList(100) + renderComponent({ files: manyFiles }) + + // Component should accept large files array + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should handle files with same name but different extensions', () => { + const files = [ + createMockDocumentItem({ id: 'doc-1', name: 'document', extension: 'pdf' }), + createMockDocumentItem({ id: 'doc-2', name: 'document', extension: 'txt' }), + ] + renderComponent({ files }) + + // Component should handle duplicate names + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + }) + + // Tests for prop variations + describe('Prop Variations', () => { + describe('value variations', () => { + it('should handle value with all fields', () => { + renderComponent({ + value: { + id: 'full-doc', + name: 'Full Document', + extension: 'pdf', + }, + }) + + expect(screen.getByText('Full Document')).toBeInTheDocument() + }) + + it('should handle value with minimal fields', () => { + renderComponent({ + value: { id: 'minimal', name: '', extension: '' }, + }) + + expect(screen.getByText('--')).toBeInTheDocument() + }) + }) + + describe('files variations', () => { + it('should handle single file', () => { + renderComponent({ + files: [createMockDocumentItem({ name: 'Single' })], + }) + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should handle two files', () => { + renderComponent({ + files: createMockDocumentList(2), + }) + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should handle many files', () => { + renderComponent({ + files: createMockDocumentList(50), + }) + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + }) + + describe('className variations', () => { + it('should apply custom className', () => { + renderComponent({ className: 'my-custom-class' }) + + const trigger = screen.getByTestId('portal-trigger') + expect(trigger.querySelector('.my-custom-class')).toBeInTheDocument() + }) + + it('should work without className', () => { + renderComponent({ className: undefined }) + + expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + }) + + it('should handle multiple class names', () => { + renderComponent({ className: 'class-one class-two' }) + + const trigger = screen.getByTestId('portal-trigger') + const element = trigger.querySelector('.class-one') + expect(element).toBeInTheDocument() + expect(element).toHaveClass('class-two') + }) + }) + + describe('extension variations', () => { + const extensions = [ + { ext: 'txt', icon: 'file-text-icon' }, + { ext: 'pdf', icon: 'file-pdf-icon' }, + { ext: 'docx', icon: 'file-word-icon' }, + { ext: 'xlsx', icon: 'file-excel-icon' }, + { ext: 'md', icon: 'file-markdown-icon' }, + ] + + test.each(extensions)('should render correct icon for $ext extension', ({ ext, icon }) => { + renderComponent({ + value: createMockDocumentItem({ extension: ext }), + files: [], // Use empty files to avoid duplicate icons + }) + + expect(screen.getByTestId(icon)).toBeInTheDocument() + }) + }) + }) + + // Tests for document list rendering + describe('Document List Rendering', () => { + it('should render all documents in the list', () => { + const files = createMockDocumentList(5) + renderComponent({ files }) + + // All documents should be visible + files.forEach((file) => { + expect(screen.getByText(file.name)).toBeInTheDocument() + }) + }) + + it('should pass onChange handler to DocumentList', () => { + const onChange = jest.fn() + const files = createMockDocumentList(3) + + renderComponent({ files, onChange }) + + // Click on first document + fireEvent.click(screen.getByText('Document 1')) + + expect(onChange).toHaveBeenCalledWith(files[0]) + }) + + it('should show count header only for multiple files', () => { + // Single file - no header + const { rerender } = render( + , + ) + expect(screen.queryByText(/files/)).not.toBeInTheDocument() + + // Multiple files - show header + rerender( + , + ) + expect(screen.getByText('3 files')).toBeInTheDocument() + }) + }) + + // Tests for visual states + describe('Visual States', () => { + it('should apply hover styles on trigger', () => { + renderComponent() + + const trigger = screen.getByTestId('portal-trigger') + const innerDiv = trigger.querySelector('.hover\\:bg-state-base-hover') + expect(innerDiv).toBeInTheDocument() + }) + + it('should have truncate class for long names', () => { + renderComponent({ + value: createMockDocumentItem({ name: 'Very Long Document Name' }), + }) + + const nameElement = screen.getByText('Very Long Document Name') + expect(nameElement).toHaveClass('truncate') + }) + + it('should have max-width on name element', () => { + renderComponent({ + value: createMockDocumentItem({ name: 'Test' }), + }) + + const nameElement = screen.getByText('Test') + expect(nameElement).toHaveClass('max-w-[200px]') + }) + }) + + // Tests for handleChange callback + describe('handleChange Callback', () => { + it('should call onChange with selected document item', () => { + const onChange = jest.fn() + const files = createMockDocumentList(3) + + renderComponent({ files, onChange }) + + // Click first document + fireEvent.click(screen.getByText('Document 1')) + + expect(onChange).toHaveBeenCalledWith(files[0]) + }) + + it('should handle different document items in files', () => { + const onChange = jest.fn() + const customFiles = [ + { id: 'custom-1', name: 'Custom File 1', extension: 'pdf' }, + { id: 'custom-2', name: 'Custom File 2', extension: 'txt' }, + ] + + renderComponent({ files: customFiles, onChange }) + + // Click on first custom file + fireEvent.click(screen.getByText('Custom File 1')) + expect(onChange).toHaveBeenCalledWith(customFiles[0]) + + // Click on second custom file + fireEvent.click(screen.getByText('Custom File 2')) + expect(onChange).toHaveBeenCalledWith(customFiles[1]) + }) + + it('should work with multiple sequential selections', () => { + const onChange = jest.fn() + const files = createMockDocumentList(3) + + renderComponent({ files, onChange }) + + // Select multiple documents sequentially + fireEvent.click(screen.getByText('Document 1')) + fireEvent.click(screen.getByText('Document 3')) + fireEvent.click(screen.getByText('Document 2')) + + expect(onChange).toHaveBeenCalledTimes(3) + expect(onChange).toHaveBeenNthCalledWith(1, files[0]) + expect(onChange).toHaveBeenNthCalledWith(2, files[2]) + expect(onChange).toHaveBeenNthCalledWith(3, files[1]) + }) + }) +}) diff --git a/web/app/components/datasets/common/retrieval-method-config/index.spec.tsx b/web/app/components/datasets/common/retrieval-method-config/index.spec.tsx new file mode 100644 index 0000000000..be509f1c6e --- /dev/null +++ b/web/app/components/datasets/common/retrieval-method-config/index.spec.tsx @@ -0,0 +1,912 @@ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import type { RetrievalConfig } from '@/types/app' +import { RETRIEVE_METHOD } from '@/types/app' +import { + DEFAULT_WEIGHTED_SCORE, + RerankingModeEnum, + WeightedScoreEnum, +} from '@/models/datasets' +import RetrievalMethodConfig from './index' + +// Mock react-i18next +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +// Mock provider context with controllable supportRetrievalMethods +let mockSupportRetrievalMethods: RETRIEVE_METHOD[] = [ + RETRIEVE_METHOD.semantic, + RETRIEVE_METHOD.fullText, + RETRIEVE_METHOD.hybrid, +] + +jest.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + supportRetrievalMethods: mockSupportRetrievalMethods, + }), +})) + +// Mock model hooks with controllable return values +let mockRerankDefaultModel: { provider: { provider: string }; model: string } | undefined = { + provider: { provider: 'test-provider' }, + model: 'test-rerank-model', +} +let mockIsRerankDefaultModelValid: boolean | undefined = true + +jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({ + defaultModel: mockRerankDefaultModel, + currentModel: mockIsRerankDefaultModelValid, + }), +})) + +// Mock child component RetrievalParamConfig to simplify testing +jest.mock('../retrieval-param-config', () => ({ + __esModule: true, + default: ({ type, value, onChange, showMultiModalTip }: { + type: RETRIEVE_METHOD + value: RetrievalConfig + onChange: (v: RetrievalConfig) => void + showMultiModalTip?: boolean + }) => ( +
+ {type} + {String(showMultiModalTip)} + +
+ ), +})) + +// Factory function to create mock RetrievalConfig +const createMockRetrievalConfig = (overrides: Partial = {}): RetrievalConfig => ({ + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + top_k: 4, + score_threshold_enabled: false, + score_threshold: 0.5, + ...overrides, +}) + +// Helper to render component with default props +const renderComponent = (props: Partial> = {}) => { + const defaultProps = { + value: createMockRetrievalConfig(), + onChange: jest.fn(), + } + return render() +} + +describe('RetrievalMethodConfig', () => { + beforeEach(() => { + jest.clearAllMocks() + // Reset mock values to defaults + mockSupportRetrievalMethods = [ + RETRIEVE_METHOD.semantic, + RETRIEVE_METHOD.fullText, + RETRIEVE_METHOD.hybrid, + ] + mockRerankDefaultModel = { + provider: { provider: 'test-provider' }, + model: 'test-rerank-model', + } + mockIsRerankDefaultModelValid = true + }) + + // Tests for basic rendering + describe('Rendering', () => { + it('should render without crashing', () => { + renderComponent() + + expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument() + }) + + it('should render all three retrieval methods when all are supported', () => { + renderComponent() + + expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument() + expect(screen.getByText('dataset.retrieval.full_text_search.title')).toBeInTheDocument() + expect(screen.getByText('dataset.retrieval.hybrid_search.title')).toBeInTheDocument() + }) + + it('should render descriptions for all retrieval methods', () => { + renderComponent() + + expect(screen.getByText('dataset.retrieval.semantic_search.description')).toBeInTheDocument() + expect(screen.getByText('dataset.retrieval.full_text_search.description')).toBeInTheDocument() + expect(screen.getByText('dataset.retrieval.hybrid_search.description')).toBeInTheDocument() + }) + + it('should only render semantic search when only semantic is supported', () => { + mockSupportRetrievalMethods = [RETRIEVE_METHOD.semantic] + renderComponent() + + expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument() + expect(screen.queryByText('dataset.retrieval.full_text_search.title')).not.toBeInTheDocument() + expect(screen.queryByText('dataset.retrieval.hybrid_search.title')).not.toBeInTheDocument() + }) + + it('should only render fullText search when only fullText is supported', () => { + mockSupportRetrievalMethods = [RETRIEVE_METHOD.fullText] + renderComponent() + + expect(screen.queryByText('dataset.retrieval.semantic_search.title')).not.toBeInTheDocument() + expect(screen.getByText('dataset.retrieval.full_text_search.title')).toBeInTheDocument() + expect(screen.queryByText('dataset.retrieval.hybrid_search.title')).not.toBeInTheDocument() + }) + + it('should only render hybrid search when only hybrid is supported', () => { + mockSupportRetrievalMethods = [RETRIEVE_METHOD.hybrid] + renderComponent() + + expect(screen.queryByText('dataset.retrieval.semantic_search.title')).not.toBeInTheDocument() + expect(screen.queryByText('dataset.retrieval.full_text_search.title')).not.toBeInTheDocument() + expect(screen.getByText('dataset.retrieval.hybrid_search.title')).toBeInTheDocument() + }) + + it('should render nothing when no retrieval methods are supported', () => { + mockSupportRetrievalMethods = [] + const { container } = renderComponent() + + // Only the wrapper div should exist + expect(container.firstChild?.childNodes.length).toBe(0) + }) + + it('should show RetrievalParamConfig for the active method', () => { + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }), + }) + + expect(screen.getByTestId('retrieval-param-config-semantic_search')).toBeInTheDocument() + expect(screen.queryByTestId('retrieval-param-config-full_text_search')).not.toBeInTheDocument() + expect(screen.queryByTestId('retrieval-param-config-hybrid_search')).not.toBeInTheDocument() + }) + + it('should show RetrievalParamConfig for fullText when active', () => { + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.fullText }), + }) + + expect(screen.queryByTestId('retrieval-param-config-semantic_search')).not.toBeInTheDocument() + expect(screen.getByTestId('retrieval-param-config-full_text_search')).toBeInTheDocument() + expect(screen.queryByTestId('retrieval-param-config-hybrid_search')).not.toBeInTheDocument() + }) + + it('should show RetrievalParamConfig for hybrid when active', () => { + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.hybrid }), + }) + + expect(screen.queryByTestId('retrieval-param-config-semantic_search')).not.toBeInTheDocument() + expect(screen.queryByTestId('retrieval-param-config-full_text_search')).not.toBeInTheDocument() + expect(screen.getByTestId('retrieval-param-config-hybrid_search')).toBeInTheDocument() + }) + }) + + // Tests for props handling + describe('Props', () => { + it('should pass showMultiModalTip to RetrievalParamConfig', () => { + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }), + showMultiModalTip: true, + }) + + expect(screen.getByTestId('param-config-multimodal-tip')).toHaveTextContent('true') + }) + + it('should default showMultiModalTip to false', () => { + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }), + }) + + expect(screen.getByTestId('param-config-multimodal-tip')).toHaveTextContent('false') + }) + + it('should apply disabled state to option cards', () => { + renderComponent({ disabled: true }) + + // When disabled, clicking should not trigger onChange + const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor"]') + expect(semanticOption).toHaveClass('cursor-not-allowed') + }) + + it('should default disabled to false', () => { + renderComponent() + + const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor"]') + expect(semanticOption).not.toHaveClass('cursor-not-allowed') + }) + }) + + // Tests for user interactions and event handlers + describe('User Interactions', () => { + it('should call onChange when switching to semantic search', () => { + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.fullText }), + onChange, + }) + + const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(semanticOption!) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: true, + }), + ) + }) + + it('should call onChange when switching to fullText search', () => { + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }), + onChange, + }) + + const fullTextOption = screen.getByText('dataset.retrieval.full_text_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(fullTextOption!) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + search_method: RETRIEVE_METHOD.fullText, + reranking_enable: true, + }), + ) + }) + + it('should call onChange when switching to hybrid search', () => { + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }), + onChange, + }) + + const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(hybridOption!) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + search_method: RETRIEVE_METHOD.hybrid, + reranking_enable: true, + }), + ) + }) + + it('should not call onChange when clicking the already active method', () => { + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }), + onChange, + }) + + const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(semanticOption!) + + expect(onChange).not.toHaveBeenCalled() + }) + + it('should not call onChange when disabled', () => { + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }), + onChange, + disabled: true, + }) + + const fullTextOption = screen.getByText('dataset.retrieval.full_text_search.title').closest('div[class*="cursor"]') + fireEvent.click(fullTextOption!) + + expect(onChange).not.toHaveBeenCalled() + }) + + it('should propagate onChange from RetrievalParamConfig', () => { + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }), + onChange, + }) + + const updateButton = screen.getByTestId('update-top-k-semantic_search') + fireEvent.click(updateButton) + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + top_k: 10, + }), + ) + }) + }) + + // Tests for reranking model configuration + describe('Reranking Model Configuration', () => { + it('should set reranking model when switching to semantic and model is valid', () => { + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ + search_method: RETRIEVE_METHOD.fullText, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + }), + onChange, + }) + + const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(semanticOption!) + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + reranking_model: { + reranking_provider_name: 'test-provider', + reranking_model_name: 'test-rerank-model', + }, + reranking_enable: true, + }), + ) + }) + + it('should preserve existing reranking model when switching', () => { + const onChange = jest.fn() + const existingModel = { + reranking_provider_name: 'existing-provider', + reranking_model_name: 'existing-model', + } + renderComponent({ + value: createMockRetrievalConfig({ + search_method: RETRIEVE_METHOD.fullText, + reranking_model: existingModel, + }), + onChange, + }) + + const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(semanticOption!) + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + reranking_model: existingModel, + reranking_enable: true, + }), + ) + }) + + it('should set reranking_enable to false when no valid model', () => { + mockIsRerankDefaultModelValid = false + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ + search_method: RETRIEVE_METHOD.fullText, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + }), + onChange, + }) + + const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(semanticOption!) + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + reranking_enable: false, + }), + ) + }) + + it('should set reranking_mode for hybrid search', () => { + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ + search_method: RETRIEVE_METHOD.semantic, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + }), + onChange, + }) + + const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(hybridOption!) + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + search_method: RETRIEVE_METHOD.hybrid, + reranking_mode: RerankingModeEnum.RerankingModel, + }), + ) + }) + + it('should set weighted score mode when no valid rerank model for hybrid', () => { + mockIsRerankDefaultModelValid = false + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ + search_method: RETRIEVE_METHOD.semantic, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + }), + onChange, + }) + + const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(hybridOption!) + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + reranking_mode: RerankingModeEnum.WeightedScore, + }), + ) + }) + + it('should set default weights for hybrid search when no existing weights', () => { + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ + search_method: RETRIEVE_METHOD.semantic, + weights: undefined, + }), + onChange, + }) + + const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(hybridOption!) + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + weights: { + weight_type: WeightedScoreEnum.Customized, + vector_setting: { + vector_weight: DEFAULT_WEIGHTED_SCORE.other.semantic, + embedding_provider_name: '', + embedding_model_name: '', + }, + keyword_setting: { + keyword_weight: DEFAULT_WEIGHTED_SCORE.other.keyword, + }, + }, + }), + ) + }) + + it('should preserve existing weights for hybrid search', () => { + const existingWeights = { + weight_type: WeightedScoreEnum.Customized, + vector_setting: { + vector_weight: 0.8, + embedding_provider_name: 'test-embed-provider', + embedding_model_name: 'test-embed-model', + }, + keyword_setting: { + keyword_weight: 0.2, + }, + } + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ + search_method: RETRIEVE_METHOD.semantic, + weights: existingWeights, + }), + onChange, + }) + + const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(hybridOption!) + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + weights: existingWeights, + }), + ) + }) + + it('should use RerankingModel mode and enable reranking for hybrid when existing reranking model', () => { + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ + search_method: RETRIEVE_METHOD.semantic, + reranking_model: { + reranking_provider_name: 'existing-provider', + reranking_model_name: 'existing-model', + }, + }), + onChange, + }) + + const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(hybridOption!) + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + search_method: RETRIEVE_METHOD.hybrid, + reranking_enable: true, + reranking_mode: RerankingModeEnum.RerankingModel, + }), + ) + }) + }) + + // Tests for callback stability and memoization + describe('Callback Stability', () => { + it('should maintain stable onSwitch callback when value changes', () => { + const onChange = jest.fn() + const value1 = createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.fullText, top_k: 4 }) + const value2 = createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.fullText, top_k: 8 }) + + const { rerender } = render( + , + ) + + const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(semanticOption!) + + expect(onChange).toHaveBeenCalledTimes(1) + + rerender() + + fireEvent.click(semanticOption!) + expect(onChange).toHaveBeenCalledTimes(2) + }) + + it('should use updated onChange callback after rerender', () => { + const onChange1 = jest.fn() + const onChange2 = jest.fn() + const value = createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.fullText }) + + const { rerender } = render( + , + ) + + rerender() + + const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(semanticOption!) + + expect(onChange1).not.toHaveBeenCalled() + expect(onChange2).toHaveBeenCalledTimes(1) + }) + }) + + // Tests for component memoization + describe('Component Memoization', () => { + it('should be memoized with React.memo', () => { + // Verify the component is wrapped with React.memo by checking its displayName or type + expect(RetrievalMethodConfig).toBeDefined() + // React.memo components have a $$typeof property + expect((RetrievalMethodConfig as any).$$typeof).toBeDefined() + }) + + it('should not re-render when props are the same', () => { + const onChange = jest.fn() + const value = createMockRetrievalConfig() + + const { rerender } = render( + , + ) + + // Rerender with same props reference + rerender() + + // Component should still be rendered correctly + expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument() + }) + }) + + // Tests for edge cases and error handling + describe('Edge Cases', () => { + it('should handle undefined reranking_model', () => { + const onChange = jest.fn() + const value = createMockRetrievalConfig({ + search_method: RETRIEVE_METHOD.fullText, + }) + // @ts-expect-error - Testing edge case + value.reranking_model = undefined + + renderComponent({ + value, + onChange, + }) + + // Should not crash + expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument() + }) + + it('should handle missing default model', () => { + mockRerankDefaultModel = undefined + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ + search_method: RETRIEVE_METHOD.fullText, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + }), + onChange, + }) + + const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(semanticOption!) + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + }), + ) + }) + + it('should use fallback empty string when default model provider is undefined', () => { + // @ts-expect-error - Testing edge case where provider is undefined + mockRerankDefaultModel = { provider: undefined, model: 'test-model' } + mockIsRerankDefaultModelValid = true + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ + search_method: RETRIEVE_METHOD.fullText, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + }), + onChange, + }) + + const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(hybridOption!) + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + reranking_model: { + reranking_provider_name: '', + reranking_model_name: 'test-model', + }, + }), + ) + }) + + it('should use fallback empty string when default model name is undefined', () => { + // @ts-expect-error - Testing edge case where model is undefined + mockRerankDefaultModel = { provider: { provider: 'test-provider' }, model: undefined } + mockIsRerankDefaultModelValid = true + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ + search_method: RETRIEVE_METHOD.fullText, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + }), + onChange, + }) + + const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(hybridOption!) + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + reranking_model: { + reranking_provider_name: 'test-provider', + reranking_model_name: '', + }, + }), + ) + }) + + it('should handle rapid sequential clicks', () => { + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }), + onChange, + }) + + const fullTextOption = screen.getByText('dataset.retrieval.full_text_search.title').closest('div[class*="cursor-pointer"]') + const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]') + + // Rapid clicks + fireEvent.click(fullTextOption!) + fireEvent.click(hybridOption!) + fireEvent.click(fullTextOption!) + + expect(onChange).toHaveBeenCalledTimes(3) + }) + + it('should handle empty supportRetrievalMethods array', () => { + mockSupportRetrievalMethods = [] + const { container } = renderComponent() + + expect(container.querySelector('[class*="flex-col"]')?.childNodes.length).toBe(0) + }) + + it('should handle partial supportRetrievalMethods', () => { + mockSupportRetrievalMethods = [RETRIEVE_METHOD.semantic, RETRIEVE_METHOD.hybrid] + renderComponent() + + expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument() + expect(screen.queryByText('dataset.retrieval.full_text_search.title')).not.toBeInTheDocument() + expect(screen.getByText('dataset.retrieval.hybrid_search.title')).toBeInTheDocument() + }) + + it('should handle value with all optional fields set', () => { + const fullValue = createMockRetrievalConfig({ + search_method: RETRIEVE_METHOD.hybrid, + reranking_enable: true, + reranking_model: { + reranking_provider_name: 'provider', + reranking_model_name: 'model', + }, + top_k: 10, + score_threshold_enabled: true, + score_threshold: 0.8, + reranking_mode: RerankingModeEnum.WeightedScore, + weights: { + weight_type: WeightedScoreEnum.Customized, + vector_setting: { + vector_weight: 0.6, + embedding_provider_name: 'embed-provider', + embedding_model_name: 'embed-model', + }, + keyword_setting: { + keyword_weight: 0.4, + }, + }, + }) + + renderComponent({ value: fullValue }) + + expect(screen.getByTestId('retrieval-param-config-hybrid_search')).toBeInTheDocument() + }) + }) + + // Tests for all prop variations + describe('Prop Variations', () => { + it('should render with minimum required props', () => { + const { container } = render( + , + ) + + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render with all props set', () => { + renderComponent({ + disabled: true, + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.hybrid }), + showMultiModalTip: true, + onChange: jest.fn(), + }) + + expect(screen.getByText('dataset.retrieval.hybrid_search.title')).toBeInTheDocument() + }) + + describe('disabled prop variations', () => { + it('should handle disabled=true', () => { + renderComponent({ disabled: true }) + const option = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor"]') + expect(option).toHaveClass('cursor-not-allowed') + }) + + it('should handle disabled=false', () => { + renderComponent({ disabled: false }) + const option = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor"]') + expect(option).toHaveClass('cursor-pointer') + }) + }) + + describe('search_method variations', () => { + const methods = [ + RETRIEVE_METHOD.semantic, + RETRIEVE_METHOD.fullText, + RETRIEVE_METHOD.hybrid, + ] + + test.each(methods)('should correctly highlight %s when active', (method) => { + renderComponent({ + value: createMockRetrievalConfig({ search_method: method }), + }) + + // The active method should have its RetrievalParamConfig rendered + expect(screen.getByTestId(`retrieval-param-config-${method}`)).toBeInTheDocument() + }) + }) + + describe('showMultiModalTip variations', () => { + it('should pass true to child component', () => { + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }), + showMultiModalTip: true, + }) + expect(screen.getByTestId('param-config-multimodal-tip')).toHaveTextContent('true') + }) + + it('should pass false to child component', () => { + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }), + showMultiModalTip: false, + }) + expect(screen.getByTestId('param-config-multimodal-tip')).toHaveTextContent('false') + }) + }) + }) + + // Tests for active state visual indication + describe('Active State Visual Indication', () => { + it('should show recommended badge only on hybrid search', () => { + renderComponent() + + // The hybrid search option should have the recommended badge + // This is verified by checking the isRecommended prop passed to OptionCard + const hybridTitle = screen.getByText('dataset.retrieval.hybrid_search.title') + const hybridCard = hybridTitle.closest('div[class*="cursor"]') + + // Should contain recommended badge from OptionCard + expect(hybridCard?.querySelector('[class*="badge"]') || screen.queryByText('datasetCreation.stepTwo.recommend')).toBeTruthy() + }) + }) + + // Tests for integration with OptionCard + describe('OptionCard Integration', () => { + it('should pass correct props to OptionCard for semantic search', () => { + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }), + }) + + const semanticTitle = screen.getByText('dataset.retrieval.semantic_search.title') + expect(semanticTitle).toBeInTheDocument() + + // Check description + const semanticDesc = screen.getByText('dataset.retrieval.semantic_search.description') + expect(semanticDesc).toBeInTheDocument() + }) + + it('should pass correct props to OptionCard for fullText search', () => { + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.fullText }), + }) + + const fullTextTitle = screen.getByText('dataset.retrieval.full_text_search.title') + expect(fullTextTitle).toBeInTheDocument() + + const fullTextDesc = screen.getByText('dataset.retrieval.full_text_search.description') + expect(fullTextDesc).toBeInTheDocument() + }) + + it('should pass correct props to OptionCard for hybrid search', () => { + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.hybrid }), + }) + + const hybridTitle = screen.getByText('dataset.retrieval.hybrid_search.title') + expect(hybridTitle).toBeInTheDocument() + + const hybridDesc = screen.getByText('dataset.retrieval.hybrid_search.description') + expect(hybridDesc).toBeInTheDocument() + }) + }) +})