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()
+ })
+ })
+})