mirror of
https://github.com/langgenius/dify.git
synced 2025-12-19 22:28:46 +00:00
feat: add automated tests for pipeline setting (#29478)
Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
This commit is contained in:
@@ -62,7 +62,7 @@ type CurrChildChunkType = {
|
||||
showModal: boolean
|
||||
}
|
||||
|
||||
type SegmentListContextValue = {
|
||||
export type SegmentListContextValue = {
|
||||
isCollapsed: boolean
|
||||
fullScreen: boolean
|
||||
toggleFullScreen: (fullscreen?: boolean) => void
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
968
web/app/components/datasets/documents/status-item/index.spec.tsx
Normal file
968
web/app/components/datasets/documents/status-item/index.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user