diff --git a/web/app/components/datasets/documents/detail/completed/index.tsx b/web/app/components/datasets/documents/detail/completed/index.tsx
index a3f76d9481..ad405f6b15 100644
--- a/web/app/components/datasets/documents/detail/completed/index.tsx
+++ b/web/app/components/datasets/documents/detail/completed/index.tsx
@@ -62,7 +62,7 @@ type CurrChildChunkType = {
showModal: boolean
}
-type SegmentListContextValue = {
+export type SegmentListContextValue = {
isCollapsed: boolean
fullScreen: boolean
toggleFullScreen: (fullscreen?: boolean) => void
diff --git a/web/app/components/datasets/documents/detail/completed/segment-card/index.spec.tsx b/web/app/components/datasets/documents/detail/completed/segment-card/index.spec.tsx
new file mode 100644
index 0000000000..ced1bf05a9
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/segment-card/index.spec.tsx
@@ -0,0 +1,1204 @@
+import React from 'react'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import SegmentCard from './index'
+import { type Attachment, type ChildChunkDetail, ChunkingMode, type ParentMode, type SegmentDetailModel } from '@/models/datasets'
+import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context'
+import type { SegmentListContextValue } from '@/app/components/datasets/documents/detail/completed'
+
+// Mock react-i18next - external dependency
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, options?: { count?: number }) => {
+ if (key === 'datasetDocuments.segment.characters')
+ return options?.count === 1 ? 'character' : 'characters'
+ if (key === 'datasetDocuments.segment.childChunks')
+ return options?.count === 1 ? 'child chunk' : 'child chunks'
+ return key
+ },
+ }),
+}))
+
+// ============================================================================
+// Context Mocks - need to control test scenarios
+// ============================================================================
+
+const mockDocForm = { current: ChunkingMode.text }
+const mockParentMode = { current: 'paragraph' as ParentMode }
+
+jest.mock('../../context', () => ({
+ useDocumentContext: (selector: (value: DocumentContextValue) => unknown) => {
+ const value: DocumentContextValue = {
+ datasetId: 'test-dataset-id',
+ documentId: 'test-document-id',
+ docForm: mockDocForm.current,
+ parentMode: mockParentMode.current,
+ }
+ return selector(value)
+ },
+}))
+
+const mockIsCollapsed = { current: true }
+jest.mock('../index', () => ({
+ useSegmentListContext: (selector: (value: SegmentListContextValue) => unknown) => {
+ const value: SegmentListContextValue = {
+ isCollapsed: mockIsCollapsed.current,
+ fullScreen: false,
+ toggleFullScreen: jest.fn(),
+ currSegment: { showModal: false },
+ currChildChunk: { showModal: false },
+ }
+ return selector(value)
+ },
+}))
+
+// ============================================================================
+// Component Mocks - components with complex ESM dependencies (ky, react-pdf-highlighter, etc.)
+// These are mocked to avoid Jest ESM parsing issues, not because they're external
+// ============================================================================
+
+// StatusItem has deep dependency: use-document hooks → service/base → ky (ESM)
+jest.mock('../../../status-item', () => ({
+ __esModule: true,
+ default: ({ status, reverse, textCls }: { status: string; reverse?: boolean; textCls?: string }) => (
+
+ Status: {status}
+
+ ),
+}))
+
+// ImageList has deep dependency: FileThumb → file-uploader → ky, react-pdf-highlighter (ESM)
+jest.mock('@/app/components/datasets/common/image-list', () => ({
+ __esModule: true,
+ default: ({ images, size, className }: { images: Array<{ sourceUrl: string; name: string }>; size?: string; className?: string }) => (
+
+ {images.map((img, idx: number) => (
+

+ ))}
+
+ ),
+}))
+
+// Markdown uses next/dynamic and react-syntax-highlighter (ESM)
+jest.mock('@/app/components/base/markdown', () => ({
+ __esModule: true,
+ Markdown: ({ content, className }: { content: string; className?: string }) => (
+ {content}
+ ),
+}))
+
+// ============================================================================
+// Test Data Factories
+// ============================================================================
+
+const createMockAttachment = (overrides: Partial = {}): Attachment => ({
+ id: 'attachment-1',
+ name: 'test-image.png',
+ size: 1024,
+ extension: 'png',
+ mime_type: 'image/png',
+ source_url: 'https://example.com/test-image.png',
+ ...overrides,
+})
+
+const createMockChildChunk = (overrides: Partial = {}): ChildChunkDetail => ({
+ id: 'child-chunk-1',
+ position: 1,
+ segment_id: 'segment-1',
+ content: 'Child chunk content',
+ word_count: 100,
+ created_at: 1700000000,
+ updated_at: 1700000000,
+ type: 'automatic',
+ ...overrides,
+})
+
+const createMockSegmentDetail = (overrides: Partial = {}): SegmentDetailModel & { document?: { name: string } } => ({
+ id: 'segment-1',
+ position: 1,
+ document_id: 'doc-1',
+ content: 'Test segment content',
+ sign_content: 'Test signed content',
+ word_count: 100,
+ tokens: 50,
+ keywords: ['keyword1', 'keyword2'],
+ index_node_id: 'index-1',
+ index_node_hash: 'hash-1',
+ hit_count: 10,
+ enabled: true,
+ disabled_at: 0,
+ disabled_by: '',
+ status: 'completed',
+ created_by: 'user-1',
+ created_at: 1700000000,
+ indexing_at: 1700000100,
+ completed_at: 1700000200,
+ error: null,
+ stopped_at: 0,
+ updated_at: 1700000000,
+ attachments: [],
+ child_chunks: [],
+ document: { name: 'Test Document' },
+ ...overrides,
+})
+
+const defaultFocused = { segmentIndex: false, segmentContent: false }
+
+// ============================================================================
+// Tests
+// ============================================================================
+
+describe('SegmentCard', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockDocForm.current = ChunkingMode.text
+ mockParentMode.current = 'paragraph'
+ mockIsCollapsed.current = true
+ })
+
+ // --------------------------------------------------------------------------
+ // Rendering Tests
+ // --------------------------------------------------------------------------
+ describe('Rendering', () => {
+ it('should render loading skeleton when loading is true', () => {
+ render()
+
+ // ParentChunkCardSkeleton should render
+ expect(screen.getByTestId('parent-chunk-card-skeleton')).toBeInTheDocument()
+ })
+
+ it('should render segment card content when loading is false', () => {
+ const detail = createMockSegmentDetail()
+
+ render()
+
+ // ChunkContent shows sign_content first, then content
+ expect(screen.getByText('Test signed content')).toBeInTheDocument()
+ })
+
+ it('should render segment index tag with correct position', () => {
+ const detail = createMockSegmentDetail({ position: 5 })
+
+ render()
+
+ expect(screen.getByText(/Chunk-05/i)).toBeInTheDocument()
+ })
+
+ it('should render word count text', () => {
+ const detail = createMockSegmentDetail({ word_count: 250 })
+
+ render()
+
+ expect(screen.getByText('250 characters')).toBeInTheDocument()
+ })
+
+ it('should render hit count text', () => {
+ const detail = createMockSegmentDetail({ hit_count: 42 })
+
+ render()
+
+ expect(screen.getByText('42 datasetDocuments.segment.hitCount')).toBeInTheDocument()
+ })
+
+ it('should apply custom className', () => {
+ const detail = createMockSegmentDetail()
+
+ render(
+ ,
+ )
+
+ const card = screen.getByTestId('segment-card')
+ expect(card).toHaveClass('custom-class')
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Props Tests
+ // --------------------------------------------------------------------------
+ describe('Props', () => {
+ it('should use default empty object when detail is undefined', () => {
+ render()
+
+ expect(screen.getByText(/Chunk/i)).toBeInTheDocument()
+ })
+
+ it('should handle archived prop correctly - switch should be disabled', () => {
+ const detail = createMockSegmentDetail()
+
+ render(
+ ,
+ )
+
+ const switchElement = screen.getByRole('switch')
+ expect(switchElement).toHaveClass('!cursor-not-allowed')
+ })
+
+ it('should show action buttons when embeddingAvailable is true', () => {
+ const detail = createMockSegmentDetail()
+
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('segment-edit-button')).toBeInTheDocument()
+ expect(screen.getByTestId('segment-delete-button')).toBeInTheDocument()
+ expect(screen.getByRole('switch')).toBeInTheDocument()
+ })
+
+ it('should not show action buttons when embeddingAvailable is false', () => {
+ const detail = createMockSegmentDetail()
+
+ render(
+ ,
+ )
+
+ expect(screen.queryByRole('switch')).not.toBeInTheDocument()
+ })
+
+ it('should apply focused styles when segmentContent is focused', () => {
+ const detail = createMockSegmentDetail()
+
+ render(
+ ,
+ )
+
+ const card = screen.getByTestId('segment-card')
+ expect(card).toHaveClass('bg-dataset-chunk-detail-card-hover-bg')
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // State Management Tests
+ // --------------------------------------------------------------------------
+ describe('State Management', () => {
+ it('should toggle delete confirmation modal when delete button clicked', async () => {
+ const detail = createMockSegmentDetail()
+
+ render(
+ ,
+ )
+
+ const deleteButton = screen.getByTestId('segment-delete-button')
+ fireEvent.click(deleteButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('datasetDocuments.segment.delete')).toBeInTheDocument()
+ })
+ })
+
+ it('should close delete confirmation modal when cancel is clicked', async () => {
+ const detail = createMockSegmentDetail()
+
+ render(
+ ,
+ )
+
+ const deleteButton = screen.getByTestId('segment-delete-button')
+ fireEvent.click(deleteButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('datasetDocuments.segment.delete')).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByText('common.operation.cancel'))
+
+ await waitFor(() => {
+ expect(screen.queryByText('datasetDocuments.segment.delete')).not.toBeInTheDocument()
+ })
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Callback Tests
+ // --------------------------------------------------------------------------
+ describe('Callbacks', () => {
+ it('should call onClick when card is clicked in general mode', () => {
+ const onClick = jest.fn()
+ const detail = createMockSegmentDetail()
+ mockDocForm.current = ChunkingMode.text
+
+ render(
+ ,
+ )
+
+ const card = screen.getByTestId('segment-card')
+ fireEvent.click(card)
+
+ expect(onClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('should not call onClick when card is clicked in full-doc mode', () => {
+ const onClick = jest.fn()
+ const detail = createMockSegmentDetail()
+ mockDocForm.current = ChunkingMode.parentChild
+ mockParentMode.current = 'full-doc'
+
+ render(
+ ,
+ )
+
+ const card = screen.getByTestId('segment-card')
+ fireEvent.click(card)
+
+ expect(onClick).not.toHaveBeenCalled()
+ })
+
+ it('should call onClick when view more button is clicked in full-doc mode', () => {
+ const onClick = jest.fn()
+ const detail = createMockSegmentDetail()
+ mockDocForm.current = ChunkingMode.parentChild
+ mockParentMode.current = 'full-doc'
+
+ render()
+
+ const viewMoreButton = screen.getByRole('button', { name: /viewMore/i })
+ fireEvent.click(viewMoreButton)
+
+ expect(onClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onClickEdit when edit button is clicked', () => {
+ const onClickEdit = jest.fn()
+ const detail = createMockSegmentDetail()
+
+ render(
+ ,
+ )
+
+ const editButton = screen.getByTestId('segment-edit-button')
+ fireEvent.click(editButton)
+
+ expect(onClickEdit).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onDelete when confirm delete is clicked', async () => {
+ const onDelete = jest.fn().mockResolvedValue(undefined)
+ const detail = createMockSegmentDetail({ id: 'test-segment-id' })
+
+ render(
+ ,
+ )
+
+ const deleteButton = screen.getByTestId('segment-delete-button')
+ fireEvent.click(deleteButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('datasetDocuments.segment.delete')).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByText('common.operation.sure'))
+
+ await waitFor(() => {
+ expect(onDelete).toHaveBeenCalledWith('test-segment-id')
+ })
+ })
+
+ it('should call onChangeSwitch when switch is toggled', async () => {
+ const onChangeSwitch = jest.fn().mockResolvedValue(undefined)
+ const detail = createMockSegmentDetail({ id: 'test-segment-id', enabled: true, status: 'completed' })
+
+ render(
+ ,
+ )
+
+ const switchElement = screen.getByRole('switch')
+ fireEvent.click(switchElement)
+
+ await waitFor(() => {
+ expect(onChangeSwitch).toHaveBeenCalledWith(false, 'test-segment-id')
+ })
+ })
+
+ it('should stop propagation when edit button is clicked', () => {
+ const onClick = jest.fn()
+ const onClickEdit = jest.fn()
+ const detail = createMockSegmentDetail()
+
+ render(
+ ,
+ )
+
+ const editButton = screen.getByTestId('segment-edit-button')
+ fireEvent.click(editButton)
+
+ expect(onClickEdit).toHaveBeenCalledTimes(1)
+ expect(onClick).not.toHaveBeenCalled()
+ })
+
+ it('should stop propagation when switch area is clicked', () => {
+ const onClick = jest.fn()
+ const detail = createMockSegmentDetail({ status: 'completed' })
+
+ render(
+ ,
+ )
+
+ const switchElement = screen.getByRole('switch')
+ const switchContainer = switchElement.parentElement
+ fireEvent.click(switchContainer!)
+
+ expect(onClick).not.toHaveBeenCalled()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Memoization Logic Tests
+ // --------------------------------------------------------------------------
+ describe('Memoization Logic', () => {
+ it('should compute isGeneralMode correctly for text mode - show keywords', () => {
+ mockDocForm.current = ChunkingMode.text
+ const detail = createMockSegmentDetail({ keywords: ['testkeyword'] })
+
+ render()
+
+ expect(screen.getByText('testkeyword')).toBeInTheDocument()
+ })
+
+ it('should compute isGeneralMode correctly for non-text mode - hide keywords', () => {
+ mockDocForm.current = ChunkingMode.qa
+ const detail = createMockSegmentDetail({ keywords: ['testkeyword'] })
+
+ render()
+
+ expect(screen.queryByText('testkeyword')).not.toBeInTheDocument()
+ })
+
+ it('should compute isParentChildMode correctly - show parent chunk prefix', () => {
+ mockDocForm.current = ChunkingMode.parentChild
+ const detail = createMockSegmentDetail()
+
+ render()
+
+ expect(screen.getByText(/datasetDocuments\.segment\.parentChunk/i)).toBeInTheDocument()
+ })
+
+ it('should compute isFullDocMode correctly - show view more button', () => {
+ mockDocForm.current = ChunkingMode.parentChild
+ mockParentMode.current = 'full-doc'
+ const detail = createMockSegmentDetail()
+
+ render()
+
+ expect(screen.getByText('common.operation.viewMore')).toBeInTheDocument()
+ })
+
+ it('should compute isParagraphMode correctly and show child chunks', () => {
+ mockDocForm.current = ChunkingMode.parentChild
+ mockParentMode.current = 'paragraph'
+ const childChunks = [createMockChildChunk()]
+ const detail = createMockSegmentDetail({ child_chunks: childChunks })
+
+ render()
+
+ // ChildSegmentList should render
+ expect(screen.getByText(/child chunk/i)).toBeInTheDocument()
+ })
+
+ it('should compute chunkEdited correctly when updated_at > created_at', () => {
+ mockDocForm.current = ChunkingMode.text
+ const detail = createMockSegmentDetail({
+ created_at: 1700000000,
+ updated_at: 1700000001,
+ })
+
+ render()
+
+ expect(screen.getByText('datasetDocuments.segment.edited')).toBeInTheDocument()
+ })
+
+ it('should not show edited badge when timestamps are equal', () => {
+ mockDocForm.current = ChunkingMode.text
+ const detail = createMockSegmentDetail({
+ created_at: 1700000000,
+ updated_at: 1700000000,
+ })
+
+ render()
+
+ expect(screen.queryByText('datasetDocuments.segment.edited')).not.toBeInTheDocument()
+ })
+
+ it('should not show edited badge in full-doc mode', () => {
+ mockDocForm.current = ChunkingMode.parentChild
+ mockParentMode.current = 'full-doc'
+ const detail = createMockSegmentDetail({
+ created_at: 1700000000,
+ updated_at: 1700000001,
+ })
+
+ render()
+
+ expect(screen.queryByText('datasetDocuments.segment.edited')).not.toBeInTheDocument()
+ })
+
+ it('should compute contentOpacity correctly when enabled', () => {
+ const detail = createMockSegmentDetail({ enabled: true })
+
+ const { container } = render()
+
+ const wordCount = container.querySelector('.system-xs-medium.text-text-tertiary')
+ expect(wordCount).not.toHaveClass('opacity-50')
+ })
+
+ it('should compute contentOpacity correctly when disabled', () => {
+ const detail = createMockSegmentDetail({ enabled: false })
+
+ render()
+
+ // ChunkContent receives opacity class when disabled
+ const markdown = screen.getByTestId('markdown')
+ expect(markdown).toHaveClass('opacity-50')
+ })
+
+ it('should not apply opacity when disabled but focused', () => {
+ const detail = createMockSegmentDetail({ enabled: false })
+
+ const { container } = render(
+ ,
+ )
+
+ const wordCount = container.querySelector('.system-xs-medium.text-text-tertiary')
+ expect(wordCount).not.toHaveClass('opacity-50')
+ })
+
+ it('should compute wordCountText with correct format for singular', () => {
+ const detail = createMockSegmentDetail({ word_count: 1 })
+
+ render()
+
+ expect(screen.getByText('1 character')).toBeInTheDocument()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Mode-specific Rendering Tests
+ // --------------------------------------------------------------------------
+ describe('Mode-specific Rendering', () => {
+ it('should render without padding classes in full-doc mode', () => {
+ mockDocForm.current = ChunkingMode.parentChild
+ mockParentMode.current = 'full-doc'
+ const detail = createMockSegmentDetail()
+
+ render()
+
+ const card = screen.getByTestId('segment-card')
+ expect(card).not.toHaveClass('pb-2')
+ expect(card).not.toHaveClass('pt-2.5')
+ })
+
+ it('should render with hover classes in non full-doc mode', () => {
+ mockDocForm.current = ChunkingMode.text
+ const detail = createMockSegmentDetail()
+
+ render()
+
+ const card = screen.getByTestId('segment-card')
+ expect(card).toHaveClass('pb-2')
+ expect(card).toHaveClass('pt-2.5')
+ })
+
+ it('should not render status item in full-doc mode', () => {
+ mockDocForm.current = ChunkingMode.parentChild
+ mockParentMode.current = 'full-doc'
+ const detail = createMockSegmentDetail()
+
+ render()
+
+ // In full-doc mode, status item should not render
+ expect(screen.queryByText('Status:')).not.toBeInTheDocument()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Child Segment List Tests
+ // --------------------------------------------------------------------------
+ describe('Child Segment List', () => {
+ it('should render ChildSegmentList when in paragraph mode with child chunks', () => {
+ mockDocForm.current = ChunkingMode.parentChild
+ mockParentMode.current = 'paragraph'
+ const childChunks = [createMockChildChunk(), createMockChildChunk({ id: 'child-2', position: 2 })]
+ const detail = createMockSegmentDetail({ child_chunks: childChunks })
+
+ render()
+
+ expect(screen.getByText(/2 child chunks/i)).toBeInTheDocument()
+ })
+
+ it('should not render ChildSegmentList when child_chunks is empty', () => {
+ mockDocForm.current = ChunkingMode.parentChild
+ mockParentMode.current = 'paragraph'
+ const detail = createMockSegmentDetail({ child_chunks: [] })
+
+ render()
+
+ expect(screen.queryByText(/child chunk/i)).not.toBeInTheDocument()
+ })
+
+ it('should not render ChildSegmentList in full-doc mode', () => {
+ mockDocForm.current = ChunkingMode.parentChild
+ mockParentMode.current = 'full-doc'
+ const childChunks = [createMockChildChunk()]
+ const detail = createMockSegmentDetail({ child_chunks: childChunks })
+
+ render()
+
+ // In full-doc mode, ChildSegmentList should not render
+ expect(screen.queryByText(/1 child chunk$/i)).not.toBeInTheDocument()
+ })
+
+ it('should call handleAddNewChildChunk when add button is clicked', () => {
+ mockDocForm.current = ChunkingMode.parentChild
+ mockParentMode.current = 'paragraph'
+ const handleAddNewChildChunk = jest.fn()
+ const childChunks = [createMockChildChunk()]
+ const detail = createMockSegmentDetail({ id: 'parent-id', child_chunks: childChunks })
+
+ render(
+ ,
+ )
+
+ const addButton = screen.getByText('common.operation.add')
+ fireEvent.click(addButton)
+
+ expect(handleAddNewChildChunk).toHaveBeenCalledWith('parent-id')
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Keywords Display Tests
+ // --------------------------------------------------------------------------
+ describe('Keywords Display', () => {
+ it('should render keywords with # prefix in general mode', () => {
+ mockDocForm.current = ChunkingMode.text
+ const detail = createMockSegmentDetail({ keywords: ['keyword1', 'keyword2'] })
+
+ const { container } = render()
+
+ expect(screen.getByText('keyword1')).toBeInTheDocument()
+ expect(screen.getByText('keyword2')).toBeInTheDocument()
+ // Tag component shows # prefix
+ const hashtags = container.querySelectorAll('.text-text-quaternary')
+ expect(hashtags.length).toBeGreaterThan(0)
+ })
+
+ it('should not render keywords in QA mode', () => {
+ mockDocForm.current = ChunkingMode.qa
+ const detail = createMockSegmentDetail({ keywords: ['keyword1'] })
+
+ render()
+
+ expect(screen.queryByText('keyword1')).not.toBeInTheDocument()
+ })
+
+ it('should not render keywords in parent-child mode', () => {
+ mockDocForm.current = ChunkingMode.parentChild
+ const detail = createMockSegmentDetail({ keywords: ['keyword1'] })
+
+ render()
+
+ expect(screen.queryByText('keyword1')).not.toBeInTheDocument()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Images Display Tests
+ // --------------------------------------------------------------------------
+ describe('Images Display', () => {
+ it('should render ImageList when attachments exist', () => {
+ const attachments = [createMockAttachment()]
+ const detail = createMockSegmentDetail({ attachments })
+
+ render()
+
+ // ImageList uses FileThumb which renders images
+ expect(screen.getByAltText('test-image.png')).toBeInTheDocument()
+ })
+
+ it('should not render ImageList when attachments is empty', () => {
+ const detail = createMockSegmentDetail({ attachments: [] })
+
+ render()
+
+ expect(screen.queryByAltText('test-image.png')).not.toBeInTheDocument()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Edge Cases and Error Handling Tests
+ // --------------------------------------------------------------------------
+ describe('Edge Cases and Error Handling', () => {
+ it('should handle undefined detail gracefully', () => {
+ render()
+
+ expect(screen.getByText(/Chunk/i)).toBeInTheDocument()
+ })
+
+ it('should handle empty detail object gracefully', () => {
+ render()
+
+ expect(screen.getByText(/Chunk/i)).toBeInTheDocument()
+ })
+
+ it('should handle missing callback functions gracefully', () => {
+ const detail = createMockSegmentDetail()
+
+ render(
+ ,
+ )
+
+ const card = screen.getByTestId('segment-card')
+ expect(() => fireEvent.click(card)).not.toThrow()
+ })
+
+ it('should handle switch being disabled when status is not completed', () => {
+ const detail = createMockSegmentDetail({ status: 'indexing' })
+
+ render(
+ ,
+ )
+
+ // The Switch component uses CSS classes for disabled state, not the native disabled attribute
+ const switchElement = screen.getByRole('switch')
+ expect(switchElement).toHaveClass('!cursor-not-allowed', '!opacity-50')
+ })
+
+ it('should handle zero word count', () => {
+ const detail = createMockSegmentDetail({ word_count: 0 })
+
+ render()
+
+ expect(screen.getByText('0 characters')).toBeInTheDocument()
+ })
+
+ it('should handle zero hit count', () => {
+ const detail = createMockSegmentDetail({ hit_count: 0 })
+
+ render()
+
+ expect(screen.getByText('0 datasetDocuments.segment.hitCount')).toBeInTheDocument()
+ })
+
+ it('should handle very long content', () => {
+ const longContent = 'A'.repeat(10000)
+ // ChunkContent shows sign_content first, so set it to the long content
+ const detail = createMockSegmentDetail({ sign_content: longContent })
+
+ render()
+
+ expect(screen.getByText(longContent)).toBeInTheDocument()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Component Integration Tests
+ // --------------------------------------------------------------------------
+ describe('Component Integration', () => {
+ it('should render real Tag component with hashtag styling', () => {
+ mockDocForm.current = ChunkingMode.text
+ const detail = createMockSegmentDetail({ keywords: ['testkeyword'] })
+
+ render()
+
+ expect(screen.getByText('testkeyword')).toBeInTheDocument()
+ })
+
+ it('should render real Divider component', () => {
+ const detail = createMockSegmentDetail()
+
+ render(
+ ,
+ )
+
+ const dividers = document.querySelectorAll('.bg-divider-regular')
+ expect(dividers.length).toBeGreaterThan(0)
+ })
+
+ it('should render real Badge component when edited', () => {
+ mockDocForm.current = ChunkingMode.text
+ const detail = createMockSegmentDetail({
+ created_at: 1700000000,
+ updated_at: 1700000001,
+ })
+
+ render()
+
+ const editedBadge = screen.getByText('datasetDocuments.segment.edited')
+ expect(editedBadge).toHaveClass('system-2xs-medium-uppercase')
+ })
+
+ it('should render real Switch component with correct enabled state', () => {
+ const detail = createMockSegmentDetail({ enabled: true, status: 'completed' })
+
+ render(
+ ,
+ )
+
+ const switchElement = screen.getByRole('switch')
+ expect(switchElement).toHaveClass('bg-components-toggle-bg')
+ })
+
+ it('should render real Switch component with unchecked state', () => {
+ const detail = createMockSegmentDetail({ enabled: false, status: 'completed' })
+
+ render(
+ ,
+ )
+
+ const switchElement = screen.getByRole('switch')
+ expect(switchElement).toHaveClass('bg-components-toggle-bg-unchecked')
+ })
+
+ it('should render real SegmentIndexTag with position formatting', () => {
+ const detail = createMockSegmentDetail({ position: 1 })
+
+ render()
+
+ expect(screen.getByText(/Chunk-01/i)).toBeInTheDocument()
+ })
+
+ it('should render real SegmentIndexTag with double digit position', () => {
+ const detail = createMockSegmentDetail({ position: 12 })
+
+ render()
+
+ expect(screen.getByText(/Chunk-12/i)).toBeInTheDocument()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // All Props Variations Tests
+ // --------------------------------------------------------------------------
+ describe('All Props Variations', () => {
+ it('should render correctly with all props provided', () => {
+ mockDocForm.current = ChunkingMode.parentChild
+ mockParentMode.current = 'paragraph'
+ const childChunks = [createMockChildChunk()]
+ const attachments = [createMockAttachment()]
+ const detail = createMockSegmentDetail({
+ id: 'full-props-segment',
+ position: 10,
+ sign_content: 'Full signed content',
+ content: 'Full content',
+ word_count: 500,
+ hit_count: 25,
+ enabled: true,
+ keywords: ['key1', 'key2'],
+ child_chunks: childChunks,
+ attachments,
+ created_at: 1700000000,
+ updated_at: 1700000001,
+ status: 'completed',
+ })
+
+ render(
+ ,
+ )
+
+ // ChunkContent shows sign_content first
+ expect(screen.getByText('Full signed content')).toBeInTheDocument()
+ expect(screen.getByRole('switch')).toBeInTheDocument()
+ })
+
+ it('should render correctly with minimal props', () => {
+ render()
+
+ expect(screen.getByText('common.operation.viewMore')).toBeInTheDocument()
+ })
+
+ it('should handle loading transition correctly', () => {
+ const detail = createMockSegmentDetail()
+
+ const { rerender } = render()
+
+ // When loading, content should not be visible
+ expect(screen.queryByText('Test signed content')).not.toBeInTheDocument()
+
+ rerender()
+
+ // ChunkContent shows sign_content first
+ expect(screen.getByText('Test signed content')).toBeInTheDocument()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // ChunkContent QA Mode Tests - cover lines 25-49
+ // --------------------------------------------------------------------------
+ describe('ChunkContent QA Mode', () => {
+ it('should render Q and A sections when answer is provided', () => {
+ const detail = createMockSegmentDetail({
+ content: 'This is the question content',
+ answer: 'This is the answer content',
+ sign_content: '',
+ })
+
+ render()
+
+ // Should render Q label
+ expect(screen.getByText('Q')).toBeInTheDocument()
+ // Should render A label
+ expect(screen.getByText('A')).toBeInTheDocument()
+ // Should render question content
+ expect(screen.getByText('This is the question content')).toBeInTheDocument()
+ // Should render answer content
+ expect(screen.getByText('This is the answer content')).toBeInTheDocument()
+ })
+
+ it('should apply line-clamp-2 class when isCollapsed is true in QA mode', () => {
+ mockIsCollapsed.current = true
+ const detail = createMockSegmentDetail({
+ content: 'Question content',
+ answer: 'Answer content',
+ sign_content: '',
+ })
+
+ render()
+
+ // Markdown components should have line-clamp-2 class when collapsed
+ const markdowns = screen.getAllByTestId('markdown')
+ markdowns.forEach((markdown) => {
+ expect(markdown).toHaveClass('line-clamp-2')
+ })
+ })
+
+ it('should apply line-clamp-20 class when isCollapsed is false in QA mode', () => {
+ mockIsCollapsed.current = false
+ const detail = createMockSegmentDetail({
+ content: 'Question content',
+ answer: 'Answer content',
+ sign_content: '',
+ })
+
+ render()
+
+ // Markdown components should have line-clamp-20 class when not collapsed
+ const markdowns = screen.getAllByTestId('markdown')
+ markdowns.forEach((markdown) => {
+ expect(markdown).toHaveClass('line-clamp-20')
+ })
+ })
+
+ it('should render QA mode with className applied to wrapper', () => {
+ const detail = createMockSegmentDetail({
+ content: 'Question',
+ answer: 'Answer',
+ sign_content: '',
+ enabled: false,
+ })
+
+ const { container } = render()
+
+ // The ChunkContent wrapper should have opacity class when disabled
+ const qaWrapper = container.querySelector('.flex.gap-x-1')
+ expect(qaWrapper).toBeInTheDocument()
+ })
+
+ it('should not render QA mode when answer is empty string', () => {
+ const detail = createMockSegmentDetail({
+ content: 'Regular content',
+ answer: '',
+ sign_content: 'Signed content',
+ })
+
+ render()
+
+ // Should not render Q and A labels
+ expect(screen.queryByText('Q')).not.toBeInTheDocument()
+ expect(screen.queryByText('A')).not.toBeInTheDocument()
+ // Should render signed content instead
+ expect(screen.getByText('Signed content')).toBeInTheDocument()
+ })
+
+ it('should not render QA mode when answer is undefined', () => {
+ const detail = createMockSegmentDetail({
+ content: 'Regular content',
+ answer: undefined,
+ sign_content: 'Signed content',
+ })
+
+ render()
+
+ // Should not render Q and A labels
+ expect(screen.queryByText('Q')).not.toBeInTheDocument()
+ expect(screen.queryByText('A')).not.toBeInTheDocument()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // ChunkContent Non-QA Mode Tests - ensure full coverage
+ // --------------------------------------------------------------------------
+ describe('ChunkContent Non-QA Mode', () => {
+ it('should apply line-clamp-3 in fullDocMode', () => {
+ mockDocForm.current = ChunkingMode.parentChild
+ mockParentMode.current = 'full-doc'
+ const detail = createMockSegmentDetail({
+ sign_content: 'Content in full doc mode',
+ })
+
+ render()
+
+ const markdown = screen.getByTestId('markdown')
+ expect(markdown).toHaveClass('line-clamp-3')
+ })
+
+ it('should apply line-clamp-2 when not fullDocMode and isCollapsed is true', () => {
+ mockDocForm.current = ChunkingMode.text
+ mockIsCollapsed.current = true
+ const detail = createMockSegmentDetail({
+ sign_content: 'Collapsed content',
+ })
+
+ render()
+
+ const markdown = screen.getByTestId('markdown')
+ expect(markdown).toHaveClass('line-clamp-2')
+ })
+
+ it('should apply line-clamp-20 when not fullDocMode and isCollapsed is false', () => {
+ mockDocForm.current = ChunkingMode.text
+ mockIsCollapsed.current = false
+ const detail = createMockSegmentDetail({
+ sign_content: 'Expanded content',
+ })
+
+ render()
+
+ const markdown = screen.getByTestId('markdown')
+ expect(markdown).toHaveClass('line-clamp-20')
+ })
+
+ it('should fall back to content when sign_content is empty', () => {
+ const detail = createMockSegmentDetail({
+ content: 'Fallback content',
+ sign_content: '',
+ })
+
+ render()
+
+ expect(screen.getByText('Fallback content')).toBeInTheDocument()
+ })
+
+ it('should render empty string when both sign_content and content are empty', () => {
+ const detail = createMockSegmentDetail({
+ content: '',
+ sign_content: '',
+ })
+
+ render()
+
+ const markdown = screen.getByTestId('markdown')
+ expect(markdown).toHaveTextContent('')
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/segment-card/index.tsx b/web/app/components/datasets/documents/detail/completed/segment-card/index.tsx
index 679a0ec777..ce24b843de 100644
--- a/web/app/components/datasets/documents/detail/completed/segment-card/index.tsx
+++ b/web/app/components/datasets/documents/detail/completed/segment-card/index.tsx
@@ -129,6 +129,7 @@ const SegmentCard: FC = ({
return (
= ({
popupClassName='text-text-secondary system-xs-medium'
>
{
e.stopPropagation()
@@ -184,7 +186,9 @@ const SegmentCard: FC
= ({
popupContent='Delete'
popupClassName='text-text-secondary system-xs-medium'
>
- {
e.stopPropagation()
setShowModal(true)
diff --git a/web/app/components/datasets/documents/detail/completed/skeleton/parent-chunk-card-skeleton.tsx b/web/app/components/datasets/documents/detail/completed/skeleton/parent-chunk-card-skeleton.tsx
index f22024bb8e..b013d952a7 100644
--- a/web/app/components/datasets/documents/detail/completed/skeleton/parent-chunk-card-skeleton.tsx
+++ b/web/app/components/datasets/documents/detail/completed/skeleton/parent-chunk-card-skeleton.tsx
@@ -10,7 +10,7 @@ import {
const ParentChunkCardSkelton = () => {
const { t } = useTranslation()
return (
-
+
diff --git a/web/app/components/datasets/documents/detail/context.ts b/web/app/components/datasets/documents/detail/context.ts
index 1d6f121d6b..ae737994d9 100644
--- a/web/app/components/datasets/documents/detail/context.ts
+++ b/web/app/components/datasets/documents/detail/context.ts
@@ -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
diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.spec.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.spec.tsx
new file mode 100644
index 0000000000..79968b5b24
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.spec.tsx
@@ -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
+ configurations: Array<{ variable: string; label: string; type: string }>
+ schema: unknown
+ onSubmit: (data: Record) => void
+ onPreview: () => void
+ isRunning: boolean
+ }) {
+ if (ref && typeof ref === 'object' && 'current' in ref) {
+ (ref as React.MutableRefObject<{ submit: () => void }>).current = {
+ submit: () => onSubmit(initialData),
+ }
+ }
+ return (
+
+ )
+ }
+})
+
+// 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 (
+
+ {dataSourceType}
+ {localFiles.length}
+ {onlineDocuments.length}
+ {websitePages.length}
+ {onlineDriveFiles.length}
+ {String(isIdle)}
+ {String(isPending)}
+ {String(!!estimateData)}
+
+ )
+ }
+})
+
+// Test utilities
+const createQueryClient = () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ })
+
+const renderWithProviders = (ui: React.ReactElement) => {
+ const queryClient = createQueryClient()
+ return render(
+
+ {ui}
+ ,
+ )
+}
+
+// Factory functions for test data
+const createMockExecutionLogResponse = (
+ overrides: Partial = {},
+): 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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+ 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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+ // 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()
+ 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()
+ 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()
+ 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()
+ 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()
+ 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()
+ 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()
+ 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.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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+ 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()
+ 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()
+ 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(() => undefined)
+ // When mutateAsync is called, set isPending to true and trigger rerender
+ mockMutateAsync.mockImplementation(() => {
+ isPendingState = true
+ return pendingPromise
+ })
+
+ const props = createDefaultProps()
+ const { rerender } = renderWithProviders()
+
+ // 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(
+
+
+ ,
+ )
+
+ // 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(() => undefined))
+ const props = createDefaultProps()
+
+ // Act
+ renderWithProviders()
+ // 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')
+ })
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.tsx
index a075aa3308..b5660259a8 100644
--- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.tsx
+++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.tsx
@@ -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')}
>
diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/index.spec.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/index.spec.tsx
new file mode 100644
index 0000000000..aae59b30a9
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/index.spec.tsx
@@ -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
+ configurations: Array<{ variable: string; label: string; type: string }>
+ schema: unknown
+ onSubmit: (data: Record) => 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 (
+
+ )
+ }
+})
+
+// Test utilities
+const createQueryClient = () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ })
+
+const renderWithProviders = (ui: React.ReactElement) => {
+ const queryClient = createQueryClient()
+ return render(
+
+ {ui}
+ ,
+ )
+}
+
+// Factory function for creating mock variables - matches RAGPipelineVariable type
+const createMockVariable = (overrides: Partial = {}): 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
+ isRunning: boolean
+ ref: React.RefObject<{ submit: () => void } | null>
+ onProcess: () => void
+ onPreview: () => void
+ onSubmit: (data: Record) => 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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+ 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()
+ 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()
+ 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()
+ 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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // Assert
+ expect(screen.getByTestId('process-form')).toBeInTheDocument()
+ })
+
+ it('should handle empty variables array', () => {
+ // Arrange
+ mockParamsConfig.mockReturnValue({ variables: [] })
+ const props = createDefaultProps()
+
+ // Act
+ renderWithProviders()
+
+ // 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()
+
+ // 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()
+ 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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/status-item/index.spec.tsx b/web/app/components/datasets/documents/status-item/index.spec.tsx
new file mode 100644
index 0000000000..b057af9102
--- /dev/null
+++ b/web/app/components/datasets/documents/status-item/index.spec.tsx
@@ -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(
+
+ {ui}
+ ,
+ )
+}
+
+// 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()
+
+ // 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()
+
+ // Assert
+ const indicator = screen.getByTestId('status-indicator')
+ expect(indicator).toHaveClass(expectedBg)
+ })
+
+ it('should render status text from translation', () => {
+ // Arrange & Act
+ renderWithProviders()
+
+ // Assert
+ expect(screen.getByText('datasetDocuments.list.status.available')).toBeInTheDocument()
+ })
+
+ it('should handle case-insensitive status', () => {
+ // Arrange & Act
+ renderWithProviders(
+ ,
+ )
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // 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(
+ ,
+ )
+
+ // Assert - Switch renders as a button element
+ expect(screen.queryByRole('switch')).not.toBeInTheDocument()
+ })
+
+ it('should render switch in detail scene', () => {
+ // Arrange & Act
+ renderWithProviders(
+ ,
+ )
+
+ // Assert
+ expect(screen.getByRole('switch')).toBeInTheDocument()
+ })
+
+ it('should default to list scene', () => {
+ // Arrange & Act
+ renderWithProviders(
+ ,
+ )
+
+ // Assert
+ expect(screen.queryByRole('switch')).not.toBeInTheDocument()
+ })
+ })
+
+ // textCls prop tests
+ describe('textCls prop', () => {
+ it('should apply custom text class', () => {
+ // Arrange & Act
+ renderWithProviders(
+ ,
+ )
+
+ // Assert
+ const statusText = screen.getByText('datasetDocuments.list.status.available')
+ expect(statusText).toHaveClass('custom-text-class')
+ })
+
+ it('should default to empty string', () => {
+ // Arrange & Act
+ renderWithProviders()
+
+ // 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(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ // 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()
+
+ // 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()
+
+ // 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(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ 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(
+ ,
+ )
+
+ 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(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ // Assert - should render without errors
+ expect(screen.getByRole('switch')).toBeInTheDocument()
+ })
+
+ it('should handle undefined detail gracefully', () => {
+ // Arrange & Act
+ renderWithProviders(
+ ,
+ )
+
+ // Assert
+ const switchEl = screen.getByRole('switch')
+ expect(switchEl).toHaveAttribute('aria-checked', 'false')
+ })
+
+ it('should handle empty string id in detail', async () => {
+ // Arrange
+ renderWithProviders(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ // 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 = ' & < > " \''
+ renderWithProviders(
+ ,
+ )
+
+ // 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()
+ 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()
+ rerender(
+
+
+ ,
+ )
+
+ // Assert
+ const indicator = screen.getByTestId('status-indicator')
+ expect(indicator).toBeInTheDocument()
+ })
+
+ it('should update when status prop changes', () => {
+ // Arrange
+ const { rerender } = renderWithProviders()
+
+ // Assert initial - green/success background
+ let indicator = screen.getByTestId('status-indicator')
+ expect(indicator).toHaveClass('bg-components-badge-status-light-success-bg')
+
+ // Act
+ rerender(
+
+
+ ,
+ )
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // 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(
+ ,
+ )
+
+ // Assert - check switch has the md size class (h-4 w-7)
+ const switchEl = screen.getByRole('switch')
+ expect(switchEl).toHaveClass('h-4', 'w-7')
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/status-item/index.tsx b/web/app/components/datasets/documents/status-item/index.tsx
index 4ab7246a29..4adb622747 100644
--- a/web/app/components/datasets/documents/status-item/index.tsx
+++ b/web/app/components/datasets/documents/status-item/index.tsx
@@ -105,6 +105,7 @@ const StatusItem = ({
{errorMessage}
}
triggerClassName='ml-1 w-4 h-4'
+ triggerTestId='error-tooltip-trigger'
/>
)
}
diff --git a/web/app/components/header/indicator/index.tsx b/web/app/components/header/indicator/index.tsx
index 8d27825247..d3a49a9714 100644
--- a/web/app/components/header/indicator/index.tsx
+++ b/web/app/components/header/indicator/index.tsx
@@ -47,6 +47,7 @@ export default function Indicator({
}: IndicatorProps) {
return (