diff --git a/web/app/components/datasets/documents/detail/batch-modal/csv-downloader.spec.tsx b/web/app/components/datasets/documents/detail/batch-modal/csv-downloader.spec.tsx
new file mode 100644
index 0000000000..c5757b8e05
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/batch-modal/csv-downloader.spec.tsx
@@ -0,0 +1,242 @@
+import { render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { LanguagesSupported } from '@/i18n-config/language'
+import { ChunkingMode } from '@/models/datasets'
+
+import CSVDownload from './csv-downloader'
+
+// Mock useLocale
+let mockLocale = LanguagesSupported[0] // en-US
+vi.mock('@/context/i18n', () => ({
+ useLocale: () => mockLocale,
+}))
+
+// Mock react-papaparse
+const MockCSVDownloader = ({ children, data, filename, type }: { children: React.ReactNode, data: unknown, filename: string, type: string }) => (
+
+ {children}
+
+)
+
+vi.mock('react-papaparse', () => ({
+ useCSVDownloader: () => ({
+ CSVDownloader: MockCSVDownloader,
+ Type: { Link: 'link' },
+ }),
+}))
+
+describe('CSVDownloader', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockLocale = LanguagesSupported[0] // Reset to English
+ })
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render structure title', () => {
+ // Arrange & Act
+ render()
+
+ // Assert - i18n key format
+ expect(screen.getByText(/csvStructureTitle/i)).toBeInTheDocument()
+ })
+
+ it('should render download template link', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('csv-downloader-link')).toBeInTheDocument()
+ expect(screen.getByText(/list\.batchModal\.template/i)).toBeInTheDocument()
+ })
+ })
+
+ // Table structure for QA mode
+ describe('QA Mode Table', () => {
+ it('should render QA table with question and answer columns when docForm is qa', () => {
+ // Arrange & Act
+ render()
+
+ // Assert - Check for question/answer headers
+ const questionHeaders = screen.getAllByText(/list\.batchModal\.question/i)
+ const answerHeaders = screen.getAllByText(/list\.batchModal\.answer/i)
+
+ expect(questionHeaders.length).toBeGreaterThan(0)
+ expect(answerHeaders.length).toBeGreaterThan(0)
+ })
+
+ it('should render two data rows for QA mode', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const tbody = container.querySelector('tbody')
+ expect(tbody).toBeInTheDocument()
+ const rows = tbody?.querySelectorAll('tr')
+ expect(rows?.length).toBe(2)
+ })
+ })
+
+ // Table structure for Text mode
+ describe('Text Mode Table', () => {
+ it('should render text table with content column when docForm is text', () => {
+ // Arrange & Act
+ render()
+
+ // Assert - Check for content header
+ expect(screen.getByText(/list\.batchModal\.contentTitle/i)).toBeInTheDocument()
+ })
+
+ it('should not render question/answer columns in text mode', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.queryByText(/list\.batchModal\.question/i)).not.toBeInTheDocument()
+ expect(screen.queryByText(/list\.batchModal\.answer/i)).not.toBeInTheDocument()
+ })
+
+ it('should render two data rows for text mode', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const tbody = container.querySelector('tbody')
+ expect(tbody).toBeInTheDocument()
+ const rows = tbody?.querySelectorAll('tr')
+ expect(rows?.length).toBe(2)
+ })
+ })
+
+ // CSV Template Data
+ describe('CSV Template Data', () => {
+ it('should provide English QA template when locale is English and docForm is qa', () => {
+ // Arrange
+ mockLocale = LanguagesSupported[0] // en-US
+
+ // Act
+ render()
+
+ // Assert
+ const link = screen.getByTestId('csv-downloader-link')
+ const data = JSON.parse(link.getAttribute('data-data') || '[]')
+ expect(data).toEqual([
+ ['question', 'answer'],
+ ['question1', 'answer1'],
+ ['question2', 'answer2'],
+ ])
+ })
+
+ it('should provide English text template when locale is English and docForm is text', () => {
+ // Arrange
+ mockLocale = LanguagesSupported[0] // en-US
+
+ // Act
+ render()
+
+ // Assert
+ const link = screen.getByTestId('csv-downloader-link')
+ const data = JSON.parse(link.getAttribute('data-data') || '[]')
+ expect(data).toEqual([
+ ['segment content'],
+ ['content1'],
+ ['content2'],
+ ])
+ })
+
+ it('should provide Chinese QA template when locale is Chinese and docForm is qa', () => {
+ // Arrange
+ mockLocale = LanguagesSupported[1] // zh-Hans
+
+ // Act
+ render()
+
+ // Assert
+ const link = screen.getByTestId('csv-downloader-link')
+ const data = JSON.parse(link.getAttribute('data-data') || '[]')
+ expect(data).toEqual([
+ ['问题', '答案'],
+ ['问题 1', '答案 1'],
+ ['问题 2', '答案 2'],
+ ])
+ })
+
+ it('should provide Chinese text template when locale is Chinese and docForm is text', () => {
+ // Arrange
+ mockLocale = LanguagesSupported[1] // zh-Hans
+
+ // Act
+ render()
+
+ // Assert
+ const link = screen.getByTestId('csv-downloader-link')
+ const data = JSON.parse(link.getAttribute('data-data') || '[]')
+ expect(data).toEqual([
+ ['分段内容'],
+ ['内容 1'],
+ ['内容 2'],
+ ])
+ })
+ })
+
+ // CSVDownloader props
+ describe('CSVDownloader Props', () => {
+ it('should set filename to template', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ const link = screen.getByTestId('csv-downloader-link')
+ expect(link.getAttribute('data-filename')).toBe('template')
+ })
+
+ it('should set type to Link', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ const link = screen.getByTestId('csv-downloader-link')
+ expect(link.getAttribute('data-type')).toBe('link')
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should maintain structure when rerendered with different docForm', () => {
+ // Arrange
+ const { rerender } = render()
+
+ // Act
+ rerender()
+
+ // Assert - should now show QA table
+ expect(screen.getAllByText(/list\.batchModal\.question/i).length).toBeGreaterThan(0)
+ })
+
+ it('should render correctly for non-English locales', () => {
+ // Arrange
+ mockLocale = LanguagesSupported[1] // zh-Hans
+
+ // Act
+ render()
+
+ // Assert - Check that Chinese template is used
+ const link = screen.getByTestId('csv-downloader-link')
+ const data = JSON.parse(link.getAttribute('data-data') || '[]')
+ expect(data[0]).toEqual(['问题', '答案'])
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.spec.tsx b/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.spec.tsx
new file mode 100644
index 0000000000..fb31ec5f97
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.spec.tsx
@@ -0,0 +1,368 @@
+import type { CustomFile, FileItem } from '@/models/datasets'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { Theme } from '@/types/app'
+
+import CSVUploader from './csv-uploader'
+
+// Mock upload service
+const mockUpload = vi.fn()
+vi.mock('@/service/base', () => ({
+ upload: (...args: unknown[]) => mockUpload(...args),
+}))
+
+// Mock useFileUploadConfig
+vi.mock('@/service/use-common', () => ({
+ useFileUploadConfig: () => ({
+ data: { file_size_limit: 15 },
+ }),
+}))
+
+// Mock useTheme
+vi.mock('@/hooks/use-theme', () => ({
+ default: () => ({ theme: Theme.light }),
+}))
+
+// Mock ToastContext
+const mockNotify = vi.fn()
+vi.mock('@/app/components/base/toast', () => ({
+ ToastContext: {
+ Provider: ({ children }: { children: React.ReactNode }) => children,
+ Consumer: ({ children }: { children: (ctx: { notify: typeof mockNotify }) => React.ReactNode }) => children({ notify: mockNotify }),
+ },
+}))
+
+// Create a mock ToastContext for useContext
+vi.mock('use-context-selector', async (importOriginal) => {
+ const actual = await importOriginal() as Record
+ return {
+ ...actual,
+ useContext: () => ({ notify: mockNotify }),
+ }
+})
+
+describe('CSVUploader', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ const defaultProps = {
+ file: undefined as FileItem | undefined,
+ updateFile: vi.fn(),
+ }
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render upload area when no file is present', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/list\.batchModal\.csvUploadTitle/i)).toBeInTheDocument()
+ expect(screen.getByText(/list\.batchModal\.browse/i)).toBeInTheDocument()
+ })
+
+ it('should render hidden file input', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const fileInput = container.querySelector('input[type="file"]')
+ expect(fileInput).toBeInTheDocument()
+ expect(fileInput).toHaveStyle({ display: 'none' })
+ })
+
+ it('should accept only CSV files', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const fileInput = container.querySelector('input[type="file"]')
+ expect(fileInput).toHaveAttribute('accept', '.csv')
+ })
+ })
+
+ // File display tests
+ describe('File Display', () => {
+ it('should display file info when file is present', () => {
+ // Arrange
+ const mockFile: FileItem = {
+ fileID: 'file-1',
+ file: new File(['content'], 'test-file.csv', { type: 'text/csv' }) as CustomFile,
+ progress: 100,
+ }
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('test-file')).toBeInTheDocument()
+ expect(screen.getByText('.csv')).toBeInTheDocument()
+ })
+
+ it('should not show upload area when file is present', () => {
+ // Arrange
+ const mockFile: FileItem = {
+ fileID: 'file-1',
+ file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile,
+ progress: 100,
+ }
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.queryByText(/list\.batchModal\.csvUploadTitle/i)).not.toBeInTheDocument()
+ })
+
+ it('should show change button when file is present', () => {
+ // Arrange
+ const mockFile: FileItem = {
+ fileID: 'file-1',
+ file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile,
+ progress: 100,
+ }
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/stepOne\.uploader\.change/i)).toBeInTheDocument()
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should trigger file input click when browse is clicked', () => {
+ // Arrange
+ const { container } = render()
+ const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
+ const clickSpy = vi.spyOn(fileInput, 'click')
+
+ // Act
+ fireEvent.click(screen.getByText(/list\.batchModal\.browse/i))
+
+ // Assert
+ expect(clickSpy).toHaveBeenCalled()
+ })
+
+ it('should call updateFile when file is selected', async () => {
+ // Arrange
+ const mockUpdateFile = vi.fn()
+ mockUpload.mockResolvedValueOnce({ id: 'uploaded-id' })
+
+ const { container } = render(
+ ,
+ )
+ const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
+ const testFile = new File(['content'], 'test.csv', { type: 'text/csv' })
+
+ // Act
+ fireEvent.change(fileInput, { target: { files: [testFile] } })
+
+ // Assert
+ await waitFor(() => {
+ expect(mockUpdateFile).toHaveBeenCalled()
+ })
+ })
+
+ it('should call updateFile with undefined when remove is clicked', () => {
+ // Arrange
+ const mockUpdateFile = vi.fn()
+ const mockFile: FileItem = {
+ fileID: 'file-1',
+ file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile,
+ progress: 100,
+ }
+ const { container } = render(
+ ,
+ )
+
+ // Act
+ const deleteButton = container.querySelector('.cursor-pointer')
+ if (deleteButton)
+ fireEvent.click(deleteButton)
+
+ // Assert
+ expect(mockUpdateFile).toHaveBeenCalledWith()
+ })
+ })
+
+ // Validation tests
+ describe('Validation', () => {
+ it('should show error for non-CSV files', () => {
+ // Arrange
+ const { container } = render()
+ const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
+ const testFile = new File(['content'], 'test.txt', { type: 'text/plain' })
+
+ // Act
+ fireEvent.change(fileInput, { target: { files: [testFile] } })
+
+ // Assert
+ expect(mockNotify).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'error',
+ }),
+ )
+ })
+
+ it('should show error for files exceeding size limit', () => {
+ // Arrange
+ const { container } = render()
+ const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
+
+ // Create a mock file with a large size (16MB) without actually creating the data
+ const testFile = new File(['test'], 'large.csv', { type: 'text/csv' })
+ Object.defineProperty(testFile, 'size', { value: 16 * 1024 * 1024 })
+
+ // Act
+ fireEvent.change(fileInput, { target: { files: [testFile] } })
+
+ // Assert
+ expect(mockNotify).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'error',
+ }),
+ )
+ })
+ })
+
+ // Upload progress tests
+ describe('Upload Progress', () => {
+ it('should show progress indicator when upload is in progress', () => {
+ // Arrange
+ const mockFile: FileItem = {
+ fileID: 'file-1',
+ file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile,
+ progress: 50,
+ }
+
+ // Act
+ const { container } = render()
+
+ // Assert - SimplePieChart should be rendered for progress 0-99
+ // The pie chart would be in the hidden group element
+ expect(container.querySelector('.group')).toBeInTheDocument()
+ })
+
+ it('should not show progress for completed uploads', () => {
+ // Arrange
+ const mockFile: FileItem = {
+ fileID: 'file-1',
+ file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile,
+ progress: 100,
+ }
+
+ // Act
+ render()
+
+ // Assert - File name should be displayed
+ expect(screen.getByText('test')).toBeInTheDocument()
+ })
+ })
+
+ // Props tests
+ describe('Props', () => {
+ it('should call updateFile prop when provided', async () => {
+ // Arrange
+ const mockUpdateFile = vi.fn()
+ mockUpload.mockResolvedValueOnce({ id: 'test-id' })
+
+ const { container } = render(
+ ,
+ )
+ const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
+ const testFile = new File(['content'], 'test.csv', { type: 'text/csv' })
+
+ // Act
+ fireEvent.change(fileInput, { target: { files: [testFile] } })
+
+ // Assert
+ await waitFor(() => {
+ expect(mockUpdateFile).toHaveBeenCalled()
+ })
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle empty file list', () => {
+ // Arrange
+ const mockUpdateFile = vi.fn()
+ const { container } = render(
+ ,
+ )
+ const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
+
+ // Act
+ fireEvent.change(fileInput, { target: { files: [] } })
+
+ // Assert
+ expect(mockUpdateFile).not.toHaveBeenCalled()
+ })
+
+ it('should handle null file', () => {
+ // Arrange
+ const mockUpdateFile = vi.fn()
+ const { container } = render(
+ ,
+ )
+ const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
+
+ // Act
+ fireEvent.change(fileInput, { target: { files: null } })
+
+ // Assert
+ expect(mockUpdateFile).not.toHaveBeenCalled()
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender } = render()
+
+ // Act
+ const mockFile: FileItem = {
+ fileID: 'file-1',
+ file: new File(['content'], 'updated.csv', { type: 'text/csv' }) as CustomFile,
+ progress: 100,
+ }
+ rerender()
+
+ // Assert
+ expect(screen.getByText('updated')).toBeInTheDocument()
+ })
+
+ it('should handle upload error', async () => {
+ // Arrange
+ const mockUpdateFile = vi.fn()
+ mockUpload.mockRejectedValueOnce(new Error('Upload failed'))
+
+ const { container } = render(
+ ,
+ )
+ const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
+ const testFile = new File(['content'], 'test.csv', { type: 'text/csv' })
+
+ // Act
+ fireEvent.change(fileInput, { target: { files: [testFile] } })
+
+ // Assert
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'error',
+ }),
+ )
+ })
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/batch-modal/index.spec.tsx b/web/app/components/datasets/documents/detail/batch-modal/index.spec.tsx
new file mode 100644
index 0000000000..7e1ec0d7db
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/batch-modal/index.spec.tsx
@@ -0,0 +1,213 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ChunkingMode } from '@/models/datasets'
+
+import BatchModal from './index'
+
+// Mock child components
+vi.mock('./csv-downloader', () => ({
+ default: ({ docForm }: { docForm: ChunkingMode }) => (
+
+ CSV Downloader
+
+ ),
+}))
+
+vi.mock('./csv-uploader', () => ({
+ default: ({ file, updateFile }: { file: { file?: { id: string } } | undefined, updateFile: (file: { file: { id: string } } | undefined) => void }) => (
+
+
+
+ {file && {file.file?.id}}
+
+ ),
+}))
+
+describe('BatchModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ const defaultProps = {
+ isShow: true,
+ docForm: ChunkingMode.text,
+ onCancel: vi.fn(),
+ onConfirm: vi.fn(),
+ }
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing when isShow is true', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/list\.batchModal\.title/i)).toBeInTheDocument()
+ })
+
+ it('should not render content when isShow is false', () => {
+ // Arrange & Act
+ render()
+
+ // Assert - Modal is closed
+ expect(screen.queryByText(/list\.batchModal\.title/i)).not.toBeInTheDocument()
+ })
+
+ it('should render CSVDownloader component', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('csv-downloader')).toBeInTheDocument()
+ })
+
+ it('should render CSVUploader component', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('csv-uploader')).toBeInTheDocument()
+ })
+
+ it('should render cancel and run buttons', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/list\.batchModal\.cancel/i)).toBeInTheDocument()
+ expect(screen.getByText(/list\.batchModal\.run/i)).toBeInTheDocument()
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call onCancel when cancel button is clicked', () => {
+ // Arrange
+ const mockOnCancel = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByText(/list\.batchModal\.cancel/i))
+
+ // Assert
+ expect(mockOnCancel).toHaveBeenCalledTimes(1)
+ })
+
+ it('should disable run button when no file is uploaded', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ const runButton = screen.getByText(/list\.batchModal\.run/i).closest('button')
+ expect(runButton).toBeDisabled()
+ })
+
+ it('should enable run button after file is uploaded', async () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('upload-btn'))
+
+ // Assert
+ await waitFor(() => {
+ const runButton = screen.getByText(/list\.batchModal\.run/i).closest('button')
+ expect(runButton).not.toBeDisabled()
+ })
+ })
+
+ it('should call onConfirm with file when run button is clicked', async () => {
+ // Arrange
+ const mockOnConfirm = vi.fn()
+ const mockOnCancel = vi.fn()
+ render()
+
+ // Act - upload file first
+ fireEvent.click(screen.getByTestId('upload-btn'))
+
+ await waitFor(() => {
+ const runButton = screen.getByText(/list\.batchModal\.run/i).closest('button')
+ expect(runButton).not.toBeDisabled()
+ })
+
+ // Act - click run
+ fireEvent.click(screen.getByText(/list\.batchModal\.run/i))
+
+ // Assert
+ expect(mockOnCancel).toHaveBeenCalledTimes(1)
+ expect(mockOnConfirm).toHaveBeenCalledWith({ file: { id: 'test-file-id' } })
+ })
+ })
+
+ // Props tests
+ describe('Props', () => {
+ it('should pass docForm to CSVDownloader', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('csv-downloader').getAttribute('data-doc-form')).toBe(ChunkingMode.qa)
+ })
+ })
+
+ // State reset tests
+ describe('State Reset', () => {
+ it('should reset file when modal is closed and reopened', async () => {
+ // Arrange
+ const { rerender } = render()
+
+ // Upload a file
+ fireEvent.click(screen.getByTestId('upload-btn'))
+ await waitFor(() => {
+ expect(screen.getByTestId('file-info')).toBeInTheDocument()
+ })
+
+ // Close modal
+ rerender()
+
+ // Reopen modal
+ rerender()
+
+ // Assert - file should be cleared
+ expect(screen.queryByTestId('file-info')).not.toBeInTheDocument()
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should not call onConfirm when no file is present', () => {
+ // Arrange
+ const mockOnConfirm = vi.fn()
+ render()
+
+ // Act - try to click run (should be disabled)
+ const runButton = screen.getByText(/list\.batchModal\.run/i).closest('button')
+ if (runButton)
+ fireEvent.click(runButton)
+
+ // Assert
+ expect(mockOnConfirm).not.toHaveBeenCalled()
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender } = render()
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(screen.getByText(/list\.batchModal\.title/i)).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/child-segment-detail.spec.tsx b/web/app/components/datasets/documents/detail/completed/child-segment-detail.spec.tsx
new file mode 100644
index 0000000000..8b3d62aa7f
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/child-segment-detail.spec.tsx
@@ -0,0 +1,292 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ChunkingMode } from '@/models/datasets'
+
+import ChildSegmentDetail from './child-segment-detail'
+
+// Mock segment list context
+let mockFullScreen = false
+const mockToggleFullScreen = vi.fn()
+vi.mock('./index', () => ({
+ useSegmentListContext: (selector: (state: { fullScreen: boolean, toggleFullScreen: () => void }) => unknown) => {
+ const state = {
+ fullScreen: mockFullScreen,
+ toggleFullScreen: mockToggleFullScreen,
+ }
+ return selector(state)
+ },
+}))
+
+// Mock event emitter context
+vi.mock('@/context/event-emitter', () => ({
+ useEventEmitterContextContext: () => ({
+ eventEmitter: {
+ useSubscription: vi.fn(),
+ },
+ }),
+}))
+
+// Mock child components
+vi.mock('./common/action-buttons', () => ({
+ default: ({ handleCancel, handleSave, loading, isChildChunk }: { handleCancel: () => void, handleSave: () => void, loading: boolean, isChildChunk?: boolean }) => (
+
+
+
+ {isChildChunk ? 'true' : 'false'}
+
+ ),
+}))
+
+vi.mock('./common/chunk-content', () => ({
+ default: ({ question, onQuestionChange, isEditMode }: { question: string, onQuestionChange: (v: string) => void, isEditMode: boolean }) => (
+
+ onQuestionChange(e.target.value)}
+ />
+ {isEditMode ? 'editing' : 'viewing'}
+
+ ),
+}))
+
+vi.mock('./common/dot', () => ({
+ default: () => •,
+}))
+
+vi.mock('./common/segment-index-tag', () => ({
+ SegmentIndexTag: ({ positionId, labelPrefix }: { positionId?: string, labelPrefix?: string }) => (
+
+ {labelPrefix}
+ {' '}
+ {positionId}
+
+ ),
+}))
+
+describe('ChildSegmentDetail', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockFullScreen = false
+ })
+
+ const defaultChildChunkInfo = {
+ id: 'child-chunk-1',
+ content: 'Test content',
+ position: 1,
+ updated_at: 1609459200, // 2021-01-01
+ }
+
+ const defaultProps = {
+ chunkId: 'chunk-1',
+ childChunkInfo: defaultChildChunkInfo,
+ onUpdate: vi.fn(),
+ onCancel: vi.fn(),
+ docForm: ChunkingMode.text,
+ }
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render edit child chunk title', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/segment\.editChildChunk/i)).toBeInTheDocument()
+ })
+
+ it('should render chunk content component', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('chunk-content')).toBeInTheDocument()
+ })
+
+ it('should render segment index tag', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('segment-index-tag')).toBeInTheDocument()
+ })
+
+ it('should render word count', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/segment\.characters/i)).toBeInTheDocument()
+ })
+
+ it('should render edit time', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/segment\.editedAt/i)).toBeInTheDocument()
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call onCancel when close button is clicked', () => {
+ // Arrange
+ const mockOnCancel = vi.fn()
+ const { container } = render(
+ ,
+ )
+
+ // Act
+ const closeButtons = container.querySelectorAll('.cursor-pointer')
+ if (closeButtons.length > 1)
+ fireEvent.click(closeButtons[1])
+
+ // Assert
+ expect(mockOnCancel).toHaveBeenCalled()
+ })
+
+ it('should call toggleFullScreen when expand button is clicked', () => {
+ // Arrange
+ const { container } = render()
+
+ // Act
+ const expandButtons = container.querySelectorAll('.cursor-pointer')
+ if (expandButtons.length > 0)
+ fireEvent.click(expandButtons[0])
+
+ // Assert
+ expect(mockToggleFullScreen).toHaveBeenCalled()
+ })
+
+ it('should call onUpdate when save is clicked', () => {
+ // Arrange
+ const mockOnUpdate = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('save-btn'))
+
+ // Assert
+ expect(mockOnUpdate).toHaveBeenCalledWith(
+ 'chunk-1',
+ 'child-chunk-1',
+ 'Test content',
+ )
+ })
+
+ it('should update content when input changes', () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.change(screen.getByTestId('content-input'), {
+ target: { value: 'Updated content' },
+ })
+
+ // Assert
+ expect(screen.getByTestId('content-input')).toHaveValue('Updated content')
+ })
+ })
+
+ // Full screen mode
+ describe('Full Screen Mode', () => {
+ it('should show action buttons in header when fullScreen is true', () => {
+ // Arrange
+ mockFullScreen = true
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('action-buttons')).toBeInTheDocument()
+ })
+
+ it('should not show footer action buttons when fullScreen is true', () => {
+ // Arrange
+ mockFullScreen = true
+
+ // Act
+ render()
+
+ // Assert - footer with border-t-divider-subtle should not exist
+ const actionButtons = screen.getAllByTestId('action-buttons')
+ // Only one action buttons set should exist in fullScreen mode
+ expect(actionButtons.length).toBe(1)
+ })
+
+ it('should show footer action buttons when fullScreen is false', () => {
+ // Arrange
+ mockFullScreen = false
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('action-buttons')).toBeInTheDocument()
+ })
+ })
+
+ // Props
+ describe('Props', () => {
+ it('should pass isChildChunk true to ActionButtons', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('is-child-chunk')).toHaveTextContent('true')
+ })
+
+ it('should pass isEditMode true to ChunkContent', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('edit-mode')).toHaveTextContent('editing')
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle undefined childChunkInfo', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should handle empty content', () => {
+ // Arrange
+ const emptyChildChunkInfo = { ...defaultChildChunkInfo, content: '' }
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('content-input')).toHaveValue('')
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender } = render()
+
+ // Act
+ const updatedInfo = { ...defaultChildChunkInfo, content: 'New content' }
+ rerender()
+
+ // Assert
+ expect(screen.getByTestId('content-input')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/common/action-buttons.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/action-buttons.spec.tsx
new file mode 100644
index 0000000000..a8c24731e7
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/common/action-buttons.spec.tsx
@@ -0,0 +1,386 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ChunkingMode } from '@/models/datasets'
+import { DocumentContext } from '../../context'
+import ActionButtons from './action-buttons'
+
+// Create wrapper component for providing context
+const createWrapper = (contextValue: {
+ docForm?: ChunkingMode
+ parentMode?: 'paragraph' | 'full-doc'
+}) => {
+ return ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ )
+}
+
+describe('ActionButtons', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ { wrapper: createWrapper({}) },
+ )
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render cancel button', () => {
+ // Arrange & Act
+ render(
+ ,
+ { wrapper: createWrapper({}) },
+ )
+
+ // Assert
+ expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
+ })
+
+ it('should render save button', () => {
+ // Arrange & Act
+ render(
+ ,
+ { wrapper: createWrapper({}) },
+ )
+
+ // Assert
+ expect(screen.getByText(/operation\.save/i)).toBeInTheDocument()
+ })
+
+ it('should render ESC keyboard hint on cancel button', () => {
+ // Arrange & Act
+ render(
+ ,
+ { wrapper: createWrapper({}) },
+ )
+
+ // Assert
+ expect(screen.getByText('ESC')).toBeInTheDocument()
+ })
+
+ it('should render S keyboard hint on save button', () => {
+ // Arrange & Act
+ render(
+ ,
+ { wrapper: createWrapper({}) },
+ )
+
+ // Assert
+ expect(screen.getByText('S')).toBeInTheDocument()
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call handleCancel when cancel button is clicked', () => {
+ // Arrange
+ const mockHandleCancel = vi.fn()
+ render(
+ ,
+ { wrapper: createWrapper({}) },
+ )
+
+ // Act
+ const cancelButton = screen.getAllByRole('button')[0]
+ fireEvent.click(cancelButton)
+
+ // Assert
+ expect(mockHandleCancel).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call handleSave when save button is clicked', () => {
+ // Arrange
+ const mockHandleSave = vi.fn()
+ render(
+ ,
+ { wrapper: createWrapper({}) },
+ )
+
+ // Act
+ const buttons = screen.getAllByRole('button')
+ const saveButton = buttons[buttons.length - 1] // Save button is last
+ fireEvent.click(saveButton)
+
+ // Assert
+ expect(mockHandleSave).toHaveBeenCalledTimes(1)
+ })
+
+ it('should disable save button when loading is true', () => {
+ // Arrange & Act
+ render(
+ ,
+ { wrapper: createWrapper({}) },
+ )
+
+ // Assert
+ const buttons = screen.getAllByRole('button')
+ const saveButton = buttons[buttons.length - 1]
+ expect(saveButton).toBeDisabled()
+ })
+ })
+
+ // Regeneration button tests
+ describe('Regeneration Button', () => {
+ it('should show regeneration button in parent-child paragraph mode for edit action', () => {
+ // Arrange & Act
+ render(
+ ,
+ { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
+ )
+
+ // Assert
+ expect(screen.getByText(/operation\.saveAndRegenerate/i)).toBeInTheDocument()
+ })
+
+ it('should not show regeneration button when isChildChunk is true', () => {
+ // Arrange & Act
+ render(
+ ,
+ { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
+ )
+
+ // Assert
+ expect(screen.queryByText(/operation\.saveAndRegenerate/i)).not.toBeInTheDocument()
+ })
+
+ it('should not show regeneration button when showRegenerationButton is false', () => {
+ // Arrange & Act
+ render(
+ ,
+ { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
+ )
+
+ // Assert
+ expect(screen.queryByText(/operation\.saveAndRegenerate/i)).not.toBeInTheDocument()
+ })
+
+ it('should not show regeneration button when actionType is add', () => {
+ // Arrange & Act
+ render(
+ ,
+ { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
+ )
+
+ // Assert
+ expect(screen.queryByText(/operation\.saveAndRegenerate/i)).not.toBeInTheDocument()
+ })
+
+ it('should call handleRegeneration when regeneration button is clicked', () => {
+ // Arrange
+ const mockHandleRegeneration = vi.fn()
+ render(
+ ,
+ { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
+ )
+
+ // Act
+ const regenerationButton = screen.getByText(/operation\.saveAndRegenerate/i).closest('button')
+ if (regenerationButton)
+ fireEvent.click(regenerationButton)
+
+ // Assert
+ expect(mockHandleRegeneration).toHaveBeenCalledTimes(1)
+ })
+
+ it('should disable regeneration button when loading is true', () => {
+ // Arrange & Act
+ render(
+ ,
+ { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
+ )
+
+ // Assert
+ const regenerationButton = screen.getByText(/operation\.saveAndRegenerate/i).closest('button')
+ expect(regenerationButton).toBeDisabled()
+ })
+ })
+
+ // Default props tests
+ describe('Default Props', () => {
+ it('should use default actionType of edit', () => {
+ // Arrange & Act - when not specifying actionType and other conditions are met
+ render(
+ ,
+ { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
+ )
+
+ // Assert - regeneration button should show with default actionType='edit'
+ expect(screen.getByText(/operation\.saveAndRegenerate/i)).toBeInTheDocument()
+ })
+
+ it('should use default isChildChunk of false', () => {
+ // Arrange & Act - when not specifying isChildChunk
+ render(
+ ,
+ { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
+ )
+
+ // Assert - regeneration button should show with default isChildChunk=false
+ expect(screen.getByText(/operation\.saveAndRegenerate/i)).toBeInTheDocument()
+ })
+
+ it('should use default showRegenerationButton of true', () => {
+ // Arrange & Act - when not specifying showRegenerationButton
+ render(
+ ,
+ { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
+ )
+
+ // Assert - regeneration button should show with default showRegenerationButton=true
+ expect(screen.getByText(/operation\.saveAndRegenerate/i)).toBeInTheDocument()
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle missing context values gracefully', () => {
+ // Arrange & Act & Assert - should not throw
+ expect(() => {
+ render(
+ ,
+ { wrapper: createWrapper({}) },
+ )
+ }).not.toThrow()
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender } = render(
+ ,
+ { wrapper: createWrapper({}) },
+ )
+
+ // Act
+ rerender(
+
+
+ ,
+ )
+
+ // Assert
+ expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
+ expect(screen.getByText(/operation\.save/i)).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/common/add-another.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/add-another.spec.tsx
new file mode 100644
index 0000000000..6f76fb4f79
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/common/add-another.spec.tsx
@@ -0,0 +1,194 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import AddAnother from './add-another'
+
+describe('AddAnother', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render the checkbox', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert - Checkbox component renders with shrink-0 class
+ const checkbox = container.querySelector('.shrink-0')
+ expect(checkbox).toBeInTheDocument()
+ })
+
+ it('should render the add another text', () => {
+ // Arrange & Act
+ render()
+
+ // Assert - i18n key format
+ expect(screen.getByText(/segment\.addAnother/i)).toBeInTheDocument()
+ })
+
+ it('should render with correct base styling classes', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('flex')
+ expect(wrapper).toHaveClass('items-center')
+ expect(wrapper).toHaveClass('gap-x-1')
+ expect(wrapper).toHaveClass('pl-1')
+ })
+ })
+
+ // Props tests
+ describe('Props', () => {
+ it('should render unchecked state when isChecked is false', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert - unchecked checkbox has border class
+ const checkbox = container.querySelector('.border-components-checkbox-border')
+ expect(checkbox).toBeInTheDocument()
+ })
+
+ it('should render checked state when isChecked is true', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert - checked checkbox has bg-components-checkbox-bg class
+ const checkbox = container.querySelector('.bg-components-checkbox-bg')
+ expect(checkbox).toBeInTheDocument()
+ })
+
+ it('should apply custom className', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('custom-class')
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call onCheck when checkbox is clicked', () => {
+ // Arrange
+ const mockOnCheck = vi.fn()
+ const { container } = render(
+ ,
+ )
+
+ // Act - click on the checkbox element
+ const checkbox = container.querySelector('.shrink-0')
+ if (checkbox)
+ fireEvent.click(checkbox)
+
+ // Assert
+ expect(mockOnCheck).toHaveBeenCalledTimes(1)
+ })
+
+ it('should toggle checked state on multiple clicks', () => {
+ // Arrange
+ const mockOnCheck = vi.fn()
+ const { container, rerender } = render(
+ ,
+ )
+
+ // Act - first click
+ const checkbox = container.querySelector('.shrink-0')
+ if (checkbox) {
+ fireEvent.click(checkbox)
+ rerender()
+ fireEvent.click(checkbox)
+ }
+
+ // Assert
+ expect(mockOnCheck).toHaveBeenCalledTimes(2)
+ })
+ })
+
+ // Structure tests
+ describe('Structure', () => {
+ it('should render text with tertiary text color', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const textElement = container.querySelector('.text-text-tertiary')
+ expect(textElement).toBeInTheDocument()
+ })
+
+ it('should render text with xs medium font styling', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const textElement = container.querySelector('.system-xs-medium')
+ expect(textElement).toBeInTheDocument()
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const mockOnCheck = vi.fn()
+ const { rerender, container } = render(
+ ,
+ )
+
+ // Act
+ rerender()
+
+ // Assert
+ const checkbox = container.querySelector('.shrink-0')
+ expect(checkbox).toBeInTheDocument()
+ })
+
+ it('should handle rapid state changes', () => {
+ // Arrange
+ const mockOnCheck = vi.fn()
+ const { container } = render(
+ ,
+ )
+
+ // Act
+ const checkbox = container.querySelector('.shrink-0')
+ if (checkbox) {
+ for (let i = 0; i < 5; i++)
+ fireEvent.click(checkbox)
+ }
+
+ // Assert
+ expect(mockOnCheck).toHaveBeenCalledTimes(5)
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/common/batch-action.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/batch-action.spec.tsx
new file mode 100644
index 0000000000..0c0190ed5d
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/common/batch-action.spec.tsx
@@ -0,0 +1,277 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import BatchAction from './batch-action'
+
+describe('BatchAction', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ const defaultProps = {
+ selectedIds: ['1', '2', '3'],
+ onBatchEnable: vi.fn(),
+ onBatchDisable: vi.fn(),
+ onBatchDelete: vi.fn().mockResolvedValue(undefined),
+ onCancel: vi.fn(),
+ }
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should display selected count', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('3')).toBeInTheDocument()
+ })
+
+ it('should render enable button', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/batchAction\.enable/i)).toBeInTheDocument()
+ })
+
+ it('should render disable button', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/batchAction\.disable/i)).toBeInTheDocument()
+ })
+
+ it('should render delete button', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/batchAction\.delete/i)).toBeInTheDocument()
+ })
+
+ it('should render cancel button', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/batchAction\.cancel/i)).toBeInTheDocument()
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call onBatchEnable when enable button is clicked', () => {
+ // Arrange
+ const mockOnBatchEnable = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByText(/batchAction\.enable/i))
+
+ // Assert
+ expect(mockOnBatchEnable).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onBatchDisable when disable button is clicked', () => {
+ // Arrange
+ const mockOnBatchDisable = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByText(/batchAction\.disable/i))
+
+ // Assert
+ expect(mockOnBatchDisable).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onCancel when cancel button is clicked', () => {
+ // Arrange
+ const mockOnCancel = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByText(/batchAction\.cancel/i))
+
+ // Assert
+ expect(mockOnCancel).toHaveBeenCalledTimes(1)
+ })
+
+ it('should show delete confirmation dialog when delete button is clicked', () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.click(screen.getByText(/batchAction\.delete/i))
+
+ // Assert - Confirm dialog should appear
+ expect(screen.getByText(/list\.delete\.title/i)).toBeInTheDocument()
+ })
+
+ it('should call onBatchDelete when confirm is clicked in delete dialog', async () => {
+ // Arrange
+ const mockOnBatchDelete = vi.fn().mockResolvedValue(undefined)
+ render()
+
+ // Act - open delete dialog
+ fireEvent.click(screen.getByText(/batchAction\.delete/i))
+
+ // Act - click confirm
+ const confirmButton = screen.getByText(/operation\.sure/i)
+ fireEvent.click(confirmButton)
+
+ // Assert
+ await waitFor(() => {
+ expect(mockOnBatchDelete).toHaveBeenCalledTimes(1)
+ })
+ })
+ })
+
+ // Optional props tests
+ describe('Optional Props', () => {
+ it('should render download button when onBatchDownload is provided', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/batchAction\.download/i)).toBeInTheDocument()
+ })
+
+ it('should not render download button when onBatchDownload is not provided', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.queryByText(/batchAction\.download/i)).not.toBeInTheDocument()
+ })
+
+ it('should render archive button when onArchive is provided', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/batchAction\.archive/i)).toBeInTheDocument()
+ })
+
+ it('should render metadata button when onEditMetadata is provided', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/metadata\.metadata/i)).toBeInTheDocument()
+ })
+
+ it('should render re-index button when onBatchReIndex is provided', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/batchAction\.reIndex/i)).toBeInTheDocument()
+ })
+
+ it('should call onBatchDownload when download button is clicked', () => {
+ // Arrange
+ const mockOnBatchDownload = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByText(/batchAction\.download/i))
+
+ // Assert
+ expect(mockOnBatchDownload).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onArchive when archive button is clicked', () => {
+ // Arrange
+ const mockOnArchive = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByText(/batchAction\.archive/i))
+
+ // Assert
+ expect(mockOnArchive).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onEditMetadata when metadata button is clicked', () => {
+ // Arrange
+ const mockOnEditMetadata = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByText(/metadata\.metadata/i))
+
+ // Assert
+ expect(mockOnEditMetadata).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onBatchReIndex when re-index button is clicked', () => {
+ // Arrange
+ const mockOnBatchReIndex = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByText(/batchAction\.reIndex/i))
+
+ // Assert
+ expect(mockOnBatchReIndex).toHaveBeenCalledTimes(1)
+ })
+
+ it('should apply custom className', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('custom-class')
+ })
+ })
+
+ // Selected count display tests
+ describe('Selected Count', () => {
+ it('should display correct count for single selection', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('1')).toBeInTheDocument()
+ })
+
+ it('should display correct count for multiple selections', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('5')).toBeInTheDocument()
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender } = render()
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(screen.getByText('2')).toBeInTheDocument()
+ })
+
+ it('should handle empty selectedIds array', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('0')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/common/chunk-content.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/chunk-content.spec.tsx
new file mode 100644
index 0000000000..6e04a9d93c
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/common/chunk-content.spec.tsx
@@ -0,0 +1,309 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ChunkingMode } from '@/models/datasets'
+import ChunkContent from './chunk-content'
+
+// Mock ResizeObserver
+class MockResizeObserver {
+ observe = vi.fn()
+ disconnect = vi.fn()
+ unobserve = vi.fn()
+}
+globalThis.ResizeObserver = MockResizeObserver as typeof ResizeObserver
+
+describe('ChunkContent', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ const defaultProps = {
+ question: 'Test question content',
+ onQuestionChange: vi.fn(),
+ docForm: ChunkingMode.text,
+ }
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render textarea in edit mode with text docForm', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ const textarea = screen.getByRole('textbox')
+ expect(textarea).toBeInTheDocument()
+ })
+
+ it('should render Markdown content in view mode with text docForm', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - In view mode, textarea should not be present, Markdown renders instead
+ expect(container.querySelector('textarea')).not.toBeInTheDocument()
+ })
+ })
+
+ // QA mode tests
+ describe('QA Mode', () => {
+ it('should render QA layout when docForm is qa', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert - QA mode has QUESTION and ANSWER labels
+ expect(screen.getByText('QUESTION')).toBeInTheDocument()
+ expect(screen.getByText('ANSWER')).toBeInTheDocument()
+ })
+
+ it('should display question value in QA mode', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert
+ const textareas = screen.getAllByRole('textbox')
+ expect(textareas[0]).toHaveValue('My question')
+ })
+
+ it('should display answer value in QA mode', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert
+ const textareas = screen.getAllByRole('textbox')
+ expect(textareas[1]).toHaveValue('My answer')
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call onQuestionChange when textarea value changes in text mode', () => {
+ // Arrange
+ const mockOnQuestionChange = vi.fn()
+ render(
+ ,
+ )
+
+ // Act
+ const textarea = screen.getByRole('textbox')
+ fireEvent.change(textarea, { target: { value: 'New content' } })
+
+ // Assert
+ expect(mockOnQuestionChange).toHaveBeenCalledWith('New content')
+ })
+
+ it('should call onQuestionChange when question textarea changes in QA mode', () => {
+ // Arrange
+ const mockOnQuestionChange = vi.fn()
+ render(
+ ,
+ )
+
+ // Act
+ const textareas = screen.getAllByRole('textbox')
+ fireEvent.change(textareas[0], { target: { value: 'New question' } })
+
+ // Assert
+ expect(mockOnQuestionChange).toHaveBeenCalledWith('New question')
+ })
+
+ it('should call onAnswerChange when answer textarea changes in QA mode', () => {
+ // Arrange
+ const mockOnAnswerChange = vi.fn()
+ render(
+ ,
+ )
+
+ // Act
+ const textareas = screen.getAllByRole('textbox')
+ fireEvent.change(textareas[1], { target: { value: 'New answer' } })
+
+ // Assert
+ expect(mockOnAnswerChange).toHaveBeenCalledWith('New answer')
+ })
+
+ it('should disable textarea when isEditMode is false in text mode', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert - In view mode, Markdown is rendered instead of textarea
+ expect(container.querySelector('textarea')).not.toBeInTheDocument()
+ })
+
+ it('should disable textareas when isEditMode is false in QA mode', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert
+ const textareas = screen.getAllByRole('textbox')
+ textareas.forEach((textarea) => {
+ expect(textarea).toBeDisabled()
+ })
+ })
+ })
+
+ // DocForm variations
+ describe('DocForm Variations', () => {
+ it('should handle ChunkingMode.text', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ })
+
+ it('should handle ChunkingMode.qa', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert - QA mode should show both question and answer
+ expect(screen.getByText('QUESTION')).toBeInTheDocument()
+ expect(screen.getByText('ANSWER')).toBeInTheDocument()
+ })
+
+ it('should handle ChunkingMode.parentChild similar to text mode', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert - parentChild should render like text mode
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle empty question', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert
+ const textarea = screen.getByRole('textbox')
+ expect(textarea).toHaveValue('')
+ })
+
+ it('should handle empty answer in QA mode', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert
+ const textareas = screen.getAllByRole('textbox')
+ expect(textareas[1]).toHaveValue('')
+ })
+
+ it('should handle undefined answer in QA mode', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert - should render without crashing
+ expect(screen.getByText('QUESTION')).toBeInTheDocument()
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender } = render(
+ ,
+ )
+
+ // Act
+ rerender(
+ ,
+ )
+
+ // Assert
+ const textarea = screen.getByRole('textbox')
+ expect(textarea).toHaveValue('Updated')
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/common/dot.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/dot.spec.tsx
new file mode 100644
index 0000000000..af8c981bf5
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/common/dot.spec.tsx
@@ -0,0 +1,60 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import Dot from './dot'
+
+describe('Dot', () => {
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render the dot character', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('·')).toBeInTheDocument()
+ })
+
+ it('should render with correct styling classes', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const dotElement = container.firstChild as HTMLElement
+ expect(dotElement).toHaveClass('system-xs-medium')
+ expect(dotElement).toHaveClass('text-text-quaternary')
+ })
+ })
+
+ // Memoization tests
+ describe('Memoization', () => {
+ it('should render consistently across multiple renders', () => {
+ // Arrange & Act
+ const { container: container1 } = render()
+ const { container: container2 } = render()
+
+ // Assert
+ expect(container1.firstChild?.textContent).toBe(container2.firstChild?.textContent)
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender } = render()
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(screen.getByText('·')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/common/empty.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/empty.spec.tsx
new file mode 100644
index 0000000000..6feb9ea4c0
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/common/empty.spec.tsx
@@ -0,0 +1,153 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import Empty from './empty'
+
+describe('Empty', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render the file list icon', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - RiFileList2Line icon should be rendered
+ const icon = container.querySelector('.h-6.w-6')
+ expect(icon).toBeInTheDocument()
+ })
+
+ it('should render empty message text', () => {
+ // Arrange & Act
+ render()
+
+ // Assert - i18n key format: datasetDocuments:segment.empty
+ expect(screen.getByText(/segment\.empty/i)).toBeInTheDocument()
+ })
+
+ it('should render clear filter button', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should render background empty cards', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - should have 10 background cards
+ const emptyCards = container.querySelectorAll('.bg-background-section-burn')
+ expect(emptyCards).toHaveLength(10)
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call onClearFilter when clear filter button is clicked', () => {
+ // Arrange
+ const mockOnClearFilter = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByRole('button'))
+
+ // Assert
+ expect(mockOnClearFilter).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ // Structure tests
+ describe('Structure', () => {
+ it('should render the decorative lines', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - there should be 4 Line components (SVG elements)
+ const svgElements = container.querySelectorAll('svg')
+ expect(svgElements.length).toBeGreaterThanOrEqual(4)
+ })
+
+ it('should render mask overlay', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const maskElement = container.querySelector('.bg-dataset-chunk-list-mask-bg')
+ expect(maskElement).toBeInTheDocument()
+ })
+
+ it('should render icon container with proper styling', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const iconContainer = container.querySelector('.shadow-lg')
+ expect(iconContainer).toBeInTheDocument()
+ })
+
+ it('should render clear filter button with accent text styling', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ const button = screen.getByRole('button')
+ expect(button).toHaveClass('text-text-accent')
+ })
+ })
+
+ // Props tests
+ describe('Props', () => {
+ it('should accept onClearFilter callback prop', () => {
+ // Arrange
+ const mockCallback = vi.fn()
+
+ // Act
+ render()
+ fireEvent.click(screen.getByRole('button'))
+
+ // Assert
+ expect(mockCallback).toHaveBeenCalled()
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle multiple clicks on clear filter button', () => {
+ // Arrange
+ const mockOnClearFilter = vi.fn()
+ render()
+
+ // Act
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+ fireEvent.click(button)
+ fireEvent.click(button)
+
+ // Assert
+ expect(mockOnClearFilter).toHaveBeenCalledTimes(3)
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender, container } = render()
+
+ // Act
+ rerender()
+
+ // Assert
+ const emptyCards = container.querySelectorAll('.bg-background-section-burn')
+ expect(emptyCards).toHaveLength(10)
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/common/full-screen-drawer.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/full-screen-drawer.spec.tsx
new file mode 100644
index 0000000000..3c0bc9dfad
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/common/full-screen-drawer.spec.tsx
@@ -0,0 +1,261 @@
+import { render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import FullScreenDrawer from './full-screen-drawer'
+
+// Mock the Drawer component since it has high complexity
+vi.mock('./drawer', () => ({
+ default: ({ children, open, panelClassName, panelContentClassName, showOverlay, needCheckChunks, modal }: { children: React.ReactNode, open: boolean, panelClassName: string, panelContentClassName: string, showOverlay: boolean, needCheckChunks: boolean, modal: boolean }) => {
+ if (!open)
+ return null
+ return (
+
+ {children}
+
+ )
+ },
+}))
+
+describe('FullScreenDrawer', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing when open', () => {
+ // Arrange & Act
+ render(
+
+ Content
+ ,
+ )
+
+ // Assert
+ expect(screen.getByTestId('drawer-mock')).toBeInTheDocument()
+ })
+
+ it('should not render when closed', () => {
+ // Arrange & Act
+ render(
+
+ Content
+ ,
+ )
+
+ // Assert
+ expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument()
+ })
+
+ it('should render children content', () => {
+ // Arrange & Act
+ render(
+
+ Test Content
+ ,
+ )
+
+ // Assert
+ expect(screen.getByText('Test Content')).toBeInTheDocument()
+ })
+ })
+
+ // Props tests
+ describe('Props', () => {
+ it('should pass fullScreen=true to Drawer with full width class', () => {
+ // Arrange & Act
+ render(
+
+ Content
+ ,
+ )
+
+ // Assert
+ const drawer = screen.getByTestId('drawer-mock')
+ expect(drawer.getAttribute('data-panel-class')).toContain('w-full')
+ })
+
+ it('should pass fullScreen=false to Drawer with fixed width class', () => {
+ // Arrange & Act
+ render(
+
+ Content
+ ,
+ )
+
+ // Assert
+ const drawer = screen.getByTestId('drawer-mock')
+ expect(drawer.getAttribute('data-panel-class')).toContain('w-[568px]')
+ })
+
+ it('should pass showOverlay prop with default true', () => {
+ // Arrange & Act
+ render(
+
+ Content
+ ,
+ )
+
+ // Assert
+ const drawer = screen.getByTestId('drawer-mock')
+ expect(drawer.getAttribute('data-show-overlay')).toBe('true')
+ })
+
+ it('should pass showOverlay=false when specified', () => {
+ // Arrange & Act
+ render(
+
+ Content
+ ,
+ )
+
+ // Assert
+ const drawer = screen.getByTestId('drawer-mock')
+ expect(drawer.getAttribute('data-show-overlay')).toBe('false')
+ })
+
+ it('should pass needCheckChunks prop with default false', () => {
+ // Arrange & Act
+ render(
+
+ Content
+ ,
+ )
+
+ // Assert
+ const drawer = screen.getByTestId('drawer-mock')
+ expect(drawer.getAttribute('data-need-check-chunks')).toBe('false')
+ })
+
+ it('should pass needCheckChunks=true when specified', () => {
+ // Arrange & Act
+ render(
+
+ Content
+ ,
+ )
+
+ // Assert
+ const drawer = screen.getByTestId('drawer-mock')
+ expect(drawer.getAttribute('data-need-check-chunks')).toBe('true')
+ })
+
+ it('should pass modal prop with default false', () => {
+ // Arrange & Act
+ render(
+
+ Content
+ ,
+ )
+
+ // Assert
+ const drawer = screen.getByTestId('drawer-mock')
+ expect(drawer.getAttribute('data-modal')).toBe('false')
+ })
+
+ it('should pass modal=true when specified', () => {
+ // Arrange & Act
+ render(
+
+ Content
+ ,
+ )
+
+ // Assert
+ const drawer = screen.getByTestId('drawer-mock')
+ expect(drawer.getAttribute('data-modal')).toBe('true')
+ })
+ })
+
+ // Styling tests
+ describe('Styling', () => {
+ it('should apply panel content classes for non-fullScreen mode', () => {
+ // Arrange & Act
+ render(
+
+ Content
+ ,
+ )
+
+ // Assert
+ const drawer = screen.getByTestId('drawer-mock')
+ const contentClass = drawer.getAttribute('data-panel-content-class')
+ expect(contentClass).toContain('bg-components-panel-bg')
+ expect(contentClass).toContain('rounded-xl')
+ })
+
+ it('should apply panel content classes without border for fullScreen mode', () => {
+ // Arrange & Act
+ render(
+
+ Content
+ ,
+ )
+
+ // Assert
+ const drawer = screen.getByTestId('drawer-mock')
+ const contentClass = drawer.getAttribute('data-panel-content-class')
+ expect(contentClass).toContain('bg-components-panel-bg')
+ expect(contentClass).not.toContain('rounded-xl')
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle undefined onClose gracefully', () => {
+ // Arrange & Act & Assert - should not throw
+ expect(() => {
+ render(
+
+ Content
+ ,
+ )
+ }).not.toThrow()
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender } = render(
+
+ Content
+ ,
+ )
+
+ // Act
+ rerender(
+
+ Updated Content
+ ,
+ )
+
+ // Assert
+ expect(screen.getByText('Updated Content')).toBeInTheDocument()
+ })
+
+ it('should handle toggle between open and closed states', () => {
+ // Arrange
+ const { rerender } = render(
+
+ Content
+ ,
+ )
+ expect(screen.getByTestId('drawer-mock')).toBeInTheDocument()
+
+ // Act
+ rerender(
+
+ Content
+ ,
+ )
+
+ // Assert
+ expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/common/keywords.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/keywords.spec.tsx
new file mode 100644
index 0000000000..9e32237a0f
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/common/keywords.spec.tsx
@@ -0,0 +1,249 @@
+import { render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import Keywords from './keywords'
+
+describe('Keywords', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render the keywords label', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert - i18n key format
+ expect(screen.getByText(/segment\.keywords/i)).toBeInTheDocument()
+ })
+
+ it('should render with correct container classes', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('flex')
+ expect(wrapper).toHaveClass('flex-col')
+ })
+ })
+
+ // Props tests
+ describe('Props', () => {
+ it('should display dash when no keywords and actionType is view', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert
+ expect(screen.getByText('-')).toBeInTheDocument()
+ })
+
+ it('should not display dash when actionType is edit', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert
+ expect(screen.queryByText('-')).not.toBeInTheDocument()
+ })
+
+ it('should not display dash when actionType is add', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert
+ expect(screen.queryByText('-')).not.toBeInTheDocument()
+ })
+
+ it('should apply custom className', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('custom-class')
+ })
+
+ it('should use default actionType of view', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert - dash should appear in view mode with empty keywords
+ expect(screen.getByText('-')).toBeInTheDocument()
+ })
+ })
+
+ // Structure tests
+ describe('Structure', () => {
+ it('should render label with uppercase styling', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const labelElement = container.querySelector('.system-xs-medium-uppercase')
+ expect(labelElement).toBeInTheDocument()
+ })
+
+ it('should render keywords container with overflow handling', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const keywordsContainer = container.querySelector('.overflow-auto')
+ expect(keywordsContainer).toBeInTheDocument()
+ })
+
+ it('should render keywords container with max height', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const keywordsContainer = container.querySelector('.max-h-\\[200px\\]')
+ expect(keywordsContainer).toBeInTheDocument()
+ })
+ })
+
+ // Edit mode tests
+ describe('Edit Mode', () => {
+ it('should render TagInput component when keywords exist', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert - TagInput should be rendered instead of dash
+ expect(screen.queryByText('-')).not.toBeInTheDocument()
+ expect(container.querySelector('.flex-wrap')).toBeInTheDocument()
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle empty keywords array in view mode without segInfo keywords', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert - container should be rendered
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender, container } = render(
+ ,
+ )
+
+ // Act
+ rerender(
+ ,
+ )
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should handle segInfo with undefined keywords showing dash in view mode', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert - dash should show because segInfo.keywords is undefined/empty
+ expect(screen.getByText('-')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/common/regeneration-modal.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/regeneration-modal.spec.tsx
new file mode 100644
index 0000000000..1c0215ff45
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/common/regeneration-modal.spec.tsx
@@ -0,0 +1,150 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { EventEmitterContextProvider } from '@/context/event-emitter'
+import RegenerationModal from './regeneration-modal'
+
+// Create a wrapper component with event emitter context
+const createWrapper = () => {
+ return ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ )
+}
+
+describe('RegenerationModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ const defaultProps = {
+ isShow: true,
+ onConfirm: vi.fn(),
+ onCancel: vi.fn(),
+ onClose: vi.fn(),
+ }
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing when isShow is true', () => {
+ // Arrange & Act
+ render(, { wrapper: createWrapper() })
+
+ // Assert
+ expect(screen.getByText(/segment\.regenerationConfirmTitle/i)).toBeInTheDocument()
+ })
+
+ it('should not render content when isShow is false', () => {
+ // Arrange & Act
+ render(, { wrapper: createWrapper() })
+
+ // Assert - Modal container might exist but content should not be visible
+ expect(screen.queryByText(/segment\.regenerationConfirmTitle/i)).not.toBeInTheDocument()
+ })
+
+ it('should render confirmation message', () => {
+ // Arrange & Act
+ render(, { wrapper: createWrapper() })
+
+ // Assert
+ expect(screen.getByText(/segment\.regenerationConfirmMessage/i)).toBeInTheDocument()
+ })
+
+ it('should render cancel button in default state', () => {
+ // Arrange & Act
+ render(, { wrapper: createWrapper() })
+
+ // Assert
+ expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
+ })
+
+ it('should render regenerate button in default state', () => {
+ // Arrange & Act
+ render(, { wrapper: createWrapper() })
+
+ // Assert
+ expect(screen.getByText(/operation\.regenerate/i)).toBeInTheDocument()
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call onCancel when cancel button is clicked', () => {
+ // Arrange
+ const mockOnCancel = vi.fn()
+ render(, { wrapper: createWrapper() })
+
+ // Act
+ fireEvent.click(screen.getByText(/operation\.cancel/i))
+
+ // Assert
+ expect(mockOnCancel).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onConfirm when regenerate button is clicked', () => {
+ // Arrange
+ const mockOnConfirm = vi.fn()
+ render(, { wrapper: createWrapper() })
+
+ // Act
+ fireEvent.click(screen.getByText(/operation\.regenerate/i))
+
+ // Assert
+ expect(mockOnConfirm).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ // Modal content states - these would require event emitter manipulation
+ describe('Modal States', () => {
+ it('should show default content initially', () => {
+ // Arrange & Act
+ render(, { wrapper: createWrapper() })
+
+ // Assert
+ expect(screen.getByText(/segment\.regenerationConfirmTitle/i)).toBeInTheDocument()
+ expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle toggling isShow prop', () => {
+ // Arrange
+ const { rerender } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ expect(screen.getByText(/segment\.regenerationConfirmTitle/i)).toBeInTheDocument()
+
+ // Act
+ rerender(
+
+
+ ,
+ )
+
+ // Assert
+ expect(screen.queryByText(/segment\.regenerationConfirmTitle/i)).not.toBeInTheDocument()
+ })
+
+ it('should maintain handlers when rerendered', () => {
+ // Arrange
+ const mockOnConfirm = vi.fn()
+ const { rerender } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Act
+ rerender(
+
+
+ ,
+ )
+ fireEvent.click(screen.getByText(/operation\.regenerate/i))
+
+ // Assert
+ expect(mockOnConfirm).toHaveBeenCalledTimes(1)
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/common/segment-index-tag.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/segment-index-tag.spec.tsx
new file mode 100644
index 0000000000..8d0bf89636
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/common/segment-index-tag.spec.tsx
@@ -0,0 +1,215 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import SegmentIndexTag from './segment-index-tag'
+
+describe('SegmentIndexTag', () => {
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render the Chunk icon', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const icon = container.querySelector('.h-3.w-3')
+ expect(icon).toBeInTheDocument()
+ })
+
+ it('should render with correct container classes', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('flex')
+ expect(wrapper).toHaveClass('items-center')
+ })
+ })
+
+ // Props tests
+ describe('Props', () => {
+ it('should render position ID with default prefix', () => {
+ // Arrange & Act
+ render()
+
+ // Assert - default prefix is 'Chunk'
+ expect(screen.getByText('Chunk-05')).toBeInTheDocument()
+ })
+
+ it('should render position ID without padding for two-digit numbers', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('Chunk-15')).toBeInTheDocument()
+ })
+
+ it('should render position ID without padding for three-digit numbers', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('Chunk-123')).toBeInTheDocument()
+ })
+
+ it('should render custom label when provided', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('Custom Label')).toBeInTheDocument()
+ })
+
+ it('should use custom labelPrefix', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('Segment-03')).toBeInTheDocument()
+ })
+
+ it('should apply custom className', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('custom-class')
+ })
+
+ it('should apply custom iconClassName', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const icon = container.querySelector('.custom-icon-class')
+ expect(icon).toBeInTheDocument()
+ })
+
+ it('should apply custom labelClassName', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const label = container.querySelector('.custom-label-class')
+ expect(label).toBeInTheDocument()
+ })
+
+ it('should handle string positionId', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('Chunk-07')).toBeInTheDocument()
+ })
+ })
+
+ // Memoization tests
+ describe('Memoization', () => {
+ it('should compute localPositionId based on positionId and labelPrefix', () => {
+ // Arrange & Act
+ const { rerender } = render()
+ expect(screen.getByText('Chunk-01')).toBeInTheDocument()
+
+ // Act - change positionId
+ rerender()
+
+ // Assert
+ expect(screen.getByText('Chunk-02')).toBeInTheDocument()
+ })
+
+ it('should update when labelPrefix changes', () => {
+ // Arrange & Act
+ const { rerender } = render()
+ expect(screen.getByText('Chunk-01')).toBeInTheDocument()
+
+ // Act - change labelPrefix
+ rerender()
+
+ // Assert
+ expect(screen.getByText('Part-01')).toBeInTheDocument()
+ })
+ })
+
+ // Structure tests
+ describe('Structure', () => {
+ it('should render icon with tertiary text color', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const icon = container.querySelector('.text-text-tertiary')
+ expect(icon).toBeInTheDocument()
+ })
+
+ it('should render label with xs medium font styling', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const label = container.querySelector('.system-xs-medium')
+ expect(label).toBeInTheDocument()
+ })
+
+ it('should render icon with margin-right spacing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const icon = container.querySelector('.mr-0\\.5')
+ expect(icon).toBeInTheDocument()
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle positionId of 0', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('Chunk-00')).toBeInTheDocument()
+ })
+
+ it('should handle undefined positionId', () => {
+ // Arrange & Act
+ render()
+
+ // Assert - should display 'Chunk-undefined' or similar
+ expect(screen.getByText(/Chunk-/)).toBeInTheDocument()
+ })
+
+ it('should prioritize label over computed positionId', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('Override')).toBeInTheDocument()
+ expect(screen.queryByText('Chunk-99')).not.toBeInTheDocument()
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender, container } = render()
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/common/tag.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/tag.spec.tsx
new file mode 100644
index 0000000000..8456652126
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/common/tag.spec.tsx
@@ -0,0 +1,151 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import Tag from './tag'
+
+describe('Tag', () => {
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render the hash symbol', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('#')).toBeInTheDocument()
+ })
+
+ it('should render the text content', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('keyword')).toBeInTheDocument()
+ })
+
+ it('should render with correct base styling classes', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const tagElement = container.firstChild as HTMLElement
+ expect(tagElement).toHaveClass('inline-flex')
+ expect(tagElement).toHaveClass('items-center')
+ expect(tagElement).toHaveClass('gap-x-0.5')
+ })
+ })
+
+ // Props tests
+ describe('Props', () => {
+ it('should apply custom className', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const tagElement = container.firstChild as HTMLElement
+ expect(tagElement).toHaveClass('custom-class')
+ })
+
+ it('should render different text values', () => {
+ // Arrange & Act
+ const { rerender } = render()
+ expect(screen.getByText('first')).toBeInTheDocument()
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(screen.getByText('second')).toBeInTheDocument()
+ })
+ })
+
+ // Structure tests
+ describe('Structure', () => {
+ it('should render hash with quaternary text color', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const hashSpan = container.querySelector('.text-text-quaternary')
+ expect(hashSpan).toBeInTheDocument()
+ expect(hashSpan).toHaveTextContent('#')
+ })
+
+ it('should render text with tertiary text color', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const textSpan = container.querySelector('.text-text-tertiary')
+ expect(textSpan).toBeInTheDocument()
+ expect(textSpan).toHaveTextContent('test')
+ })
+
+ it('should have truncate class for text overflow', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const textSpan = container.querySelector('.truncate')
+ expect(textSpan).toBeInTheDocument()
+ })
+
+ it('should have max-width constraint on text', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const textSpan = container.querySelector('.max-w-12')
+ expect(textSpan).toBeInTheDocument()
+ })
+ })
+
+ // Memoization tests
+ describe('Memoization', () => {
+ it('should render consistently with same props', () => {
+ // Arrange & Act
+ const { container: container1 } = render()
+ const { container: container2 } = render()
+
+ // Assert
+ expect(container1.firstChild?.textContent).toBe(container2.firstChild?.textContent)
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle empty text', () => {
+ // Arrange & Act
+ render()
+
+ // Assert - should still render the hash symbol
+ expect(screen.getByText('#')).toBeInTheDocument()
+ })
+
+ it('should handle special characters in text', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('test-tag_1')).toBeInTheDocument()
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender } = render()
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(screen.getByText('#')).toBeInTheDocument()
+ expect(screen.getByText('test')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/display-toggle.spec.tsx b/web/app/components/datasets/documents/detail/completed/display-toggle.spec.tsx
new file mode 100644
index 0000000000..e1004b1454
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/display-toggle.spec.tsx
@@ -0,0 +1,130 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import DisplayToggle from './display-toggle'
+
+describe('DisplayToggle', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should render button with proper styling', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ const button = screen.getByRole('button')
+ expect(button).toHaveClass('flex')
+ expect(button).toHaveClass('items-center')
+ expect(button).toHaveClass('justify-center')
+ expect(button).toHaveClass('rounded-lg')
+ })
+ })
+
+ // Props tests
+ describe('Props', () => {
+ it('should render expand icon when isCollapsed is true', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert - RiLineHeight icon for expand
+ const icon = container.querySelector('.h-4.w-4')
+ expect(icon).toBeInTheDocument()
+ })
+
+ it('should render collapse icon when isCollapsed is false', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert - Collapse icon
+ const icon = container.querySelector('.h-4.w-4')
+ expect(icon).toBeInTheDocument()
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call toggleCollapsed when button is clicked', () => {
+ // Arrange
+ const mockToggle = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByRole('button'))
+
+ // Assert
+ expect(mockToggle).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call toggleCollapsed on multiple clicks', () => {
+ // Arrange
+ const mockToggle = vi.fn()
+ render()
+
+ // Act
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+ fireEvent.click(button)
+ fireEvent.click(button)
+
+ // Assert
+ expect(mockToggle).toHaveBeenCalledTimes(3)
+ })
+ })
+
+ // Tooltip tests
+ describe('Tooltip', () => {
+ it('should render with tooltip wrapper', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert - Tooltip renders a wrapper around button
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should toggle icon when isCollapsed prop changes', () => {
+ // Arrange
+ const { rerender, container } = render(
+ ,
+ )
+
+ // Act
+ rerender()
+
+ // Assert - icon should still be present
+ const icon = container.querySelector('.h-4.w-4')
+ expect(icon).toBeInTheDocument()
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender } = render(
+ ,
+ )
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/new-child-segment.spec.tsx b/web/app/components/datasets/documents/detail/completed/new-child-segment.spec.tsx
new file mode 100644
index 0000000000..49f07014ab
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/new-child-segment.spec.tsx
@@ -0,0 +1,377 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import NewChildSegmentModal from './new-child-segment'
+
+// Mock next/navigation
+vi.mock('next/navigation', () => ({
+ useParams: () => ({
+ datasetId: 'test-dataset-id',
+ documentId: 'test-document-id',
+ }),
+}))
+
+// Mock ToastContext
+const mockNotify = vi.fn()
+vi.mock('use-context-selector', async (importOriginal) => {
+ const actual = await importOriginal() as Record
+ return {
+ ...actual,
+ useContext: () => ({ notify: mockNotify }),
+ }
+})
+
+// Mock document context
+let mockParentMode = 'paragraph'
+vi.mock('../context', () => ({
+ useDocumentContext: (selector: (state: { parentMode: string }) => unknown) => {
+ return selector({ parentMode: mockParentMode })
+ },
+}))
+
+// Mock segment list context
+let mockFullScreen = false
+const mockToggleFullScreen = vi.fn()
+vi.mock('./index', () => ({
+ useSegmentListContext: (selector: (state: { fullScreen: boolean, toggleFullScreen: () => void }) => unknown) => {
+ const state = {
+ fullScreen: mockFullScreen,
+ toggleFullScreen: mockToggleFullScreen,
+ }
+ return selector(state)
+ },
+}))
+
+// Mock useAddChildSegment
+const mockAddChildSegment = vi.fn()
+vi.mock('@/service/knowledge/use-segment', () => ({
+ useAddChildSegment: () => ({
+ mutateAsync: mockAddChildSegment,
+ }),
+}))
+
+// Mock app store
+vi.mock('@/app/components/app/store', () => ({
+ useStore: () => ({ appSidebarExpand: 'expand' }),
+}))
+
+// Mock child components
+vi.mock('./common/action-buttons', () => ({
+ default: ({ handleCancel, handleSave, loading, actionType, isChildChunk }: { handleCancel: () => void, handleSave: () => void, loading: boolean, actionType: string, isChildChunk?: boolean }) => (
+
+
+
+ {actionType}
+ {isChildChunk ? 'true' : 'false'}
+
+ ),
+}))
+
+vi.mock('./common/add-another', () => ({
+ default: ({ isChecked, onCheck, className }: { isChecked: boolean, onCheck: () => void, className?: string }) => (
+
+
+
+ ),
+}))
+
+vi.mock('./common/chunk-content', () => ({
+ default: ({ question, onQuestionChange, isEditMode }: { question: string, onQuestionChange: (v: string) => void, isEditMode: boolean }) => (
+
+ onQuestionChange(e.target.value)}
+ />
+ {isEditMode ? 'editing' : 'viewing'}
+
+ ),
+}))
+
+vi.mock('./common/dot', () => ({
+ default: () => •,
+}))
+
+vi.mock('./common/segment-index-tag', () => ({
+ SegmentIndexTag: ({ label }: { label: string }) => {label},
+}))
+
+describe('NewChildSegmentModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockFullScreen = false
+ mockParentMode = 'paragraph'
+ })
+
+ const defaultProps = {
+ chunkId: 'chunk-1',
+ onCancel: vi.fn(),
+ onSave: vi.fn(),
+ viewNewlyAddedChildChunk: vi.fn(),
+ }
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render add child chunk title', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/segment\.addChildChunk/i)).toBeInTheDocument()
+ })
+
+ it('should render chunk content component', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('chunk-content')).toBeInTheDocument()
+ })
+
+ it('should render segment index tag with new child chunk label', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('segment-index-tag')).toBeInTheDocument()
+ })
+
+ it('should render add another checkbox', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('add-another')).toBeInTheDocument()
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call onCancel when close button is clicked', () => {
+ // Arrange
+ const mockOnCancel = vi.fn()
+ const { container } = render(
+ ,
+ )
+
+ // Act
+ const closeButtons = container.querySelectorAll('.cursor-pointer')
+ if (closeButtons.length > 1)
+ fireEvent.click(closeButtons[1])
+
+ // Assert
+ expect(mockOnCancel).toHaveBeenCalled()
+ })
+
+ it('should call toggleFullScreen when expand button is clicked', () => {
+ // Arrange
+ const { container } = render()
+
+ // Act
+ const expandButtons = container.querySelectorAll('.cursor-pointer')
+ if (expandButtons.length > 0)
+ fireEvent.click(expandButtons[0])
+
+ // Assert
+ expect(mockToggleFullScreen).toHaveBeenCalled()
+ })
+
+ it('should update content when input changes', () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.change(screen.getByTestId('content-input'), {
+ target: { value: 'New content' },
+ })
+
+ // Assert
+ expect(screen.getByTestId('content-input')).toHaveValue('New content')
+ })
+
+ it('should toggle add another checkbox', () => {
+ // Arrange
+ render()
+ const checkbox = screen.getByTestId('add-another-checkbox')
+
+ // Act
+ fireEvent.click(checkbox)
+
+ // Assert
+ expect(checkbox).toBeInTheDocument()
+ })
+ })
+
+ // Save validation
+ describe('Save Validation', () => {
+ it('should show error when content is empty', async () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('save-btn'))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'error',
+ }),
+ )
+ })
+ })
+ })
+
+ // Successful save
+ describe('Successful Save', () => {
+ it('should call addChildSegment when valid content is provided', async () => {
+ // Arrange
+ mockAddChildSegment.mockImplementation((_params, options) => {
+ options.onSuccess({ data: { id: 'new-child-id' } })
+ options.onSettled()
+ return Promise.resolve()
+ })
+
+ render()
+ fireEvent.change(screen.getByTestId('content-input'), {
+ target: { value: 'Valid content' },
+ })
+
+ // Act
+ fireEvent.click(screen.getByTestId('save-btn'))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockAddChildSegment).toHaveBeenCalledWith(
+ expect.objectContaining({
+ datasetId: 'test-dataset-id',
+ documentId: 'test-document-id',
+ segmentId: 'chunk-1',
+ body: expect.objectContaining({
+ content: 'Valid content',
+ }),
+ }),
+ expect.any(Object),
+ )
+ })
+ })
+
+ it('should show success notification after save', async () => {
+ // Arrange
+ mockAddChildSegment.mockImplementation((_params, options) => {
+ options.onSuccess({ data: { id: 'new-child-id' } })
+ options.onSettled()
+ return Promise.resolve()
+ })
+
+ render()
+ fireEvent.change(screen.getByTestId('content-input'), {
+ target: { value: 'Valid content' },
+ })
+
+ // Act
+ fireEvent.click(screen.getByTestId('save-btn'))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'success',
+ }),
+ )
+ })
+ })
+ })
+
+ // Full screen mode
+ describe('Full Screen Mode', () => {
+ it('should show action buttons in header when fullScreen', () => {
+ // Arrange
+ mockFullScreen = true
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('action-buttons')).toBeInTheDocument()
+ })
+
+ it('should show add another in header when fullScreen', () => {
+ // Arrange
+ mockFullScreen = true
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('add-another')).toBeInTheDocument()
+ })
+ })
+
+ // Props
+ describe('Props', () => {
+ it('should pass actionType add to ActionButtons', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('action-type')).toHaveTextContent('add')
+ })
+
+ it('should pass isChildChunk true to ActionButtons', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('is-child-chunk')).toHaveTextContent('true')
+ })
+
+ it('should pass isEditMode true to ChunkContent', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('edit-mode')).toHaveTextContent('editing')
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle undefined viewNewlyAddedChildChunk', () => {
+ // Arrange
+ const props = { ...defaultProps, viewNewlyAddedChildChunk: undefined }
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender } = render()
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(screen.getByTestId('chunk-content')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/segment-card/chunk-content.spec.tsx b/web/app/components/datasets/documents/detail/completed/segment-card/chunk-content.spec.tsx
new file mode 100644
index 0000000000..e0b1197ab0
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/segment-card/chunk-content.spec.tsx
@@ -0,0 +1,269 @@
+import { render, screen } from '@testing-library/react'
+import { noop } from 'es-toolkit/function'
+import { createContext, useContextSelector } from 'use-context-selector'
+import { describe, expect, it, vi } from 'vitest'
+
+import ChunkContent from './chunk-content'
+
+// Create mock context matching the actual SegmentListContextValue
+type SegmentListContextValue = {
+ isCollapsed: boolean
+ fullScreen: boolean
+ toggleFullScreen: (fullscreen?: boolean) => void
+ currSegment: { showModal: boolean }
+ currChildChunk: { showModal: boolean }
+}
+
+const MockSegmentListContext = createContext({
+ isCollapsed: true,
+ fullScreen: false,
+ toggleFullScreen: noop,
+ currSegment: { showModal: false },
+ currChildChunk: { showModal: false },
+})
+
+// Mock the context module
+vi.mock('..', () => ({
+ useSegmentListContext: (selector: (value: SegmentListContextValue) => unknown) => {
+ return useContextSelector(MockSegmentListContext, selector)
+ },
+}))
+
+// Helper to create wrapper with context
+const createWrapper = (isCollapsed: boolean = true) => {
+ return ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ )
+}
+
+describe('ChunkContent', () => {
+ const defaultDetail = {
+ content: 'Test content',
+ sign_content: 'Test sign content',
+ }
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render content in non-QA mode', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Assert - should render without Q and A labels
+ expect(container.textContent).not.toContain('Q')
+ expect(container.textContent).not.toContain('A')
+ })
+ })
+
+ // QA mode tests
+ describe('QA Mode', () => {
+ it('should render Q and A labels when answer is present', () => {
+ // Arrange
+ const qaDetail = {
+ content: 'Question content',
+ sign_content: 'Sign content',
+ answer: 'Answer content',
+ }
+
+ // Act
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Assert
+ expect(screen.getByText('Q')).toBeInTheDocument()
+ expect(screen.getByText('A')).toBeInTheDocument()
+ })
+
+ it('should not render Q and A labels when answer is undefined', () => {
+ // Arrange & Act
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Assert
+ expect(screen.queryByText('Q')).not.toBeInTheDocument()
+ expect(screen.queryByText('A')).not.toBeInTheDocument()
+ })
+ })
+
+ // Props tests
+ describe('Props', () => {
+ it('should apply custom className', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Assert
+ expect(container.querySelector('.custom-class')).toBeInTheDocument()
+ })
+
+ it('should handle isFullDocMode=true', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Assert - should have line-clamp-3 class
+ expect(container.querySelector('.line-clamp-3')).toBeInTheDocument()
+ })
+
+ it('should handle isFullDocMode=false with isCollapsed=true', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ { wrapper: createWrapper(true) },
+ )
+
+ // Assert - should have line-clamp-2 class
+ expect(container.querySelector('.line-clamp-2')).toBeInTheDocument()
+ })
+
+ it('should handle isFullDocMode=false with isCollapsed=false', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ { wrapper: createWrapper(false) },
+ )
+
+ // Assert - should have line-clamp-20 class
+ expect(container.querySelector('.line-clamp-20')).toBeInTheDocument()
+ })
+ })
+
+ // Content priority tests
+ describe('Content Priority', () => {
+ it('should prefer sign_content over content when both exist', () => {
+ // Arrange
+ const detail = {
+ content: 'Regular content',
+ sign_content: 'Sign content',
+ }
+
+ // Act
+ const { container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Assert - The component uses sign_content || content
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should use content when sign_content is empty', () => {
+ // Arrange
+ const detail = {
+ content: 'Regular content',
+ sign_content: '',
+ }
+
+ // Act
+ const { container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle empty content', () => {
+ // Arrange
+ const emptyDetail = {
+ content: '',
+ sign_content: '',
+ }
+
+ // Act
+ const { container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should handle empty answer in QA mode', () => {
+ // Arrange
+ const qaDetail = {
+ content: 'Question',
+ sign_content: '',
+ answer: '',
+ }
+
+ // Act - empty answer is falsy, so QA mode won't render
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Assert - should not show Q and A labels since answer is empty string (falsy)
+ expect(screen.queryByText('Q')).not.toBeInTheDocument()
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender, container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Act
+ rerender(
+
+
+ ,
+ )
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/segment-detail.spec.tsx b/web/app/components/datasets/documents/detail/completed/segment-detail.spec.tsx
new file mode 100644
index 0000000000..be4b2b0a0e
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/segment-detail.spec.tsx
@@ -0,0 +1,507 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { IndexingType } from '@/app/components/datasets/create/step-two'
+import { ChunkingMode } from '@/models/datasets'
+
+import SegmentDetail from './segment-detail'
+
+// Mock dataset detail context
+let mockIndexingTechnique = IndexingType.QUALIFIED
+let mockRuntimeMode = 'general'
+vi.mock('@/context/dataset-detail', () => ({
+ useDatasetDetailContextWithSelector: (selector: (state: { dataset: { indexing_technique: string, runtime_mode: string } }) => unknown) => {
+ return selector({
+ dataset: {
+ indexing_technique: mockIndexingTechnique,
+ runtime_mode: mockRuntimeMode,
+ },
+ })
+ },
+}))
+
+// Mock document context
+let mockParentMode = 'paragraph'
+vi.mock('../context', () => ({
+ useDocumentContext: (selector: (state: { parentMode: string }) => unknown) => {
+ return selector({ parentMode: mockParentMode })
+ },
+}))
+
+// Mock segment list context
+let mockFullScreen = false
+const mockToggleFullScreen = vi.fn()
+vi.mock('./index', () => ({
+ useSegmentListContext: (selector: (state: { fullScreen: boolean, toggleFullScreen: () => void }) => unknown) => {
+ const state = {
+ fullScreen: mockFullScreen,
+ toggleFullScreen: mockToggleFullScreen,
+ }
+ return selector(state)
+ },
+}))
+
+// Mock event emitter context
+vi.mock('@/context/event-emitter', () => ({
+ useEventEmitterContextContext: () => ({
+ eventEmitter: {
+ useSubscription: vi.fn(),
+ },
+ }),
+}))
+
+// Mock child components
+vi.mock('./common/action-buttons', () => ({
+ default: ({ handleCancel, handleSave, handleRegeneration, loading, showRegenerationButton }: { handleCancel: () => void, handleSave: () => void, handleRegeneration?: () => void, loading: boolean, showRegenerationButton?: boolean }) => (
+
+
+
+ {showRegenerationButton && (
+
+ )}
+
+ ),
+}))
+
+vi.mock('./common/chunk-content', () => ({
+ default: ({ docForm, question, answer, onQuestionChange, onAnswerChange, isEditMode }: { docForm: string, question: string, answer: string, onQuestionChange: (v: string) => void, onAnswerChange: (v: string) => void, isEditMode: boolean }) => (
+
+ onQuestionChange(e.target.value)}
+ />
+ {docForm === ChunkingMode.qa && (
+ onAnswerChange(e.target.value)}
+ />
+ )}
+ {isEditMode ? 'editing' : 'viewing'}
+
+ ),
+}))
+
+vi.mock('./common/dot', () => ({
+ default: () => •,
+}))
+
+vi.mock('./common/keywords', () => ({
+ default: ({ keywords, onKeywordsChange, _isEditMode, actionType }: { keywords: string[], onKeywordsChange: (v: string[]) => void, _isEditMode?: boolean, actionType: string }) => (
+
+ {actionType}
+ onKeywordsChange(e.target.value.split(',').filter(Boolean))}
+ />
+
+ ),
+}))
+
+vi.mock('./common/segment-index-tag', () => ({
+ SegmentIndexTag: ({ positionId, label, labelPrefix }: { positionId?: string, label?: string, labelPrefix?: string }) => (
+
+ {labelPrefix}
+ {' '}
+ {positionId}
+ {' '}
+ {label}
+
+ ),
+}))
+
+vi.mock('./common/regeneration-modal', () => ({
+ default: ({ isShow, onConfirm, onCancel, onClose }: { isShow: boolean, onConfirm: () => void, onCancel: () => void, onClose: () => void }) => (
+ isShow
+ ? (
+
+
+
+
+
+ )
+ : null
+ ),
+}))
+
+vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-chunk', () => ({
+ default: ({ disabled }: { value?: unknown[], onChange?: (v: unknown[]) => void, disabled?: boolean }) => (
+
+ {disabled ? 'disabled' : 'enabled'}
+
+ ),
+}))
+
+describe('SegmentDetail', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockFullScreen = false
+ mockIndexingTechnique = IndexingType.QUALIFIED
+ mockRuntimeMode = 'general'
+ mockParentMode = 'paragraph'
+ })
+
+ const defaultSegInfo = {
+ id: 'segment-1',
+ content: 'Test content',
+ sign_content: 'Signed content',
+ answer: 'Test answer',
+ position: 1,
+ word_count: 100,
+ keywords: ['keyword1', 'keyword2'],
+ attachments: [],
+ }
+
+ const defaultProps = {
+ segInfo: defaultSegInfo,
+ onUpdate: vi.fn(),
+ onCancel: vi.fn(),
+ isEditMode: false,
+ docForm: ChunkingMode.text,
+ onModalStateChange: vi.fn(),
+ }
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render title for view mode', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/segment\.chunkDetail/i)).toBeInTheDocument()
+ })
+
+ it('should render title for edit mode', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/segment\.editChunk/i)).toBeInTheDocument()
+ })
+
+ it('should render chunk content component', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('chunk-content')).toBeInTheDocument()
+ })
+
+ it('should render image uploader', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('image-uploader')).toBeInTheDocument()
+ })
+
+ it('should render segment index tag', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('segment-index-tag')).toBeInTheDocument()
+ })
+ })
+
+ // Edit mode vs View mode
+ describe('Edit/View Mode', () => {
+ it('should pass isEditMode to ChunkContent', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('edit-mode')).toHaveTextContent('editing')
+ })
+
+ it('should disable image uploader in view mode', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('uploader-disabled')).toHaveTextContent('disabled')
+ })
+
+ it('should enable image uploader in edit mode', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('uploader-disabled')).toHaveTextContent('enabled')
+ })
+
+ it('should show action buttons in edit mode', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('action-buttons')).toBeInTheDocument()
+ })
+
+ it('should not show action buttons in view mode (non-fullscreen)', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.queryByTestId('action-buttons')).not.toBeInTheDocument()
+ })
+ })
+
+ // Keywords display
+ describe('Keywords', () => {
+ it('should show keywords component when indexing is ECONOMICAL', () => {
+ // Arrange
+ mockIndexingTechnique = IndexingType.ECONOMICAL
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('keywords')).toBeInTheDocument()
+ })
+
+ it('should not show keywords when indexing is QUALIFIED', () => {
+ // Arrange
+ mockIndexingTechnique = IndexingType.QUALIFIED
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.queryByTestId('keywords')).not.toBeInTheDocument()
+ })
+
+ it('should pass view action type when not in edit mode', () => {
+ // Arrange
+ mockIndexingTechnique = IndexingType.ECONOMICAL
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('keywords-action')).toHaveTextContent('view')
+ })
+
+ it('should pass edit action type when in edit mode', () => {
+ // Arrange
+ mockIndexingTechnique = IndexingType.ECONOMICAL
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('keywords-action')).toHaveTextContent('edit')
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call onCancel when close button is clicked', () => {
+ // Arrange
+ const mockOnCancel = vi.fn()
+ const { container } = render()
+
+ // Act
+ const closeButtons = container.querySelectorAll('.cursor-pointer')
+ if (closeButtons.length > 1)
+ fireEvent.click(closeButtons[1])
+
+ // Assert
+ expect(mockOnCancel).toHaveBeenCalled()
+ })
+
+ it('should call toggleFullScreen when expand button is clicked', () => {
+ // Arrange
+ const { container } = render()
+
+ // Act
+ const expandButtons = container.querySelectorAll('.cursor-pointer')
+ if (expandButtons.length > 0)
+ fireEvent.click(expandButtons[0])
+
+ // Assert
+ expect(mockToggleFullScreen).toHaveBeenCalled()
+ })
+
+ it('should call onUpdate when save is clicked', () => {
+ // Arrange
+ const mockOnUpdate = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('save-btn'))
+
+ // Assert
+ expect(mockOnUpdate).toHaveBeenCalledWith(
+ 'segment-1',
+ expect.any(String),
+ expect.any(String),
+ expect.any(Array),
+ expect.any(Array),
+ )
+ })
+
+ it('should update question when input changes', () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.change(screen.getByTestId('question-input'), {
+ target: { value: 'Updated content' },
+ })
+
+ // Assert
+ expect(screen.getByTestId('question-input')).toHaveValue('Updated content')
+ })
+ })
+
+ // Regeneration Modal
+ describe('Regeneration Modal', () => {
+ it('should show regeneration button when runtimeMode is general', () => {
+ // Arrange
+ mockRuntimeMode = 'general'
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('regenerate-btn')).toBeInTheDocument()
+ })
+
+ it('should not show regeneration button when runtimeMode is not general', () => {
+ // Arrange
+ mockRuntimeMode = 'pipeline'
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.queryByTestId('regenerate-btn')).not.toBeInTheDocument()
+ })
+
+ it('should show regeneration modal when regenerate is clicked', () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('regenerate-btn'))
+
+ // Assert
+ expect(screen.getByTestId('regeneration-modal')).toBeInTheDocument()
+ })
+
+ it('should call onModalStateChange when regeneration modal opens', () => {
+ // Arrange
+ const mockOnModalStateChange = vi.fn()
+ render(
+ ,
+ )
+
+ // Act
+ fireEvent.click(screen.getByTestId('regenerate-btn'))
+
+ // Assert
+ expect(mockOnModalStateChange).toHaveBeenCalledWith(true)
+ })
+
+ it('should close modal when cancel is clicked', () => {
+ // Arrange
+ const mockOnModalStateChange = vi.fn()
+ render(
+ ,
+ )
+ fireEvent.click(screen.getByTestId('regenerate-btn'))
+
+ // Act
+ fireEvent.click(screen.getByTestId('cancel-regeneration'))
+
+ // Assert
+ expect(mockOnModalStateChange).toHaveBeenCalledWith(false)
+ expect(screen.queryByTestId('regeneration-modal')).not.toBeInTheDocument()
+ })
+ })
+
+ // Full screen mode
+ describe('Full Screen Mode', () => {
+ it('should show action buttons in header when fullScreen and editMode', () => {
+ // Arrange
+ mockFullScreen = true
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('action-buttons')).toBeInTheDocument()
+ })
+
+ it('should apply full screen styling when fullScreen is true', () => {
+ // Arrange
+ mockFullScreen = true
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ const header = container.querySelector('.border-divider-subtle')
+ expect(header).toBeInTheDocument()
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle segInfo with minimal data', () => {
+ // Arrange
+ const minimalSegInfo = {
+ id: 'segment-minimal',
+ position: 1,
+ word_count: 0,
+ }
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should handle empty keywords array', () => {
+ // Arrange
+ mockIndexingTechnique = IndexingType.ECONOMICAL
+ const segInfo = { ...defaultSegInfo, keywords: [] }
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('keywords-input')).toHaveValue('')
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender } = render()
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(screen.getByTestId('action-buttons')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/segment-list.spec.tsx b/web/app/components/datasets/documents/detail/completed/segment-list.spec.tsx
new file mode 100644
index 0000000000..62dc804395
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/segment-list.spec.tsx
@@ -0,0 +1,297 @@
+import type { SegmentDetailModel } from '@/models/datasets'
+import { render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ChunkingMode } from '@/models/datasets'
+
+import SegmentList from './segment-list'
+
+// Mock document context
+let mockDocForm = ChunkingMode.text
+let mockParentMode = 'paragraph'
+vi.mock('../context', () => ({
+ useDocumentContext: (selector: (state: { docForm: ChunkingMode, parentMode: string }) => unknown) => {
+ return selector({
+ docForm: mockDocForm,
+ parentMode: mockParentMode,
+ })
+ },
+}))
+
+// Mock segment list context
+let mockCurrSegment: { segInfo: { id: string } } | null = null
+let mockCurrChildChunk: { childChunkInfo: { segment_id: string } } | null = null
+vi.mock('./index', () => ({
+ useSegmentListContext: (selector: (state: { currSegment: { segInfo: { id: string } } | null, currChildChunk: { childChunkInfo: { segment_id: string } } | null }) => unknown) => {
+ return selector({
+ currSegment: mockCurrSegment,
+ currChildChunk: mockCurrChildChunk,
+ })
+ },
+}))
+
+// Mock child components
+vi.mock('./common/empty', () => ({
+ default: ({ onClearFilter }: { onClearFilter: () => void }) => (
+
+
+
+ ),
+}))
+
+vi.mock('./segment-card', () => ({
+ default: ({ detail, onClick, _onChangeSwitch, archived, embeddingAvailable, focused }: { detail: SegmentDetailModel, onClick: () => void, _onChangeSwitch?: () => void, archived: boolean, embeddingAvailable: boolean, focused: { segmentIndex: boolean, segmentContent: boolean } }) => (
+
+ {detail.content}
+ {archived ? 'true' : 'false'}
+ {embeddingAvailable ? 'true' : 'false'}
+ {focused.segmentIndex ? 'true' : 'false'}
+ {focused.segmentContent ? 'true' : 'false'}
+
+
+ ),
+}))
+
+vi.mock('./skeleton/general-list-skeleton', () => ({
+ default: () => Loading...
,
+}))
+
+vi.mock('./skeleton/paragraph-list-skeleton', () => ({
+ default: () => Loading Paragraph...
,
+}))
+
+describe('SegmentList', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockDocForm = ChunkingMode.text
+ mockParentMode = 'paragraph'
+ mockCurrSegment = null
+ mockCurrChildChunk = null
+ })
+
+ const createMockSegment = (id: string, content: string): SegmentDetailModel => ({
+ id,
+ content,
+ position: 1,
+ word_count: 10,
+ tokens: 5,
+ hit_count: 0,
+ enabled: true,
+ status: 'completed',
+ created_at: Date.now(),
+ updated_at: Date.now(),
+ keywords: [],
+ document_id: 'doc-1',
+ sign_content: content,
+ index_node_id: `index-${id}`,
+ index_node_hash: `hash-${id}`,
+ answer: '',
+ error: null,
+ disabled_at: null,
+ disabled_by: null,
+ } as unknown as SegmentDetailModel)
+
+ const defaultProps = {
+ ref: null,
+ isLoading: false,
+ items: [createMockSegment('seg-1', 'Segment 1 content')],
+ selectedSegmentIds: [],
+ onSelected: vi.fn(),
+ onClick: vi.fn(),
+ onChangeSwitch: vi.fn(),
+ onDelete: vi.fn(),
+ onDeleteChildChunk: vi.fn(),
+ handleAddNewChildChunk: vi.fn(),
+ onClickSlice: vi.fn(),
+ archived: false,
+ embeddingAvailable: true,
+ onClearFilter: vi.fn(),
+ }
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render segment cards for each item', () => {
+ // Arrange
+ const items = [
+ createMockSegment('seg-1', 'Content 1'),
+ createMockSegment('seg-2', 'Content 2'),
+ ]
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getAllByTestId('segment-card')).toHaveLength(2)
+ })
+
+ it('should render empty component when items is empty', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('empty')).toBeInTheDocument()
+ })
+ })
+
+ // Loading state
+ describe('Loading State', () => {
+ it('should render general skeleton when loading and docForm is text', () => {
+ // Arrange
+ mockDocForm = ChunkingMode.text
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('general-skeleton')).toBeInTheDocument()
+ })
+
+ it('should render paragraph skeleton when loading and docForm is parentChild with paragraph mode', () => {
+ // Arrange
+ mockDocForm = ChunkingMode.parentChild
+ mockParentMode = 'paragraph'
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('paragraph-skeleton')).toBeInTheDocument()
+ })
+
+ it('should render general skeleton when loading and docForm is parentChild with full-doc mode', () => {
+ // Arrange
+ mockDocForm = ChunkingMode.parentChild
+ mockParentMode = 'full-doc'
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('general-skeleton')).toBeInTheDocument()
+ })
+ })
+
+ // Props passing
+ describe('Props Passing', () => {
+ it('should pass archived prop to SegmentCard', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('archived')).toHaveTextContent('true')
+ })
+
+ it('should pass embeddingAvailable prop to SegmentCard', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('embedding-available')).toHaveTextContent('false')
+ })
+ })
+
+ // Focused state
+ describe('Focused State', () => {
+ it('should set focused index when currSegment matches', () => {
+ // Arrange
+ mockCurrSegment = { segInfo: { id: 'seg-1' } }
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('focused-index')).toHaveTextContent('true')
+ })
+
+ it('should set focused content when currSegment matches', () => {
+ // Arrange
+ mockCurrSegment = { segInfo: { id: 'seg-1' } }
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('focused-content')).toHaveTextContent('true')
+ })
+
+ it('should set focused when currChildChunk parent matches', () => {
+ // Arrange
+ mockCurrChildChunk = { childChunkInfo: { segment_id: 'seg-1' } }
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('focused-index')).toHaveTextContent('true')
+ })
+ })
+
+ // Clear filter
+ describe('Clear Filter', () => {
+ it('should call onClearFilter when clear filter button is clicked', async () => {
+ // Arrange
+ const mockOnClearFilter = vi.fn()
+ render()
+
+ // Act
+ screen.getByTestId('clear-filter-btn').click()
+
+ // Assert
+ expect(mockOnClearFilter).toHaveBeenCalled()
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle single item without divider', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('segment-card')).toBeInTheDocument()
+ })
+
+ it('should handle multiple items with dividers', () => {
+ // Arrange
+ const items = [
+ createMockSegment('seg-1', 'Content 1'),
+ createMockSegment('seg-2', 'Content 2'),
+ createMockSegment('seg-3', 'Content 3'),
+ ]
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getAllByTestId('segment-card')).toHaveLength(3)
+ })
+
+ it('should maintain structure when rerendered with different items', () => {
+ // Arrange
+ const { rerender } = render(
+ ,
+ )
+
+ // Act
+ rerender(
+ ,
+ )
+
+ // Assert
+ expect(screen.getAllByTestId('segment-card')).toHaveLength(2)
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/skeleton/full-doc-list-skeleton.spec.tsx b/web/app/components/datasets/documents/detail/completed/skeleton/full-doc-list-skeleton.spec.tsx
new file mode 100644
index 0000000000..08ba55cc35
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/skeleton/full-doc-list-skeleton.spec.tsx
@@ -0,0 +1,124 @@
+import { render } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import FullDocListSkeleton from './full-doc-list-skeleton'
+
+describe('FullDocListSkeleton', () => {
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render the correct number of slice elements', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - component renders 15 slices
+ const sliceElements = container.querySelectorAll('.flex.flex-col.gap-y-1')
+ expect(sliceElements).toHaveLength(15)
+ })
+
+ it('should render mask overlay element', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - check for the mask overlay element
+ const maskElement = container.querySelector('.bg-dataset-chunk-list-mask-bg')
+ expect(maskElement).toBeInTheDocument()
+ })
+
+ it('should render with correct container classes', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const containerElement = container.firstChild as HTMLElement
+ expect(containerElement).toHaveClass('relative')
+ expect(containerElement).toHaveClass('z-10')
+ expect(containerElement).toHaveClass('flex')
+ expect(containerElement).toHaveClass('w-full')
+ expect(containerElement).toHaveClass('grow')
+ expect(containerElement).toHaveClass('flex-col')
+ expect(containerElement).toHaveClass('gap-y-3')
+ expect(containerElement).toHaveClass('overflow-y-hidden')
+ })
+ })
+
+ // Structure tests
+ describe('Structure', () => {
+ it('should render slice elements with proper structure', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - each slice should have the content placeholder elements
+ const slices = container.querySelectorAll('.flex.flex-col.gap-y-1')
+ slices.forEach((slice) => {
+ // Each slice should have children for the skeleton content
+ expect(slice.children.length).toBeGreaterThan(0)
+ })
+ })
+
+ it('should render slice with width placeholder elements', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - check for skeleton content width class
+ const widthElements = container.querySelectorAll('.w-2\\/3')
+ expect(widthElements.length).toBeGreaterThan(0)
+ })
+
+ it('should render slice elements with background classes', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - check for skeleton background classes
+ const bgElements = container.querySelectorAll('.bg-state-base-hover')
+ expect(bgElements.length).toBeGreaterThan(0)
+ })
+ })
+
+ // Memoization tests
+ describe('Memoization', () => {
+ it('should render consistently across multiple renders', () => {
+ // Arrange & Act
+ const { container: container1 } = render()
+ const { container: container2 } = render()
+
+ // Assert - structure should be identical
+ const slices1 = container1.querySelectorAll('.flex.flex-col.gap-y-1')
+ const slices2 = container2.querySelectorAll('.flex.flex-col.gap-y-1')
+ expect(slices1.length).toBe(slices2.length)
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should maintain structure when rendered multiple times', () => {
+ // Arrange
+ const { rerender, container } = render()
+
+ // Act
+ rerender()
+ rerender()
+
+ // Assert
+ const sliceElements = container.querySelectorAll('.flex.flex-col.gap-y-1')
+ expect(sliceElements).toHaveLength(15)
+ })
+
+ it('should not have accessibility issues with skeleton content', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - skeleton should be purely visual, no interactive elements
+ const buttons = container.querySelectorAll('button')
+ const links = container.querySelectorAll('a')
+ expect(buttons).toHaveLength(0)
+ expect(links).toHaveLength(0)
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/skeleton/general-list-skeleton.spec.tsx b/web/app/components/datasets/documents/detail/completed/skeleton/general-list-skeleton.spec.tsx
new file mode 100644
index 0000000000..0430724671
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/skeleton/general-list-skeleton.spec.tsx
@@ -0,0 +1,195 @@
+import { render } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import GeneralListSkeleton, { CardSkelton } from './general-list-skeleton'
+
+describe('CardSkelton', () => {
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render skeleton rows', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - component should have skeleton rectangle elements
+ const skeletonRectangles = container.querySelectorAll('.bg-text-quaternary')
+ expect(skeletonRectangles.length).toBeGreaterThan(0)
+ })
+
+ it('should render with proper container padding', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.querySelector('.p-1')).toBeInTheDocument()
+ expect(container.querySelector('.pb-2')).toBeInTheDocument()
+ })
+ })
+
+ // Structure tests
+ describe('Structure', () => {
+ it('should render skeleton points as separators', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - check for opacity class on skeleton points
+ const opacityElements = container.querySelectorAll('.opacity-20')
+ expect(opacityElements.length).toBeGreaterThan(0)
+ })
+
+ it('should render width-constrained skeleton elements', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - check for various width classes
+ expect(container.querySelector('.w-\\[72px\\]')).toBeInTheDocument()
+ expect(container.querySelector('.w-24')).toBeInTheDocument()
+ expect(container.querySelector('.w-full')).toBeInTheDocument()
+ })
+ })
+})
+
+describe('GeneralListSkeleton', () => {
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render the correct number of list items', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - component renders 10 items (Checkbox is a div with shrink-0 and h-4 w-4)
+ const listItems = container.querySelectorAll('.items-start.gap-x-2')
+ expect(listItems).toHaveLength(10)
+ })
+
+ it('should render mask overlay element', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const maskElement = container.querySelector('.bg-dataset-chunk-list-mask-bg')
+ expect(maskElement).toBeInTheDocument()
+ })
+
+ it('should render with correct container classes', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const containerElement = container.firstChild as HTMLElement
+ expect(containerElement).toHaveClass('relative')
+ expect(containerElement).toHaveClass('z-10')
+ expect(containerElement).toHaveClass('flex')
+ expect(containerElement).toHaveClass('grow')
+ expect(containerElement).toHaveClass('flex-col')
+ expect(containerElement).toHaveClass('overflow-y-hidden')
+ })
+ })
+
+ // Checkbox tests
+ describe('Checkboxes', () => {
+ it('should render disabled checkboxes', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - Checkbox component uses cursor-not-allowed class when disabled
+ const disabledCheckboxes = container.querySelectorAll('.cursor-not-allowed')
+ expect(disabledCheckboxes.length).toBeGreaterThan(0)
+ })
+
+ it('should render checkboxes with shrink-0 class for consistent sizing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const checkboxContainers = container.querySelectorAll('.shrink-0')
+ expect(checkboxContainers.length).toBeGreaterThan(0)
+ })
+ })
+
+ // Divider tests
+ describe('Dividers', () => {
+ it('should render dividers between items except for the last one', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - should have 9 dividers (not after last item)
+ const dividers = container.querySelectorAll('.bg-divider-subtle')
+ expect(dividers).toHaveLength(9)
+ })
+ })
+
+ // Structure tests
+ describe('Structure', () => {
+ it('should render list items with proper gap styling', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const listItems = container.querySelectorAll('.gap-x-2')
+ expect(listItems.length).toBeGreaterThan(0)
+ })
+
+ it('should render CardSkelton inside each list item', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - each list item should contain card skeleton content
+ const cardContainers = container.querySelectorAll('.grow')
+ expect(cardContainers.length).toBeGreaterThan(0)
+ })
+ })
+
+ // Memoization tests
+ describe('Memoization', () => {
+ it('should render consistently across multiple renders', () => {
+ // Arrange & Act
+ const { container: container1 } = render()
+ const { container: container2 } = render()
+
+ // Assert
+ const checkboxes1 = container1.querySelectorAll('input[type="checkbox"]')
+ const checkboxes2 = container2.querySelectorAll('input[type="checkbox"]')
+ expect(checkboxes1.length).toBe(checkboxes2.length)
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender, container } = render()
+
+ // Act
+ rerender()
+
+ // Assert
+ const listItems = container.querySelectorAll('.items-start.gap-x-2')
+ expect(listItems).toHaveLength(10)
+ })
+
+ it('should not have interactive elements besides disabled checkboxes', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const buttons = container.querySelectorAll('button')
+ const links = container.querySelectorAll('a')
+ expect(buttons).toHaveLength(0)
+ expect(links).toHaveLength(0)
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/skeleton/paragraph-list-skeleton.spec.tsx b/web/app/components/datasets/documents/detail/completed/skeleton/paragraph-list-skeleton.spec.tsx
new file mode 100644
index 0000000000..a26b357e1e
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/skeleton/paragraph-list-skeleton.spec.tsx
@@ -0,0 +1,151 @@
+import { render } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import ParagraphListSkeleton from './paragraph-list-skeleton'
+
+describe('ParagraphListSkeleton', () => {
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render the correct number of list items', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - component renders 10 items
+ const listItems = container.querySelectorAll('.items-start.gap-x-2')
+ expect(listItems).toHaveLength(10)
+ })
+
+ it('should render mask overlay element', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const maskElement = container.querySelector('.bg-dataset-chunk-list-mask-bg')
+ expect(maskElement).toBeInTheDocument()
+ })
+
+ it('should render with correct container classes', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const containerElement = container.firstChild as HTMLElement
+ expect(containerElement).toHaveClass('relative')
+ expect(containerElement).toHaveClass('z-10')
+ expect(containerElement).toHaveClass('flex')
+ expect(containerElement).toHaveClass('h-full')
+ expect(containerElement).toHaveClass('flex-col')
+ expect(containerElement).toHaveClass('overflow-y-hidden')
+ })
+ })
+
+ // Checkbox tests
+ describe('Checkboxes', () => {
+ it('should render disabled checkboxes', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - Checkbox component uses cursor-not-allowed class when disabled
+ const disabledCheckboxes = container.querySelectorAll('.cursor-not-allowed')
+ expect(disabledCheckboxes.length).toBeGreaterThan(0)
+ })
+
+ it('should render checkboxes with shrink-0 class for consistent sizing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const checkboxContainers = container.querySelectorAll('.shrink-0')
+ expect(checkboxContainers.length).toBeGreaterThan(0)
+ })
+ })
+
+ // Divider tests
+ describe('Dividers', () => {
+ it('should render dividers between items except for the last one', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - should have 9 dividers (not after last item)
+ const dividers = container.querySelectorAll('.bg-divider-subtle')
+ expect(dividers).toHaveLength(9)
+ })
+ })
+
+ // Structure tests
+ describe('Structure', () => {
+ it('should render arrow icon for expand button styling', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - paragraph list skeleton has expand button styled area
+ const expandBtnElements = container.querySelectorAll('.bg-dataset-child-chunk-expand-btn-bg')
+ expect(expandBtnElements.length).toBeGreaterThan(0)
+ })
+
+ it('should render skeleton rectangles with quaternary text color', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const skeletonElements = container.querySelectorAll('.bg-text-quaternary')
+ expect(skeletonElements.length).toBeGreaterThan(0)
+ })
+
+ it('should render CardSkelton inside each list item', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - each list item should contain card skeleton content
+ const cardContainers = container.querySelectorAll('.grow')
+ expect(cardContainers.length).toBeGreaterThan(0)
+ })
+ })
+
+ // Memoization tests
+ describe('Memoization', () => {
+ it('should render consistently across multiple renders', () => {
+ // Arrange & Act
+ const { container: container1 } = render()
+ const { container: container2 } = render()
+
+ // Assert
+ const items1 = container1.querySelectorAll('.items-start.gap-x-2')
+ const items2 = container2.querySelectorAll('.items-start.gap-x-2')
+ expect(items1.length).toBe(items2.length)
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender, container } = render()
+
+ // Act
+ rerender()
+
+ // Assert
+ const listItems = container.querySelectorAll('.items-start.gap-x-2')
+ expect(listItems).toHaveLength(10)
+ })
+
+ it('should not have interactive elements besides disabled checkboxes', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const buttons = container.querySelectorAll('button')
+ const links = container.querySelectorAll('a')
+ expect(buttons).toHaveLength(0)
+ expect(links).toHaveLength(0)
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/skeleton/parent-chunk-card-skeleton.spec.tsx b/web/app/components/datasets/documents/detail/completed/skeleton/parent-chunk-card-skeleton.spec.tsx
new file mode 100644
index 0000000000..71d15a9178
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/skeleton/parent-chunk-card-skeleton.spec.tsx
@@ -0,0 +1,132 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import ParentChunkCardSkelton from './parent-chunk-card-skeleton'
+
+describe('ParentChunkCardSkelton', () => {
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('parent-chunk-card-skeleton')).toBeInTheDocument()
+ })
+
+ it('should render with correct container classes', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ const container = screen.getByTestId('parent-chunk-card-skeleton')
+ expect(container).toHaveClass('flex')
+ expect(container).toHaveClass('flex-col')
+ expect(container).toHaveClass('pb-2')
+ })
+
+ it('should render skeleton rectangles', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const skeletonRectangles = container.querySelectorAll('.bg-text-quaternary')
+ expect(skeletonRectangles.length).toBeGreaterThan(0)
+ })
+ })
+
+ // i18n tests
+ describe('i18n', () => {
+ it('should render view more button with translated text', () => {
+ // Arrange & Act
+ render()
+
+ // Assert - the button should contain translated text
+ const viewMoreButton = screen.getByRole('button')
+ expect(viewMoreButton).toBeInTheDocument()
+ })
+
+ it('should render disabled view more button', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ const viewMoreButton = screen.getByRole('button')
+ expect(viewMoreButton).toBeDisabled()
+ })
+ })
+
+ // Structure tests
+ describe('Structure', () => {
+ it('should render skeleton points as separators', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const opacityElements = container.querySelectorAll('.opacity-20')
+ expect(opacityElements.length).toBeGreaterThan(0)
+ })
+
+ it('should render width-constrained skeleton elements', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - check for various width classes
+ expect(container.querySelector('.w-\\[72px\\]')).toBeInTheDocument()
+ expect(container.querySelector('.w-24')).toBeInTheDocument()
+ expect(container.querySelector('.w-full')).toBeInTheDocument()
+ expect(container.querySelector('.w-2\\/3')).toBeInTheDocument()
+ })
+
+ it('should render button with proper styling classes', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ const button = screen.getByRole('button')
+ expect(button).toHaveClass('system-xs-semibold-uppercase')
+ expect(button).toHaveClass('text-components-button-secondary-accent-text-disabled')
+ })
+ })
+
+ // Memoization tests
+ describe('Memoization', () => {
+ it('should render consistently across multiple renders', () => {
+ // Arrange & Act
+ const { container: container1 } = render()
+ const { container: container2 } = render()
+
+ // Assert
+ const skeletons1 = container1.querySelectorAll('.bg-text-quaternary')
+ const skeletons2 = container2.querySelectorAll('.bg-text-quaternary')
+ expect(skeletons1.length).toBe(skeletons2.length)
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender, container } = render()
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(screen.getByTestId('parent-chunk-card-skeleton')).toBeInTheDocument()
+ const skeletons = container.querySelectorAll('.bg-text-quaternary')
+ expect(skeletons.length).toBeGreaterThan(0)
+ })
+
+ it('should have only one interactive element (disabled button)', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const buttons = container.querySelectorAll('button')
+ const links = container.querySelectorAll('a')
+ expect(buttons).toHaveLength(1)
+ expect(buttons[0]).toBeDisabled()
+ expect(links).toHaveLength(0)
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/status-item.spec.tsx b/web/app/components/datasets/documents/detail/completed/status-item.spec.tsx
new file mode 100644
index 0000000000..a9114ffe79
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/status-item.spec.tsx
@@ -0,0 +1,118 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import StatusItem from './status-item'
+
+describe('StatusItem', () => {
+ const defaultItem = {
+ value: '1',
+ name: 'Test Status',
+ }
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render item name', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('Test Status')).toBeInTheDocument()
+ })
+
+ it('should render with correct styling classes', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('flex')
+ expect(wrapper).toHaveClass('items-center')
+ expect(wrapper).toHaveClass('justify-between')
+ })
+ })
+
+ // Props tests
+ describe('Props', () => {
+ it('should show check icon when selected is true', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - RiCheckLine icon should be present
+ const checkIcon = container.querySelector('.text-text-accent')
+ expect(checkIcon).toBeInTheDocument()
+ })
+
+ it('should not show check icon when selected is false', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - RiCheckLine icon should not be present
+ const checkIcon = container.querySelector('.text-text-accent')
+ expect(checkIcon).not.toBeInTheDocument()
+ })
+
+ it('should render different item names', () => {
+ // Arrange & Act
+ const item = { value: '2', name: 'Different Status' }
+ render()
+
+ // Assert
+ expect(screen.getByText('Different Status')).toBeInTheDocument()
+ })
+ })
+
+ // Memoization tests
+ describe('Memoization', () => {
+ it('should render consistently with same props', () => {
+ // Arrange & Act
+ const { container: container1 } = render()
+ const { container: container2 } = render()
+
+ // Assert
+ expect(container1.textContent).toBe(container2.textContent)
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle empty item name', () => {
+ // Arrange
+ const emptyItem = { value: '1', name: '' }
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should handle special characters in item name', () => {
+ // Arrange
+ const specialItem = { value: '1', name: 'Status <>&"' }
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('Status <>&"')).toBeInTheDocument()
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender } = render()
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(screen.getByText('Test Status')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/document-title.spec.tsx b/web/app/components/datasets/documents/detail/document-title.spec.tsx
new file mode 100644
index 0000000000..dca2d068ec
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/document-title.spec.tsx
@@ -0,0 +1,169 @@
+import { render } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ChunkingMode } from '@/models/datasets'
+
+import { DocumentTitle } from './document-title'
+
+// Mock next/navigation
+const mockPush = vi.fn()
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({
+ push: mockPush,
+ }),
+}))
+
+// Mock DocumentPicker
+vi.mock('../../common/document-picker', () => ({
+ default: ({ datasetId, value, onChange }: { datasetId: string, value: unknown, onChange: (doc: { id: string }) => void }) => (
+ onChange({ id: 'new-doc-id' })}
+ >
+ Document Picker
+
+ ),
+}))
+
+describe('DocumentTitle', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render DocumentPicker component', () => {
+ // Arrange & Act
+ const { getByTestId } = render(
+ ,
+ )
+
+ // Assert
+ expect(getByTestId('document-picker')).toBeInTheDocument()
+ })
+
+ it('should render with correct container classes', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('flex')
+ expect(wrapper).toHaveClass('flex-1')
+ expect(wrapper).toHaveClass('items-center')
+ expect(wrapper).toHaveClass('justify-start')
+ })
+ })
+
+ // Props tests
+ describe('Props', () => {
+ it('should pass datasetId to DocumentPicker', () => {
+ // Arrange & Act
+ const { getByTestId } = render(
+ ,
+ )
+
+ // Assert
+ expect(getByTestId('document-picker').getAttribute('data-dataset-id')).toBe('test-dataset-id')
+ })
+
+ it('should pass value props to DocumentPicker', () => {
+ // Arrange & Act
+ const { getByTestId } = render(
+ ,
+ )
+
+ // Assert
+ const value = JSON.parse(getByTestId('document-picker').getAttribute('data-value') || '{}')
+ expect(value.name).toBe('test-document')
+ expect(value.extension).toBe('pdf')
+ expect(value.chunkingMode).toBe(ChunkingMode.text)
+ expect(value.parentMode).toBe('paragraph')
+ })
+
+ it('should default parentMode to paragraph when parent_mode is undefined', () => {
+ // Arrange & Act
+ const { getByTestId } = render(
+ ,
+ )
+
+ // Assert
+ const value = JSON.parse(getByTestId('document-picker').getAttribute('data-value') || '{}')
+ expect(value.parentMode).toBe('paragraph')
+ })
+
+ it('should apply custom wrapperCls', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('custom-wrapper')
+ })
+ })
+
+ // Navigation tests
+ describe('Navigation', () => {
+ it('should navigate to document page when document is selected', () => {
+ // Arrange
+ const { getByTestId } = render(
+ ,
+ )
+
+ // Act
+ getByTestId('document-picker').click()
+
+ // Assert
+ expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents/new-doc-id')
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle undefined optional props', () => {
+ // Arrange & Act
+ const { getByTestId } = render(
+ ,
+ )
+
+ // Assert
+ const value = JSON.parse(getByTestId('document-picker').getAttribute('data-value') || '{}')
+ expect(value.name).toBeUndefined()
+ expect(value.extension).toBeUndefined()
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender, getByTestId } = render(
+ ,
+ )
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(getByTestId('document-picker').getAttribute('data-dataset-id')).toBe('dataset-2')
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/index.tsx b/web/app/components/datasets/documents/detail/index.tsx
index ea2c453355..e147bf9aba 100644
--- a/web/app/components/datasets/documents/detail/index.tsx
+++ b/web/app/components/datasets/documents/detail/index.tsx
@@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
-import type { DataSourceInfo, FileItem, LegacyDataSourceInfo } from '@/models/datasets'
+import type { DataSourceInfo, FileItem, FullDocumentDetail, LegacyDataSourceInfo } from '@/models/datasets'
import { RiArrowLeftLine, RiLayoutLeft2Line, RiLayoutRight2Line } from '@remixicon/react'
import { useRouter } from 'next/navigation'
import * as React from 'react'
@@ -256,7 +256,7 @@ const DocumentDetail: FC = ({ datasetId, documentId }) => {
className="mr-2 mt-3"
datasetId={datasetId}
documentId={documentId}
- docDetail={{ ...documentDetail, ...documentMetadata, doc_type: documentMetadata?.doc_type === 'others' ? '' : documentMetadata?.doc_type } as any}
+ docDetail={{ ...documentDetail, ...documentMetadata, doc_type: documentMetadata?.doc_type === 'others' ? '' : documentMetadata?.doc_type } as FullDocumentDetail}
/>
diff --git a/web/app/components/datasets/documents/detail/new-segment.spec.tsx b/web/app/components/datasets/documents/detail/new-segment.spec.tsx
new file mode 100644
index 0000000000..7fc94ab80f
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/new-segment.spec.tsx
@@ -0,0 +1,503 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ChunkingMode } from '@/models/datasets'
+import { IndexingType } from '../../create/step-two'
+
+import NewSegmentModal from './new-segment'
+
+// Mock next/navigation
+vi.mock('next/navigation', () => ({
+ useParams: () => ({
+ datasetId: 'test-dataset-id',
+ documentId: 'test-document-id',
+ }),
+}))
+
+// Mock ToastContext
+const mockNotify = vi.fn()
+vi.mock('use-context-selector', async (importOriginal) => {
+ const actual = await importOriginal() as Record
+ return {
+ ...actual,
+ useContext: () => ({ notify: mockNotify }),
+ }
+})
+
+// Mock dataset detail context
+let mockIndexingTechnique = IndexingType.QUALIFIED
+vi.mock('@/context/dataset-detail', () => ({
+ useDatasetDetailContextWithSelector: (selector: (state: { dataset: { indexing_technique: string } }) => unknown) => {
+ return selector({ dataset: { indexing_technique: mockIndexingTechnique } })
+ },
+}))
+
+// Mock segment list context
+let mockFullScreen = false
+const mockToggleFullScreen = vi.fn()
+vi.mock('./completed', () => ({
+ useSegmentListContext: (selector: (state: { fullScreen: boolean, toggleFullScreen: () => void }) => unknown) => {
+ const state = {
+ fullScreen: mockFullScreen,
+ toggleFullScreen: mockToggleFullScreen,
+ }
+ return selector(state)
+ },
+}))
+
+// Mock useAddSegment
+const mockAddSegment = vi.fn()
+vi.mock('@/service/knowledge/use-segment', () => ({
+ useAddSegment: () => ({
+ mutateAsync: mockAddSegment,
+ }),
+}))
+
+// Mock app store
+vi.mock('@/app/components/app/store', () => ({
+ useStore: () => ({ appSidebarExpand: 'expand' }),
+}))
+
+// Mock child components
+vi.mock('./completed/common/action-buttons', () => ({
+ default: ({ handleCancel, handleSave, loading, actionType }: { handleCancel: () => void, handleSave: () => void, loading: boolean, actionType: string }) => (
+
+
+
+ {actionType}
+
+ ),
+}))
+
+vi.mock('./completed/common/add-another', () => ({
+ default: ({ isChecked, onCheck, className }: { isChecked: boolean, onCheck: () => void, className?: string }) => (
+
+
+
+ ),
+}))
+
+vi.mock('./completed/common/chunk-content', () => ({
+ default: ({ docForm, question, answer, onQuestionChange, onAnswerChange, isEditMode }: { docForm: string, question: string, answer: string, onQuestionChange: (v: string) => void, onAnswerChange: (v: string) => void, isEditMode: boolean }) => (
+
+ onQuestionChange(e.target.value)}
+ placeholder={docForm === ChunkingMode.qa ? 'Question' : 'Content'}
+ />
+ {docForm === ChunkingMode.qa && (
+ onAnswerChange(e.target.value)}
+ placeholder="Answer"
+ />
+ )}
+ {isEditMode ? 'editing' : 'viewing'}
+
+ ),
+}))
+
+vi.mock('./completed/common/dot', () => ({
+ default: () => •,
+}))
+
+vi.mock('./completed/common/keywords', () => ({
+ default: ({ keywords, onKeywordsChange, _isEditMode, _actionType }: { keywords: string[], onKeywordsChange: (v: string[]) => void, _isEditMode?: boolean, _actionType?: string }) => (
+
+ onKeywordsChange(e.target.value.split(',').filter(Boolean))}
+ />
+
+ ),
+}))
+
+vi.mock('./completed/common/segment-index-tag', () => ({
+ SegmentIndexTag: ({ label }: { label: string }) => {label},
+}))
+
+vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-chunk', () => ({
+ default: ({ onChange }: { value?: unknown[], onChange: (v: { uploadedId: string }[]) => void }) => (
+
+
+
+ ),
+}))
+
+describe('NewSegmentModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockFullScreen = false
+ mockIndexingTechnique = IndexingType.QUALIFIED
+ })
+
+ const defaultProps = {
+ onCancel: vi.fn(),
+ docForm: ChunkingMode.text,
+ onSave: vi.fn(),
+ viewNewlyAddedChunk: vi.fn(),
+ }
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render title text', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/segment\.addChunk/i)).toBeInTheDocument()
+ })
+
+ it('should render chunk content component', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('chunk-content')).toBeInTheDocument()
+ })
+
+ it('should render image uploader', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('image-uploader')).toBeInTheDocument()
+ })
+
+ it('should render segment index tag', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('segment-index-tag')).toBeInTheDocument()
+ })
+
+ it('should render dot separator', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('dot')).toBeInTheDocument()
+ })
+ })
+
+ // Keywords display
+ describe('Keywords', () => {
+ it('should show keywords component when indexing is ECONOMICAL', () => {
+ // Arrange
+ mockIndexingTechnique = IndexingType.ECONOMICAL
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('keywords')).toBeInTheDocument()
+ })
+
+ it('should not show keywords when indexing is QUALIFIED', () => {
+ // Arrange
+ mockIndexingTechnique = IndexingType.QUALIFIED
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.queryByTestId('keywords')).not.toBeInTheDocument()
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call onCancel when close button is clicked', () => {
+ // Arrange
+ const mockOnCancel = vi.fn()
+ const { container } = render()
+
+ // Act - find and click close button (RiCloseLine icon wrapper)
+ const closeButtons = container.querySelectorAll('.cursor-pointer')
+ // The close button is the second cursor-pointer element
+ if (closeButtons.length > 1)
+ fireEvent.click(closeButtons[1])
+
+ // Assert
+ expect(mockOnCancel).toHaveBeenCalled()
+ })
+
+ it('should update question when typing', () => {
+ // Arrange
+ render()
+ const questionInput = screen.getByTestId('question-input')
+
+ // Act
+ fireEvent.change(questionInput, { target: { value: 'New question content' } })
+
+ // Assert
+ expect(questionInput).toHaveValue('New question content')
+ })
+
+ it('should update answer when docForm is QA and typing', () => {
+ // Arrange
+ render()
+ const answerInput = screen.getByTestId('answer-input')
+
+ // Act
+ fireEvent.change(answerInput, { target: { value: 'New answer content' } })
+
+ // Assert
+ expect(answerInput).toHaveValue('New answer content')
+ })
+
+ it('should toggle add another checkbox', () => {
+ // Arrange
+ render()
+ const checkbox = screen.getByTestId('add-another-checkbox')
+
+ // Act
+ fireEvent.click(checkbox)
+
+ // Assert - checkbox state should toggle
+ expect(checkbox).toBeInTheDocument()
+ })
+ })
+
+ // Save validation
+ describe('Save Validation', () => {
+ it('should show error when content is empty for text mode', async () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('save-btn'))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'error',
+ }),
+ )
+ })
+ })
+
+ it('should show error when question is empty for QA mode', async () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('save-btn'))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'error',
+ }),
+ )
+ })
+ })
+
+ it('should show error when answer is empty for QA mode', async () => {
+ // Arrange
+ render()
+ fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Question' } })
+
+ // Act
+ fireEvent.click(screen.getByTestId('save-btn'))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'error',
+ }),
+ )
+ })
+ })
+ })
+
+ // Successful save
+ describe('Successful Save', () => {
+ it('should call addSegment when valid content is provided for text mode', async () => {
+ // Arrange
+ mockAddSegment.mockImplementation((_params, options) => {
+ options.onSuccess()
+ options.onSettled()
+ return Promise.resolve()
+ })
+
+ render()
+ fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Valid content' } })
+
+ // Act
+ fireEvent.click(screen.getByTestId('save-btn'))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockAddSegment).toHaveBeenCalledWith(
+ expect.objectContaining({
+ datasetId: 'test-dataset-id',
+ documentId: 'test-document-id',
+ body: expect.objectContaining({
+ content: 'Valid content',
+ }),
+ }),
+ expect.any(Object),
+ )
+ })
+ })
+
+ it('should show success notification after save', async () => {
+ // Arrange
+ mockAddSegment.mockImplementation((_params, options) => {
+ options.onSuccess()
+ options.onSettled()
+ return Promise.resolve()
+ })
+
+ render()
+ fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Valid content' } })
+
+ // Act
+ fireEvent.click(screen.getByTestId('save-btn'))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'success',
+ }),
+ )
+ })
+ })
+ })
+
+ // Full screen mode
+ describe('Full Screen Mode', () => {
+ it('should apply full screen styling when fullScreen is true', () => {
+ // Arrange
+ mockFullScreen = true
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ const header = container.querySelector('.border-divider-subtle')
+ expect(header).toBeInTheDocument()
+ })
+
+ it('should show action buttons in header when fullScreen', () => {
+ // Arrange
+ mockFullScreen = true
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('action-buttons')).toBeInTheDocument()
+ })
+
+ it('should show add another in header when fullScreen', () => {
+ // Arrange
+ mockFullScreen = true
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('add-another')).toBeInTheDocument()
+ })
+
+ it('should call toggleFullScreen when expand button is clicked', () => {
+ // Arrange
+ const { container } = render()
+
+ // Act - click the expand button (first cursor-pointer)
+ const expandButtons = container.querySelectorAll('.cursor-pointer')
+ if (expandButtons.length > 0)
+ fireEvent.click(expandButtons[0])
+
+ // Assert
+ expect(mockToggleFullScreen).toHaveBeenCalled()
+ })
+ })
+
+ // Props
+ describe('Props', () => {
+ it('should pass actionType add to ActionButtons', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('action-type')).toHaveTextContent('add')
+ })
+
+ it('should pass isEditMode true to ChunkContent', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('edit-mode')).toHaveTextContent('editing')
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle keyword changes for ECONOMICAL indexing', () => {
+ // Arrange
+ mockIndexingTechnique = IndexingType.ECONOMICAL
+ render()
+
+ // Act
+ fireEvent.change(screen.getByTestId('keywords-input'), {
+ target: { value: 'keyword1,keyword2' },
+ })
+
+ // Assert
+ expect(screen.getByTestId('keywords-input')).toHaveValue('keyword1,keyword2')
+ })
+
+ it('should handle image upload', () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('upload-image-btn'))
+
+ // Assert - image uploader should be rendered
+ expect(screen.getByTestId('image-uploader')).toBeInTheDocument()
+ })
+
+ it('should maintain structure when rerendered with different docForm', () => {
+ // Arrange
+ const { rerender } = render()
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(screen.getByTestId('answer-input')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/segment-add/index.spec.tsx b/web/app/components/datasets/documents/detail/segment-add/index.spec.tsx
new file mode 100644
index 0000000000..c671787320
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/segment-add/index.spec.tsx
@@ -0,0 +1,350 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { Plan } from '@/app/components/billing/type'
+
+import SegmentAdd, { ProcessStatus } from './index'
+
+// Mock provider context
+let mockPlan = { type: Plan.professional }
+let mockEnableBilling = true
+vi.mock('@/context/provider-context', () => ({
+ useProviderContext: () => ({
+ plan: mockPlan,
+ enableBilling: mockEnableBilling,
+ }),
+}))
+
+// Mock PlanUpgradeModal
+vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({
+ default: ({ show, onClose, title, description }: { show: boolean, onClose: () => void, title?: string, description?: string }) => (
+ show
+ ? (
+
+ {title}
+ {description}
+
+
+ )
+ : null
+ ),
+}))
+
+// Mock Popover
+vi.mock('@/app/components/base/popover', () => ({
+ default: ({ htmlContent, btnElement, disabled }: { htmlContent: React.ReactNode, btnElement: React.ReactNode, disabled?: boolean }) => (
+
+
+
{htmlContent}
+
+ ),
+}))
+
+describe('SegmentAdd', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockPlan = { type: Plan.professional }
+ mockEnableBilling = true
+ })
+
+ const defaultProps = {
+ importStatus: undefined as ProcessStatus | string | undefined,
+ clearProcessStatus: vi.fn(),
+ showNewSegmentModal: vi.fn(),
+ showBatchModal: vi.fn(),
+ embedding: false,
+ }
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render add button when no importStatus', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/list\.action\.addButton/i)).toBeInTheDocument()
+ })
+
+ it('should render popover for batch add', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('popover')).toBeInTheDocument()
+ })
+ })
+
+ // Import Status displays
+ describe('Import Status Display', () => {
+ it('should show processing indicator when status is WAITING', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/list\.batchModal\.processing/i)).toBeInTheDocument()
+ })
+
+ it('should show processing indicator when status is PROCESSING', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/list\.batchModal\.processing/i)).toBeInTheDocument()
+ })
+
+ it('should show completed status with ok button', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/list\.batchModal\.completed/i)).toBeInTheDocument()
+ expect(screen.getByText(/list\.batchModal\.ok/i)).toBeInTheDocument()
+ })
+
+ it('should show error status with ok button', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/list\.batchModal\.error/i)).toBeInTheDocument()
+ expect(screen.getByText(/list\.batchModal\.ok/i)).toBeInTheDocument()
+ })
+
+ it('should not show add button when importStatus is set', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.queryByText(/list\.action\.addButton/i)).not.toBeInTheDocument()
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call showNewSegmentModal when add button is clicked', () => {
+ // Arrange
+ const mockShowNewSegmentModal = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByText(/list\.action\.addButton/i))
+
+ // Assert
+ expect(mockShowNewSegmentModal).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call clearProcessStatus when ok is clicked on completed status', () => {
+ // Arrange
+ const mockClearProcessStatus = vi.fn()
+ render(
+ ,
+ )
+
+ // Act
+ fireEvent.click(screen.getByText(/list\.batchModal\.ok/i))
+
+ // Assert
+ expect(mockClearProcessStatus).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call clearProcessStatus when ok is clicked on error status', () => {
+ // Arrange
+ const mockClearProcessStatus = vi.fn()
+ render(
+ ,
+ )
+
+ // Act
+ fireEvent.click(screen.getByText(/list\.batchModal\.ok/i))
+
+ // Assert
+ expect(mockClearProcessStatus).toHaveBeenCalledTimes(1)
+ })
+
+ it('should render batch add option in popover', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/list\.action\.batchAdd/i)).toBeInTheDocument()
+ })
+
+ it('should call showBatchModal when batch add is clicked', () => {
+ // Arrange
+ const mockShowBatchModal = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByText(/list\.action\.batchAdd/i))
+
+ // Assert
+ expect(mockShowBatchModal).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ // Disabled state (embedding)
+ describe('Embedding State', () => {
+ it('should disable add button when embedding is true', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ const addButton = screen.getByText(/list\.action\.addButton/i).closest('button')
+ expect(addButton).toBeDisabled()
+ })
+
+ it('should disable popover button when embedding is true', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('popover-btn')).toBeDisabled()
+ })
+
+ it('should apply disabled styling when embedding is true', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('border-components-button-secondary-border-disabled')
+ })
+ })
+
+ // Plan upgrade modal
+ describe('Plan Upgrade Modal', () => {
+ it('should show plan upgrade modal when sandbox user tries to add', () => {
+ // Arrange
+ mockPlan = { type: Plan.sandbox }
+ render()
+
+ // Act
+ fireEvent.click(screen.getByText(/list\.action\.addButton/i))
+
+ // Assert
+ expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument()
+ })
+
+ it('should not call showNewSegmentModal for sandbox users', () => {
+ // Arrange
+ mockPlan = { type: Plan.sandbox }
+ const mockShowNewSegmentModal = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByText(/list\.action\.addButton/i))
+
+ // Assert
+ expect(mockShowNewSegmentModal).not.toHaveBeenCalled()
+ })
+
+ it('should allow add when billing is disabled regardless of plan', () => {
+ // Arrange
+ mockPlan = { type: Plan.sandbox }
+ mockEnableBilling = false
+ const mockShowNewSegmentModal = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByText(/list\.action\.addButton/i))
+
+ // Assert
+ expect(mockShowNewSegmentModal).toHaveBeenCalledTimes(1)
+ })
+
+ it('should close plan upgrade modal when close button is clicked', () => {
+ // Arrange
+ mockPlan = { type: Plan.sandbox }
+ render()
+
+ // Show modal
+ fireEvent.click(screen.getByText(/list\.action\.addButton/i))
+ expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument()
+
+ // Act
+ fireEvent.click(screen.getByTestId('close-modal'))
+
+ // Assert
+ expect(screen.queryByTestId('plan-upgrade-modal')).not.toBeInTheDocument()
+ })
+ })
+
+ // Progress bar width tests
+ describe('Progress Bar', () => {
+ it('should show 3/12 width progress bar for WAITING status', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const progressBar = container.querySelector('.w-3\\/12')
+ expect(progressBar).toBeInTheDocument()
+ })
+
+ it('should show 2/3 width progress bar for PROCESSING status', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const progressBar = container.querySelector('.w-2\\/3')
+ expect(progressBar).toBeInTheDocument()
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle unknown importStatus string', () => {
+ // Arrange & Act - pass unknown status
+ const { container } = render()
+
+ // Assert - empty fragment is rendered for unknown status (container exists but has no visible content)
+ expect(container).toBeInTheDocument()
+ expect(container.textContent).toBe('')
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender } = render()
+
+ // Act
+ rerender()
+
+ // Assert
+ const addButton = screen.getByText(/list\.action\.addButton/i).closest('button')
+ expect(addButton).toBeDisabled()
+ })
+
+ it('should handle callback change', () => {
+ // Arrange
+ const mockShowNewSegmentModal1 = vi.fn()
+ const mockShowNewSegmentModal2 = vi.fn()
+ const { rerender } = render(
+ ,
+ )
+
+ // Act
+ rerender()
+ fireEvent.click(screen.getByText(/list\.action\.addButton/i))
+
+ // Assert
+ expect(mockShowNewSegmentModal1).not.toHaveBeenCalled()
+ expect(mockShowNewSegmentModal2).toHaveBeenCalledTimes(1)
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/settings/document-settings.spec.tsx b/web/app/components/datasets/documents/detail/settings/document-settings.spec.tsx
new file mode 100644
index 0000000000..545a51bd49
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/settings/document-settings.spec.tsx
@@ -0,0 +1,374 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import DocumentSettings from './document-settings'
+
+// Mock next/navigation
+const mockPush = vi.fn()
+const mockBack = vi.fn()
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({
+ push: mockPush,
+ back: mockBack,
+ }),
+}))
+
+// Mock use-context-selector
+vi.mock('use-context-selector', async (importOriginal) => {
+ const actual = await importOriginal() as Record
+ return {
+ ...actual,
+ useContext: () => ({
+ indexingTechnique: 'qualified',
+ dataset: { id: 'dataset-1' },
+ }),
+ }
+})
+
+// Mock hooks
+const mockInvalidDocumentList = vi.fn()
+const mockInvalidDocumentDetail = vi.fn()
+let mockDocumentDetail: Record | null = {
+ name: 'test-document',
+ data_source_type: 'upload_file',
+ data_source_info: {
+ upload_file: { id: 'file-1', name: 'test.pdf' },
+ },
+}
+let mockError: Error | null = null
+
+vi.mock('@/service/knowledge/use-document', () => ({
+ useDocumentDetail: () => ({
+ data: mockDocumentDetail,
+ error: mockError,
+ }),
+ useInvalidDocumentList: () => mockInvalidDocumentList,
+ useInvalidDocumentDetail: () => mockInvalidDocumentDetail,
+}))
+
+// Mock useDefaultModel
+vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
+ useDefaultModel: () => ({
+ data: { model: 'text-embedding-ada-002' },
+ }),
+}))
+
+// Mock child components
+vi.mock('@/app/components/base/app-unavailable', () => ({
+ default: ({ code, unknownReason }: { code?: number, unknownReason?: string }) => (
+
+ {code}
+ {unknownReason}
+
+ ),
+}))
+
+vi.mock('@/app/components/base/loading', () => ({
+ default: ({ type }: { type?: string }) => (
+ Loading...
+ ),
+}))
+
+vi.mock('@/app/components/datasets/create/step-two', () => ({
+ default: ({
+ isAPIKeySet,
+ onSetting,
+ datasetId,
+ dataSourceType,
+ files,
+ onSave,
+ onCancel,
+ isSetting,
+ }: {
+ isAPIKeySet?: boolean
+ onSetting?: () => void
+ datasetId?: string
+ dataSourceType?: string
+ files?: unknown[]
+ onSave?: () => void
+ onCancel?: () => void
+ isSetting?: boolean
+ }) => (
+
+ {isAPIKeySet ? 'true' : 'false'}
+ {datasetId}
+ {dataSourceType}
+ {isSetting ? 'true' : 'false'}
+ {files?.length || 0}
+
+
+
+
+ ),
+}))
+
+vi.mock('@/app/components/header/account-setting', () => ({
+ default: ({ activeTab, onCancel }: { activeTab?: string, onCancel?: () => void }) => (
+
+ {activeTab}
+
+
+ ),
+}))
+
+describe('DocumentSettings', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockDocumentDetail = {
+ name: 'test-document',
+ data_source_type: 'upload_file',
+ data_source_info: {
+ upload_file: { id: 'file-1', name: 'test.pdf' },
+ },
+ }
+ mockError = null
+ })
+
+ const defaultProps = {
+ datasetId: 'dataset-1',
+ documentId: 'document-1',
+ }
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render StepTwo component when data is loaded', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('step-two')).toBeInTheDocument()
+ })
+
+ it('should render loading when documentDetail is not available', () => {
+ // Arrange
+ mockDocumentDetail = null
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('loading')).toBeInTheDocument()
+ })
+
+ it('should render AppUnavailable when error occurs', () => {
+ // Arrange
+ mockError = new Error('Error loading document')
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('app-unavailable')).toBeInTheDocument()
+ expect(screen.getByTestId('error-code')).toHaveTextContent('500')
+ })
+ })
+
+ // Props passing
+ describe('Props Passing', () => {
+ it('should pass datasetId to StepTwo', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('dataset-id')).toHaveTextContent('dataset-1')
+ })
+
+ it('should pass isSetting true to StepTwo', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('is-setting')).toHaveTextContent('true')
+ })
+
+ it('should pass isAPIKeySet when embedding model is available', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('api-key-set')).toHaveTextContent('true')
+ })
+
+ it('should pass data source type to StepTwo', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('data-source-type')).toHaveTextContent('upload_file')
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call router.back when cancel is clicked', () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('cancel-btn'))
+
+ // Assert
+ expect(mockBack).toHaveBeenCalled()
+ })
+
+ it('should navigate to document page when save is clicked', () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('save-btn'))
+
+ // Assert
+ expect(mockInvalidDocumentList).toHaveBeenCalled()
+ expect(mockInvalidDocumentDetail).toHaveBeenCalled()
+ expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents/document-1')
+ })
+
+ it('should show AccountSetting modal when setting button is clicked', () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('setting-btn'))
+
+ // Assert
+ expect(screen.getByTestId('account-setting')).toBeInTheDocument()
+ })
+
+ it('should hide AccountSetting modal when close is clicked', async () => {
+ // Arrange
+ render()
+ fireEvent.click(screen.getByTestId('setting-btn'))
+ expect(screen.getByTestId('account-setting')).toBeInTheDocument()
+
+ // Act
+ fireEvent.click(screen.getByTestId('close-setting'))
+
+ // Assert
+ expect(screen.queryByTestId('account-setting')).not.toBeInTheDocument()
+ })
+ })
+
+ // Data source types
+ describe('Data Source Types', () => {
+ it('should handle legacy upload_file data source', () => {
+ // Arrange
+ mockDocumentDetail = {
+ name: 'test-document',
+ data_source_type: 'upload_file',
+ data_source_info: {
+ upload_file: { id: 'file-1', name: 'test.pdf' },
+ },
+ }
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('files-count')).toHaveTextContent('1')
+ })
+
+ it('should handle website crawl data source', () => {
+ // Arrange
+ mockDocumentDetail = {
+ name: 'test-website',
+ data_source_type: 'website_crawl',
+ data_source_info: {
+ title: 'Test Page',
+ source_url: 'https://example.com',
+ content: 'Page content',
+ description: 'Page description',
+ },
+ }
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('data-source-type')).toHaveTextContent('website_crawl')
+ })
+
+ it('should handle local file data source', () => {
+ // Arrange
+ mockDocumentDetail = {
+ name: 'local-file',
+ data_source_type: 'upload_file',
+ data_source_info: {
+ related_id: 'file-id',
+ transfer_method: 'local',
+ name: 'local-file.pdf',
+ extension: 'pdf',
+ },
+ }
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('files-count')).toHaveTextContent('1')
+ })
+
+ it('should handle online document (Notion) data source', () => {
+ // Arrange
+ mockDocumentDetail = {
+ name: 'notion-page',
+ data_source_type: 'notion_import',
+ data_source_info: {
+ workspace_id: 'ws-1',
+ credential_id: 'cred-1',
+ page: {
+ page_id: 'page-1',
+ page_name: 'Test Page',
+ page_icon: '📄',
+ type: 'page',
+ },
+ },
+ }
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('data-source-type')).toHaveTextContent('notion_import')
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle undefined data_source_info', () => {
+ // Arrange
+ mockDocumentDetail = {
+ name: 'test-document',
+ data_source_type: 'upload_file',
+ data_source_info: undefined,
+ }
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('files-count')).toHaveTextContent('0')
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender } = render(
+ ,
+ )
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(screen.getByTestId('step-two')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/settings/index.spec.tsx b/web/app/components/datasets/documents/detail/settings/index.spec.tsx
new file mode 100644
index 0000000000..3a7c10a0be
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/settings/index.spec.tsx
@@ -0,0 +1,143 @@
+import { render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import Settings from './index'
+
+// Mock the dataset detail context
+let mockRuntimeMode: string | undefined = 'general'
+vi.mock('@/context/dataset-detail', () => ({
+ useDatasetDetailContextWithSelector: (selector: (state: { dataset: { runtime_mode: string | undefined } }) => unknown) => {
+ return selector({ dataset: { runtime_mode: mockRuntimeMode } })
+ },
+}))
+
+// Mock child components
+vi.mock('./document-settings', () => ({
+ default: ({ datasetId, documentId }: { datasetId: string, documentId: string }) => (
+
+ DocumentSettings -
+ {' '}
+ {datasetId}
+ {' '}
+ -
+ {' '}
+ {documentId}
+
+ ),
+}))
+
+vi.mock('./pipeline-settings', () => ({
+ default: ({ datasetId, documentId }: { datasetId: string, documentId: string }) => (
+
+ PipelineSettings -
+ {' '}
+ {datasetId}
+ {' '}
+ -
+ {' '}
+ {documentId}
+
+ ),
+}))
+
+describe('Settings', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockRuntimeMode = 'general'
+ })
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+
+ // Conditional rendering tests
+ describe('Conditional Rendering', () => {
+ it('should render DocumentSettings when runtimeMode is general', () => {
+ // Arrange
+ mockRuntimeMode = 'general'
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('document-settings')).toBeInTheDocument()
+ expect(screen.queryByTestId('pipeline-settings')).not.toBeInTheDocument()
+ })
+
+ it('should render PipelineSettings when runtimeMode is not general', () => {
+ // Arrange
+ mockRuntimeMode = 'pipeline'
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('pipeline-settings')).toBeInTheDocument()
+ expect(screen.queryByTestId('document-settings')).not.toBeInTheDocument()
+ })
+ })
+
+ // Props passing tests
+ describe('Props', () => {
+ it('should pass datasetId and documentId to DocumentSettings', () => {
+ // Arrange
+ mockRuntimeMode = 'general'
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/test-dataset/)).toBeInTheDocument()
+ expect(screen.getByText(/test-document/)).toBeInTheDocument()
+ })
+
+ it('should pass datasetId and documentId to PipelineSettings', () => {
+ // Arrange
+ mockRuntimeMode = 'pipeline'
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/test-dataset/)).toBeInTheDocument()
+ expect(screen.getByText(/test-document/)).toBeInTheDocument()
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle undefined runtimeMode as non-general', () => {
+ // Arrange
+ mockRuntimeMode = undefined
+
+ // Act
+ render()
+
+ // Assert - undefined !== 'general', so PipelineSettings should render
+ expect(screen.getByTestId('pipeline-settings')).toBeInTheDocument()
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ mockRuntimeMode = 'general'
+ const { rerender } = render(
+ ,
+ )
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(screen.getByText(/dataset-2/)).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.spec.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.spec.tsx
new file mode 100644
index 0000000000..208b3b3955
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.spec.tsx
@@ -0,0 +1,154 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import LeftHeader from './left-header'
+
+// Mock next/navigation
+const mockBack = vi.fn()
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({
+ back: mockBack,
+ }),
+}))
+
+describe('LeftHeader', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render the title', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('My Document Title')).toBeInTheDocument()
+ })
+
+ it('should render the process documents text', () => {
+ // Arrange & Act
+ render()
+
+ // Assert - i18n key format
+ expect(screen.getByText(/addDocuments\.steps\.processDocuments/i)).toBeInTheDocument()
+ })
+
+ it('should render back button', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ const backButton = screen.getByRole('button')
+ expect(backButton).toBeInTheDocument()
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call router.back when back button is clicked', () => {
+ // Arrange
+ render()
+
+ // Act
+ const backButton = screen.getByRole('button')
+ fireEvent.click(backButton)
+
+ // Assert
+ expect(mockBack).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call router.back multiple times on multiple clicks', () => {
+ // Arrange
+ render()
+
+ // Act
+ const backButton = screen.getByRole('button')
+ fireEvent.click(backButton)
+ fireEvent.click(backButton)
+
+ // Assert
+ expect(mockBack).toHaveBeenCalledTimes(2)
+ })
+ })
+
+ // Props tests
+ describe('Props', () => {
+ it('should render different titles', () => {
+ // Arrange
+ const { rerender } = render()
+ expect(screen.getByText('First Title')).toBeInTheDocument()
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(screen.getByText('Second Title')).toBeInTheDocument()
+ })
+ })
+
+ // Styling tests
+ describe('Styling', () => {
+ it('should have back button with proper styling', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ const backButton = screen.getByRole('button')
+ expect(backButton).toHaveClass('absolute')
+ expect(backButton).toHaveClass('rounded-full')
+ })
+
+ it('should render title with gradient background styling', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const titleElement = container.querySelector('.bg-pipeline-add-documents-title-bg')
+ expect(titleElement).toBeInTheDocument()
+ })
+ })
+
+ // Accessibility tests
+ describe('Accessibility', () => {
+ it('should have aria-label on back button', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ const backButton = screen.getByRole('button')
+ expect(backButton).toHaveAttribute('aria-label')
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle empty title', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender } = render()
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(screen.getByText('Updated Test')).toBeInTheDocument()
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/actions.spec.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/actions.spec.tsx
new file mode 100644
index 0000000000..67c935a7b8
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/actions.spec.tsx
@@ -0,0 +1,158 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import Actions from './actions'
+
+describe('Actions', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render save and process button', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should render button with translated text', () => {
+ // Arrange & Act
+ render()
+
+ // Assert - i18n key format
+ expect(screen.getByText(/operations\.saveAndProcess/i)).toBeInTheDocument()
+ })
+
+ it('should render with correct container styling', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('flex')
+ expect(wrapper).toHaveClass('items-center')
+ expect(wrapper).toHaveClass('justify-end')
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call onProcess when button is clicked', () => {
+ // Arrange
+ const mockOnProcess = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByRole('button'))
+
+ // Assert
+ expect(mockOnProcess).toHaveBeenCalledTimes(1)
+ })
+
+ it('should not call onProcess when button is disabled', () => {
+ // Arrange
+ const mockOnProcess = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByRole('button'))
+
+ // Assert
+ expect(mockOnProcess).not.toHaveBeenCalled()
+ })
+ })
+
+ // Props tests
+ describe('Props', () => {
+ it('should disable button when runDisabled is true', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByRole('button')).toBeDisabled()
+ })
+
+ it('should enable button when runDisabled is false', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByRole('button')).not.toBeDisabled()
+ })
+
+ it('should enable button when runDisabled is undefined (default)', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByRole('button')).not.toBeDisabled()
+ })
+ })
+
+ // Button variant tests
+ describe('Button Styling', () => {
+ it('should render button with primary variant', () => {
+ // Arrange & Act
+ render()
+
+ // Assert - primary variant buttons have specific classes
+ const button = screen.getByRole('button')
+ expect(button).toBeInTheDocument()
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle multiple rapid clicks', () => {
+ // Arrange
+ const mockOnProcess = vi.fn()
+ render()
+
+ // Act
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+ fireEvent.click(button)
+ fireEvent.click(button)
+
+ // Assert
+ expect(mockOnProcess).toHaveBeenCalledTimes(3)
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const mockOnProcess = vi.fn()
+ const { rerender } = render()
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(screen.getByRole('button')).toBeDisabled()
+ })
+
+ it('should handle callback change', () => {
+ // Arrange
+ const mockOnProcess1 = vi.fn()
+ const mockOnProcess2 = vi.fn()
+ const { rerender } = render()
+
+ // Act
+ rerender()
+ fireEvent.click(screen.getByRole('button'))
+
+ // Assert
+ expect(mockOnProcess1).not.toHaveBeenCalled()
+ expect(mockOnProcess2).toHaveBeenCalledTimes(1)
+ })
+ })
+})
diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json
index 755ea07f56..d1259e358e 100644
--- a/web/eslint-suppressions.json
+++ b/web/eslint-suppressions.json
@@ -1818,11 +1818,6 @@
"count": 1
}
},
- "app/components/datasets/documents/detail/index.tsx": {
- "ts/no-explicit-any": {
- "count": 1
- }
- },
"app/components/datasets/documents/detail/metadata/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 4