From becaa9db0fc3495fbc61cf3cb434bb3d1413de9f Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Tue, 20 Jan 2026 16:13:21 +0800 Subject: [PATCH] test: add unit tests for document title, new segment modal, CSV uploader, and various skeleton components in dataset documents detail --- .../batch-modal/csv-downloader.spec.tsx | 242 +++++++++ .../detail/batch-modal/csv-uploader.spec.tsx | 368 +++++++++++++ .../detail/batch-modal/index.spec.tsx | 213 ++++++++ .../completed/child-segment-detail.spec.tsx | 292 ++++++++++ .../completed/common/action-buttons.spec.tsx | 386 +++++++++++++ .../completed/common/add-another.spec.tsx | 194 +++++++ .../completed/common/batch-action.spec.tsx | 277 ++++++++++ .../completed/common/chunk-content.spec.tsx | 309 +++++++++++ .../detail/completed/common/dot.spec.tsx | 60 +++ .../detail/completed/common/empty.spec.tsx | 153 ++++++ .../common/full-screen-drawer.spec.tsx | 261 +++++++++ .../detail/completed/common/keywords.spec.tsx | 249 +++++++++ .../common/regeneration-modal.spec.tsx | 150 ++++++ .../common/segment-index-tag.spec.tsx | 215 ++++++++ .../detail/completed/common/tag.spec.tsx | 151 ++++++ .../detail/completed/display-toggle.spec.tsx | 130 +++++ .../completed/new-child-segment.spec.tsx | 377 +++++++++++++ .../segment-card/chunk-content.spec.tsx | 269 ++++++++++ .../detail/completed/segment-detail.spec.tsx | 507 ++++++++++++++++++ .../detail/completed/segment-list.spec.tsx | 297 ++++++++++ .../skeleton/full-doc-list-skeleton.spec.tsx | 124 +++++ .../skeleton/general-list-skeleton.spec.tsx | 195 +++++++ .../skeleton/paragraph-list-skeleton.spec.tsx | 151 ++++++ .../parent-chunk-card-skeleton.spec.tsx | 132 +++++ .../detail/completed/status-item.spec.tsx | 118 ++++ .../documents/detail/document-title.spec.tsx | 169 ++++++ .../datasets/documents/detail/index.tsx | 4 +- .../documents/detail/new-segment.spec.tsx | 503 +++++++++++++++++ .../detail/segment-add/index.spec.tsx | 350 ++++++++++++ .../settings/document-settings.spec.tsx | 374 +++++++++++++ .../documents/detail/settings/index.spec.tsx | 143 +++++ .../pipeline-settings/left-header.spec.tsx | 154 ++++++ .../process-documents/actions.spec.tsx | 158 ++++++ web/eslint-suppressions.json | 5 - 34 files changed, 7673 insertions(+), 7 deletions(-) create mode 100644 web/app/components/datasets/documents/detail/batch-modal/csv-downloader.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/batch-modal/csv-uploader.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/batch-modal/index.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/child-segment-detail.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/common/action-buttons.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/common/add-another.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/common/batch-action.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/common/chunk-content.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/common/dot.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/common/empty.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/common/full-screen-drawer.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/common/keywords.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/common/regeneration-modal.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/common/segment-index-tag.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/common/tag.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/display-toggle.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/new-child-segment.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/segment-card/chunk-content.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/segment-detail.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/segment-list.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/skeleton/full-doc-list-skeleton.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/skeleton/general-list-skeleton.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/skeleton/paragraph-list-skeleton.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/skeleton/parent-chunk-card-skeleton.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/status-item.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/document-title.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/new-segment.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/segment-add/index.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/settings/document-settings.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/settings/index.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/actions.spec.tsx 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