feat: add automated tests for pipeline setting (#29478)

Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
This commit is contained in:
Coding On Star
2025-12-17 10:26:58 +08:00
committed by GitHub
parent 91714ee413
commit 581b62cf01
11 changed files with 3542 additions and 4 deletions

View File

@@ -62,7 +62,7 @@ type CurrChildChunkType = {
showModal: boolean
}
type SegmentListContextValue = {
export type SegmentListContextValue = {
isCollapsed: boolean
fullScreen: boolean
toggleFullScreen: (fullscreen?: boolean) => void

View File

@@ -129,6 +129,7 @@ const SegmentCard: FC<ISegmentCardProps> = ({
return (
<div
data-testid="segment-card"
className={cn(
'chunk-card group/card w-full rounded-xl px-3',
isFullDocMode ? '' : 'pb-2 pt-2.5 hover:bg-dataset-chunk-detail-card-hover-bg',
@@ -172,6 +173,7 @@ const SegmentCard: FC<ISegmentCardProps> = ({
popupClassName='text-text-secondary system-xs-medium'
>
<div
data-testid="segment-edit-button"
className='flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover'
onClick={(e) => {
e.stopPropagation()
@@ -184,7 +186,9 @@ const SegmentCard: FC<ISegmentCardProps> = ({
popupContent='Delete'
popupClassName='text-text-secondary system-xs-medium'
>
<div className='group/delete flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-lg hover:bg-state-destructive-hover'
<div
data-testid="segment-delete-button"
className='group/delete flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-lg hover:bg-state-destructive-hover'
onClick={(e) => {
e.stopPropagation()
setShowModal(true)

View File

@@ -10,7 +10,7 @@ import {
const ParentChunkCardSkelton = () => {
const { t } = useTranslation()
return (
<div className='flex flex-col pb-2'>
<div data-testid='parent-chunk-card-skeleton' className='flex flex-col pb-2'>
<SkeletonContainer className='gap-y-0 p-1 pb-0'>
<SkeletonContainer className='gap-y-0.5 px-2 pt-1.5'>
<SkeletonRow className='py-0.5'>

View File

@@ -1,7 +1,7 @@
import type { ChunkingMode, ParentMode } from '@/models/datasets'
import { createContext, useContextSelector } from 'use-context-selector'
type DocumentContextValue = {
export type DocumentContextValue = {
datasetId?: string
documentId?: string
docForm?: ChunkingMode

View File

@@ -0,0 +1,786 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import PipelineSettings from './index'
import { DatasourceType } from '@/models/pipeline'
import type { PipelineExecutionLogResponse } from '@/models/pipeline'
// Mock i18n
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock Next.js router
const mockPush = jest.fn()
const mockBack = jest.fn()
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
back: mockBack,
}),
}))
// Mock dataset detail context
const mockPipelineId = 'pipeline-123'
jest.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: { dataset: { pipeline_id: string; doc_form: string } }) => unknown) =>
selector({ dataset: { pipeline_id: mockPipelineId, doc_form: 'text_model' } }),
}))
// Mock API hooks for PipelineSettings
const mockUsePipelineExecutionLog = jest.fn()
const mockMutateAsync = jest.fn()
const mockUseRunPublishedPipeline = jest.fn()
jest.mock('@/service/use-pipeline', () => ({
usePipelineExecutionLog: (params: { dataset_id: string; document_id: string }) => mockUsePipelineExecutionLog(params),
useRunPublishedPipeline: () => mockUseRunPublishedPipeline(),
// For ProcessDocuments component
usePublishedPipelineProcessingParams: () => ({
data: { variables: [] },
isFetching: false,
}),
}))
// Mock document invalidation hooks
const mockInvalidDocumentList = jest.fn()
const mockInvalidDocumentDetail = jest.fn()
jest.mock('@/service/knowledge/use-document', () => ({
useInvalidDocumentList: () => mockInvalidDocumentList,
useInvalidDocumentDetail: () => mockInvalidDocumentDetail,
}))
// Mock Form component in ProcessDocuments - internal dependencies are too complex
jest.mock('../../../create-from-pipeline/process-documents/form', () => {
return function MockForm({
ref,
initialData,
configurations,
onSubmit,
onPreview,
isRunning,
}: {
ref: React.RefObject<{ submit: () => void }>
initialData: Record<string, unknown>
configurations: Array<{ variable: string; label: string; type: string }>
schema: unknown
onSubmit: (data: Record<string, unknown>) => void
onPreview: () => void
isRunning: boolean
}) {
if (ref && typeof ref === 'object' && 'current' in ref) {
(ref as React.MutableRefObject<{ submit: () => void }>).current = {
submit: () => onSubmit(initialData),
}
}
return (
<form
data-testid="process-form"
onSubmit={(e) => {
e.preventDefault()
onSubmit(initialData)
}}
>
{configurations.map((config, index) => (
<div key={index} data-testid={`field-${config.variable}`}>
<label>{config.label}</label>
</div>
))}
<button type="button" data-testid="preview-btn" onClick={onPreview} disabled={isRunning}>
Preview
</button>
</form>
)
}
})
// Mock ChunkPreview - has complex internal state and many dependencies
jest.mock('../../../create-from-pipeline/preview/chunk-preview', () => {
return function MockChunkPreview({
dataSourceType,
localFiles,
onlineDocuments,
websitePages,
onlineDriveFiles,
isIdle,
isPending,
estimateData,
}: {
dataSourceType: string
localFiles: unknown[]
onlineDocuments: unknown[]
websitePages: unknown[]
onlineDriveFiles: unknown[]
isIdle: boolean
isPending: boolean
estimateData: unknown
}) {
return (
<div data-testid="chunk-preview">
<span data-testid="datasource-type">{dataSourceType}</span>
<span data-testid="local-files-count">{localFiles.length}</span>
<span data-testid="online-documents-count">{onlineDocuments.length}</span>
<span data-testid="website-pages-count">{websitePages.length}</span>
<span data-testid="online-drive-files-count">{onlineDriveFiles.length}</span>
<span data-testid="is-idle">{String(isIdle)}</span>
<span data-testid="is-pending">{String(isPending)}</span>
<span data-testid="has-estimate-data">{String(!!estimateData)}</span>
</div>
)
}
})
// Test utilities
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const renderWithProviders = (ui: React.ReactElement) => {
const queryClient = createQueryClient()
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
)
}
// Factory functions for test data
const createMockExecutionLogResponse = (
overrides: Partial<PipelineExecutionLogResponse> = {},
): PipelineExecutionLogResponse => ({
datasource_type: DatasourceType.localFile,
input_data: { chunk_size: '100' },
datasource_node_id: 'datasource-node-1',
datasource_info: {
related_id: 'file-1',
name: 'test-file.pdf',
extension: 'pdf',
},
...overrides,
})
const createDefaultProps = () => ({
datasetId: 'dataset-123',
documentId: 'document-456',
})
describe('PipelineSettings', () => {
beforeEach(() => {
jest.clearAllMocks()
mockPush.mockClear()
mockBack.mockClear()
mockMutateAsync.mockClear()
mockInvalidDocumentList.mockClear()
mockInvalidDocumentDetail.mockClear()
// Default: successful data fetch
mockUsePipelineExecutionLog.mockReturnValue({
data: createMockExecutionLogResponse(),
isFetching: false,
isError: false,
})
// Default: useRunPublishedPipeline mock
mockUseRunPublishedPipeline.mockReturnValue({
mutateAsync: mockMutateAsync,
isIdle: true,
isPending: false,
})
})
// ==================== Rendering Tests ====================
// Test basic rendering with real components
describe('Rendering', () => {
it('should render without crashing when data is loaded', () => {
// Arrange
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert - Real LeftHeader should render with correct content
expect(screen.getByText('datasetPipeline.documentSettings.title')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.addDocuments.steps.processDocuments')).toBeInTheDocument()
// Real ProcessDocuments should render
expect(screen.getByTestId('process-form')).toBeInTheDocument()
// ChunkPreview should render
expect(screen.getByTestId('chunk-preview')).toBeInTheDocument()
})
it('should render Loading component when fetching data', () => {
// Arrange
mockUsePipelineExecutionLog.mockReturnValue({
data: undefined,
isFetching: true,
isError: false,
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert - Loading component should be rendered, not main content
expect(screen.queryByText('datasetPipeline.documentSettings.title')).not.toBeInTheDocument()
expect(screen.queryByTestId('process-form')).not.toBeInTheDocument()
})
it('should render AppUnavailable when there is an error', () => {
// Arrange
mockUsePipelineExecutionLog.mockReturnValue({
data: undefined,
isFetching: false,
isError: true,
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert - AppUnavailable should be rendered
expect(screen.queryByText('datasetPipeline.documentSettings.title')).not.toBeInTheDocument()
})
it('should render container with correct CSS classes', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = renderWithProviders(<PipelineSettings {...props} />)
// Assert
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toHaveClass('relative', 'flex', 'min-w-[1024px]')
})
})
// ==================== LeftHeader Integration ====================
// Test real LeftHeader component behavior
describe('LeftHeader Integration', () => {
it('should render LeftHeader with title prop', () => {
// Arrange
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert - LeftHeader displays the title
expect(screen.getByText('datasetPipeline.documentSettings.title')).toBeInTheDocument()
})
it('should render back button in LeftHeader', () => {
// Arrange
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert - Back button should exist with proper aria-label
const backButton = screen.getByRole('button', { name: 'common.operation.back' })
expect(backButton).toBeInTheDocument()
})
it('should call router.back when back button is clicked', () => {
// Arrange
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
const backButton = screen.getByRole('button', { name: 'common.operation.back' })
fireEvent.click(backButton)
// Assert
expect(mockBack).toHaveBeenCalledTimes(1)
})
})
// ==================== Props Testing ====================
describe('Props', () => {
it('should pass datasetId and documentId to usePipelineExecutionLog', () => {
// Arrange
const props = { datasetId: 'custom-dataset', documentId: 'custom-document' }
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert
expect(mockUsePipelineExecutionLog).toHaveBeenCalledWith({
dataset_id: 'custom-dataset',
document_id: 'custom-document',
})
})
})
// ==================== Memoization - Data Transformation ====================
describe('Memoization - Data Transformation', () => {
it('should transform localFile datasource correctly', () => {
// Arrange
const mockData = createMockExecutionLogResponse({
datasource_type: DatasourceType.localFile,
datasource_info: {
related_id: 'file-123',
name: 'document.pdf',
extension: 'pdf',
},
})
mockUsePipelineExecutionLog.mockReturnValue({
data: mockData,
isFetching: false,
isError: false,
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert
expect(screen.getByTestId('local-files-count')).toHaveTextContent('1')
expect(screen.getByTestId('datasource-type')).toHaveTextContent(DatasourceType.localFile)
})
it('should transform websiteCrawl datasource correctly', () => {
// Arrange
const mockData = createMockExecutionLogResponse({
datasource_type: DatasourceType.websiteCrawl,
datasource_info: {
content: 'Page content',
description: 'Page description',
source_url: 'https://example.com/page',
title: 'Page Title',
},
})
mockUsePipelineExecutionLog.mockReturnValue({
data: mockData,
isFetching: false,
isError: false,
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert
expect(screen.getByTestId('website-pages-count')).toHaveTextContent('1')
expect(screen.getByTestId('local-files-count')).toHaveTextContent('0')
})
it('should transform onlineDocument datasource correctly', () => {
// Arrange
const mockData = createMockExecutionLogResponse({
datasource_type: DatasourceType.onlineDocument,
datasource_info: {
workspace_id: 'workspace-1',
page: { page_id: 'page-1', page_name: 'Notion Page' },
},
})
mockUsePipelineExecutionLog.mockReturnValue({
data: mockData,
isFetching: false,
isError: false,
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert
expect(screen.getByTestId('online-documents-count')).toHaveTextContent('1')
})
it('should transform onlineDrive datasource correctly', () => {
// Arrange
const mockData = createMockExecutionLogResponse({
datasource_type: DatasourceType.onlineDrive,
datasource_info: { id: 'drive-1', type: 'doc', name: 'Google Doc', size: 1024 },
})
mockUsePipelineExecutionLog.mockReturnValue({
data: mockData,
isFetching: false,
isError: false,
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert
expect(screen.getByTestId('online-drive-files-count')).toHaveTextContent('1')
})
})
// ==================== User Interactions - Process ====================
describe('User Interactions - Process', () => {
it('should trigger form submit when process button is clicked', async () => {
// Arrange
mockMutateAsync.mockResolvedValue({})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Find the "Save and Process" button (from real ProcessDocuments > Actions)
const processButton = screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })
fireEvent.click(processButton)
// Assert
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
})
})
it('should call handleProcess with is_preview=false', async () => {
// Arrange
mockMutateAsync.mockResolvedValue({})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
// Assert
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
is_preview: false,
pipeline_id: mockPipelineId,
original_document_id: 'document-456',
}),
expect.any(Object),
)
})
})
it('should navigate to documents list after successful process', async () => {
// Arrange
mockMutateAsync.mockImplementation((_request, options) => {
options?.onSuccess?.()
return Promise.resolve({})
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
// Assert
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-123/documents')
})
})
it('should invalidate document cache after successful process', async () => {
// Arrange
mockMutateAsync.mockImplementation((_request, options) => {
options?.onSuccess?.()
return Promise.resolve({})
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
// Assert
await waitFor(() => {
expect(mockInvalidDocumentList).toHaveBeenCalled()
expect(mockInvalidDocumentDetail).toHaveBeenCalled()
})
})
})
// ==================== User Interactions - Preview ====================
describe('User Interactions - Preview', () => {
it('should trigger preview when preview button is clicked', async () => {
// Arrange
mockMutateAsync.mockResolvedValue({ data: { outputs: {} } })
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByTestId('preview-btn'))
// Assert
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
})
})
it('should call handlePreviewChunks with is_preview=true', async () => {
// Arrange
mockMutateAsync.mockResolvedValue({ data: { outputs: {} } })
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByTestId('preview-btn'))
// Assert
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
is_preview: true,
pipeline_id: mockPipelineId,
}),
expect.any(Object),
)
})
})
it('should update estimateData on successful preview', async () => {
// Arrange
const mockOutputs = { chunks: [], total_tokens: 50 }
mockMutateAsync.mockImplementation((_req, opts) => {
opts?.onSuccess?.({ data: { outputs: mockOutputs } })
return Promise.resolve({ data: { outputs: mockOutputs } })
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByTestId('preview-btn'))
// Assert
await waitFor(() => {
expect(screen.getByTestId('has-estimate-data')).toHaveTextContent('true')
})
})
})
// ==================== API Integration ====================
describe('API Integration', () => {
it('should pass correct parameters for preview', async () => {
// Arrange
const mockData = createMockExecutionLogResponse({
datasource_type: DatasourceType.localFile,
datasource_node_id: 'node-xyz',
datasource_info: { related_id: 'file-1', name: 'test.pdf', extension: 'pdf' },
input_data: {},
})
mockUsePipelineExecutionLog.mockReturnValue({
data: mockData,
isFetching: false,
isError: false,
})
mockMutateAsync.mockResolvedValue({ data: { outputs: {} } })
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByTestId('preview-btn'))
// Assert - inputs come from initialData which is transformed by useInitialData
// Since usePublishedPipelineProcessingParams returns empty variables, inputs is {}
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(
{
pipeline_id: mockPipelineId,
inputs: {},
start_node_id: 'node-xyz',
datasource_type: DatasourceType.localFile,
datasource_info_list: [{ related_id: 'file-1', name: 'test.pdf', extension: 'pdf' }],
is_preview: true,
},
expect.any(Object),
)
})
})
})
// ==================== Edge Cases ====================
describe('Edge Cases', () => {
it.each([
[DatasourceType.localFile, 'local-files-count', '1'],
[DatasourceType.websiteCrawl, 'website-pages-count', '1'],
[DatasourceType.onlineDocument, 'online-documents-count', '1'],
[DatasourceType.onlineDrive, 'online-drive-files-count', '1'],
])('should handle %s datasource type correctly', (datasourceType, testId, expectedCount) => {
// Arrange
const datasourceInfoMap: Record<DatasourceType, Record<string, unknown>> = {
[DatasourceType.localFile]: { related_id: 'f1', name: 'file.pdf', extension: 'pdf' },
[DatasourceType.websiteCrawl]: { content: 'c', description: 'd', source_url: 'u', title: 't' },
[DatasourceType.onlineDocument]: { workspace_id: 'w1', page: { page_id: 'p1' } },
[DatasourceType.onlineDrive]: { id: 'd1', type: 'doc', name: 'n', size: 100 },
}
const mockData = createMockExecutionLogResponse({
datasource_type: datasourceType,
datasource_info: datasourceInfoMap[datasourceType],
})
mockUsePipelineExecutionLog.mockReturnValue({
data: mockData,
isFetching: false,
isError: false,
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert
expect(screen.getByTestId(testId)).toHaveTextContent(expectedCount)
})
it('should show loading state during initial fetch', () => {
// Arrange
mockUsePipelineExecutionLog.mockReturnValue({
data: undefined,
isFetching: true,
isError: false,
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert
expect(screen.queryByTestId('process-form')).not.toBeInTheDocument()
})
it('should show error state when API fails', () => {
// Arrange
mockUsePipelineExecutionLog.mockReturnValue({
data: undefined,
isFetching: false,
isError: true,
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert
expect(screen.queryByTestId('process-form')).not.toBeInTheDocument()
})
})
// ==================== State Management ====================
describe('State Management', () => {
it('should initialize with undefined estimateData', () => {
// Arrange
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert
expect(screen.getByTestId('has-estimate-data')).toHaveTextContent('false')
})
it('should update estimateData after successful preview', async () => {
// Arrange
const mockEstimateData = { chunks: [], total_tokens: 50 }
mockMutateAsync.mockImplementation((_req, opts) => {
opts?.onSuccess?.({ data: { outputs: mockEstimateData } })
return Promise.resolve({ data: { outputs: mockEstimateData } })
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByTestId('preview-btn'))
// Assert
await waitFor(() => {
expect(screen.getByTestId('has-estimate-data')).toHaveTextContent('true')
})
})
it('should set isPreview ref to false when process is clicked', async () => {
// Arrange
mockMutateAsync.mockResolvedValue({})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
// Assert
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({ is_preview: false }),
expect.any(Object),
)
})
})
it('should set isPreview ref to true when preview is clicked', async () => {
// Arrange
mockMutateAsync.mockResolvedValue({ data: { outputs: {} } })
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByTestId('preview-btn'))
// Assert
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({ is_preview: true }),
expect.any(Object),
)
})
})
it('should pass isPending=true to ChunkPreview when preview is pending', async () => {
// Arrange - Start with isPending=false so buttons are enabled
let isPendingState = false
mockUseRunPublishedPipeline.mockImplementation(() => ({
mutateAsync: mockMutateAsync,
isIdle: !isPendingState,
isPending: isPendingState,
}))
// A promise that never resolves to keep the pending state
const pendingPromise = new Promise<void>(() => undefined)
// When mutateAsync is called, set isPending to true and trigger rerender
mockMutateAsync.mockImplementation(() => {
isPendingState = true
return pendingPromise
})
const props = createDefaultProps()
const { rerender } = renderWithProviders(<PipelineSettings {...props} />)
// Act - Click preview button (sets isPreview.current = true and calls mutateAsync)
fireEvent.click(screen.getByTestId('preview-btn'))
// Update mock and rerender to reflect isPending=true state
mockUseRunPublishedPipeline.mockReturnValue({
mutateAsync: mockMutateAsync,
isIdle: false,
isPending: true,
})
rerender(
<QueryClientProvider client={createQueryClient()}>
<PipelineSettings {...props} />
</QueryClientProvider>,
)
// Assert - isPending && isPreview.current should both be true now
expect(screen.getByTestId('is-pending')).toHaveTextContent('true')
})
it('should pass isPending=false to ChunkPreview when process is pending (not preview)', async () => {
// Arrange - isPending is true but isPreview.current is false
mockUseRunPublishedPipeline.mockReturnValue({
mutateAsync: mockMutateAsync,
isIdle: false,
isPending: true,
})
mockMutateAsync.mockReturnValue(new Promise<void>(() => undefined))
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Click process (not preview) to set isPreview.current = false
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
// Assert - isPending && isPreview.current should be false (true && false = false)
await waitFor(() => {
expect(screen.getByTestId('is-pending')).toHaveTextContent('false')
})
})
})
})

View File

@@ -31,6 +31,7 @@ const LeftHeader = ({
variant='secondary-accent'
className='absolute -left-11 top-3.5 size-9 rounded-full p-0'
onClick={navigateBack}
aria-label={t('common.operation.back')}
>
<RiArrowLeftLine className='size-5 ' />
</Button>

View File

@@ -0,0 +1,573 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import ProcessDocuments from './index'
import { PipelineInputVarType } from '@/models/pipeline'
import type { RAGPipelineVariable } from '@/models/pipeline'
// Mock i18n
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock dataset detail context - required for useInputVariables hook
const mockPipelineId = 'pipeline-123'
jest.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: { dataset: { pipeline_id: string } }) => string) =>
selector({ dataset: { pipeline_id: mockPipelineId } }),
}))
// Mock API call for pipeline processing params
const mockParamsConfig = jest.fn()
jest.mock('@/service/use-pipeline', () => ({
usePublishedPipelineProcessingParams: () => ({
data: mockParamsConfig(),
isFetching: false,
}),
}))
// Mock Form component - internal dependencies (useAppForm, BaseField) are too complex
// Keep the mock minimal and focused on testing the integration
jest.mock('../../../../create-from-pipeline/process-documents/form', () => {
return function MockForm({
ref,
initialData,
configurations,
onSubmit,
onPreview,
isRunning,
}: {
ref: React.RefObject<{ submit: () => void }>
initialData: Record<string, unknown>
configurations: Array<{ variable: string; label: string; type: string }>
schema: unknown
onSubmit: (data: Record<string, unknown>) => void
onPreview: () => void
isRunning: boolean
}) {
// Expose submit method via ref for parent component control
if (ref && typeof ref === 'object' && 'current' in ref) {
(ref as React.MutableRefObject<{ submit: () => void }>).current = {
submit: () => onSubmit(initialData),
}
}
return (
<form
data-testid="process-form"
onSubmit={(e) => {
e.preventDefault()
onSubmit(initialData)
}}
>
{/* Render actual field labels from configurations */}
{configurations.map((config, index) => (
<div key={index} data-testid={`field-${config.variable}`}>
<label>{config.label}</label>
<input
name={config.variable}
defaultValue={String(initialData[config.variable] ?? '')}
data-testid={`input-${config.variable}`}
/>
</div>
))}
<button type="button" data-testid="preview-btn" onClick={onPreview} disabled={isRunning}>
Preview
</button>
</form>
)
}
})
// Test utilities
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const renderWithProviders = (ui: React.ReactElement) => {
const queryClient = createQueryClient()
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
)
}
// Factory function for creating mock variables - matches RAGPipelineVariable type
const createMockVariable = (overrides: Partial<RAGPipelineVariable> = {}): RAGPipelineVariable => ({
belong_to_node_id: 'node-123',
type: PipelineInputVarType.textInput,
variable: 'test_var',
label: 'Test Variable',
required: false,
...overrides,
})
// Default props factory
const createDefaultProps = (overrides: Partial<{
datasourceNodeId: string
lastRunInputData: Record<string, unknown>
isRunning: boolean
ref: React.RefObject<{ submit: () => void } | null>
onProcess: () => void
onPreview: () => void
onSubmit: (data: Record<string, unknown>) => void
}> = {}) => ({
datasourceNodeId: 'node-123',
lastRunInputData: {},
isRunning: false,
ref: { current: null } as React.RefObject<{ submit: () => void } | null>,
onProcess: jest.fn(),
onPreview: jest.fn(),
onSubmit: jest.fn(),
...overrides,
})
describe('ProcessDocuments', () => {
beforeEach(() => {
jest.clearAllMocks()
// Default: return empty variables
mockParamsConfig.mockReturnValue({ variables: [] })
})
// ==================== Rendering Tests ====================
// Test basic rendering and component structure
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert - verify both Form and Actions are rendered
expect(screen.getByTestId('process-form')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })).toBeInTheDocument()
})
it('should render with correct container structure', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = renderWithProviders(<ProcessDocuments {...props} />)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex', 'flex-col', 'gap-y-4', 'pt-4')
})
it('should render form fields based on variables configuration', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'chunk_size', label: 'Chunk Size', type: PipelineInputVarType.number }),
createMockVariable({ variable: 'separator', label: 'Separator', type: PipelineInputVarType.textInput }),
]
mockParamsConfig.mockReturnValue({ variables })
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert - real hooks transform variables to configurations
expect(screen.getByTestId('field-chunk_size')).toBeInTheDocument()
expect(screen.getByTestId('field-separator')).toBeInTheDocument()
expect(screen.getByText('Chunk Size')).toBeInTheDocument()
expect(screen.getByText('Separator')).toBeInTheDocument()
})
})
// ==================== Props Testing ====================
// Test how component behaves with different prop values
describe('Props', () => {
describe('lastRunInputData', () => {
it('should use lastRunInputData as initial form values', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'chunk_size', label: 'Chunk Size', type: PipelineInputVarType.number, default_value: '100' }),
]
mockParamsConfig.mockReturnValue({ variables })
const lastRunInputData = { chunk_size: 500 }
const props = createDefaultProps({ lastRunInputData })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert - lastRunInputData should override default_value
const input = screen.getByTestId('input-chunk_size') as HTMLInputElement
expect(input.defaultValue).toBe('500')
})
it('should use default_value when lastRunInputData is empty', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'chunk_size', label: 'Chunk Size', type: PipelineInputVarType.number, default_value: '100' }),
]
mockParamsConfig.mockReturnValue({ variables })
const props = createDefaultProps({ lastRunInputData: {} })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
const input = screen.getByTestId('input-chunk_size') as HTMLInputElement
expect(input.value).toBe('100')
})
})
describe('isRunning', () => {
it('should enable Actions button when isRunning is false', () => {
// Arrange
const props = createDefaultProps({ isRunning: false })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
const processButton = screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })
expect(processButton).not.toBeDisabled()
})
it('should disable Actions button when isRunning is true', () => {
// Arrange
const props = createDefaultProps({ isRunning: true })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
const processButton = screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })
expect(processButton).toBeDisabled()
})
it('should disable preview button when isRunning is true', () => {
// Arrange
const props = createDefaultProps({ isRunning: true })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
expect(screen.getByTestId('preview-btn')).toBeDisabled()
})
})
describe('ref', () => {
it('should expose submit method via ref', () => {
// Arrange
const ref = { current: null } as React.RefObject<{ submit: () => void } | null>
const onSubmit = jest.fn()
const props = createDefaultProps({ ref, onSubmit })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
expect(ref.current).not.toBeNull()
expect(typeof ref.current?.submit).toBe('function')
// Act - call submit via ref
ref.current?.submit()
// Assert - onSubmit should be called
expect(onSubmit).toHaveBeenCalled()
})
})
})
// ==================== User Interactions ====================
// Test event handlers and user interactions
describe('User Interactions', () => {
describe('onProcess', () => {
it('should call onProcess when Save and Process button is clicked', () => {
// Arrange
const onProcess = jest.fn()
const props = createDefaultProps({ onProcess })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
// Assert
expect(onProcess).toHaveBeenCalledTimes(1)
})
it('should not call onProcess when button is disabled due to isRunning', () => {
// Arrange
const onProcess = jest.fn()
const props = createDefaultProps({ onProcess, isRunning: true })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
// Assert
expect(onProcess).not.toHaveBeenCalled()
})
})
describe('onPreview', () => {
it('should call onPreview when preview button is clicked', () => {
// Arrange
const onPreview = jest.fn()
const props = createDefaultProps({ onPreview })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
fireEvent.click(screen.getByTestId('preview-btn'))
// Assert
expect(onPreview).toHaveBeenCalledTimes(1)
})
})
describe('onSubmit', () => {
it('should call onSubmit with form data when form is submitted', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'chunk_size', label: 'Chunk Size', type: PipelineInputVarType.number, default_value: '100' }),
]
mockParamsConfig.mockReturnValue({ variables })
const onSubmit = jest.fn()
const props = createDefaultProps({ onSubmit })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
fireEvent.submit(screen.getByTestId('process-form'))
// Assert - should submit with initial data transformed by real hooks
// Note: default_value is string type, so the value remains as string
expect(onSubmit).toHaveBeenCalledWith({ chunk_size: '100' })
})
})
})
// ==================== Data Transformation Tests ====================
// Test real hooks transform data correctly
describe('Data Transformation', () => {
it('should transform text-input variable to string initial value', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'name', label: 'Name', type: PipelineInputVarType.textInput, default_value: 'default' }),
]
mockParamsConfig.mockReturnValue({ variables })
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
const input = screen.getByTestId('input-name') as HTMLInputElement
expect(input.defaultValue).toBe('default')
})
it('should transform number variable to number initial value', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'count', label: 'Count', type: PipelineInputVarType.number, default_value: '42' }),
]
mockParamsConfig.mockReturnValue({ variables })
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
const input = screen.getByTestId('input-count') as HTMLInputElement
expect(input.defaultValue).toBe('42')
})
it('should use empty string for text-input without default value', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'name', label: 'Name', type: PipelineInputVarType.textInput }),
]
mockParamsConfig.mockReturnValue({ variables })
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
const input = screen.getByTestId('input-name') as HTMLInputElement
expect(input.defaultValue).toBe('')
})
it('should prioritize lastRunInputData over default_value', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'size', label: 'Size', type: PipelineInputVarType.number, default_value: '100' }),
]
mockParamsConfig.mockReturnValue({ variables })
const props = createDefaultProps({ lastRunInputData: { size: 999 } })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
const input = screen.getByTestId('input-size') as HTMLInputElement
expect(input.defaultValue).toBe('999')
})
})
// ==================== Edge Cases ====================
// Test boundary conditions and error handling
describe('Edge Cases', () => {
describe('Empty/Null data handling', () => {
it('should handle undefined paramsConfig.variables', () => {
// Arrange
mockParamsConfig.mockReturnValue({ variables: undefined })
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert - should render without fields
expect(screen.getByTestId('process-form')).toBeInTheDocument()
expect(screen.queryByTestId(/^field-/)).not.toBeInTheDocument()
})
it('should handle null paramsConfig', () => {
// Arrange
mockParamsConfig.mockReturnValue(null)
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
expect(screen.getByTestId('process-form')).toBeInTheDocument()
})
it('should handle empty variables array', () => {
// Arrange
mockParamsConfig.mockReturnValue({ variables: [] })
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
expect(screen.getByTestId('process-form')).toBeInTheDocument()
expect(screen.queryByTestId(/^field-/)).not.toBeInTheDocument()
})
})
describe('Multiple variables', () => {
it('should handle multiple variables of different types', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'text_field', label: 'Text', type: PipelineInputVarType.textInput, default_value: 'hello' }),
createMockVariable({ variable: 'number_field', label: 'Number', type: PipelineInputVarType.number, default_value: '123' }),
createMockVariable({ variable: 'select_field', label: 'Select', type: PipelineInputVarType.select, default_value: 'option1' }),
]
mockParamsConfig.mockReturnValue({ variables })
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert - all fields should be rendered
expect(screen.getByTestId('field-text_field')).toBeInTheDocument()
expect(screen.getByTestId('field-number_field')).toBeInTheDocument()
expect(screen.getByTestId('field-select_field')).toBeInTheDocument()
})
it('should submit all variables data correctly', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'field1', label: 'Field 1', type: PipelineInputVarType.textInput, default_value: 'value1' }),
createMockVariable({ variable: 'field2', label: 'Field 2', type: PipelineInputVarType.number, default_value: '42' }),
]
mockParamsConfig.mockReturnValue({ variables })
const onSubmit = jest.fn()
const props = createDefaultProps({ onSubmit })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
fireEvent.submit(screen.getByTestId('process-form'))
// Assert - default_value is string type, so values remain as strings
expect(onSubmit).toHaveBeenCalledWith({
field1: 'value1',
field2: '42',
})
})
})
describe('Variable with options (select type)', () => {
it('should handle select variable with options', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({
variable: 'mode',
label: 'Mode',
type: PipelineInputVarType.select,
options: ['auto', 'manual', 'custom'],
default_value: 'auto',
}),
]
mockParamsConfig.mockReturnValue({ variables })
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
expect(screen.getByTestId('field-mode')).toBeInTheDocument()
const input = screen.getByTestId('input-mode') as HTMLInputElement
expect(input.defaultValue).toBe('auto')
})
})
})
// ==================== Integration Tests ====================
// Test Form and Actions components work together with real hooks
describe('Integration', () => {
it('should coordinate form submission flow correctly', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'setting', label: 'Setting', type: PipelineInputVarType.textInput, default_value: 'initial' }),
]
mockParamsConfig.mockReturnValue({ variables })
const onProcess = jest.fn()
const onSubmit = jest.fn()
const props = createDefaultProps({ onProcess, onSubmit })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert - form is rendered with correct initial data
const input = screen.getByTestId('input-setting') as HTMLInputElement
expect(input.defaultValue).toBe('initial')
// Act - click process button
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
// Assert - onProcess is called
expect(onProcess).toHaveBeenCalled()
})
it('should render complete UI with all interactive elements', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'test', label: 'Test Field', type: PipelineInputVarType.textInput }),
]
mockParamsConfig.mockReturnValue({ variables })
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert - all UI elements are present
expect(screen.getByTestId('process-form')).toBeInTheDocument()
expect(screen.getByText('Test Field')).toBeInTheDocument()
expect(screen.getByTestId('preview-btn')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,968 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import StatusItem from './index'
import type { DocumentDisplayStatus } from '@/models/datasets'
// Mock i18n - required for translation
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock ToastContext - required to verify notifications
const mockNotify = jest.fn()
jest.mock('use-context-selector', () => ({
...jest.requireActual('use-context-selector'),
useContext: () => ({ notify: mockNotify }),
}))
// Mock document service hooks - required to avoid real API calls
const mockEnableDocument = jest.fn()
const mockDisableDocument = jest.fn()
const mockDeleteDocument = jest.fn()
jest.mock('@/service/knowledge/use-document', () => ({
useDocumentEnable: () => ({ mutateAsync: mockEnableDocument }),
useDocumentDisable: () => ({ mutateAsync: mockDisableDocument }),
useDocumentDelete: () => ({ mutateAsync: mockDeleteDocument }),
}))
// Mock useDebounceFn to execute immediately for testing
jest.mock('ahooks', () => ({
...jest.requireActual('ahooks'),
useDebounceFn: (fn: (...args: unknown[]) => void) => ({ run: fn }),
}))
// Test utilities
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const renderWithProviders = (ui: React.ReactElement) => {
const queryClient = createQueryClient()
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
)
}
// Factory functions for test data
const createDetailProps = (overrides: Partial<{
enabled: boolean
archived: boolean
id: string
}> = {}) => ({
enabled: false,
archived: false,
id: 'doc-123',
...overrides,
})
describe('StatusItem', () => {
beforeEach(() => {
jest.clearAllMocks()
mockEnableDocument.mockResolvedValue({ result: 'success' })
mockDisableDocument.mockResolvedValue({ result: 'success' })
mockDeleteDocument.mockResolvedValue({ result: 'success' })
})
// ==================== Rendering Tests ====================
// Test basic rendering with different status values
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="available" />)
// Assert - check indicator element exists (real Indicator component)
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toBeInTheDocument()
})
it.each([
['queuing', 'bg-components-badge-status-light-warning-bg'],
['indexing', 'bg-components-badge-status-light-normal-bg'],
['paused', 'bg-components-badge-status-light-warning-bg'],
['error', 'bg-components-badge-status-light-error-bg'],
['available', 'bg-components-badge-status-light-success-bg'],
['enabled', 'bg-components-badge-status-light-success-bg'],
['disabled', 'bg-components-badge-status-light-disabled-bg'],
['archived', 'bg-components-badge-status-light-disabled-bg'],
] as const)('should render status "%s" with correct indicator background', (status, expectedBg) => {
// Arrange & Act
renderWithProviders(<StatusItem status={status} />)
// Assert
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass(expectedBg)
})
it('should render status text from translation', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="available" />)
// Assert
expect(screen.getByText('datasetDocuments.list.status.available')).toBeInTheDocument()
})
it('should handle case-insensitive status', () => {
// Arrange & Act
renderWithProviders(
<StatusItem status={'AVAILABLE' as DocumentDisplayStatus} />,
)
// Assert
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass('bg-components-badge-status-light-success-bg')
})
})
// ==================== Props Testing ====================
// Test all prop variations and combinations
describe('Props', () => {
// reverse prop tests
describe('reverse prop', () => {
it('should apply default layout when reverse is false', () => {
// Arrange & Act
const { container } = renderWithProviders(<StatusItem status="available" reverse={false} />)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).not.toHaveClass('flex-row-reverse')
})
it('should apply reversed layout when reverse is true', () => {
// Arrange & Act
const { container } = renderWithProviders(<StatusItem status="available" reverse />)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex-row-reverse')
})
it('should apply ml-2 to indicator when reversed', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="available" reverse />)
// Assert
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass('ml-2')
})
it('should apply mr-2 to indicator when not reversed', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="available" reverse={false} />)
// Assert
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass('mr-2')
})
})
// scene prop tests
describe('scene prop', () => {
it('should not render switch in list scene', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="available"
scene="list"
detail={createDetailProps()}
/>,
)
// Assert - Switch renders as a button element
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
})
it('should render switch in detail scene', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="available"
scene="detail"
detail={createDetailProps()}
/>,
)
// Assert
expect(screen.getByRole('switch')).toBeInTheDocument()
})
it('should default to list scene', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="available"
detail={createDetailProps()}
/>,
)
// Assert
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
})
})
// textCls prop tests
describe('textCls prop', () => {
it('should apply custom text class', () => {
// Arrange & Act
renderWithProviders(
<StatusItem status="available" textCls="custom-text-class" />,
)
// Assert
const statusText = screen.getByText('datasetDocuments.list.status.available')
expect(statusText).toHaveClass('custom-text-class')
})
it('should default to empty string', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="available" />)
// Assert
const statusText = screen.getByText('datasetDocuments.list.status.available')
expect(statusText).toHaveClass('text-sm')
})
})
// errorMessage prop tests
describe('errorMessage prop', () => {
it('should render tooltip trigger when errorMessage is provided', () => {
// Arrange & Act
renderWithProviders(
<StatusItem status="error" errorMessage="Something went wrong" />,
)
// Assert - tooltip trigger element should exist
const tooltipTrigger = screen.getByTestId('error-tooltip-trigger')
expect(tooltipTrigger).toBeInTheDocument()
})
it('should show error message on hover', async () => {
// Arrange
renderWithProviders(
<StatusItem status="error" errorMessage="Something went wrong" />,
)
// Act - hover the tooltip trigger
const tooltipTrigger = screen.getByTestId('error-tooltip-trigger')
fireEvent.mouseEnter(tooltipTrigger)
// Assert - wait for tooltip content to appear
expect(await screen.findByText('Something went wrong')).toBeInTheDocument()
})
it('should not render tooltip trigger when errorMessage is not provided', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="error" />)
// Assert - tooltip trigger should not exist
const tooltipTrigger = screen.queryByTestId('error-tooltip-trigger')
expect(tooltipTrigger).not.toBeInTheDocument()
})
it('should not render tooltip trigger when errorMessage is empty', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="error" errorMessage="" />)
// Assert - tooltip trigger should not exist
const tooltipTrigger = screen.queryByTestId('error-tooltip-trigger')
expect(tooltipTrigger).not.toBeInTheDocument()
})
})
// detail prop tests
describe('detail prop', () => {
it('should use default values when detail is undefined', () => {
// Arrange & Act
renderWithProviders(
<StatusItem status="available" scene="detail" />,
)
// Assert - switch should be unchecked (defaultValue = false when archived = false and enabled = false)
const switchEl = screen.getByRole('switch')
expect(switchEl).toHaveAttribute('aria-checked', 'false')
})
it('should use enabled value from detail', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="available"
scene="detail"
detail={createDetailProps({ enabled: true })}
/>,
)
// Assert
const switchEl = screen.getByRole('switch')
expect(switchEl).toHaveAttribute('aria-checked', 'true')
})
it('should set switch to false when archived regardless of enabled', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="available"
scene="detail"
detail={createDetailProps({ enabled: true, archived: true })}
/>,
)
// Assert - archived overrides enabled, defaultValue becomes false
const switchEl = screen.getByRole('switch')
expect(switchEl).toHaveAttribute('aria-checked', 'false')
})
})
})
// ==================== Memoization Tests ====================
// Test useMemo logic for embedding status (disables switch)
describe('Memoization', () => {
it.each([
['queuing', true],
['indexing', true],
['paused', true],
['available', false],
['enabled', false],
['disabled', false],
['archived', false],
['error', false],
] as const)('should correctly identify embedding status for "%s" - disabled: %s', (status, isEmbedding) => {
// Arrange & Act
renderWithProviders(
<StatusItem
status={status}
scene="detail"
detail={createDetailProps()}
/>,
)
// Assert - check if switch is visually disabled (via CSS classes)
// The Switch component uses CSS classes for disabled state, not the native disabled attribute
const switchEl = screen.getByRole('switch')
if (isEmbedding)
expect(switchEl).toHaveClass('!cursor-not-allowed', '!opacity-50')
else
expect(switchEl).not.toHaveClass('!cursor-not-allowed')
})
it('should disable switch when archived', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="available"
scene="detail"
detail={createDetailProps({ archived: true })}
/>,
)
// Assert - visually disabled via CSS classes
const switchEl = screen.getByRole('switch')
expect(switchEl).toHaveClass('!cursor-not-allowed', '!opacity-50')
})
it('should disable switch when both embedding and archived', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="indexing"
scene="detail"
detail={createDetailProps({ archived: true })}
/>,
)
// Assert - visually disabled via CSS classes
const switchEl = screen.getByRole('switch')
expect(switchEl).toHaveClass('!cursor-not-allowed', '!opacity-50')
})
})
// ==================== Switch Toggle Tests ====================
// Test Switch toggle interactions
describe('Switch Toggle', () => {
it('should call enable operation when switch is toggled on', async () => {
// Arrange
const mockOnUpdate = jest.fn()
renderWithProviders(
<StatusItem
status="disabled"
scene="detail"
detail={createDetailProps({ enabled: false })}
datasetId="dataset-123"
onUpdate={mockOnUpdate}
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockEnableDocument).toHaveBeenCalledWith({
datasetId: 'dataset-123',
documentId: 'doc-123',
})
})
})
it('should call disable operation when switch is toggled off', async () => {
// Arrange
const mockOnUpdate = jest.fn()
renderWithProviders(
<StatusItem
status="enabled"
scene="detail"
detail={createDetailProps({ enabled: true })}
datasetId="dataset-123"
onUpdate={mockOnUpdate}
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockDisableDocument).toHaveBeenCalledWith({
datasetId: 'dataset-123',
documentId: 'doc-123',
})
})
})
it('should not call any operation when archived', () => {
// Arrange
renderWithProviders(
<StatusItem
status="available"
scene="detail"
detail={createDetailProps({ archived: true })}
datasetId="dataset-123"
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
expect(mockEnableDocument).not.toHaveBeenCalled()
expect(mockDisableDocument).not.toHaveBeenCalled()
})
it('should render switch as checked when enabled is true', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="enabled"
scene="detail"
detail={createDetailProps({ enabled: true })}
datasetId="dataset-123"
/>,
)
// Assert - verify switch shows checked state
const switchEl = screen.getByRole('switch')
expect(switchEl).toHaveAttribute('aria-checked', 'true')
})
it('should render switch as unchecked when enabled is false', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="disabled"
scene="detail"
detail={createDetailProps({ enabled: false })}
datasetId="dataset-123"
/>,
)
// Assert - verify switch shows unchecked state
const switchEl = screen.getByRole('switch')
expect(switchEl).toHaveAttribute('aria-checked', 'false')
})
it('should skip enable operation when props.enabled is true (guard branch)', () => {
// Covers guard condition: if (operationName === 'enable' && enabled) return
// Note: The guard checks props.enabled, NOT the Switch's internal UI state.
// This prevents redundant API calls when the UI toggles back to a state
// that already matches the server-side data (props haven't been updated yet).
const mockOnUpdate = jest.fn()
renderWithProviders(
<StatusItem
status="enabled"
scene="detail"
detail={createDetailProps({ enabled: true })}
datasetId="dataset-123"
onUpdate={mockOnUpdate}
/>,
)
const switchEl = screen.getByRole('switch')
// First click: Switch UI toggles OFF, calls disable (props.enabled=true, so allowed)
fireEvent.click(switchEl)
// Second click: Switch UI toggles ON, tries to call enable
// BUT props.enabled is still true (not updated), so guard skips the API call
fireEvent.click(switchEl)
// Assert - disable was called once, enable was skipped because props.enabled=true
expect(mockDisableDocument).toHaveBeenCalledTimes(1)
expect(mockEnableDocument).not.toHaveBeenCalled()
})
it('should skip disable operation when props.enabled is false (guard branch)', () => {
// Covers guard condition: if (operationName === 'disable' && !enabled) return
// Note: The guard checks props.enabled, NOT the Switch's internal UI state.
// This prevents redundant API calls when the UI toggles back to a state
// that already matches the server-side data (props haven't been updated yet).
const mockOnUpdate = jest.fn()
renderWithProviders(
<StatusItem
status="disabled"
scene="detail"
detail={createDetailProps({ enabled: false })}
datasetId="dataset-123"
onUpdate={mockOnUpdate}
/>,
)
const switchEl = screen.getByRole('switch')
// First click: Switch UI toggles ON, calls enable (props.enabled=false, so allowed)
fireEvent.click(switchEl)
// Second click: Switch UI toggles OFF, tries to call disable
// BUT props.enabled is still false (not updated), so guard skips the API call
fireEvent.click(switchEl)
// Assert - enable was called once, disable was skipped because props.enabled=false
expect(mockEnableDocument).toHaveBeenCalledTimes(1)
expect(mockDisableDocument).not.toHaveBeenCalled()
})
})
// ==================== onUpdate Callback Tests ====================
// Test onUpdate callback behavior
describe('onUpdate Callback', () => {
it('should call onUpdate with operation name on successful enable', async () => {
// Arrange
const mockOnUpdate = jest.fn()
renderWithProviders(
<StatusItem
status="disabled"
scene="detail"
detail={createDetailProps({ enabled: false })}
datasetId="dataset-123"
onUpdate={mockOnUpdate}
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockOnUpdate).toHaveBeenCalledWith('enable')
})
})
it('should call onUpdate with operation name on successful disable', async () => {
// Arrange
const mockOnUpdate = jest.fn()
renderWithProviders(
<StatusItem
status="enabled"
scene="detail"
detail={createDetailProps({ enabled: true })}
datasetId="dataset-123"
onUpdate={mockOnUpdate}
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockOnUpdate).toHaveBeenCalledWith('disable')
})
})
it('should not call onUpdate when operation fails', async () => {
// Arrange
mockEnableDocument.mockRejectedValue(new Error('API Error'))
const mockOnUpdate = jest.fn()
renderWithProviders(
<StatusItem
status="disabled"
scene="detail"
detail={createDetailProps({ enabled: false })}
datasetId="dataset-123"
onUpdate={mockOnUpdate}
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'common.actionMsg.modifiedUnsuccessfully',
})
})
expect(mockOnUpdate).not.toHaveBeenCalled()
})
it('should not throw when onUpdate is not provided', () => {
// Arrange
renderWithProviders(
<StatusItem
status="disabled"
scene="detail"
detail={createDetailProps({ enabled: false })}
datasetId="dataset-123"
/>,
)
// Act
const switchEl = screen.getByRole('switch')
// Assert - should not throw
expect(() => fireEvent.click(switchEl)).not.toThrow()
})
})
// ==================== API Calls ====================
// Test API operations and toast notifications
describe('API Operations', () => {
it('should show success toast on successful operation', async () => {
// Arrange
renderWithProviders(
<StatusItem
status="disabled"
scene="detail"
detail={createDetailProps({ enabled: false })}
datasetId="dataset-123"
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
message: 'common.actionMsg.modifiedSuccessfully',
})
})
})
it('should show error toast on failed operation', async () => {
// Arrange
mockDisableDocument.mockRejectedValue(new Error('Network error'))
renderWithProviders(
<StatusItem
status="enabled"
scene="detail"
detail={createDetailProps({ enabled: true })}
datasetId="dataset-123"
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'common.actionMsg.modifiedUnsuccessfully',
})
})
})
it('should pass correct parameters to enable API', async () => {
// Arrange
renderWithProviders(
<StatusItem
status="disabled"
scene="detail"
detail={createDetailProps({ enabled: false, id: 'test-doc-id' })}
datasetId="test-dataset-id"
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockEnableDocument).toHaveBeenCalledWith({
datasetId: 'test-dataset-id',
documentId: 'test-doc-id',
})
})
})
it('should pass correct parameters to disable API', async () => {
// Arrange
renderWithProviders(
<StatusItem
status="enabled"
scene="detail"
detail={createDetailProps({ enabled: true, id: 'test-doc-456' })}
datasetId="test-dataset-456"
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockDisableDocument).toHaveBeenCalledWith({
datasetId: 'test-dataset-456',
documentId: 'test-doc-456',
})
})
})
})
// ==================== Edge Cases ====================
// Test boundary conditions and unusual inputs
describe('Edge Cases', () => {
it('should handle empty datasetId', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="available"
scene="detail"
detail={createDetailProps()}
/>,
)
// Assert - should render without errors
expect(screen.getByRole('switch')).toBeInTheDocument()
})
it('should handle undefined detail gracefully', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="available"
scene="detail"
detail={undefined}
/>,
)
// Assert
const switchEl = screen.getByRole('switch')
expect(switchEl).toHaveAttribute('aria-checked', 'false')
})
it('should handle empty string id in detail', async () => {
// Arrange
renderWithProviders(
<StatusItem
status="disabled"
scene="detail"
detail={createDetailProps({ enabled: false, id: '' })}
datasetId="dataset-123"
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockEnableDocument).toHaveBeenCalledWith({
datasetId: 'dataset-123',
documentId: '',
})
})
})
it('should handle very long error messages', async () => {
// Arrange
const longErrorMessage = 'A'.repeat(500)
renderWithProviders(
<StatusItem status="error" errorMessage={longErrorMessage} />,
)
// Act - hover to show tooltip
const tooltipTrigger = screen.getByTestId('error-tooltip-trigger')
fireEvent.mouseEnter(tooltipTrigger)
// Assert
await waitFor(() => {
expect(screen.getByText(longErrorMessage)).toBeInTheDocument()
})
})
it('should handle special characters in error message', async () => {
// Arrange
const specialChars = '<script>alert("xss")</script> & < > " \''
renderWithProviders(
<StatusItem status="error" errorMessage={specialChars} />,
)
// Act - hover to show tooltip
const tooltipTrigger = screen.getByTestId('error-tooltip-trigger')
fireEvent.mouseEnter(tooltipTrigger)
// Assert
await waitFor(() => {
expect(screen.getByText(specialChars)).toBeInTheDocument()
})
})
it('should handle all status types in sequence', () => {
// Arrange
const statuses: DocumentDisplayStatus[] = [
'queuing', 'indexing', 'paused', 'error',
'available', 'enabled', 'disabled', 'archived',
]
// Act & Assert
statuses.forEach((status) => {
const { unmount } = renderWithProviders(<StatusItem status={status} />)
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toBeInTheDocument()
unmount()
})
})
})
// ==================== Component Memoization ====================
// Test React.memo behavior
describe('Component Memoization', () => {
it('should be wrapped with React.memo', () => {
// Assert
expect(StatusItem).toHaveProperty('$$typeof', Symbol.for('react.memo'))
})
it('should render correctly with same props', () => {
// Arrange
const props = {
status: 'available' as const,
scene: 'detail' as const,
detail: createDetailProps(),
}
// Act
const { rerender } = renderWithProviders(<StatusItem {...props} />)
rerender(
<QueryClientProvider client={createQueryClient()}>
<StatusItem {...props} />
</QueryClientProvider>,
)
// Assert
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toBeInTheDocument()
})
it('should update when status prop changes', () => {
// Arrange
const { rerender } = renderWithProviders(<StatusItem status="available" />)
// Assert initial - green/success background
let indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass('bg-components-badge-status-light-success-bg')
// Act
rerender(
<QueryClientProvider client={createQueryClient()}>
<StatusItem status="error" />
</QueryClientProvider>,
)
// Assert updated - red/error background
indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass('bg-components-badge-status-light-error-bg')
})
})
// ==================== Styling Tests ====================
// Test CSS classes and styling
describe('Styling', () => {
it('should apply correct status text color for green status', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="available" />)
// Assert
const statusText = screen.getByText('datasetDocuments.list.status.available')
expect(statusText).toHaveClass('text-util-colors-green-green-600')
})
it('should apply correct status text color for red status', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="error" />)
// Assert
const statusText = screen.getByText('datasetDocuments.list.status.error')
expect(statusText).toHaveClass('text-util-colors-red-red-600')
})
it('should apply correct status text color for orange status', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="queuing" />)
// Assert
const statusText = screen.getByText('datasetDocuments.list.status.queuing')
expect(statusText).toHaveClass('text-util-colors-warning-warning-600')
})
it('should apply correct status text color for blue status', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="indexing" />)
// Assert
const statusText = screen.getByText('datasetDocuments.list.status.indexing')
expect(statusText).toHaveClass('text-util-colors-blue-light-blue-light-600')
})
it('should apply correct status text color for gray status', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="disabled" />)
// Assert
const statusText = screen.getByText('datasetDocuments.list.status.disabled')
expect(statusText).toHaveClass('text-text-tertiary')
})
it('should render switch with md size in detail scene', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="available"
scene="detail"
detail={createDetailProps()}
/>,
)
// Assert - check switch has the md size class (h-4 w-7)
const switchEl = screen.getByRole('switch')
expect(switchEl).toHaveClass('h-4', 'w-7')
})
})
})

View File

@@ -105,6 +105,7 @@ const StatusItem = ({
<div className='max-w-[260px] break-all'>{errorMessage}</div>
}
triggerClassName='ml-1 w-4 h-4'
triggerTestId='error-tooltip-trigger'
/>
)
}

View File

@@ -47,6 +47,7 @@ export default function Indicator({
}: IndicatorProps) {
return (
<div
data-testid="status-indicator"
className={classNames(
'h-2 w-2 rounded-[3px] border border-solid',
BACKGROUND_MAP[color],