test: add unit tests for website crawl and document preview components

- Introduced new test files for CheckboxWithLabel, CrawledResultItem, ErrorMessage, and various components related to website crawling and document preview.
- Enhanced test coverage by validating rendering, user interactions, and edge cases for each component.
- Ensured proper functionality of user interactions such as checkbox selections, button clicks, and rendering of dynamic content.

These additions improve the reliability and maintainability of the website crawl and document preview features.
This commit is contained in:
CodingOnStar
2026-02-10 17:31:40 +08:00
parent 5e6e8a16ce
commit 5006a5e804
26 changed files with 2171 additions and 1203 deletions

View File

@ -0,0 +1,176 @@
import type { ParentChildConfig } from '../hooks'
import type { FileIndexingEstimateResponse } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode, DataSourceType } from '@/models/datasets'
import { PreviewPanel } from './preview-panel'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: { count?: number }) => opts?.count !== undefined ? `${key}-${opts.count}` : key,
}),
}))
vi.mock('@remixicon/react', () => ({
RiSearchEyeLine: () => <span data-testid="search-icon" />,
}))
vi.mock('@/app/components/base/float-right-container', () => ({
default: ({ children }: { children: React.ReactNode }) => <div data-testid="float-container">{children}</div>,
}))
vi.mock('@/app/components/base/badge', () => ({
default: ({ text }: { text: string }) => <span data-testid="badge">{text}</span>,
}))
vi.mock('@/app/components/base/skeleton', () => ({
SkeletonContainer: ({ children }: { children: React.ReactNode }) => <div data-testid="skeleton">{children}</div>,
SkeletonPoint: () => <span />,
SkeletonRectangle: () => <span />,
SkeletonRow: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('../../../chunk', () => ({
ChunkContainer: ({ children, label }: { children: React.ReactNode, label: string }) => (
<div data-testid="chunk-container">
{label}
:
{' '}
{children}
</div>
),
QAPreview: ({ qa }: { qa: { question: string } }) => <div data-testid="qa-preview">{qa.question}</div>,
}))
vi.mock('../../../common/document-picker/preview-document-picker', () => ({
default: () => <div data-testid="doc-picker" />,
}))
vi.mock('../../../documents/detail/completed/common/summary-label', () => ({
default: ({ summary }: { summary: string }) => <span data-testid="summary">{summary}</span>,
}))
vi.mock('../../../formatted-text/flavours/preview-slice', () => ({
PreviewSlice: ({ label, text }: { label: string, text: string }) => (
<span data-testid="preview-slice">
{label}
:
{' '}
{text}
</span>
),
}))
vi.mock('../../../formatted-text/formatted', () => ({
FormattedText: ({ children }: { children: React.ReactNode }) => <p data-testid="formatted-text">{children}</p>,
}))
vi.mock('../../../preview/container', () => ({
default: ({ children, header }: { children: React.ReactNode, header: React.ReactNode }) => (
<div data-testid="preview-container">
{header}
{children}
</div>
),
}))
vi.mock('../../../preview/header', () => ({
PreviewHeader: ({ children, title }: { children: React.ReactNode, title: string }) => (
<div data-testid="preview-header">
{title}
{children}
</div>
),
}))
vi.mock('@/config', () => ({
FULL_DOC_PREVIEW_LENGTH: 3,
}))
describe('PreviewPanel', () => {
const defaultProps = {
isMobile: false,
dataSourceType: DataSourceType.FILE,
currentDocForm: ChunkingMode.text,
parentChildConfig: { chunkForContext: 'paragraph' } as ParentChildConfig,
pickerFiles: [{ id: '1', name: 'file.pdf', extension: 'pdf' }],
pickerValue: { id: '1', name: 'file.pdf', extension: 'pdf' },
isIdle: false,
isPending: false,
onPickerChange: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render preview header with title', () => {
render(<PreviewPanel {...defaultProps} />)
expect(screen.getByTestId('preview-header')).toHaveTextContent('stepTwo.preview')
})
it('should render document picker', () => {
render(<PreviewPanel {...defaultProps} />)
expect(screen.getByTestId('doc-picker')).toBeInTheDocument()
})
it('should show idle state when isIdle is true', () => {
render(<PreviewPanel {...defaultProps} isIdle={true} />)
expect(screen.getByText('stepTwo.previewChunkTip')).toBeInTheDocument()
})
it('should show loading skeletons when isPending', () => {
render(<PreviewPanel {...defaultProps} isPending={true} />)
expect(screen.getAllByTestId('skeleton')).toHaveLength(10)
})
it('should render text preview chunks', () => {
const estimate: Partial<FileIndexingEstimateResponse> = {
total_segments: 2,
preview: [
{ content: 'chunk 1 text', child_chunks: [], summary: '' },
{ content: 'chunk 2 text', child_chunks: [], summary: 'summary text' },
],
}
render(<PreviewPanel {...defaultProps} estimate={estimate as FileIndexingEstimateResponse} />)
expect(screen.getAllByTestId('chunk-container')).toHaveLength(2)
})
it('should render QA preview', () => {
const estimate: Partial<FileIndexingEstimateResponse> = {
qa_preview: [
{ question: 'Q1', answer: 'A1' },
],
}
render(
<PreviewPanel
{...defaultProps}
currentDocForm={ChunkingMode.qa}
estimate={estimate as FileIndexingEstimateResponse}
/>,
)
expect(screen.getByTestId('qa-preview')).toHaveTextContent('Q1')
})
it('should render parent-child preview', () => {
const estimate: Partial<FileIndexingEstimateResponse> = {
preview: [
{ content: 'parent chunk', child_chunks: ['child1', 'child2'], summary: '' },
],
}
render(
<PreviewPanel
{...defaultProps}
currentDocForm={ChunkingMode.parentChild}
estimate={estimate as FileIndexingEstimateResponse}
/>,
)
expect(screen.getAllByTestId('preview-slice')).toHaveLength(2)
})
it('should show badge with chunk count for non-QA mode', () => {
const estimate: Partial<FileIndexingEstimateResponse> = { total_segments: 5, preview: [] }
render(<PreviewPanel {...defaultProps} estimate={estimate as FileIndexingEstimateResponse} />)
expect(screen.getByTestId('badge')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,237 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DocumentActionType } from '@/models/datasets'
import { useDocumentActions } from './use-document-actions'
const mockArchive = vi.fn()
const mockSummary = vi.fn()
const mockEnable = vi.fn()
const mockDisable = vi.fn()
const mockDelete = vi.fn()
const mockRetryIndex = vi.fn()
const mockDownloadZip = vi.fn()
let mockIsDownloadingZip = false
vi.mock('@/service/knowledge/use-document', () => ({
useDocumentArchive: () => ({ mutateAsync: mockArchive }),
useDocumentSummary: () => ({ mutateAsync: mockSummary }),
useDocumentEnable: () => ({ mutateAsync: mockEnable }),
useDocumentDisable: () => ({ mutateAsync: mockDisable }),
useDocumentDelete: () => ({ mutateAsync: mockDelete }),
useDocumentBatchRetryIndex: () => ({ mutateAsync: mockRetryIndex }),
useDocumentDownloadZip: () => ({ mutateAsync: mockDownloadZip, isPending: mockIsDownloadingZip }),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
const mockToastNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
default: { notify: (...args: unknown[]) => mockToastNotify(...args) },
}))
const mockDownloadBlob = vi.fn()
vi.mock('@/utils/download', () => ({
downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args),
}))
describe('useDocumentActions', () => {
const defaultOptions = {
datasetId: 'ds-1',
selectedIds: ['doc-1', 'doc-2'],
downloadableSelectedIds: ['doc-1'],
onUpdate: vi.fn(),
onClearSelection: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
mockIsDownloadingZip = false
})
it('should return expected functions and state', () => {
const { result } = renderHook(() => useDocumentActions(defaultOptions))
expect(result.current.handleAction).toBeInstanceOf(Function)
expect(result.current.handleBatchReIndex).toBeInstanceOf(Function)
expect(result.current.handleBatchDownload).toBeInstanceOf(Function)
expect(typeof result.current.isDownloadingZip).toBe('boolean')
})
describe('handleAction', () => {
it('should call archive API and show success toast', async () => {
mockArchive.mockResolvedValue({ result: 'success' })
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleAction(DocumentActionType.archive)()
})
expect(mockArchive).toHaveBeenCalledWith({
datasetId: 'ds-1',
documentIds: ['doc-1', 'doc-2'],
})
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'success' }),
)
expect(defaultOptions.onUpdate).toHaveBeenCalled()
})
it('should call enable API on enable action', async () => {
mockEnable.mockResolvedValue({ result: 'success' })
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleAction(DocumentActionType.enable)()
})
expect(mockEnable).toHaveBeenCalledWith({
datasetId: 'ds-1',
documentIds: ['doc-1', 'doc-2'],
})
expect(defaultOptions.onUpdate).toHaveBeenCalled()
})
it('should call disable API on disable action', async () => {
mockDisable.mockResolvedValue({ result: 'success' })
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleAction(DocumentActionType.disable)()
})
expect(mockDisable).toHaveBeenCalled()
})
it('should call summary API on summary action', async () => {
mockSummary.mockResolvedValue({ result: 'success' })
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleAction(DocumentActionType.summary)()
})
expect(mockSummary).toHaveBeenCalled()
})
it('should call onClearSelection on delete action success', async () => {
mockDelete.mockResolvedValue({ result: 'success' })
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleAction(DocumentActionType.delete)()
})
expect(mockDelete).toHaveBeenCalled()
expect(defaultOptions.onClearSelection).toHaveBeenCalled()
expect(defaultOptions.onUpdate).toHaveBeenCalled()
})
it('should not call onClearSelection on non-delete action success', async () => {
mockArchive.mockResolvedValue({ result: 'success' })
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleAction(DocumentActionType.archive)()
})
expect(defaultOptions.onClearSelection).not.toHaveBeenCalled()
})
it('should show error toast on action failure', async () => {
mockArchive.mockRejectedValue(new Error('fail'))
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleAction(DocumentActionType.archive)()
})
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
expect(defaultOptions.onUpdate).not.toHaveBeenCalled()
})
})
describe('handleBatchReIndex', () => {
it('should call retry index API and show success toast', async () => {
mockRetryIndex.mockResolvedValue({ result: 'success' })
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleBatchReIndex()
})
expect(mockRetryIndex).toHaveBeenCalledWith({
datasetId: 'ds-1',
documentIds: ['doc-1', 'doc-2'],
})
expect(defaultOptions.onClearSelection).toHaveBeenCalled()
expect(defaultOptions.onUpdate).toHaveBeenCalled()
})
it('should show error toast on reindex failure', async () => {
mockRetryIndex.mockRejectedValue(new Error('fail'))
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleBatchReIndex()
})
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
})
describe('handleBatchDownload', () => {
it('should download blob on success', async () => {
const blob = new Blob(['test'])
mockDownloadZip.mockResolvedValue(blob)
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleBatchDownload()
})
expect(mockDownloadZip).toHaveBeenCalledWith({
datasetId: 'ds-1',
documentIds: ['doc-1'],
})
expect(mockDownloadBlob).toHaveBeenCalledWith(
expect.objectContaining({
data: blob,
fileName: expect.stringContaining('-docs.zip'),
}),
)
})
it('should show error toast on download failure', async () => {
mockDownloadZip.mockRejectedValue(new Error('fail'))
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleBatchDownload()
})
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
it('should show error toast when blob is null', async () => {
mockDownloadZip.mockResolvedValue(null)
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleBatchDownload()
})
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
})
})

