mirror of
https://github.com/langgenius/dify.git
synced 2025-12-19 22:28:46 +00:00
test: add unit tests for DocumentPicker, PreviewDocumentPicker, and R… (#29695)
Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
This commit is contained in:
1101
web/app/components/datasets/common/document-picker/index.spec.tsx
Normal file
1101
web/app/components/datasets/common/document-picker/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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<string, unknown>) => {
|
||||
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
|
||||
}) => (
|
||||
<div data-testid="portal-elem" data-open={String(open || false)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
}) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
// Always render content to allow testing document selection
|
||||
PortalToFollowElemContent: ({ children, className }: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) => (
|
||||
<div data-testid="portal-content" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock icons
|
||||
jest.mock('@remixicon/react', () => ({
|
||||
RiArrowDownSLine: () => <span data-testid="arrow-icon">↓</span>,
|
||||
RiFile3Fill: () => <span data-testid="file-icon">📄</span>,
|
||||
RiFileCodeFill: () => <span data-testid="file-code-icon">📄</span>,
|
||||
RiFileExcelFill: () => <span data-testid="file-excel-icon">📄</span>,
|
||||
RiFileGifFill: () => <span data-testid="file-gif-icon">📄</span>,
|
||||
RiFileImageFill: () => <span data-testid="file-image-icon">📄</span>,
|
||||
RiFileMusicFill: () => <span data-testid="file-music-icon">📄</span>,
|
||||
RiFilePdf2Fill: () => <span data-testid="file-pdf-icon">📄</span>,
|
||||
RiFilePpt2Fill: () => <span data-testid="file-ppt-icon">📄</span>,
|
||||
RiFileTextFill: () => <span data-testid="file-text-icon">📄</span>,
|
||||
RiFileVideoFill: () => <span data-testid="file-video-icon">📄</span>,
|
||||
RiFileWordFill: () => <span data-testid="file-word-icon">📄</span>,
|
||||
RiMarkdownFill: () => <span data-testid="file-markdown-icon">📄</span>,
|
||||
}))
|
||||
|
||||
// Factory function to create mock DocumentItem
|
||||
const createMockDocumentItem = (overrides: Partial<DocumentItem> = {}): 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<React.ComponentProps<typeof PreviewDocumentPicker>> = {}) => ({
|
||||
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<React.ComponentProps<typeof PreviewDocumentPicker>> = {}) => {
|
||||
const defaultProps = createDefaultProps(props)
|
||||
return {
|
||||
...render(<PreviewDocumentPicker {...defaultProps} />),
|
||||
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(<PreviewDocumentPicker {...props} />)
|
||||
|
||||
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(
|
||||
<PreviewDocumentPicker
|
||||
value={value1}
|
||||
files={createMockDocumentList(3)}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<PreviewDocumentPicker
|
||||
value={value2}
|
||||
files={createMockDocumentList(3)}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<PreviewDocumentPicker value={value} files={files} onChange={onChange1} />,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<PreviewDocumentPicker value={value} files={files} onChange={onChange2} />,
|
||||
)
|
||||
|
||||
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(
|
||||
<PreviewDocumentPicker value={value} files={files} onChange={onChange} />,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<PreviewDocumentPicker value={value} files={files} onChange={onChange} />,
|
||||
)
|
||||
|
||||
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 = '<script>alert("xss")</script>'
|
||||
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(<PreviewDocumentPicker {...props} />)
|
||||
|
||||
// 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(
|
||||
<PreviewDocumentPicker
|
||||
value={createMockDocumentItem()}
|
||||
files={[createMockDocumentItem({ name: 'Single File' })]}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByText(/files/)).not.toBeInTheDocument()
|
||||
|
||||
// Multiple files - show header
|
||||
rerender(
|
||||
<PreviewDocumentPicker
|
||||
value={createMockDocumentItem()}
|
||||
files={createMockDocumentList(3)}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
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])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}) => (
|
||||
<div data-testid={`retrieval-param-config-${type}`}>
|
||||
<span data-testid="param-config-type">{type}</span>
|
||||
<span data-testid="param-config-multimodal-tip">{String(showMultiModalTip)}</span>
|
||||
<button
|
||||
data-testid={`update-top-k-${type}`}
|
||||
onClick={() => onChange({ ...value, top_k: 10 })}
|
||||
>
|
||||
Update Top K
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Factory function to create mock RetrievalConfig
|
||||
const createMockRetrievalConfig = (overrides: Partial<RetrievalConfig> = {}): 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<React.ComponentProps<typeof RetrievalMethodConfig>> = {}) => {
|
||||
const defaultProps = {
|
||||
value: createMockRetrievalConfig(),
|
||||
onChange: jest.fn(),
|
||||
}
|
||||
return render(<RetrievalMethodConfig {...defaultProps} {...props} />)
|
||||
}
|
||||
|
||||
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(
|
||||
<RetrievalMethodConfig value={value1} onChange={onChange} />,
|
||||
)
|
||||
|
||||
const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]')
|
||||
fireEvent.click(semanticOption!)
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(<RetrievalMethodConfig value={value2} onChange={onChange} />)
|
||||
|
||||
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(
|
||||
<RetrievalMethodConfig value={value} onChange={onChange1} />,
|
||||
)
|
||||
|
||||
rerender(<RetrievalMethodConfig value={value} onChange={onChange2} />)
|
||||
|
||||
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(
|
||||
<RetrievalMethodConfig value={value} onChange={onChange} />,
|
||||
)
|
||||
|
||||
// Rerender with same props reference
|
||||
rerender(<RetrievalMethodConfig value={value} onChange={onChange} />)
|
||||
|
||||
// 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(
|
||||
<RetrievalMethodConfig
|
||||
value={createMockRetrievalConfig()}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user