feat(tests): add comprehensive tests for Processing and EmbeddingProcess components (#29873)

Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
This commit is contained in:
Coding On Star
2025-12-19 15:21:21 +08:00
committed by GitHub
parent 933bc72fd7
commit d7b8db2afc
19 changed files with 7015 additions and 229 deletions

View File

@@ -0,0 +1,461 @@
import { fireEvent, render, screen } from '@testing-library/react'
import React from 'react'
import ChunkPreview from './chunk-preview'
import { ChunkingMode } from '@/models/datasets'
import type { CrawlResultItem, CustomFile, FileIndexingEstimateResponse } from '@/models/datasets'
import type { NotionPage } from '@/models/common'
import type { OnlineDriveFile } from '@/models/pipeline'
import { DatasourceType, OnlineDriveFileType } from '@/models/pipeline'
// Uses __mocks__/react-i18next.ts automatically
// Mock dataset-detail context - needs mock to control return values
const mockDocForm = jest.fn()
jest.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (_selector: (s: { dataset: { doc_form: ChunkingMode } }) => ChunkingMode) => {
return mockDocForm()
},
}))
// Mock document picker - needs mock for simplified interaction testing
jest.mock('../../../common/document-picker/preview-document-picker', () => ({
__esModule: true,
default: ({ files, onChange, value }: {
files: Array<{ id: string; name: string; extension: string }>
onChange: (selected: { id: string; name: string; extension: string }) => void
value: { id: string; name: string; extension: string }
}) => (
<div data-testid="document-picker">
<span data-testid="picker-value">{value?.name || 'No selection'}</span>
<select
data-testid="picker-select"
value={value?.id || ''}
onChange={(e) => {
const selected = files.find(f => f.id === e.target.value)
if (selected)
onChange(selected)
}}
>
{files.map(f => (
<option key={f.id} value={f.id}>{f.name}</option>
))}
</select>
</div>
),
}))
// Test data factories
const createMockLocalFile = (overrides?: Partial<CustomFile>): CustomFile => ({
id: 'file-1',
name: 'test-file.pdf',
size: 1024,
type: 'application/pdf',
extension: 'pdf',
lastModified: Date.now(),
webkitRelativePath: '',
arrayBuffer: jest.fn() as () => Promise<ArrayBuffer>,
bytes: jest.fn() as () => Promise<Uint8Array>,
slice: jest.fn() as (start?: number, end?: number, contentType?: string) => Blob,
stream: jest.fn() as () => ReadableStream<Uint8Array>,
text: jest.fn() as () => Promise<string>,
...overrides,
} as CustomFile)
const createMockNotionPage = (overrides?: Partial<NotionPage>): NotionPage => ({
page_id: 'page-1',
page_name: 'Test Page',
workspace_id: 'workspace-1',
type: 'page',
page_icon: null,
parent_id: 'parent-1',
is_bound: true,
...overrides,
})
const createMockCrawlResult = (overrides?: Partial<CrawlResultItem>): CrawlResultItem => ({
title: 'Test Website',
markdown: 'Test content',
description: 'Test description',
source_url: 'https://example.com',
...overrides,
})
const createMockOnlineDriveFile = (overrides?: Partial<OnlineDriveFile>): OnlineDriveFile => ({
id: 'drive-file-1',
name: 'test-drive-file.docx',
size: 2048,
type: OnlineDriveFileType.file,
...overrides,
})
const createMockEstimateData = (overrides?: Partial<FileIndexingEstimateResponse>): FileIndexingEstimateResponse => ({
total_nodes: 5,
tokens: 1000,
total_price: 0.01,
currency: 'USD',
total_segments: 10,
preview: [
{ content: 'Chunk content 1', child_chunks: ['child 1', 'child 2'] },
{ content: 'Chunk content 2', child_chunks: ['child 3'] },
],
qa_preview: [
{ question: 'Q1', answer: 'A1' },
{ question: 'Q2', answer: 'A2' },
],
...overrides,
})
const defaultProps = {
dataSourceType: DatasourceType.localFile,
localFiles: [createMockLocalFile()],
onlineDocuments: [createMockNotionPage()],
websitePages: [createMockCrawlResult()],
onlineDriveFiles: [createMockOnlineDriveFile()],
isIdle: false,
isPending: false,
estimateData: undefined,
onPreview: jest.fn(),
handlePreviewFileChange: jest.fn(),
handlePreviewOnlineDocumentChange: jest.fn(),
handlePreviewWebsitePageChange: jest.fn(),
handlePreviewOnlineDriveFileChange: jest.fn(),
}
describe('ChunkPreview', () => {
beforeEach(() => {
jest.clearAllMocks()
mockDocForm.mockReturnValue(ChunkingMode.text)
})
describe('Rendering', () => {
it('should render the component with preview container', () => {
render(<ChunkPreview {...defaultProps} />)
// i18n mock returns key by default
expect(screen.getByText('datasetCreation.stepTwo.preview')).toBeInTheDocument()
})
it('should render document picker for local files', () => {
render(<ChunkPreview {...defaultProps} dataSourceType={DatasourceType.localFile} />)
expect(screen.getByTestId('document-picker')).toBeInTheDocument()
})
it('should render document picker for online documents', () => {
render(<ChunkPreview {...defaultProps} dataSourceType={DatasourceType.onlineDocument} />)
expect(screen.getByTestId('document-picker')).toBeInTheDocument()
})
it('should render document picker for website pages', () => {
render(<ChunkPreview {...defaultProps} dataSourceType={DatasourceType.websiteCrawl} />)
expect(screen.getByTestId('document-picker')).toBeInTheDocument()
})
it('should render document picker for online drive files', () => {
render(<ChunkPreview {...defaultProps} dataSourceType={DatasourceType.onlineDrive} />)
expect(screen.getByTestId('document-picker')).toBeInTheDocument()
})
it('should render badge with chunk count for non-QA mode', () => {
const estimateData = createMockEstimateData({ total_segments: 15 })
mockDocForm.mockReturnValue(ChunkingMode.text)
render(<ChunkPreview {...defaultProps} estimateData={estimateData} />)
// Badge shows chunk count via i18n key with count option
expect(screen.getByText(/previewChunkCount.*15/)).toBeInTheDocument()
})
it('should not render badge for QA mode', () => {
mockDocForm.mockReturnValue(ChunkingMode.qa)
const estimateData = createMockEstimateData()
render(<ChunkPreview {...defaultProps} estimateData={estimateData} />)
// No badge with total_segments
expect(screen.queryByText(/10/)).not.toBeInTheDocument()
})
})
describe('Idle State', () => {
it('should render idle state with preview tip and button', () => {
render(<ChunkPreview {...defaultProps} isIdle={true} />)
// i18n mock returns keys
expect(screen.getByText('datasetCreation.stepTwo.previewChunkTip')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.previewChunks')).toBeInTheDocument()
})
it('should call onPreview when preview button is clicked', () => {
const onPreview = jest.fn()
render(<ChunkPreview {...defaultProps} isIdle={true} onPreview={onPreview} />)
const button = screen.getByRole('button', { name: /previewChunks/i })
fireEvent.click(button)
expect(onPreview).toHaveBeenCalledTimes(1)
})
})
describe('Loading State', () => {
it('should render skeleton loading when isPending is true', () => {
render(<ChunkPreview {...defaultProps} isPending={true} />)
// Skeleton loading renders multiple skeleton containers
expect(document.querySelector('.space-y-6')).toBeInTheDocument()
})
it('should not render preview content when loading', () => {
const estimateData = createMockEstimateData()
render(<ChunkPreview {...defaultProps} isPending={true} estimateData={estimateData} />)
expect(screen.queryByText('Chunk content 1')).not.toBeInTheDocument()
})
})
describe('QA Mode Preview', () => {
it('should render QA preview chunks when doc_form is qa', () => {
mockDocForm.mockReturnValue(ChunkingMode.qa)
const estimateData = createMockEstimateData({
qa_preview: [
{ question: 'Question 1?', answer: 'Answer 1' },
{ question: 'Question 2?', answer: 'Answer 2' },
],
})
render(<ChunkPreview {...defaultProps} estimateData={estimateData} />)
expect(screen.getByText('Question 1?')).toBeInTheDocument()
expect(screen.getByText('Answer 1')).toBeInTheDocument()
expect(screen.getByText('Question 2?')).toBeInTheDocument()
expect(screen.getByText('Answer 2')).toBeInTheDocument()
})
})
describe('Text Mode Preview', () => {
it('should render text preview chunks when doc_form is text', () => {
mockDocForm.mockReturnValue(ChunkingMode.text)
const estimateData = createMockEstimateData({
preview: [
{ content: 'Text chunk 1', child_chunks: [] },
{ content: 'Text chunk 2', child_chunks: [] },
],
})
render(<ChunkPreview {...defaultProps} estimateData={estimateData} />)
expect(screen.getByText('Text chunk 1')).toBeInTheDocument()
expect(screen.getByText('Text chunk 2')).toBeInTheDocument()
})
})
describe('Parent-Child Mode Preview', () => {
it('should render parent-child preview chunks', () => {
mockDocForm.mockReturnValue(ChunkingMode.parentChild)
const estimateData = createMockEstimateData({
preview: [
{ content: 'Parent chunk 1', child_chunks: ['Child 1', 'Child 2'] },
],
})
render(<ChunkPreview {...defaultProps} estimateData={estimateData} />)
expect(screen.getByText('Child 1')).toBeInTheDocument()
expect(screen.getByText('Child 2')).toBeInTheDocument()
})
})
describe('Document Selection', () => {
it('should handle local file selection change', () => {
const handlePreviewFileChange = jest.fn()
const localFiles = [
createMockLocalFile({ id: 'file-1', name: 'file1.pdf' }),
createMockLocalFile({ id: 'file-2', name: 'file2.pdf' }),
]
render(
<ChunkPreview
{...defaultProps}
dataSourceType={DatasourceType.localFile}
localFiles={localFiles}
handlePreviewFileChange={handlePreviewFileChange}
/>,
)
const select = screen.getByTestId('picker-select')
fireEvent.change(select, { target: { value: 'file-2' } })
expect(handlePreviewFileChange).toHaveBeenCalled()
})
it('should handle online document selection change', () => {
const handlePreviewOnlineDocumentChange = jest.fn()
const onlineDocuments = [
createMockNotionPage({ page_id: 'page-1', page_name: 'Page 1' }),
createMockNotionPage({ page_id: 'page-2', page_name: 'Page 2' }),
]
render(
<ChunkPreview
{...defaultProps}
dataSourceType={DatasourceType.onlineDocument}
onlineDocuments={onlineDocuments}
handlePreviewOnlineDocumentChange={handlePreviewOnlineDocumentChange}
/>,
)
const select = screen.getByTestId('picker-select')
fireEvent.change(select, { target: { value: 'page-2' } })
expect(handlePreviewOnlineDocumentChange).toHaveBeenCalled()
})
it('should handle website page selection change', () => {
const handlePreviewWebsitePageChange = jest.fn()
const websitePages = [
createMockCrawlResult({ source_url: 'https://example1.com', title: 'Site 1' }),
createMockCrawlResult({ source_url: 'https://example2.com', title: 'Site 2' }),
]
render(
<ChunkPreview
{...defaultProps}
dataSourceType={DatasourceType.websiteCrawl}
websitePages={websitePages}
handlePreviewWebsitePageChange={handlePreviewWebsitePageChange}
/>,
)
const select = screen.getByTestId('picker-select')
fireEvent.change(select, { target: { value: 'https://example2.com' } })
expect(handlePreviewWebsitePageChange).toHaveBeenCalled()
})
it('should handle online drive file selection change', () => {
const handlePreviewOnlineDriveFileChange = jest.fn()
const onlineDriveFiles = [
createMockOnlineDriveFile({ id: 'drive-1', name: 'file1.docx' }),
createMockOnlineDriveFile({ id: 'drive-2', name: 'file2.docx' }),
]
render(
<ChunkPreview
{...defaultProps}
dataSourceType={DatasourceType.onlineDrive}
onlineDriveFiles={onlineDriveFiles}
handlePreviewOnlineDriveFileChange={handlePreviewOnlineDriveFileChange}
/>,
)
const select = screen.getByTestId('picker-select')
fireEvent.change(select, { target: { value: 'drive-2' } })
expect(handlePreviewOnlineDriveFileChange).toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should handle empty estimate data', () => {
mockDocForm.mockReturnValue(ChunkingMode.text)
render(<ChunkPreview {...defaultProps} estimateData={undefined} />)
expect(screen.queryByText('Chunk content')).not.toBeInTheDocument()
})
it('should handle empty preview array', () => {
mockDocForm.mockReturnValue(ChunkingMode.text)
const estimateData = createMockEstimateData({ preview: [] })
render(<ChunkPreview {...defaultProps} estimateData={estimateData} />)
expect(screen.queryByText('Chunk content')).not.toBeInTheDocument()
})
it('should handle empty qa_preview array', () => {
mockDocForm.mockReturnValue(ChunkingMode.qa)
const estimateData = createMockEstimateData({ qa_preview: [] })
render(<ChunkPreview {...defaultProps} estimateData={estimateData} />)
expect(screen.queryByText('Q1')).not.toBeInTheDocument()
})
it('should handle empty child_chunks in parent-child mode', () => {
mockDocForm.mockReturnValue(ChunkingMode.parentChild)
const estimateData = createMockEstimateData({
preview: [{ content: 'Parent', child_chunks: [] }],
})
render(<ChunkPreview {...defaultProps} estimateData={estimateData} />)
expect(screen.queryByText('Child')).not.toBeInTheDocument()
})
it('should handle badge showing 0 chunks', () => {
mockDocForm.mockReturnValue(ChunkingMode.text)
const estimateData = createMockEstimateData({ total_segments: 0 })
render(<ChunkPreview {...defaultProps} estimateData={estimateData} />)
// Badge with 0
expect(screen.getByText(/0/)).toBeInTheDocument()
})
it('should handle undefined online document properties', () => {
const onlineDocuments = [createMockNotionPage({ page_id: '', page_name: '' })]
render(
<ChunkPreview
{...defaultProps}
dataSourceType={DatasourceType.onlineDocument}
onlineDocuments={onlineDocuments}
/>,
)
expect(screen.getByTestId('document-picker')).toBeInTheDocument()
})
it('should handle undefined website page properties', () => {
const websitePages = [createMockCrawlResult({ source_url: '', title: '' })]
render(
<ChunkPreview
{...defaultProps}
dataSourceType={DatasourceType.websiteCrawl}
websitePages={websitePages}
/>,
)
expect(screen.getByTestId('document-picker')).toBeInTheDocument()
})
it('should handle undefined online drive file properties', () => {
const onlineDriveFiles = [createMockOnlineDriveFile({ id: '', name: '' })]
render(
<ChunkPreview
{...defaultProps}
dataSourceType={DatasourceType.onlineDrive}
onlineDriveFiles={onlineDriveFiles}
/>,
)
expect(screen.getByTestId('document-picker')).toBeInTheDocument()
})
})
describe('Component Memoization', () => {
it('should be exported as a memoized component', () => {
// ChunkPreview is wrapped with React.memo
// We verify this by checking the component type
expect(typeof ChunkPreview).toBe('object')
expect(ChunkPreview.$$typeof?.toString()).toBe('Symbol(react.memo)')
})
})
})

View File

@@ -0,0 +1,320 @@
import { fireEvent, render, screen } from '@testing-library/react'
import React from 'react'
import FilePreview from './file-preview'
import type { CustomFile as File } from '@/models/datasets'
// Uses __mocks__/react-i18next.ts automatically
// Mock useFilePreview hook - needs to be mocked to control return values
const mockUseFilePreview = jest.fn()
jest.mock('@/service/use-common', () => ({
useFilePreview: (fileID: string) => mockUseFilePreview(fileID),
}))
// Test data factory
const createMockFile = (overrides?: Partial<File>): File => ({
id: 'file-123',
name: 'test-document.pdf',
size: 2048,
type: 'application/pdf',
extension: 'pdf',
lastModified: Date.now(),
webkitRelativePath: '',
arrayBuffer: jest.fn() as () => Promise<ArrayBuffer>,
bytes: jest.fn() as () => Promise<Uint8Array>,
slice: jest.fn() as (start?: number, end?: number, contentType?: string) => Blob,
stream: jest.fn() as () => ReadableStream<Uint8Array>,
text: jest.fn() as () => Promise<string>,
...overrides,
} as File)
const createMockFilePreviewData = (content: string = 'This is the file content') => ({
content,
})
const defaultProps = {
file: createMockFile(),
hidePreview: jest.fn(),
}
describe('FilePreview', () => {
beforeEach(() => {
jest.clearAllMocks()
mockUseFilePreview.mockReturnValue({
data: undefined,
isFetching: false,
})
})
describe('Rendering', () => {
it('should render the component with file information', () => {
render(<FilePreview {...defaultProps} />)
// i18n mock returns key by default
expect(screen.getByText('datasetPipeline.addDocuments.stepOne.preview')).toBeInTheDocument()
expect(screen.getByText('test-document.pdf')).toBeInTheDocument()
})
it('should display file extension in uppercase via CSS class', () => {
render(<FilePreview {...defaultProps} />)
// The extension is displayed in the info section (as uppercase via CSS class)
const extensionElement = screen.getByText('pdf')
expect(extensionElement).toBeInTheDocument()
expect(extensionElement).toHaveClass('uppercase')
})
it('should display formatted file size', () => {
render(<FilePreview {...defaultProps} />)
// Real formatFileSize: 2048 bytes => "2.00 KB"
expect(screen.getByText('2.00 KB')).toBeInTheDocument()
})
it('should render close button', () => {
render(<FilePreview {...defaultProps} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should call useFilePreview with correct fileID', () => {
const file = createMockFile({ id: 'specific-file-id' })
render(<FilePreview {...defaultProps} file={file} />)
expect(mockUseFilePreview).toHaveBeenCalledWith('specific-file-id')
})
})
describe('File Name Processing', () => {
it('should extract file name without extension', () => {
const file = createMockFile({ name: 'my-document.pdf', extension: 'pdf' })
render(<FilePreview {...defaultProps} file={file} />)
// The displayed text is `${fileName}.${extension}`, where fileName is name without ext
// my-document.pdf -> fileName = 'my-document', displayed as 'my-document.pdf'
expect(screen.getByText('my-document.pdf')).toBeInTheDocument()
})
it('should handle file name with multiple dots', () => {
const file = createMockFile({ name: 'my.file.name.pdf', extension: 'pdf' })
render(<FilePreview {...defaultProps} file={file} />)
// fileName = arr.slice(0, -1).join() = 'my,file,name', then displayed as 'my,file,name.pdf'
expect(screen.getByText('my,file,name.pdf')).toBeInTheDocument()
})
it('should handle empty file name', () => {
const file = createMockFile({ name: '', extension: '' })
render(<FilePreview {...defaultProps} file={file} />)
// fileName = '', displayed as '.'
expect(screen.getByText('.')).toBeInTheDocument()
})
it('should handle file without extension in name', () => {
const file = createMockFile({ name: 'noextension', extension: '' })
render(<FilePreview {...defaultProps} file={file} />)
// fileName = '' (slice returns empty for single element array), displayed as '.'
expect(screen.getByText('.')).toBeInTheDocument()
})
})
describe('Loading State', () => {
it('should render loading component when fetching', () => {
mockUseFilePreview.mockReturnValue({
data: undefined,
isFetching: true,
})
render(<FilePreview {...defaultProps} />)
// Loading component renders skeleton
expect(document.querySelector('.overflow-hidden')).toBeInTheDocument()
})
it('should not render content when loading', () => {
mockUseFilePreview.mockReturnValue({
data: createMockFilePreviewData('Some content'),
isFetching: true,
})
render(<FilePreview {...defaultProps} />)
expect(screen.queryByText('Some content')).not.toBeInTheDocument()
})
})
describe('Content Display', () => {
it('should render file content when loaded', () => {
mockUseFilePreview.mockReturnValue({
data: createMockFilePreviewData('This is the file content'),
isFetching: false,
})
render(<FilePreview {...defaultProps} />)
expect(screen.getByText('This is the file content')).toBeInTheDocument()
})
it('should display character count when data is available', () => {
mockUseFilePreview.mockReturnValue({
data: createMockFilePreviewData('Hello'), // 5 characters
isFetching: false,
})
render(<FilePreview {...defaultProps} />)
// Real formatNumberAbbreviated returns "5" for numbers < 1000
expect(screen.getByText(/5/)).toBeInTheDocument()
})
it('should format large character counts', () => {
const longContent = 'a'.repeat(2500)
mockUseFilePreview.mockReturnValue({
data: createMockFilePreviewData(longContent),
isFetching: false,
})
render(<FilePreview {...defaultProps} />)
// Real formatNumberAbbreviated uses lowercase 'k': "2.5k"
expect(screen.getByText(/2\.5k/)).toBeInTheDocument()
})
it('should not display character count when data is not available', () => {
mockUseFilePreview.mockReturnValue({
data: undefined,
isFetching: false,
})
render(<FilePreview {...defaultProps} />)
// No character text shown
expect(screen.queryByText(/datasetPipeline\.addDocuments\.characters/)).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call hidePreview when close button is clicked', () => {
const hidePreview = jest.fn()
render(<FilePreview {...defaultProps} hidePreview={hidePreview} />)
const closeButton = screen.getByRole('button')
fireEvent.click(closeButton)
expect(hidePreview).toHaveBeenCalledTimes(1)
})
})
describe('File Size Formatting', () => {
it('should format small file sizes in bytes', () => {
const file = createMockFile({ size: 500 })
render(<FilePreview {...defaultProps} file={file} />)
// Real formatFileSize: 500 => "500.00 bytes"
expect(screen.getByText('500.00 bytes')).toBeInTheDocument()
})
it('should format kilobyte file sizes', () => {
const file = createMockFile({ size: 5120 })
render(<FilePreview {...defaultProps} file={file} />)
// Real formatFileSize: 5120 => "5.00 KB"
expect(screen.getByText('5.00 KB')).toBeInTheDocument()
})
it('should format megabyte file sizes', () => {
const file = createMockFile({ size: 2097152 })
render(<FilePreview {...defaultProps} file={file} />)
// Real formatFileSize: 2097152 => "2.00 MB"
expect(screen.getByText('2.00 MB')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle undefined file id', () => {
const file = createMockFile({ id: undefined })
render(<FilePreview {...defaultProps} file={file} />)
expect(mockUseFilePreview).toHaveBeenCalledWith('')
})
it('should handle empty extension', () => {
const file = createMockFile({ extension: undefined })
render(<FilePreview {...defaultProps} file={file} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle zero file size', () => {
const file = createMockFile({ size: 0 })
render(<FilePreview {...defaultProps} file={file} />)
// Real formatFileSize returns 0 for falsy values
// The component still renders
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle very long file content', () => {
const veryLongContent = 'a'.repeat(1000000)
mockUseFilePreview.mockReturnValue({
data: createMockFilePreviewData(veryLongContent),
isFetching: false,
})
render(<FilePreview {...defaultProps} />)
// Real formatNumberAbbreviated: 1000000 => "1M"
expect(screen.getByText(/1M/)).toBeInTheDocument()
})
it('should handle empty content', () => {
mockUseFilePreview.mockReturnValue({
data: createMockFilePreviewData(''),
isFetching: false,
})
render(<FilePreview {...defaultProps} />)
// Real formatNumberAbbreviated: 0 => "0"
// Find the element that contains character count info
expect(screen.getByText(/0 datasetPipeline/)).toBeInTheDocument()
})
})
describe('useMemo for fileName', () => {
it('should extract file name when file exists', () => {
// When file exists, it should extract the name without extension
const file = createMockFile({ name: 'document.txt', extension: 'txt' })
render(<FilePreview {...defaultProps} file={file} />)
expect(screen.getByText('document.txt')).toBeInTheDocument()
})
it('should memoize fileName based on file prop', () => {
const file = createMockFile({ name: 'test.pdf', extension: 'pdf' })
const { rerender } = render(<FilePreview {...defaultProps} file={file} />)
// Same file should produce same result
rerender(<FilePreview {...defaultProps} file={file} />)
expect(screen.getByText('test.pdf')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,359 @@
import { render, screen, waitFor } from '@testing-library/react'
import { fireEvent } from '@testing-library/react'
import React from 'react'
import OnlineDocumentPreview from './online-document-preview'
import type { NotionPage } from '@/models/common'
import Toast from '@/app/components/base/toast'
// Uses __mocks__/react-i18next.ts automatically
// Spy on Toast.notify
const toastNotifySpy = jest.spyOn(Toast, 'notify')
// Mock dataset-detail context - needs mock to control return values
const mockPipelineId = jest.fn()
jest.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (_selector: (s: { dataset: { pipeline_id: string } }) => string) => {
return mockPipelineId()
},
}))
// Mock usePreviewOnlineDocument hook - needs mock to control mutation behavior
const mockMutateAsync = jest.fn()
const mockUsePreviewOnlineDocument = jest.fn()
jest.mock('@/service/use-pipeline', () => ({
usePreviewOnlineDocument: () => mockUsePreviewOnlineDocument(),
}))
// Mock data source store - needs mock to control store state
const mockCurrentCredentialId = 'credential-123'
const mockGetState = jest.fn(() => ({
currentCredentialId: mockCurrentCredentialId,
}))
jest.mock('../data-source/store', () => ({
useDataSourceStore: () => ({
getState: mockGetState,
}),
}))
// Test data factory
const createMockNotionPage = (overrides?: Partial<NotionPage>): NotionPage => ({
page_id: 'page-123',
page_name: 'Test Notion Page',
workspace_id: 'workspace-456',
type: 'page',
page_icon: null,
parent_id: 'parent-789',
is_bound: true,
...overrides,
})
const defaultProps = {
currentPage: createMockNotionPage(),
datasourceNodeId: 'datasource-node-123',
hidePreview: jest.fn(),
}
describe('OnlineDocumentPreview', () => {
beforeEach(() => {
jest.clearAllMocks()
mockPipelineId.mockReturnValue('pipeline-123')
mockUsePreviewOnlineDocument.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: false,
})
mockMutateAsync.mockImplementation((params, callbacks) => {
callbacks.onSuccess({ content: 'Test content' })
return Promise.resolve({ content: 'Test content' })
})
})
describe('Rendering', () => {
it('should render the component with page information', () => {
render(<OnlineDocumentPreview {...defaultProps} />)
// i18n mock returns key by default
expect(screen.getByText('datasetPipeline.addDocuments.stepOne.preview')).toBeInTheDocument()
expect(screen.getByText('Test Notion Page')).toBeInTheDocument()
})
it('should display page type', () => {
const currentPage = createMockNotionPage({ type: 'database' })
render(<OnlineDocumentPreview {...defaultProps} currentPage={currentPage} />)
expect(screen.getByText('database')).toBeInTheDocument()
})
it('should render close button', () => {
render(<OnlineDocumentPreview {...defaultProps} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('Data Fetching', () => {
it('should call mutateAsync with correct parameters on mount', async () => {
const currentPage = createMockNotionPage({
workspace_id: 'ws-123',
page_id: 'pg-456',
type: 'page',
})
render(
<OnlineDocumentPreview
{...defaultProps}
currentPage={currentPage}
datasourceNodeId="node-789"
/>,
)
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(
{
workspaceID: 'ws-123',
pageID: 'pg-456',
pageType: 'page',
pipelineId: 'pipeline-123',
datasourceNodeId: 'node-789',
credentialId: mockCurrentCredentialId,
},
expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}),
)
})
})
it('should fetch data again when page_id changes', async () => {
const currentPage1 = createMockNotionPage({ page_id: 'page-1' })
const currentPage2 = createMockNotionPage({ page_id: 'page-2' })
const { rerender } = render(
<OnlineDocumentPreview {...defaultProps} currentPage={currentPage1} />,
)
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledTimes(1)
})
rerender(<OnlineDocumentPreview {...defaultProps} currentPage={currentPage2} />)
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledTimes(2)
})
})
it('should handle empty pipelineId', async () => {
mockPipelineId.mockReturnValue(undefined)
render(<OnlineDocumentPreview {...defaultProps} />)
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
pipelineId: '',
}),
expect.anything(),
)
})
})
})
describe('Loading State', () => {
it('should render loading component when isPending is true', () => {
mockUsePreviewOnlineDocument.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: true,
})
render(<OnlineDocumentPreview {...defaultProps} />)
// Loading component renders skeleton
expect(document.querySelector('.overflow-hidden')).toBeInTheDocument()
})
it('should not render markdown content when loading', () => {
mockUsePreviewOnlineDocument.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: true,
})
render(<OnlineDocumentPreview {...defaultProps} />)
// Content area should not be present
expect(screen.queryByText('Test content')).not.toBeInTheDocument()
})
})
describe('Content Display', () => {
it('should render markdown content when loaded', async () => {
mockMutateAsync.mockImplementation((params, callbacks) => {
callbacks.onSuccess({ content: 'Markdown content here' })
return Promise.resolve({ content: 'Markdown content here' })
})
render(<OnlineDocumentPreview {...defaultProps} />)
await waitFor(() => {
// Markdown component renders the content
const contentArea = document.querySelector('.overflow-hidden.px-6.py-5')
expect(contentArea).toBeInTheDocument()
})
})
it('should display character count', async () => {
mockMutateAsync.mockImplementation((params, callbacks) => {
callbacks.onSuccess({ content: 'Hello' }) // 5 characters
return Promise.resolve({ content: 'Hello' })
})
render(<OnlineDocumentPreview {...defaultProps} />)
await waitFor(() => {
// Real formatNumberAbbreviated returns "5" for numbers < 1000
expect(screen.getByText(/5/)).toBeInTheDocument()
})
})
it('should format large character counts', async () => {
const longContent = 'a'.repeat(2500)
mockMutateAsync.mockImplementation((params, callbacks) => {
callbacks.onSuccess({ content: longContent })
return Promise.resolve({ content: longContent })
})
render(<OnlineDocumentPreview {...defaultProps} />)
await waitFor(() => {
// Real formatNumberAbbreviated uses lowercase 'k': "2.5k"
expect(screen.getByText(/2\.5k/)).toBeInTheDocument()
})
})
it('should show character count based on fetched content', async () => {
// When content is set via onSuccess, character count is displayed
mockMutateAsync.mockImplementation((params, callbacks) => {
callbacks.onSuccess({ content: 'Test content' }) // 12 characters
return Promise.resolve({ content: 'Test content' })
})
render(<OnlineDocumentPreview {...defaultProps} />)
await waitFor(() => {
expect(screen.getByText(/12/)).toBeInTheDocument()
})
})
})
describe('Error Handling', () => {
it('should show toast notification on error', async () => {
const errorMessage = 'Failed to fetch document'
mockMutateAsync.mockImplementation((params, callbacks) => {
callbacks.onError(new Error(errorMessage))
// Return a resolved promise to avoid unhandled rejection
return Promise.resolve()
})
render(<OnlineDocumentPreview {...defaultProps} />)
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith({
type: 'error',
message: errorMessage,
})
})
})
it('should handle network errors', async () => {
const networkError = new Error('Network Error')
mockMutateAsync.mockImplementation((params, callbacks) => {
callbacks.onError(networkError)
// Return a resolved promise to avoid unhandled rejection
return Promise.resolve()
})
render(<OnlineDocumentPreview {...defaultProps} />)
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith({
type: 'error',
message: 'Network Error',
})
})
})
})
describe('User Interactions', () => {
it('should call hidePreview when close button is clicked', () => {
const hidePreview = jest.fn()
render(<OnlineDocumentPreview {...defaultProps} hidePreview={hidePreview} />)
// Find the close button in the header area (not toast buttons)
const headerArea = document.querySelector('.flex.gap-x-2.border-b')
const closeButton = headerArea?.querySelector('button')
expect(closeButton).toBeInTheDocument()
fireEvent.click(closeButton!)
expect(hidePreview).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {
it('should handle undefined page_name', () => {
const currentPage = createMockNotionPage({ page_name: '' })
render(<OnlineDocumentPreview {...defaultProps} currentPage={currentPage} />)
// Find the close button in the header area
const headerArea = document.querySelector('.flex.gap-x-2.border-b')
const closeButton = headerArea?.querySelector('button')
expect(closeButton).toBeInTheDocument()
})
it('should handle different page types', () => {
const currentPage = createMockNotionPage({ type: 'database' })
render(<OnlineDocumentPreview {...defaultProps} currentPage={currentPage} />)
expect(screen.getByText('database')).toBeInTheDocument()
})
it('should use credentialId from store', async () => {
mockGetState.mockReturnValue({
currentCredentialId: 'custom-credential',
})
render(<OnlineDocumentPreview {...defaultProps} />)
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
credentialId: 'custom-credential',
}),
expect.anything(),
)
})
})
it('should not render markdown content when content is empty and not pending', async () => {
mockMutateAsync.mockImplementation((params, callbacks) => {
callbacks.onSuccess({ content: '' })
return Promise.resolve({ content: '' })
})
mockUsePreviewOnlineDocument.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: false,
})
render(<OnlineDocumentPreview {...defaultProps} />)
// Content is empty, markdown area should still render but be empty
await waitFor(() => {
expect(screen.queryByText('Test content')).not.toBeInTheDocument()
})
})
})
})