View File

@ -1,658 +1,64 @@
import type { DataSourceCredential } from '@/types/pipeline'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import Header from './header'
// Mock CredentialTypeEnum to avoid deep import chain issues
enum MockCredentialTypeEnum {
OAUTH2 = 'oauth2',
API_KEY = 'api_key',
}
// Mock plugin-auth module to avoid deep import chain issues
vi.mock('@/app/components/plugins/plugin-auth', () => ({
CredentialTypeEnum: {
OAUTH2: 'oauth2',
API_KEY: 'api_key',
},
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: { pluginName?: string }) => opts?.pluginName ? `${key}-${opts.pluginName}` : key,
}),
}))
// Mock portal-to-follow-elem - required for CredentialSelector
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
const MockPortalToFollowElem = ({ children, open }: any) => {
return (
<div data-testid="portal-root" data-open={open}>
{React.Children.map(children, (child: any) => {
if (!child)
return null
return React.cloneElement(child, { __portalOpen: open })
})}
</div>
)
}
vi.mock('@remixicon/react', () => ({
RiBookOpenLine: () => <span data-testid="book-icon" />,
RiEqualizer2Line: ({ onClick }: { onClick?: () => void }) => <span data-testid="config-icon" onClick={onClick} />,
}))
const MockPortalToFollowElemTrigger = ({ children, onClick, className, __portalOpen }: any) => (
<div data-testid="portal-trigger" onClick={onClick} className={className} data-open={__portalOpen}>
{children}
</div>
)
vi.mock('@/app/components/base/button', () => ({
default: ({ children }: { children: React.ReactNode }) => <button>{children}</button>,
}))
const MockPortalToFollowElemContent = ({ children, className, __portalOpen }: any) => {
if (!__portalOpen)
return null
return (
<div data-testid="portal-content" className={className}>
{children}
</div>
)
}
vi.mock('@/app/components/base/divider', () => ({
default: () => <span data-testid="divider" />,
}))
return {
PortalToFollowElem: MockPortalToFollowElem,
PortalToFollowElemTrigger: MockPortalToFollowElemTrigger,
PortalToFollowElemContent: MockPortalToFollowElemContent,
}
})
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children }: { children: React.ReactNode }) => <div data-testid="tooltip">{children}</div>,
}))
// ==========================================
// Test Data Builders
// ==========================================
const createMockCredential = (overrides?: Partial<DataSourceCredential>): DataSourceCredential => ({
id: 'cred-1',
name: 'Test Credential',
avatar_url: 'https://example.com/avatar.png',
credential: { key: 'value' },
is_default: false,
type: MockCredentialTypeEnum.OAUTH2 as unknown as DataSourceCredential['type'],
...overrides,
})
const createMockCredentials = (count: number = 3): DataSourceCredential[] =>
Array.from({ length: count }, (_, i) =>
createMockCredential({
id: `cred-${i + 1}`,
name: `Credential ${i + 1}`,
avatar_url: `https://example.com/avatar-${i + 1}.png`,
is_default: i === 0,
}))
type HeaderProps = React.ComponentProps<typeof Header>
const createDefaultProps = (overrides?: Partial<HeaderProps>): HeaderProps => ({
docTitle: 'Documentation',
docLink: 'https://docs.example.com',
pluginName: 'Test Plugin',
currentCredentialId: 'cred-1',
onCredentialChange: vi.fn(),
credentials: createMockCredentials(),
...overrides,
})
vi.mock('./credential-selector', () => ({
default: () => <div data-testid="credential-selector" />,
}))
describe('Header', () => {
beforeEach(() => {
vi.clearAllMocks()
const defaultProps = {
docTitle: 'Documentation',
docLink: 'https://docs.example.com',
onClickConfiguration: vi.fn(),
pluginName: 'TestPlugin',
credentials: [],
currentCredentialId: '',
onCredentialChange: vi.fn(),
}
it('should render doc link with title', () => {
render(<Header {...defaultProps} />)
expect(screen.getByText('Documentation')).toBeInTheDocument()
})
// ==========================================
// Rendering Tests
// ==========================================
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText('Documentation')).toBeInTheDocument()
})
it('should render documentation link with correct attributes', () => {
// Arrange
const props = createDefaultProps({
docTitle: 'API Docs',
docLink: 'https://api.example.com/docs',
})
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link', { name: /API Docs/i })
expect(link).toHaveAttribute('href', 'https://api.example.com/docs')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
it('should render document title with title attribute', () => {
// Arrange
const props = createDefaultProps({ docTitle: 'My Documentation' })
// Act
render(<Header {...props} />)
// Assert
const titleSpan = screen.getByText('My Documentation')
expect(titleSpan).toHaveAttribute('title', 'My Documentation')
})
it('should render CredentialSelector with correct props', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert - CredentialSelector should render current credential name
expect(screen.getByText('Credential 1')).toBeInTheDocument()
})
it('should render configuration button', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render book icon in documentation link', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert - RiBookOpenLine renders as SVG
const link = screen.getByRole('link')
const svg = link.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should render divider between credential selector and configuration button', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Header {...props} />)
// Assert - Divider component should be rendered
// Divider typically renders as a div with specific styling
const divider = container.querySelector('[class*="divider"]') || container.querySelector('.mx-1.h-3\\.5')
expect(divider).toBeInTheDocument()
})
it('should render credential selector', () => {
render(<Header {...defaultProps} />)
expect(screen.getByTestId('credential-selector')).toBeInTheDocument()
})
// ==========================================
// Props Testing
// ==========================================
describe('Props', () => {
describe('docTitle prop', () => {
it('should display the document title', () => {
// Arrange
const props = createDefaultProps({ docTitle: 'Getting Started Guide' })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText('Getting Started Guide')).toBeInTheDocument()
})
it.each([
'Quick Start',
'API Reference',
'Configuration Guide',
'Plugin Documentation',
])('should display "%s" as document title', (title) => {
// Arrange
const props = createDefaultProps({ docTitle: title })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText(title)).toBeInTheDocument()
})
})
describe('docLink prop', () => {
it('should set correct href on documentation link', () => {
// Arrange
const props = createDefaultProps({ docLink: 'https://custom.docs.com/guide' })
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link')
expect(link).toHaveAttribute('href', 'https://custom.docs.com/guide')
})
it.each([
'https://docs.dify.ai',
'https://example.com/api',
'/local/docs',
])('should accept "%s" as docLink', (link) => {
// Arrange
const props = createDefaultProps({ docLink: link })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByRole('link')).toHaveAttribute('href', link)
})
})
describe('pluginName prop', () => {
it('should pass pluginName to translation function', () => {
// Arrange
const props = createDefaultProps({ pluginName: 'MyPlugin' })
// Act
render(<Header {...props} />)
// Assert - The translation mock returns the key with options
// Tooltip uses the translated content
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('onClickConfiguration prop', () => {
it('should call onClickConfiguration when configuration icon is clicked', () => {
// Arrange
const mockOnClick = vi.fn()
const props = createDefaultProps({ onClickConfiguration: mockOnClick })
render(<Header {...props} />)
// Act - Find the configuration button and click the icon inside
// The button contains the RiEqualizer2Line icon with onClick handler
const configButton = screen.getByRole('button')
const configIcon = configButton.querySelector('svg')
expect(configIcon).toBeInTheDocument()
fireEvent.click(configIcon!)
// Assert
expect(mockOnClick).toHaveBeenCalledTimes(1)
})
it('should not crash when onClickConfiguration is undefined', () => {
// Arrange
const props = createDefaultProps({ onClickConfiguration: undefined })
render(<Header {...props} />)
// Act - Find the configuration button and click the icon inside
const configButton = screen.getByRole('button')
const configIcon = configButton.querySelector('svg')
expect(configIcon).toBeInTheDocument()
fireEvent.click(configIcon!)
// Assert - Component should still be rendered (no crash)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('CredentialSelector props passthrough', () => {
it('should pass currentCredentialId to CredentialSelector', () => {
// Arrange
const props = createDefaultProps({ currentCredentialId: 'cred-2' })
// Act
render(<Header {...props} />)
// Assert - Should display the second credential
expect(screen.getByText('Credential 2')).toBeInTheDocument()
})
it('should pass credentials to CredentialSelector', () => {
// Arrange
const customCredentials = [
createMockCredential({ id: 'custom-1', name: 'Custom Credential' }),
]
const props = createDefaultProps({
credentials: customCredentials,
currentCredentialId: 'custom-1',
})
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText('Custom Credential')).toBeInTheDocument()
})
it('should pass onCredentialChange to CredentialSelector', () => {
// Arrange
const mockOnChange = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange })
render(<Header {...props} />)
// Act - Open dropdown and select a credential
// Use getAllByTestId and select the first one (CredentialSelector's trigger)
const triggers = screen.getAllByTestId('portal-trigger')
fireEvent.click(triggers[0])
const credential2 = screen.getByText('Credential 2')
fireEvent.click(credential2)
// Assert
expect(mockOnChange).toHaveBeenCalledWith('cred-2')
})
})
it('should render configuration button', () => {
render(<Header {...defaultProps} />)
expect(screen.getByTestId('config-icon')).toBeInTheDocument()
})
// ==========================================
// User Interactions
// ==========================================
describe('User Interactions', () => {
it('should open external link in new tab when clicking documentation link', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert - Link has target="_blank" for new tab
const link = screen.getByRole('link')
expect(link).toHaveAttribute('target', '_blank')
})
it('should allow credential selection through CredentialSelector', () => {
// Arrange
const mockOnChange = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange })
render(<Header {...props} />)
// Act - Open dropdown (use first trigger which is CredentialSelector's)
const triggers = screen.getAllByTestId('portal-trigger')
fireEvent.click(triggers[0])
// Assert - Dropdown should be open
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
})
it('should trigger configuration callback when clicking config icon', () => {
// Arrange
const mockOnConfig = vi.fn()
const props = createDefaultProps({ onClickConfiguration: mockOnConfig })
const { container } = render(<Header {...props} />)
// Act
const configIcon = container.querySelector('.h-4.w-4')
fireEvent.click(configIcon!)
// Assert
expect(mockOnConfig).toHaveBeenCalled()
})
})
// ==========================================
// Component Memoization
// ==========================================
describe('Component Memoization', () => {
it('should be wrapped with React.memo', () => {
// Assert
expect(Header.$$typeof).toBe(Symbol.for('react.memo'))
})
it('should not re-render when props remain the same', () => {
// Arrange
const props = createDefaultProps()
const renderSpy = vi.fn()
const TrackedHeader: React.FC<HeaderProps> = (trackedProps) => {
renderSpy()
return <Header {...trackedProps} />
}
const MemoizedTracked = React.memo(TrackedHeader)
// Act
const { rerender } = render(<MemoizedTracked {...props} />)
rerender(<MemoizedTracked {...props} />)
// Assert - Should only render once due to same props
expect(renderSpy).toHaveBeenCalledTimes(1)
})
it('should re-render when docTitle changes', () => {
// Arrange
const props = createDefaultProps({ docTitle: 'Original Title' })
const { rerender } = render(<Header {...props} />)
// Assert initial
expect(screen.getByText('Original Title')).toBeInTheDocument()
// Act
rerender(<Header {...props} docTitle="Updated Title" />)
// Assert
expect(screen.getByText('Updated Title')).toBeInTheDocument()
})
it('should re-render when currentCredentialId changes', () => {
// Arrange
const props = createDefaultProps({ currentCredentialId: 'cred-1' })
const { rerender } = render(<Header {...props} />)
// Assert initial
expect(screen.getByText('Credential 1')).toBeInTheDocument()
// Act
rerender(<Header {...props} currentCredentialId="cred-2" />)
// Assert
expect(screen.getByText('Credential 2')).toBeInTheDocument()
})
})
// ==========================================
// Edge Cases
// ==========================================
describe('Edge Cases', () => {
it('should handle empty docTitle', () => {
// Arrange
const props = createDefaultProps({ docTitle: '' })
// Act
render(<Header {...props} />)
// Assert - Should render without crashing
const link = screen.getByRole('link')
expect(link).toBeInTheDocument()
})
it('should handle very long docTitle', () => {
// Arrange
const longTitle = 'A'.repeat(200)
const props = createDefaultProps({ docTitle: longTitle })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText(longTitle)).toBeInTheDocument()
})
it('should handle special characters in docTitle', () => {
// Arrange
const specialTitle = 'Docs & Guide <v2> "Special"'
const props = createDefaultProps({ docTitle: specialTitle })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText(specialTitle)).toBeInTheDocument()
})
it('should handle empty credentials array', () => {
// Arrange
const props = createDefaultProps({
credentials: [],
currentCredentialId: '',
})
// Act
render(<Header {...props} />)
// Assert - Should render without crashing
expect(screen.getByRole('link')).toBeInTheDocument()
})
it('should handle special characters in pluginName', () => {
// Arrange
const props = createDefaultProps({ pluginName: 'Plugin & Tool <v1>' })
// Act
render(<Header {...props} />)
// Assert - Should render without crashing
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle unicode characters in docTitle', () => {
// Arrange
const props = createDefaultProps({ docTitle: '文档说明 📚' })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText('文档说明 📚')).toBeInTheDocument()
})
})
// ==========================================
// Styling
// ==========================================
describe('Styling', () => {
it('should apply correct classes to container', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Header {...props} />)
// Assert
const rootDiv = container.firstChild as HTMLElement
expect(rootDiv).toHaveClass('flex', 'items-center', 'justify-between', 'gap-x-2')
})
it('should apply correct classes to documentation link', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link')
expect(link).toHaveClass('system-xs-medium', 'text-text-accent')
})
it('should apply shrink-0 to documentation link', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link')
expect(link).toHaveClass('shrink-0')
})
})
// ==========================================
// Integration Tests
// ==========================================
describe('Integration', () => {
it('should work with full credential workflow', () => {
// Arrange
const mockOnCredentialChange = vi.fn()
const props = createDefaultProps({
onCredentialChange: mockOnCredentialChange,
currentCredentialId: 'cred-1',
})
render(<Header {...props} />)
// Assert initial state
expect(screen.getByText('Credential 1')).toBeInTheDocument()
// Act - Open dropdown and select different credential
// Use first trigger which is CredentialSelector's
const triggers = screen.getAllByTestId('portal-trigger')
fireEvent.click(triggers[0])
const credential3 = screen.getByText('Credential 3')
fireEvent.click(credential3)
// Assert
expect(mockOnCredentialChange).toHaveBeenCalledWith('cred-3')
})
it('should display all components together correctly', () => {
// Arrange
const mockOnConfig = vi.fn()
const props = createDefaultProps({
docTitle: 'Integration Test Docs',
docLink: 'https://test.com/docs',
pluginName: 'TestPlugin',
onClickConfiguration: mockOnConfig,
})
// Act
render(<Header {...props} />)
// Assert - All main elements present
expect(screen.getByText('Credential 1')).toBeInTheDocument() // CredentialSelector
expect(screen.getByRole('button')).toBeInTheDocument() // Config button
expect(screen.getByText('Integration Test Docs')).toBeInTheDocument() // Doc link
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://test.com/docs')
})
})
// ==========================================
// Accessibility
// ==========================================
describe('Accessibility', () => {
it('should have accessible link', () => {
// Arrange
const props = createDefaultProps({ docTitle: 'Accessible Docs' })
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link', { name: /Accessible Docs/i })
expect(link).toBeInTheDocument()
})
it('should have accessible button for configuration', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})
it('should have noopener noreferrer for security on external links', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
it('should link to external doc', () => {
render(<Header {...defaultProps} />)
const link = screen.getByText('Documentation').closest('a')
expect(link).toHaveAttribute('href', 'https://docs.example.com')
expect(link).toHaveAttribute('target', '_blank')
})
})

