import type { ReactNode } from 'react' import type { DataSet, HitTesting, HitTestingChildChunk, HitTestingRecord, HitTestingResponse, Query } from '@/models/datasets' import type { RetrievalConfig } from '@/types/app' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { FileAppearanceTypeEnum } from '@/app/components/base/file-uploader/types' import { RETRIEVE_METHOD } from '@/types/app' // ============================================================================ // Imports (after mocks) // ============================================================================ import ChildChunksItem from './components/child-chunks-item' import ChunkDetailModal from './components/chunk-detail-modal' import EmptyRecords from './components/empty-records' import Mask from './components/mask' import QueryInput from './components/query-input' import Textarea from './components/query-input/textarea' import Records from './components/records' import ResultItem from './components/result-item' import ResultItemExternal from './components/result-item-external' import ResultItemFooter from './components/result-item-footer' import ResultItemMeta from './components/result-item-meta' import Score from './components/score' import HitTestingPage from './index' import ModifyExternalRetrievalModal from './modify-external-retrieval-modal' import ModifyRetrievalModal from './modify-retrieval-modal' import { extensionToFileType } from './utils/extension-to-file-type' // Mock Toast // Note: These components use real implementations for integration testing: // - Toast, FloatRightContainer, Drawer, Pagination, Loading // - RetrievalMethodConfig, EconomicalRetrievalMethodConfig // - ImageUploaderInRetrievalTesting, retrieval-method-info, check-rerank-model // Mock RetrievalSettings to allow triggering onChange vi.mock('@/app/components/datasets/external-knowledge-base/create/RetrievalSettings', () => ({ default: ({ onChange }: { onChange: (data: { top_k?: number, score_threshold?: number, score_threshold_enabled?: boolean }) => void }) => { return (
) }, })) // ============================================================================ // Mock Setup // ============================================================================ // Mock next/navigation vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn(), }), usePathname: () => '/test', useSearchParams: () => new URLSearchParams(), })) // Mock use-context-selector const mockDataset = { id: 'dataset-1', name: 'Test Dataset', provider: 'vendor', indexing_technique: 'high_quality' as const, retrieval_model_dict: { search_method: RETRIEVE_METHOD.semantic, reranking_enable: false, reranking_mode: undefined, reranking_model: { reranking_provider_name: '', reranking_model_name: '', }, weights: undefined, top_k: 10, score_threshold_enabled: false, score_threshold: 0.5, }, is_multimodal: false, } as Partial vi.mock('use-context-selector', () => ({ useContext: vi.fn(() => ({ dataset: mockDataset })), useContextSelector: vi.fn((_, selector) => selector({ dataset: mockDataset })), createContext: vi.fn(() => ({})), })) // Mock dataset detail context vi.mock('@/context/dataset-detail', () => ({ default: {}, useDatasetDetailContext: vi.fn(() => ({ dataset: mockDataset })), useDatasetDetailContextWithSelector: vi.fn((selector: (v: { dataset?: typeof mockDataset }) => unknown) => selector({ dataset: mockDataset as DataSet }), ), })) // Mock service hooks const mockRecordsRefetch = vi.fn() const mockHitTestingMutateAsync = vi.fn() const mockExternalHitTestingMutateAsync = vi.fn() vi.mock('@/service/knowledge/use-dataset', () => ({ useDatasetTestingRecords: vi.fn(() => ({ data: { data: [], total: 0, page: 1, limit: 10, has_more: false, }, refetch: mockRecordsRefetch, isLoading: false, })), })) vi.mock('@/service/knowledge/use-hit-testing', () => ({ useHitTesting: vi.fn(() => ({ mutateAsync: mockHitTestingMutateAsync, isPending: false, })), useExternalKnowledgeBaseHitTesting: vi.fn(() => ({ mutateAsync: mockExternalHitTestingMutateAsync, isPending: false, })), })) // Mock breakpoints hook vi.mock('@/hooks/use-breakpoints', () => ({ default: vi.fn(() => 'pc'), MediaType: { mobile: 'mobile', pc: 'pc', }, })) // Mock timestamp hook vi.mock('@/hooks/use-timestamp', () => ({ default: vi.fn(() => ({ formatTime: vi.fn((timestamp: number, _format: string) => new Date(timestamp * 1000).toISOString()), })), })) // Mock use-common to avoid QueryClient issues in nested hooks vi.mock('@/service/use-common', () => ({ useFileUploadConfig: vi.fn(() => ({ data: { file_size_limit: 10, batch_count_limit: 5, image_file_size_limit: 5, }, isLoading: false, })), })) // Store ref to ImageUploader onChange for testing let mockImageUploaderOnChange: ((files: Array<{ sourceUrl?: string, uploadedId?: string, mimeType: string, name: string, size: number, extension: string }>) => void) | null = null // Mock ImageUploaderInRetrievalTesting to capture onChange vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', () => ({ default: ({ textArea, actionButton, onChange }: { textArea: React.ReactNode actionButton: React.ReactNode onChange: (files: Array<{ sourceUrl?: string, uploadedId?: string, mimeType: string, name: string, size: number, extension: string }>) => void }) => { mockImageUploaderOnChange = onChange return (
{textArea} {actionButton}
) }, })) // Mock docLink hook vi.mock('@/context/i18n', () => ({ useDocLink: vi.fn(() => () => 'https://docs.example.com'), })) // Mock provider context for retrieval method config vi.mock('@/context/provider-context', () => ({ useProviderContext: vi.fn(() => ({ supportRetrievalMethods: [ 'semantic_search', 'full_text_search', 'hybrid_search', ], })), })) // Mock model list hook - include all exports used by child components vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ useModelList: vi.fn(() => ({ data: [], isLoading: false, })), useModelListAndDefaultModelAndCurrentProviderAndModel: vi.fn(() => ({ modelList: [], defaultModel: undefined, currentProvider: undefined, currentModel: undefined, })), useModelListAndDefaultModel: vi.fn(() => ({ modelList: [], defaultModel: undefined, })), useCurrentProviderAndModel: vi.fn(() => ({ currentProvider: undefined, currentModel: undefined, })), useDefaultModel: vi.fn(() => ({ defaultModel: undefined, })), })) // ============================================================================ // Test Wrapper with QueryClientProvider // ============================================================================ const createTestQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0, }, mutations: { retry: false, }, }, }) const TestWrapper = ({ children }: { children: ReactNode }) => { const queryClient = createTestQueryClient() return ( {children} ) } const renderWithProviders = (ui: React.ReactElement) => { return render(ui, { wrapper: TestWrapper }) } // ============================================================================ // Test Factories // ============================================================================ const createMockSegment = (overrides = {}) => ({ id: 'segment-1', document: { id: 'doc-1', data_source_type: 'upload_file', name: 'test-document.pdf', doc_type: 'book' as const, }, content: 'Test segment content', sign_content: 'Test signed content', position: 1, word_count: 100, tokens: 50, keywords: ['test', 'keyword'], hit_count: 5, index_node_hash: 'hash-123', answer: '', ...overrides, }) const createMockHitTesting = (overrides = {}): HitTesting => ({ segment: createMockSegment() as HitTesting['segment'], content: createMockSegment() as HitTesting['content'], score: 0.85, tsne_position: { x: 0.5, y: 0.5 }, child_chunks: null, files: [], ...overrides, }) const createMockChildChunk = (overrides = {}): HitTestingChildChunk => ({ id: 'child-chunk-1', content: 'Child chunk content', position: 1, score: 0.9, ...overrides, }) const createMockRecord = (overrides = {}): HitTestingRecord => ({ id: 'record-1', source: 'hit_testing', source_app_id: 'app-1', created_by_role: 'account', created_by: 'user-1', created_at: 1609459200, queries: [ { content: 'Test query', content_type: 'text_query', file_info: null }, ], ...overrides, }) const createMockRetrievalConfig = (overrides = {}): RetrievalConfig => ({ search_method: RETRIEVE_METHOD.semantic, reranking_enable: false, reranking_mode: undefined, reranking_model: { reranking_provider_name: '', reranking_model_name: '', }, weights: undefined, top_k: 10, score_threshold_enabled: false, score_threshold: 0.5, ...overrides, } as RetrievalConfig) // ============================================================================ // Utility Function Tests // ============================================================================ describe('extensionToFileType', () => { describe('PDF files', () => { it('should return pdf type for pdf extension', () => { expect(extensionToFileType('pdf')).toBe(FileAppearanceTypeEnum.pdf) }) }) describe('Word files', () => { it('should return word type for doc extension', () => { expect(extensionToFileType('doc')).toBe(FileAppearanceTypeEnum.word) }) it('should return word type for docx extension', () => { expect(extensionToFileType('docx')).toBe(FileAppearanceTypeEnum.word) }) }) describe('Markdown files', () => { it('should return markdown type for md extension', () => { expect(extensionToFileType('md')).toBe(FileAppearanceTypeEnum.markdown) }) it('should return markdown type for mdx extension', () => { expect(extensionToFileType('mdx')).toBe(FileAppearanceTypeEnum.markdown) }) it('should return markdown type for markdown extension', () => { expect(extensionToFileType('markdown')).toBe(FileAppearanceTypeEnum.markdown) }) }) describe('Excel files', () => { it('should return excel type for csv extension', () => { expect(extensionToFileType('csv')).toBe(FileAppearanceTypeEnum.excel) }) it('should return excel type for xls extension', () => { expect(extensionToFileType('xls')).toBe(FileAppearanceTypeEnum.excel) }) it('should return excel type for xlsx extension', () => { expect(extensionToFileType('xlsx')).toBe(FileAppearanceTypeEnum.excel) }) }) describe('Document files', () => { it('should return document type for txt extension', () => { expect(extensionToFileType('txt')).toBe(FileAppearanceTypeEnum.document) }) it('should return document type for epub extension', () => { expect(extensionToFileType('epub')).toBe(FileAppearanceTypeEnum.document) }) it('should return document type for html extension', () => { expect(extensionToFileType('html')).toBe(FileAppearanceTypeEnum.document) }) it('should return document type for htm extension', () => { expect(extensionToFileType('htm')).toBe(FileAppearanceTypeEnum.document) }) it('should return document type for xml extension', () => { expect(extensionToFileType('xml')).toBe(FileAppearanceTypeEnum.document) }) }) describe('PowerPoint files', () => { it('should return ppt type for ppt extension', () => { expect(extensionToFileType('ppt')).toBe(FileAppearanceTypeEnum.ppt) }) it('should return ppt type for pptx extension', () => { expect(extensionToFileType('pptx')).toBe(FileAppearanceTypeEnum.ppt) }) }) describe('Edge cases', () => { it('should return custom type for unknown extension', () => { expect(extensionToFileType('unknown')).toBe(FileAppearanceTypeEnum.custom) }) it('should return custom type for empty string', () => { expect(extensionToFileType('')).toBe(FileAppearanceTypeEnum.custom) }) }) }) // ============================================================================ // Score Component Tests // ============================================================================ describe('Score', () => { describe('Rendering', () => { it('should render score with correct value', () => { render() expect(screen.getByText('0.85')).toBeInTheDocument() expect(screen.getByText('score')).toBeInTheDocument() }) it('should render nothing when value is null', () => { const { container } = render() expect(container.firstChild).toBeNull() }) it('should render nothing when value is NaN', () => { const { container } = render() expect(container.firstChild).toBeNull() }) it('should render nothing when value is 0', () => { const { container } = render() expect(container.firstChild).toBeNull() }) }) describe('Props', () => { it('should apply besideChunkName styles when prop is true', () => { const { container } = render() const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('border-l-0') }) it('should apply rounded styles when besideChunkName is false', () => { const { container } = render() const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('rounded-md') }) }) describe('Edge Cases', () => { it('should display full score correctly', () => { render() expect(screen.getByText('1.00')).toBeInTheDocument() }) it('should display very small score correctly', () => { render() expect(screen.getByText('0.01')).toBeInTheDocument() }) }) }) // ============================================================================ // Mask Component Tests // ============================================================================ describe('Mask', () => { describe('Rendering', () => { it('should render without crashing', () => { const { container } = render() expect(container.firstChild).toBeInTheDocument() }) it('should have gradient background class', () => { const { container } = render() expect(container.firstChild).toHaveClass('bg-gradient-to-b') }) }) describe('Props', () => { it('should apply custom className', () => { const { container } = render() expect(container.firstChild).toHaveClass('custom-class') }) }) }) // ============================================================================ // EmptyRecords Component Tests // ============================================================================ describe('EmptyRecords', () => { describe('Rendering', () => { it('should render without crashing', () => { render() expect(screen.getByText(/noRecentTip/i)).toBeInTheDocument() }) it('should render history icon', () => { const { container } = render() const icon = container.querySelector('svg') expect(icon).toBeInTheDocument() }) }) }) // ============================================================================ // ResultItemMeta Component Tests // ============================================================================ describe('ResultItemMeta', () => { const defaultProps = { labelPrefix: 'Chunk', positionId: 1, wordCount: 100, score: 0.85, } describe('Rendering', () => { it('should render without crashing', () => { render() expect(screen.getByText(/100/)).toBeInTheDocument() }) it('should render score component', () => { render() expect(screen.getByText('0.85')).toBeInTheDocument() }) it('should render word count', () => { render() expect(screen.getByText(/100/)).toBeInTheDocument() }) }) describe('Props', () => { it('should apply custom className', () => { const { container } = render() expect(container.firstChild).toHaveClass('custom-class') }) it('should handle different position IDs', () => { render() // Position ID is passed to SegmentIndexTag expect(screen.getByText(/42/)).toBeInTheDocument() }) }) }) // ============================================================================ // ResultItemFooter Component Tests // ============================================================================ describe('ResultItemFooter', () => { const mockShowDetailModal = vi.fn() const defaultProps = { docType: FileAppearanceTypeEnum.pdf, docTitle: 'Test Document.pdf', showDetailModal: mockShowDetailModal, } beforeEach(() => { vi.clearAllMocks() }) describe('Rendering', () => { it('should render without crashing', () => { render() expect(screen.getByText('Test Document.pdf')).toBeInTheDocument() }) it('should render open button', () => { render() expect(screen.getByText(/open/i)).toBeInTheDocument() }) }) describe('User Interactions', () => { it('should call showDetailModal when open button is clicked', async () => { render() const openButton = screen.getByText(/open/i).parentElement if (openButton) fireEvent.click(openButton) expect(mockShowDetailModal).toHaveBeenCalledTimes(1) }) }) }) // ============================================================================ // ChildChunksItem Component Tests // ============================================================================ describe('ChildChunksItem', () => { const mockChildChunk = createMockChildChunk() describe('Rendering', () => { it('should render without crashing', () => { render() expect(screen.getByText(/Child chunk content/)).toBeInTheDocument() }) it('should render position identifier', () => { render() // The C- and position number are in the same element expect(screen.getByText(/C-/)).toBeInTheDocument() }) it('should render score', () => { render() expect(screen.getByText('0.90')).toBeInTheDocument() }) }) describe('Props', () => { it('should apply line-clamp when isShowAll is false', () => { const { container } = render() expect(container.firstChild).toHaveClass('line-clamp-2') }) it('should not apply line-clamp when isShowAll is true', () => { const { container } = render() expect(container.firstChild).not.toHaveClass('line-clamp-2') }) }) }) // ============================================================================ // ResultItem Component Tests // ============================================================================ describe('ResultItem', () => { const mockHitTesting = createMockHitTesting() describe('Rendering', () => { it('should render without crashing', () => { render() // Document name should be visible expect(screen.getByText('test-document.pdf')).toBeInTheDocument() }) it('should render score', () => { render() expect(screen.getByText('0.85')).toBeInTheDocument() }) it('should render document name in footer', () => { render() expect(screen.getByText('test-document.pdf')).toBeInTheDocument() }) }) describe('User Interactions', () => { it('should open detail modal when clicked', async () => { render() const item = screen.getByText('test-document.pdf').closest('.cursor-pointer') if (item) fireEvent.click(item) await waitFor(() => { expect(screen.getByText(/chunkDetail/i)).toBeInTheDocument() }) }) }) describe('Parent-Child Retrieval', () => { it('should render child chunks when present', () => { const payloadWithChildren = createMockHitTesting({ child_chunks: [createMockChildChunk()], }) render() expect(screen.getByText(/hitChunks/i)).toBeInTheDocument() }) it('should toggle fold state when child chunks header is clicked', async () => { const payloadWithChildren = createMockHitTesting({ child_chunks: [createMockChildChunk()], }) render() // Child chunks should be visible by default (not folded) expect(screen.getByText(/Child chunk content/)).toBeInTheDocument() // Click to fold const toggleButton = screen.getByText(/hitChunks/i).parentElement if (toggleButton) { fireEvent.click(toggleButton) await waitFor(() => { expect(screen.queryByText(/Child chunk content/)).not.toBeInTheDocument() }) } }) }) describe('Keywords', () => { it('should render keywords when present and no child chunks', () => { const payload = createMockHitTesting({ segment: createMockSegment({ keywords: ['keyword1', 'keyword2'] }), child_chunks: null, }) render() expect(screen.getByText('keyword1')).toBeInTheDocument() expect(screen.getByText('keyword2')).toBeInTheDocument() }) it('should not render keywords when child chunks are present', () => { const payload = createMockHitTesting({ segment: createMockSegment({ keywords: ['keyword1'] }), child_chunks: [createMockChildChunk()], }) render() expect(screen.queryByText('keyword1')).not.toBeInTheDocument() }) }) }) // ============================================================================ // ResultItemExternal Component Tests // ============================================================================ describe('ResultItemExternal', () => { const defaultProps = { payload: { content: 'External content', title: 'External Title', score: 0.75, metadata: { 'x-amz-bedrock-kb-source-uri': 'source-uri', 'x-amz-bedrock-kb-data-source-id': 'data-source-id', }, }, positionId: 1, } describe('Rendering', () => { it('should render without crashing', () => { render() expect(screen.getByText('External content')).toBeInTheDocument() }) it('should render title in footer', () => { render() expect(screen.getByText('External Title')).toBeInTheDocument() }) it('should render score', () => { render() expect(screen.getByText('0.75')).toBeInTheDocument() }) }) describe('User Interactions', () => { it('should open detail modal when clicked', async () => { render() const item = screen.getByText('External content').closest('.cursor-pointer') if (item) fireEvent.click(item) await waitFor(() => { expect(screen.getByText(/chunkDetail/i)).toBeInTheDocument() }) }) }) }) // ============================================================================ // Textarea Component Tests // ============================================================================ describe('Textarea', () => { const mockHandleTextChange = vi.fn() beforeEach(() => { vi.clearAllMocks() }) describe('Rendering', () => { it('should render without crashing', () => { render(