From 581b62cf01ae98dab37439a50b1c84234dd665db Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Wed, 17 Dec 2025 10:26:58 +0800 Subject: [PATCH] feat: add automated tests for pipeline setting (#29478) Co-authored-by: CodingOnStar --- .../documents/detail/completed/index.tsx | 2 +- .../completed/segment-card/index.spec.tsx | 1204 +++++++++++++++++ .../detail/completed/segment-card/index.tsx | 6 +- .../skeleton/parent-chunk-card-skeleton.tsx | 2 +- .../datasets/documents/detail/context.ts | 2 +- .../settings/pipeline-settings/index.spec.tsx | 786 +++++++++++ .../pipeline-settings/left-header.tsx | 1 + .../process-documents/index.spec.tsx | 573 ++++++++ .../documents/status-item/index.spec.tsx | 968 +++++++++++++ .../datasets/documents/status-item/index.tsx | 1 + web/app/components/header/indicator/index.tsx | 1 + 11 files changed, 3542 insertions(+), 4 deletions(-) create mode 100644 web/app/components/datasets/documents/detail/completed/segment-card/index.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/settings/pipeline-settings/index.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/index.spec.tsx create mode 100644 web/app/components/datasets/documents/status-item/index.spec.tsx 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) => ( + {img.name} + ))} +
+ ), +})) + +// 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 ( +
{ + e.preventDefault() + onSubmit(initialData) + }} + > + {configurations.map((config, index) => ( +
+ +
+ ))} + +
+ ) + } +}) + +// 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 ( +
{ + e.preventDefault() + onSubmit(initialData) + }} + > + {/* Render actual field labels from configurations */} + {configurations.map((config, index) => ( +
+ + +
+ ))} + +
+ ) + } +}) + +// 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 (