View File

@ -1,38 +1,16 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import EmptyFolder from './empty-folder'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
afterEach(() => {
cleanup()
})
describe('EmptyFolder', () => {
it('should render without crashing', () => {
it('should render empty folder message', () => {
render(<EmptyFolder />)
expect(screen.getByText('onlineDrive.emptyFolder')).toBeInTheDocument()
})
it('should render the empty folder text', () => {
render(<EmptyFolder />)
expect(screen.getByText('onlineDrive.emptyFolder')).toBeInTheDocument()
})
it('should have proper styling classes', () => {
const { container } = render(<EmptyFolder />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex')
expect(wrapper).toHaveClass('items-center')
expect(wrapper).toHaveClass('justify-center')
})
it('should be wrapped with React.memo', () => {
expect((EmptyFolder as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
})
})

View File

@ -0,0 +1,96 @@
import type { OnlineDriveFile } from '@/models/pipeline'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Item from './item'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/base/checkbox', () => ({
default: ({ checked, onCheck, disabled }: { checked: boolean, onCheck: () => void, disabled?: boolean }) => (
<input type="checkbox" data-testid="checkbox" checked={checked} onChange={onCheck} disabled={disabled} />
),
}))
vi.mock('@/app/components/base/radio/ui', () => ({
default: ({ isChecked, onCheck }: { isChecked: boolean, onCheck: () => void }) => (
<input type="radio" data-testid="radio" checked={isChecked} onChange={onCheck} />
),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
<div data-testid="tooltip" title={popupContent}>{children}</div>
),
}))
vi.mock('./file-icon', () => ({
default: () => <span data-testid="file-icon" />,
}))
describe('Item', () => {
const makeFile = (type: string, name = 'test.pdf', size = 1024): OnlineDriveFile => ({
id: 'f-1',
name,
type: type as OnlineDriveFile['type'],
size,
})
const defaultProps = {
file: makeFile('file'),
isSelected: false,
onSelect: vi.fn(),
onOpen: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render file name', () => {
render(<Item {...defaultProps} />)
expect(screen.getByText('test.pdf')).toBeInTheDocument()
})
it('should render checkbox for file type in multiple choice mode', () => {
render(<Item {...defaultProps} />)
expect(screen.getByTestId('checkbox')).toBeInTheDocument()
})
it('should render radio for file type in single choice mode', () => {
render(<Item {...defaultProps} isMultipleChoice={false} />)
expect(screen.getByTestId('radio')).toBeInTheDocument()
})
it('should not render checkbox for bucket type', () => {
render(<Item {...defaultProps} file={makeFile('bucket', 'my-bucket')} />)
expect(screen.queryByTestId('checkbox')).not.toBeInTheDocument()
})
it('should call onOpen for folder click', () => {
const file = makeFile('folder', 'my-folder')
render(<Item {...defaultProps} file={file} />)
fireEvent.click(screen.getByText('my-folder'))
expect(defaultProps.onOpen).toHaveBeenCalledWith(file)
})
it('should call onSelect for file click', () => {
render(<Item {...defaultProps} />)
fireEvent.click(screen.getByText('test.pdf'))
expect(defaultProps.onSelect).toHaveBeenCalledWith(defaultProps.file)
})
it('should not call handlers when disabled', () => {
render(<Item {...defaultProps} disabled={true} />)
fireEvent.click(screen.getByText('test.pdf'))
expect(defaultProps.onSelect).not.toHaveBeenCalled()
})
it('should render file icon', () => {
render(<Item {...defaultProps} />)
expect(screen.getByTestId('file-icon')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,50 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CheckboxWithLabel from './checkbox-with-label'
vi.mock('@/app/components/base/checkbox', () => ({
default: ({ checked, onCheck }: { checked: boolean, onCheck: () => void }) => (
<input type="checkbox" data-testid="checkbox" checked={checked} onChange={onCheck} />
),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent: string }) => <div data-testid="tooltip">{popupContent}</div>,
}))
describe('CheckboxWithLabel', () => {
const defaultProps = {
isChecked: false,
onChange: vi.fn(),
label: 'Test Label',
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render label text', () => {
render(<CheckboxWithLabel {...defaultProps} />)
expect(screen.getByText('Test Label')).toBeInTheDocument()
})
it('should render checkbox', () => {
render(<CheckboxWithLabel {...defaultProps} />)
expect(screen.getByTestId('checkbox')).toBeInTheDocument()
})
it('should render tooltip when provided', () => {
render(<CheckboxWithLabel {...defaultProps} tooltip="Help text" />)
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
})
it('should not render tooltip when not provided', () => {
render(<CheckboxWithLabel {...defaultProps} />)
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument()
})
it('should apply custom className', () => {
const { container } = render(<CheckboxWithLabel {...defaultProps} className="custom-cls" />)
expect(container.querySelector('.custom-cls')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,75 @@
import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CrawledResultItem from './crawled-result-item'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/base/button', () => ({
default: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<button data-testid="preview-button" onClick={onClick}>{children}</button>
),
}))
vi.mock('@/app/components/base/checkbox', () => ({
default: ({ checked, onCheck }: { checked: boolean, onCheck: () => void }) => (
<input type="checkbox" data-testid="checkbox" checked={checked} onChange={onCheck} />
),
}))
vi.mock('@/app/components/base/radio/ui', () => ({
default: ({ isChecked, onCheck }: { isChecked: boolean, onCheck: () => void }) => (
<input type="radio" data-testid="radio" checked={isChecked} onChange={onCheck} />
),
}))
describe('CrawledResultItem', () => {
const defaultProps = {
payload: {
title: 'Test Page',
source_url: 'https://example.com/page',
markdown: '',
description: '',
} satisfies CrawlResultItemType,
isChecked: false,
onCheckChange: vi.fn(),
isPreview: false,
showPreview: true,
onPreview: vi.fn(),
isMultipleChoice: true,
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render title and URL', () => {
render(<CrawledResultItem {...defaultProps} />)
expect(screen.getByText('Test Page')).toBeInTheDocument()
expect(screen.getByText('https://example.com/page')).toBeInTheDocument()
})
it('should render checkbox in multiple choice mode', () => {
render(<CrawledResultItem {...defaultProps} />)
expect(screen.getByTestId('checkbox')).toBeInTheDocument()
})
it('should render radio in single choice mode', () => {
render(<CrawledResultItem {...defaultProps} isMultipleChoice={false} />)
expect(screen.getByTestId('radio')).toBeInTheDocument()
})
it('should show preview button when showPreview is true', () => {
render(<CrawledResultItem {...defaultProps} />)
expect(screen.getByTestId('preview-button')).toBeInTheDocument()
})
it('should not show preview button when showPreview is false', () => {
render(<CrawledResultItem {...defaultProps} showPreview={false} />)
expect(screen.queryByTestId('preview-button')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,27 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import Crawling from './crawling'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('Crawling', () => {
it('should render crawl progress', () => {
render(<Crawling crawledNum={5} totalNum={10} />)
expect(screen.getByText(/5/)).toBeInTheDocument()
expect(screen.getByText(/10/)).toBeInTheDocument()
})
it('should render total page scraped label', () => {
render(<Crawling crawledNum={0} totalNum={0} />)
expect(screen.getByText(/stepOne\.website\.totalPageScraped/)).toBeInTheDocument()
})
it('should apply custom className', () => {
const { container } = render(<Crawling crawledNum={1} totalNum={5} className="custom" />)
expect(container.querySelector('.custom')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,35 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ErrorMessage from './error-message'
vi.mock('@remixicon/react', () => ({
RiErrorWarningFill: () => <span data-testid="error-icon" />,
}))
describe('ErrorMessage', () => {
it('should render title', () => {
render(<ErrorMessage title="Something went wrong" />)
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
})
it('should render error icon', () => {
render(<ErrorMessage title="Error" />)
expect(screen.getByTestId('error-icon')).toBeInTheDocument()
})
it('should render error message when provided', () => {
render(<ErrorMessage title="Error" errorMsg="Detailed error info" />)
expect(screen.getByText('Detailed error info')).toBeInTheDocument()
})
it('should not render error message when not provided', () => {
const { container } = render(<ErrorMessage title="Error" />)
const textElements = container.querySelectorAll('.system-xs-regular')
expect(textElements).toHaveLength(0)
})
it('should apply custom className', () => {
const { container } = render(<ErrorMessage title="Error" className="custom-cls" />)
expect(container.querySelector('.custom-cls')).toBeInTheDocument()
})
})

View File

@ -1,320 +1,77 @@
import type { CustomFile as File } from '@/models/datasets'
import type { CustomFile } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import FilePreview from './file-preview'
// Uses global react-i18next mock from web/vitest.setup.ts
// Mock useFilePreview hook - needs to be mocked to control return values
const mockUseFilePreview = vi.fn()
vi.mock('@/service/use-common', () => ({
useFilePreview: (fileID: string) => mockUseFilePreview(fileID),
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// 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: vi.fn() as () => Promise<ArrayBuffer>,
bytes: vi.fn() as () => Promise<Uint8Array>,
slice: vi.fn() as (start?: number, end?: number, contentType?: string) => Blob,
stream: vi.fn() as () => ReadableStream<Uint8Array>,
text: vi.fn() as () => Promise<string>,
...overrides,
} as File)
vi.mock('@remixicon/react', () => ({
RiCloseLine: () => <span data-testid="close-icon" />,
}))
const createMockFilePreviewData = (content: string = 'This is the file content') => ({
content,
})
const mockFileData = { content: 'file content here with some text' }
let mockIsFetching = false
const defaultProps = {
file: createMockFile(),
hidePreview: vi.fn(),
}
vi.mock('@/service/use-common', () => ({
useFilePreview: () => ({
data: mockIsFetching ? undefined : mockFileData,
isFetching: mockIsFetching,
}),
}))
vi.mock('../../../common/document-file-icon', () => ({
default: () => <span data-testid="file-icon" />,
}))
vi.mock('./loading', () => ({
default: () => <div data-testid="loading" />,
}))
describe('FilePreview', () => {
const defaultProps = {
file: {
id: 'file-1',
name: 'document.pdf',
extension: 'pdf',
size: 1024,
} as CustomFile,
hidePreview: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
mockUseFilePreview.mockReturnValue({
data: undefined,
isFetching: false,
})
mockIsFetching = 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')
})
it('should render preview label', () => {
render(<FilePreview {...defaultProps} />)
expect(screen.getByText('addDocuments.stepOne.preview')).toBeInTheDocument()
})
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()
})
it('should render file name', () => {
render(<FilePreview {...defaultProps} />)
expect(screen.getByText('document.pdf')).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()
})
it('should render file content when loaded', () => {
render(<FilePreview {...defaultProps} />)
expect(screen.getByText('file content here with some text')).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()
})
it('should render loading state', () => {
mockIsFetching = true
render(<FilePreview {...defaultProps} />)
expect(screen.getByTestId('loading')).toBeInTheDocument()
})
describe('User Interactions', () => {
it('should call hidePreview when close button is clicked', () => {
const hidePreview = vi.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()
})
it('should call hidePreview when close button clicked', () => {
render(<FilePreview {...defaultProps} />)
const closeBtn = screen.getByTestId('close-icon').closest('button')!
fireEvent.click(closeBtn)
expect(defaultProps.hidePreview).toHaveBeenCalled()
})
})

View File

@ -1,256 +1,58 @@
import type { CrawlResultItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import WebsitePreview from './web-preview'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import WebPreview from './web-preview'
// Uses global react-i18next mock from web/vitest.setup.ts
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// 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,
})
vi.mock('@remixicon/react', () => ({
RiCloseLine: () => <span data-testid="close-icon" />,
RiGlobalLine: () => <span data-testid="global-icon" />,
}))
const defaultProps = {
currentWebsite: createMockCrawlResult(),
hidePreview: vi.fn(),
}
describe('WebPreview', () => {
const defaultProps = {
currentWebsite: {
title: 'Test Page',
source_url: 'https://example.com',
markdown: 'Hello **markdown** content',
description: '',
} satisfies CrawlResultItem,
hidePreview: vi.fn(),
}
describe('WebsitePreview', () => {
beforeEach(() => {
vi.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()
})
it('should render preview label', () => {
render(<WebPreview {...defaultProps} />)
expect(screen.getByText('addDocuments.stepOne.preview')).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()
})
it('should render page title', () => {
render(<WebPreview {...defaultProps} />)
expect(screen.getByText('Test Page')).toBeInTheDocument()
})
describe('User Interactions', () => {
it('should call hidePreview when close button is clicked', () => {
const hidePreview = vi.fn()
render(<WebsitePreview {...defaultProps} hidePreview={hidePreview} />)
const closeButton = screen.getByRole('button')
fireEvent.click(closeButton)
expect(hidePreview).toHaveBeenCalledTimes(1)
})
it('should render source URL', () => {
render(<WebPreview {...defaultProps} />)
expect(screen.getByText('https://example.com')).toBeInTheDocument()
})
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()
})
it('should render markdown content', () => {
render(<WebPreview {...defaultProps} />)
expect(screen.getByText('Hello **markdown** content')).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 = vi.fn()
const hidePreview2 = vi.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)
})
it('should call hidePreview when close button clicked', () => {
render(<WebPreview {...defaultProps} />)
const closeBtn = screen.getByTestId('close-icon').closest('button')!
fireEvent.click(closeBtn)
expect(defaultProps.hidePreview).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,67 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Header from './header'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@remixicon/react', () => ({
RiSearchEyeLine: () => <span data-testid="search-icon" />,
}))
vi.mock('@/app/components/base/button', () => ({
default: ({ children, onClick, disabled, variant }: { children: React.ReactNode, onClick: () => void, disabled?: boolean, variant: string }) => (
<button data-testid={`btn-${variant}`} onClick={onClick} disabled={disabled}>
{children}
</button>
),
}))
describe('Header', () => {
const defaultProps = {
onReset: vi.fn(),
resetDisabled: false,
previewDisabled: false,
onPreview: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render chunk settings title', () => {
render(<Header {...defaultProps} />)
expect(screen.getByText('addDocuments.stepTwo.chunkSettings')).toBeInTheDocument()
})
it('should render reset and preview buttons', () => {
render(<Header {...defaultProps} />)
expect(screen.getByTestId('btn-ghost')).toBeInTheDocument()
expect(screen.getByTestId('btn-secondary-accent')).toBeInTheDocument()
})
it('should call onReset when reset clicked', () => {
render(<Header {...defaultProps} />)
fireEvent.click(screen.getByTestId('btn-ghost'))
expect(defaultProps.onReset).toHaveBeenCalled()
})
it('should call onPreview when preview clicked', () => {
render(<Header {...defaultProps} />)
fireEvent.click(screen.getByTestId('btn-secondary-accent'))
expect(defaultProps.onPreview).toHaveBeenCalled()
})
it('should disable reset button when resetDisabled is true', () => {
render(<Header {...defaultProps} resetDisabled={true} />)
expect(screen.getByTestId('btn-ghost')).toBeDisabled()
})
it('should disable preview button when previewDisabled is true', () => {
render(<Header {...defaultProps} previewDisabled={true} />)
expect(screen.getByTestId('btn-secondary-accent')).toBeDisabled()
})
})

View File

@ -0,0 +1,112 @@
import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import DrawerGroup from './drawer-group'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('../common/full-screen-drawer', () => ({
default: ({ isOpen, children }: { isOpen: boolean, children: React.ReactNode }) => (
isOpen ? <div data-testid="full-screen-drawer">{children}</div> : null
),
}))
vi.mock('../segment-detail', () => ({
default: () => <div data-testid="segment-detail" />,
}))
vi.mock('../child-segment-detail', () => ({
default: () => <div data-testid="child-segment-detail" />,
}))
vi.mock('../new-child-segment', () => ({
default: () => <div data-testid="new-child-segment" />,
}))
vi.mock('@/app/components/datasets/documents/detail/new-segment', () => ({
default: () => <div data-testid="new-segment" />,
}))
describe('DrawerGroup', () => {
const defaultProps = {
currSegment: { segInfo: undefined, showModal: false, isEditMode: false },
onCloseSegmentDetail: vi.fn(),
onUpdateSegment: vi.fn(),
isRegenerationModalOpen: false,
setIsRegenerationModalOpen: vi.fn(),
showNewSegmentModal: false,
onCloseNewSegmentModal: vi.fn(),
onSaveNewSegment: vi.fn(),
viewNewlyAddedChunk: vi.fn(),
currChildChunk: { childChunkInfo: undefined, showModal: false },
currChunkId: 'chunk-1',
onCloseChildSegmentDetail: vi.fn(),
onUpdateChildChunk: vi.fn(),
showNewChildSegmentModal: false,
onCloseNewChildChunkModal: vi.fn(),
onSaveNewChildChunk: vi.fn(),
viewNewlyAddedChildChunk: vi.fn(),
fullScreen: false,
docForm: ChunkingMode.text,
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render nothing when all modals are closed', () => {
const { container } = render(<DrawerGroup {...defaultProps} />)
expect(container.querySelector('[data-testid="full-screen-drawer"]')).toBeNull()
})
it('should render segment detail when segment modal is open', () => {
render(
<DrawerGroup
{...defaultProps}
currSegment={{ segInfo: { id: 'seg-1' } as SegmentDetailModel, showModal: true, isEditMode: true }}
/>,
)
expect(screen.getByTestId('segment-detail')).toBeInTheDocument()
})
it('should render new segment modal when showNewSegmentModal is true', () => {
render(
<DrawerGroup {...defaultProps} showNewSegmentModal={true} />,
)
expect(screen.getByTestId('new-segment')).toBeInTheDocument()
})
it('should render child segment detail when child chunk modal is open', () => {
render(
<DrawerGroup
{...defaultProps}
currChildChunk={{ childChunkInfo: { id: 'child-1' } as ChildChunkDetail, showModal: true }}
/>,
)
expect(screen.getByTestId('child-segment-detail')).toBeInTheDocument()
})
it('should render new child segment modal when showNewChildSegmentModal is true', () => {
render(
<DrawerGroup {...defaultProps} showNewChildSegmentModal={true} />,
)
expect(screen.getByTestId('new-child-segment')).toBeInTheDocument()
})
it('should render multiple drawers simultaneously', () => {
render(
<DrawerGroup
{...defaultProps}
currSegment={{ segInfo: { id: 'seg-1' } as SegmentDetailModel, showModal: true }}
showNewChildSegmentModal={true}
/>,
)
expect(screen.getByTestId('segment-detail')).toBeInTheDocument()
expect(screen.getByTestId('new-child-segment')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,80 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MenuBar from './menu-bar'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@remixicon/react', () => ({
RiSearchLine: () => <span data-testid="search-icon" />,
RiCloseLine: () => <span data-testid="close-icon" />,
}))
vi.mock('../display-toggle', () => ({
default: ({ isCollapsed, toggleCollapsed }: { isCollapsed: boolean, toggleCollapsed: () => void }) => (
<button data-testid="display-toggle" onClick={toggleCollapsed}>
{isCollapsed ? 'collapsed' : 'expanded'}
</button>
),
}))
vi.mock('../status-item', () => ({
default: ({ item }: { item: { name: string } }) => <div data-testid="status-item">{item.name}</div>,
}))
describe('MenuBar', () => {
const defaultProps = {
isAllSelected: false,
isSomeSelected: false,
onSelectedAll: vi.fn(),
isLoading: false,
totalText: '10 Chunks',
statusList: [
{ value: 'all', name: 'All' },
{ value: 0, name: 'Enabled' },
{ value: 1, name: 'Disabled' },
],
selectDefaultValue: 'all' as const,
onChangeStatus: vi.fn(),
inputValue: '',
onInputChange: vi.fn(),
isCollapsed: false,
toggleCollapsed: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render total text', () => {
render(<MenuBar {...defaultProps} />)
expect(screen.getByText('10 Chunks')).toBeInTheDocument()
})
it('should render checkbox', () => {
const { container } = render(<MenuBar {...defaultProps} />)
const checkbox = container.querySelector('[class*="shrink-0"]')
expect(checkbox).toBeInTheDocument()
})
it('should call onInputChange when input changes', () => {
render(<MenuBar {...defaultProps} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test search' } })
expect(defaultProps.onInputChange).toHaveBeenCalledWith('test search')
})
it('should render display toggle', () => {
render(<MenuBar {...defaultProps} />)
expect(screen.getByTestId('display-toggle')).toBeInTheDocument()
})
it('should call toggleCollapsed when display toggle clicked', () => {
render(<MenuBar {...defaultProps} />)
fireEvent.click(screen.getByTestId('display-toggle'))
expect(defaultProps.toggleCollapsed).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,102 @@
import type { SegmentDetailModel } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { FullDocModeContent, GeneralModeContent } from './segment-list-content'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('../child-segment-list', () => ({
default: ({ parentChunkId }: { parentChunkId: string }) => (
<div data-testid="child-segment-list">{parentChunkId}</div>
),
}))
vi.mock('../segment-card', () => ({
default: ({ detail }: { detail: { id: string } }) => (
<div data-testid="segment-card">{detail?.id}</div>
),
}))
vi.mock('../segment-list', () => {
const SegmentList = vi.fn(({ items }: { items: { id: string }[] }) => (
<div data-testid="segment-list">
{items?.length ?? 0}
{' '}
items
</div>
))
return { default: SegmentList }
})
describe('FullDocModeContent', () => {
const defaultProps = {
segments: [{ id: 'seg-1', position: 1, content: 'test', word_count: 10 }] as SegmentDetailModel[],
childSegments: [],
isLoadingSegmentList: false,
isLoadingChildSegmentList: false,
currSegmentId: undefined,
onClickCard: vi.fn(),
onDeleteChildChunk: vi.fn(),
handleInputChange: vi.fn(),
handleAddNewChildChunk: vi.fn(),
onClickSlice: vi.fn(),
archived: false,
childChunkTotal: 0,
inputValue: '',
onClearFilter: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render segment card with first segment', () => {
render(<FullDocModeContent {...defaultProps} />)
expect(screen.getByTestId('segment-card')).toHaveTextContent('seg-1')
})
it('should render child segment list', () => {
render(<FullDocModeContent {...defaultProps} />)
expect(screen.getByTestId('child-segment-list')).toHaveTextContent('seg-1')
})
it('should apply overflow-y-hidden when loading', () => {
const { container } = render(
<FullDocModeContent {...defaultProps} isLoadingSegmentList={true} />,
)
expect(container.firstChild).toHaveClass('overflow-y-hidden')
})
it('should apply overflow-y-auto when not loading', () => {
const { container } = render(<FullDocModeContent {...defaultProps} />)
expect(container.firstChild).toHaveClass('overflow-y-auto')
})
})
describe('GeneralModeContent', () => {
const defaultProps = {
segmentListRef: { current: null },
embeddingAvailable: true,
isLoadingSegmentList: false,
segments: [{ id: 'seg-1' }, { id: 'seg-2' }] as SegmentDetailModel[],
selectedSegmentIds: [],
onSelected: vi.fn(),
onChangeSwitch: vi.fn(),
onDelete: vi.fn(),
onClickCard: vi.fn(),
archived: false,
onDeleteChildChunk: vi.fn(),
handleAddNewChildChunk: vi.fn(),
onClickSlice: vi.fn(),
onClearFilter: vi.fn(),
}
it('should render segment list with items', () => {
render(<GeneralModeContent {...defaultProps} />)
expect(screen.getByTestId('segment-list')).toHaveTextContent('2 items')
})
})

View File

@ -0,0 +1,77 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { EditSlice } from './edit-slice'
vi.mock('@floating-ui/react', () => ({
autoUpdate: vi.fn(),
flip: vi.fn(),
shift: vi.fn(),
offset: vi.fn(),
FloatingFocusManager: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
useFloating: () => ({
refs: { setReference: vi.fn(), setFloating: vi.fn() },
floatingStyles: {},
context: { open: false, onOpenChange: vi.fn(), refs: { domReference: { current: null } }, nodeId: undefined },
}),
useHover: () => ({}),
useDismiss: () => ({}),
useRole: () => ({}),
useInteractions: () => ({
getReferenceProps: () => ({}),
getFloatingProps: () => ({}),
}),
}))
vi.mock('@remixicon/react', () => ({
RiDeleteBinLine: () => <span data-testid="delete-icon" />,
}))
vi.mock('@/app/components/base/action-button', () => {
const comp = ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<button data-testid="action-button" onClick={onClick}>{children}</button>
)
return {
default: comp,
ActionButtonState: { Destructive: 'destructive' },
}
})
describe('EditSlice', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render label and text', () => {
render(<EditSlice label="C-1" text="chunk content" onDelete={vi.fn()} />)
expect(screen.getByText('C-1')).toBeInTheDocument()
expect(screen.getByText('chunk content')).toBeInTheDocument()
})
it('should render divider by default', () => {
const { container } = render(<EditSlice label="C-1" text="text" onDelete={vi.fn()} />)
// SliceDivider renders a zero-width space
const spans = container.querySelectorAll('span')
const dividerSpan = Array.from(spans).find(s => s.textContent?.includes('\u200B'))
expect(dividerSpan).toBeTruthy()
})
it('should not render divider when showDivider is false', () => {
const { container } = render(
<EditSlice label="C-1" text="text" onDelete={vi.fn()} showDivider={false} />,
)
const spans = container.querySelectorAll('span')
const dividerSpan = Array.from(spans).find(s => s.textContent === '\u200B')
expect(dividerSpan).toBeFalsy()
})
it('should apply custom labelClassName', () => {
render(<EditSlice label="C-1" text="text" onDelete={vi.fn()} labelClassName="custom-label" />)
const labelParent = screen.getByText('C-1').parentElement!
expect(labelParent).toHaveClass('custom-label')
})
it('should apply custom contentClassName', () => {
render(<EditSlice label="C-1" text="content" onDelete={vi.fn()} contentClassName="custom-content" />)
expect(screen.getByText('content')).toHaveClass('custom-content')
})
})

View File

@ -0,0 +1,57 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { PreviewSlice } from './preview-slice'
vi.mock('@floating-ui/react', () => ({
autoUpdate: vi.fn(),
flip: vi.fn(),
shift: vi.fn(),
inline: vi.fn(),
useFloating: () => ({
refs: { setReference: vi.fn(), setFloating: vi.fn() },
floatingStyles: {},
context: { open: false, onOpenChange: vi.fn(), refs: { domReference: { current: null } }, nodeId: undefined },
}),
useHover: () => ({}),
useDismiss: () => ({}),
useRole: () => ({}),
useInteractions: () => ({
getReferenceProps: () => ({}),
getFloatingProps: () => ({}),
}),
}))
describe('PreviewSlice', () => {
it('should render label and text', () => {
render(<PreviewSlice label="P-1" text="preview content" tooltip="tooltip text" />)
expect(screen.getByText('P-1')).toBeInTheDocument()
expect(screen.getByText('preview content')).toBeInTheDocument()
})
it('should not show tooltip by default', () => {
render(<PreviewSlice label="P-1" text="text" tooltip="tooltip" />)
expect(screen.queryByText('tooltip')).not.toBeInTheDocument()
})
it('should apply custom className', () => {
const { container } = render(
<PreviewSlice label="P-1" text="text" tooltip="tip" className="custom-class" />,
)
const sliceContainer = container.querySelector('.custom-class')
expect(sliceContainer).toBeInTheDocument()
})
it('should apply labelInnerClassName', () => {
render(<PreviewSlice label="Label" text="text" tooltip="tip" labelInnerClassName="inner-cls" />)
expect(screen.getByText('Label')).toHaveClass('inner-cls')
})
it('should render divider', () => {
const { container } = render(
<PreviewSlice label="P-1" text="text" tooltip="tip" />,
)
const spans = container.querySelectorAll('span')
const dividerSpan = Array.from(spans).find(s => s.textContent?.includes('\u200B'))
expect(dividerSpan).toBeTruthy()
})
})

View File

@ -0,0 +1,85 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { SliceContainer, SliceContent, SliceDivider, SliceLabel } from './shared'
describe('SliceContainer', () => {
it('should render children', () => {
render(<SliceContainer>content</SliceContainer>)
expect(screen.getByText('content')).toBeInTheDocument()
})
it('should be a span element', () => {
render(<SliceContainer>text</SliceContainer>)
expect(screen.getByText('text').tagName).toBe('SPAN')
})
it('should merge custom className', () => {
render(<SliceContainer className="custom">text</SliceContainer>)
expect(screen.getByText('text')).toHaveClass('custom')
})
it('should have display name', () => {
expect(SliceContainer.displayName).toBe('SliceContainer')
})
})
describe('SliceLabel', () => {
it('should render children with uppercase text', () => {
render(<SliceLabel>Label</SliceLabel>)
expect(screen.getByText('Label')).toBeInTheDocument()
})
it('should apply label styling', () => {
render(<SliceLabel>Label</SliceLabel>)
const outer = screen.getByText('Label').parentElement!
expect(outer).toHaveClass('uppercase')
})
it('should apply labelInnerClassName to inner span', () => {
render(<SliceLabel labelInnerClassName="inner-class">Label</SliceLabel>)
expect(screen.getByText('Label')).toHaveClass('inner-class')
})
it('should have display name', () => {
expect(SliceLabel.displayName).toBe('SliceLabel')
})
})
describe('SliceContent', () => {
it('should render children', () => {
render(<SliceContent>Content</SliceContent>)
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('should apply whitespace-pre-line and break-all', () => {
render(<SliceContent>Content</SliceContent>)
const el = screen.getByText('Content')
expect(el).toHaveClass('whitespace-pre-line')
expect(el).toHaveClass('break-all')
})
it('should have display name', () => {
expect(SliceContent.displayName).toBe('SliceContent')
})
})
describe('SliceDivider', () => {
it('should render as span', () => {
const { container } = render(<SliceDivider />)
expect(container.querySelector('span')).toBeInTheDocument()
})
it('should contain zero-width space', () => {
const { container } = render(<SliceDivider />)
expect(container.textContent).toContain('\u200B')
})
it('should merge custom className', () => {
const { container } = render(<SliceDivider className="custom" />)
expect(container.querySelector('span')).toHaveClass('custom')
})
it('should have display name', () => {
expect(SliceDivider.displayName).toBe('SliceDivider')
})
})

View File

@ -0,0 +1,27 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { FormattedText } from './formatted'
describe('FormattedText', () => {
it('should render children', () => {
render(<FormattedText>Hello World</FormattedText>)
expect(screen.getByText('Hello World')).toBeInTheDocument()
})
it('should apply leading-7 class by default', () => {
render(<FormattedText>Text</FormattedText>)
expect(screen.getByText('Text')).toHaveClass('leading-7')
})
it('should merge custom className', () => {
render(<FormattedText className="custom-class">Text</FormattedText>)
const el = screen.getByText('Text')
expect(el).toHaveClass('leading-7')
expect(el).toHaveClass('custom-class')
})
it('should render as a p element', () => {
render(<FormattedText>Text</FormattedText>)
expect(screen.getByText('Text').tagName).toBe('P')
})
})

View File

@ -0,0 +1,143 @@
import type { HitTesting } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ChunkDetailModal from './chunk-detail-modal'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: { num?: number }) => opts?.num ? `${key}-${opts.num}` : key,
}),
}))
vi.mock('@/app/components/base/file-uploader/file-type-icon', () => ({
default: () => <span data-testid="file-icon" />,
}))
vi.mock('@/app/components/base/markdown', () => ({
Markdown: ({ content }: { content: string }) => <div data-testid="markdown">{content}</div>,
}))
vi.mock('@/app/components/base/modal', () => ({
default: ({ children, title, onClose }: { children: React.ReactNode, title: string, onClose: () => void }) => (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>close</button>
{children}
</div>
),
}))
vi.mock('../../common/image-list', () => ({
default: () => <div data-testid="image-list" />,
}))
vi.mock('../../documents/detail/completed/common/dot', () => ({
default: () => <span data-testid="dot" />,
}))
vi.mock('../../documents/detail/completed/common/segment-index-tag', () => ({
SegmentIndexTag: ({ positionId }: { positionId: number }) => <span data-testid="segment-index-tag">{positionId}</span>,
}))
vi.mock('../../documents/detail/completed/common/summary-text', () => ({
default: ({ value }: { value: string }) => <div data-testid="summary-text">{value}</div>,
}))
vi.mock('@/app/components/datasets/documents/detail/completed/common/tag', () => ({
default: ({ text }: { text: string }) => <span data-testid="tag">{text}</span>,
}))
vi.mock('./child-chunks-item', () => ({
default: ({ payload }: { payload: { id: string } }) => <div data-testid="child-chunk">{payload.id}</div>,
}))
vi.mock('./mask', () => ({
default: () => <div data-testid="mask" />,
}))
vi.mock('./score', () => ({
default: ({ value }: { value: number }) => <span data-testid="score">{value}</span>,
}))
const makePayload = (overrides: Record<string, unknown> = {}): HitTesting => {
const segmentOverrides = (overrides.segment ?? {}) as Record<string, unknown>
const segment = {
position: 1,
content: 'chunk content',
sign_content: '',
keywords: [],
document: { name: 'file.pdf' },
answer: '',
word_count: 100,
...segmentOverrides,
}
return {
segment,
content: segment,
score: 0.85,
tsne_position: { x: 0, y: 0 },
child_chunks: (overrides.child_chunks ?? []) as HitTesting['child_chunks'],
files: (overrides.files ?? []) as HitTesting['files'],
summary: (overrides.summary ?? '') as string,
} as unknown as HitTesting
}
describe('ChunkDetailModal', () => {
const onHide = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render modal with title', () => {
render(<ChunkDetailModal payload={makePayload()} onHide={onHide} />)
expect(screen.getByTestId('modal-title')).toHaveTextContent('chunkDetail')
})
it('should render segment index tag and score', () => {
render(<ChunkDetailModal payload={makePayload()} onHide={onHide} />)
expect(screen.getByTestId('segment-index-tag')).toHaveTextContent('1')
expect(screen.getByTestId('score')).toHaveTextContent('0.85')
})
it('should render markdown content', () => {
render(<ChunkDetailModal payload={makePayload()} onHide={onHide} />)
expect(screen.getByTestId('markdown')).toHaveTextContent('chunk content')
})
it('should render QA content when answer exists', () => {
const payload = makePayload({
segment: { answer: 'answer text', content: 'question text' },
})
render(<ChunkDetailModal payload={payload} onHide={onHide} />)
expect(screen.getByText('question text')).toBeInTheDocument()
expect(screen.getByText('answer text')).toBeInTheDocument()
})
it('should render keywords when present and not parent-child', () => {
const payload = makePayload({
segment: { keywords: ['k1', 'k2'] },
})
render(<ChunkDetailModal payload={payload} onHide={onHide} />)
expect(screen.getAllByTestId('tag')).toHaveLength(2)
})
it('should render child chunks section for parent-child retrieval', () => {
const payload = makePayload({
child_chunks: [{ id: 'c1' }, { id: 'c2' }],
})
render(<ChunkDetailModal payload={payload} onHide={onHide} />)
expect(screen.getAllByTestId('child-chunk')).toHaveLength(2)
})
it('should render summary text when summary exists', () => {
const payload = makePayload({ summary: 'test summary' })
render(<ChunkDetailModal payload={payload} onHide={onHide} />)
expect(screen.getByTestId('summary-text')).toHaveTextContent('test summary')
})
it('should render mask overlay', () => {
render(<ChunkDetailModal payload={makePayload()} onHide={onHide} />)
expect(screen.getByTestId('mask')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,112 @@
import type { Query } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import QueryInput from './index'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('next/image', () => ({
default: (props: Record<string, unknown>) => <div data-testid="next-image" role="img" aria-label={props.alt as string} />,
}))
vi.mock('uuid', () => ({
v4: () => 'mock-uuid',
}))
vi.mock('@remixicon/react', () => ({
RiEqualizer2Line: () => <span data-testid="equalizer-icon" />,
RiPlayCircleLine: () => <span data-testid="play-icon" />,
}))
vi.mock('@/app/components/base/button', () => ({
default: ({ children, onClick, disabled, loading }: { children: React.ReactNode, onClick: () => void, disabled?: boolean, loading?: boolean }) => (
<button data-testid="submit-button" onClick={onClick} disabled={disabled || loading}>
{children}
</button>
),
}))
vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', () => ({
default: ({ textArea, actionButton }: { textArea: React.ReactNode, actionButton: React.ReactNode }) => (
<div data-testid="image-uploader">
{textArea}
{actionButton}
</div>
),
}))
vi.mock('@/app/components/datasets/common/retrieval-method-info', () => ({
getIcon: () => '/test-icon.png',
}))
vi.mock('@/app/components/datasets/hit-testing/modify-external-retrieval-modal', () => ({
default: () => <div data-testid="external-retrieval-modal" />,
}))
vi.mock('./textarea', () => ({
default: ({ text }: { text: string }) => <textarea data-testid="textarea" defaultValue={text} />,
}))
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: () => false,
}))
describe('QueryInput', () => {
const defaultProps = {
onUpdateList: vi.fn(),
setHitResult: vi.fn(),
setExternalHitResult: vi.fn(),
loading: false,
queries: [{ content: 'test query', content_type: 'text_query', file_info: null }] satisfies Query[],
setQueries: vi.fn(),
isExternal: false,
onClickRetrievalMethod: vi.fn(),
retrievalConfig: { search_method: 'semantic_search' } as RetrievalConfig,
isEconomy: false,
hitTestingMutation: vi.fn(),
externalKnowledgeBaseHitTestingMutation: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render title', () => {
render(<QueryInput {...defaultProps} />)
expect(screen.getByText('input.title')).toBeInTheDocument()
})
it('should render textarea with query text', () => {
render(<QueryInput {...defaultProps} />)
expect(screen.getByTestId('textarea')).toBeInTheDocument()
})
it('should render submit button', () => {
render(<QueryInput {...defaultProps} />)
expect(screen.getByTestId('submit-button')).toBeInTheDocument()
})
it('should disable submit button when text is empty', () => {
const props = {
...defaultProps,
queries: [{ content: '', content_type: 'text_query', file_info: null }] satisfies Query[],
}
render(<QueryInput {...props} />)
expect(screen.getByTestId('submit-button')).toBeDisabled()
})
it('should render retrieval method for non-external mode', () => {
render(<QueryInput {...defaultProps} />)
expect(screen.getByText('retrieval.semantic_search.title')).toBeInTheDocument()
})
it('should render settings button for external mode', () => {
render(<QueryInput {...defaultProps} isExternal={true} />)
expect(screen.getByText('settingTitle')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,120 @@
import type { HitTestingRecord } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Records from './records'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: (ts: number, _fmt: string) => `time-${ts}`,
}),
}))
vi.mock('@remixicon/react', () => ({
RiApps2Line: (props: Record<string, unknown>) => <span data-testid="apps-icon" {...props} />,
RiFocus2Line: (props: Record<string, unknown>) => <span data-testid="focus-icon" {...props} />,
RiArrowDownLine: (props: Record<string, unknown>) => <span data-testid="arrow-down" {...props} />,
}))
vi.mock('../../common/image-list', () => ({
default: () => <div data-testid="image-list" />,
}))
const makeRecord = (id: string, source: string, created_at: number, content = 'query text') => ({
id,
source,
created_at,
queries: [{ content, content_type: 'text_query', file_info: null }],
}) as unknown as HitTestingRecord
describe('Records', () => {
const mockOnClick = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render table headers', () => {
render(<Records records={[]} onClickRecord={mockOnClick} />)
expect(screen.getByText('table.header.queryContent')).toBeInTheDocument()
expect(screen.getByText('table.header.source')).toBeInTheDocument()
expect(screen.getByText('table.header.time')).toBeInTheDocument()
})
it('should render records', () => {
const records = [
makeRecord('1', 'app', 1000),
makeRecord('2', 'hit_testing', 2000),
]
render(<Records records={records} onClickRecord={mockOnClick} />)
expect(screen.getAllByText('query text')).toHaveLength(2)
})
it('should show app icon for app source', () => {
const records = [makeRecord('1', 'app', 1000)]
render(<Records records={records} onClickRecord={mockOnClick} />)
expect(screen.getByTestId('apps-icon')).toBeInTheDocument()
})
it('should show focus icon for non-app source', () => {
const records = [makeRecord('1', 'hit_testing', 1000)]
render(<Records records={records} onClickRecord={mockOnClick} />)
expect(screen.getByTestId('focus-icon')).toBeInTheDocument()
})
it('should call onClickRecord when row clicked', () => {
const records = [makeRecord('1', 'app', 1000)]
render(<Records records={records} onClickRecord={mockOnClick} />)
fireEvent.click(screen.getByText('query text'))
expect(mockOnClick).toHaveBeenCalledWith(records[0])
})
it('should sort records by time descending by default', () => {
const records = [
makeRecord('1', 'app', 1000, 'early'),
makeRecord('2', 'app', 3000, 'late'),
makeRecord('3', 'app', 2000, 'mid'),
]
render(<Records records={records} onClickRecord={mockOnClick} />)
const rows = screen.getAllByRole('row').slice(1) // skip header
expect(rows[0]).toHaveTextContent('late')
expect(rows[1]).toHaveTextContent('mid')
expect(rows[2]).toHaveTextContent('early')
})
it('should toggle sort order on time header click', () => {
const records = [
makeRecord('1', 'app', 1000, 'early'),
makeRecord('2', 'app', 3000, 'late'),
]
render(<Records records={records} onClickRecord={mockOnClick} />)
// Default: desc, so late first
let rows = screen.getAllByRole('row').slice(1)
expect(rows[0]).toHaveTextContent('late')
// Click to sort asc
fireEvent.click(screen.getByText('table.header.time'))
rows = screen.getAllByRole('row').slice(1)
expect(rows[0]).toHaveTextContent('early')
})
it('should render image list for image queries', () => {
const records = [{
id: '1',
source: 'app',
created_at: 1000,
queries: [
{ content: '', content_type: 'text_query', file_info: null },
{ content: '', content_type: 'image_query', file_info: { name: 'img.png', mime_type: 'image/png', source_url: 'url', size: 100, extension: 'png' } },
],
}] as unknown as HitTestingRecord[]
render(<Records records={records} onClickRecord={mockOnClick} />)
expect(screen.getByTestId('image-list')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,134 @@
import type { HitTesting } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ResultItem from './result-item'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: { num?: number }) => opts?.num ? `${key}-${opts.num}` : key,
}),
}))
vi.mock('ahooks', () => {
// eslint-disable-next-line ts/no-require-imports
const { useState } = require('react')
return {
useBoolean: (initial: boolean) => {
const [val, setVal] = useState(initial)
return [val, { toggle: () => setVal((v: boolean) => !v), setTrue: () => setVal(true), setFalse: () => setVal(false) }]
},
}
})
vi.mock('@remixicon/react', () => ({
RiArrowDownSLine: () => <span data-testid="arrow-down" />,
RiArrowRightSLine: () => <span data-testid="arrow-right" />,
}))
vi.mock('@/app/components/base/markdown', () => ({
Markdown: ({ content }: { content: string }) => <div data-testid="markdown">{content}</div>,
}))
vi.mock('../../common/image-list', () => ({
default: () => <div data-testid="image-list" />,
}))
vi.mock('./child-chunks-item', () => ({
default: ({ payload }: { payload: { id: string } }) => <div data-testid="child-chunk">{payload.id}</div>,
}))
vi.mock('./chunk-detail-modal', () => ({
default: () => <div data-testid="chunk-detail-modal" />,
}))
vi.mock('./result-item-footer', () => ({
default: ({ docTitle }: { docTitle: string }) => <div data-testid="result-item-footer">{docTitle}</div>,
}))
vi.mock('./result-item-meta', () => ({
default: ({ positionId }: { positionId: number }) => <div data-testid="result-item-meta">{positionId}</div>,
}))
vi.mock('@/app/components/datasets/documents/detail/completed/common/summary-label', () => ({
default: ({ summary }: { summary: string }) => <div data-testid="summary-label">{summary}</div>,
}))
vi.mock('@/app/components/datasets/documents/detail/completed/common/tag', () => ({
default: ({ text }: { text: string }) => <span data-testid="tag">{text}</span>,
}))
vi.mock('@/app/components/datasets/hit-testing/utils/extension-to-file-type', () => ({
extensionToFileType: () => 'pdf',
}))
const makePayload = (overrides: Record<string, unknown> = {}): HitTesting => {
const segmentOverrides = (overrides.segment ?? {}) as Record<string, unknown>
const segment = {
position: 1,
word_count: 100,
content: 'test content',
sign_content: '',
keywords: [],
document: { name: 'file.pdf' },
answer: '',
...segmentOverrides,
}
return {
segment,
content: segment,
score: 0.95,
tsne_position: { x: 0, y: 0 },
child_chunks: (overrides.child_chunks ?? []) as HitTesting['child_chunks'],
files: (overrides.files ?? []) as HitTesting['files'],
summary: (overrides.summary ?? '') as string,
} as unknown as HitTesting
}
describe('ResultItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render meta, content, and footer', () => {
render(<ResultItem payload={makePayload()} />)
expect(screen.getByTestId('result-item-meta')).toHaveTextContent('1')
expect(screen.getByTestId('markdown')).toHaveTextContent('test content')
expect(screen.getByTestId('result-item-footer')).toHaveTextContent('file.pdf')
})
it('should render keywords when no child_chunks', () => {
const payload = makePayload({
segment: { keywords: ['key1', 'key2'] },
})
render(<ResultItem payload={payload} />)
expect(screen.getAllByTestId('tag')).toHaveLength(2)
})
it('should render child chunks when present', () => {
const payload = makePayload({
child_chunks: [{ id: 'c1' }, { id: 'c2' }],
})
render(<ResultItem payload={payload} />)
expect(screen.getAllByTestId('child-chunk')).toHaveLength(2)
})
it('should render summary label when summary exists', () => {
const payload = makePayload({ summary: 'test summary' })
render(<ResultItem payload={payload} />)
expect(screen.getByTestId('summary-label')).toHaveTextContent('test summary')
})
it('should show chunk detail modal on click', () => {
render(<ResultItem payload={makePayload()} />)
fireEvent.click(screen.getByTestId('markdown'))
expect(screen.getByTestId('chunk-detail-modal')).toBeInTheDocument()
})
it('should render images when files exist', () => {
const payload = makePayload({
files: [{ name: 'img.png', mime_type: 'image/png', source_url: 'url', size: 100, extension: 'png' }],
})
render(<ResultItem payload={payload} />)
expect(screen.getByTestId('image-list')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,94 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ModifyExternalRetrievalModal from './modify-external-retrieval-modal'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@remixicon/react', () => ({
RiCloseLine: () => <span data-testid="close-icon" />,
}))
vi.mock('@/app/components/base/action-button', () => ({
default: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<button data-testid="action-button" onClick={onClick}>{children}</button>
),
}))
vi.mock('@/app/components/base/button', () => ({
default: ({ children, onClick, variant }: { children: React.ReactNode, onClick: () => void, variant?: string }) => (
<button data-testid={variant === 'primary' ? 'save-button' : 'cancel-button'} onClick={onClick}>
{children}
</button>
),
}))
vi.mock('../external-knowledge-base/create/RetrievalSettings', () => ({
default: ({ topK, scoreThreshold, _scoreThresholdEnabled, onChange }: { topK: number, scoreThreshold: number, _scoreThresholdEnabled: boolean, onChange: (data: Record<string, unknown>) => void }) => (
<div data-testid="retrieval-settings">
<span data-testid="top-k">{topK}</span>
<span data-testid="score-threshold">{scoreThreshold}</span>
<button data-testid="change-top-k" onClick={() => onChange({ top_k: 10 })}>change</button>
</div>
),
}))
describe('ModifyExternalRetrievalModal', () => {
const defaultProps = {
onClose: vi.fn(),
onSave: vi.fn(),
initialTopK: 4,
initialScoreThreshold: 0.5,
initialScoreThresholdEnabled: false,
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render title', () => {
render(<ModifyExternalRetrievalModal {...defaultProps} />)
expect(screen.getByText('settingTitle')).toBeInTheDocument()
})
it('should render retrieval settings with initial values', () => {
render(<ModifyExternalRetrievalModal {...defaultProps} />)
expect(screen.getByTestId('top-k')).toHaveTextContent('4')
expect(screen.getByTestId('score-threshold')).toHaveTextContent('0.5')
})
it('should call onClose when close button clicked', () => {
render(<ModifyExternalRetrievalModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('action-button'))
expect(defaultProps.onClose).toHaveBeenCalled()
})
it('should call onClose when cancel button clicked', () => {
render(<ModifyExternalRetrievalModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('cancel-button'))
expect(defaultProps.onClose).toHaveBeenCalled()
})
it('should call onSave with current values and close when save clicked', () => {
render(<ModifyExternalRetrievalModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('save-button'))
expect(defaultProps.onSave).toHaveBeenCalledWith({
top_k: 4,
score_threshold: 0.5,
score_threshold_enabled: false,
})
expect(defaultProps.onClose).toHaveBeenCalled()
})
it('should save updated values after settings change', () => {
render(<ModifyExternalRetrievalModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('change-top-k'))
fireEvent.click(screen.getByTestId('save-button'))
expect(defaultProps.onSave).toHaveBeenCalledWith(
expect.objectContaining({ top_k: 10 }),
)
})
})