View File

@@ -0,0 +1,256 @@
import { fireEvent, render, screen } from '@testing-library/react'
import React from 'react'
import WebsitePreview from './web-preview'
import type { CrawlResultItem } from '@/models/datasets'
// Uses __mocks__/react-i18next.ts automatically
// Test data factory
const createMockCrawlResult = (overrides?: Partial<CrawlResultItem>): CrawlResultItem => ({
title: 'Test Website Title',
markdown: 'This is the **markdown** content of the website.',
description: 'Test description',
source_url: 'https://example.com/page',
...overrides,
})
const defaultProps = {
currentWebsite: createMockCrawlResult(),
hidePreview: jest.fn(),
}
describe('WebsitePreview', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('Rendering', () => {
it('should render the component with website information', () => {
render(<WebsitePreview {...defaultProps} />)
// i18n mock returns key by default
expect(screen.getByText('datasetPipeline.addDocuments.stepOne.preview')).toBeInTheDocument()
expect(screen.getByText('Test Website Title')).toBeInTheDocument()
})
it('should display the source URL', () => {
render(<WebsitePreview {...defaultProps} />)
expect(screen.getByText('https://example.com/page')).toBeInTheDocument()
})
it('should render close button', () => {
render(<WebsitePreview {...defaultProps} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render the markdown content', () => {
render(<WebsitePreview {...defaultProps} />)
expect(screen.getByText('This is the **markdown** content of the website.')).toBeInTheDocument()
})
})
describe('Character Count', () => {
it('should display character count for small content', () => {
const currentWebsite = createMockCrawlResult({ markdown: 'Hello' }) // 5 characters
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
// Real formatNumberAbbreviated returns "5" for numbers < 1000
expect(screen.getByText(/5/)).toBeInTheDocument()
})
it('should format character count in thousands', () => {
const longContent = 'a'.repeat(2500)
const currentWebsite = createMockCrawlResult({ markdown: longContent })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
// Real formatNumberAbbreviated uses lowercase 'k': "2.5k"
expect(screen.getByText(/2\.5k/)).toBeInTheDocument()
})
it('should format character count in millions', () => {
const veryLongContent = 'a'.repeat(1500000)
const currentWebsite = createMockCrawlResult({ markdown: veryLongContent })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByText(/1\.5M/)).toBeInTheDocument()
})
it('should show 0 characters for empty markdown', () => {
const currentWebsite = createMockCrawlResult({ markdown: '' })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByText(/0/)).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call hidePreview when close button is clicked', () => {
const hidePreview = jest.fn()
render(<WebsitePreview {...defaultProps} hidePreview={hidePreview} />)
const closeButton = screen.getByRole('button')
fireEvent.click(closeButton)
expect(hidePreview).toHaveBeenCalledTimes(1)
})
})
describe('URL Display', () => {
it('should display long URLs', () => {
const longUrl = 'https://example.com/very/long/path/to/page/with/many/segments'
const currentWebsite = createMockCrawlResult({ source_url: longUrl })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
const urlElement = screen.getByTitle(longUrl)
expect(urlElement).toBeInTheDocument()
expect(urlElement).toHaveTextContent(longUrl)
})
it('should display URL with title attribute', () => {
const currentWebsite = createMockCrawlResult({ source_url: 'https://test.com' })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByTitle('https://test.com')).toBeInTheDocument()
})
})
describe('Content Display', () => {
it('should display the markdown content in content area', () => {
const currentWebsite = createMockCrawlResult({
markdown: 'Content with **bold** and *italic* text.',
})
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByText('Content with **bold** and *italic* text.')).toBeInTheDocument()
})
it('should handle multiline content', () => {
const multilineContent = 'Line 1\nLine 2\nLine 3'
const currentWebsite = createMockCrawlResult({ markdown: multilineContent })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
// Multiline content is rendered as-is
expect(screen.getByText((content) => {
return content.includes('Line 1') && content.includes('Line 2') && content.includes('Line 3')
})).toBeInTheDocument()
})
it('should handle special characters in content', () => {
const specialContent = '<script>alert("xss")</script> & < > " \''
const currentWebsite = createMockCrawlResult({ markdown: specialContent })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByText(specialContent)).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty title', () => {
const currentWebsite = createMockCrawlResult({ title: '' })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle empty source URL', () => {
const currentWebsite = createMockCrawlResult({ source_url: '' })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle very long title', () => {
const longTitle = 'A'.repeat(500)
const currentWebsite = createMockCrawlResult({ title: longTitle })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByText(longTitle)).toBeInTheDocument()
})
it('should handle unicode characters in content', () => {
const unicodeContent = '你好世界 🌍 مرحبا こんにちは'
const currentWebsite = createMockCrawlResult({ markdown: unicodeContent })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByText(unicodeContent)).toBeInTheDocument()
})
it('should handle URL with query parameters', () => {
const urlWithParams = 'https://example.com/page?query=test&param=value'
const currentWebsite = createMockCrawlResult({ source_url: urlWithParams })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByTitle(urlWithParams)).toBeInTheDocument()
})
it('should handle URL with hash fragment', () => {
const urlWithHash = 'https://example.com/page#section-1'
const currentWebsite = createMockCrawlResult({ source_url: urlWithHash })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByTitle(urlWithHash)).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should apply container styles', () => {
const { container } = render(<WebsitePreview {...defaultProps} />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toHaveClass('flex', 'h-full', 'w-full', 'flex-col')
})
})
describe('Multiple Renders', () => {
it('should update when currentWebsite changes', () => {
const website1 = createMockCrawlResult({ title: 'Website 1', markdown: 'Content 1' })
const website2 = createMockCrawlResult({ title: 'Website 2', markdown: 'Content 2' })
const { rerender } = render(<WebsitePreview {...defaultProps} currentWebsite={website1} />)
expect(screen.getByText('Website 1')).toBeInTheDocument()
expect(screen.getByText('Content 1')).toBeInTheDocument()
rerender(<WebsitePreview {...defaultProps} currentWebsite={website2} />)
expect(screen.getByText('Website 2')).toBeInTheDocument()
expect(screen.getByText('Content 2')).toBeInTheDocument()
})
it('should call new hidePreview when prop changes', () => {
const hidePreview1 = jest.fn()
const hidePreview2 = jest.fn()
const { rerender } = render(<WebsitePreview {...defaultProps} hidePreview={hidePreview1} />)
const closeButton = screen.getByRole('button')
fireEvent.click(closeButton)
expect(hidePreview1).toHaveBeenCalledTimes(1)
rerender(<WebsitePreview {...defaultProps} hidePreview={hidePreview2} />)
fireEvent.click(closeButton)
expect(hidePreview2).toHaveBeenCalledTimes(1)
expect(hidePreview1).toHaveBeenCalledTimes(1)
})
})
})