View File

@ -0,0 +1,124 @@
import type { RetrievalConfig } from '@/types/app'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { RETRIEVE_METHOD } from '@/types/app'
import ModifyRetrievalModal from './modify-retrieval-modal'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@remixicon/react', () => ({
RiCloseLine: () => <span data-testid="close-icon" />,
}))
vi.mock('@/app/components/base/button', () => ({
default: ({ children, onClick, variant }: { children: React.ReactNode, onClick: () => void, variant?: string }) => (
<button data-testid={variant === 'primary' ? 'save-button' : 'cancel-button'} onClick={onClick}>
{children}
</button>
),
}))
vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({
isReRankModelSelected: vi.fn(() => true),
}))
vi.mock('@/app/components/datasets/common/retrieval-method-config', () => ({
default: ({ value, onChange }: { value: RetrievalConfig, onChange: (v: RetrievalConfig) => void }) => (
<div data-testid="retrieval-method-config">
<span>{value.search_method}</span>
<button data-testid="change-config" onClick={() => onChange({ ...value, search_method: RETRIEVE_METHOD.hybrid })}>change</button>
</div>
),
}))
vi.mock('@/app/components/datasets/common/economical-retrieval-method-config', () => ({
default: () => <div data-testid="economical-config" />,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelList: () => ({ data: [] }),
}))
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: () => 'model-name',
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
}))
vi.mock('../../base/toast', () => ({
default: { notify: vi.fn() },
}))
vi.mock('../settings/utils', () => ({
checkShowMultiModalTip: () => false,
}))
describe('ModifyRetrievalModal', () => {
const defaultProps = {
indexMethod: 'high_quality',
value: {
search_method: 'semantic_search',
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
} as RetrievalConfig,
isShow: true,
onHide: vi.fn(),
onSave: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should return null when isShow is false', () => {
const { container } = render(<ModifyRetrievalModal {...defaultProps} isShow={false} />)
expect(container.firstChild).toBeNull()
})
it('should render title when isShow is true', () => {
render(<ModifyRetrievalModal {...defaultProps} />)
expect(screen.getByText('form.retrievalSetting.title')).toBeInTheDocument()
})
it('should render high quality retrieval config for high_quality index', () => {
render(<ModifyRetrievalModal {...defaultProps} />)
expect(screen.getByTestId('retrieval-method-config')).toBeInTheDocument()
})
it('should render economical config for non high_quality index', () => {
render(<ModifyRetrievalModal {...defaultProps} indexMethod="economy" />)
expect(screen.getByTestId('economical-config')).toBeInTheDocument()
})
it('should call onHide when close button clicked', () => {
render(<ModifyRetrievalModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('close-icon'))
expect(defaultProps.onHide).toHaveBeenCalled()
})
it('should call onHide when cancel button clicked', () => {
render(<ModifyRetrievalModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('cancel-button'))
expect(defaultProps.onHide).toHaveBeenCalled()
})
it('should call onSave with retrieval config when save clicked', () => {
render(<ModifyRetrievalModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('save-button'))
expect(defaultProps.onSave).toHaveBeenCalled()
})
it('should render learn more link', () => {
render(<ModifyRetrievalModal {...defaultProps} />)
expect(screen.getByText('form.retrievalSetting.learnMore')).toBeInTheDocument()
})
})

View File

@ -3462,11 +3462,6 @@
"count": 1
}
},
"app/components/datasets/documents/create-from-pipeline/data-source/base/header.spec.tsx": {
"ts/no-explicit-any": {
"count": 4
}
},
"app/components/datasets/documents/create-from-pipeline/data-source/base/header.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1