diff --git a/web/app/components/datasets/external-api/external-api-modal/Form.spec.tsx b/web/app/components/datasets/external-api/external-api-modal/Form.spec.tsx
new file mode 100644
index 0000000000..346bcd00b7
--- /dev/null
+++ b/web/app/components/datasets/external-api/external-api-modal/Form.spec.tsx
@@ -0,0 +1,239 @@
+import type { CreateExternalAPIReq, FormSchema } from '../declarations'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import Form from './Form'
+
+// Mock context for i18n doc link
+vi.mock('@/context/i18n', () => ({
+ useDocLink: () => (path: string) => `https://docs.example.com${path}`,
+}))
+
+describe('Form', () => {
+ const defaultFormSchemas: FormSchema[] = [
+ {
+ variable: 'name',
+ type: 'text',
+ label: { en_US: 'Name', zh_CN: '名称' },
+ required: true,
+ },
+ {
+ variable: 'endpoint',
+ type: 'text',
+ label: { en_US: 'API Endpoint', zh_CN: 'API 端点' },
+ required: true,
+ },
+ {
+ variable: 'api_key',
+ type: 'secret',
+ label: { en_US: 'API Key', zh_CN: 'API 密钥' },
+ required: true,
+ },
+ ]
+
+ const defaultValue: CreateExternalAPIReq = {
+ name: '',
+ settings: {
+ endpoint: '',
+ api_key: '',
+ },
+ }
+
+ const defaultProps = {
+ value: defaultValue,
+ onChange: vi.fn(),
+ formSchemas: defaultFormSchemas,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const { container } = render(
)
+ expect(container.querySelector('form')).toBeInTheDocument()
+ })
+
+ it('should render all form fields based on formSchemas', () => {
+ render()
+ expect(screen.getByLabelText(/name/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/api endpoint/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/api key/i)).toBeInTheDocument()
+ })
+
+ it('should render required indicator for required fields', () => {
+ render()
+ const labels = screen.getAllByText('*')
+ expect(labels.length).toBe(3) // All 3 fields are required
+ })
+
+ it('should render documentation link for endpoint field', () => {
+ render()
+ const docLink = screen.getByText('dataset.externalAPIPanelDocumentation')
+ expect(docLink).toBeInTheDocument()
+ expect(docLink.closest('a')).toHaveAttribute('href', expect.stringContaining('docs.example.com'))
+ })
+
+ it('should render password type input for secret fields', () => {
+ render()
+ const apiKeyInput = screen.getByLabelText(/api key/i)
+ expect(apiKeyInput).toHaveAttribute('type', 'password')
+ })
+
+ it('should render text type input for text fields', () => {
+ render()
+ const nameInput = screen.getByLabelText(/name/i)
+ expect(nameInput).toHaveAttribute('type', 'text')
+ })
+ })
+
+ describe('Props', () => {
+ it('should apply custom className to form', () => {
+ const { container } = render()
+ expect(container.querySelector('form')).toHaveClass('custom-form-class')
+ })
+
+ it('should apply itemClassName to form items', () => {
+ const { container } = render()
+ const items = container.querySelectorAll('.custom-item-class')
+ expect(items.length).toBe(3)
+ })
+
+ it('should apply fieldLabelClassName to labels', () => {
+ const { container } = render()
+ const labels = container.querySelectorAll('label.custom-label-class')
+ expect(labels.length).toBe(3)
+ })
+
+ it('should apply inputClassName to inputs', () => {
+ render()
+ const inputs = screen.getAllByRole('textbox')
+ inputs.forEach((input) => {
+ expect(input).toHaveClass('custom-input-class')
+ })
+ })
+
+ it('should display initial values', () => {
+ const valueWithData: CreateExternalAPIReq = {
+ name: 'Test API',
+ settings: {
+ endpoint: 'https://api.example.com',
+ api_key: 'secret-key',
+ },
+ }
+ render()
+ expect(screen.getByLabelText(/name/i)).toHaveValue('Test API')
+ expect(screen.getByLabelText(/api endpoint/i)).toHaveValue('https://api.example.com')
+ expect(screen.getByLabelText(/api key/i)).toHaveValue('secret-key')
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onChange when name field changes', () => {
+ const onChange = vi.fn()
+ render()
+
+ const nameInput = screen.getByLabelText(/name/i)
+ fireEvent.change(nameInput, { target: { value: 'New API Name' } })
+
+ expect(onChange).toHaveBeenCalledWith({
+ name: 'New API Name',
+ settings: { endpoint: '', api_key: '' },
+ })
+ })
+
+ it('should call onChange when endpoint field changes', () => {
+ const onChange = vi.fn()
+ render()
+
+ const endpointInput = screen.getByLabelText(/api endpoint/i)
+ fireEvent.change(endpointInput, { target: { value: 'https://new-api.example.com' } })
+
+ expect(onChange).toHaveBeenCalledWith({
+ name: '',
+ settings: { endpoint: 'https://new-api.example.com', api_key: '' },
+ })
+ })
+
+ it('should call onChange when api_key field changes', () => {
+ const onChange = vi.fn()
+ render()
+
+ const apiKeyInput = screen.getByLabelText(/api key/i)
+ fireEvent.change(apiKeyInput, { target: { value: 'new-secret-key' } })
+
+ expect(onChange).toHaveBeenCalledWith({
+ name: '',
+ settings: { endpoint: '', api_key: 'new-secret-key' },
+ })
+ })
+
+ it('should update settings without affecting name', () => {
+ const onChange = vi.fn()
+ const initialValue: CreateExternalAPIReq = {
+ name: 'Existing Name',
+ settings: { endpoint: '', api_key: '' },
+ }
+ render()
+
+ const endpointInput = screen.getByLabelText(/api endpoint/i)
+ fireEvent.change(endpointInput, { target: { value: 'https://api.example.com' } })
+
+ expect(onChange).toHaveBeenCalledWith({
+ name: 'Existing Name',
+ settings: { endpoint: 'https://api.example.com', api_key: '' },
+ })
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty formSchemas', () => {
+ const { container } = render()
+ expect(container.querySelector('form')).toBeInTheDocument()
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
+ })
+
+ it('should handle optional field (required: false)', () => {
+ const schemasWithOptional: FormSchema[] = [
+ {
+ variable: 'description',
+ type: 'text',
+ label: { en_US: 'Description' },
+ required: false,
+ },
+ ]
+ render()
+ expect(screen.queryByText('*')).not.toBeInTheDocument()
+ })
+
+ it('should fallback to en_US label when current language label is not available', () => {
+ const schemasWithEnOnly: FormSchema[] = [
+ {
+ variable: 'test',
+ type: 'text',
+ label: { en_US: 'Test Field' },
+ required: false,
+ },
+ ]
+ render()
+ expect(screen.getByLabelText(/test field/i)).toBeInTheDocument()
+ })
+
+ it('should preserve existing settings when updating one field', () => {
+ const onChange = vi.fn()
+ const initialValue: CreateExternalAPIReq = {
+ name: '',
+ settings: { endpoint: 'https://existing.com', api_key: 'existing-key' },
+ }
+ render()
+
+ const endpointInput = screen.getByLabelText(/api endpoint/i)
+ fireEvent.change(endpointInput, { target: { value: 'https://new.com' } })
+
+ expect(onChange).toHaveBeenCalledWith({
+ name: '',
+ settings: { endpoint: 'https://new.com', api_key: 'existing-key' },
+ })
+ })
+ })
+})
diff --git a/web/app/components/datasets/external-api/external-api-modal/index.spec.tsx b/web/app/components/datasets/external-api/external-api-modal/index.spec.tsx
new file mode 100644
index 0000000000..94c4deab04
--- /dev/null
+++ b/web/app/components/datasets/external-api/external-api-modal/index.spec.tsx
@@ -0,0 +1,424 @@
+import type { CreateExternalAPIReq } from '../declarations'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+// Import mocked service
+import { createExternalAPI } from '@/service/datasets'
+
+import AddExternalAPIModal from './index'
+
+// Mock API service
+vi.mock('@/service/datasets', () => ({
+ createExternalAPI: vi.fn(),
+}))
+
+// Mock toast context
+const mockNotify = vi.fn()
+vi.mock('@/app/components/base/toast', () => ({
+ useToastContext: () => ({
+ notify: mockNotify,
+ }),
+}))
+
+describe('AddExternalAPIModal', () => {
+ const defaultProps = {
+ onSave: vi.fn(),
+ onCancel: vi.fn(),
+ isEditMode: false,
+ }
+
+ const initialData: CreateExternalAPIReq = {
+ name: 'Test API',
+ settings: {
+ endpoint: 'https://api.example.com',
+ api_key: 'test-key-12345',
+ },
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument()
+ })
+
+ it('should render create title when not in edit mode', () => {
+ render()
+ expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument()
+ })
+
+ it('should render edit title when in edit mode', () => {
+ render()
+ expect(screen.getByText('dataset.editExternalAPIFormTitle')).toBeInTheDocument()
+ })
+
+ it('should render form fields', () => {
+ render()
+ expect(screen.getByLabelText(/name/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/api endpoint/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/api key/i)).toBeInTheDocument()
+ })
+
+ it('should render cancel and save buttons', () => {
+ render()
+ expect(screen.getByText('dataset.externalAPIForm.cancel')).toBeInTheDocument()
+ expect(screen.getByText('dataset.externalAPIForm.save')).toBeInTheDocument()
+ })
+
+ it('should render encryption notice', () => {
+ render()
+ expect(screen.getByText('PKCS1_OAEP')).toBeInTheDocument()
+ })
+
+ it('should render close button', () => {
+ render()
+ // Close button is rendered in a portal
+ const closeButton = document.body.querySelector('.action-btn')
+ expect(closeButton).toBeInTheDocument()
+ })
+ })
+
+ describe('Edit Mode with Dataset Bindings', () => {
+ it('should show warning when editing with dataset bindings', () => {
+ const datasetBindings = [
+ { id: 'ds-1', name: 'Dataset 1' },
+ { id: 'ds-2', name: 'Dataset 2' },
+ ]
+ render(
+ ,
+ )
+ expect(screen.getByText('dataset.editExternalAPIFormWarning.front')).toBeInTheDocument()
+ // Verify the count is displayed in the warning section
+ const warningElement = screen.getByText('dataset.editExternalAPIFormWarning.front').parentElement
+ expect(warningElement?.textContent).toContain('2')
+ })
+
+ it('should not show warning when no dataset bindings', () => {
+ render(
+ ,
+ )
+ expect(screen.queryByText('dataset.editExternalAPIFormWarning.front')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Form Interactions', () => {
+ it('should update form values when input changes', () => {
+ render()
+
+ const nameInput = screen.getByLabelText(/name/i)
+ fireEvent.change(nameInput, { target: { value: 'New API Name' } })
+ expect(nameInput).toHaveValue('New API Name')
+ })
+
+ it('should initialize form with data in edit mode', () => {
+ render()
+
+ expect(screen.getByLabelText(/name/i)).toHaveValue('Test API')
+ expect(screen.getByLabelText(/api endpoint/i)).toHaveValue('https://api.example.com')
+ expect(screen.getByLabelText(/api key/i)).toHaveValue('test-key-12345')
+ })
+
+ it('should disable save button when form has empty inputs', () => {
+ render()
+
+ const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')
+ expect(saveButton).toBeDisabled()
+ })
+
+ it('should enable save button when all fields are filled', () => {
+ render()
+
+ const nameInput = screen.getByLabelText(/name/i)
+ const endpointInput = screen.getByLabelText(/api endpoint/i)
+ const apiKeyInput = screen.getByLabelText(/api key/i)
+
+ fireEvent.change(nameInput, { target: { value: 'Test' } })
+ fireEvent.change(endpointInput, { target: { value: 'https://test.com' } })
+ fireEvent.change(apiKeyInput, { target: { value: 'key12345' } })
+
+ const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')
+ expect(saveButton).not.toBeDisabled()
+ })
+ })
+
+ describe('Create Mode - Save', () => {
+ it('should create API and call onSave on success', async () => {
+ const mockResponse = {
+ id: 'new-api-123',
+ tenant_id: 'tenant-1',
+ name: 'Test',
+ description: '',
+ settings: { endpoint: 'https://test.com', api_key: 'key12345' },
+ dataset_bindings: [],
+ created_by: 'user-1',
+ created_at: '2021-01-01T00:00:00Z',
+ }
+ vi.mocked(createExternalAPI).mockResolvedValue(mockResponse)
+ const onSave = vi.fn()
+ const onCancel = vi.fn()
+
+ render()
+
+ const nameInput = screen.getByLabelText(/name/i)
+ const endpointInput = screen.getByLabelText(/api endpoint/i)
+ const apiKeyInput = screen.getByLabelText(/api key/i)
+
+ fireEvent.change(nameInput, { target: { value: 'Test' } })
+ fireEvent.change(endpointInput, { target: { value: 'https://test.com' } })
+ fireEvent.change(apiKeyInput, { target: { value: 'key12345' } })
+
+ const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(createExternalAPI).toHaveBeenCalledWith({
+ body: {
+ name: 'Test',
+ settings: { endpoint: 'https://test.com', api_key: 'key12345' },
+ },
+ })
+ expect(mockNotify).toHaveBeenCalledWith({
+ type: 'success',
+ message: 'External API saved successfully',
+ })
+ expect(onSave).toHaveBeenCalledWith(mockResponse)
+ expect(onCancel).toHaveBeenCalled()
+ })
+ })
+
+ it('should show error notification when API key is too short', async () => {
+ render()
+
+ const nameInput = screen.getByLabelText(/name/i)
+ const endpointInput = screen.getByLabelText(/api endpoint/i)
+ const apiKeyInput = screen.getByLabelText(/api key/i)
+
+ fireEvent.change(nameInput, { target: { value: 'Test' } })
+ fireEvent.change(endpointInput, { target: { value: 'https://test.com' } })
+ fireEvent.change(apiKeyInput, { target: { value: 'key' } }) // Less than 5 characters
+
+ const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'common.apiBasedExtension.modal.apiKey.lengthError',
+ })
+ })
+ })
+
+ it('should handle create API error', async () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ vi.mocked(createExternalAPI).mockRejectedValue(new Error('Create failed'))
+
+ render()
+
+ const nameInput = screen.getByLabelText(/name/i)
+ const endpointInput = screen.getByLabelText(/api endpoint/i)
+ const apiKeyInput = screen.getByLabelText(/api key/i)
+
+ fireEvent.change(nameInput, { target: { value: 'Test' } })
+ fireEvent.change(endpointInput, { target: { value: 'https://test.com' } })
+ fireEvent.change(apiKeyInput, { target: { value: 'key12345' } })
+
+ const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'Failed to save/update External API',
+ })
+ })
+
+ consoleSpy.mockRestore()
+ })
+ })
+
+ describe('Edit Mode - Save', () => {
+ it('should call onEdit directly when editing without dataset bindings', async () => {
+ const onEdit = vi.fn().mockResolvedValue(undefined)
+ const onCancel = vi.fn()
+
+ render(
+ ,
+ )
+
+ const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ // When no datasetBindings, onEdit is called directly with original form data
+ expect(onEdit).toHaveBeenCalledWith({
+ name: 'Test API',
+ settings: {
+ endpoint: 'https://api.example.com',
+ api_key: 'test-key-12345',
+ },
+ })
+ })
+ })
+
+ it('should show confirm dialog when editing with dataset bindings', async () => {
+ const datasetBindings = [{ id: 'ds-1', name: 'Dataset 1' }]
+ const onEdit = vi.fn().mockResolvedValue(undefined)
+
+ render(
+ ,
+ )
+
+ const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
+ })
+ })
+
+ it('should proceed with save after confirming in edit mode with bindings', async () => {
+ vi.mocked(createExternalAPI).mockResolvedValue({
+ id: 'api-123',
+ tenant_id: 'tenant-1',
+ name: 'Test API',
+ description: '',
+ settings: { endpoint: 'https://api.example.com', api_key: 'test-key-12345' },
+ dataset_bindings: [],
+ created_by: 'user-1',
+ created_at: '2021-01-01T00:00:00Z',
+ })
+ const datasetBindings = [{ id: 'ds-1', name: 'Dataset 1' }]
+ const onCancel = vi.fn()
+
+ render(
+ ,
+ )
+
+ const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
+ })
+
+ const confirmButton = screen.getByRole('button', { name: /confirm/i })
+ fireEvent.click(confirmButton)
+
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'success' }),
+ )
+ })
+ })
+
+ it('should close confirm dialog when cancel is clicked', async () => {
+ const datasetBindings = [{ id: 'ds-1', name: 'Dataset 1' }]
+
+ render(
+ ,
+ )
+
+ const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
+ })
+
+ // There are multiple cancel buttons, find the one in the confirm dialog
+ const cancelButtons = screen.getAllByRole('button', { name: /cancel/i })
+ const confirmDialogCancelButton = cancelButtons[cancelButtons.length - 1]
+ fireEvent.click(confirmDialogCancelButton)
+
+ await waitFor(() => {
+ // Confirm button should be gone after canceling
+ expect(screen.queryAllByRole('button', { name: /confirm/i })).toHaveLength(0)
+ })
+ })
+ })
+
+ describe('Cancel', () => {
+ it('should call onCancel when cancel button is clicked', () => {
+ const onCancel = vi.fn()
+ render()
+
+ const cancelButton = screen.getByText('dataset.externalAPIForm.cancel').closest('button')!
+ fireEvent.click(cancelButton)
+
+ expect(onCancel).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onCancel when close button is clicked', () => {
+ const onCancel = vi.fn()
+ render()
+
+ // Close button is rendered in a portal
+ const closeButton = document.body.querySelector('.action-btn')!
+ fireEvent.click(closeButton)
+
+ expect(onCancel).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle undefined data in edit mode', () => {
+ render()
+ expect(screen.getByLabelText(/name/i)).toHaveValue('')
+ })
+
+ it('should handle null datasetBindings', () => {
+ render(
+ ,
+ )
+ expect(screen.queryByText('dataset.editExternalAPIFormWarning.front')).not.toBeInTheDocument()
+ })
+
+ it('should render documentation link in encryption notice', () => {
+ render()
+ const link = screen.getByRole('link', { name: 'PKCS1_OAEP' })
+ expect(link).toHaveAttribute('href', 'https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html')
+ expect(link).toHaveAttribute('target', '_blank')
+ })
+ })
+})
diff --git a/web/app/components/datasets/external-api/external-api-panel/index.spec.tsx b/web/app/components/datasets/external-api/external-api-panel/index.spec.tsx
new file mode 100644
index 0000000000..55297132a9
--- /dev/null
+++ b/web/app/components/datasets/external-api/external-api-panel/index.spec.tsx
@@ -0,0 +1,207 @@
+import type { ExternalAPIItem } from '@/models/datasets'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import ExternalAPIPanel from './index'
+
+// Mock external contexts (only mock context providers, not base components)
+const mockSetShowExternalKnowledgeAPIModal = vi.fn()
+const mockMutateExternalKnowledgeApis = vi.fn()
+let mockIsLoading = false
+let mockExternalKnowledgeApiList: ExternalAPIItem[] = []
+
+vi.mock('@/context/modal-context', () => ({
+ useModalContext: () => ({
+ setShowExternalKnowledgeAPIModal: mockSetShowExternalKnowledgeAPIModal,
+ }),
+}))
+
+vi.mock('@/context/external-knowledge-api-context', () => ({
+ useExternalKnowledgeApi: () => ({
+ externalKnowledgeApiList: mockExternalKnowledgeApiList,
+ mutateExternalKnowledgeApis: mockMutateExternalKnowledgeApis,
+ isLoading: mockIsLoading,
+ }),
+}))
+
+vi.mock('@/context/i18n', () => ({
+ useDocLink: () => (path: string) => `https://docs.example.com${path}`,
+}))
+
+// Mock the ExternalKnowledgeAPICard to avoid mocking its internal dependencies
+vi.mock('../external-knowledge-api-card', () => ({
+ default: ({ api }: { api: ExternalAPIItem }) => (
+ {api.name}
+ ),
+}))
+
+// i18n mock returns 'namespace.key' format
+
+describe('ExternalAPIPanel', () => {
+ const defaultProps = {
+ onClose: vi.fn(),
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockIsLoading = false
+ mockExternalKnowledgeApiList = []
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByText('dataset.externalAPIPanelTitle')).toBeInTheDocument()
+ })
+
+ it('should render panel title and description', () => {
+ render()
+ expect(screen.getByText('dataset.externalAPIPanelTitle')).toBeInTheDocument()
+ expect(screen.getByText('dataset.externalAPIPanelDescription')).toBeInTheDocument()
+ })
+
+ it('should render documentation link', () => {
+ render()
+ const docLink = screen.getByText('dataset.externalAPIPanelDocumentation')
+ expect(docLink).toBeInTheDocument()
+ expect(docLink.closest('a')).toHaveAttribute('href', 'https://docs.example.com/guides/knowledge-base/connect-external-knowledge-base')
+ })
+
+ it('should render create button', () => {
+ render()
+ expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument()
+ })
+
+ it('should render close button', () => {
+ const { container } = render()
+ const closeButton = container.querySelector('[class*="action-button"]') || screen.getAllByRole('button')[0]
+ expect(closeButton).toBeInTheDocument()
+ })
+ })
+
+ describe('Loading State', () => {
+ it('should render loading indicator when isLoading is true', () => {
+ mockIsLoading = true
+ const { container } = render()
+ // Loading component should be rendered
+ const loadingElement = container.querySelector('[class*="loading"]')
+ || container.querySelector('.animate-spin')
+ || screen.queryByRole('status')
+ expect(loadingElement || container.textContent).toBeTruthy()
+ })
+ })
+
+ describe('API List Rendering', () => {
+ it('should render empty list when no APIs exist', () => {
+ mockExternalKnowledgeApiList = []
+ render()
+ expect(screen.queryByTestId(/api-card-/)).not.toBeInTheDocument()
+ })
+
+ it('should render API cards when APIs exist', () => {
+ mockExternalKnowledgeApiList = [
+ {
+ id: 'api-1',
+ tenant_id: 'tenant-1',
+ name: 'Test API 1',
+ description: '',
+ settings: { endpoint: 'https://api1.example.com', api_key: 'key1' },
+ dataset_bindings: [],
+ created_by: 'user-1',
+ created_at: '2021-01-01T00:00:00Z',
+ },
+ {
+ id: 'api-2',
+ tenant_id: 'tenant-1',
+ name: 'Test API 2',
+ description: '',
+ settings: { endpoint: 'https://api2.example.com', api_key: 'key2' },
+ dataset_bindings: [],
+ created_by: 'user-1',
+ created_at: '2021-01-01T00:00:00Z',
+ },
+ ]
+ render()
+ expect(screen.getByTestId('api-card-api-1')).toBeInTheDocument()
+ expect(screen.getByTestId('api-card-api-2')).toBeInTheDocument()
+ expect(screen.getByText('Test API 1')).toBeInTheDocument()
+ expect(screen.getByText('Test API 2')).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onClose when close button is clicked', () => {
+ const onClose = vi.fn()
+ render()
+ // Find the close button (ActionButton with close icon)
+ const buttons = screen.getAllByRole('button')
+ const closeButton = buttons.find(btn => btn.querySelector('svg[class*="ri-close"]'))
+ || buttons[0]
+ fireEvent.click(closeButton)
+ expect(onClose).toHaveBeenCalledTimes(1)
+ })
+
+ it('should open external API modal when create button is clicked', async () => {
+ render()
+ const createButton = screen.getByText('dataset.createExternalAPI').closest('button')!
+ fireEvent.click(createButton)
+
+ await waitFor(() => {
+ expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledTimes(1)
+ expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledWith(
+ expect.objectContaining({
+ payload: { name: '', settings: { endpoint: '', api_key: '' } },
+ datasetBindings: [],
+ isEditMode: false,
+ }),
+ )
+ })
+ })
+
+ it('should call mutateExternalKnowledgeApis in onSaveCallback', async () => {
+ render()
+ const createButton = screen.getByText('dataset.createExternalAPI').closest('button')!
+ fireEvent.click(createButton)
+
+ const callArgs = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0]
+ callArgs.onSaveCallback()
+
+ expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
+ })
+
+ it('should call mutateExternalKnowledgeApis in onCancelCallback', async () => {
+ render()
+ const createButton = screen.getByText('dataset.createExternalAPI').closest('button')!
+ fireEvent.click(createButton)
+
+ const callArgs = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0]
+ callArgs.onCancelCallback()
+
+ expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle single API in list', () => {
+ mockExternalKnowledgeApiList = [
+ {
+ id: 'single-api',
+ tenant_id: 'tenant-1',
+ name: 'Single API',
+ description: '',
+ settings: { endpoint: 'https://single.example.com', api_key: 'key' },
+ dataset_bindings: [],
+ created_by: 'user-1',
+ created_at: '2021-01-01T00:00:00Z',
+ },
+ ]
+ render()
+ expect(screen.getByTestId('api-card-single-api')).toBeInTheDocument()
+ })
+
+ it('should render documentation link with correct target', () => {
+ render()
+ const docLink = screen.getByText('dataset.externalAPIPanelDocumentation').closest('a')
+ expect(docLink).toHaveAttribute('target', '_blank')
+ })
+ })
+})
diff --git a/web/app/components/datasets/external-api/external-knowledge-api-card/index.spec.tsx b/web/app/components/datasets/external-api/external-knowledge-api-card/index.spec.tsx
new file mode 100644
index 0000000000..f8aacde3e1
--- /dev/null
+++ b/web/app/components/datasets/external-api/external-knowledge-api-card/index.spec.tsx
@@ -0,0 +1,382 @@
+import type { ExternalAPIItem } from '@/models/datasets'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+// Import mocked services
+import { checkUsageExternalAPI, deleteExternalAPI, fetchExternalAPI } from '@/service/datasets'
+
+import ExternalKnowledgeAPICard from './index'
+
+// Mock API services
+vi.mock('@/service/datasets', () => ({
+ fetchExternalAPI: vi.fn(),
+ updateExternalAPI: vi.fn(),
+ deleteExternalAPI: vi.fn(),
+ checkUsageExternalAPI: vi.fn(),
+}))
+
+// Mock contexts
+const mockSetShowExternalKnowledgeAPIModal = vi.fn()
+const mockMutateExternalKnowledgeApis = vi.fn()
+
+vi.mock('@/context/modal-context', () => ({
+ useModalContext: () => ({
+ setShowExternalKnowledgeAPIModal: mockSetShowExternalKnowledgeAPIModal,
+ }),
+}))
+
+vi.mock('@/context/external-knowledge-api-context', () => ({
+ useExternalKnowledgeApi: () => ({
+ mutateExternalKnowledgeApis: mockMutateExternalKnowledgeApis,
+ }),
+}))
+
+describe('ExternalKnowledgeAPICard', () => {
+ const mockApi: ExternalAPIItem = {
+ id: 'api-123',
+ tenant_id: 'tenant-1',
+ name: 'Test External API',
+ description: 'Test API description',
+ settings: {
+ endpoint: 'https://api.example.com/knowledge',
+ api_key: 'secret-key-123',
+ },
+ dataset_bindings: [],
+ created_by: 'user-1',
+ created_at: '2021-01-01T00:00:00Z',
+ }
+
+ const defaultProps = {
+ api: mockApi,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByText('Test External API')).toBeInTheDocument()
+ })
+
+ it('should render API name', () => {
+ render()
+ expect(screen.getByText('Test External API')).toBeInTheDocument()
+ })
+
+ it('should render API endpoint', () => {
+ render()
+ expect(screen.getByText('https://api.example.com/knowledge')).toBeInTheDocument()
+ })
+
+ it('should render edit and delete buttons', () => {
+ const { container } = render()
+ const buttons = container.querySelectorAll('button')
+ expect(buttons.length).toBe(2)
+ })
+
+ it('should render API connection icon', () => {
+ const { container } = render()
+ const icon = container.querySelector('svg')
+ expect(icon).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions - Edit', () => {
+ it('should fetch API details and open modal when edit button is clicked', async () => {
+ const mockResponse: ExternalAPIItem = {
+ id: 'api-123',
+ tenant_id: 'tenant-1',
+ name: 'Test External API',
+ description: 'Test API description',
+ settings: {
+ endpoint: 'https://api.example.com/knowledge',
+ api_key: 'secret-key-123',
+ },
+ dataset_bindings: [{ id: 'ds-1', name: 'Dataset 1' }],
+ created_by: 'user-1',
+ created_at: '2021-01-01T00:00:00Z',
+ }
+ vi.mocked(fetchExternalAPI).mockResolvedValue(mockResponse)
+
+ const { container } = render()
+ const buttons = container.querySelectorAll('button')
+ const editButton = buttons[0]
+
+ fireEvent.click(editButton)
+
+ await waitFor(() => {
+ expect(fetchExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' })
+ expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledWith(
+ expect.objectContaining({
+ payload: {
+ name: 'Test External API',
+ settings: {
+ endpoint: 'https://api.example.com/knowledge',
+ api_key: 'secret-key-123',
+ },
+ },
+ isEditMode: true,
+ datasetBindings: [{ id: 'ds-1', name: 'Dataset 1' }],
+ }),
+ )
+ })
+ })
+
+ it('should handle fetch error gracefully', async () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ vi.mocked(fetchExternalAPI).mockRejectedValue(new Error('Fetch failed'))
+
+ const { container } = render()
+ const buttons = container.querySelectorAll('button')
+ const editButton = buttons[0]
+
+ fireEvent.click(editButton)
+
+ await waitFor(() => {
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Error fetching external knowledge API data:',
+ expect.any(Error),
+ )
+ })
+
+ consoleSpy.mockRestore()
+ })
+
+ it('should call mutate on save callback', async () => {
+ const mockResponse: ExternalAPIItem = {
+ id: 'api-123',
+ tenant_id: 'tenant-1',
+ name: 'Test External API',
+ description: 'Test API description',
+ settings: {
+ endpoint: 'https://api.example.com/knowledge',
+ api_key: 'secret-key-123',
+ },
+ dataset_bindings: [],
+ created_by: 'user-1',
+ created_at: '2021-01-01T00:00:00Z',
+ }
+ vi.mocked(fetchExternalAPI).mockResolvedValue(mockResponse)
+
+ const { container } = render()
+ const editButton = container.querySelectorAll('button')[0]
+
+ fireEvent.click(editButton)
+
+ await waitFor(() => {
+ expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalled()
+ })
+
+ // Simulate save callback
+ const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0]
+ modalCall.onSaveCallback()
+
+ expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
+ })
+
+ it('should call mutate on cancel callback', async () => {
+ const mockResponse: ExternalAPIItem = {
+ id: 'api-123',
+ tenant_id: 'tenant-1',
+ name: 'Test External API',
+ description: 'Test API description',
+ settings: {
+ endpoint: 'https://api.example.com/knowledge',
+ api_key: 'secret-key-123',
+ },
+ dataset_bindings: [],
+ created_by: 'user-1',
+ created_at: '2021-01-01T00:00:00Z',
+ }
+ vi.mocked(fetchExternalAPI).mockResolvedValue(mockResponse)
+
+ const { container } = render()
+ const editButton = container.querySelectorAll('button')[0]
+
+ fireEvent.click(editButton)
+
+ await waitFor(() => {
+ expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalled()
+ })
+
+ // Simulate cancel callback
+ const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0]
+ modalCall.onCancelCallback()
+
+ expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
+ })
+ })
+
+ describe('User Interactions - Delete', () => {
+ it('should check usage and show confirm dialog when delete button is clicked', async () => {
+ vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
+
+ const { container } = render()
+ const buttons = container.querySelectorAll('button')
+ const deleteButton = buttons[1]
+
+ fireEvent.click(deleteButton)
+
+ await waitFor(() => {
+ expect(checkUsageExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' })
+ })
+
+ // Confirm dialog should be shown
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
+ })
+ })
+
+ it('should show usage count in confirm dialog when API is in use', async () => {
+ vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: true, count: 3 })
+
+ const { container } = render()
+ const deleteButton = container.querySelectorAll('button')[1]
+
+ fireEvent.click(deleteButton)
+
+ await waitFor(() => {
+ expect(screen.getByText(/3/)).toBeInTheDocument()
+ })
+ })
+
+ it('should delete API and refresh list when confirmed', async () => {
+ vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
+ vi.mocked(deleteExternalAPI).mockResolvedValue({ result: 'success' })
+
+ const { container } = render()
+ const deleteButton = container.querySelectorAll('button')[1]
+
+ fireEvent.click(deleteButton)
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
+ })
+
+ const confirmButton = screen.getByRole('button', { name: /confirm/i })
+ fireEvent.click(confirmButton)
+
+ await waitFor(() => {
+ expect(deleteExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' })
+ expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
+ })
+ })
+
+ it('should close confirm dialog when cancel is clicked', async () => {
+ vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
+
+ const { container } = render()
+ const deleteButton = container.querySelectorAll('button')[1]
+
+ fireEvent.click(deleteButton)
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
+ })
+
+ const cancelButton = screen.getByRole('button', { name: /cancel/i })
+ fireEvent.click(cancelButton)
+
+ await waitFor(() => {
+ expect(screen.queryByRole('button', { name: /confirm/i })).not.toBeInTheDocument()
+ })
+ })
+
+ it('should handle delete error gracefully', async () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
+ vi.mocked(deleteExternalAPI).mockRejectedValue(new Error('Delete failed'))
+
+ const { container } = render()
+ const deleteButton = container.querySelectorAll('button')[1]
+
+ fireEvent.click(deleteButton)
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
+ })
+
+ const confirmButton = screen.getByRole('button', { name: /confirm/i })
+ fireEvent.click(confirmButton)
+
+ await waitFor(() => {
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Error deleting external knowledge API:',
+ expect.any(Error),
+ )
+ })
+
+ consoleSpy.mockRestore()
+ })
+
+ it('should handle check usage error gracefully', async () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ vi.mocked(checkUsageExternalAPI).mockRejectedValue(new Error('Check failed'))
+
+ const { container } = render()
+ const deleteButton = container.querySelectorAll('button')[1]
+
+ fireEvent.click(deleteButton)
+
+ await waitFor(() => {
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Error checking external API usage:',
+ expect.any(Error),
+ )
+ })
+
+ consoleSpy.mockRestore()
+ })
+ })
+
+ describe('Hover State', () => {
+ it('should apply hover styles when delete button is hovered', () => {
+ const { container } = render()
+ const deleteButton = container.querySelectorAll('button')[1]
+ const cardContainer = container.querySelector('[class*="shadows-shadow"]')
+
+ fireEvent.mouseEnter(deleteButton)
+ expect(cardContainer).toHaveClass('border-state-destructive-border')
+ expect(cardContainer).toHaveClass('bg-state-destructive-hover')
+
+ fireEvent.mouseLeave(deleteButton)
+ expect(cardContainer).not.toHaveClass('border-state-destructive-border')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle API with empty endpoint', () => {
+ const apiWithEmptyEndpoint: ExternalAPIItem = {
+ ...mockApi,
+ settings: { endpoint: '', api_key: 'key' },
+ }
+ render()
+ expect(screen.getByText('Test External API')).toBeInTheDocument()
+ })
+
+ it('should handle delete response with unsuccessful result', async () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
+ vi.mocked(deleteExternalAPI).mockResolvedValue({ result: 'error' })
+
+ const { container } = render()
+ const deleteButton = container.querySelectorAll('button')[1]
+
+ fireEvent.click(deleteButton)
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
+ })
+
+ const confirmButton = screen.getByRole('button', { name: /confirm/i })
+ fireEvent.click(confirmButton)
+
+ await waitFor(() => {
+ expect(consoleSpy).toHaveBeenCalledWith('Failed to delete external API')
+ })
+
+ consoleSpy.mockRestore()
+ })
+ })
+})
diff --git a/web/app/components/datasets/extra-info/index.spec.tsx b/web/app/components/datasets/extra-info/index.spec.tsx
new file mode 100644
index 0000000000..ce34ea26e3
--- /dev/null
+++ b/web/app/components/datasets/extra-info/index.spec.tsx
@@ -0,0 +1,1169 @@
+import type { DataSet, RelatedApp, RelatedAppResponse } from '@/models/datasets'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi } from 'vitest'
+import { AppModeEnum } from '@/types/app'
+
+// ============================================================================
+// Component Imports (after mocks)
+// ============================================================================
+
+import ApiAccess from './api-access'
+import ApiAccessCard from './api-access/card'
+import ExtraInfo from './index'
+import Statistics from './statistics'
+
+// ============================================================================
+// Mock Setup
+// ============================================================================
+
+// Mock next/navigation
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({
+ push: vi.fn(),
+ replace: vi.fn(),
+ }),
+ usePathname: () => '/test',
+ useSearchParams: () => new URLSearchParams(),
+}))
+
+// Mock next/link
+vi.mock('next/link', () => ({
+ default: ({ children, href, ...props }: { children: React.ReactNode, href: string, [key: string]: unknown }) => (
+ {children}
+ ),
+}))
+
+// Dataset context mock data
+const mockDataset: Partial = {
+ id: 'dataset-123',
+ name: 'Test Dataset',
+ enable_api: true,
+}
+
+// Mock use-context-selector
+vi.mock('use-context-selector', () => ({
+ useContext: vi.fn(() => ({ dataset: mockDataset })),
+ useContextSelector: vi.fn((_, selector) => selector({ dataset: mockDataset })),
+ createContext: vi.fn(() => ({})),
+}))
+
+// Mock dataset detail context
+const mockMutateDatasetRes = vi.fn()
+vi.mock('@/context/dataset-detail', () => ({
+ default: {},
+ useDatasetDetailContext: vi.fn(() => ({
+ dataset: mockDataset,
+ mutateDatasetRes: mockMutateDatasetRes,
+ })),
+ useDatasetDetailContextWithSelector: vi.fn((selector: (v: { dataset?: typeof mockDataset, mutateDatasetRes?: () => void }) => unknown) =>
+ selector({ dataset: mockDataset as DataSet, mutateDatasetRes: mockMutateDatasetRes }),
+ ),
+}))
+
+// Mock app context for workspace permissions
+let mockIsCurrentWorkspaceManager = true
+vi.mock('@/context/app-context', () => ({
+ useSelector: vi.fn((selector: (state: { isCurrentWorkspaceManager: boolean }) => unknown) =>
+ selector({ isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager }),
+ ),
+}))
+
+// Mock service hooks
+const mockEnableDatasetServiceApi = vi.fn(() => Promise.resolve({ result: 'success' }))
+const mockDisableDatasetServiceApi = vi.fn(() => Promise.resolve({ result: 'success' }))
+
+vi.mock('@/service/knowledge/use-dataset', () => ({
+ useDatasetApiBaseUrl: vi.fn(() => ({
+ data: { api_base_url: 'https://api.example.com' },
+ isLoading: false,
+ })),
+ useEnableDatasetServiceApi: vi.fn(() => ({
+ mutateAsync: mockEnableDatasetServiceApi,
+ isPending: false,
+ })),
+ useDisableDatasetServiceApi: vi.fn(() => ({
+ mutateAsync: mockDisableDatasetServiceApi,
+ isPending: false,
+ })),
+}))
+
+// Mock API access URL hook
+vi.mock('@/hooks/use-api-access-url', () => ({
+ useDatasetApiAccessUrl: vi.fn(() => 'https://docs.dify.ai/api-reference/datasets'),
+}))
+
+// Mock docLink hook
+vi.mock('@/context/i18n', () => ({
+ useDocLink: vi.fn(() => (path: string) => `https://docs.example.com${path}`),
+}))
+
+// Mock SecretKeyModal to avoid complex modal rendering
+vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({
+ default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => (
+ isShow
+ ? (
+
+
+
+ )
+ : null
+ ),
+}))
+
+// ============================================================================
+// Test Data Factory
+// ============================================================================
+
+const createMockRelatedApp = (overrides: Partial = {}): RelatedApp => ({
+ id: 'app-1',
+ name: 'Test App',
+ mode: AppModeEnum.COMPLETION,
+ icon: 'icon-url',
+ icon_type: 'image',
+ icon_background: '#fff',
+ icon_url: '',
+ ...overrides,
+})
+
+const createMockRelatedAppsResponse = (count: number = 2): RelatedAppResponse => ({
+ data: Array.from({ length: count }, (_, i) =>
+ createMockRelatedApp({ id: `app-${i + 1}`, name: `App ${i + 1}` })),
+ total: count,
+})
+
+// ============================================================================
+// Statistics Component Tests
+// ============================================================================
+
+describe('Statistics', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('10')).toBeInTheDocument()
+ })
+
+ it('should render document count correctly', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('42')).toBeInTheDocument()
+ })
+
+ it('should render related apps total correctly', () => {
+ const relatedApps = createMockRelatedAppsResponse(5)
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('5')).toBeInTheDocument()
+ })
+
+ it('should display translated document label', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/documents/i)).toBeInTheDocument()
+ })
+
+ it('should display translated related app label', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/relatedApp/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should render placeholder when documentCount is undefined', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('--')).toBeInTheDocument()
+ })
+
+ it('should render placeholder when relatedApps is undefined', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getAllByText('--').length).toBeGreaterThanOrEqual(1)
+ })
+
+ it('should handle zero document count', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('0')).toBeInTheDocument()
+ })
+
+ it('should handle empty related apps array', () => {
+ const emptyRelatedApps: RelatedAppResponse = { data: [], total: 0 }
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('0')).toBeInTheDocument()
+ })
+
+ it('should handle large numbers correctly', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('999999')).toBeInTheDocument()
+ expect(screen.getByText('100')).toBeInTheDocument()
+ })
+ })
+
+ describe('Tooltip Interactions', () => {
+ it('should render tooltip trigger with info icon', () => {
+ render(
+ ,
+ )
+
+ // Find the cursor-pointer element containing the relatedApp text
+ const tooltipTrigger = screen.getByText(/relatedApp/i).closest('.cursor-pointer')
+ expect(tooltipTrigger).toBeInTheDocument()
+ })
+
+ it('should render LinkedAppsPanel when related apps exist', async () => {
+ const relatedApps = createMockRelatedAppsResponse(3)
+
+ render(
+ ,
+ )
+
+ // The LinkedAppsPanel should be rendered inside the tooltip
+ // We can't easily test tooltip content in this context without more setup
+ // But we verify the condition logic works by checking component renders
+ expect(screen.getByText('3')).toBeInTheDocument()
+ })
+
+ it('should render NoLinkedAppsPanel when no related apps', () => {
+ const emptyRelatedApps: RelatedAppResponse = { data: [], total: 0 }
+
+ render(
+ ,
+ )
+
+ // Verify component renders correctly with empty apps
+ expect(screen.getByText('0')).toBeInTheDocument()
+ })
+ })
+
+ describe('Props Variations', () => {
+ it('should handle expand=false', () => {
+ render(
+ ,
+ )
+
+ // Component should still render with expand=false
+ expect(screen.getByText('10')).toBeInTheDocument()
+ })
+
+ it('should pass isMobile based on expand prop', () => {
+ // When expand is false, isMobile should be true (!expand)
+ render(
+ ,
+ )
+
+ // Component renders - the isMobile logic is internal
+ expect(screen.getByText('10')).toBeInTheDocument()
+ })
+ })
+
+ describe('Memoization', () => {
+ it('should be memoized with React.memo', () => {
+ const { rerender } = render(
+ ,
+ )
+
+ // Rerender with same props
+ rerender(
+ ,
+ )
+
+ // Component should not cause unnecessary re-renders
+ expect(screen.getByText('10')).toBeInTheDocument()
+ })
+ })
+})
+
+// ============================================================================
+// ApiAccess Component Tests
+// ============================================================================
+
+describe('ApiAccess', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
+ })
+
+ it('should render API title when expanded', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
+ })
+
+ it('should not render API title when collapsed', () => {
+ render(
+ ,
+ )
+
+ expect(screen.queryByText(/appMenus\.apiAccess/i)).not.toBeInTheDocument()
+ })
+
+ it('should render indicator when API is enabled', () => {
+ const { container } = render(
+ ,
+ )
+
+ // Indicator component should be present
+ const indicatorElement = container.querySelector('.relative.flex.h-8')
+ expect(indicatorElement).toBeInTheDocument()
+ })
+
+ it('should render indicator when API is disabled', () => {
+ const { container } = render(
+ ,
+ )
+
+ // Indicator component should be present
+ const indicatorElement = container.querySelector('.relative.flex.h-8')
+ expect(indicatorElement).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should toggle popup open state on click', async () => {
+ const user = userEvent.setup()
+
+ render(
+ ,
+ )
+
+ const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
+ expect(trigger).toBeInTheDocument()
+
+ if (trigger) {
+ await user.click(trigger)
+ // After click, the Card component should be rendered in the portal
+ }
+ })
+
+ it('should apply hover styles on trigger', () => {
+ render(
+ ,
+ )
+
+ const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('div[class*="cursor-pointer"]')
+ expect(trigger).toHaveClass('cursor-pointer')
+ })
+ })
+
+ describe('Props Variations', () => {
+ it('should apply compressed layout when expand is false', () => {
+ const { container } = render(
+ ,
+ )
+
+ // When collapsed, width should be w-8
+ const triggerContainer = container.querySelector('[class*="w-8"]')
+ expect(triggerContainer).toBeInTheDocument()
+ })
+
+ it('should pass apiEnabled to Card component', async () => {
+ const user = userEvent.setup()
+
+ render(
+ ,
+ )
+
+ const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
+ if (trigger) {
+ await user.click(trigger)
+ // The apiEnabled should be passed to Card
+ }
+ })
+ })
+
+ describe('Memoization', () => {
+ it('should be memoized with React.memo', () => {
+ const { rerender } = render(
+ ,
+ )
+
+ rerender(
+ ,
+ )
+
+ expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
+ })
+ })
+})
+
+// ============================================================================
+// ApiAccessCard Component Tests
+// ============================================================================
+
+describe('ApiAccessCard', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockIsCurrentWorkspaceManager = true
+ mockEnableDatasetServiceApi.mockResolvedValue({ result: 'success' })
+ mockDisableDatasetServiceApi.mockResolvedValue({ result: 'success' })
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
+ })
+
+ it('should display enabled status when API is enabled', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
+ })
+
+ it('should display disabled status when API is disabled', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/serviceApi\.disabled/i)).toBeInTheDocument()
+ })
+
+ it('should render API Reference link', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/overview\.apiInfo\.doc/i)).toBeInTheDocument()
+ })
+
+ it('should render switch component', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('switch')).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call enableDatasetServiceApi when switch is toggled on', async () => {
+ const user = userEvent.setup()
+
+ render(
+ ,
+ )
+
+ const switchButton = screen.getByRole('switch')
+ await user.click(switchButton)
+
+ await waitFor(() => {
+ expect(mockEnableDatasetServiceApi).toHaveBeenCalledWith('dataset-123')
+ })
+ })
+
+ it('should call disableDatasetServiceApi when switch is toggled off', async () => {
+ const user = userEvent.setup()
+
+ render(
+ ,
+ )
+
+ const switchButton = screen.getByRole('switch')
+ await user.click(switchButton)
+
+ await waitFor(() => {
+ expect(mockDisableDatasetServiceApi).toHaveBeenCalledWith('dataset-123')
+ })
+ })
+
+ it('should call mutateDatasetRes after successful API toggle', async () => {
+ const user = userEvent.setup()
+
+ render(
+ ,
+ )
+
+ const switchButton = screen.getByRole('switch')
+ await user.click(switchButton)
+
+ await waitFor(() => {
+ expect(mockMutateDatasetRes).toHaveBeenCalled()
+ })
+ })
+
+ it('should not call mutateDatasetRes on API toggle failure', async () => {
+ mockEnableDatasetServiceApi.mockResolvedValueOnce({ result: 'fail' })
+ const user = userEvent.setup()
+
+ render(
+ ,
+ )
+
+ const switchButton = screen.getByRole('switch')
+ await user.click(switchButton)
+
+ await waitFor(() => {
+ expect(mockEnableDatasetServiceApi).toHaveBeenCalled()
+ })
+
+ // mutateDatasetRes should not be called on failure
+ expect(mockMutateDatasetRes).not.toHaveBeenCalled()
+ })
+
+ it('should have correct href for API Reference link', () => {
+ render(
+ ,
+ )
+
+ const apiRefLink = screen.getByText(/overview\.apiInfo\.doc/i).closest('a')
+ expect(apiRefLink).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets')
+ })
+ })
+
+ describe('Permission Handling', () => {
+ it('should disable switch when user is not workspace manager', () => {
+ mockIsCurrentWorkspaceManager = false
+
+ render(
+ ,
+ )
+
+ const switchButton = screen.getByRole('switch')
+ // Headless UI Switch uses CSS classes for disabled state
+ expect(switchButton).toHaveClass('!cursor-not-allowed')
+ expect(switchButton).toHaveClass('!opacity-50')
+ })
+
+ it('should enable switch when user is workspace manager', () => {
+ mockIsCurrentWorkspaceManager = true
+
+ render(
+ ,
+ )
+
+ const switchButton = screen.getByRole('switch')
+ expect(switchButton).not.toHaveClass('!cursor-not-allowed')
+ expect(switchButton).not.toHaveClass('!opacity-50')
+ })
+ })
+
+ describe('Memoization', () => {
+ it('should be memoized with React.memo', () => {
+ const { rerender } = render(
+ ,
+ )
+
+ rerender(
+ ,
+ )
+
+ expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
+ })
+
+ it('should use useCallback for handlers', () => {
+ // Verify handlers are stable by rendering multiple times
+ const { rerender } = render(
+ ,
+ )
+
+ rerender(
+ ,
+ )
+
+ // Component should render without issues with memoized callbacks
+ expect(screen.getByRole('switch')).toBeInTheDocument()
+ })
+ })
+})
+
+// ============================================================================
+// ExtraInfo (Main Component) Tests
+// ============================================================================
+
+describe('ExtraInfo', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render(
+ ,
+ )
+
+ // Should render ApiAccess component
+ expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
+ })
+
+ it('should render Statistics when expand is true', () => {
+ render(
+ ,
+ )
+
+ // Statistics shows document count
+ expect(screen.getByText('10')).toBeInTheDocument()
+ })
+
+ it('should not render Statistics when expand is false', () => {
+ render(
+ ,
+ )
+
+ // Document count should not be visible when collapsed
+ expect(screen.queryByText('10')).not.toBeInTheDocument()
+ })
+
+ it('should always render ApiAccess regardless of expand state', () => {
+ const { rerender } = render(
+ ,
+ )
+
+ // Check expanded state has ApiAccess title
+ expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
+
+ rerender(
+ ,
+ )
+
+ // ApiAccess should still be present (but without title text when collapsed)
+ // The component is still rendered, just with different styling
+ })
+ })
+
+ describe('Context Integration', () => {
+ it('should read apiEnabled from dataset detail context', () => {
+ render(
+ ,
+ )
+
+ // Since mockDataset has enable_api: true, the indicator should be green
+ expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
+ })
+
+ it('should read apiBaseUrl from useDatasetApiBaseUrl hook', () => {
+ render(
+ ,
+ )
+
+ // Component should render with the mocked API base URL
+ expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
+ })
+
+ it('should handle missing apiBaseInfo with fallback empty string', async () => {
+ const { useDatasetApiBaseUrl } = await import('@/service/knowledge/use-dataset')
+ vi.mocked(useDatasetApiBaseUrl).mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ } as ReturnType)
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
+
+ // Reset mock
+ vi.mocked(useDatasetApiBaseUrl).mockReturnValue({
+ data: { api_base_url: 'https://api.example.com' },
+ isLoading: false,
+ } as ReturnType)
+ })
+
+ it('should handle missing apiEnabled with fallback false', async () => {
+ const { useDatasetDetailContextWithSelector } = await import('@/context/dataset-detail')
+ vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector) => {
+ // Simulate dataset without enable_api by using a partial dataset
+ const partialDataset = { ...mockDataset } as Partial
+ delete (partialDataset as { enable_api?: boolean }).enable_api
+ return selector({
+ dataset: partialDataset as DataSet,
+ mutateDatasetRes: vi.fn(),
+ })
+ })
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
+
+ // Reset mock
+ vi.mocked(useDatasetDetailContextWithSelector).mockImplementation(selector =>
+ selector({ dataset: mockDataset as DataSet, mutateDatasetRes: vi.fn() }),
+ )
+ })
+ })
+
+ describe('Props Variations', () => {
+ it('should pass expand prop to Statistics component', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('10')).toBeInTheDocument()
+ })
+
+ it('should pass expand prop to ApiAccess component', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
+ })
+
+ it('should pass documentCount to Statistics component', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('99')).toBeInTheDocument()
+ })
+
+ it('should pass relatedApps to Statistics component', () => {
+ const relatedApps = createMockRelatedAppsResponse(7)
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('7')).toBeInTheDocument()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle undefined documentCount', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('--')).toBeInTheDocument()
+ })
+
+ it('should handle undefined relatedApps', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('10')).toBeInTheDocument()
+ })
+
+ it('should handle all undefined optional props', () => {
+ render(
+ ,
+ )
+
+ // Should render without crashing
+ expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
+ })
+
+ it('should handle zero values correctly', () => {
+ const emptyRelatedApps: RelatedAppResponse = { data: [], total: 0 }
+
+ render(
+ ,
+ )
+
+ expect(screen.getAllByText('0')).toHaveLength(2)
+ })
+ })
+
+ describe('Memoization', () => {
+ it('should be memoized with React.memo', () => {
+ const { rerender } = render(
+ ,
+ )
+
+ // Rerender with same props
+ rerender(
+ ,
+ )
+
+ expect(screen.getByText('10')).toBeInTheDocument()
+ })
+
+ it('should update when props change', () => {
+ const { rerender } = render(
+ ,
+ )
+
+ expect(screen.getByText('10')).toBeInTheDocument()
+
+ rerender(
+ ,
+ )
+
+ expect(screen.getByText('20')).toBeInTheDocument()
+ })
+
+ it('should hide Statistics when expand changes to false', () => {
+ const { rerender } = render(
+ ,
+ )
+
+ expect(screen.getByText('10')).toBeInTheDocument()
+
+ rerender(
+ ,
+ )
+
+ expect(screen.queryByText('10')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Component Composition', () => {
+ it('should render Statistics before ApiAccess when expanded', () => {
+ const { container } = render(
+ ,
+ )
+
+ // Statistics should appear before ApiAccess in DOM order
+ const elements = container.querySelectorAll('div')
+ expect(elements.length).toBeGreaterThan(0)
+ })
+
+ it('should render only ApiAccess when collapsed', () => {
+ render(
+ ,
+ )
+
+ // Only ApiAccess should be rendered (without its title in collapsed state)
+ expect(screen.queryByText('10')).not.toBeInTheDocument()
+ })
+ })
+})
+
+// ============================================================================
+// Integration Tests
+// ============================================================================
+
+describe('ExtraInfo Integration', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render complete expanded view with all child components', () => {
+ render(
+ ,
+ )
+
+ // Statistics content
+ expect(screen.getByText('25')).toBeInTheDocument()
+ expect(screen.getByText('5')).toBeInTheDocument()
+
+ // ApiAccess content
+ expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
+ })
+
+ it('should handle complete user workflow: view stats and toggle API', async () => {
+ const user = userEvent.setup()
+
+ render(
+ ,
+ )
+
+ // Verify statistics are visible
+ expect(screen.getByText('10')).toBeInTheDocument()
+ expect(screen.getByText('3')).toBeInTheDocument()
+
+ // Click on ApiAccess to open the card
+ const apiAccessTrigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
+ if (apiAccessTrigger)
+ await user.click(apiAccessTrigger)
+
+ // The popup should open with Card content (showing enabled/disabled status)
+ await waitFor(() => {
+ expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
+ })
+ })
+
+ it('should integrate with context correctly across all components', async () => {
+ render(
+ ,
+ )
+
+ // The component tree should correctly receive context values
+ // apiEnabled from context affects ApiAccess indicator color
+ expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/datasets/hit-testing/index.spec.tsx b/web/app/components/datasets/hit-testing/index.spec.tsx
new file mode 100644
index 0000000000..45c68e44b1
--- /dev/null
+++ b/web/app/components/datasets/hit-testing/index.spec.tsx
@@ -0,0 +1,2654 @@
+import type { ReactNode } from 'react'
+import type { DataSet, HitTesting, HitTestingChildChunk, HitTestingRecord, HitTestingResponse, Query } from '@/models/datasets'
+import type { RetrievalConfig } from '@/types/app'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { FileAppearanceTypeEnum } from '@/app/components/base/file-uploader/types'
+import { RETRIEVE_METHOD } from '@/types/app'
+
+// ============================================================================
+// Imports (after mocks)
+// ============================================================================
+
+import ChildChunksItem from './components/child-chunks-item'
+import ChunkDetailModal from './components/chunk-detail-modal'
+import EmptyRecords from './components/empty-records'
+import Mask from './components/mask'
+import QueryInput from './components/query-input'
+import Textarea from './components/query-input/textarea'
+import Records from './components/records'
+import ResultItem from './components/result-item'
+import ResultItemExternal from './components/result-item-external'
+import ResultItemFooter from './components/result-item-footer'
+import ResultItemMeta from './components/result-item-meta'
+import Score from './components/score'
+import HitTestingPage from './index'
+import ModifyExternalRetrievalModal from './modify-external-retrieval-modal'
+import ModifyRetrievalModal from './modify-retrieval-modal'
+import { extensionToFileType } from './utils/extension-to-file-type'
+
+// Mock Toast
+// Note: These components use real implementations for integration testing:
+// - Toast, FloatRightContainer, Drawer, Pagination, Loading
+// - RetrievalMethodConfig, EconomicalRetrievalMethodConfig
+// - ImageUploaderInRetrievalTesting, retrieval-method-info, check-rerank-model
+
+// Mock RetrievalSettings to allow triggering onChange
+vi.mock('@/app/components/datasets/external-knowledge-base/create/RetrievalSettings', () => ({
+ default: ({ onChange }: { onChange: (data: { top_k?: number, score_threshold?: number, score_threshold_enabled?: boolean }) => void }) => {
+ return (
+
+
+
+
+
+ )
+ },
+}))
+
+// ============================================================================
+// Mock Setup
+// ============================================================================
+
+// Mock next/navigation
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({
+ push: vi.fn(),
+ replace: vi.fn(),
+ }),
+ usePathname: () => '/test',
+ useSearchParams: () => new URLSearchParams(),
+}))
+
+// Mock use-context-selector
+const mockDataset = {
+ id: 'dataset-1',
+ name: 'Test Dataset',
+ provider: 'vendor',
+ indexing_technique: 'high_quality' as const,
+ retrieval_model_dict: {
+ search_method: RETRIEVE_METHOD.semantic,
+ reranking_enable: false,
+ reranking_mode: undefined,
+ reranking_model: {
+ reranking_provider_name: '',
+ reranking_model_name: '',
+ },
+ weights: undefined,
+ top_k: 10,
+ score_threshold_enabled: false,
+ score_threshold: 0.5,
+ },
+ is_multimodal: false,
+} as Partial
+
+vi.mock('use-context-selector', () => ({
+ useContext: vi.fn(() => ({ dataset: mockDataset })),
+ useContextSelector: vi.fn((_, selector) => selector({ dataset: mockDataset })),
+ createContext: vi.fn(() => ({})),
+}))
+
+// Mock dataset detail context
+vi.mock('@/context/dataset-detail', () => ({
+ default: {},
+ useDatasetDetailContext: vi.fn(() => ({ dataset: mockDataset })),
+ useDatasetDetailContextWithSelector: vi.fn((selector: (v: { dataset?: typeof mockDataset }) => unknown) =>
+ selector({ dataset: mockDataset as DataSet }),
+ ),
+}))
+
+// Mock service hooks
+const mockRecordsRefetch = vi.fn()
+const mockHitTestingMutateAsync = vi.fn()
+const mockExternalHitTestingMutateAsync = vi.fn()
+
+vi.mock('@/service/knowledge/use-dataset', () => ({
+ useDatasetTestingRecords: vi.fn(() => ({
+ data: {
+ data: [],
+ total: 0,
+ page: 1,
+ limit: 10,
+ has_more: false,
+ },
+ refetch: mockRecordsRefetch,
+ isLoading: false,
+ })),
+}))
+
+vi.mock('@/service/knowledge/use-hit-testing', () => ({
+ useHitTesting: vi.fn(() => ({
+ mutateAsync: mockHitTestingMutateAsync,
+ isPending: false,
+ })),
+ useExternalKnowledgeBaseHitTesting: vi.fn(() => ({
+ mutateAsync: mockExternalHitTestingMutateAsync,
+ isPending: false,
+ })),
+}))
+
+// Mock breakpoints hook
+vi.mock('@/hooks/use-breakpoints', () => ({
+ default: vi.fn(() => 'pc'),
+ MediaType: {
+ mobile: 'mobile',
+ pc: 'pc',
+ },
+}))
+
+// Mock timestamp hook
+vi.mock('@/hooks/use-timestamp', () => ({
+ default: vi.fn(() => ({
+ formatTime: vi.fn((timestamp: number, _format: string) => new Date(timestamp * 1000).toISOString()),
+ })),
+}))
+
+// Mock use-common to avoid QueryClient issues in nested hooks
+vi.mock('@/service/use-common', () => ({
+ useFileUploadConfig: vi.fn(() => ({
+ data: {
+ file_size_limit: 10,
+ batch_count_limit: 5,
+ image_file_size_limit: 5,
+ },
+ isLoading: false,
+ })),
+}))
+
+// Store ref to ImageUploader onChange for testing
+let mockImageUploaderOnChange: ((files: Array<{ sourceUrl?: string, uploadedId?: string, mimeType: string, name: string, size: number, extension: string }>) => void) | null = null
+
+// Mock ImageUploaderInRetrievalTesting to capture onChange
+vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', () => ({
+ default: ({ textArea, actionButton, onChange }: {
+ textArea: React.ReactNode
+ actionButton: React.ReactNode
+ onChange: (files: Array<{ sourceUrl?: string, uploadedId?: string, mimeType: string, name: string, size: number, extension: string }>) => void
+ }) => {
+ mockImageUploaderOnChange = onChange
+ return (
+
+ {textArea}
+ {actionButton}
+
+
+ )
+ },
+}))
+
+// Mock docLink hook
+vi.mock('@/context/i18n', () => ({
+ useDocLink: vi.fn(() => () => 'https://docs.example.com'),
+}))
+
+// Mock provider context for retrieval method config
+vi.mock('@/context/provider-context', () => ({
+ useProviderContext: vi.fn(() => ({
+ supportRetrievalMethods: [
+ 'semantic_search',
+ 'full_text_search',
+ 'hybrid_search',
+ ],
+ })),
+}))
+
+// Mock model list hook - include all exports used by child components
+vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
+ useModelList: vi.fn(() => ({
+ data: [],
+ isLoading: false,
+ })),
+ useModelListAndDefaultModelAndCurrentProviderAndModel: vi.fn(() => ({
+ modelList: [],
+ defaultModel: undefined,
+ currentProvider: undefined,
+ currentModel: undefined,
+ })),
+ useModelListAndDefaultModel: vi.fn(() => ({
+ modelList: [],
+ defaultModel: undefined,
+ })),
+ useCurrentProviderAndModel: vi.fn(() => ({
+ currentProvider: undefined,
+ currentModel: undefined,
+ })),
+ useDefaultModel: vi.fn(() => ({
+ defaultModel: undefined,
+ })),
+}))
+
+// ============================================================================
+// Test Wrapper with QueryClientProvider
+// ============================================================================
+
+const createTestQueryClient = () => new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ gcTime: 0,
+ },
+ mutations: {
+ retry: false,
+ },
+ },
+})
+
+const TestWrapper = ({ children }: { children: ReactNode }) => {
+ const queryClient = createTestQueryClient()
+ return (
+
+ {children}
+
+ )
+}
+
+const renderWithProviders = (ui: React.ReactElement) => {
+ return render(ui, { wrapper: TestWrapper })
+}
+
+// ============================================================================
+// Test Factories
+// ============================================================================
+
+const createMockSegment = (overrides = {}) => ({
+ id: 'segment-1',
+ document: {
+ id: 'doc-1',
+ data_source_type: 'upload_file',
+ name: 'test-document.pdf',
+ doc_type: 'book' as const,
+ },
+ content: 'Test segment content',
+ sign_content: 'Test signed content',
+ position: 1,
+ word_count: 100,
+ tokens: 50,
+ keywords: ['test', 'keyword'],
+ hit_count: 5,
+ index_node_hash: 'hash-123',
+ answer: '',
+ ...overrides,
+})
+
+const createMockHitTesting = (overrides = {}): HitTesting => ({
+ segment: createMockSegment() as HitTesting['segment'],
+ content: createMockSegment() as HitTesting['content'],
+ score: 0.85,
+ tsne_position: { x: 0.5, y: 0.5 },
+ child_chunks: null,
+ files: [],
+ ...overrides,
+})
+
+const createMockChildChunk = (overrides = {}): HitTestingChildChunk => ({
+ id: 'child-chunk-1',
+ content: 'Child chunk content',
+ position: 1,
+ score: 0.9,
+ ...overrides,
+})
+
+const createMockRecord = (overrides = {}): HitTestingRecord => ({
+ id: 'record-1',
+ source: 'hit_testing',
+ source_app_id: 'app-1',
+ created_by_role: 'account',
+ created_by: 'user-1',
+ created_at: 1609459200,
+ queries: [
+ { content: 'Test query', content_type: 'text_query', file_info: null },
+ ],
+ ...overrides,
+})
+
+const createMockRetrievalConfig = (overrides = {}): RetrievalConfig => ({
+ search_method: RETRIEVE_METHOD.semantic,
+ reranking_enable: false,
+ reranking_mode: undefined,
+ reranking_model: {
+ reranking_provider_name: '',
+ reranking_model_name: '',
+ },
+ weights: undefined,
+ top_k: 10,
+ score_threshold_enabled: false,
+ score_threshold: 0.5,
+ ...overrides,
+} as RetrievalConfig)
+
+// ============================================================================
+// Utility Function Tests
+// ============================================================================
+
+describe('extensionToFileType', () => {
+ describe('PDF files', () => {
+ it('should return pdf type for pdf extension', () => {
+ expect(extensionToFileType('pdf')).toBe(FileAppearanceTypeEnum.pdf)
+ })
+ })
+
+ describe('Word files', () => {
+ it('should return word type for doc extension', () => {
+ expect(extensionToFileType('doc')).toBe(FileAppearanceTypeEnum.word)
+ })
+
+ it('should return word type for docx extension', () => {
+ expect(extensionToFileType('docx')).toBe(FileAppearanceTypeEnum.word)
+ })
+ })
+
+ describe('Markdown files', () => {
+ it('should return markdown type for md extension', () => {
+ expect(extensionToFileType('md')).toBe(FileAppearanceTypeEnum.markdown)
+ })
+
+ it('should return markdown type for mdx extension', () => {
+ expect(extensionToFileType('mdx')).toBe(FileAppearanceTypeEnum.markdown)
+ })
+
+ it('should return markdown type for markdown extension', () => {
+ expect(extensionToFileType('markdown')).toBe(FileAppearanceTypeEnum.markdown)
+ })
+ })
+
+ describe('Excel files', () => {
+ it('should return excel type for csv extension', () => {
+ expect(extensionToFileType('csv')).toBe(FileAppearanceTypeEnum.excel)
+ })
+
+ it('should return excel type for xls extension', () => {
+ expect(extensionToFileType('xls')).toBe(FileAppearanceTypeEnum.excel)
+ })
+
+ it('should return excel type for xlsx extension', () => {
+ expect(extensionToFileType('xlsx')).toBe(FileAppearanceTypeEnum.excel)
+ })
+ })
+
+ describe('Document files', () => {
+ it('should return document type for txt extension', () => {
+ expect(extensionToFileType('txt')).toBe(FileAppearanceTypeEnum.document)
+ })
+
+ it('should return document type for epub extension', () => {
+ expect(extensionToFileType('epub')).toBe(FileAppearanceTypeEnum.document)
+ })
+
+ it('should return document type for html extension', () => {
+ expect(extensionToFileType('html')).toBe(FileAppearanceTypeEnum.document)
+ })
+
+ it('should return document type for htm extension', () => {
+ expect(extensionToFileType('htm')).toBe(FileAppearanceTypeEnum.document)
+ })
+
+ it('should return document type for xml extension', () => {
+ expect(extensionToFileType('xml')).toBe(FileAppearanceTypeEnum.document)
+ })
+ })
+
+ describe('PowerPoint files', () => {
+ it('should return ppt type for ppt extension', () => {
+ expect(extensionToFileType('ppt')).toBe(FileAppearanceTypeEnum.ppt)
+ })
+
+ it('should return ppt type for pptx extension', () => {
+ expect(extensionToFileType('pptx')).toBe(FileAppearanceTypeEnum.ppt)
+ })
+ })
+
+ describe('Edge cases', () => {
+ it('should return custom type for unknown extension', () => {
+ expect(extensionToFileType('unknown')).toBe(FileAppearanceTypeEnum.custom)
+ })
+
+ it('should return custom type for empty string', () => {
+ expect(extensionToFileType('')).toBe(FileAppearanceTypeEnum.custom)
+ })
+ })
+})
+
+// ============================================================================
+// Score Component Tests
+// ============================================================================
+
+describe('Score', () => {
+ describe('Rendering', () => {
+ it('should render score with correct value', () => {
+ render()
+ expect(screen.getByText('0.85')).toBeInTheDocument()
+ expect(screen.getByText('score')).toBeInTheDocument()
+ })
+
+ it('should render nothing when value is null', () => {
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('should render nothing when value is NaN', () => {
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('should render nothing when value is 0', () => {
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+ })
+
+ describe('Props', () => {
+ it('should apply besideChunkName styles when prop is true', () => {
+ const { container } = render()
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('border-l-0')
+ })
+
+ it('should apply rounded styles when besideChunkName is false', () => {
+ const { container } = render()
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('rounded-md')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should display full score correctly', () => {
+ render()
+ expect(screen.getByText('1.00')).toBeInTheDocument()
+ })
+
+ it('should display very small score correctly', () => {
+ render()
+ expect(screen.getByText('0.01')).toBeInTheDocument()
+ })
+ })
+})
+
+// ============================================================================
+// Mask Component Tests
+// ============================================================================
+
+describe('Mask', () => {
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const { container } = render()
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should have gradient background class', () => {
+ const { container } = render()
+ expect(container.firstChild).toHaveClass('bg-gradient-to-b')
+ })
+ })
+
+ describe('Props', () => {
+ it('should apply custom className', () => {
+ const { container } = render()
+ expect(container.firstChild).toHaveClass('custom-class')
+ })
+ })
+})
+
+// ============================================================================
+// EmptyRecords Component Tests
+// ============================================================================
+
+describe('EmptyRecords', () => {
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByText(/noRecentTip/i)).toBeInTheDocument()
+ })
+
+ it('should render history icon', () => {
+ const { container } = render()
+ const icon = container.querySelector('svg')
+ expect(icon).toBeInTheDocument()
+ })
+ })
+})
+
+// ============================================================================
+// ResultItemMeta Component Tests
+// ============================================================================
+
+describe('ResultItemMeta', () => {
+ const defaultProps = {
+ labelPrefix: 'Chunk',
+ positionId: 1,
+ wordCount: 100,
+ score: 0.85,
+ }
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByText(/100/)).toBeInTheDocument()
+ })
+
+ it('should render score component', () => {
+ render()
+ expect(screen.getByText('0.85')).toBeInTheDocument()
+ })
+
+ it('should render word count', () => {
+ render()
+ expect(screen.getByText(/100/)).toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should apply custom className', () => {
+ const { container } = render()
+ expect(container.firstChild).toHaveClass('custom-class')
+ })
+
+ it('should handle different position IDs', () => {
+ render()
+ // Position ID is passed to SegmentIndexTag
+ expect(screen.getByText(/42/)).toBeInTheDocument()
+ })
+ })
+})
+
+// ============================================================================
+// ResultItemFooter Component Tests
+// ============================================================================
+
+describe('ResultItemFooter', () => {
+ const mockShowDetailModal = vi.fn()
+ const defaultProps = {
+ docType: FileAppearanceTypeEnum.pdf,
+ docTitle: 'Test Document.pdf',
+ showDetailModal: mockShowDetailModal,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByText('Test Document.pdf')).toBeInTheDocument()
+ })
+
+ it('should render open button', () => {
+ render()
+ expect(screen.getByText(/open/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call showDetailModal when open button is clicked', async () => {
+ render()
+
+ const openButton = screen.getByText(/open/i).parentElement
+ if (openButton)
+ fireEvent.click(openButton)
+
+ expect(mockShowDetailModal).toHaveBeenCalledTimes(1)
+ })
+ })
+})
+
+// ============================================================================
+// ChildChunksItem Component Tests
+// ============================================================================
+
+describe('ChildChunksItem', () => {
+ const mockChildChunk = createMockChildChunk()
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByText(/Child chunk content/)).toBeInTheDocument()
+ })
+
+ it('should render position identifier', () => {
+ render()
+ // The C- and position number are in the same element
+ expect(screen.getByText(/C-/)).toBeInTheDocument()
+ })
+
+ it('should render score', () => {
+ render()
+ expect(screen.getByText('0.90')).toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should apply line-clamp when isShowAll is false', () => {
+ const { container } = render()
+ expect(container.firstChild).toHaveClass('line-clamp-2')
+ })
+
+ it('should not apply line-clamp when isShowAll is true', () => {
+ const { container } = render()
+ expect(container.firstChild).not.toHaveClass('line-clamp-2')
+ })
+ })
+})
+
+// ============================================================================
+// ResultItem Component Tests
+// ============================================================================
+
+describe('ResultItem', () => {
+ const mockHitTesting = createMockHitTesting()
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ // Document name should be visible
+ expect(screen.getByText('test-document.pdf')).toBeInTheDocument()
+ })
+
+ it('should render score', () => {
+ render()
+ expect(screen.getByText('0.85')).toBeInTheDocument()
+ })
+
+ it('should render document name in footer', () => {
+ render()
+ expect(screen.getByText('test-document.pdf')).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should open detail modal when clicked', async () => {
+ render()
+
+ const item = screen.getByText('test-document.pdf').closest('.cursor-pointer')
+ if (item)
+ fireEvent.click(item)
+
+ await waitFor(() => {
+ expect(screen.getByText(/chunkDetail/i)).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Parent-Child Retrieval', () => {
+ it('should render child chunks when present', () => {
+ const payloadWithChildren = createMockHitTesting({
+ child_chunks: [createMockChildChunk()],
+ })
+
+ render()
+ expect(screen.getByText(/hitChunks/i)).toBeInTheDocument()
+ })
+
+ it('should toggle fold state when child chunks header is clicked', async () => {
+ const payloadWithChildren = createMockHitTesting({
+ child_chunks: [createMockChildChunk()],
+ })
+
+ render()
+
+ // Child chunks should be visible by default (not folded)
+ expect(screen.getByText(/Child chunk content/)).toBeInTheDocument()
+
+ // Click to fold
+ const toggleButton = screen.getByText(/hitChunks/i).parentElement
+ if (toggleButton) {
+ fireEvent.click(toggleButton)
+
+ await waitFor(() => {
+ expect(screen.queryByText(/Child chunk content/)).not.toBeInTheDocument()
+ })
+ }
+ })
+ })
+
+ describe('Keywords', () => {
+ it('should render keywords when present and no child chunks', () => {
+ const payload = createMockHitTesting({
+ segment: createMockSegment({ keywords: ['keyword1', 'keyword2'] }),
+ child_chunks: null,
+ })
+
+ render()
+ expect(screen.getByText('keyword1')).toBeInTheDocument()
+ expect(screen.getByText('keyword2')).toBeInTheDocument()
+ })
+
+ it('should not render keywords when child chunks are present', () => {
+ const payload = createMockHitTesting({
+ segment: createMockSegment({ keywords: ['keyword1'] }),
+ child_chunks: [createMockChildChunk()],
+ })
+
+ render()
+ expect(screen.queryByText('keyword1')).not.toBeInTheDocument()
+ })
+ })
+})
+
+// ============================================================================
+// ResultItemExternal Component Tests
+// ============================================================================
+
+describe('ResultItemExternal', () => {
+ const defaultProps = {
+ payload: {
+ content: 'External content',
+ title: 'External Title',
+ score: 0.75,
+ metadata: {
+ 'x-amz-bedrock-kb-source-uri': 'source-uri',
+ 'x-amz-bedrock-kb-data-source-id': 'data-source-id',
+ },
+ },
+ positionId: 1,
+ }
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByText('External content')).toBeInTheDocument()
+ })
+
+ it('should render title in footer', () => {
+ render()
+ expect(screen.getByText('External Title')).toBeInTheDocument()
+ })
+
+ it('should render score', () => {
+ render()
+ expect(screen.getByText('0.75')).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should open detail modal when clicked', async () => {
+ render()
+
+ const item = screen.getByText('External content').closest('.cursor-pointer')
+ if (item)
+ fireEvent.click(item)
+
+ await waitFor(() => {
+ expect(screen.getByText(/chunkDetail/i)).toBeInTheDocument()
+ })
+ })
+ })
+})
+
+// ============================================================================
+// Textarea Component Tests
+// ============================================================================
+
+describe('Textarea', () => {
+ const mockHandleTextChange = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ })
+
+ it('should display text value', () => {
+ render()
+ expect(screen.getByDisplayValue('Test input')).toBeInTheDocument()
+ })
+
+ it('should display character count', () => {
+ render()
+ expect(screen.getByText('5/200')).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call handleTextChange when typing', async () => {
+ render()
+
+ const textarea = screen.getByRole('textbox')
+ fireEvent.change(textarea, { target: { value: 'New text' } })
+
+ expect(mockHandleTextChange).toHaveBeenCalled()
+ })
+ })
+
+ describe('Validation', () => {
+ it('should show warning style when text exceeds 200 characters', () => {
+ const longText = 'a'.repeat(201)
+ const { container } = render()
+
+ expect(container.querySelector('.border-state-destructive-active')).toBeInTheDocument()
+ })
+
+ it('should show warning count when text exceeds 200 characters', () => {
+ const longText = 'a'.repeat(201)
+ render()
+
+ expect(screen.getByText('201/200')).toBeInTheDocument()
+ })
+ })
+})
+
+// ============================================================================
+// Records Component Tests
+// ============================================================================
+
+describe('Records', () => {
+ const mockOnClickRecord = vi.fn()
+ const mockRecords = [
+ createMockRecord({ id: 'record-1', created_at: 1609459200 }),
+ createMockRecord({ id: 'record-2', created_at: 1609545600 }),
+ ]
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByText(/queryContent/i)).toBeInTheDocument()
+ })
+
+ it('should render all records', () => {
+ render()
+ // Each record has "Test query" as content
+ expect(screen.getAllByText('Test query')).toHaveLength(2)
+ })
+
+ it('should render table headers', () => {
+ render()
+ expect(screen.getByText(/queryContent/i)).toBeInTheDocument()
+ expect(screen.getByText(/source/i)).toBeInTheDocument()
+ expect(screen.getByText(/time/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onClickRecord when a record row is clicked', async () => {
+ render()
+
+ // Find the table body row with the query content
+ const queryText = screen.getAllByText('Test query')[0]
+ const row = queryText.closest('tr')
+ if (row)
+ fireEvent.click(row)
+
+ expect(mockOnClickRecord).toHaveBeenCalledTimes(1)
+ })
+
+ it('should toggle sort order when time header is clicked', async () => {
+ render()
+
+ const timeHeader = screen.getByText(/time/i)
+ fireEvent.click(timeHeader)
+
+ // Sort order should have toggled (default is desc, now should be asc)
+ // The records should be reordered
+ await waitFor(() => {
+ const rows = screen.getAllByText('Test query')
+ expect(rows).toHaveLength(2)
+ })
+ })
+ })
+
+ describe('Source Display', () => {
+ it('should display source correctly for hit_testing', () => {
+ render()
+ expect(screen.getAllByText(/retrieval test/i)).toHaveLength(2)
+ })
+
+ it('should display source correctly for app', () => {
+ const appRecords = [createMockRecord({ source: 'app' })]
+ render()
+ expect(screen.getByText('app')).toBeInTheDocument()
+ })
+ })
+})
+
+// ============================================================================
+// ModifyExternalRetrievalModal Component Tests
+// ============================================================================
+
+describe('ModifyExternalRetrievalModal', () => {
+ const mockOnClose = vi.fn()
+ const mockOnSave = vi.fn()
+ const defaultProps = {
+ onClose: mockOnClose,
+ onSave: mockOnSave,
+ initialTopK: 4,
+ initialScoreThreshold: 0.5,
+ initialScoreThresholdEnabled: false,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByText(/settingTitle/i)).toBeInTheDocument()
+ })
+
+ it('should render cancel and save buttons', () => {
+ render()
+ expect(screen.getByText(/cancel/i)).toBeInTheDocument()
+ expect(screen.getByText(/save/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onClose when cancel is clicked', async () => {
+ render()
+
+ fireEvent.click(screen.getByText(/cancel/i))
+
+ expect(mockOnClose).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onSave with settings when save is clicked', async () => {
+ render()
+
+ fireEvent.click(screen.getByText(/save/i))
+
+ expect(mockOnSave).toHaveBeenCalledWith({
+ top_k: 4,
+ score_threshold: 0.5,
+ score_threshold_enabled: false,
+ })
+ })
+
+ it('should call onClose when close button is clicked', async () => {
+ render()
+
+ const closeButton = screen.getByRole('button', { name: '' })
+ fireEvent.click(closeButton)
+
+ expect(mockOnClose).toHaveBeenCalled()
+ })
+ })
+
+ describe('Settings Change Handling', () => {
+ it('should update top_k when settings change', async () => {
+ render()
+
+ // Click the button to change top_k
+ fireEvent.click(screen.getByTestId('change-top-k'))
+
+ // Save to verify the change
+ fireEvent.click(screen.getByText(/save/i))
+
+ expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({
+ top_k: 8,
+ }))
+ })
+
+ it('should update score_threshold when settings change', async () => {
+ render()
+
+ // Click the button to change score_threshold
+ fireEvent.click(screen.getByTestId('change-score-threshold'))
+
+ fireEvent.click(screen.getByText(/save/i))
+
+ expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({
+ score_threshold: 0.9,
+ }))
+ })
+
+ it('should update score_threshold_enabled when settings change', async () => {
+ render()
+
+ // Click the button to change score_threshold_enabled
+ fireEvent.click(screen.getByTestId('change-score-enabled'))
+
+ fireEvent.click(screen.getByText(/save/i))
+
+ expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({
+ score_threshold_enabled: true,
+ }))
+ })
+
+ it('should call onClose after save', async () => {
+ render()
+
+ fireEvent.click(screen.getByText(/save/i))
+
+ // onClose should be called after onSave
+ expect(mockOnClose).toHaveBeenCalled()
+ })
+
+ it('should render with different initial values', () => {
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByText(/save/i))
+
+ expect(mockOnSave).toHaveBeenCalledWith({
+ top_k: 10,
+ score_threshold: 0.8,
+ score_threshold_enabled: true,
+ })
+ })
+
+ it('should handle partial settings changes', async () => {
+ render()
+
+ // Change only top_k
+ fireEvent.click(screen.getByTestId('change-top-k'))
+
+ fireEvent.click(screen.getByText(/save/i))
+
+ // Should have updated top_k while keeping other values
+ expect(mockOnSave).toHaveBeenCalledWith({
+ top_k: 8,
+ score_threshold: 0.5,
+ score_threshold_enabled: false,
+ })
+ })
+
+ it('should handle multiple settings changes', async () => {
+ render()
+
+ // Change multiple settings
+ fireEvent.click(screen.getByTestId('change-top-k'))
+ fireEvent.click(screen.getByTestId('change-score-threshold'))
+ fireEvent.click(screen.getByTestId('change-score-enabled'))
+
+ fireEvent.click(screen.getByText(/save/i))
+
+ expect(mockOnSave).toHaveBeenCalledWith({
+ top_k: 8,
+ score_threshold: 0.9,
+ score_threshold_enabled: true,
+ })
+ })
+ })
+})
+
+// ============================================================================
+// ModifyRetrievalModal Component Tests
+// ============================================================================
+
+describe('ModifyRetrievalModal', () => {
+ const mockOnHide = vi.fn()
+ const mockOnSave = vi.fn()
+ const defaultProps = {
+ indexMethod: 'high_quality',
+ value: createMockRetrievalConfig(),
+ isShow: true,
+ onHide: mockOnHide,
+ onSave: mockOnSave,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing when isShow is true', () => {
+ const { container } = renderWithProviders()
+ // Modal should be rendered
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render nothing when isShow is false', () => {
+ const { container } = renderWithProviders()
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('should render cancel and save buttons', () => {
+ renderWithProviders()
+ const buttons = screen.getAllByRole('button')
+ expect(buttons.length).toBeGreaterThanOrEqual(2)
+ })
+
+ it('should render learn more link', () => {
+ renderWithProviders()
+ const link = screen.getByRole('link')
+ expect(link).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onHide when cancel button is clicked', async () => {
+ renderWithProviders()
+
+ // Find cancel button (second to last button typically)
+ const buttons = screen.getAllByRole('button')
+ const cancelButton = buttons.find(btn => btn.textContent?.toLowerCase().includes('cancel'))
+ if (cancelButton)
+ fireEvent.click(cancelButton)
+
+ expect(mockOnHide).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onHide when close icon is clicked', async () => {
+ const { container } = renderWithProviders()
+
+ // Find close button by its position (usually has the close icon)
+ const closeButton = container.querySelector('.cursor-pointer')
+ if (closeButton)
+ fireEvent.click(closeButton)
+
+ expect(mockOnHide).toHaveBeenCalled()
+ })
+
+ it('should call onSave when save button is clicked', async () => {
+ renderWithProviders()
+
+ const buttons = screen.getAllByRole('button')
+ const saveButton = buttons.find(btn => btn.textContent?.toLowerCase().includes('save'))
+ if (saveButton)
+ fireEvent.click(saveButton)
+
+ expect(mockOnSave).toHaveBeenCalled()
+ })
+ })
+
+ describe('Index Method', () => {
+ it('should render for high_quality index method', () => {
+ const { container } = renderWithProviders()
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render for economy index method', () => {
+ const { container } = renderWithProviders()
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+})
+
+// ============================================================================
+// ChunkDetailModal Component Tests
+// ============================================================================
+
+describe('ChunkDetailModal', () => {
+ const mockOnHide = vi.fn()
+ const mockPayload = createMockHitTesting()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByText(/chunkDetail/i)).toBeInTheDocument()
+ })
+
+ it('should render document name', () => {
+ render()
+ expect(screen.getByText('test-document.pdf')).toBeInTheDocument()
+ })
+
+ it('should render score', () => {
+ render()
+ expect(screen.getByText('0.85')).toBeInTheDocument()
+ })
+ })
+
+ describe('Parent-Child Retrieval', () => {
+ it('should render child chunks section when present', () => {
+ const payloadWithChildren = createMockHitTesting({
+ child_chunks: [createMockChildChunk()],
+ })
+
+ render()
+ expect(screen.getByText(/hitChunks/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('Keywords', () => {
+ it('should render keywords section when present and no child chunks', () => {
+ const payload = createMockHitTesting({
+ segment: createMockSegment({ keywords: ['keyword1', 'keyword2'] }),
+ child_chunks: null,
+ })
+
+ render()
+ // Keywords should be rendered as tags
+ expect(screen.getByText('keyword1')).toBeInTheDocument()
+ expect(screen.getByText('keyword2')).toBeInTheDocument()
+ })
+ })
+
+ describe('Q&A Mode', () => {
+ it('should render Q&A format when answer is present', () => {
+ const payload = createMockHitTesting({
+ segment: createMockSegment({
+ content: 'Question content',
+ answer: 'Answer content',
+ }),
+ })
+
+ render()
+ expect(screen.getByText('Q')).toBeInTheDocument()
+ expect(screen.getByText('A')).toBeInTheDocument()
+ expect(screen.getByText('Question content')).toBeInTheDocument()
+ expect(screen.getByText('Answer content')).toBeInTheDocument()
+ })
+ })
+})
+
+// ============================================================================
+// QueryInput Component Tests
+// ============================================================================
+
+describe('QueryInput', () => {
+ const mockSetHitResult = vi.fn()
+ const mockSetExternalHitResult = vi.fn()
+ const mockOnUpdateList = vi.fn()
+ const mockSetQueries = vi.fn()
+ const mockOnClickRetrievalMethod = vi.fn()
+ const mockOnSubmit = vi.fn()
+
+ const defaultProps = {
+ setHitResult: mockSetHitResult,
+ setExternalHitResult: mockSetExternalHitResult,
+ onUpdateList: mockOnUpdateList,
+ loading: false,
+ queries: [] as Query[],
+ setQueries: mockSetQueries,
+ isExternal: false,
+ onClickRetrievalMethod: mockOnClickRetrievalMethod,
+ retrievalConfig: createMockRetrievalConfig(),
+ isEconomy: false,
+ onSubmit: mockOnSubmit,
+ hitTestingMutation: mockHitTestingMutateAsync,
+ externalKnowledgeBaseHitTestingMutation: mockExternalHitTestingMutateAsync,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const { container } = render()
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render textarea', () => {
+ render()
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ })
+
+ it('should render testing button', () => {
+ render()
+ // Find button by role
+ const buttons = screen.getAllByRole('button')
+ expect(buttons.length).toBeGreaterThan(0)
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should update queries when text changes', async () => {
+ render()
+
+ const textarea = screen.getByRole('textbox')
+ fireEvent.change(textarea, { target: { value: 'New query' } })
+
+ expect(mockSetQueries).toHaveBeenCalled()
+ })
+
+ it('should have disabled button when text is empty', () => {
+ render()
+
+ // Find the primary/submit button
+ const buttons = screen.getAllByRole('button')
+ const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]'))
+ expect(submitButton).toBeDisabled()
+ })
+
+ it('should enable button when text is present', () => {
+ const queries: Query[] = [{ content: 'Test query', content_type: 'text_query', file_info: null }]
+ render()
+
+ const buttons = screen.getAllByRole('button')
+ const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]'))
+ expect(submitButton).not.toBeDisabled()
+ })
+
+ it('should disable button when text exceeds 200 characters', () => {
+ const longQuery: Query[] = [{ content: 'a'.repeat(201), content_type: 'text_query', file_info: null }]
+ render()
+
+ const buttons = screen.getAllByRole('button')
+ const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]'))
+ expect(submitButton).toBeDisabled()
+ })
+
+ it('should show loading state on button when loading', () => {
+ const queries: Query[] = [{ content: 'Test query', content_type: 'text_query', file_info: null }]
+ render()
+
+ const buttons = screen.getAllByRole('button')
+ const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]'))
+ // Button should have disabled styling classes
+ expect(submitButton).toHaveClass('disabled:btn-disabled')
+ })
+ })
+
+ describe('External Mode', () => {
+ it('should render settings button for external mode', () => {
+ render()
+ // In external mode, there should be a settings button
+ const buttons = screen.getAllByRole('button')
+ expect(buttons.length).toBeGreaterThanOrEqual(2)
+ })
+
+ it('should open settings modal when settings button is clicked', async () => {
+ renderWithProviders()
+
+ // Find the settings button (not the submit button)
+ const buttons = screen.getAllByRole('button')
+ const settingsButton = buttons.find(btn => !btn.classList.contains('w-[88px]'))
+ if (settingsButton)
+ fireEvent.click(settingsButton)
+
+ await waitFor(() => {
+ // The modal should render - look for more buttons after modal opens
+ expect(screen.getAllByRole('button').length).toBeGreaterThan(2)
+ })
+ })
+ })
+
+ describe('Non-External Mode', () => {
+ it('should render retrieval method selector for non-external mode', () => {
+ const { container } = renderWithProviders()
+ // Should have the retrieval method display (a clickable div)
+ const methodSelector = container.querySelector('.cursor-pointer')
+ expect(methodSelector).toBeInTheDocument()
+ })
+
+ it('should call onClickRetrievalMethod when clicked', async () => {
+ const { container } = renderWithProviders()
+
+ // Find the method selector (the cursor-pointer div that's not a button)
+ const methodSelectors = container.querySelectorAll('.cursor-pointer')
+ const methodSelector = Array.from(methodSelectors).find(el => !el.closest('button'))
+ if (methodSelector)
+ fireEvent.click(methodSelector)
+
+ expect(mockOnClickRetrievalMethod).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('Submission', () => {
+ it('should call hitTestingMutation when submit is clicked for non-external', async () => {
+ const queries: Query[] = [{ content: 'Test query', content_type: 'text_query', file_info: null }]
+ mockHitTestingMutateAsync.mockResolvedValue({ records: [] })
+
+ render()
+
+ const buttons = screen.getAllByRole('button')
+ const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]'))
+ if (submitButton)
+ fireEvent.click(submitButton)
+
+ await waitFor(() => {
+ expect(mockHitTestingMutateAsync).toHaveBeenCalled()
+ })
+ })
+
+ it('should call externalKnowledgeBaseHitTestingMutation when submit is clicked for external', async () => {
+ const queries: Query[] = [{ content: 'Test query', content_type: 'text_query', file_info: null }]
+ mockExternalHitTestingMutateAsync.mockResolvedValue({ records: [] })
+
+ render()
+
+ const buttons = screen.getAllByRole('button')
+ const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]'))
+ if (submitButton)
+ fireEvent.click(submitButton)
+
+ await waitFor(() => {
+ expect(mockExternalHitTestingMutateAsync).toHaveBeenCalled()
+ })
+ })
+
+ it('should call setHitResult and onUpdateList on successful non-external submission', async () => {
+ const queries: Query[] = [{ content: 'Test query', content_type: 'text_query', file_info: null }]
+ const mockResponse = { query: { content: 'test' }, records: [] }
+
+ mockHitTestingMutateAsync.mockImplementation(async (_params, options) => {
+ options?.onSuccess?.(mockResponse)
+ return mockResponse
+ })
+
+ renderWithProviders()
+
+ const buttons = screen.getAllByRole('button')
+ const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]'))
+ if (submitButton)
+ fireEvent.click(submitButton)
+
+ await waitFor(() => {
+ expect(mockSetHitResult).toHaveBeenCalledWith(mockResponse)
+ expect(mockOnUpdateList).toHaveBeenCalled()
+ expect(mockOnSubmit).toHaveBeenCalled()
+ })
+ })
+
+ it('should call setExternalHitResult and onUpdateList on successful external submission', async () => {
+ const queries: Query[] = [{ content: 'Test query', content_type: 'text_query', file_info: null }]
+ const mockResponse = { query: { content: 'test' }, records: [] }
+
+ mockExternalHitTestingMutateAsync.mockImplementation(async (_params, options) => {
+ options?.onSuccess?.(mockResponse)
+ return mockResponse
+ })
+
+ renderWithProviders()
+
+ const buttons = screen.getAllByRole('button')
+ const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]'))
+ if (submitButton)
+ fireEvent.click(submitButton)
+
+ await waitFor(() => {
+ expect(mockSetExternalHitResult).toHaveBeenCalledWith(mockResponse)
+ expect(mockOnUpdateList).toHaveBeenCalled()
+ })
+ })
+ })
+
+ describe('Image Queries', () => {
+ it('should handle queries with image_query type', () => {
+ const queriesWithImages: Query[] = [
+ { content: 'Test query', content_type: 'text_query', file_info: null },
+ {
+ content: 'http://example.com/image.png',
+ content_type: 'image_query',
+ file_info: {
+ id: 'file-1',
+ name: 'image.png',
+ size: 1000,
+ mime_type: 'image/png',
+ extension: 'png',
+ source_url: 'http://example.com/image.png',
+ },
+ },
+ ]
+
+ const { container } = renderWithProviders()
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should disable button when images are not all uploaded', () => {
+ const queriesWithUnuploadedImages: Query[] = [
+ {
+ content: 'http://example.com/image.png',
+ content_type: 'image_query',
+ file_info: {
+ id: '', // Empty id means not uploaded
+ name: 'image.png',
+ size: 1000,
+ mime_type: 'image/png',
+ extension: 'png',
+ source_url: 'http://example.com/image.png',
+ },
+ },
+ ]
+
+ renderWithProviders()
+
+ const buttons = screen.getAllByRole('button')
+ const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]'))
+ expect(submitButton).toBeDisabled()
+ })
+
+ it('should enable button when all images are uploaded', () => {
+ const queriesWithUploadedImages: Query[] = [
+ { content: 'Test query', content_type: 'text_query', file_info: null },
+ {
+ content: 'http://example.com/image.png',
+ content_type: 'image_query',
+ file_info: {
+ id: 'uploaded-file-1',
+ name: 'image.png',
+ size: 1000,
+ mime_type: 'image/png',
+ extension: 'png',
+ source_url: 'http://example.com/image.png',
+ },
+ },
+ ]
+
+ renderWithProviders()
+
+ const buttons = screen.getAllByRole('button')
+ const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]'))
+ expect(submitButton).not.toBeDisabled()
+ })
+
+ it('should call setQueries with image queries when images are added', async () => {
+ renderWithProviders()
+
+ // Trigger image change via mock button
+ fireEvent.click(screen.getByTestId('trigger-image-change'))
+
+ expect(mockSetQueries).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ expect.objectContaining({
+ content_type: 'image_query',
+ file_info: expect.objectContaining({
+ name: 'new-image.png',
+ mime_type: 'image/png',
+ }),
+ }),
+ ]),
+ )
+ })
+
+ it('should replace existing image queries when new images are added', async () => {
+ const existingQueries: Query[] = [
+ { content: 'text', content_type: 'text_query', file_info: null },
+ {
+ content: 'old-image',
+ content_type: 'image_query',
+ file_info: {
+ id: 'old-id',
+ name: 'old.png',
+ size: 500,
+ mime_type: 'image/png',
+ extension: 'png',
+ source_url: 'http://example.com/old.png',
+ },
+ },
+ ]
+
+ renderWithProviders()
+
+ // Trigger image change - should replace existing images
+ fireEvent.click(screen.getByTestId('trigger-image-change'))
+
+ expect(mockSetQueries).toHaveBeenCalled()
+ })
+
+ it('should handle empty source URL in file', async () => {
+ // Mock the onChange to return file without sourceUrl
+ renderWithProviders()
+
+ // The component should handle files with missing sourceUrl
+ if (mockImageUploaderOnChange) {
+ mockImageUploaderOnChange([
+ {
+ sourceUrl: undefined,
+ uploadedId: 'id-1',
+ mimeType: 'image/png',
+ name: 'image.png',
+ size: 1000,
+ extension: 'png',
+ },
+ ])
+ }
+
+ expect(mockSetQueries).toHaveBeenCalled()
+ })
+
+ it('should handle file without uploadedId', async () => {
+ renderWithProviders()
+
+ if (mockImageUploaderOnChange) {
+ mockImageUploaderOnChange([
+ {
+ sourceUrl: 'http://example.com/img.png',
+ uploadedId: undefined,
+ mimeType: 'image/png',
+ name: 'image.png',
+ size: 1000,
+ extension: 'png',
+ },
+ ])
+ }
+
+ expect(mockSetQueries).toHaveBeenCalled()
+ })
+ })
+
+ describe('Economy Mode', () => {
+ it('should use keyword search method when isEconomy is true', async () => {
+ const queries: Query[] = [{ content: 'Test query', content_type: 'text_query', file_info: null }]
+ mockHitTestingMutateAsync.mockResolvedValue({ records: [] })
+
+ renderWithProviders()
+
+ const buttons = screen.getAllByRole('button')
+ const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]'))
+ if (submitButton)
+ fireEvent.click(submitButton)
+
+ await waitFor(() => {
+ expect(mockHitTestingMutateAsync).toHaveBeenCalledWith(
+ expect.objectContaining({
+ retrieval_model: expect.objectContaining({
+ search_method: 'keyword_search',
+ }),
+ }),
+ expect.anything(),
+ )
+ })
+ })
+ })
+
+ describe('Text Query Handling', () => {
+ it('should add new text query when none exists', async () => {
+ renderWithProviders()
+
+ const textarea = screen.getByRole('textbox')
+ fireEvent.change(textarea, { target: { value: 'New query' } })
+
+ expect(mockSetQueries).toHaveBeenCalledWith([
+ expect.objectContaining({
+ content: 'New query',
+ content_type: 'text_query',
+ }),
+ ])
+ })
+
+ it('should update existing text query', async () => {
+ const existingQueries: Query[] = [{ content: 'Old query', content_type: 'text_query', file_info: null }]
+ renderWithProviders()
+
+ const textarea = screen.getByRole('textbox')
+ fireEvent.change(textarea, { target: { value: 'Updated query' } })
+
+ expect(mockSetQueries).toHaveBeenCalled()
+ })
+ })
+
+ describe('External Settings Modal', () => {
+ it('should save external retrieval settings when modal saves', async () => {
+ renderWithProviders()
+
+ // Open settings modal
+ const buttons = screen.getAllByRole('button')
+ const settingsButton = buttons.find(btn => !btn.classList.contains('w-[88px]'))
+ if (settingsButton)
+ fireEvent.click(settingsButton)
+
+ await waitFor(() => {
+ // Modal should be open - look for save button in modal
+ const allButtons = screen.getAllByRole('button')
+ expect(allButtons.length).toBeGreaterThan(2)
+ })
+
+ // Click save in modal
+ const saveButton = screen.getByText(/save/i)
+ fireEvent.click(saveButton)
+
+ // Modal should close
+ await waitFor(() => {
+ const buttonsAfterClose = screen.getAllByRole('button')
+ // Should have fewer buttons after modal closes
+ expect(buttonsAfterClose.length).toBeLessThanOrEqual(screen.getAllByRole('button').length)
+ })
+ })
+
+ it('should close settings modal when close button is clicked', async () => {
+ renderWithProviders()
+
+ // Open settings modal
+ const buttons = screen.getAllByRole('button')
+ const settingsButton = buttons.find(btn => !btn.classList.contains('w-[88px]'))
+ if (settingsButton)
+ fireEvent.click(settingsButton)
+
+ await waitFor(() => {
+ const allButtons = screen.getAllByRole('button')
+ expect(allButtons.length).toBeGreaterThan(2)
+ })
+
+ // Click cancel
+ const cancelButton = screen.getByText(/cancel/i)
+ fireEvent.click(cancelButton)
+
+ // Component should still be functional
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ })
+ })
+})
+
+// ============================================================================
+// HitTestingPage Component Tests
+// ============================================================================
+
+describe('HitTestingPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const { container } = renderWithProviders()
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render page title', () => {
+ renderWithProviders()
+ // Look for heading element
+ const heading = screen.getByRole('heading', { level: 1 })
+ expect(heading).toBeInTheDocument()
+ })
+
+ it('should render records section', () => {
+ const { container } = renderWithProviders()
+ // The records section should be present
+ expect(container.querySelector('.flex-col')).toBeInTheDocument()
+ })
+
+ it('should render query input', () => {
+ renderWithProviders()
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ })
+ })
+
+ describe('Loading States', () => {
+ it('should show loading when records are loading', async () => {
+ const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset')
+ vi.mocked(useDatasetTestingRecords).mockReturnValue({
+ data: undefined,
+ refetch: mockRecordsRefetch,
+ isLoading: true,
+ } as unknown as ReturnType)
+
+ const { container } = renderWithProviders()
+ // Loading component should be visible - look for the loading animation
+ const loadingElement = container.querySelector('[class*="animate"]') || container.querySelector('.flex-1')
+ expect(loadingElement).toBeInTheDocument()
+ })
+ })
+
+ describe('Empty States', () => {
+ it('should show empty records when no data', () => {
+ const { container } = renderWithProviders()
+ // EmptyRecords component should be rendered - check that the component is mounted
+ // The EmptyRecords has a specific structure with bg-workflow-process-bg class
+ const mainContainer = container.querySelector('.flex.h-full')
+ expect(mainContainer).toBeInTheDocument()
+ })
+ })
+
+ describe('Records Display', () => {
+ it('should display records when data is present', async () => {
+ const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset')
+ vi.mocked(useDatasetTestingRecords).mockReturnValue({
+ data: {
+ data: [createMockRecord()],
+ total: 1,
+ page: 1,
+ limit: 10,
+ has_more: false,
+ },
+ refetch: mockRecordsRefetch,
+ isLoading: false,
+ } as unknown as ReturnType)
+
+ renderWithProviders()
+ expect(screen.getByText('Test query')).toBeInTheDocument()
+ })
+ })
+
+ describe('Pagination', () => {
+ it('should show pagination when total exceeds limit', async () => {
+ const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset')
+ vi.mocked(useDatasetTestingRecords).mockReturnValue({
+ data: {
+ data: Array.from({ length: 10 }, (_, i) => createMockRecord({ id: `record-${i}` })),
+ total: 25,
+ page: 1,
+ limit: 10,
+ has_more: true,
+ },
+ refetch: mockRecordsRefetch,
+ isLoading: false,
+ } as unknown as ReturnType)
+
+ const { container } = renderWithProviders()
+ // Pagination should be visible - look for pagination controls
+ const paginationElement = container.querySelector('[class*="pagination"]') || container.querySelector('nav')
+ expect(paginationElement || screen.getAllByText('Test query').length > 0).toBeTruthy()
+ })
+ })
+
+ describe('Right Panel', () => {
+ it('should render right panel container', () => {
+ const { container } = renderWithProviders()
+ // The right panel should be present (on non-mobile)
+ const rightPanel = container.querySelector('.rounded-tl-2xl')
+ expect(rightPanel).toBeInTheDocument()
+ })
+ })
+
+ describe('Retrieval Modal', () => {
+ it('should open retrieval modal when method is clicked', async () => {
+ const { container } = renderWithProviders()
+
+ // Find the method selector (cursor-pointer div with the retrieval method)
+ const methodSelectors = container.querySelectorAll('.cursor-pointer')
+ const methodSelector = Array.from(methodSelectors).find(el => !el.closest('button') && !el.closest('tr'))
+
+ // Verify we found a method selector to click
+ expect(methodSelector).toBeTruthy()
+
+ if (methodSelector)
+ fireEvent.click(methodSelector)
+
+ // The component should still be functional after the click
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+
+ describe('Hit Results Display', () => {
+ it('should display hit results when hitResult has records', async () => {
+ const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset')
+ vi.mocked(useDatasetTestingRecords).mockReturnValue({
+ data: {
+ data: [],
+ total: 0,
+ page: 1,
+ limit: 10,
+ has_more: false,
+ },
+ refetch: mockRecordsRefetch,
+ isLoading: false,
+ } as unknown as ReturnType)
+
+ const { container } = renderWithProviders()
+
+ // The right panel should show empty state initially
+ expect(container.querySelector('.rounded-tl-2xl')).toBeInTheDocument()
+ })
+
+ it('should render loading skeleton when retrieval is in progress', async () => {
+ const { useHitTesting } = await import('@/service/knowledge/use-hit-testing')
+ vi.mocked(useHitTesting).mockReturnValue({
+ mutateAsync: mockHitTestingMutateAsync,
+ isPending: true,
+ } as unknown as ReturnType)
+
+ const { container } = renderWithProviders()
+
+ // Component should render without crashing
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render results when hit testing returns data', async () => {
+ // This test simulates the flow of getting hit results
+ const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset')
+ vi.mocked(useDatasetTestingRecords).mockReturnValue({
+ data: {
+ data: [],
+ total: 0,
+ page: 1,
+ limit: 10,
+ has_more: false,
+ },
+ refetch: mockRecordsRefetch,
+ isLoading: false,
+ } as unknown as ReturnType)
+
+ const { container } = renderWithProviders()
+
+ // The component should render the result display area
+ expect(container.querySelector('.bg-background-body')).toBeInTheDocument()
+ })
+ })
+
+ describe('Record Interaction', () => {
+ it('should update queries when a record is clicked', async () => {
+ const mockRecord = createMockRecord({
+ queries: [
+ { content: 'Record query text', content_type: 'text_query', file_info: null },
+ ],
+ })
+
+ const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset')
+ vi.mocked(useDatasetTestingRecords).mockReturnValue({
+ data: {
+ data: [mockRecord],
+ total: 1,
+ page: 1,
+ limit: 10,
+ has_more: false,
+ },
+ refetch: mockRecordsRefetch,
+ isLoading: false,
+ } as unknown as ReturnType)
+
+ renderWithProviders()
+
+ // Find and click the record row
+ const recordText = screen.getByText('Record query text')
+ const row = recordText.closest('tr')
+ if (row)
+ fireEvent.click(row)
+
+ // The query input should be updated - this causes re-render with new key
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ })
+ })
+
+ describe('External Dataset', () => {
+ it('should render external dataset UI when provider is external', async () => {
+ // Mock dataset with external provider
+ const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset')
+ vi.mocked(useDatasetTestingRecords).mockReturnValue({
+ data: {
+ data: [],
+ total: 0,
+ page: 1,
+ limit: 10,
+ has_more: false,
+ },
+ refetch: mockRecordsRefetch,
+ isLoading: false,
+ } as unknown as ReturnType)
+
+ const { container } = renderWithProviders()
+
+ // Component should render
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+
+ describe('Mobile View', () => {
+ it('should handle mobile breakpoint', async () => {
+ // Mock mobile breakpoint
+ const useBreakpoints = await import('@/hooks/use-breakpoints')
+ vi.mocked(useBreakpoints.default).mockReturnValue('mobile' as unknown as ReturnType)
+
+ const { container } = renderWithProviders()
+
+ // Component should still render
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+
+ describe('useEffect for mobile panel', () => {
+ it('should update right panel visibility based on mobile state', async () => {
+ const useBreakpoints = await import('@/hooks/use-breakpoints')
+
+ // First render with desktop
+ vi.mocked(useBreakpoints.default).mockReturnValue('pc' as unknown as ReturnType)
+
+ const { rerender, container } = renderWithProviders()
+
+ expect(container.firstChild).toBeInTheDocument()
+
+ // Re-render with mobile
+ vi.mocked(useBreakpoints.default).mockReturnValue('mobile' as unknown as ReturnType)
+
+ rerender(
+
+
+ ,
+ )
+
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+})
+
+// ============================================================================
+// Integration Tests
+// ============================================================================
+
+describe('Integration: Hit Testing Flow', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockHitTestingMutateAsync.mockReset()
+ mockExternalHitTestingMutateAsync.mockReset()
+ })
+
+ it('should complete a full hit testing flow', async () => {
+ const mockResponse: HitTestingResponse = {
+ query: { content: 'Test query', tsne_position: { x: 0, y: 0 } },
+ records: [createMockHitTesting()],
+ }
+
+ mockHitTestingMutateAsync.mockImplementation(async (_params, options) => {
+ options?.onSuccess?.(mockResponse)
+ return mockResponse
+ })
+
+ renderWithProviders()
+
+ // Type query
+ const textarea = screen.getByRole('textbox')
+ fireEvent.change(textarea, { target: { value: 'Test query' } })
+
+ // Find submit button by class
+ const buttons = screen.getAllByRole('button')
+ const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]'))
+ expect(submitButton).not.toBeDisabled()
+ })
+
+ it('should handle API error gracefully', async () => {
+ mockHitTestingMutateAsync.mockRejectedValue(new Error('API Error'))
+
+ const { container } = renderWithProviders()
+
+ // Type query
+ const textarea = screen.getByRole('textbox')
+ fireEvent.change(textarea, { target: { value: 'Test query' } })
+
+ // Component should still be functional - check for the main container
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render hit results after successful submission', async () => {
+ const mockHitTestingRecord = createMockHitTesting()
+ const mockResponse: HitTestingResponse = {
+ query: { content: 'Test query', tsne_position: { x: 0, y: 0 } },
+ records: [mockHitTestingRecord],
+ }
+
+ mockHitTestingMutateAsync.mockImplementation(async (_params, options) => {
+ // Call onSuccess synchronously to ensure state is updated
+ if (options?.onSuccess)
+ options.onSuccess(mockResponse)
+ return mockResponse
+ })
+
+ const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset')
+ vi.mocked(useDatasetTestingRecords).mockReturnValue({
+ data: {
+ data: [],
+ total: 0,
+ page: 1,
+ limit: 10,
+ has_more: false,
+ },
+ refetch: mockRecordsRefetch,
+ isLoading: false,
+ } as unknown as ReturnType)
+
+ renderWithProviders()
+
+ // Type query
+ const textarea = screen.getByRole('textbox')
+ fireEvent.change(textarea, { target: { value: 'Test query' } })
+
+ // Submit
+ const buttons = screen.getAllByRole('button')
+ const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]'))
+ if (submitButton)
+ fireEvent.click(submitButton)
+
+ // Wait for the component to update
+ await waitFor(() => {
+ // Verify the component is still rendered
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ })
+ })
+
+ it('should render ResultItem components for non-external results', async () => {
+ const mockResponse: HitTestingResponse = {
+ query: { content: 'Test query', tsne_position: { x: 0, y: 0 } },
+ records: [
+ createMockHitTesting({ score: 0.95 }),
+ createMockHitTesting({ score: 0.85 }),
+ ],
+ }
+
+ mockHitTestingMutateAsync.mockImplementation(async (_params, options) => {
+ if (options?.onSuccess)
+ options.onSuccess(mockResponse)
+ return mockResponse
+ })
+
+ const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset')
+ vi.mocked(useDatasetTestingRecords).mockReturnValue({
+ data: { data: [], total: 0, page: 1, limit: 10, has_more: false },
+ refetch: mockRecordsRefetch,
+ isLoading: false,
+ } as unknown as ReturnType)
+
+ renderWithProviders()
+
+ // Submit a query
+ const textarea = screen.getByRole('textbox')
+ fireEvent.change(textarea, { target: { value: 'Test query' } })
+
+ const buttons = screen.getAllByRole('button')
+ const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]'))
+ if (submitButton)
+ fireEvent.click(submitButton)
+
+ await waitFor(() => {
+ // Verify component is rendered
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ })
+ })
+
+ it('should render external results when dataset is external', async () => {
+ const mockExternalResponse = {
+ query: { content: 'test' },
+ records: [
+ {
+ title: 'External Result 1',
+ content: 'External content',
+ score: 0.9,
+ metadata: {},
+ },
+ ],
+ }
+
+ mockExternalHitTestingMutateAsync.mockImplementation(async (_params, options) => {
+ if (options?.onSuccess)
+ options.onSuccess(mockExternalResponse)
+ return mockExternalResponse
+ })
+
+ const { container } = renderWithProviders()
+
+ // Component should render
+ expect(container.firstChild).toBeInTheDocument()
+ // Type in textarea to verify component is functional
+ const textarea = screen.getByRole('textbox')
+ fireEvent.change(textarea, { target: { value: 'Test query' } })
+
+ const buttons = screen.getAllByRole('button')
+ const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]'))
+ if (submitButton)
+ fireEvent.click(submitButton)
+
+ await waitFor(() => {
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ })
+ })
+})
+
+// ============================================================================
+// Drawer and Modal Interaction Tests
+// ============================================================================
+
+describe('Drawer and Modal Interactions', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should save retrieval config when ModifyRetrievalModal onSave is called', async () => {
+ const { container } = renderWithProviders()
+
+ // Find and click the retrieval method selector to open the drawer
+ const methodSelectors = container.querySelectorAll('.cursor-pointer')
+ const methodSelector = Array.from(methodSelectors).find(
+ el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'),
+ )
+
+ if (methodSelector) {
+ fireEvent.click(methodSelector)
+
+ await waitFor(() => {
+ // The drawer should open - verify container is still there
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ }
+
+ // Component should still be functional - verify main container
+ expect(container.querySelector('.overflow-y-auto')).toBeInTheDocument()
+ })
+
+ it('should close retrieval modal when onHide is called', async () => {
+ const { container } = renderWithProviders()
+
+ // Open the modal first
+ const methodSelectors = container.querySelectorAll('.cursor-pointer')
+ const methodSelector = Array.from(methodSelectors).find(
+ el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'),
+ )
+
+ if (methodSelector) {
+ fireEvent.click(methodSelector)
+ }
+
+ // Component should still be functional
+ expect(container.firstChild).toBeInTheDocument()
+ })
+})
+
+// ============================================================================
+// renderHitResults Coverage Tests
+// ============================================================================
+
+describe('renderHitResults Coverage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockHitTestingMutateAsync.mockReset()
+ })
+
+ it('should render hit results panel with records count', async () => {
+ const mockRecords = [
+ createMockHitTesting({ score: 0.95 }),
+ createMockHitTesting({ score: 0.85 }),
+ ]
+ const mockResponse: HitTestingResponse = {
+ query: { content: 'test', tsne_position: { x: 0, y: 0 } },
+ records: mockRecords,
+ }
+
+ // Make mutation call onSuccess synchronously
+ mockHitTestingMutateAsync.mockImplementation(async (params, options) => {
+ // Simulate async behavior
+ await Promise.resolve()
+ if (options?.onSuccess)
+ options.onSuccess(mockResponse)
+ return mockResponse
+ })
+
+ const { container } = renderWithProviders()
+
+ // Enter query
+ const textarea = screen.getByRole('textbox')
+ fireEvent.change(textarea, { target: { value: 'test query' } })
+
+ // Submit
+ const buttons = screen.getAllByRole('button')
+ const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]'))
+
+ if (submitButton)
+ fireEvent.click(submitButton)
+
+ // Verify component is functional
+ await waitFor(() => {
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+
+ it('should iterate through records and render ResultItem for each', async () => {
+ const mockRecords = [
+ createMockHitTesting({ score: 0.9 }),
+ ]
+
+ mockHitTestingMutateAsync.mockImplementation(async (_params, options) => {
+ const response = { query: { content: 'test' }, records: mockRecords }
+ if (options?.onSuccess)
+ options.onSuccess(response)
+ return response
+ })
+
+ const { container } = renderWithProviders()
+
+ const textarea = screen.getByRole('textbox')
+ fireEvent.change(textarea, { target: { value: 'test' } })
+
+ const buttons = screen.getAllByRole('button')
+ const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]'))
+ if (submitButton)
+ fireEvent.click(submitButton)
+
+ await waitFor(() => {
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+})
+
+// ============================================================================
+// Drawer onSave Coverage Tests
+// ============================================================================
+
+describe('ModifyRetrievalModal onSave Coverage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should update retrieval config when onSave is triggered', async () => {
+ const { container } = renderWithProviders()
+
+ // Open the drawer
+ const methodSelectors = container.querySelectorAll('.cursor-pointer')
+ const methodSelector = Array.from(methodSelectors).find(
+ el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'),
+ )
+
+ if (methodSelector) {
+ fireEvent.click(methodSelector)
+
+ // Wait for drawer to open
+ await waitFor(() => {
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ }
+
+ // Verify component renders correctly
+ expect(container.querySelector('.overflow-y-auto')).toBeInTheDocument()
+ })
+
+ it('should close modal after saving', async () => {
+ const { container } = renderWithProviders()
+
+ // Open the drawer
+ const methodSelectors = container.querySelectorAll('.cursor-pointer')
+ const methodSelector = Array.from(methodSelectors).find(
+ el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'),
+ )
+
+ if (methodSelector)
+ fireEvent.click(methodSelector)
+
+ // Component should still be rendered
+ expect(container.firstChild).toBeInTheDocument()
+ })
+})
+
+// ============================================================================
+// Direct Component Coverage Tests
+// ============================================================================
+
+describe('HitTestingPage Internal Functions Coverage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockHitTestingMutateAsync.mockReset()
+ mockExternalHitTestingMutateAsync.mockReset()
+ })
+
+ it('should trigger renderHitResults when mutation succeeds with records', async () => {
+ // Create mock hit testing records
+ const mockHitRecords = [
+ createMockHitTesting({ score: 0.95 }),
+ createMockHitTesting({ score: 0.85 }),
+ ]
+
+ const mockResponse: HitTestingResponse = {
+ query: { content: 'test query', tsne_position: { x: 0, y: 0 } },
+ records: mockHitRecords,
+ }
+
+ // Setup mutation to call onSuccess synchronously
+ mockHitTestingMutateAsync.mockImplementation((_params, options) => {
+ // Synchronously call onSuccess
+ if (options?.onSuccess)
+ options.onSuccess(mockResponse)
+ return Promise.resolve(mockResponse)
+ })
+
+ const { container } = renderWithProviders()
+
+ // Enter query and submit
+ const textarea = screen.getByRole('textbox')
+ fireEvent.change(textarea, { target: { value: 'test query' } })
+
+ const buttons = screen.getAllByRole('button')
+ const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]'))
+
+ if (submitButton) {
+ fireEvent.click(submitButton)
+ }
+
+ // Wait for state updates
+ await waitFor(() => {
+ expect(container.firstChild).toBeInTheDocument()
+ }, { timeout: 2000 })
+
+ // Verify mutation was called
+ expect(mockHitTestingMutateAsync).toHaveBeenCalled()
+ })
+
+ it('should handle retrieval config update via ModifyRetrievalModal', async () => {
+ const { container } = renderWithProviders()
+
+ // Find and click retrieval method to open drawer
+ const methodSelectors = container.querySelectorAll('.cursor-pointer')
+ const methodSelector = Array.from(methodSelectors).find(
+ el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'),
+ )
+
+ if (methodSelector) {
+ fireEvent.click(methodSelector)
+
+ // Wait for drawer content
+ await waitFor(() => {
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ // Try to find save button in the drawer
+ const saveButtons = screen.queryAllByText(/save/i)
+ if (saveButtons.length > 0) {
+ fireEvent.click(saveButtons[0])
+ }
+ }
+
+ // Component should still work
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should show hit count in results panel after successful query', async () => {
+ const mockRecords = [createMockHitTesting()]
+ const mockResponse: HitTestingResponse = {
+ query: { content: 'test', tsne_position: { x: 0, y: 0 } },
+ records: mockRecords,
+ }
+
+ mockHitTestingMutateAsync.mockResolvedValue(mockResponse)
+
+ const { container } = renderWithProviders()
+
+ // Submit a query
+ const textarea = screen.getByRole('textbox')
+ fireEvent.change(textarea, { target: { value: 'test' } })
+
+ const buttons = screen.getAllByRole('button')
+ const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]'))
+
+ if (submitButton)
+ fireEvent.click(submitButton)
+
+ // Verify the component renders
+ await waitFor(() => {
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+})
+
+// ============================================================================
+// Memoization Tests
+// ============================================================================
+
+describe('Memoization', () => {
+ describe('Score component memoization', () => {
+ it('should be memoized', () => {
+ // Score is wrapped in React.memo
+ const { rerender } = render()
+
+ // Rerender with same props should not cause re-render
+ rerender()
+
+ expect(screen.getByText('0.50')).toBeInTheDocument()
+ })
+ })
+
+ describe('Mask component memoization', () => {
+ it('should be memoized', () => {
+ const { rerender, container } = render()
+
+ rerender()
+
+ // Mask should still be rendered
+ expect(container.querySelector('.bg-gradient-to-b')).toBeInTheDocument()
+ })
+ })
+
+ describe('EmptyRecords component memoization', () => {
+ it('should be memoized', () => {
+ const { rerender } = render()
+
+ rerender()
+
+ expect(screen.getByText(/noRecentTip/i)).toBeInTheDocument()
+ })
+ })
+})
+
+// ============================================================================
+// Accessibility Tests
+// ============================================================================
+
+describe('Accessibility', () => {
+ describe('Textarea', () => {
+ it('should have placeholder text', () => {
+ render()
+ expect(screen.getByPlaceholderText(/placeholder/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('Buttons', () => {
+ it('should have accessible buttons in QueryInput', () => {
+ render(
+ ,
+ )
+ expect(screen.getAllByRole('button').length).toBeGreaterThan(0)
+ })
+ })
+
+ describe('Tables', () => {
+ it('should render table with proper structure', () => {
+ render(
+ ,
+ )
+ expect(screen.getByRole('table')).toBeInTheDocument()
+ })
+ })
+})
+
+// ============================================================================
+// Edge Cases
+// ============================================================================
+
+describe('Edge Cases', () => {
+ describe('Score with edge values', () => {
+ it('should handle very small scores', () => {
+ render()
+ expect(screen.getByText('0.00')).toBeInTheDocument()
+ })
+
+ it('should handle scores close to 1', () => {
+ render()
+ expect(screen.getByText('1.00')).toBeInTheDocument()
+ })
+ })
+
+ describe('Records with various sources', () => {
+ it('should handle plugin source', () => {
+ const record = createMockRecord({ source: 'plugin' })
+ render()
+ expect(screen.getByText('plugin')).toBeInTheDocument()
+ })
+
+ it('should handle app source', () => {
+ const record = createMockRecord({ source: 'app' })
+ render()
+ expect(screen.getByText('app')).toBeInTheDocument()
+ })
+ })
+
+ describe('ResultItem with various data', () => {
+ it('should handle empty keywords', () => {
+ const payload = createMockHitTesting({
+ segment: createMockSegment({ keywords: [] }),
+ child_chunks: null,
+ })
+
+ render()
+ // Should not render keywords section
+ expect(screen.queryByText('keyword')).not.toBeInTheDocument()
+ })
+
+ it('should handle missing sign_content', () => {
+ const payload = createMockHitTesting({
+ segment: createMockSegment({ sign_content: '', content: 'Fallback content' }),
+ })
+
+ render()
+ // The document name should still be visible
+ expect(screen.getByText('test-document.pdf')).toBeInTheDocument()
+ })
+ })
+
+ describe('Records with images', () => {
+ it('should handle records with image queries', () => {
+ const recordWithImages = createMockRecord({
+ queries: [
+ { content: 'Text query', content_type: 'text_query', file_info: null },
+ {
+ content: 'image-url',
+ content_type: 'image_query',
+ file_info: {
+ id: 'file-1',
+ name: 'image.png',
+ size: 1000,
+ mime_type: 'image/png',
+ extension: 'png',
+ source_url: 'http://example.com/image.png',
+ },
+ },
+ ],
+ })
+
+ render()
+ expect(screen.getByText('Text query')).toBeInTheDocument()
+ })
+ })
+
+ describe('ChunkDetailModal with files', () => {
+ it('should handle payload with image files', () => {
+ const payload = createMockHitTesting({
+ files: [
+ {
+ id: 'file-1',
+ name: 'image.png',
+ size: 1000,
+ mime_type: 'image/png',
+ extension: 'png',
+ source_url: 'http://example.com/image.png',
+ },
+ ],
+ })
+
+ render()
+ expect(screen.getByText(/chunkDetail/i)).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/list/dataset-card/components/corner-labels.spec.tsx b/web/app/components/datasets/list/dataset-card/components/corner-labels.spec.tsx
new file mode 100644
index 0000000000..00ee1f85f9
--- /dev/null
+++ b/web/app/components/datasets/list/dataset-card/components/corner-labels.spec.tsx
@@ -0,0 +1,125 @@
+import type { DataSet } from '@/models/datasets'
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { IndexingType } from '@/app/components/datasets/create/step-two'
+import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
+import CornerLabels from './corner-labels'
+
+describe('CornerLabels', () => {
+ const createMockDataset = (overrides: Partial = {}): DataSet => ({
+ id: 'dataset-1',
+ name: 'Test Dataset',
+ description: 'Test description',
+ provider: 'vendor',
+ permission: DatasetPermission.allTeamMembers,
+ data_source_type: DataSourceType.FILE,
+ indexing_technique: IndexingType.QUALIFIED,
+ embedding_available: true,
+ app_count: 5,
+ document_count: 10,
+ word_count: 1000,
+ created_at: 1609459200,
+ updated_at: 1609545600,
+ tags: [],
+ embedding_model: 'text-embedding-ada-002',
+ embedding_model_provider: 'openai',
+ created_by: 'user-1',
+ doc_form: ChunkingMode.text,
+ runtime_mode: 'general',
+ ...overrides,
+ } as DataSet)
+
+ describe('Rendering', () => {
+ it('should render without crashing when embedding is available', () => {
+ const dataset = createMockDataset({ embedding_available: true })
+ const { container } = render()
+ // Should render null when embedding is available and not pipeline
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('should render unavailable label when embedding is not available', () => {
+ const dataset = createMockDataset({ embedding_available: false })
+ render()
+ expect(screen.getByText(/unavailable/i)).toBeInTheDocument()
+ })
+
+ it('should render pipeline label when runtime_mode is rag_pipeline', () => {
+ const dataset = createMockDataset({
+ embedding_available: true,
+ runtime_mode: 'rag_pipeline',
+ })
+ render()
+ expect(screen.getByText(/pipeline/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should not render when embedding is available and not pipeline', () => {
+ const dataset = createMockDataset({
+ embedding_available: true,
+ runtime_mode: 'general',
+ })
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('should prioritize unavailable label over pipeline label', () => {
+ const dataset = createMockDataset({
+ embedding_available: false,
+ runtime_mode: 'rag_pipeline',
+ })
+ render()
+ // Should show unavailable since embedding_available is checked first
+ expect(screen.getByText(/unavailable/i)).toBeInTheDocument()
+ expect(screen.queryByText(/pipeline/i)).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Styles', () => {
+ it('should have correct positioning for unavailable label', () => {
+ const dataset = createMockDataset({ embedding_available: false })
+ const { container } = render()
+ const labelContainer = container.firstChild as HTMLElement
+ expect(labelContainer).toHaveClass('absolute', 'right-0', 'top-0', 'z-10')
+ })
+
+ it('should have correct positioning for pipeline label', () => {
+ const dataset = createMockDataset({
+ embedding_available: true,
+ runtime_mode: 'rag_pipeline',
+ })
+ const { container } = render()
+ const labelContainer = container.firstChild as HTMLElement
+ expect(labelContainer).toHaveClass('absolute', 'right-0', 'top-0', 'z-10')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle undefined runtime_mode', () => {
+ const dataset = createMockDataset({
+ embedding_available: true,
+ runtime_mode: undefined,
+ })
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('should handle empty string runtime_mode', () => {
+ const dataset = createMockDataset({
+ embedding_available: true,
+ runtime_mode: '' as DataSet['runtime_mode'],
+ })
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('should handle all false conditions', () => {
+ const dataset = createMockDataset({
+ embedding_available: true,
+ runtime_mode: 'general',
+ })
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+ })
+})
diff --git a/web/app/components/datasets/list/dataset-card/components/dataset-card-footer.spec.tsx b/web/app/components/datasets/list/dataset-card/components/dataset-card-footer.spec.tsx
new file mode 100644
index 0000000000..6ca0363097
--- /dev/null
+++ b/web/app/components/datasets/list/dataset-card/components/dataset-card-footer.spec.tsx
@@ -0,0 +1,177 @@
+import type { DataSet } from '@/models/datasets'
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { IndexingType } from '@/app/components/datasets/create/step-two'
+import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
+import DatasetCardFooter from './dataset-card-footer'
+
+// Mock the useFormatTimeFromNow hook
+vi.mock('@/hooks/use-format-time-from-now', () => ({
+ useFormatTimeFromNow: () => ({
+ formatTimeFromNow: vi.fn((timestamp: number) => {
+ const date = new Date(timestamp)
+ return `${date.toLocaleDateString()}`
+ }),
+ }),
+}))
+
+describe('DatasetCardFooter', () => {
+ const createMockDataset = (overrides: Partial = {}): DataSet => ({
+ id: 'dataset-1',
+ name: 'Test Dataset',
+ description: 'Test description',
+ provider: 'vendor',
+ permission: DatasetPermission.allTeamMembers,
+ data_source_type: DataSourceType.FILE,
+ indexing_technique: IndexingType.QUALIFIED,
+ embedding_available: true,
+ app_count: 5,
+ document_count: 10,
+ word_count: 1000,
+ created_at: 1609459200,
+ updated_at: 1609545600,
+ tags: [],
+ embedding_model: 'text-embedding-ada-002',
+ embedding_model_provider: 'openai',
+ created_by: 'user-1',
+ doc_form: ChunkingMode.text,
+ total_available_documents: 10,
+ ...overrides,
+ } as DataSet)
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const dataset = createMockDataset()
+ render()
+ expect(screen.getByText('10')).toBeInTheDocument()
+ })
+
+ it('should render document count', () => {
+ const dataset = createMockDataset({ document_count: 25, total_available_documents: 25 })
+ render()
+ expect(screen.getByText('25')).toBeInTheDocument()
+ })
+
+ it('should render app count for non-external provider', () => {
+ const dataset = createMockDataset({ app_count: 8, provider: 'vendor' })
+ render()
+ expect(screen.getByText('8')).toBeInTheDocument()
+ })
+
+ it('should not render app count for external provider', () => {
+ const dataset = createMockDataset({ app_count: 8, provider: 'external' })
+ render()
+ // App count should not be rendered
+ const appCounts = screen.queryAllByText('8')
+ expect(appCounts.length).toBe(0)
+ })
+
+ it('should render update time', () => {
+ const dataset = createMockDataset()
+ render()
+ // Check for "updated" text with i18n key
+ expect(screen.getByText(/updated/)).toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should show partial document count when total_available_documents < document_count', () => {
+ const dataset = createMockDataset({
+ document_count: 20,
+ total_available_documents: 15,
+ })
+ render()
+ expect(screen.getByText('15 / 20')).toBeInTheDocument()
+ })
+
+ it('should show full document count when all documents are available', () => {
+ const dataset = createMockDataset({
+ document_count: 20,
+ total_available_documents: 20,
+ })
+ render()
+ expect(screen.getByText('20')).toBeInTheDocument()
+ })
+
+ it('should handle zero documents', () => {
+ const dataset = createMockDataset({
+ document_count: 0,
+ total_available_documents: 0,
+ })
+ render()
+ expect(screen.getByText('0')).toBeInTheDocument()
+ })
+ })
+
+ describe('Styles', () => {
+ it('should have correct base styling when embedding is available', () => {
+ const dataset = createMockDataset({ embedding_available: true })
+ const { container } = render()
+ const footer = container.firstChild as HTMLElement
+ expect(footer).toHaveClass('flex', 'items-center', 'gap-x-3', 'px-4')
+ })
+
+ it('should have opacity class when embedding is not available', () => {
+ const dataset = createMockDataset({ embedding_available: false })
+ const { container } = render()
+ const footer = container.firstChild as HTMLElement
+ expect(footer).toHaveClass('opacity-30')
+ })
+
+ it('should not have opacity class when embedding is available', () => {
+ const dataset = createMockDataset({ embedding_available: true })
+ const { container } = render()
+ const footer = container.firstChild as HTMLElement
+ expect(footer).not.toHaveClass('opacity-30')
+ })
+ })
+
+ describe('Icons', () => {
+ it('should render document icon', () => {
+ const dataset = createMockDataset()
+ const { container } = render()
+ // RiFileTextFill icon
+ const icons = container.querySelectorAll('svg')
+ expect(icons.length).toBeGreaterThanOrEqual(1)
+ })
+
+ it('should render robot icon for non-external provider', () => {
+ const dataset = createMockDataset({ provider: 'vendor' })
+ const { container } = render()
+ // Should have both file and robot icons
+ const icons = container.querySelectorAll('svg')
+ expect(icons.length).toBe(2)
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle undefined total_available_documents', () => {
+ const dataset = createMockDataset({
+ document_count: 10,
+ total_available_documents: undefined,
+ })
+ render()
+ // Should show 0 / 10 since total_available_documents defaults to 0
+ expect(screen.getByText('0 / 10')).toBeInTheDocument()
+ })
+
+ it('should handle very large numbers', () => {
+ const dataset = createMockDataset({
+ document_count: 999999,
+ total_available_documents: 999999,
+ app_count: 888888,
+ })
+ render()
+ expect(screen.getByText('999999')).toBeInTheDocument()
+ expect(screen.getByText('888888')).toBeInTheDocument()
+ })
+
+ it('should handle zero app count', () => {
+ const dataset = createMockDataset({ app_count: 0, document_count: 5, total_available_documents: 5 })
+ render()
+ // Both document count and app count are shown
+ const zeros = screen.getAllByText('0')
+ expect(zeros.length).toBeGreaterThanOrEqual(1)
+ })
+ })
+})
diff --git a/web/app/components/datasets/list/dataset-card/components/dataset-card-header.spec.tsx b/web/app/components/datasets/list/dataset-card/components/dataset-card-header.spec.tsx
new file mode 100644
index 0000000000..6a0e3693a3
--- /dev/null
+++ b/web/app/components/datasets/list/dataset-card/components/dataset-card-header.spec.tsx
@@ -0,0 +1,254 @@
+import type { DataSet } from '@/models/datasets'
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { IndexingType } from '@/app/components/datasets/create/step-two'
+import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
+import { RETRIEVE_METHOD } from '@/types/app'
+import DatasetCardHeader from './dataset-card-header'
+
+// Mock useFormatTimeFromNow hook
+vi.mock('@/hooks/use-format-time-from-now', () => ({
+ useFormatTimeFromNow: () => ({
+ formatTimeFromNow: (timestamp: number) => {
+ const date = new Date(timestamp)
+ return date.toLocaleDateString()
+ },
+ }),
+}))
+
+// Mock useKnowledge hook
+vi.mock('@/hooks/use-knowledge', () => ({
+ useKnowledge: () => ({
+ formatIndexingTechniqueAndMethod: (technique: string, _method: string) => {
+ if (technique === 'high_quality')
+ return 'High Quality'
+ if (technique === 'economy')
+ return 'Economy'
+ return ''
+ },
+ }),
+}))
+
+describe('DatasetCardHeader', () => {
+ const createMockDataset = (overrides: Partial = {}): DataSet => ({
+ id: 'dataset-1',
+ name: 'Test Dataset',
+ description: 'Test description',
+ indexing_status: 'completed',
+ provider: 'vendor',
+ permission: DatasetPermission.allTeamMembers,
+ data_source_type: DataSourceType.FILE,
+ indexing_technique: IndexingType.QUALIFIED,
+ embedding_available: true,
+ app_count: 5,
+ document_count: 10,
+ total_document_count: 10,
+ word_count: 1000,
+ updated_at: 1609545600,
+ updated_by: 'user-1',
+ tags: [],
+ embedding_model: 'text-embedding-ada-002',
+ embedding_model_provider: 'openai',
+ created_by: 'user-1',
+ doc_form: ChunkingMode.text,
+ runtime_mode: 'general',
+ is_published: true,
+ enable_api: true,
+ is_multimodal: false,
+ built_in_field_enabled: false,
+ icon_info: {
+ icon: '📙',
+ icon_type: 'emoji' as const,
+ icon_background: '#FFF4ED',
+ icon_url: '',
+ },
+ retrieval_model_dict: {
+ search_method: RETRIEVE_METHOD.semantic,
+ } as DataSet['retrieval_model_dict'],
+ retrieval_model: {
+ search_method: RETRIEVE_METHOD.semantic,
+ } as DataSet['retrieval_model'],
+ external_knowledge_info: {
+ external_knowledge_id: '',
+ external_knowledge_api_id: '',
+ external_knowledge_api_name: '',
+ external_knowledge_api_endpoint: '',
+ },
+ external_retrieval_model: {
+ top_k: 3,
+ score_threshold: 0.5,
+ score_threshold_enabled: false,
+ },
+ author_name: 'Test User',
+ ...overrides,
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const dataset = createMockDataset()
+ render()
+ expect(screen.getByText('Test Dataset')).toBeInTheDocument()
+ })
+
+ it('should render dataset name', () => {
+ const dataset = createMockDataset({ name: 'Custom Dataset' })
+ render()
+ expect(screen.getByText('Custom Dataset')).toBeInTheDocument()
+ })
+
+ it('should render author name', () => {
+ const dataset = createMockDataset({ author_name: 'John Doe' })
+ render()
+ expect(screen.getByText('John Doe')).toBeInTheDocument()
+ })
+
+ it('should render edit time', () => {
+ const dataset = createMockDataset()
+ render()
+ // Should contain the formatted time
+ expect(screen.getByText(/segment\.editedAt/)).toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should show external knowledge base text for external provider', () => {
+ const dataset = createMockDataset({ provider: 'external' })
+ render()
+ expect(screen.getByText(/externalKnowledgeBase/)).toBeInTheDocument()
+ })
+
+ it('should show chunking mode for text_model doc_form', () => {
+ const dataset = createMockDataset({ doc_form: ChunkingMode.text })
+ render()
+ // text_model maps to 'general' in DOC_FORM_TEXT
+ expect(screen.getByText(/chunkingMode\.general/)).toBeInTheDocument()
+ })
+
+ it('should show multimodal text when is_multimodal is true', () => {
+ const dataset = createMockDataset({ is_multimodal: true })
+ render()
+ expect(screen.getByText(/multimodal/)).toBeInTheDocument()
+ })
+
+ it('should not show multimodal when is_multimodal is false', () => {
+ const dataset = createMockDataset({ is_multimodal: false })
+ render()
+ expect(screen.queryByText(/^multimodal$/)).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Icon', () => {
+ it('should render AppIcon component', () => {
+ const dataset = createMockDataset()
+ const { container } = render()
+ // AppIcon should be rendered
+ const iconContainer = container.querySelector('.relative.shrink-0')
+ expect(iconContainer).toBeInTheDocument()
+ })
+
+ it('should use default icon when icon_info is missing', () => {
+ const dataset = createMockDataset({ icon_info: undefined })
+ render()
+ // Should still render without crashing
+ expect(screen.getByText('Test Dataset')).toBeInTheDocument()
+ })
+
+ it('should render chunking mode icon for published pipeline', () => {
+ const dataset = createMockDataset({
+ doc_form: ChunkingMode.text,
+ runtime_mode: 'rag_pipeline',
+ is_published: true,
+ })
+ const { container } = render()
+ // Should have the icon badge
+ const iconBadge = container.querySelector('.absolute.-bottom-1.-right-1')
+ expect(iconBadge).toBeInTheDocument()
+ })
+ })
+
+ describe('Styles', () => {
+ it('should have opacity class when embedding is not available', () => {
+ const dataset = createMockDataset({ embedding_available: false })
+ const { container } = render()
+ const header = container.firstChild as HTMLElement
+ expect(header).toHaveClass('opacity-30')
+ })
+
+ it('should not have opacity class when embedding is available', () => {
+ const dataset = createMockDataset({ embedding_available: true })
+ const { container } = render()
+ const header = container.firstChild as HTMLElement
+ expect(header).not.toHaveClass('opacity-30')
+ })
+
+ it('should have correct base styling', () => {
+ const dataset = createMockDataset()
+ const { container } = render()
+ const header = container.firstChild as HTMLElement
+ expect(header).toHaveClass('flex', 'items-center', 'gap-x-3', 'px-4')
+ })
+ })
+
+ describe('DocModeInfo', () => {
+ it('should show doc mode info when all conditions are met', () => {
+ const dataset = createMockDataset({
+ doc_form: ChunkingMode.text,
+ indexing_technique: IndexingType.QUALIFIED,
+ retrieval_model_dict: { search_method: RETRIEVE_METHOD.semantic } as DataSet['retrieval_model_dict'],
+ runtime_mode: 'general',
+ })
+ render()
+ expect(screen.getByText(/chunkingMode/)).toBeInTheDocument()
+ })
+
+ it('should not show doc mode info for unpublished pipeline', () => {
+ const dataset = createMockDataset({
+ runtime_mode: 'rag_pipeline',
+ is_published: false,
+ })
+ render()
+ // DocModeInfo should not be rendered since isShowDocModeInfo is false
+ expect(screen.queryByText(/High Quality/)).not.toBeInTheDocument()
+ })
+
+ it('should show doc mode info for published pipeline', () => {
+ const dataset = createMockDataset({
+ doc_form: ChunkingMode.text,
+ indexing_technique: IndexingType.QUALIFIED,
+ retrieval_model_dict: { search_method: RETRIEVE_METHOD.semantic } as DataSet['retrieval_model_dict'],
+ runtime_mode: 'rag_pipeline',
+ is_published: true,
+ })
+ render()
+ expect(screen.getByText(/chunkingMode/)).toBeInTheDocument()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle missing author_name', () => {
+ const dataset = createMockDataset({ author_name: undefined })
+ render()
+ expect(screen.getByText('Test Dataset')).toBeInTheDocument()
+ })
+
+ it('should handle empty name', () => {
+ const dataset = createMockDataset({ name: '' })
+ render()
+ // Should render without crashing
+ const { container } = render()
+ expect(container).toBeInTheDocument()
+ })
+
+ it('should handle missing retrieval_model_dict', () => {
+ const dataset = createMockDataset({ retrieval_model_dict: undefined })
+ render()
+ expect(screen.getByText('Test Dataset')).toBeInTheDocument()
+ })
+
+ it('should handle undefined doc_form', () => {
+ const dataset = createMockDataset({ doc_form: undefined })
+ render()
+ expect(screen.getByText('Test Dataset')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/list/dataset-card/components/dataset-card-modals.spec.tsx b/web/app/components/datasets/list/dataset-card/components/dataset-card-modals.spec.tsx
new file mode 100644
index 0000000000..ebee72159e
--- /dev/null
+++ b/web/app/components/datasets/list/dataset-card/components/dataset-card-modals.spec.tsx
@@ -0,0 +1,237 @@
+import type { DataSet } from '@/models/datasets'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { IndexingType } from '@/app/components/datasets/create/step-two'
+import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
+import DatasetCardModals from './dataset-card-modals'
+
+// Mock RenameDatasetModal since it's from a different feature folder
+vi.mock('../../../rename-modal', () => ({
+ default: ({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess?: () => void }) => (
+ show
+ ? (
+
+
+
+
+ )
+ : null
+ ),
+}))
+
+describe('DatasetCardModals', () => {
+ const mockDataset: DataSet = {
+ id: 'dataset-1',
+ name: 'Test Dataset',
+ description: 'Test description',
+ indexing_status: 'completed',
+ provider: 'vendor',
+ permission: DatasetPermission.allTeamMembers,
+ data_source_type: DataSourceType.FILE,
+ indexing_technique: IndexingType.QUALIFIED,
+ embedding_available: true,
+ app_count: 5,
+ document_count: 10,
+ total_document_count: 10,
+ word_count: 1000,
+ updated_at: 1609545600,
+ updated_by: 'user-1',
+ tags: [],
+ embedding_model: 'text-embedding-ada-002',
+ embedding_model_provider: 'openai',
+ created_by: 'user-1',
+ doc_form: ChunkingMode.text,
+ runtime_mode: 'general',
+ enable_api: true,
+ is_multimodal: false,
+ built_in_field_enabled: false,
+ icon_info: {
+ icon: '📙',
+ icon_type: 'emoji' as const,
+ icon_background: '#FFF4ED',
+ icon_url: '',
+ },
+ retrieval_model_dict: {} as DataSet['retrieval_model_dict'],
+ retrieval_model: {} as DataSet['retrieval_model'],
+ external_knowledge_info: {
+ external_knowledge_id: '',
+ external_knowledge_api_id: '',
+ external_knowledge_api_name: '',
+ external_knowledge_api_endpoint: '',
+ },
+ external_retrieval_model: {
+ top_k: 3,
+ score_threshold: 0.5,
+ score_threshold_enabled: false,
+ },
+ }
+
+ const defaultProps = {
+ dataset: mockDataset,
+ modalState: {
+ showRenameModal: false,
+ showConfirmDelete: false,
+ confirmMessage: '',
+ },
+ onCloseRename: vi.fn(),
+ onCloseConfirm: vi.fn(),
+ onConfirmDelete: vi.fn(),
+ onSuccess: vi.fn(),
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing when no modals are shown', () => {
+ const { container } = render()
+ // Should render empty fragment
+ expect(container.innerHTML).toBe('')
+ })
+
+ it('should render rename modal when showRenameModal is true', () => {
+ render(
+ ,
+ )
+ expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
+ })
+
+ it('should render confirm modal when showConfirmDelete is true', () => {
+ render(
+ ,
+ )
+ // Confirm modal should be rendered
+ expect(screen.getByText('Are you sure?')).toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should pass dataset to rename modal', () => {
+ render(
+ ,
+ )
+ expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
+ })
+
+ it('should display confirmMessage in confirm modal', () => {
+ const confirmMessage = 'This is a custom confirm message'
+ render(
+ ,
+ )
+ expect(screen.getByText(confirmMessage)).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onCloseRename when closing rename modal', () => {
+ const onCloseRename = vi.fn()
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByText('Close Rename'))
+ expect(onCloseRename).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onConfirmDelete when confirming deletion', () => {
+ const onConfirmDelete = vi.fn()
+ render(
+ ,
+ )
+
+ // Find and click the confirm button
+ const confirmButton = screen.getByRole('button', { name: /confirm|ok|delete/i })
+ || screen.getAllByRole('button').find(btn => btn.textContent?.toLowerCase().includes('confirm'))
+ if (confirmButton)
+ fireEvent.click(confirmButton)
+
+ expect(onConfirmDelete).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onCloseConfirm when canceling deletion', () => {
+ const onCloseConfirm = vi.fn()
+ render(
+ ,
+ )
+
+ // Find and click the cancel button
+ const cancelButton = screen.getByRole('button', { name: /cancel/i })
+ fireEvent.click(cancelButton)
+
+ expect(onCloseConfirm).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle both modals being true (render both)', () => {
+ render(
+ ,
+ )
+ expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
+ expect(screen.getByText('Delete this dataset?')).toBeInTheDocument()
+ })
+
+ it('should handle empty confirmMessage', () => {
+ render(
+ ,
+ )
+ // Should still render confirm modal
+ expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/list/dataset-card/components/description.spec.tsx b/web/app/components/datasets/list/dataset-card/components/description.spec.tsx
new file mode 100644
index 0000000000..e7599f31e3
--- /dev/null
+++ b/web/app/components/datasets/list/dataset-card/components/description.spec.tsx
@@ -0,0 +1,107 @@
+import type { DataSet } from '@/models/datasets'
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { IndexingType } from '@/app/components/datasets/create/step-two'
+import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
+import Description from './description'
+
+describe('Description', () => {
+ const createMockDataset = (overrides: Partial = {}): DataSet => ({
+ id: 'dataset-1',
+ name: 'Test Dataset',
+ description: 'This is a test description',
+ provider: 'vendor',
+ permission: DatasetPermission.allTeamMembers,
+ data_source_type: DataSourceType.FILE,
+ indexing_technique: IndexingType.QUALIFIED,
+ embedding_available: true,
+ app_count: 5,
+ document_count: 10,
+ word_count: 1000,
+ created_at: 1609459200,
+ updated_at: 1609545600,
+ tags: [],
+ embedding_model: 'text-embedding-ada-002',
+ embedding_model_provider: 'openai',
+ created_by: 'user-1',
+ doc_form: ChunkingMode.text,
+ ...overrides,
+ } as DataSet)
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const dataset = createMockDataset()
+ render()
+ expect(screen.getByText('This is a test description')).toBeInTheDocument()
+ })
+
+ it('should render the description text', () => {
+ const dataset = createMockDataset({ description: 'Custom description text' })
+ render()
+ expect(screen.getByText('Custom description text')).toBeInTheDocument()
+ })
+
+ it('should set title attribute for tooltip', () => {
+ const dataset = createMockDataset({ description: 'Tooltip description' })
+ render()
+ const descDiv = screen.getByTitle('Tooltip description')
+ expect(descDiv).toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should display dataset description', () => {
+ const description = 'A very detailed description of this dataset'
+ const dataset = createMockDataset({ description })
+ render()
+ expect(screen.getByText(description)).toBeInTheDocument()
+ })
+ })
+
+ describe('Styles', () => {
+ it('should have correct base styling when embedding is available', () => {
+ const dataset = createMockDataset({ embedding_available: true })
+ render()
+ const descDiv = screen.getByTitle(dataset.description)
+ expect(descDiv).toHaveClass('system-xs-regular', 'line-clamp-2', 'h-10', 'px-4', 'py-1', 'text-text-tertiary')
+ })
+
+ it('should have opacity class when embedding is not available', () => {
+ const dataset = createMockDataset({ embedding_available: false })
+ render()
+ const descDiv = screen.getByTitle(dataset.description)
+ expect(descDiv).toHaveClass('opacity-30')
+ })
+
+ it('should not have opacity class when embedding is available', () => {
+ const dataset = createMockDataset({ embedding_available: true })
+ render()
+ const descDiv = screen.getByTitle(dataset.description)
+ expect(descDiv).not.toHaveClass('opacity-30')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty description', () => {
+ const dataset = createMockDataset({ description: '' })
+ render()
+ const descDiv = screen.getByTitle('')
+ expect(descDiv).toBeInTheDocument()
+ expect(descDiv).toHaveTextContent('')
+ })
+
+ it('should handle very long description', () => {
+ const longDescription = 'A'.repeat(500)
+ const dataset = createMockDataset({ description: longDescription })
+ render()
+ expect(screen.getByText(longDescription)).toBeInTheDocument()
+ })
+
+ it('should handle description with special characters', () => {
+ const description = ' & "quotes" \'single\''
+ const dataset = createMockDataset({ description })
+ render()
+ expect(screen.getByText(description)).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/list/dataset-card/components/operations-popover.spec.tsx b/web/app/components/datasets/list/dataset-card/components/operations-popover.spec.tsx
new file mode 100644
index 0000000000..bf8daae0c3
--- /dev/null
+++ b/web/app/components/datasets/list/dataset-card/components/operations-popover.spec.tsx
@@ -0,0 +1,162 @@
+import type { DataSet } from '@/models/datasets'
+import { fireEvent, render } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { IndexingType } from '@/app/components/datasets/create/step-two'
+import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
+import OperationsPopover from './operations-popover'
+
+describe('OperationsPopover', () => {
+ const createMockDataset = (overrides: Partial = {}): DataSet => ({
+ id: 'dataset-1',
+ name: 'Test Dataset',
+ description: 'Test description',
+ provider: 'vendor',
+ permission: DatasetPermission.allTeamMembers,
+ data_source_type: DataSourceType.FILE,
+ indexing_technique: IndexingType.QUALIFIED,
+ embedding_available: true,
+ app_count: 5,
+ document_count: 10,
+ word_count: 1000,
+ updated_at: 1609545600,
+ tags: [],
+ embedding_model: 'text-embedding-ada-002',
+ embedding_model_provider: 'openai',
+ created_by: 'user-1',
+ doc_form: ChunkingMode.text,
+ runtime_mode: 'general',
+ ...overrides,
+ } as DataSet)
+
+ const defaultProps = {
+ dataset: createMockDataset(),
+ isCurrentWorkspaceDatasetOperator: false,
+ openRenameModal: vi.fn(),
+ handleExportPipeline: vi.fn(),
+ detectIsUsedByApp: vi.fn(),
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const { container } = render()
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render the more icon button', () => {
+ const { container } = render()
+ const moreIcon = container.querySelector('svg')
+ expect(moreIcon).toBeInTheDocument()
+ })
+
+ it('should render in hidden state initially (group-hover)', () => {
+ const { container } = render()
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('hidden', 'group-hover:block')
+ })
+ })
+
+ describe('Props', () => {
+ it('should show delete option when not workspace dataset operator', () => {
+ render()
+
+ // Click to open popover
+ const triggerButton = document.querySelector('[class*="cursor-pointer"]')
+ if (triggerButton)
+ fireEvent.click(triggerButton)
+
+ // showDelete should be true (inverse of isCurrentWorkspaceDatasetOperator)
+ // This means delete operation will be visible
+ })
+
+ it('should hide delete option when is workspace dataset operator', () => {
+ render()
+
+ // Click to open popover
+ const triggerButton = document.querySelector('[class*="cursor-pointer"]')
+ if (triggerButton)
+ fireEvent.click(triggerButton)
+
+ // showDelete should be false
+ })
+
+ it('should show export pipeline when runtime_mode is rag_pipeline', () => {
+ const dataset = createMockDataset({ runtime_mode: 'rag_pipeline' })
+ render()
+
+ // Click to open popover
+ const triggerButton = document.querySelector('[class*="cursor-pointer"]')
+ if (triggerButton)
+ fireEvent.click(triggerButton)
+
+ // showExportPipeline should be true
+ })
+
+ it('should hide export pipeline when runtime_mode is not rag_pipeline', () => {
+ const dataset = createMockDataset({ runtime_mode: 'general' })
+ render()
+
+ // Click to open popover
+ const triggerButton = document.querySelector('[class*="cursor-pointer"]')
+ if (triggerButton)
+ fireEvent.click(triggerButton)
+
+ // showExportPipeline should be false
+ })
+ })
+
+ describe('Styles', () => {
+ it('should have correct positioning styles', () => {
+ const { container } = render()
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('absolute', 'right-2', 'top-2', 'z-[15]')
+ })
+
+ it('should have icon with correct size classes', () => {
+ const { container } = render()
+ const icon = container.querySelector('svg')
+ expect(icon).toHaveClass('h-5', 'w-5', 'text-text-tertiary')
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should pass openRenameModal to Operations', () => {
+ const openRenameModal = vi.fn()
+ render()
+
+ // The openRenameModal should be passed to Operations component
+ expect(openRenameModal).not.toHaveBeenCalled() // Initially not called
+ })
+
+ it('should pass handleExportPipeline to Operations', () => {
+ const handleExportPipeline = vi.fn()
+ render()
+
+ expect(handleExportPipeline).not.toHaveBeenCalled()
+ })
+
+ it('should pass detectIsUsedByApp to Operations', () => {
+ const detectIsUsedByApp = vi.fn()
+ render()
+
+ expect(detectIsUsedByApp).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle dataset with external provider', () => {
+ const dataset = createMockDataset({ provider: 'external' })
+ const { container } = render()
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should handle dataset with undefined runtime_mode', () => {
+ const dataset = createMockDataset({ runtime_mode: undefined })
+ const { container } = render()
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/list/dataset-card/components/tag-area.spec.tsx b/web/app/components/datasets/list/dataset-card/components/tag-area.spec.tsx
new file mode 100644
index 0000000000..d55628bd6c
--- /dev/null
+++ b/web/app/components/datasets/list/dataset-card/components/tag-area.spec.tsx
@@ -0,0 +1,198 @@
+import type { Tag } from '@/app/components/base/tag-management/constant'
+import type { DataSet } from '@/models/datasets'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { useRef } from 'react'
+import { describe, expect, it, vi } from 'vitest'
+import { IndexingType } from '@/app/components/datasets/create/step-two'
+import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
+import TagArea from './tag-area'
+
+// Mock TagSelector as it's a complex component from base
+vi.mock('@/app/components/base/tag-management/selector', () => ({
+ default: ({ value, selectedTags, onCacheUpdate, onChange }: {
+ value: string[]
+ selectedTags: Tag[]
+ onCacheUpdate: (tags: Tag[]) => void
+ onChange?: () => void
+ }) => (
+
+
{value.join(',')}
+
+ {selectedTags.length}
+ {' '}
+ tags
+
+
+
+
+ ),
+}))
+
+describe('TagArea', () => {
+ const createMockDataset = (overrides: Partial = {}): DataSet => ({
+ id: 'dataset-1',
+ name: 'Test Dataset',
+ description: 'Test description',
+ provider: 'vendor',
+ permission: DatasetPermission.allTeamMembers,
+ data_source_type: DataSourceType.FILE,
+ indexing_technique: IndexingType.QUALIFIED,
+ embedding_available: true,
+ app_count: 5,
+ document_count: 10,
+ word_count: 1000,
+ updated_at: 1609545600,
+ tags: [],
+ embedding_model: 'text-embedding-ada-002',
+ embedding_model_provider: 'openai',
+ created_by: 'user-1',
+ doc_form: ChunkingMode.text,
+ ...overrides,
+ } as DataSet)
+
+ const mockTags: Tag[] = [
+ { id: 'tag-1', name: 'Tag 1', type: 'knowledge', binding_count: 0 },
+ { id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 },
+ ]
+
+ const defaultProps = {
+ dataset: createMockDataset(),
+ tags: mockTags,
+ setTags: vi.fn(),
+ onSuccess: vi.fn(),
+ isHoveringTagSelector: false,
+ onClick: vi.fn(),
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
+ })
+
+ it('should render TagSelector with correct value', () => {
+ render()
+ expect(screen.getByTestId('tag-values')).toHaveTextContent('tag-1,tag-2')
+ })
+
+ it('should display selected tags count', () => {
+ render()
+ expect(screen.getByTestId('selected-count')).toHaveTextContent('2 tags')
+ })
+ })
+
+ describe('Props', () => {
+ it('should pass dataset id to TagSelector', () => {
+ const dataset = createMockDataset({ id: 'custom-dataset-id' })
+ render()
+ expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
+ })
+
+ it('should render with empty tags', () => {
+ render()
+ expect(screen.getByTestId('selected-count')).toHaveTextContent('0 tags')
+ })
+
+ it('should forward ref correctly', () => {
+ const TestComponent = () => {
+ const ref = useRef(null)
+ return
+ }
+ render()
+ expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onClick when container is clicked', () => {
+ const onClick = vi.fn()
+ const { container } = render()
+
+ const wrapper = container.firstChild as HTMLElement
+ fireEvent.click(wrapper)
+
+ expect(onClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call setTags when tags are updated', () => {
+ const setTags = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByText('Update Tags'))
+
+ expect(setTags).toHaveBeenCalledWith([{ id: 'new-tag', name: 'New Tag', type: 'knowledge', binding_count: 0 }])
+ })
+
+ it('should call onSuccess when onChange is triggered', () => {
+ const onSuccess = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByText('Trigger Change'))
+
+ expect(onSuccess).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('Styles', () => {
+ it('should have opacity class when embedding is not available', () => {
+ const dataset = createMockDataset({ embedding_available: false })
+ const { container } = render()
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('opacity-30')
+ })
+
+ it('should not have opacity class when embedding is available', () => {
+ const dataset = createMockDataset({ embedding_available: true })
+ const { container } = render()
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).not.toHaveClass('opacity-30')
+ })
+
+ it('should show mask when not hovering and has tags', () => {
+ const { container } = render()
+ const maskDiv = container.querySelector('.bg-tag-selector-mask-bg')
+ expect(maskDiv).toBeInTheDocument()
+ expect(maskDiv).not.toHaveClass('hidden')
+ })
+
+ it('should hide mask when hovering', () => {
+ const { container } = render()
+ // When hovering, the mask div should have 'hidden' class
+ const maskDiv = container.querySelector('.absolute.right-0.top-0')
+ expect(maskDiv).toHaveClass('hidden')
+ })
+
+ it('should make TagSelector visible when tags exist', () => {
+ const { container } = render()
+ const tagSelectorWrapper = container.querySelector('.visible')
+ expect(tagSelectorWrapper).toBeInTheDocument()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle undefined onSuccess', () => {
+ render()
+ // Should not throw when clicking Trigger Change
+ expect(() => fireEvent.click(screen.getByText('Trigger Change'))).not.toThrow()
+ })
+
+ it('should handle many tags', () => {
+ const manyTags: Tag[] = Array.from({ length: 20 }, (_, i) => ({
+ id: `tag-${i}`,
+ name: `Tag ${i}`,
+ type: 'knowledge' as const,
+ binding_count: 0,
+ }))
+ render()
+ expect(screen.getByTestId('selected-count')).toHaveTextContent('20 tags')
+ })
+ })
+})
diff --git a/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.spec.ts b/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.spec.ts
new file mode 100644
index 0000000000..6eda57ae5b
--- /dev/null
+++ b/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.spec.ts
@@ -0,0 +1,427 @@
+import type { DataSet } from '@/models/datasets'
+import { act, renderHook, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { IndexingType } from '@/app/components/datasets/create/step-two'
+import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
+import { useDatasetCardState } from './use-dataset-card-state'
+
+// Mock Toast
+vi.mock('@/app/components/base/toast', () => ({
+ default: {
+ notify: vi.fn(),
+ },
+}))
+
+// Mock service hooks
+const mockCheckUsage = vi.fn()
+const mockDeleteDataset = vi.fn()
+const mockExportPipeline = vi.fn()
+
+vi.mock('@/service/use-dataset-card', () => ({
+ useCheckDatasetUsage: () => ({ mutateAsync: mockCheckUsage }),
+ useDeleteDataset: () => ({ mutateAsync: mockDeleteDataset }),
+}))
+
+vi.mock('@/service/use-pipeline', () => ({
+ useExportPipelineDSL: () => ({ mutateAsync: mockExportPipeline }),
+}))
+
+describe('useDatasetCardState', () => {
+ const createMockDataset = (overrides: Partial = {}): DataSet => ({
+ id: 'dataset-1',
+ name: 'Test Dataset',
+ description: 'Test description',
+ provider: 'vendor',
+ permission: DatasetPermission.allTeamMembers,
+ data_source_type: DataSourceType.FILE,
+ indexing_technique: IndexingType.QUALIFIED,
+ embedding_available: true,
+ app_count: 5,
+ document_count: 10,
+ word_count: 1000,
+ created_at: 1609459200,
+ updated_at: 1609545600,
+ tags: [{ id: 'tag-1', name: 'Tag 1', type: 'knowledge', binding_count: 0 }],
+ embedding_model: 'text-embedding-ada-002',
+ embedding_model_provider: 'openai',
+ created_by: 'user-1',
+ doc_form: ChunkingMode.text,
+ pipeline_id: 'pipeline-1',
+ ...overrides,
+ } as DataSet)
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckUsage.mockResolvedValue({ is_using: false })
+ mockDeleteDataset.mockResolvedValue({})
+ mockExportPipeline.mockResolvedValue({ data: 'yaml content' })
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ describe('Initial State', () => {
+ it('should return tags from dataset', () => {
+ const dataset = createMockDataset()
+ const { result } = renderHook(() =>
+ useDatasetCardState({ dataset, onSuccess: vi.fn() }),
+ )
+
+ expect(result.current.tags).toEqual(dataset.tags)
+ })
+
+ it('should have initial modal state closed', () => {
+ const dataset = createMockDataset()
+ const { result } = renderHook(() =>
+ useDatasetCardState({ dataset, onSuccess: vi.fn() }),
+ )
+
+ expect(result.current.modalState.showRenameModal).toBe(false)
+ expect(result.current.modalState.showConfirmDelete).toBe(false)
+ expect(result.current.modalState.confirmMessage).toBe('')
+ })
+
+ it('should not be exporting initially', () => {
+ const dataset = createMockDataset()
+ const { result } = renderHook(() =>
+ useDatasetCardState({ dataset, onSuccess: vi.fn() }),
+ )
+
+ expect(result.current.exporting).toBe(false)
+ })
+ })
+
+ describe('Tags State', () => {
+ it('should update tags when setTags is called', () => {
+ const dataset = createMockDataset()
+ const { result } = renderHook(() =>
+ useDatasetCardState({ dataset, onSuccess: vi.fn() }),
+ )
+
+ act(() => {
+ result.current.setTags([{ id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 }])
+ })
+
+ expect(result.current.tags).toEqual([{ id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 }])
+ })
+
+ it('should sync tags when dataset tags change', () => {
+ const dataset = createMockDataset()
+ const { result, rerender } = renderHook(
+ ({ dataset }) => useDatasetCardState({ dataset, onSuccess: vi.fn() }),
+ { initialProps: { dataset } },
+ )
+
+ const newTags = [{ id: 'tag-3', name: 'Tag 3', type: 'knowledge', binding_count: 0 }]
+ const updatedDataset = createMockDataset({ tags: newTags })
+
+ rerender({ dataset: updatedDataset })
+
+ expect(result.current.tags).toEqual(newTags)
+ })
+ })
+
+ describe('Modal Handlers', () => {
+ it('should open rename modal when openRenameModal is called', () => {
+ const dataset = createMockDataset()
+ const { result } = renderHook(() =>
+ useDatasetCardState({ dataset, onSuccess: vi.fn() }),
+ )
+
+ act(() => {
+ result.current.openRenameModal()
+ })
+
+ expect(result.current.modalState.showRenameModal).toBe(true)
+ })
+
+ it('should close rename modal when closeRenameModal is called', () => {
+ const dataset = createMockDataset()
+ const { result } = renderHook(() =>
+ useDatasetCardState({ dataset, onSuccess: vi.fn() }),
+ )
+
+ act(() => {
+ result.current.openRenameModal()
+ })
+
+ act(() => {
+ result.current.closeRenameModal()
+ })
+
+ expect(result.current.modalState.showRenameModal).toBe(false)
+ })
+
+ it('should close confirm delete modal when closeConfirmDelete is called', () => {
+ const dataset = createMockDataset()
+ const { result } = renderHook(() =>
+ useDatasetCardState({ dataset, onSuccess: vi.fn() }),
+ )
+
+ // First trigger show confirm delete
+ act(() => {
+ result.current.detectIsUsedByApp()
+ })
+
+ waitFor(() => {
+ expect(result.current.modalState.showConfirmDelete).toBe(true)
+ })
+
+ act(() => {
+ result.current.closeConfirmDelete()
+ })
+
+ expect(result.current.modalState.showConfirmDelete).toBe(false)
+ })
+ })
+
+ describe('detectIsUsedByApp', () => {
+ it('should check usage and show confirm modal with not-in-use message', async () => {
+ mockCheckUsage.mockResolvedValue({ is_using: false })
+ const dataset = createMockDataset()
+ const { result } = renderHook(() =>
+ useDatasetCardState({ dataset, onSuccess: vi.fn() }),
+ )
+
+ await act(async () => {
+ await result.current.detectIsUsedByApp()
+ })
+
+ expect(mockCheckUsage).toHaveBeenCalledWith('dataset-1')
+ expect(result.current.modalState.showConfirmDelete).toBe(true)
+ expect(result.current.modalState.confirmMessage).toContain('deleteDatasetConfirmContent')
+ })
+
+ it('should show in-use message when dataset is used by app', async () => {
+ mockCheckUsage.mockResolvedValue({ is_using: true })
+ const dataset = createMockDataset()
+ const { result } = renderHook(() =>
+ useDatasetCardState({ dataset, onSuccess: vi.fn() }),
+ )
+
+ await act(async () => {
+ await result.current.detectIsUsedByApp()
+ })
+
+ expect(result.current.modalState.confirmMessage).toContain('datasetUsedByApp')
+ })
+ })
+
+ describe('onConfirmDelete', () => {
+ it('should delete dataset and call onSuccess', async () => {
+ const onSuccess = vi.fn()
+ const dataset = createMockDataset()
+ const { result } = renderHook(() =>
+ useDatasetCardState({ dataset, onSuccess }),
+ )
+
+ await act(async () => {
+ await result.current.onConfirmDelete()
+ })
+
+ expect(mockDeleteDataset).toHaveBeenCalledWith('dataset-1')
+ expect(onSuccess).toHaveBeenCalled()
+ })
+
+ it('should close confirm modal after delete', async () => {
+ const dataset = createMockDataset()
+ const { result } = renderHook(() =>
+ useDatasetCardState({ dataset, onSuccess: vi.fn() }),
+ )
+
+ // First open confirm modal
+ await act(async () => {
+ await result.current.detectIsUsedByApp()
+ })
+
+ await act(async () => {
+ await result.current.onConfirmDelete()
+ })
+
+ expect(result.current.modalState.showConfirmDelete).toBe(false)
+ })
+ })
+
+ describe('handleExportPipeline', () => {
+ it('should not export if pipeline_id is missing', async () => {
+ const dataset = createMockDataset({ pipeline_id: undefined })
+ const { result } = renderHook(() =>
+ useDatasetCardState({ dataset, onSuccess: vi.fn() }),
+ )
+
+ await act(async () => {
+ await result.current.handleExportPipeline()
+ })
+
+ expect(mockExportPipeline).not.toHaveBeenCalled()
+ })
+
+ it('should export pipeline with correct parameters', async () => {
+ const dataset = createMockDataset({ pipeline_id: 'pipeline-1', name: 'Test Pipeline' })
+ const { result } = renderHook(() =>
+ useDatasetCardState({ dataset, onSuccess: vi.fn() }),
+ )
+
+ await act(async () => {
+ await result.current.handleExportPipeline(true)
+ })
+
+ expect(mockExportPipeline).toHaveBeenCalledWith({
+ pipelineId: 'pipeline-1',
+ include: true,
+ })
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty tags array', () => {
+ const dataset = createMockDataset({ tags: [] })
+ const { result } = renderHook(() =>
+ useDatasetCardState({ dataset, onSuccess: vi.fn() }),
+ )
+
+ expect(result.current.tags).toEqual([])
+ })
+
+ it('should handle undefined onSuccess', async () => {
+ const dataset = createMockDataset()
+ const { result } = renderHook(() =>
+ useDatasetCardState({ dataset }),
+ )
+
+ // Should not throw when onSuccess is undefined
+ await act(async () => {
+ await result.current.onConfirmDelete()
+ })
+
+ expect(mockDeleteDataset).toHaveBeenCalled()
+ })
+ })
+
+ describe('Error Handling', () => {
+ it('should show error toast when export pipeline fails', async () => {
+ const Toast = await import('@/app/components/base/toast')
+ mockExportPipeline.mockRejectedValue(new Error('Export failed'))
+
+ const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
+ const { result } = renderHook(() =>
+ useDatasetCardState({ dataset, onSuccess: vi.fn() }),
+ )
+
+ await act(async () => {
+ await result.current.handleExportPipeline()
+ })
+
+ expect(Toast.default.notify).toHaveBeenCalledWith({
+ type: 'error',
+ message: expect.any(String),
+ })
+ })
+
+ it('should handle Response error in detectIsUsedByApp', async () => {
+ const Toast = await import('@/app/components/base/toast')
+ const mockResponse = new Response(JSON.stringify({ message: 'API Error' }), {
+ status: 400,
+ })
+ mockCheckUsage.mockRejectedValue(mockResponse)
+
+ const dataset = createMockDataset()
+ const { result } = renderHook(() =>
+ useDatasetCardState({ dataset, onSuccess: vi.fn() }),
+ )
+
+ await act(async () => {
+ await result.current.detectIsUsedByApp()
+ })
+
+ expect(Toast.default.notify).toHaveBeenCalledWith({
+ type: 'error',
+ message: expect.stringContaining('API Error'),
+ })
+ })
+
+ it('should handle generic Error in detectIsUsedByApp', async () => {
+ const Toast = await import('@/app/components/base/toast')
+ mockCheckUsage.mockRejectedValue(new Error('Network error'))
+
+ const dataset = createMockDataset()
+ const { result } = renderHook(() =>
+ useDatasetCardState({ dataset, onSuccess: vi.fn() }),
+ )
+
+ await act(async () => {
+ await result.current.detectIsUsedByApp()
+ })
+
+ expect(Toast.default.notify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'Network error',
+ })
+ })
+
+ it('should handle error without message in detectIsUsedByApp', async () => {
+ const Toast = await import('@/app/components/base/toast')
+ mockCheckUsage.mockRejectedValue({})
+
+ const dataset = createMockDataset()
+ const { result } = renderHook(() =>
+ useDatasetCardState({ dataset, onSuccess: vi.fn() }),
+ )
+
+ await act(async () => {
+ await result.current.detectIsUsedByApp()
+ })
+
+ expect(Toast.default.notify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'Unknown error',
+ })
+ })
+
+ it('should handle exporting state correctly', async () => {
+ const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
+ const { result } = renderHook(() =>
+ useDatasetCardState({ dataset, onSuccess: vi.fn() }),
+ )
+
+ // Exporting should initially be false
+ expect(result.current.exporting).toBe(false)
+
+ // Export should work when not exporting
+ await act(async () => {
+ await result.current.handleExportPipeline()
+ })
+
+ expect(mockExportPipeline).toHaveBeenCalled()
+ })
+
+ it('should reset exporting state after export completes', async () => {
+ const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
+ const { result } = renderHook(() =>
+ useDatasetCardState({ dataset, onSuccess: vi.fn() }),
+ )
+
+ await act(async () => {
+ await result.current.handleExportPipeline()
+ })
+
+ expect(result.current.exporting).toBe(false)
+ })
+
+ it('should reset exporting state even when export fails', async () => {
+ mockExportPipeline.mockRejectedValue(new Error('Export failed'))
+
+ const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
+ const { result } = renderHook(() =>
+ useDatasetCardState({ dataset, onSuccess: vi.fn() }),
+ )
+
+ await act(async () => {
+ await result.current.handleExportPipeline()
+ })
+
+ expect(result.current.exporting).toBe(false)
+ })
+ })
+})
diff --git a/web/app/components/datasets/list/dataset-card/index.spec.tsx b/web/app/components/datasets/list/dataset-card/index.spec.tsx
new file mode 100644
index 0000000000..dd27eaa262
--- /dev/null
+++ b/web/app/components/datasets/list/dataset-card/index.spec.tsx
@@ -0,0 +1,256 @@
+import type { DataSet } from '@/models/datasets'
+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, DatasetPermission, DataSourceType } from '@/models/datasets'
+import { RETRIEVE_METHOD } from '@/types/app'
+import DatasetCard from './index'
+
+// Mock next/navigation
+const mockPush = vi.fn()
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ push: mockPush }),
+}))
+
+// Mock ahooks useHover
+vi.mock('ahooks', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useHover: () => false,
+ }
+})
+
+// Mock app context
+vi.mock('@/context/app-context', () => ({
+ useSelector: () => false,
+}))
+
+// Mock the useDatasetCardState hook
+vi.mock('./hooks/use-dataset-card-state', () => ({
+ useDatasetCardState: () => ({
+ tags: [],
+ setTags: vi.fn(),
+ modalState: {
+ showRenameModal: false,
+ showConfirmDelete: false,
+ confirmMessage: '',
+ },
+ openRenameModal: vi.fn(),
+ closeRenameModal: vi.fn(),
+ closeConfirmDelete: vi.fn(),
+ handleExportPipeline: vi.fn(),
+ detectIsUsedByApp: vi.fn(),
+ onConfirmDelete: vi.fn(),
+ }),
+}))
+
+// Mock the RenameDatasetModal
+vi.mock('../../rename-modal', () => ({
+ default: () => null,
+}))
+
+// Mock useFormatTimeFromNow hook
+vi.mock('@/hooks/use-format-time-from-now', () => ({
+ useFormatTimeFromNow: () => ({
+ formatTimeFromNow: (timestamp: number) => {
+ const date = new Date(timestamp)
+ return date.toLocaleDateString()
+ },
+ }),
+}))
+
+// Mock useKnowledge hook
+vi.mock('@/hooks/use-knowledge', () => ({
+ useKnowledge: () => ({
+ formatIndexingTechniqueAndMethod: () => 'High Quality',
+ }),
+}))
+
+describe('DatasetCard', () => {
+ const createMockDataset = (overrides: Partial = {}): DataSet => ({
+ id: 'dataset-1',
+ name: 'Test Dataset',
+ description: 'Test description',
+ provider: 'vendor',
+ permission: DatasetPermission.allTeamMembers,
+ data_source_type: DataSourceType.FILE,
+ indexing_technique: IndexingType.QUALIFIED,
+ embedding_available: true,
+ app_count: 5,
+ document_count: 10,
+ word_count: 1000,
+ created_at: 1609459200,
+ updated_at: 1609545600,
+ tags: [],
+ embedding_model: 'text-embedding-ada-002',
+ embedding_model_provider: 'openai',
+ created_by: 'user-1',
+ doc_form: ChunkingMode.text,
+ runtime_mode: 'general',
+ is_published: true,
+ total_available_documents: 10,
+ icon_info: {
+ icon: '📙',
+ icon_type: 'emoji' as const,
+ icon_background: '#FFF4ED',
+ icon_url: '',
+ },
+ retrieval_model_dict: {
+ search_method: RETRIEVE_METHOD.semantic,
+ },
+ author_name: 'Test User',
+ ...overrides,
+ } as DataSet)
+
+ const defaultProps = {
+ dataset: createMockDataset(),
+ onSuccess: vi.fn(),
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByText('Test Dataset')).toBeInTheDocument()
+ })
+
+ it('should render dataset name', () => {
+ const dataset = createMockDataset({ name: 'Custom Dataset Name' })
+ render()
+ expect(screen.getByText('Custom Dataset Name')).toBeInTheDocument()
+ })
+
+ it('should render dataset description', () => {
+ const dataset = createMockDataset({ description: 'Custom Description' })
+ render()
+ expect(screen.getByText('Custom Description')).toBeInTheDocument()
+ })
+
+ it('should render document count', () => {
+ render()
+ expect(screen.getByText('10')).toBeInTheDocument()
+ })
+
+ it('should render app count', () => {
+ render()
+ expect(screen.getByText('5')).toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should handle external provider', () => {
+ const dataset = createMockDataset({ provider: 'external' })
+ render()
+ expect(screen.getByText('Test Dataset')).toBeInTheDocument()
+ })
+
+ it('should handle rag_pipeline runtime mode', () => {
+ const dataset = createMockDataset({ runtime_mode: 'rag_pipeline', is_published: true })
+ render()
+ expect(screen.getByText('Test Dataset')).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should navigate to documents page on click for regular dataset', () => {
+ const dataset = createMockDataset({ provider: 'vendor' })
+ render()
+
+ const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
+ fireEvent.click(card!)
+
+ expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents')
+ })
+
+ it('should navigate to hitTesting page on click for external provider', () => {
+ const dataset = createMockDataset({ provider: 'external' })
+ render()
+
+ const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
+ fireEvent.click(card!)
+
+ expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/hitTesting')
+ })
+
+ it('should navigate to pipeline page when pipeline is unpublished', () => {
+ const dataset = createMockDataset({ runtime_mode: 'rag_pipeline', is_published: false })
+ render()
+
+ const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
+ fireEvent.click(card!)
+
+ expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/pipeline')
+ })
+ })
+
+ describe('Styles', () => {
+ it('should have correct card styling', () => {
+ render()
+ const card = screen.getByText('Test Dataset').closest('.group')
+ expect(card).toHaveClass('h-[190px]', 'cursor-pointer', 'flex-col', 'rounded-xl')
+ })
+
+ it('should have data-disable-nprogress attribute', () => {
+ render()
+ const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
+ expect(card).toHaveAttribute('data-disable-nprogress', 'true')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle dataset without description', () => {
+ const dataset = createMockDataset({ description: '' })
+ render()
+ expect(screen.getByText('Test Dataset')).toBeInTheDocument()
+ })
+
+ it('should handle embedding not available', () => {
+ const dataset = createMockDataset({ embedding_available: false })
+ render()
+ expect(screen.getByText('Test Dataset')).toBeInTheDocument()
+ })
+
+ it('should handle undefined onSuccess', () => {
+ render()
+ expect(screen.getByText('Test Dataset')).toBeInTheDocument()
+ })
+ })
+
+ describe('Tag Area Click', () => {
+ it('should stop propagation and prevent default when tag area is clicked', () => {
+ render()
+
+ // Find tag area element (it's inside the card)
+ const tagAreaWrapper = document.querySelector('[class*="px-3"]')
+ if (tagAreaWrapper) {
+ const stopPropagationSpy = vi.fn()
+ const preventDefaultSpy = vi.fn()
+
+ const clickEvent = new MouseEvent('click', { bubbles: true })
+ Object.defineProperty(clickEvent, 'stopPropagation', { value: stopPropagationSpy })
+ Object.defineProperty(clickEvent, 'preventDefault', { value: preventDefaultSpy })
+
+ tagAreaWrapper.dispatchEvent(clickEvent)
+
+ expect(stopPropagationSpy).toHaveBeenCalled()
+ expect(preventDefaultSpy).toHaveBeenCalled()
+ }
+ })
+
+ it('should not navigate when clicking on tag area', () => {
+ render()
+
+ // Click on tag area should not trigger card navigation
+ const tagArea = document.querySelector('[class*="px-3"]')
+ if (tagArea) {
+ fireEvent.click(tagArea)
+ // mockPush should NOT be called when clicking tag area
+ // (stopPropagation prevents it from reaching the card click handler)
+ }
+ })
+ })
+})
diff --git a/web/app/components/datasets/list/dataset-card/operation-item.spec.tsx b/web/app/components/datasets/list/dataset-card/operation-item.spec.tsx
new file mode 100644
index 0000000000..848d83c416
--- /dev/null
+++ b/web/app/components/datasets/list/dataset-card/operation-item.spec.tsx
@@ -0,0 +1,87 @@
+import { RiEditLine } from '@remixicon/react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import OperationItem from './operation-item'
+
+describe('OperationItem', () => {
+ const defaultProps = {
+ Icon: RiEditLine,
+ name: 'Edit',
+ }
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByText('Edit')).toBeInTheDocument()
+ })
+
+ it('should render the icon', () => {
+ const { container } = render()
+ const icon = container.querySelector('svg')
+ expect(icon).toBeInTheDocument()
+ expect(icon).toHaveClass('size-4', 'text-text-tertiary')
+ })
+
+ it('should render the name text', () => {
+ render()
+ const nameSpan = screen.getByText('Edit')
+ expect(nameSpan).toHaveClass('system-md-regular', 'text-text-secondary')
+ })
+ })
+
+ describe('Props', () => {
+ it('should render different name', () => {
+ render()
+ expect(screen.getByText('Delete')).toBeInTheDocument()
+ })
+
+ it('should be callable without handleClick', () => {
+ render()
+ const item = screen.getByText('Edit').closest('div')
+ expect(() => fireEvent.click(item!)).not.toThrow()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call handleClick when clicked', () => {
+ const handleClick = vi.fn()
+ render()
+
+ const item = screen.getByText('Edit').closest('div')
+ fireEvent.click(item!)
+
+ expect(handleClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('should prevent default and stop propagation on click', () => {
+ const handleClick = vi.fn()
+ render()
+
+ const item = screen.getByText('Edit').closest('div')
+ const clickEvent = new MouseEvent('click', { bubbles: true })
+ const preventDefaultSpy = vi.spyOn(clickEvent, 'preventDefault')
+ const stopPropagationSpy = vi.spyOn(clickEvent, 'stopPropagation')
+
+ item!.dispatchEvent(clickEvent)
+
+ expect(preventDefaultSpy).toHaveBeenCalled()
+ expect(stopPropagationSpy).toHaveBeenCalled()
+ })
+ })
+
+ describe('Styles', () => {
+ it('should have correct container styling', () => {
+ render()
+ const item = screen.getByText('Edit').closest('div')
+ expect(item).toHaveClass('flex', 'cursor-pointer', 'items-center', 'gap-x-1', 'rounded-lg')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty name', () => {
+ render()
+ const container = document.querySelector('.cursor-pointer')
+ expect(container).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/list/dataset-card/operations.spec.tsx b/web/app/components/datasets/list/dataset-card/operations.spec.tsx
new file mode 100644
index 0000000000..66a799baa5
--- /dev/null
+++ b/web/app/components/datasets/list/dataset-card/operations.spec.tsx
@@ -0,0 +1,119 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import Operations from './operations'
+
+describe('Operations', () => {
+ const defaultProps = {
+ showDelete: true,
+ showExportPipeline: true,
+ openRenameModal: vi.fn(),
+ handleExportPipeline: vi.fn(),
+ detectIsUsedByApp: vi.fn(),
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ // Edit operation should always be visible
+ expect(screen.getByText(/operation\.edit/)).toBeInTheDocument()
+ })
+
+ it('should render edit operation', () => {
+ render()
+ expect(screen.getByText(/operation\.edit/)).toBeInTheDocument()
+ })
+
+ it('should render export pipeline operation when showExportPipeline is true', () => {
+ render()
+ expect(screen.getByText(/exportPipeline/)).toBeInTheDocument()
+ })
+
+ it('should not render export pipeline operation when showExportPipeline is false', () => {
+ render()
+ expect(screen.queryByText(/exportPipeline/)).not.toBeInTheDocument()
+ })
+
+ it('should render delete operation when showDelete is true', () => {
+ render()
+ expect(screen.getByText(/operation\.delete/)).toBeInTheDocument()
+ })
+
+ it('should not render delete operation when showDelete is false', () => {
+ render()
+ expect(screen.queryByText(/operation\.delete/)).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should render divider when showDelete is true', () => {
+ const { container } = render()
+ const divider = container.querySelector('.bg-divider-subtle')
+ expect(divider).toBeInTheDocument()
+ })
+
+ it('should not render divider when showDelete is false', () => {
+ const { container } = render()
+ // Should not have the divider-subtle one (the separator before delete)
+ expect(container.querySelector('.bg-divider-subtle')).toBeNull()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call openRenameModal when edit is clicked', () => {
+ const openRenameModal = vi.fn()
+ render()
+
+ const editItem = screen.getByText(/operation\.edit/).closest('div')
+ fireEvent.click(editItem!)
+
+ expect(openRenameModal).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call handleExportPipeline when export is clicked', () => {
+ const handleExportPipeline = vi.fn()
+ render()
+
+ const exportItem = screen.getByText(/exportPipeline/).closest('div')
+ fireEvent.click(exportItem!)
+
+ expect(handleExportPipeline).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call detectIsUsedByApp when delete is clicked', () => {
+ const detectIsUsedByApp = vi.fn()
+ render()
+
+ const deleteItem = screen.getByText(/operation\.delete/).closest('div')
+ fireEvent.click(deleteItem!)
+
+ expect(detectIsUsedByApp).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('Styles', () => {
+ it('should have correct container styling', () => {
+ const { container } = render()
+ const operationsContainer = container.firstChild
+ expect(operationsContainer).toHaveClass(
+ 'relative',
+ 'flex',
+ 'w-full',
+ 'flex-col',
+ 'rounded-xl',
+ )
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should render only edit when both showDelete and showExportPipeline are false', () => {
+ render()
+ expect(screen.getByText(/operation\.edit/)).toBeInTheDocument()
+ expect(screen.queryByText(/exportPipeline/)).not.toBeInTheDocument()
+ expect(screen.queryByText(/operation\.delete/)).not.toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/list/dataset-footer/index.spec.tsx b/web/app/components/datasets/list/dataset-footer/index.spec.tsx
index b59990c682..1bea093c91 100644
--- a/web/app/components/datasets/list/dataset-footer/index.spec.tsx
+++ b/web/app/components/datasets/list/dataset-footer/index.spec.tsx
@@ -1,30 +1,52 @@
import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
import DatasetFooter from './index'
describe('DatasetFooter', () => {
- it('should render correctly', () => {
- render()
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByRole('contentinfo')).toBeInTheDocument()
+ })
- // Check main title (mocked i18n returns ns:key or key)
- // The code uses t('didYouKnow', { ns: 'dataset' })
- // With default mock it likely returns 'dataset.didYouKnow'
- expect(screen.getByText('dataset.didYouKnow')).toBeInTheDocument()
+ it('should render the main heading', () => {
+ render()
+ expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument()
+ })
- // Check paragraph content
- expect(screen.getByText(/dataset.intro1/)).toBeInTheDocument()
- expect(screen.getByText(/dataset.intro2/)).toBeInTheDocument()
- expect(screen.getByText(/dataset.intro3/)).toBeInTheDocument()
- expect(screen.getByText(/dataset.intro4/)).toBeInTheDocument()
- expect(screen.getByText(/dataset.intro5/)).toBeInTheDocument()
- expect(screen.getByText(/dataset.intro6/)).toBeInTheDocument()
+ it('should render description paragraph', () => {
+ render()
+ // The paragraph contains multiple text spans
+ expect(screen.getByText(/intro1/)).toBeInTheDocument()
+ })
})
- it('should have correct styling', () => {
- const { container } = render()
- const footer = container.querySelector('footer')
- expect(footer).toHaveClass('shrink-0', 'px-12', 'py-6')
+ describe('Props', () => {
+ it('should be memoized', () => {
+ // DatasetFooter is wrapped with React.memo
+ expect(DatasetFooter).toBeDefined()
+ })
+ })
- const h3 = container.querySelector('h3')
- expect(h3).toHaveClass('text-gradient')
+ describe('Styles', () => {
+ it('should have correct footer styling', () => {
+ render()
+ const footer = screen.getByRole('contentinfo')
+ expect(footer).toHaveClass('shrink-0', 'px-12', 'py-6')
+ })
+
+ it('should have gradient text on heading', () => {
+ render()
+ const heading = screen.getByRole('heading', { level: 3 })
+ expect(heading).toHaveClass('text-gradient')
+ })
+ })
+
+ describe('Content Structure', () => {
+ it('should render accent spans for highlighted text', () => {
+ render()
+ const accentSpans = document.querySelectorAll('.text-text-accent')
+ expect(accentSpans.length).toBe(2)
+ })
})
})
diff --git a/web/app/components/datasets/list/datasets.spec.tsx b/web/app/components/datasets/list/datasets.spec.tsx
new file mode 100644
index 0000000000..38843ab2e0
--- /dev/null
+++ b/web/app/components/datasets/list/datasets.spec.tsx
@@ -0,0 +1,485 @@
+import type { DataSet } from '@/models/datasets'
+import { render, screen, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { IndexingType } from '@/app/components/datasets/create/step-two'
+import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
+import { RETRIEVE_METHOD } from '@/types/app'
+import Datasets from './datasets'
+
+// Mock next/navigation
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ push: vi.fn() }),
+}))
+
+// Mock ahooks
+vi.mock('ahooks', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useHover: () => false,
+ }
+})
+
+// Mock useFormatTimeFromNow hook
+vi.mock('@/hooks/use-format-time-from-now', () => ({
+ useFormatTimeFromNow: () => ({
+ formatTimeFromNow: (timestamp: number) => new Date(timestamp).toLocaleDateString(),
+ }),
+}))
+
+// Mock useKnowledge hook
+vi.mock('@/hooks/use-knowledge', () => ({
+ useKnowledge: () => ({
+ formatIndexingTechniqueAndMethod: () => 'High Quality',
+ }),
+}))
+
+// Mock service hooks - will be overridden in individual tests
+const mockFetchNextPage = vi.fn()
+const mockInvalidDatasetList = vi.fn()
+
+vi.mock('@/service/knowledge/use-dataset', () => ({
+ useDatasetList: vi.fn(() => ({
+ data: {
+ pages: [
+ {
+ data: [
+ createMockDataset({ id: 'dataset-1', name: 'Dataset 1' }),
+ createMockDataset({ id: 'dataset-2', name: 'Dataset 2' }),
+ ],
+ },
+ ],
+ },
+ fetchNextPage: mockFetchNextPage,
+ hasNextPage: false,
+ isFetching: false,
+ isFetchingNextPage: false,
+ })),
+ useInvalidDatasetList: () => mockInvalidDatasetList,
+}))
+
+// Mock app context - will be overridden in tests
+vi.mock('@/context/app-context', () => ({
+ useSelector: vi.fn(() => true),
+}))
+
+// Mock useDatasetCardState hook
+vi.mock('./dataset-card/hooks/use-dataset-card-state', () => ({
+ useDatasetCardState: () => ({
+ tags: [],
+ setTags: vi.fn(),
+ modalState: {
+ showRenameModal: false,
+ showConfirmDelete: false,
+ confirmMessage: '',
+ },
+ openRenameModal: vi.fn(),
+ closeRenameModal: vi.fn(),
+ closeConfirmDelete: vi.fn(),
+ handleExportPipeline: vi.fn(),
+ detectIsUsedByApp: vi.fn(),
+ onConfirmDelete: vi.fn(),
+ }),
+}))
+
+// Mock RenameDatasetModal
+vi.mock('../rename-modal', () => ({
+ default: () => null,
+}))
+
+function createMockDataset(overrides: Partial = {}): DataSet {
+ return {
+ id: 'dataset-1',
+ name: 'Test Dataset',
+ description: 'Test description',
+ provider: 'vendor',
+ permission: DatasetPermission.allTeamMembers,
+ data_source_type: DataSourceType.FILE,
+ indexing_technique: IndexingType.QUALIFIED,
+ embedding_available: true,
+ app_count: 5,
+ document_count: 10,
+ word_count: 1000,
+ created_at: 1609459200,
+ updated_at: 1609545600,
+ tags: [],
+ embedding_model: 'text-embedding-ada-002',
+ embedding_model_provider: 'openai',
+ created_by: 'user-1',
+ doc_form: ChunkingMode.text,
+ runtime_mode: 'general',
+ is_published: true,
+ total_available_documents: 10,
+ icon_info: {
+ icon: '📙',
+ icon_type: 'emoji' as const,
+ icon_background: '#FFF4ED',
+ icon_url: '',
+ },
+ retrieval_model_dict: {
+ search_method: RETRIEVE_METHOD.semantic,
+ },
+ author_name: 'Test User',
+ ...overrides,
+ } as DataSet
+}
+
+// Store IntersectionObserver callbacks for testing
+let intersectionObserverCallback: IntersectionObserverCallback | null = null
+const mockObserve = vi.fn()
+const mockDisconnect = vi.fn()
+const mockUnobserve = vi.fn()
+
+// Custom IntersectionObserver mock
+class MockIntersectionObserver {
+ constructor(callback: IntersectionObserverCallback) {
+ intersectionObserverCallback = callback
+ }
+
+ observe = mockObserve
+ disconnect = mockDisconnect
+ unobserve = mockUnobserve
+ root = null
+ rootMargin = ''
+ thresholds = []
+ takeRecords = () => []
+}
+
+describe('Datasets', () => {
+ const defaultProps = {
+ tags: [],
+ keywords: '',
+ includeAll: false,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ intersectionObserverCallback = null
+ document.title = ''
+
+ // Setup IntersectionObserver mock
+ vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByRole('navigation')).toBeInTheDocument()
+ })
+
+ it('should render NewDatasetCard when user is editor', async () => {
+ const { useSelector } = await import('@/context/app-context')
+ vi.mocked(useSelector).mockReturnValue(true)
+
+ render()
+ expect(screen.getByText(/createDataset/)).toBeInTheDocument()
+ })
+
+ it('should NOT render NewDatasetCard when user is NOT editor', async () => {
+ const { useSelector } = await import('@/context/app-context')
+ vi.mocked(useSelector).mockReturnValue(false)
+
+ render()
+ expect(screen.queryByText(/createDataset/)).not.toBeInTheDocument()
+ })
+
+ it('should render dataset cards from data', () => {
+ render()
+ expect(screen.getByText('Dataset 1')).toBeInTheDocument()
+ expect(screen.getByText('Dataset 2')).toBeInTheDocument()
+ })
+
+ it('should render anchor div for infinite scroll', () => {
+ render()
+ const anchor = document.querySelector('.h-0')
+ expect(anchor).toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should pass tags to useDatasetList', async () => {
+ const { useDatasetList } = await import('@/service/knowledge/use-dataset')
+ render()
+ expect(useDatasetList).toHaveBeenCalledWith(
+ expect.objectContaining({
+ tag_ids: ['tag-1', 'tag-2'],
+ }),
+ )
+ })
+
+ it('should pass keywords to useDatasetList', async () => {
+ const { useDatasetList } = await import('@/service/knowledge/use-dataset')
+ render()
+ expect(useDatasetList).toHaveBeenCalledWith(
+ expect.objectContaining({
+ keyword: 'search term',
+ }),
+ )
+ })
+
+ it('should pass includeAll to useDatasetList', async () => {
+ const { useDatasetList } = await import('@/service/knowledge/use-dataset')
+ render()
+ expect(useDatasetList).toHaveBeenCalledWith(
+ expect.objectContaining({
+ include_all: true,
+ }),
+ )
+ })
+ })
+
+ describe('Document Title', () => {
+ it('should set document title on mount', async () => {
+ render()
+ await waitFor(() => {
+ expect(document.title).toContain('dataset.knowledge')
+ })
+ })
+ })
+
+ describe('Loading States', () => {
+ it('should show Loading component when isFetchingNextPage is true', async () => {
+ const { useDatasetList } = await import('@/service/knowledge/use-dataset')
+ vi.mocked(useDatasetList).mockReturnValue({
+ data: { pages: [{ data: [] }] },
+ fetchNextPage: mockFetchNextPage,
+ hasNextPage: true,
+ isFetching: false,
+ isFetchingNextPage: true,
+ } as unknown as ReturnType)
+
+ render()
+ // Loading component renders a div with loading classes
+ const nav = screen.getByRole('navigation')
+ expect(nav).toBeInTheDocument()
+ })
+
+ it('should NOT show Loading component when isFetchingNextPage is false', async () => {
+ const { useDatasetList } = await import('@/service/knowledge/use-dataset')
+ vi.mocked(useDatasetList).mockReturnValue({
+ data: { pages: [{ data: [] }] },
+ fetchNextPage: mockFetchNextPage,
+ hasNextPage: true,
+ isFetching: false,
+ isFetchingNextPage: false,
+ } as unknown as ReturnType)
+
+ render()
+ expect(screen.getByRole('navigation')).toBeInTheDocument()
+ })
+ })
+
+ describe('DatasetList null handling', () => {
+ it('should handle null datasetList gracefully', async () => {
+ const { useDatasetList } = await import('@/service/knowledge/use-dataset')
+ vi.mocked(useDatasetList).mockReturnValue({
+ data: null,
+ fetchNextPage: mockFetchNextPage,
+ hasNextPage: false,
+ isFetching: false,
+ isFetchingNextPage: false,
+ } as unknown as ReturnType)
+
+ render()
+ expect(screen.getByRole('navigation')).toBeInTheDocument()
+ })
+
+ it('should handle undefined datasetList gracefully', async () => {
+ const { useDatasetList } = await import('@/service/knowledge/use-dataset')
+ vi.mocked(useDatasetList).mockReturnValue({
+ data: undefined,
+ fetchNextPage: mockFetchNextPage,
+ hasNextPage: false,
+ isFetching: false,
+ isFetchingNextPage: false,
+ } as unknown as ReturnType)
+
+ render()
+ expect(screen.getByRole('navigation')).toBeInTheDocument()
+ })
+
+ it('should handle empty pages array', async () => {
+ const { useDatasetList } = await import('@/service/knowledge/use-dataset')
+ vi.mocked(useDatasetList).mockReturnValue({
+ data: { pages: [] },
+ fetchNextPage: mockFetchNextPage,
+ hasNextPage: false,
+ isFetching: false,
+ isFetchingNextPage: false,
+ } as unknown as ReturnType)
+
+ render()
+ expect(screen.getByRole('navigation')).toBeInTheDocument()
+ })
+ })
+
+ describe('IntersectionObserver', () => {
+ it('should setup IntersectionObserver on mount', async () => {
+ const { useDatasetList } = await import('@/service/knowledge/use-dataset')
+ vi.mocked(useDatasetList).mockReturnValue({
+ data: { pages: [{ data: [] }] },
+ fetchNextPage: mockFetchNextPage,
+ hasNextPage: true,
+ isFetching: false,
+ isFetchingNextPage: false,
+ } as unknown as ReturnType)
+
+ render()
+
+ // Should observe the anchor element
+ expect(mockObserve).toHaveBeenCalled()
+ })
+
+ it('should call fetchNextPage when isIntersecting, hasNextPage, and not isFetching', async () => {
+ const { useDatasetList } = await import('@/service/knowledge/use-dataset')
+ vi.mocked(useDatasetList).mockReturnValue({
+ data: { pages: [{ data: [] }] },
+ fetchNextPage: mockFetchNextPage,
+ hasNextPage: true,
+ isFetching: false,
+ isFetchingNextPage: false,
+ } as unknown as ReturnType)
+
+ render()
+
+ // Simulate intersection
+ if (intersectionObserverCallback) {
+ intersectionObserverCallback(
+ [{ isIntersecting: true } as IntersectionObserverEntry],
+ {} as IntersectionObserver,
+ )
+ }
+
+ expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
+ })
+
+ it('should NOT call fetchNextPage when isIntersecting is false', async () => {
+ const { useDatasetList } = await import('@/service/knowledge/use-dataset')
+ vi.mocked(useDatasetList).mockReturnValue({
+ data: { pages: [{ data: [] }] },
+ fetchNextPage: mockFetchNextPage,
+ hasNextPage: true,
+ isFetching: false,
+ isFetchingNextPage: false,
+ } as unknown as ReturnType)
+
+ render()
+
+ if (intersectionObserverCallback) {
+ intersectionObserverCallback(
+ [{ isIntersecting: false } as IntersectionObserverEntry],
+ {} as IntersectionObserver,
+ )
+ }
+
+ expect(mockFetchNextPage).not.toHaveBeenCalled()
+ })
+
+ it('should NOT call fetchNextPage when hasNextPage is false', async () => {
+ const { useDatasetList } = await import('@/service/knowledge/use-dataset')
+ vi.mocked(useDatasetList).mockReturnValue({
+ data: { pages: [{ data: [] }] },
+ fetchNextPage: mockFetchNextPage,
+ hasNextPage: false, // No more pages
+ isFetching: false,
+ isFetchingNextPage: false,
+ } as unknown as ReturnType)
+
+ render()
+
+ if (intersectionObserverCallback) {
+ intersectionObserverCallback(
+ [{ isIntersecting: true } as IntersectionObserverEntry],
+ {} as IntersectionObserver,
+ )
+ }
+
+ expect(mockFetchNextPage).not.toHaveBeenCalled()
+ })
+
+ it('should NOT call fetchNextPage when isFetching is true', async () => {
+ const { useDatasetList } = await import('@/service/knowledge/use-dataset')
+ vi.mocked(useDatasetList).mockReturnValue({
+ data: { pages: [{ data: [] }] },
+ fetchNextPage: mockFetchNextPage,
+ hasNextPage: true,
+ isFetching: true, // Already fetching
+ isFetchingNextPage: false,
+ } as unknown as ReturnType)
+
+ render()
+
+ if (intersectionObserverCallback) {
+ intersectionObserverCallback(
+ [{ isIntersecting: true } as IntersectionObserverEntry],
+ {} as IntersectionObserver,
+ )
+ }
+
+ expect(mockFetchNextPage).not.toHaveBeenCalled()
+ })
+
+ it('should disconnect observer on unmount', async () => {
+ const { useDatasetList } = await import('@/service/knowledge/use-dataset')
+ vi.mocked(useDatasetList).mockReturnValue({
+ data: { pages: [{ data: [] }] },
+ fetchNextPage: mockFetchNextPage,
+ hasNextPage: true,
+ isFetching: false,
+ isFetchingNextPage: false,
+ } as unknown as ReturnType)
+
+ const { unmount } = render()
+
+ // Unmount the component
+ unmount()
+
+ // disconnect should be called during cleanup
+ expect(mockDisconnect).toHaveBeenCalled()
+ })
+ })
+
+ describe('Styles', () => {
+ it('should have correct grid styling', () => {
+ render()
+ const nav = screen.getByRole('navigation')
+ expect(nav).toHaveClass('grid', 'grow', 'gap-3', 'px-12')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty tags array', () => {
+ render()
+ expect(screen.getByRole('navigation')).toBeInTheDocument()
+ })
+
+ it('should handle empty keywords', () => {
+ render()
+ expect(screen.getByRole('navigation')).toBeInTheDocument()
+ })
+
+ it('should handle multiple pages of data', async () => {
+ const { useDatasetList } = await import('@/service/knowledge/use-dataset')
+ vi.mocked(useDatasetList).mockReturnValue({
+ data: {
+ pages: [
+ { data: [createMockDataset({ id: 'ds-1', name: 'Page 1 Dataset' })] },
+ { data: [createMockDataset({ id: 'ds-2', name: 'Page 2 Dataset' })] },
+ ],
+ },
+ fetchNextPage: mockFetchNextPage,
+ hasNextPage: false,
+ isFetching: false,
+ isFetchingNextPage: false,
+ } as unknown as ReturnType)
+
+ render()
+ expect(screen.getByText('Page 1 Dataset')).toBeInTheDocument()
+ expect(screen.getByText('Page 2 Dataset')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/list/index.spec.tsx b/web/app/components/datasets/list/index.spec.tsx
new file mode 100644
index 0000000000..ff48774c87
--- /dev/null
+++ b/web/app/components/datasets/list/index.spec.tsx
@@ -0,0 +1,368 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import List from './index'
+
+// Mock next/navigation
+const mockPush = vi.fn()
+const mockReplace = vi.fn()
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({
+ push: mockPush,
+ replace: mockReplace,
+ }),
+}))
+
+// Mock ahooks
+vi.mock('ahooks', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useBoolean: () => [false, { toggle: vi.fn(), setTrue: vi.fn(), setFalse: vi.fn() }],
+ useDebounceFn: (fn: () => void) => ({ run: fn }),
+ useHover: () => false,
+ }
+})
+
+// Mock app context
+vi.mock('@/context/app-context', () => ({
+ useAppContext: () => ({
+ currentWorkspace: { role: 'admin' },
+ isCurrentWorkspaceOwner: true,
+ }),
+ useSelector: () => true,
+}))
+
+// Mock global public context
+vi.mock('@/context/global-public-context', () => ({
+ useGlobalPublicStore: () => ({
+ systemFeatures: {
+ branding: { enabled: false },
+ },
+ }),
+}))
+
+// Mock external api panel context
+const mockSetShowExternalApiPanel = vi.fn()
+vi.mock('@/context/external-api-panel-context', () => ({
+ useExternalApiPanel: () => ({
+ showExternalApiPanel: false,
+ setShowExternalApiPanel: mockSetShowExternalApiPanel,
+ }),
+}))
+
+// Mock tag management store
+vi.mock('@/app/components/base/tag-management/store', () => ({
+ useStore: () => false,
+}))
+
+// Mock useDocumentTitle hook
+vi.mock('@/hooks/use-document-title', () => ({
+ default: vi.fn(),
+}))
+
+// Mock useFormatTimeFromNow hook
+vi.mock('@/hooks/use-format-time-from-now', () => ({
+ useFormatTimeFromNow: () => ({
+ formatTimeFromNow: (timestamp: number) => new Date(timestamp).toLocaleDateString(),
+ }),
+}))
+
+// Mock useKnowledge hook
+vi.mock('@/hooks/use-knowledge', () => ({
+ useKnowledge: () => ({
+ formatIndexingTechniqueAndMethod: () => 'High Quality',
+ }),
+}))
+
+// Mock service hooks
+vi.mock('@/service/knowledge/use-dataset', () => ({
+ useDatasetList: vi.fn(() => ({
+ data: { pages: [{ data: [] }] },
+ fetchNextPage: vi.fn(),
+ hasNextPage: false,
+ isFetching: false,
+ isFetchingNextPage: false,
+ })),
+ useInvalidDatasetList: () => vi.fn(),
+ useDatasetApiBaseUrl: () => ({
+ data: { api_base_url: 'https://api.example.com' },
+ }),
+}))
+
+// Mock Datasets component
+vi.mock('./datasets', () => ({
+ default: ({ tags, keywords, includeAll }: { tags: string[], keywords: string, includeAll: boolean }) => (
+
+ {tags.join(',')}
+ {keywords}
+ {includeAll ? 'true' : 'false'}
+
+ ),
+}))
+
+// Mock DatasetFooter component
+vi.mock('./dataset-footer', () => ({
+ default: () => ,
+}))
+
+// Mock ExternalAPIPanel component
+vi.mock('../external-api/external-api-panel', () => ({
+ default: ({ onClose }: { onClose: () => void }) => (
+
+
+
+ ),
+}))
+
+// Mock TagManagementModal
+vi.mock('@/app/components/base/tag-management', () => ({
+ default: () => ,
+}))
+
+// Mock TagFilter
+vi.mock('@/app/components/base/tag-management/filter', () => ({
+ default: ({ onChange }: { value: string[], onChange: (val: string[]) => void }) => (
+
+
+
+ ),
+}))
+
+// Mock CheckboxWithLabel
+vi.mock('@/app/components/datasets/create/website/base/checkbox-with-label', () => ({
+ default: ({ isChecked, onChange, label }: { isChecked: boolean, onChange: () => void, label: string }) => (
+
+ ),
+}))
+
+describe('List', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render(
)
+ expect(screen.getByTestId('datasets-component')).toBeInTheDocument()
+ })
+
+ it('should render the search input', () => {
+ render(
)
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ })
+
+ it('should render tag filter', () => {
+ render(
)
+ expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
+ })
+
+ it('should render external API panel button', () => {
+ render(
)
+ expect(screen.getByText(/externalAPIPanelTitle/)).toBeInTheDocument()
+ })
+
+ it('should render dataset footer when branding is disabled', () => {
+ render(
)
+ expect(screen.getByTestId('dataset-footer')).toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should pass includeAll prop to Datasets', () => {
+ render(
)
+ expect(screen.getByTestId('include-all')).toHaveTextContent('false')
+ })
+
+ it('should pass empty keywords initially', () => {
+ render(
)
+ expect(screen.getByTestId('keywords')).toHaveTextContent('')
+ })
+
+ it('should pass empty tags initially', () => {
+ render(
)
+ expect(screen.getByTestId('tags')).toHaveTextContent('')
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should open external API panel when button is clicked', () => {
+ render(
)
+
+ const button = screen.getByText(/externalAPIPanelTitle/)
+ fireEvent.click(button)
+
+ expect(mockSetShowExternalApiPanel).toHaveBeenCalledWith(true)
+ })
+
+ it('should update search input value', () => {
+ render(
)
+
+ const input = screen.getByRole('textbox')
+ fireEvent.change(input, { target: { value: 'test search' } })
+
+ expect(input).toHaveValue('test search')
+ })
+
+ it('should trigger tag filter change', () => {
+ render(
)
+ // Tag filter is rendered and interactive
+ const selectTagsBtn = screen.getByText('Select Tags')
+ expect(selectTagsBtn).toBeInTheDocument()
+ fireEvent.click(selectTagsBtn)
+ // The onChange callback was triggered (debounced)
+ })
+ })
+
+ describe('Conditional Rendering', () => {
+ it('should show include all checkbox for workspace owner', () => {
+ render(
)
+ expect(screen.getByTestId('include-all-checkbox')).toBeInTheDocument()
+ })
+ })
+
+ describe('Styles', () => {
+ it('should have correct container styling', () => {
+ const { container } = render(
)
+ const mainContainer = container.firstChild as HTMLElement
+ expect(mainContainer).toHaveClass('scroll-container', 'relative', 'flex', 'grow', 'flex-col')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty state gracefully', () => {
+ render(
)
+ // Should render without errors even with empty data
+ expect(screen.getByTestId('datasets-component')).toBeInTheDocument()
+ })
+ })
+
+ describe('Branch Coverage', () => {
+ it('should redirect normal role users to /apps', async () => {
+ // Re-mock useAppContext with normal role
+ vi.doMock('@/context/app-context', () => ({
+ useAppContext: () => ({
+ currentWorkspace: { role: 'normal' },
+ isCurrentWorkspaceOwner: false,
+ }),
+ useSelector: () => true,
+ }))
+
+ // Clear module cache and re-import
+ vi.resetModules()
+ const { default: ListComponent } = await import('./index')
+
+ render()
+
+ await waitFor(() => {
+ expect(mockReplace).toHaveBeenCalledWith('/apps')
+ })
+ })
+
+ it('should clear search input when onClear is called', () => {
+ render(
)
+
+ const input = screen.getByRole('textbox')
+ // First set a value
+ fireEvent.change(input, { target: { value: 'test search' } })
+ expect(input).toHaveValue('test search')
+
+ // Find and click the clear button
+ const clearButton = document.querySelector('[class*="clear"], button[aria-label*="clear"]')
+ if (clearButton) {
+ fireEvent.click(clearButton)
+ expect(input).toHaveValue('')
+ }
+ })
+
+ it('should show ExternalAPIPanel when showExternalApiPanel is true', async () => {
+ // Re-mock to show external API panel
+ vi.doMock('@/context/external-api-panel-context', () => ({
+ useExternalApiPanel: () => ({
+ showExternalApiPanel: true,
+ setShowExternalApiPanel: mockSetShowExternalApiPanel,
+ }),
+ }))
+
+ vi.resetModules()
+ const { default: ListComponent } = await import('./index')
+
+ render()
+
+ expect(screen.getByTestId('external-api-panel')).toBeInTheDocument()
+ })
+
+ it('should close ExternalAPIPanel when onClose is called', async () => {
+ vi.doMock('@/context/external-api-panel-context', () => ({
+ useExternalApiPanel: () => ({
+ showExternalApiPanel: true,
+ setShowExternalApiPanel: mockSetShowExternalApiPanel,
+ }),
+ }))
+
+ vi.resetModules()
+ const { default: ListComponent } = await import('./index')
+
+ render()
+
+ const closeButton = screen.getByText('Close Panel')
+ fireEvent.click(closeButton)
+
+ expect(mockSetShowExternalApiPanel).toHaveBeenCalledWith(false)
+ })
+
+ it('should show TagManagementModal when showTagManagementModal is true', async () => {
+ vi.doMock('@/app/components/base/tag-management/store', () => ({
+ useStore: () => true, // showTagManagementModal is true
+ }))
+
+ vi.resetModules()
+ const { default: ListComponent } = await import('./index')
+
+ render()
+
+ expect(screen.getByTestId('tag-management-modal')).toBeInTheDocument()
+ })
+
+ it('should not show DatasetFooter when branding is enabled', async () => {
+ vi.doMock('@/context/global-public-context', () => ({
+ useGlobalPublicStore: () => ({
+ systemFeatures: {
+ branding: { enabled: true },
+ },
+ }),
+ }))
+
+ vi.resetModules()
+ const { default: ListComponent } = await import('./index')
+
+ render()
+
+ expect(screen.queryByTestId('dataset-footer')).not.toBeInTheDocument()
+ })
+
+ it('should not show include all checkbox when not workspace owner', async () => {
+ vi.doMock('@/context/app-context', () => ({
+ useAppContext: () => ({
+ currentWorkspace: { role: 'editor' },
+ isCurrentWorkspaceOwner: false,
+ }),
+ useSelector: () => true,
+ }))
+
+ vi.resetModules()
+ const { default: ListComponent } = await import('./index')
+
+ render()
+
+ expect(screen.queryByTestId('include-all-checkbox')).not.toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/list/new-dataset-card/index.spec.tsx b/web/app/components/datasets/list/new-dataset-card/index.spec.tsx
index b361beb9f1..2ce66e134b 100644
--- a/web/app/components/datasets/list/new-dataset-card/index.spec.tsx
+++ b/web/app/components/datasets/list/new-dataset-card/index.spec.tsx
@@ -1,49 +1,76 @@
import { render, screen } from '@testing-library/react'
-import NewDatasetCard from './index'
+import { describe, expect, it } from 'vitest'
+import CreateAppCard from './index'
-type MockOptionProps = {
- text: string
- href: string
-}
+describe('CreateAppCard', () => {
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getAllByRole('link')).toHaveLength(3)
+ })
-// Mock dependencies
-vi.mock('./option', () => ({
- default: ({ text, href }: MockOptionProps) => (
-
- {text}
-
- ),
-}))
+ it('should render create dataset option', () => {
+ render()
+ expect(screen.getByText(/createDataset/)).toBeInTheDocument()
+ })
-vi.mock('@remixicon/react', () => ({
- RiAddLine: () => ,
- RiFunctionAddLine: () => ,
-}))
+ it('should render create from pipeline option', () => {
+ render()
+ expect(screen.getByText(/createFromPipeline/)).toBeInTheDocument()
+ })
-vi.mock('@/app/components/base/icons/src/vender/solid/development', () => ({
- ApiConnectionMod: () => ,
-}))
+ it('should render connect dataset option', () => {
+ render()
+ expect(screen.getByText(/connectDataset/)).toBeInTheDocument()
+ })
+ })
-describe('NewDatasetCard', () => {
- it('should render all options', () => {
- render()
+ describe('Props', () => {
+ it('should have correct displayName', () => {
+ expect(CreateAppCard.displayName).toBe('CreateAppCard')
+ })
+ })
- const options = screen.getAllByTestId('option-link')
- expect(options).toHaveLength(3)
+ describe('Links', () => {
+ it('should have correct href for create dataset', () => {
+ render()
+ const links = screen.getAllByRole('link')
+ expect(links[0]).toHaveAttribute('href', '/datasets/create')
+ })
- // Check first option (Create Dataset)
- const createDataset = options[0]
- expect(createDataset).toHaveAttribute('href', '/datasets/create')
- expect(createDataset).toHaveTextContent('dataset.createDataset')
+ it('should have correct href for create from pipeline', () => {
+ render()
+ const links = screen.getAllByRole('link')
+ expect(links[1]).toHaveAttribute('href', '/datasets/create-from-pipeline')
+ })
- // Check second option (Create from Pipeline)
- const createFromPipeline = options[1]
- expect(createFromPipeline).toHaveAttribute('href', '/datasets/create-from-pipeline')
- expect(createFromPipeline).toHaveTextContent('dataset.createFromPipeline')
+ it('should have correct href for connect dataset', () => {
+ render()
+ const links = screen.getAllByRole('link')
+ expect(links[2]).toHaveAttribute('href', '/datasets/connect')
+ })
+ })
- // Check third option (Connect Dataset)
- const connectDataset = options[2]
- expect(connectDataset).toHaveAttribute('href', '/datasets/connect')
- expect(connectDataset).toHaveTextContent('dataset.connectDataset')
+ describe('Styles', () => {
+ it('should have correct card styling', () => {
+ const { container } = render()
+ const card = container.firstChild as HTMLElement
+ expect(card).toHaveClass('h-[190px]', 'flex', 'flex-col', 'rounded-xl')
+ })
+
+ it('should have border separator for connect option', () => {
+ const { container } = render()
+ const borderDiv = container.querySelector('.border-t-\\[0\\.5px\\]')
+ expect(borderDiv).toBeInTheDocument()
+ })
+ })
+
+ describe('Icons', () => {
+ it('should render three icons for three options', () => {
+ const { container } = render()
+ // Each option has an icon
+ const icons = container.querySelectorAll('svg')
+ expect(icons.length).toBeGreaterThanOrEqual(3)
+ })
})
})
diff --git a/web/app/components/datasets/list/new-dataset-card/option.spec.tsx b/web/app/components/datasets/list/new-dataset-card/option.spec.tsx
new file mode 100644
index 0000000000..0aefaa261e
--- /dev/null
+++ b/web/app/components/datasets/list/new-dataset-card/option.spec.tsx
@@ -0,0 +1,78 @@
+import { RiAddLine } from '@remixicon/react'
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import Option from './option'
+
+describe('Option', () => {
+ const defaultProps = {
+ Icon: RiAddLine,
+ text: 'Test Option',
+ href: '/test-path',
+ }
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByRole('link')).toBeInTheDocument()
+ })
+
+ it('should render the text content', () => {
+ render()
+ expect(screen.getByText('Test Option')).toBeInTheDocument()
+ })
+
+ it('should render the icon', () => {
+ render()
+ // Icon should be rendered with correct size class
+ const icon = document.querySelector('.h-4.w-4')
+ expect(icon).toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should have correct href attribute', () => {
+ render()
+ const link = screen.getByRole('link')
+ expect(link).toHaveAttribute('href', '/test-path')
+ })
+
+ it('should render different text based on props', () => {
+ render()
+ expect(screen.getByText('Different Text')).toBeInTheDocument()
+ })
+
+ it('should render different href based on props', () => {
+ render()
+ const link = screen.getByRole('link')
+ expect(link).toHaveAttribute('href', '/different-path')
+ })
+ })
+
+ describe('Styles', () => {
+ it('should have correct base styling', () => {
+ render()
+ const link = screen.getByRole('link')
+ expect(link).toHaveClass('flex', 'w-full', 'items-center', 'gap-x-2', 'rounded-lg')
+ })
+
+ it('should have text span with correct styling', () => {
+ render()
+ const textSpan = screen.getByText('Test Option')
+ expect(textSpan).toHaveClass('system-sm-medium', 'grow', 'text-left')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty text', () => {
+ render()
+ const link = screen.getByRole('link')
+ expect(link).toBeInTheDocument()
+ })
+
+ it('should handle long text', () => {
+ const longText = 'A'.repeat(100)
+ render()
+ expect(screen.getByText(longText)).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/metadata/add-metadata-button.spec.tsx b/web/app/components/datasets/metadata/add-metadata-button.spec.tsx
new file mode 100644
index 0000000000..642b8b71ec
--- /dev/null
+++ b/web/app/components/datasets/metadata/add-metadata-button.spec.tsx
@@ -0,0 +1,92 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import AddedMetadataButton from './add-metadata-button'
+
+describe('AddedMetadataButton', () => {
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should render with translated text', () => {
+ render()
+ // The button should contain text from i18n
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should render add icon', () => {
+ const { container } = render()
+ // Check if there's an SVG element (the RiAddLine icon)
+ const svg = container.querySelector('svg')
+ expect(svg).toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should apply custom className', () => {
+ render()
+ const button = screen.getByRole('button')
+ expect(button).toHaveClass('custom-class')
+ })
+
+ it('should apply default classes', () => {
+ render()
+ const button = screen.getByRole('button')
+ expect(button).toHaveClass('flex', 'w-full', 'items-center')
+ })
+
+ it('should merge custom className with default classes', () => {
+ render()
+ const button = screen.getByRole('button')
+ expect(button).toHaveClass('flex', 'w-full', 'items-center', 'my-custom-class')
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onClick when button is clicked', () => {
+ const handleClick = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByRole('button'))
+
+ expect(handleClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('should not throw when onClick is not provided and button is clicked', () => {
+ render()
+
+ expect(() => {
+ fireEvent.click(screen.getByRole('button'))
+ }).not.toThrow()
+ })
+
+ it('should call onClick multiple times on multiple clicks', () => {
+ const handleClick = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByRole('button'))
+ fireEvent.click(screen.getByRole('button'))
+ fireEvent.click(screen.getByRole('button'))
+
+ expect(handleClick).toHaveBeenCalledTimes(3)
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should render with undefined className', () => {
+ render()
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should render with empty className', () => {
+ render()
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should render with undefined onClick', () => {
+ render()
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/metadata/base/date-picker.spec.tsx b/web/app/components/datasets/metadata/base/date-picker.spec.tsx
new file mode 100644
index 0000000000..c8d0addaa2
--- /dev/null
+++ b/web/app/components/datasets/metadata/base/date-picker.spec.tsx
@@ -0,0 +1,287 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import WrappedDatePicker from './date-picker'
+
+type TriggerArgs = {
+ handleClickTrigger: () => void
+}
+
+type DatePickerProps = {
+ onChange: (value: Date | null) => void
+ onClear: () => void
+ renderTrigger: (args: TriggerArgs) => React.ReactNode
+ value?: Date
+}
+
+// Mock the base date picker component
+vi.mock('@/app/components/base/date-and-time-picker/date-picker', () => ({
+ default: ({ onChange, onClear, renderTrigger, value }: DatePickerProps) => {
+ const trigger = renderTrigger({
+ handleClickTrigger: () => {},
+ })
+ return (
+
+ {trigger}
+
+
+
+ )
+ },
+}))
+
+// Mock useTimestamp hook
+vi.mock('@/hooks/use-timestamp', () => ({
+ default: () => ({
+ formatTime: (timestamp: number) => {
+ if (!timestamp)
+ return ''
+ return new Date(timestamp * 1000).toLocaleDateString()
+ },
+ }),
+}))
+
+describe('WrappedDatePicker', () => {
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const handleChange = vi.fn()
+ render()
+ expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
+ })
+
+ it('should render placeholder text when no value', () => {
+ const handleChange = vi.fn()
+ render()
+ // When no value, should show placeholder from i18n
+ expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
+ })
+
+ it('should render formatted date when value is provided', () => {
+ const handleChange = vi.fn()
+ const timestamp = Math.floor(Date.now() / 1000)
+ render()
+ expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
+ })
+
+ it('should render calendar icon', () => {
+ const handleChange = vi.fn()
+ const { container } = render()
+ const svg = container.querySelector('svg')
+ expect(svg).toBeInTheDocument()
+ })
+
+ it('should render select date button', () => {
+ const handleChange = vi.fn()
+ render()
+ expect(screen.getByTestId('select-date')).toBeInTheDocument()
+ })
+
+ it('should render clear date button', () => {
+ const handleChange = vi.fn()
+ render()
+ expect(screen.getByTestId('clear-date')).toBeInTheDocument()
+ })
+
+ it('should render close icon for clearing', () => {
+ const handleChange = vi.fn()
+ const timestamp = Math.floor(Date.now() / 1000)
+ const { container } = render(
+ ,
+ )
+ // RiCloseCircleFill should be rendered
+ const closeIcon = container.querySelectorAll('svg')
+ expect(closeIcon.length).toBeGreaterThan(0)
+ })
+ })
+
+ describe('Props', () => {
+ it('should apply custom className', () => {
+ const handleChange = vi.fn()
+ const { container } = render(
+ ,
+ )
+ const triggerElement = container.querySelector('.custom-class')
+ expect(triggerElement).toBeInTheDocument()
+ })
+
+ it('should accept undefined value', () => {
+ const handleChange = vi.fn()
+ render()
+ expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
+ })
+
+ it('should accept number value', () => {
+ const handleChange = vi.fn()
+ const timestamp = 1609459200 // 2021-01-01
+ render()
+ expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onChange with timestamp when date is selected', () => {
+ const handleChange = vi.fn()
+ const timestamp = Math.floor(Date.now() / 1000)
+ render()
+
+ fireEvent.click(screen.getByTestId('select-date'))
+
+ expect(handleChange).toHaveBeenCalled()
+ })
+
+ it('should call onChange with null when date is cleared via onClear', () => {
+ const handleChange = vi.fn()
+ const timestamp = Math.floor(Date.now() / 1000)
+ render()
+
+ fireEvent.click(screen.getByTestId('clear-date'))
+
+ expect(handleChange).toHaveBeenCalledWith(null)
+ })
+
+ it('should call onChange with null when close icon is clicked directly', () => {
+ const handleChange = vi.fn()
+ const timestamp = Math.floor(Date.now() / 1000)
+ const { container } = render(
+ ,
+ )
+
+ // Find the RiCloseCircleFill icon (it has specific classes)
+ const closeIcon = container.querySelector('.cursor-pointer.hover\\:text-components-input-text-filled')
+ if (closeIcon) {
+ fireEvent.click(closeIcon)
+ expect(handleChange).toHaveBeenCalledWith(null)
+ }
+ })
+
+ it('should show close button on hover when value exists', () => {
+ const handleChange = vi.fn()
+ const timestamp = Math.floor(Date.now() / 1000)
+ const { container } = render(
+ ,
+ )
+
+ // The close icon should be present but hidden initially
+ const triggerGroup = container.querySelector('.group')
+ expect(triggerGroup).toBeInTheDocument()
+ })
+
+ it('should handle clicking on trigger element', () => {
+ const handleChange = vi.fn()
+ const timestamp = Math.floor(Date.now() / 1000)
+ const { container } = render(
+ ,
+ )
+
+ const trigger = container.querySelector('.group.flex')
+ if (trigger)
+ fireEvent.click(trigger)
+
+ expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
+ })
+ })
+
+ describe('Styling', () => {
+ it('should have tertiary text color when no value', () => {
+ const handleChange = vi.fn()
+ const { container } = render()
+ const textElement = container.querySelector('.text-text-tertiary')
+ expect(textElement).toBeInTheDocument()
+ })
+
+ it('should have secondary text color when value exists', () => {
+ const handleChange = vi.fn()
+ const timestamp = Math.floor(Date.now() / 1000)
+ const { container } = render(
+ ,
+ )
+ const textElement = container.querySelector('.text-text-secondary')
+ expect(textElement).toBeInTheDocument()
+ })
+
+ it('should have input background styling', () => {
+ const handleChange = vi.fn()
+ const { container } = render()
+ const bgElement = container.querySelector('.bg-components-input-bg-normal')
+ expect(bgElement).toBeInTheDocument()
+ })
+
+ it('should have quaternary text color for close icon when value exists', () => {
+ const handleChange = vi.fn()
+ const timestamp = Math.floor(Date.now() / 1000)
+ const { container } = render(
+ ,
+ )
+ const closeIcon = container.querySelector('.text-text-quaternary')
+ expect(closeIcon).toBeInTheDocument()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle timestamp of 0', () => {
+ const handleChange = vi.fn()
+ render()
+ // 0 is falsy but is a valid timestamp (epoch)
+ expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
+ })
+
+ it('should handle very large timestamp', () => {
+ const handleChange = vi.fn()
+ const farFuture = 4102444800 // 2100-01-01
+ render()
+ expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
+ })
+
+ it('should handle switching between no value and value', () => {
+ const handleChange = vi.fn()
+ const { rerender } = render(
+ ,
+ )
+
+ expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
+
+ const timestamp = Math.floor(Date.now() / 1000)
+ rerender()
+
+ expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
+ })
+
+ it('should handle clearing date multiple times', () => {
+ const handleChange = vi.fn()
+ const timestamp = Math.floor(Date.now() / 1000)
+ render()
+
+ fireEvent.click(screen.getByTestId('clear-date'))
+ fireEvent.click(screen.getByTestId('clear-date'))
+ fireEvent.click(screen.getByTestId('clear-date'))
+
+ expect(handleChange).toHaveBeenCalledTimes(3)
+ })
+
+ it('should handle rapid date selections', () => {
+ const handleChange = vi.fn()
+ const timestamp = Math.floor(Date.now() / 1000)
+ render()
+
+ fireEvent.click(screen.getByTestId('select-date'))
+ fireEvent.click(screen.getByTestId('select-date'))
+ fireEvent.click(screen.getByTestId('select-date'))
+
+ expect(handleChange).toHaveBeenCalledTimes(3)
+ })
+
+ it('should handle onChange with date object that has unix method', () => {
+ const handleChange = vi.fn()
+ render()
+
+ // The mock triggers onChange with the value prop
+ fireEvent.click(screen.getByTestId('select-date'))
+
+ // onChange should have been called
+ expect(handleChange).toHaveBeenCalled()
+ })
+ })
+})
diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/add-row.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/add-row.spec.tsx
new file mode 100644
index 0000000000..0c47873b31
--- /dev/null
+++ b/web/app/components/datasets/metadata/edit-metadata-batch/add-row.spec.tsx
@@ -0,0 +1,257 @@
+import type { MetadataItemWithEdit } from '../types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { DataType } from '../types'
+import AddRow from './add-row'
+
+type InputCombinedProps = {
+ type: DataType
+ value: string | number | null
+ onChange: (value: string | number) => void
+}
+
+type LabelProps = {
+ text: string
+}
+
+// Mock InputCombined component
+vi.mock('./input-combined', () => ({
+ default: ({ type, value, onChange }: InputCombinedProps) => (
+ onChange(e.target.value)}
+ />
+ ),
+}))
+
+// Mock Label component
+vi.mock('./label', () => ({
+ default: ({ text }: LabelProps) => {text}
,
+}))
+
+describe('AddRow', () => {
+ const mockPayload: MetadataItemWithEdit = {
+ id: 'test-id',
+ name: 'test_field',
+ type: DataType.string,
+ value: 'test value',
+ }
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const handleChange = vi.fn()
+ const handleRemove = vi.fn()
+ const { container } = render(
+ ,
+ )
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render label with payload name', () => {
+ const handleChange = vi.fn()
+ const handleRemove = vi.fn()
+ render(
+ ,
+ )
+ expect(screen.getByTestId('label')).toHaveTextContent('test_field')
+ })
+
+ it('should render input combined component', () => {
+ const handleChange = vi.fn()
+ const handleRemove = vi.fn()
+ render(
+ ,
+ )
+ expect(screen.getByTestId('input-combined')).toBeInTheDocument()
+ })
+
+ it('should render remove button icon', () => {
+ const handleChange = vi.fn()
+ const handleRemove = vi.fn()
+ const { container } = render(
+ ,
+ )
+ const svg = container.querySelector('svg')
+ expect(svg).toBeInTheDocument()
+ })
+
+ it('should pass correct type to input combined', () => {
+ const handleChange = vi.fn()
+ const handleRemove = vi.fn()
+ render(
+ ,
+ )
+ expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.string)
+ })
+
+ it('should pass correct value to input combined', () => {
+ const handleChange = vi.fn()
+ const handleRemove = vi.fn()
+ render(
+ ,
+ )
+ expect(screen.getByTestId('input-combined')).toHaveValue('test value')
+ })
+ })
+
+ describe('Props', () => {
+ it('should apply custom className', () => {
+ const handleChange = vi.fn()
+ const handleRemove = vi.fn()
+ const { container } = render(
+ ,
+ )
+ expect(container.firstChild).toHaveClass('custom-class')
+ })
+
+ it('should have default flex styling', () => {
+ const handleChange = vi.fn()
+ const handleRemove = vi.fn()
+ const { container } = render(
+ ,
+ )
+ expect(container.firstChild).toHaveClass('flex', 'h-6', 'items-center', 'space-x-0.5')
+ })
+
+ it('should handle different data types', () => {
+ const handleChange = vi.fn()
+ const handleRemove = vi.fn()
+ const numberPayload: MetadataItemWithEdit = {
+ ...mockPayload,
+ type: DataType.number,
+ value: 42,
+ }
+ render(
+ ,
+ )
+ expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.number)
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onChange with updated payload when input changes', () => {
+ const handleChange = vi.fn()
+ const handleRemove = vi.fn()
+ render(
+ ,
+ )
+
+ fireEvent.change(screen.getByTestId('input-combined'), { target: { value: 'new value' } })
+
+ expect(handleChange).toHaveBeenCalledWith({
+ ...mockPayload,
+ value: 'new value',
+ })
+ })
+
+ it('should call onRemove when remove button is clicked', () => {
+ const handleChange = vi.fn()
+ const handleRemove = vi.fn()
+ const { container } = render(
+ ,
+ )
+
+ const removeButton = container.querySelector('.cursor-pointer')
+ if (removeButton)
+ fireEvent.click(removeButton)
+
+ expect(handleRemove).toHaveBeenCalledTimes(1)
+ })
+
+ it('should preserve other payload properties on change', () => {
+ const handleChange = vi.fn()
+ const handleRemove = vi.fn()
+ render(
+ ,
+ )
+
+ fireEvent.change(screen.getByTestId('input-combined'), { target: { value: 'updated' } })
+
+ expect(handleChange).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 'test-id',
+ name: 'test_field',
+ type: DataType.string,
+ }),
+ )
+ })
+ })
+
+ describe('Remove Button Styling', () => {
+ it('should have hover styling on remove button', () => {
+ const handleChange = vi.fn()
+ const handleRemove = vi.fn()
+ const { container } = render(
+ ,
+ )
+ const removeButton = container.querySelector('.cursor-pointer')
+ expect(removeButton).toHaveClass('hover:bg-state-destructive-hover', 'hover:text-text-destructive')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle null value', () => {
+ const handleChange = vi.fn()
+ const handleRemove = vi.fn()
+ const nullPayload: MetadataItemWithEdit = {
+ ...mockPayload,
+ value: null,
+ }
+ render(
+ ,
+ )
+ expect(screen.getByTestId('input-combined')).toBeInTheDocument()
+ })
+
+ it('should handle empty string value', () => {
+ const handleChange = vi.fn()
+ const handleRemove = vi.fn()
+ const emptyPayload: MetadataItemWithEdit = {
+ ...mockPayload,
+ value: '',
+ }
+ render(
+ ,
+ )
+ expect(screen.getByTestId('input-combined')).toHaveValue('')
+ })
+
+ it('should handle time type payload', () => {
+ const handleChange = vi.fn()
+ const handleRemove = vi.fn()
+ const timePayload: MetadataItemWithEdit = {
+ ...mockPayload,
+ type: DataType.time,
+ value: 1609459200,
+ }
+ render(
+ ,
+ )
+ expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.time)
+ })
+
+ it('should handle multiple onRemove calls', () => {
+ const handleChange = vi.fn()
+ const handleRemove = vi.fn()
+ const { container } = render(
+ ,
+ )
+
+ const removeButton = container.querySelector('.cursor-pointer')
+ if (removeButton) {
+ fireEvent.click(removeButton)
+ fireEvent.click(removeButton)
+ fireEvent.click(removeButton)
+ }
+
+ expect(handleRemove).toHaveBeenCalledTimes(3)
+ })
+ })
+})
diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/edit-row.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/edit-row.spec.tsx
new file mode 100644
index 0000000000..a2d743e8be
--- /dev/null
+++ b/web/app/components/datasets/metadata/edit-metadata-batch/edit-row.spec.tsx
@@ -0,0 +1,395 @@
+import type { MetadataItemWithEdit } from '../types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { DataType, UpdateType } from '../types'
+import EditMetadatabatchItem from './edit-row'
+
+type InputCombinedProps = {
+ type: DataType
+ value: string | number | null
+ onChange: (value: string | number) => void
+ readOnly?: boolean
+}
+
+type MultipleValueInputProps = {
+ onClear: () => void
+ readOnly?: boolean
+}
+
+type LabelProps = {
+ text: string
+ isDeleted?: boolean
+}
+
+type EditedBeaconProps = {
+ onReset: () => void
+}
+
+// Mock InputCombined component
+vi.mock('./input-combined', () => ({
+ default: ({ type, value, onChange, readOnly }: InputCombinedProps) => (
+ onChange(e.target.value)}
+ readOnly={readOnly}
+ />
+ ),
+}))
+
+// Mock InputHasSetMultipleValue component
+vi.mock('./input-has-set-multiple-value', () => ({
+ default: ({ onClear, readOnly }: MultipleValueInputProps) => (
+
+
+
+ ),
+}))
+
+// Mock Label component
+vi.mock('./label', () => ({
+ default: ({ text, isDeleted }: LabelProps) => (
+ {text}
+ ),
+}))
+
+// Mock EditedBeacon component
+vi.mock('./edited-beacon', () => ({
+ default: ({ onReset }: EditedBeaconProps) => (
+
+ ),
+}))
+
+describe('EditMetadatabatchItem', () => {
+ const mockPayload: MetadataItemWithEdit = {
+ id: 'test-id',
+ name: 'test_field',
+ type: DataType.string,
+ value: 'test value',
+ isMultipleValue: false,
+ isUpdated: false,
+ }
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const { container } = render(
+ ,
+ )
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render label with payload name', () => {
+ render(
+ ,
+ )
+ expect(screen.getByTestId('label')).toHaveTextContent('test_field')
+ })
+
+ it('should render input combined for single value', () => {
+ render(
+ ,
+ )
+ expect(screen.getByTestId('input-combined')).toBeInTheDocument()
+ })
+
+ it('should render multiple value input when isMultipleValue is true', () => {
+ const multiplePayload: MetadataItemWithEdit = {
+ ...mockPayload,
+ isMultipleValue: true,
+ }
+ render(
+ ,
+ )
+ expect(screen.getByTestId('multiple-value-input')).toBeInTheDocument()
+ })
+
+ it('should render delete button icon', () => {
+ const { container } = render(
+ ,
+ )
+ const svg = container.querySelector('svg')
+ expect(svg).toBeInTheDocument()
+ })
+ })
+
+ describe('Updated State', () => {
+ it('should show edited beacon when isUpdated is true', () => {
+ const updatedPayload: MetadataItemWithEdit = {
+ ...mockPayload,
+ isUpdated: true,
+ }
+ render(
+ ,
+ )
+ expect(screen.getByTestId('edited-beacon')).toBeInTheDocument()
+ })
+
+ it('should not show edited beacon when isUpdated is false', () => {
+ render(
+ ,
+ )
+ expect(screen.queryByTestId('edited-beacon')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Deleted State', () => {
+ it('should pass isDeleted to label when updateType is delete', () => {
+ const deletedPayload: MetadataItemWithEdit = {
+ ...mockPayload,
+ updateType: UpdateType.delete,
+ }
+ render(
+ ,
+ )
+ expect(screen.getByTestId('label')).toHaveAttribute('data-deleted', 'true')
+ })
+
+ it('should set readOnly on input when deleted', () => {
+ const deletedPayload: MetadataItemWithEdit = {
+ ...mockPayload,
+ updateType: UpdateType.delete,
+ }
+ render(
+ ,
+ )
+ expect(screen.getByTestId('input-combined')).toHaveAttribute('readonly')
+ })
+
+ it('should have destructive styling on delete button when deleted', () => {
+ const deletedPayload: MetadataItemWithEdit = {
+ ...mockPayload,
+ updateType: UpdateType.delete,
+ }
+ const { container } = render(
+ ,
+ )
+ const deleteButton = container.querySelector('.bg-state-destructive-hover')
+ expect(deleteButton).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onChange with updated payload when input changes', () => {
+ const handleChange = vi.fn()
+ render(
+ ,
+ )
+
+ fireEvent.change(screen.getByTestId('input-combined'), { target: { value: 'new value' } })
+
+ expect(handleChange).toHaveBeenCalledWith(
+ expect.objectContaining({
+ ...mockPayload,
+ value: 'new value',
+ }),
+ )
+ })
+
+ it('should call onRemove with id when delete button is clicked', () => {
+ const handleRemove = vi.fn()
+ const { container } = render(
+ ,
+ )
+
+ const deleteButton = container.querySelector('.cursor-pointer')
+ if (deleteButton)
+ fireEvent.click(deleteButton)
+
+ expect(handleRemove).toHaveBeenCalledWith('test-id')
+ })
+
+ it('should call onReset with id when reset beacon is clicked', () => {
+ const handleReset = vi.fn()
+ const updatedPayload: MetadataItemWithEdit = {
+ ...mockPayload,
+ isUpdated: true,
+ }
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('edited-beacon'))
+
+ expect(handleReset).toHaveBeenCalledWith('test-id')
+ })
+
+ it('should call onChange to clear multiple value', () => {
+ const handleChange = vi.fn()
+ const multiplePayload: MetadataItemWithEdit = {
+ ...mockPayload,
+ isMultipleValue: true,
+ }
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('clear-multiple'))
+
+ expect(handleChange).toHaveBeenCalledWith(
+ expect.objectContaining({
+ value: null,
+ isMultipleValue: false,
+ }),
+ )
+ })
+ })
+
+ describe('Multiple Value State', () => {
+ it('should render multiple value input when isMultipleValue is true', () => {
+ const multiplePayload: MetadataItemWithEdit = {
+ ...mockPayload,
+ isMultipleValue: true,
+ }
+ render(
+ ,
+ )
+ expect(screen.getByTestId('multiple-value-input')).toBeInTheDocument()
+ expect(screen.queryByTestId('input-combined')).not.toBeInTheDocument()
+ })
+
+ it('should pass readOnly to multiple value input when deleted', () => {
+ const multipleDeletedPayload: MetadataItemWithEdit = {
+ ...mockPayload,
+ isMultipleValue: true,
+ updateType: UpdateType.delete,
+ }
+ render(
+ ,
+ )
+ expect(screen.getByTestId('multiple-value-input')).toHaveAttribute('data-readonly', 'true')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle payload with number type', () => {
+ const numberPayload: MetadataItemWithEdit = {
+ ...mockPayload,
+ type: DataType.number,
+ value: 42,
+ }
+ render(
+ ,
+ )
+ expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.number)
+ })
+
+ it('should handle payload with time type', () => {
+ const timePayload: MetadataItemWithEdit = {
+ ...mockPayload,
+ type: DataType.time,
+ value: 1609459200,
+ }
+ render(
+ ,
+ )
+ expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.time)
+ })
+
+ it('should handle null value', () => {
+ const nullPayload: MetadataItemWithEdit = {
+ ...mockPayload,
+ value: null,
+ }
+ render(
+ ,
+ )
+ expect(screen.getByTestId('input-combined')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/edited-beacon.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/edited-beacon.spec.tsx
new file mode 100644
index 0000000000..0ab4287b4c
--- /dev/null
+++ b/web/app/components/datasets/metadata/edit-metadata-batch/edited-beacon.spec.tsx
@@ -0,0 +1,179 @@
+import { fireEvent, render, waitFor } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import EditedBeacon from './edited-beacon'
+
+describe('EditedBeacon', () => {
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const handleReset = vi.fn()
+ const { container } = render()
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render with correct size', () => {
+ const handleReset = vi.fn()
+ const { container } = render()
+ expect(container.firstChild).toHaveClass('size-4', 'cursor-pointer')
+ })
+
+ it('should render beacon dot by default (not hovering)', () => {
+ const handleReset = vi.fn()
+ const { container } = render()
+ // When not hovering, should show the small beacon dot
+ const beaconDot = container.querySelector('.size-1')
+ expect(beaconDot).toBeInTheDocument()
+ })
+ })
+
+ describe('Hover State', () => {
+ it('should show reset icon on hover', async () => {
+ const handleReset = vi.fn()
+ const { container } = render()
+
+ const wrapper = container.firstChild as HTMLElement
+ fireEvent.mouseEnter(wrapper)
+
+ await waitFor(() => {
+ // On hover, should show the reset icon (RiResetLeftLine)
+ const svg = container.querySelector('svg')
+ expect(svg).toBeInTheDocument()
+ })
+ })
+
+ it('should show beacon dot when not hovering', () => {
+ const handleReset = vi.fn()
+ const { container } = render()
+
+ // By default (not hovering), should show beacon dot
+ const beaconDot = container.querySelector('.size-1.rounded-full.bg-text-accent-secondary')
+ expect(beaconDot).toBeInTheDocument()
+ })
+
+ it('should hide beacon dot on hover', async () => {
+ const handleReset = vi.fn()
+ const { container } = render()
+
+ const wrapper = container.firstChild as HTMLElement
+ fireEvent.mouseEnter(wrapper)
+
+ await waitFor(() => {
+ // On hover, the small beacon dot should be hidden
+ const beaconDot = container.querySelector('.size-1.rounded-full.bg-text-accent-secondary')
+ expect(beaconDot).not.toBeInTheDocument()
+ })
+ })
+
+ it('should show beacon dot again on mouse leave', async () => {
+ const handleReset = vi.fn()
+ const { container } = render()
+
+ const wrapper = container.firstChild as HTMLElement
+
+ // Hover
+ fireEvent.mouseEnter(wrapper)
+
+ await waitFor(() => {
+ const svg = container.querySelector('svg')
+ expect(svg).toBeInTheDocument()
+ })
+
+ // Leave
+ fireEvent.mouseLeave(wrapper)
+
+ await waitFor(() => {
+ const beaconDot = container.querySelector('.size-1.rounded-full.bg-text-accent-secondary')
+ expect(beaconDot).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onReset when reset button is clicked', async () => {
+ const handleReset = vi.fn()
+ const { container } = render()
+
+ const wrapper = container.firstChild as HTMLElement
+
+ // Hover to show reset button
+ fireEvent.mouseEnter(wrapper)
+
+ await waitFor(() => {
+ const resetButton = container.querySelector('.bg-text-accent-secondary')
+ expect(resetButton).toBeInTheDocument()
+ })
+
+ // Find and click the reset button (the clickable element with onClick)
+ const clickableElement = container.querySelector('.flex.size-4.items-center.justify-center.rounded-full.bg-text-accent-secondary')
+ if (clickableElement) {
+ fireEvent.click(clickableElement)
+ }
+
+ expect(handleReset).toHaveBeenCalledTimes(1)
+ })
+
+ it('should not call onReset when clicking beacon dot (not hovering)', () => {
+ const handleReset = vi.fn()
+ const { container } = render()
+
+ // Click on the wrapper when not hovering
+ const wrapper = container.firstChild as HTMLElement
+ fireEvent.click(wrapper)
+
+ // onReset should not be called because we're not hovering
+ expect(handleReset).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Tooltip', () => {
+ it('should render tooltip on hover', async () => {
+ const handleReset = vi.fn()
+ const { container } = render()
+
+ const wrapper = container.firstChild as HTMLElement
+ fireEvent.mouseEnter(wrapper)
+
+ // Tooltip should be rendered (it wraps the reset button)
+ await waitFor(() => {
+ const resetIcon = container.querySelector('svg')
+ expect(resetIcon).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle multiple hover/leave cycles', async () => {
+ const handleReset = vi.fn()
+ const { container } = render()
+
+ const wrapper = container.firstChild as HTMLElement
+
+ for (let i = 0; i < 3; i++) {
+ fireEvent.mouseEnter(wrapper)
+ await waitFor(() => {
+ expect(container.querySelector('svg')).toBeInTheDocument()
+ })
+
+ fireEvent.mouseLeave(wrapper)
+ await waitFor(() => {
+ expect(container.querySelector('.size-1.rounded-full')).toBeInTheDocument()
+ })
+ }
+ })
+
+ it('should handle rapid hover/leave', async () => {
+ const handleReset = vi.fn()
+ const { container } = render()
+
+ const wrapper = container.firstChild as HTMLElement
+
+ // Rapid hover/leave
+ fireEvent.mouseEnter(wrapper)
+ fireEvent.mouseLeave(wrapper)
+ fireEvent.mouseEnter(wrapper)
+
+ await waitFor(() => {
+ expect(container.querySelector('svg')).toBeInTheDocument()
+ })
+ })
+ })
+})
diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/input-combined.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/input-combined.spec.tsx
new file mode 100644
index 0000000000..2a4d092822
--- /dev/null
+++ b/web/app/components/datasets/metadata/edit-metadata-batch/input-combined.spec.tsx
@@ -0,0 +1,269 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { DataType } from '../types'
+import InputCombined from './input-combined'
+
+type DatePickerProps = {
+ value: number | null
+ onChange: (value: number) => void
+ className?: string
+}
+
+// Mock the base date-picker component
+vi.mock('../base/date-picker', () => ({
+ default: ({ value, onChange, className }: DatePickerProps) => (
+ onChange(Date.now())}>
+ {value || 'Pick date'}
+
+ ),
+}))
+
+describe('InputCombined', () => {
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const handleChange = vi.fn()
+ const { container } = render(
+ ,
+ )
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render text input for string type', () => {
+ const handleChange = vi.fn()
+ render(
+ ,
+ )
+ const input = screen.getByDisplayValue('test')
+ expect(input).toBeInTheDocument()
+ expect(input.tagName.toLowerCase()).toBe('input')
+ })
+
+ it('should render number input for number type', () => {
+ const handleChange = vi.fn()
+ render(
+ ,
+ )
+ const input = screen.getByDisplayValue('42')
+ expect(input).toBeInTheDocument()
+ })
+
+ it('should render date picker for time type', () => {
+ const handleChange = vi.fn()
+ render(
+ ,
+ )
+ expect(screen.getByTestId('date-picker')).toBeInTheDocument()
+ })
+ })
+
+ describe('String Input', () => {
+ it('should call onChange with input value for string type', () => {
+ const handleChange = vi.fn()
+ render(
+ ,
+ )
+
+ const input = screen.getByRole('textbox')
+ fireEvent.change(input, { target: { value: 'new value' } })
+
+ expect(handleChange).toHaveBeenCalledWith('new value')
+ })
+
+ it('should display current value for string type', () => {
+ const handleChange = vi.fn()
+ render(
+ ,
+ )
+
+ expect(screen.getByDisplayValue('existing value')).toBeInTheDocument()
+ })
+
+ it('should apply readOnly prop to string input', () => {
+ const handleChange = vi.fn()
+ render(
+ ,
+ )
+
+ const input = screen.getByRole('textbox')
+ expect(input).toHaveAttribute('readonly')
+ })
+ })
+
+ describe('Number Input', () => {
+ it('should call onChange with number value for number type', () => {
+ const handleChange = vi.fn()
+ render(
+ ,
+ )
+
+ const input = screen.getByRole('spinbutton')
+ fireEvent.change(input, { target: { value: '123' } })
+
+ expect(handleChange).toHaveBeenCalled()
+ })
+
+ it('should display current value for number type', () => {
+ const handleChange = vi.fn()
+ render(
+ ,
+ )
+
+ expect(screen.getByDisplayValue('999')).toBeInTheDocument()
+ })
+
+ it('should apply readOnly prop to number input', () => {
+ const handleChange = vi.fn()
+ render(
+ ,
+ )
+
+ const input = screen.getByRole('spinbutton')
+ expect(input).toHaveAttribute('readonly')
+ })
+ })
+
+ describe('Time/Date Input', () => {
+ it('should render date picker for time type', () => {
+ const handleChange = vi.fn()
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('date-picker')).toBeInTheDocument()
+ })
+
+ it('should call onChange when date is selected', () => {
+ const handleChange = vi.fn()
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('date-picker'))
+ expect(handleChange).toHaveBeenCalled()
+ })
+ })
+
+ describe('Props', () => {
+ it('should apply custom className', () => {
+ const handleChange = vi.fn()
+ const { container } = render(
+ ,
+ )
+
+ // Check that custom class is applied to wrapper
+ const wrapper = container.querySelector('.custom-class')
+ expect(wrapper).toBeInTheDocument()
+ })
+
+ it('should handle null value for string type', () => {
+ const handleChange = vi.fn()
+ render(
+ ,
+ )
+
+ const input = screen.getByRole('textbox')
+ expect(input).toBeInTheDocument()
+ })
+
+ it('should handle undefined value for string type', () => {
+ const handleChange = vi.fn()
+ render(
+ ,
+ )
+
+ const input = screen.getByRole('textbox')
+ expect(input).toBeInTheDocument()
+ })
+
+ it('should handle null value for number type', () => {
+ const handleChange = vi.fn()
+ render(
+ ,
+ )
+
+ const input = screen.getByRole('spinbutton')
+ expect(input).toBeInTheDocument()
+ })
+ })
+
+ describe('Styling', () => {
+ it('should have correct base styling for string input', () => {
+ const handleChange = vi.fn()
+ render(
+ ,
+ )
+
+ const input = screen.getByRole('textbox')
+ expect(input).toHaveClass('h-6', 'grow', 'p-0.5', 'text-xs', 'rounded-md')
+ })
+
+ it('should have correct styling for number input', () => {
+ const handleChange = vi.fn()
+ render(
+ ,
+ )
+
+ const input = screen.getByRole('spinbutton')
+ expect(input).toHaveClass('rounded-l-md')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty string value', () => {
+ const handleChange = vi.fn()
+ render(
+ ,
+ )
+
+ const input = screen.getByRole('textbox')
+ expect(input).toHaveValue('')
+ })
+
+ it('should handle zero value for number', () => {
+ const handleChange = vi.fn()
+ render(
+ ,
+ )
+
+ expect(screen.getByDisplayValue('0')).toBeInTheDocument()
+ })
+
+ it('should handle negative number', () => {
+ const handleChange = vi.fn()
+ render(
+ ,
+ )
+
+ expect(screen.getByDisplayValue('-100')).toBeInTheDocument()
+ })
+
+ it('should handle special characters in string', () => {
+ const handleChange = vi.fn()
+ render(
+ alert("xss")'} onChange={handleChange} />,
+ )
+
+ expect(screen.getByDisplayValue('')).toBeInTheDocument()
+ })
+
+ it('should handle switching between types', () => {
+ const handleChange = vi.fn()
+ const { rerender } = render(
+ ,
+ )
+
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+
+ rerender(
+ ,
+ )
+
+ expect(screen.getByRole('spinbutton')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/input-has-set-multiple-value.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/input-has-set-multiple-value.spec.tsx
new file mode 100644
index 0000000000..40dd7a83b9
--- /dev/null
+++ b/web/app/components/datasets/metadata/edit-metadata-batch/input-has-set-multiple-value.spec.tsx
@@ -0,0 +1,147 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import InputHasSetMultipleValue from './input-has-set-multiple-value'
+
+describe('InputHasSetMultipleValue', () => {
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const handleClear = vi.fn()
+ const { container } = render()
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render with correct wrapper styling', () => {
+ const handleClear = vi.fn()
+ const { container } = render()
+ expect(container.firstChild).toHaveClass('h-6', 'grow', 'rounded-md', 'bg-components-input-bg-normal', 'p-0.5')
+ })
+
+ it('should render multiple value text', () => {
+ const handleClear = vi.fn()
+ render()
+ // The text should come from i18n
+ expect(screen.getByText(/multipleValue|Multiple/i)).toBeInTheDocument()
+ })
+
+ it('should render close icon when not readOnly', () => {
+ const handleClear = vi.fn()
+ const { container } = render()
+ // Should have close icon (RiCloseLine)
+ const svg = container.querySelector('svg')
+ expect(svg).toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should not show close icon when readOnly is true', () => {
+ const handleClear = vi.fn()
+ const { container } = render()
+ // Should not have close icon
+ const svg = container.querySelector('svg')
+ expect(svg).not.toBeInTheDocument()
+ })
+
+ it('should show close icon when readOnly is false', () => {
+ const handleClear = vi.fn()
+ const { container } = render()
+ const svg = container.querySelector('svg')
+ expect(svg).toBeInTheDocument()
+ })
+
+ it('should show close icon when readOnly is undefined', () => {
+ const handleClear = vi.fn()
+ const { container } = render()
+ const svg = container.querySelector('svg')
+ expect(svg).toBeInTheDocument()
+ })
+
+ it('should apply pr-1.5 padding when readOnly', () => {
+ const handleClear = vi.fn()
+ const { container } = render()
+ const badge = container.querySelector('.inline-flex')
+ expect(badge).toHaveClass('pr-1.5')
+ })
+
+ it('should apply pr-0.5 padding when not readOnly', () => {
+ const handleClear = vi.fn()
+ const { container } = render()
+ const badge = container.querySelector('.inline-flex')
+ expect(badge).toHaveClass('pr-0.5')
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onClear when close icon is clicked', () => {
+ const handleClear = vi.fn()
+ const { container } = render()
+
+ const closeIcon = container.querySelector('svg')
+ expect(closeIcon).toBeInTheDocument()
+
+ if (closeIcon) {
+ fireEvent.click(closeIcon)
+ }
+
+ expect(handleClear).toHaveBeenCalledTimes(1)
+ })
+
+ it('should not call onClear when readOnly and clicking on component', () => {
+ const handleClear = vi.fn()
+ const { container } = render()
+
+ // Click on the wrapper
+ fireEvent.click(container.firstChild as HTMLElement)
+
+ expect(handleClear).not.toHaveBeenCalled()
+ })
+
+ it('should call onClear multiple times on multiple clicks', () => {
+ const handleClear = vi.fn()
+ const { container } = render()
+
+ const closeIcon = container.querySelector('svg')
+
+ if (closeIcon) {
+ fireEvent.click(closeIcon)
+ fireEvent.click(closeIcon)
+ fireEvent.click(closeIcon)
+ }
+
+ expect(handleClear).toHaveBeenCalledTimes(3)
+ })
+ })
+
+ describe('Styling', () => {
+ it('should have badge styling', () => {
+ const handleClear = vi.fn()
+ const { container } = render()
+ const badge = container.querySelector('.inline-flex')
+ expect(badge).toHaveClass('h-5', 'items-center', 'rounded-[5px]', 'border-[0.5px]')
+ })
+
+ it('should have hover styles on close button wrapper', () => {
+ const handleClear = vi.fn()
+ const { container } = render()
+ const closeWrapper = container.querySelector('.cursor-pointer')
+ expect(closeWrapper).toHaveClass('hover:bg-state-base-hover', 'hover:text-text-secondary')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should render correctly when switching readOnly state', () => {
+ const handleClear = vi.fn()
+ const { container, rerender } = render()
+
+ // Initially not readOnly
+ expect(container.querySelector('svg')).toBeInTheDocument()
+
+ // Switch to readOnly
+ rerender()
+ expect(container.querySelector('svg')).not.toBeInTheDocument()
+
+ // Switch back to not readOnly
+ rerender()
+ expect(container.querySelector('svg')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/label.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/label.spec.tsx
new file mode 100644
index 0000000000..1ec62ebb94
--- /dev/null
+++ b/web/app/components/datasets/metadata/edit-metadata-batch/label.spec.tsx
@@ -0,0 +1,113 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import Label from './label'
+
+describe('Label', () => {
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByText('Test Label')).toBeInTheDocument()
+ })
+
+ it('should render text with correct styling', () => {
+ render()
+ const labelElement = screen.getByText('My Label')
+ expect(labelElement).toHaveClass('system-xs-medium', 'w-[136px]', 'shrink-0', 'truncate', 'text-text-tertiary')
+ })
+
+ it('should not have deleted styling by default', () => {
+ render()
+ const labelElement = screen.getByText('Label')
+ expect(labelElement).not.toHaveClass('text-text-quaternary', 'line-through')
+ })
+ })
+
+ describe('Props', () => {
+ it('should apply custom className', () => {
+ render()
+ const labelElement = screen.getByText('Label')
+ expect(labelElement).toHaveClass('custom-class')
+ })
+
+ it('should merge custom className with default classes', () => {
+ render()
+ const labelElement = screen.getByText('Label')
+ expect(labelElement).toHaveClass('system-xs-medium', 'my-custom-class')
+ })
+
+ it('should apply deleted styling when isDeleted is true', () => {
+ render()
+ const labelElement = screen.getByText('Label')
+ expect(labelElement).toHaveClass('text-text-quaternary', 'line-through')
+ })
+
+ it('should not apply deleted styling when isDeleted is false', () => {
+ render()
+ const labelElement = screen.getByText('Label')
+ expect(labelElement).not.toHaveClass('text-text-quaternary', 'line-through')
+ })
+
+ it('should render different text values', () => {
+ const { rerender } = render()
+ expect(screen.getByText('First')).toBeInTheDocument()
+
+ rerender()
+ expect(screen.getByText('Second')).toBeInTheDocument()
+ })
+ })
+
+ describe('Deleted State', () => {
+ it('should have strikethrough when deleted', () => {
+ render()
+ const labelElement = screen.getByText('Deleted Label')
+ expect(labelElement).toHaveClass('line-through')
+ })
+
+ it('should have quaternary text color when deleted', () => {
+ render()
+ const labelElement = screen.getByText('Deleted Label')
+ expect(labelElement).toHaveClass('text-text-quaternary')
+ })
+
+ it('should combine deleted styling with custom className', () => {
+ render()
+ const labelElement = screen.getByText('Label')
+ expect(labelElement).toHaveClass('line-through', 'custom')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should render with empty text', () => {
+ const { container } = render()
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render with long text (truncation)', () => {
+ const longText = 'This is a very long label text that should be truncated'
+ render()
+ const labelElement = screen.getByText(longText)
+ expect(labelElement).toHaveClass('truncate')
+ })
+
+ it('should render with undefined className', () => {
+ render()
+ expect(screen.getByText('Label')).toBeInTheDocument()
+ })
+
+ it('should render with undefined isDeleted', () => {
+ render()
+ const labelElement = screen.getByText('Label')
+ expect(labelElement).not.toHaveClass('line-through')
+ })
+
+ it('should handle special characters in text', () => {
+ render()
+ expect(screen.getByText('Label & "chars"')).toBeInTheDocument()
+ })
+
+ it('should handle numbers in text', () => {
+ render()
+ expect(screen.getByText('Label 123')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/modal.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/modal.spec.tsx
new file mode 100644
index 0000000000..55cce87a40
--- /dev/null
+++ b/web/app/components/datasets/metadata/edit-metadata-batch/modal.spec.tsx
@@ -0,0 +1,548 @@
+import type { MetadataItemInBatchEdit, MetadataItemWithEdit } from '../types'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { DataType, UpdateType } from '../types'
+import EditMetadataBatchModal from './modal'
+
+// Mock service/API calls
+const mockDoAddMetaData = vi.fn().mockResolvedValue({})
+vi.mock('@/service/knowledge/use-metadata', () => ({
+ useCreateMetaData: () => ({
+ mutate: mockDoAddMetaData,
+ }),
+ useDatasetMetaData: () => ({
+ data: {
+ doc_metadata: [
+ { id: 'existing-1', name: 'existing_field', type: DataType.string },
+ { id: 'existing-2', name: 'another_field', type: DataType.number },
+ ],
+ },
+ }),
+}))
+
+// Mock check name hook to control validation
+let mockCheckNameResult = { errorMsg: '' }
+vi.mock('../hooks/use-check-metadata-name', () => ({
+ default: () => ({
+ checkName: () => mockCheckNameResult,
+ }),
+}))
+
+// Mock Toast to verify notifications
+const mockToastNotify = vi.fn()
+vi.mock('@/app/components/base/toast', () => ({
+ default: {
+ notify: (args: unknown) => mockToastNotify(args),
+ },
+}))
+
+// Type definitions for mock props
+type EditRowProps = {
+ payload: MetadataItemWithEdit
+ onChange: (item: MetadataItemWithEdit) => void
+ onRemove: (id: string) => void
+ onReset: (id: string) => void
+}
+
+type AddRowProps = {
+ payload: MetadataItemWithEdit
+ onChange: (item: MetadataItemWithEdit) => void
+ onRemove: () => void
+}
+
+type SelectModalProps = {
+ trigger: React.ReactNode
+ onSelect: (item: MetadataItemInBatchEdit) => void
+ onSave: (data: { name: string, type: DataType }) => Promise
+ onManage: () => void
+}
+
+// Mock child components with callback exposure
+vi.mock('./edit-row', () => ({
+ default: ({ payload, onChange, onRemove, onReset }: EditRowProps) => (
+
+ {payload.name}
+
+
+
+
+ ),
+}))
+
+vi.mock('./add-row', () => ({
+ default: ({ payload, onChange, onRemove }: AddRowProps) => (
+
+ {payload.name}
+
+
+
+ ),
+}))
+
+vi.mock('../metadata-dataset/select-metadata-modal', () => ({
+ default: ({ trigger, onSelect, onSave, onManage }: SelectModalProps) => (
+
+ {trigger}
+
+
+
+
+ ),
+}))
+
+describe('EditMetadataBatchModal', () => {
+ const mockList: MetadataItemInBatchEdit[] = [
+ { id: '1', name: 'field_one', type: DataType.string, value: 'Value 1', isMultipleValue: false },
+ { id: '2', name: 'field_two', type: DataType.number, value: 42, isMultipleValue: false },
+ ]
+
+ const defaultProps = {
+ datasetId: 'ds-1',
+ documentNum: 5,
+ list: mockList,
+ onSave: vi.fn(),
+ onHide: vi.fn(),
+ onShowManage: vi.fn(),
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckNameResult = { errorMsg: '' }
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', async () => {
+ render()
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+ })
+
+ it('should render document count', async () => {
+ render()
+ await waitFor(() => {
+ expect(screen.getByText(/5/)).toBeInTheDocument()
+ })
+ })
+
+ it('should render all edit rows for existing items', async () => {
+ render()
+ await waitFor(() => {
+ const editRows = screen.getAllByTestId('edit-row')
+ expect(editRows).toHaveLength(2)
+ })
+ })
+
+ it('should render field names for existing items', async () => {
+ render()
+ await waitFor(() => {
+ expect(screen.getByText('field_one')).toBeInTheDocument()
+ expect(screen.getByText('field_two')).toBeInTheDocument()
+ })
+ })
+
+ it('should render checkbox for apply to all', async () => {
+ render()
+ await waitFor(() => {
+ const checkboxes = document.querySelectorAll('[data-testid*="checkbox"]')
+ expect(checkboxes.length).toBeGreaterThan(0)
+ })
+ })
+
+ it('should render select metadata modal', async () => {
+ render()
+ await waitFor(() => {
+ expect(screen.getByTestId('select-modal')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onHide when cancel button is clicked', async () => {
+ const onHide = vi.fn()
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ const cancelButton = screen.getByText(/cancel/i)
+ fireEvent.click(cancelButton)
+
+ expect(onHide).toHaveBeenCalled()
+ })
+
+ it('should call onSave when save button is clicked', async () => {
+ const onSave = vi.fn()
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ // Find the primary save button (not the one in SelectMetadataModal)
+ const saveButtons = screen.getAllByText(/save/i)
+ const modalSaveButton = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
+ if (modalSaveButton)
+ fireEvent.click(modalSaveButton)
+
+ expect(onSave).toHaveBeenCalled()
+ })
+
+ it('should toggle apply to all checkbox', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ const checkboxContainer = document.querySelector('[data-testid*="checkbox"]')
+ expect(checkboxContainer).toBeInTheDocument()
+
+ if (checkboxContainer) {
+ fireEvent.click(checkboxContainer)
+ await waitFor(() => {
+ const checkIcon = checkboxContainer.querySelector('svg')
+ expect(checkIcon).toBeInTheDocument()
+ })
+ }
+ })
+
+ it('should call onHide when modal close button is clicked', async () => {
+ const onHide = vi.fn()
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Edit Row Operations', () => {
+ it('should update item value when change is triggered', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByTestId('change-1'))
+
+ // The component should update internally
+ expect(screen.getAllByTestId('edit-row').length).toBe(2)
+ })
+
+ it('should mark item as deleted when remove is clicked', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByTestId('remove-1'))
+
+ // The component should update internally - item marked as deleted
+ expect(screen.getAllByTestId('edit-row').length).toBe(2)
+ })
+
+ it('should reset item when reset is clicked', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ // First change the item
+ fireEvent.click(screen.getByTestId('change-1'))
+ // Then reset it
+ fireEvent.click(screen.getByTestId('reset-1'))
+
+ expect(screen.getAllByTestId('edit-row').length).toBe(2)
+ })
+ })
+
+ describe('Add Metadata Operations', () => {
+ it('should add new item when metadata is selected', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByTestId('select-metadata'))
+
+ // Should now have add-row for the new item
+ await waitFor(() => {
+ expect(screen.getByTestId('add-row')).toBeInTheDocument()
+ })
+ })
+
+ it('should remove added item when remove is clicked', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ // First add an item
+ fireEvent.click(screen.getByTestId('select-metadata'))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('add-row')).toBeInTheDocument()
+ })
+
+ // Then remove it
+ fireEvent.click(screen.getByTestId('add-remove'))
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('add-row')).not.toBeInTheDocument()
+ })
+ })
+
+ it('should update added item when change is triggered', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ // First add an item
+ fireEvent.click(screen.getByTestId('select-metadata'))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('add-row')).toBeInTheDocument()
+ })
+
+ // Then change it
+ fireEvent.click(screen.getByTestId('add-change-new-1'))
+
+ expect(screen.getByTestId('add-row')).toBeInTheDocument()
+ })
+
+ it('should call doAddMetaData when saving new metadata with valid name', async () => {
+ mockCheckNameResult = { errorMsg: '' }
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByTestId('save-metadata'))
+
+ await waitFor(() => {
+ expect(mockDoAddMetaData).toHaveBeenCalled()
+ })
+ })
+
+ it('should show success toast when saving with valid name', async () => {
+ mockCheckNameResult = { errorMsg: '' }
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByTestId('save-metadata'))
+
+ await waitFor(() => {
+ expect(mockDoAddMetaData).toHaveBeenCalled()
+ })
+
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'success',
+ }),
+ )
+ })
+ })
+
+ it('should show error toast when saving with invalid name', async () => {
+ mockCheckNameResult = { errorMsg: 'Name already exists' }
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByTestId('save-metadata'))
+
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'error',
+ message: 'Name already exists',
+ }),
+ )
+ })
+ })
+
+ it('should call onShowManage when manage is clicked', async () => {
+ const onShowManage = vi.fn()
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByTestId('manage-metadata'))
+
+ expect(onShowManage).toHaveBeenCalled()
+ })
+ })
+
+ describe('Props', () => {
+ it('should pass correct datasetId', async () => {
+ render()
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+ })
+
+ it('should display correct document number', async () => {
+ render()
+ await waitFor(() => {
+ expect(screen.getByText(/10/)).toBeInTheDocument()
+ })
+ })
+
+ it('should handle empty list', async () => {
+ render()
+ await waitFor(() => {
+ expect(screen.queryByTestId('edit-row')).not.toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle list with multiple value items', async () => {
+ const multipleValueList: MetadataItemInBatchEdit[] = [
+ { id: '1', name: 'field', type: DataType.string, value: null, isMultipleValue: true },
+ ]
+ render()
+ await waitFor(() => {
+ expect(screen.getByTestId('edit-row')).toBeInTheDocument()
+ })
+ })
+
+ it('should handle rapid save clicks', async () => {
+ const onSave = vi.fn()
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ // Find the primary save button
+ const saveButtons = screen.getAllByText(/save/i)
+ const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
+ if (saveBtn) {
+ fireEvent.click(saveBtn)
+ fireEvent.click(saveBtn)
+ fireEvent.click(saveBtn)
+ }
+
+ expect(onSave).toHaveBeenCalledTimes(3)
+ })
+
+ it('should pass correct arguments to onSave', async () => {
+ const onSave = vi.fn()
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ const saveButtons = screen.getAllByText(/save/i)
+ const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
+ if (saveBtn)
+ fireEvent.click(saveBtn)
+
+ expect(onSave).toHaveBeenCalledWith(
+ expect.any(Array),
+ expect.any(Array),
+ expect.any(Boolean),
+ )
+ })
+
+ it('should pass isApplyToAllSelectDocument as true when checked', async () => {
+ const onSave = vi.fn()
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ const checkboxContainer = document.querySelector('[data-testid*="checkbox"]')
+ if (checkboxContainer)
+ fireEvent.click(checkboxContainer)
+
+ const saveButtons = screen.getAllByText(/save/i)
+ const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
+ if (saveBtn)
+ fireEvent.click(saveBtn)
+
+ await waitFor(() => {
+ expect(onSave).toHaveBeenCalledWith(
+ expect.any(Array),
+ expect.any(Array),
+ true,
+ )
+ })
+ })
+
+ it('should filter out deleted items when saving', async () => {
+ const onSave = vi.fn()
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ // Remove an item
+ fireEvent.click(screen.getByTestId('remove-1'))
+
+ // Save
+ const saveButtons = screen.getAllByText(/save/i)
+ const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
+ if (saveBtn)
+ fireEvent.click(saveBtn)
+
+ expect(onSave).toHaveBeenCalled()
+ // The first argument should not contain the deleted item (id '1')
+ const savedList = onSave.mock.calls[0][0] as MetadataItemInBatchEdit[]
+ const hasDeletedItem = savedList.some(item => item.id === '1')
+ expect(hasDeletedItem).toBe(false)
+ })
+
+ it('should handle multiple add and remove operations', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ // Add first item
+ fireEvent.click(screen.getByTestId('select-metadata'))
+ await waitFor(() => {
+ expect(screen.getByTestId('add-row')).toBeInTheDocument()
+ })
+
+ // Remove it
+ fireEvent.click(screen.getByTestId('add-remove'))
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('add-row')).not.toBeInTheDocument()
+ })
+
+ // Add again
+ fireEvent.click(screen.getByTestId('select-metadata'))
+ await waitFor(() => {
+ expect(screen.getByTestId('add-row')).toBeInTheDocument()
+ })
+ })
+ })
+})
diff --git a/web/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata.spec.ts b/web/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata.spec.ts
new file mode 100644
index 0000000000..c508a45dc7
--- /dev/null
+++ b/web/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata.spec.ts
@@ -0,0 +1,647 @@
+import { act, renderHook, waitFor } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { DataType, UpdateType } from '../types'
+import useBatchEditDocumentMetadata from './use-batch-edit-document-metadata'
+
+type DocMetadataItem = {
+ id: string
+ name: string
+ type: DataType
+ value: string | number | null
+}
+
+type DocListItem = {
+ id: string
+ name?: string
+ doc_metadata?: DocMetadataItem[] | null
+}
+
+type MetadataItemWithEdit = {
+ id: string
+ name: string
+ type: DataType
+ value: string | number | null
+ isMultipleValue?: boolean
+ updateType?: UpdateType
+}
+
+// Mock useBatchUpdateDocMetadata
+const mockMutateAsync = vi.fn().mockResolvedValue({})
+vi.mock('@/service/knowledge/use-metadata', () => ({
+ useBatchUpdateDocMetadata: () => ({
+ mutateAsync: mockMutateAsync,
+ }),
+}))
+
+// Mock Toast
+vi.mock('@/app/components/base/toast', () => ({
+ default: {
+ notify: vi.fn(),
+ },
+}))
+
+describe('useBatchEditDocumentMetadata', () => {
+ const mockDocList: DocListItem[] = [
+ {
+ id: 'doc-1',
+ name: 'Document 1',
+ doc_metadata: [
+ { id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
+ { id: '2', name: 'field_two', type: DataType.number, value: 42 },
+ ],
+ },
+ {
+ id: 'doc-2',
+ name: 'Document 2',
+ doc_metadata: [
+ { id: '1', name: 'field_one', type: DataType.string, value: 'Value 2' },
+ ],
+ },
+ ]
+
+ const defaultProps = {
+ datasetId: 'ds-1',
+ docList: mockDocList as Parameters[0]['docList'],
+ onUpdate: vi.fn(),
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Hook Initialization', () => {
+ it('should initialize with isShowEditModal as false', () => {
+ const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
+ expect(result.current.isShowEditModal).toBe(false)
+ })
+
+ it('should return showEditModal function', () => {
+ const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
+ expect(typeof result.current.showEditModal).toBe('function')
+ })
+
+ it('should return hideEditModal function', () => {
+ const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
+ expect(typeof result.current.hideEditModal).toBe('function')
+ })
+
+ it('should return originalList', () => {
+ const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
+ expect(Array.isArray(result.current.originalList)).toBe(true)
+ })
+
+ it('should return handleSave function', () => {
+ const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
+ expect(typeof result.current.handleSave).toBe('function')
+ })
+ })
+
+ describe('Modal Control', () => {
+ it('should show modal when showEditModal is called', () => {
+ const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
+
+ act(() => {
+ result.current.showEditModal()
+ })
+
+ expect(result.current.isShowEditModal).toBe(true)
+ })
+
+ it('should hide modal when hideEditModal is called', () => {
+ const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
+
+ act(() => {
+ result.current.showEditModal()
+ })
+
+ act(() => {
+ result.current.hideEditModal()
+ })
+
+ expect(result.current.isShowEditModal).toBe(false)
+ })
+ })
+
+ describe('Original List Processing', () => {
+ it('should compute originalList from docList metadata', () => {
+ const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
+
+ expect(result.current.originalList.length).toBeGreaterThan(0)
+ })
+
+ it('should filter out built-in metadata', () => {
+ const docListWithBuiltIn: DocListItem[] = [
+ {
+ id: 'doc-1',
+ doc_metadata: [
+ { id: 'built-in', name: 'created_at', type: DataType.time, value: 123 },
+ { id: '1', name: 'custom', type: DataType.string, value: 'test' },
+ ],
+ },
+ ]
+
+ const { result } = renderHook(() =>
+ useBatchEditDocumentMetadata({
+ ...defaultProps,
+ docList: docListWithBuiltIn as Parameters[0]['docList'],
+ }),
+ )
+
+ const hasBuiltIn = result.current.originalList.some(item => item.id === 'built-in')
+ expect(hasBuiltIn).toBe(false)
+ })
+
+ it('should mark items with multiple values', () => {
+ const docListWithDifferentValues: DocListItem[] = [
+ {
+ id: 'doc-1',
+ doc_metadata: [
+ { id: '1', name: 'field', type: DataType.string, value: 'Value A' },
+ ],
+ },
+ {
+ id: 'doc-2',
+ doc_metadata: [
+ { id: '1', name: 'field', type: DataType.string, value: 'Value B' },
+ ],
+ },
+ ]
+
+ const { result } = renderHook(() =>
+ useBatchEditDocumentMetadata({
+ ...defaultProps,
+ docList: docListWithDifferentValues as Parameters[0]['docList'],
+ }),
+ )
+
+ const fieldItem = result.current.originalList.find(item => item.id === '1')
+ expect(fieldItem?.isMultipleValue).toBe(true)
+ })
+
+ it('should not mark items with same values as multiple', () => {
+ const docListWithSameValues: DocListItem[] = [
+ {
+ id: 'doc-1',
+ doc_metadata: [
+ { id: '1', name: 'field', type: DataType.string, value: 'Same Value' },
+ ],
+ },
+ {
+ id: 'doc-2',
+ doc_metadata: [
+ { id: '1', name: 'field', type: DataType.string, value: 'Same Value' },
+ ],
+ },
+ ]
+
+ const { result } = renderHook(() =>
+ useBatchEditDocumentMetadata({
+ ...defaultProps,
+ docList: docListWithSameValues as Parameters[0]['docList'],
+ }),
+ )
+
+ const fieldItem = result.current.originalList.find(item => item.id === '1')
+ expect(fieldItem?.isMultipleValue).toBe(false)
+ })
+
+ it('should skip already marked multiple value items', () => {
+ // Three docs with same field but different values
+ const docListThreeDocs: DocListItem[] = [
+ {
+ id: 'doc-1',
+ doc_metadata: [
+ { id: '1', name: 'field', type: DataType.string, value: 'Value A' },
+ ],
+ },
+ {
+ id: 'doc-2',
+ doc_metadata: [
+ { id: '1', name: 'field', type: DataType.string, value: 'Value B' },
+ ],
+ },
+ {
+ id: 'doc-3',
+ doc_metadata: [
+ { id: '1', name: 'field', type: DataType.string, value: 'Value C' },
+ ],
+ },
+ ]
+
+ const { result } = renderHook(() =>
+ useBatchEditDocumentMetadata({
+ ...defaultProps,
+ docList: docListThreeDocs as Parameters[0]['docList'],
+ }),
+ )
+
+ // Should only have one item for field '1', marked as multiple
+ const fieldItems = result.current.originalList.filter(item => item.id === '1')
+ expect(fieldItems.length).toBe(1)
+ expect(fieldItems[0].isMultipleValue).toBe(true)
+ })
+ })
+
+ describe('handleSave', () => {
+ it('should call mutateAsync with correct data', async () => {
+ const onUpdate = vi.fn()
+ const { result } = renderHook(() =>
+ useBatchEditDocumentMetadata({ ...defaultProps, onUpdate }),
+ )
+
+ await act(async () => {
+ await result.current.handleSave([], [], false)
+ })
+
+ expect(mockMutateAsync).toHaveBeenCalled()
+ })
+
+ it('should call onUpdate after successful save', async () => {
+ const onUpdate = vi.fn()
+ const { result } = renderHook(() =>
+ useBatchEditDocumentMetadata({ ...defaultProps, onUpdate }),
+ )
+
+ await act(async () => {
+ await result.current.handleSave([], [], false)
+ })
+
+ await waitFor(() => {
+ expect(onUpdate).toHaveBeenCalled()
+ })
+ })
+
+ it('should hide modal after successful save', async () => {
+ const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
+
+ act(() => {
+ result.current.showEditModal()
+ })
+
+ expect(result.current.isShowEditModal).toBe(true)
+
+ await act(async () => {
+ await result.current.handleSave([], [], false)
+ })
+
+ await waitFor(() => {
+ expect(result.current.isShowEditModal).toBe(false)
+ })
+ })
+
+ it('should handle edited items with changeValue updateType', async () => {
+ const docListSingleDoc: DocListItem[] = [
+ {
+ id: 'doc-1',
+ doc_metadata: [
+ { id: '1', name: 'field_one', type: DataType.string, value: 'Old Value' },
+ ],
+ },
+ ]
+
+ const { result } = renderHook(() =>
+ useBatchEditDocumentMetadata({
+ ...defaultProps,
+ docList: docListSingleDoc as Parameters[0]['docList'],
+ }),
+ )
+
+ const editedList: MetadataItemWithEdit[] = [
+ {
+ id: '1',
+ name: 'field_one',
+ type: DataType.string,
+ value: 'New Value',
+ updateType: UpdateType.changeValue,
+ },
+ ]
+
+ await act(async () => {
+ await result.current.handleSave(editedList, [], false)
+ })
+
+ expect(mockMutateAsync).toHaveBeenCalledWith(
+ expect.objectContaining({
+ metadata_list: expect.arrayContaining([
+ expect.objectContaining({
+ document_id: 'doc-1',
+ metadata_list: expect.arrayContaining([
+ expect.objectContaining({
+ id: '1',
+ value: 'New Value',
+ }),
+ ]),
+ }),
+ ]),
+ }),
+ )
+ })
+
+ it('should handle removed items', async () => {
+ const docListSingleDoc: DocListItem[] = [
+ {
+ id: 'doc-1',
+ doc_metadata: [
+ { id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
+ { id: '2', name: 'field_two', type: DataType.number, value: 42 },
+ ],
+ },
+ ]
+
+ const { result } = renderHook(() =>
+ useBatchEditDocumentMetadata({
+ ...defaultProps,
+ docList: docListSingleDoc as Parameters[0]['docList'],
+ }),
+ )
+
+ // Only pass field_one in editedList, field_two should be removed
+ const editedList: MetadataItemWithEdit[] = [
+ {
+ id: '1',
+ name: 'field_one',
+ type: DataType.string,
+ value: 'Value 1',
+ },
+ ]
+
+ await act(async () => {
+ await result.current.handleSave(editedList, [], false)
+ })
+
+ expect(mockMutateAsync).toHaveBeenCalled()
+ })
+
+ it('should handle added items', async () => {
+ const docListSingleDoc: DocListItem[] = [
+ {
+ id: 'doc-1',
+ doc_metadata: [
+ { id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
+ ],
+ },
+ ]
+
+ const { result } = renderHook(() =>
+ useBatchEditDocumentMetadata({
+ ...defaultProps,
+ docList: docListSingleDoc as Parameters[0]['docList'],
+ }),
+ )
+
+ const addedList = [
+ {
+ id: 'new-1',
+ name: 'new_field',
+ type: DataType.string,
+ value: 'New Value',
+ isMultipleValue: false,
+ },
+ ]
+
+ await act(async () => {
+ await result.current.handleSave([], addedList, false)
+ })
+
+ expect(mockMutateAsync).toHaveBeenCalledWith(
+ expect.objectContaining({
+ metadata_list: expect.arrayContaining([
+ expect.objectContaining({
+ metadata_list: expect.arrayContaining([
+ expect.objectContaining({
+ name: 'new_field',
+ }),
+ ]),
+ }),
+ ]),
+ }),
+ )
+ })
+
+ it('should add missing metadata when isApplyToAllSelectDocument is true', async () => {
+ // Doc 1 has field, Doc 2 doesn't have it
+ const docListMissingField: DocListItem[] = [
+ {
+ id: 'doc-1',
+ doc_metadata: [
+ { id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
+ ],
+ },
+ {
+ id: 'doc-2',
+ doc_metadata: [],
+ },
+ ]
+
+ const { result } = renderHook(() =>
+ useBatchEditDocumentMetadata({
+ ...defaultProps,
+ docList: docListMissingField as Parameters[0]['docList'],
+ }),
+ )
+
+ const editedList: MetadataItemWithEdit[] = [
+ {
+ id: '1',
+ name: 'field_one',
+ type: DataType.string,
+ value: 'Updated Value',
+ isMultipleValue: false,
+ updateType: UpdateType.changeValue,
+ },
+ ]
+
+ await act(async () => {
+ await result.current.handleSave(editedList, [], true)
+ })
+
+ // Both documents should have the field after applying to all
+ expect(mockMutateAsync).toHaveBeenCalled()
+ const callArgs = mockMutateAsync.mock.calls[0][0]
+ expect(callArgs.metadata_list.length).toBe(2)
+ })
+
+ it('should not add missing metadata for multiple value items when isApplyToAllSelectDocument is true', async () => {
+ // Two docs with different values for same field
+ const docListDifferentValues: DocListItem[] = [
+ {
+ id: 'doc-1',
+ doc_metadata: [
+ { id: '1', name: 'field_one', type: DataType.string, value: 'Value A' },
+ ],
+ },
+ {
+ id: 'doc-2',
+ doc_metadata: [
+ { id: '1', name: 'field_one', type: DataType.string, value: 'Value B' },
+ ],
+ },
+ {
+ id: 'doc-3',
+ doc_metadata: [],
+ },
+ ]
+
+ const { result } = renderHook(() =>
+ useBatchEditDocumentMetadata({
+ ...defaultProps,
+ docList: docListDifferentValues as Parameters[0]['docList'],
+ }),
+ )
+
+ // Mark it as multiple value item - should not be added to doc-3
+ const editedList: MetadataItemWithEdit[] = [
+ {
+ id: '1',
+ name: 'field_one',
+ type: DataType.string,
+ value: null,
+ isMultipleValue: true,
+ updateType: UpdateType.changeValue,
+ },
+ ]
+
+ await act(async () => {
+ await result.current.handleSave(editedList, [], true)
+ })
+
+ expect(mockMutateAsync).toHaveBeenCalled()
+ })
+
+ it('should update existing items in the list', async () => {
+ const docListSingleDoc: DocListItem[] = [
+ {
+ id: 'doc-1',
+ doc_metadata: [
+ { id: '1', name: 'field_one', type: DataType.string, value: 'Old Value' },
+ { id: '2', name: 'field_two', type: DataType.number, value: 100 },
+ ],
+ },
+ ]
+
+ const { result } = renderHook(() =>
+ useBatchEditDocumentMetadata({
+ ...defaultProps,
+ docList: docListSingleDoc as Parameters[0]['docList'],
+ }),
+ )
+
+ // Edit both items
+ const editedList: MetadataItemWithEdit[] = [
+ {
+ id: '1',
+ name: 'field_one',
+ type: DataType.string,
+ value: 'New Value 1',
+ updateType: UpdateType.changeValue,
+ },
+ {
+ id: '2',
+ name: 'field_two',
+ type: DataType.number,
+ value: 200,
+ updateType: UpdateType.changeValue,
+ },
+ ]
+
+ await act(async () => {
+ await result.current.handleSave(editedList, [], false)
+ })
+
+ expect(mockMutateAsync).toHaveBeenCalledWith(
+ expect.objectContaining({
+ metadata_list: expect.arrayContaining([
+ expect.objectContaining({
+ metadata_list: expect.arrayContaining([
+ expect.objectContaining({ id: '1', value: 'New Value 1' }),
+ expect.objectContaining({ id: '2', value: 200 }),
+ ]),
+ }),
+ ]),
+ }),
+ )
+ })
+ })
+
+ describe('Selected Document IDs', () => {
+ it('should use selectedDocumentIds when provided', async () => {
+ const selectedIds = ['doc-1']
+ const { result } = renderHook(() =>
+ useBatchEditDocumentMetadata({
+ ...defaultProps,
+ selectedDocumentIds: selectedIds,
+ }),
+ )
+
+ await act(async () => {
+ await result.current.handleSave([], [], false)
+ })
+
+ expect(mockMutateAsync).toHaveBeenCalledWith(
+ expect.objectContaining({
+ dataset_id: 'ds-1',
+ metadata_list: expect.arrayContaining([
+ expect.objectContaining({
+ document_id: 'doc-1',
+ }),
+ ]),
+ }),
+ )
+ })
+
+ it('should handle selectedDocumentIds not in docList', async () => {
+ // Select a document that's not in docList
+ const selectedIds = ['doc-1', 'doc-not-in-list']
+ const { result } = renderHook(() =>
+ useBatchEditDocumentMetadata({
+ ...defaultProps,
+ selectedDocumentIds: selectedIds,
+ }),
+ )
+
+ await act(async () => {
+ await result.current.handleSave([], [], false)
+ })
+
+ expect(mockMutateAsync).toHaveBeenCalledWith(
+ expect.objectContaining({
+ metadata_list: expect.arrayContaining([
+ expect.objectContaining({
+ document_id: 'doc-not-in-list',
+ partial_update: true,
+ }),
+ ]),
+ }),
+ )
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty docList', () => {
+ const { result } = renderHook(() =>
+ useBatchEditDocumentMetadata({
+ ...defaultProps,
+ docList: [] as Parameters[0]['docList'],
+ }),
+ )
+
+ expect(result.current.originalList).toEqual([])
+ })
+
+ it('should handle documents without metadata', () => {
+ const docListNoMetadata: DocListItem[] = [
+ { id: 'doc-1', name: 'Doc 1' },
+ { id: 'doc-2', name: 'Doc 2', doc_metadata: null },
+ ]
+
+ const { result } = renderHook(() =>
+ useBatchEditDocumentMetadata({
+ ...defaultProps,
+ docList: docListNoMetadata as Parameters[0]['docList'],
+ }),
+ )
+
+ expect(result.current.originalList).toEqual([])
+ })
+ })
+})
diff --git a/web/app/components/datasets/metadata/hooks/use-check-metadata-name.spec.ts b/web/app/components/datasets/metadata/hooks/use-check-metadata-name.spec.ts
new file mode 100644
index 0000000000..14081908c0
--- /dev/null
+++ b/web/app/components/datasets/metadata/hooks/use-check-metadata-name.spec.ts
@@ -0,0 +1,166 @@
+import { renderHook } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import useCheckMetadataName from './use-check-metadata-name'
+
+describe('useCheckMetadataName', () => {
+ describe('Hook Initialization', () => {
+ it('should return an object with checkName function', () => {
+ const { result } = renderHook(() => useCheckMetadataName())
+ expect(result.current).toHaveProperty('checkName')
+ expect(typeof result.current.checkName).toBe('function')
+ })
+ })
+
+ describe('checkName - Empty Name Validation', () => {
+ it('should return error for empty string', () => {
+ const { result } = renderHook(() => useCheckMetadataName())
+ const { errorMsg } = result.current.checkName('')
+ expect(errorMsg).toBeTruthy()
+ })
+
+ it('should return error for whitespace-only string', () => {
+ const { result } = renderHook(() => useCheckMetadataName())
+ // Whitespace is not valid since it doesn't match the pattern
+ const { errorMsg } = result.current.checkName(' ')
+ expect(errorMsg).toBeTruthy()
+ })
+ })
+
+ describe('checkName - Pattern Validation', () => {
+ it('should return error for name starting with number', () => {
+ const { result } = renderHook(() => useCheckMetadataName())
+ const { errorMsg } = result.current.checkName('1name')
+ expect(errorMsg).toBeTruthy()
+ })
+
+ it('should return error for name starting with uppercase', () => {
+ const { result } = renderHook(() => useCheckMetadataName())
+ const { errorMsg } = result.current.checkName('Name')
+ expect(errorMsg).toBeTruthy()
+ })
+
+ it('should return error for name starting with underscore', () => {
+ const { result } = renderHook(() => useCheckMetadataName())
+ const { errorMsg } = result.current.checkName('_name')
+ expect(errorMsg).toBeTruthy()
+ })
+
+ it('should return error for name with spaces', () => {
+ const { result } = renderHook(() => useCheckMetadataName())
+ const { errorMsg } = result.current.checkName('my name')
+ expect(errorMsg).toBeTruthy()
+ })
+
+ it('should return error for name with special characters', () => {
+ const { result } = renderHook(() => useCheckMetadataName())
+ const { errorMsg } = result.current.checkName('name-with-dash')
+ expect(errorMsg).toBeTruthy()
+ })
+
+ it('should return error for name with dots', () => {
+ const { result } = renderHook(() => useCheckMetadataName())
+ const { errorMsg } = result.current.checkName('name.with.dot')
+ expect(errorMsg).toBeTruthy()
+ })
+
+ it('should accept valid name starting with lowercase letter', () => {
+ const { result } = renderHook(() => useCheckMetadataName())
+ const { errorMsg } = result.current.checkName('validname')
+ expect(errorMsg).toBe('')
+ })
+
+ it('should accept valid name with numbers after first character', () => {
+ const { result } = renderHook(() => useCheckMetadataName())
+ const { errorMsg } = result.current.checkName('name123')
+ expect(errorMsg).toBe('')
+ })
+
+ it('should accept valid name with underscores after first character', () => {
+ const { result } = renderHook(() => useCheckMetadataName())
+ const { errorMsg } = result.current.checkName('name_with_underscore')
+ expect(errorMsg).toBe('')
+ })
+
+ it('should accept single lowercase letter', () => {
+ const { result } = renderHook(() => useCheckMetadataName())
+ const { errorMsg } = result.current.checkName('a')
+ expect(errorMsg).toBe('')
+ })
+ })
+
+ describe('checkName - Length Validation', () => {
+ it('should return error for name longer than 255 characters', () => {
+ const { result } = renderHook(() => useCheckMetadataName())
+ const longName = 'a'.repeat(256)
+ const { errorMsg } = result.current.checkName(longName)
+ expect(errorMsg).toBeTruthy()
+ })
+
+ it('should accept name with exactly 255 characters', () => {
+ const { result } = renderHook(() => useCheckMetadataName())
+ const maxLengthName = 'a'.repeat(255)
+ const { errorMsg } = result.current.checkName(maxLengthName)
+ expect(errorMsg).toBe('')
+ })
+
+ it('should accept name with less than 255 characters', () => {
+ const { result } = renderHook(() => useCheckMetadataName())
+ const shortName = 'a'.repeat(100)
+ const { errorMsg } = result.current.checkName(shortName)
+ expect(errorMsg).toBe('')
+ })
+ })
+
+ describe('checkName - Edge Cases', () => {
+ it('should validate all lowercase letters', () => {
+ const { result } = renderHook(() => useCheckMetadataName())
+ const { errorMsg } = result.current.checkName('abcdefghijklmnopqrstuvwxyz')
+ expect(errorMsg).toBe('')
+ })
+
+ it('should validate name with mixed numbers and underscores', () => {
+ const { result } = renderHook(() => useCheckMetadataName())
+ const { errorMsg } = result.current.checkName('a1_2_3_test')
+ expect(errorMsg).toBe('')
+ })
+
+ it('should reject uppercase letters anywhere in name', () => {
+ const { result } = renderHook(() => useCheckMetadataName())
+ const { errorMsg } = result.current.checkName('nameWithUppercase')
+ expect(errorMsg).toBeTruthy()
+ })
+
+ it('should reject unicode characters', () => {
+ const { result } = renderHook(() => useCheckMetadataName())
+ const { errorMsg } = result.current.checkName('名字')
+ expect(errorMsg).toBeTruthy()
+ })
+
+ it('should reject emoji characters', () => {
+ const { result } = renderHook(() => useCheckMetadataName())
+ const { errorMsg } = result.current.checkName('name😀')
+ expect(errorMsg).toBeTruthy()
+ })
+ })
+
+ describe('Return Value Structure', () => {
+ it('should return object with errorMsg property', () => {
+ const { result } = renderHook(() => useCheckMetadataName())
+ const returnValue = result.current.checkName('test')
+ expect(returnValue).toHaveProperty('errorMsg')
+ })
+
+ it('should return empty string for valid name', () => {
+ const { result } = renderHook(() => useCheckMetadataName())
+ const { errorMsg } = result.current.checkName('valid_name')
+ expect(errorMsg).toBe('')
+ })
+
+ it('should return non-empty string for invalid name', () => {
+ const { result } = renderHook(() => useCheckMetadataName())
+ const { errorMsg } = result.current.checkName('')
+ expect(typeof errorMsg).toBe('string')
+ expect(errorMsg.length).toBeGreaterThan(0)
+ })
+ })
+})
diff --git a/web/app/components/datasets/metadata/hooks/use-edit-dataset-metadata.spec.ts b/web/app/components/datasets/metadata/hooks/use-edit-dataset-metadata.spec.ts
new file mode 100644
index 0000000000..132660302d
--- /dev/null
+++ b/web/app/components/datasets/metadata/hooks/use-edit-dataset-metadata.spec.ts
@@ -0,0 +1,308 @@
+import { act, renderHook, waitFor } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { DataType } from '../types'
+import useEditDatasetMetadata from './use-edit-dataset-metadata'
+
+// Mock service hooks
+const mockDoAddMetaData = vi.fn().mockResolvedValue({})
+const mockDoRenameMetaData = vi.fn().mockResolvedValue({})
+const mockDoDeleteMetaData = vi.fn().mockResolvedValue({})
+const mockToggleBuiltInStatus = vi.fn().mockResolvedValue({})
+
+vi.mock('@/service/knowledge/use-metadata', () => ({
+ useDatasetMetaData: () => ({
+ data: {
+ doc_metadata: [
+ { id: '1', name: 'field_one', type: DataType.string, count: 5 },
+ { id: '2', name: 'field_two', type: DataType.number, count: 3 },
+ ],
+ built_in_field_enabled: false,
+ },
+ }),
+ useCreateMetaData: () => ({
+ mutate: mockDoAddMetaData,
+ }),
+ useRenameMeta: () => ({
+ mutate: mockDoRenameMetaData,
+ }),
+ useDeleteMetaData: () => ({
+ mutateAsync: mockDoDeleteMetaData,
+ }),
+ useUpdateBuiltInStatus: () => ({
+ mutateAsync: mockToggleBuiltInStatus,
+ }),
+ useBuiltInMetaDataFields: () => ({
+ data: {
+ fields: [
+ { name: 'created_at', type: DataType.time },
+ { name: 'modified_at', type: DataType.time },
+ ],
+ },
+ }),
+}))
+
+// Mock Toast
+vi.mock('@/app/components/base/toast', () => ({
+ default: {
+ notify: vi.fn(),
+ },
+}))
+
+// Mock useCheckMetadataName
+vi.mock('./use-check-metadata-name', () => ({
+ default: () => ({
+ checkName: (name: string) => ({
+ errorMsg: name && /^[a-z][a-z0-9_]*$/.test(name) ? '' : 'Invalid name',
+ }),
+ }),
+}))
+
+// Mock localStorage
+const localStorageMock = {
+ getItem: vi.fn(),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ clear: vi.fn(),
+}
+Object.defineProperty(window, 'localStorage', { value: localStorageMock })
+
+describe('useEditDatasetMetadata', () => {
+ const defaultProps = {
+ datasetId: 'ds-1',
+ onUpdateDocList: vi.fn(),
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ localStorageMock.getItem.mockReturnValue(null)
+ })
+
+ describe('Hook Initialization', () => {
+ it('should initialize with isShowEditModal as false', () => {
+ const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
+ expect(result.current.isShowEditModal).toBe(false)
+ })
+
+ it('should return showEditModal function', () => {
+ const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
+ expect(typeof result.current.showEditModal).toBe('function')
+ })
+
+ it('should return hideEditModal function', () => {
+ const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
+ expect(typeof result.current.hideEditModal).toBe('function')
+ })
+
+ it('should return datasetMetaData', () => {
+ const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
+ expect(result.current.datasetMetaData).toBeDefined()
+ })
+
+ it('should return handleAddMetaData function', () => {
+ const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
+ expect(typeof result.current.handleAddMetaData).toBe('function')
+ })
+
+ it('should return handleRename function', () => {
+ const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
+ expect(typeof result.current.handleRename).toBe('function')
+ })
+
+ it('should return handleDeleteMetaData function', () => {
+ const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
+ expect(typeof result.current.handleDeleteMetaData).toBe('function')
+ })
+
+ it('should return builtInMetaData', () => {
+ const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
+ expect(result.current.builtInMetaData).toBeDefined()
+ })
+
+ it('should return builtInEnabled', () => {
+ const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
+ expect(typeof result.current.builtInEnabled).toBe('boolean')
+ })
+
+ it('should return setBuiltInEnabled function', () => {
+ const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
+ expect(typeof result.current.setBuiltInEnabled).toBe('function')
+ })
+ })
+
+ describe('Modal Control', () => {
+ it('should show modal when showEditModal is called', () => {
+ const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
+
+ act(() => {
+ result.current.showEditModal()
+ })
+
+ expect(result.current.isShowEditModal).toBe(true)
+ })
+
+ it('should hide modal when hideEditModal is called', () => {
+ const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
+
+ act(() => {
+ result.current.showEditModal()
+ })
+
+ act(() => {
+ result.current.hideEditModal()
+ })
+
+ expect(result.current.isShowEditModal).toBe(false)
+ })
+
+ it('should handle toggle of modal state', () => {
+ const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
+
+ // Initially closed
+ expect(result.current.isShowEditModal).toBe(false)
+
+ // Show, hide, show
+ act(() => result.current.showEditModal())
+ expect(result.current.isShowEditModal).toBe(true)
+
+ act(() => result.current.hideEditModal())
+ expect(result.current.isShowEditModal).toBe(false)
+
+ act(() => result.current.showEditModal())
+ expect(result.current.isShowEditModal).toBe(true)
+ })
+ })
+
+ describe('handleAddMetaData', () => {
+ it('should call doAddMetaData with valid name', async () => {
+ const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
+
+ await act(async () => {
+ await result.current.handleAddMetaData({
+ name: 'valid_name',
+ type: DataType.string,
+ })
+ })
+
+ expect(mockDoAddMetaData).toHaveBeenCalled()
+ })
+
+ it('should reject invalid name', async () => {
+ const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
+
+ await expect(
+ act(async () => {
+ await result.current.handleAddMetaData({
+ name: '',
+ type: DataType.string,
+ })
+ }),
+ ).rejects.toThrow()
+ })
+ })
+
+ describe('handleRename', () => {
+ it('should call doRenameMetaData with valid name', async () => {
+ const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
+
+ await act(async () => {
+ await result.current.handleRename({
+ id: '1',
+ name: 'new_valid_name',
+ type: DataType.string,
+ count: 5,
+ })
+ })
+
+ expect(mockDoRenameMetaData).toHaveBeenCalled()
+ })
+
+ it('should call onUpdateDocList after rename', async () => {
+ const onUpdateDocList = vi.fn()
+ const { result } = renderHook(() =>
+ useEditDatasetMetadata({ ...defaultProps, onUpdateDocList }),
+ )
+
+ await act(async () => {
+ await result.current.handleRename({
+ id: '1',
+ name: 'renamed',
+ type: DataType.string,
+ count: 5,
+ })
+ })
+
+ await waitFor(() => {
+ expect(onUpdateDocList).toHaveBeenCalled()
+ })
+ })
+
+ it('should reject invalid name for rename', async () => {
+ const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
+
+ await expect(
+ act(async () => {
+ await result.current.handleRename({
+ id: '1',
+ name: 'Invalid Name',
+ type: DataType.string,
+ count: 5,
+ })
+ }),
+ ).rejects.toThrow()
+ })
+ })
+
+ describe('handleDeleteMetaData', () => {
+ it('should call doDeleteMetaData', async () => {
+ const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
+
+ await act(async () => {
+ await result.current.handleDeleteMetaData('1')
+ })
+
+ expect(mockDoDeleteMetaData).toHaveBeenCalledWith('1')
+ })
+
+ it('should call onUpdateDocList after delete', async () => {
+ const onUpdateDocList = vi.fn()
+ const { result } = renderHook(() =>
+ useEditDatasetMetadata({ ...defaultProps, onUpdateDocList }),
+ )
+
+ await act(async () => {
+ await result.current.handleDeleteMetaData('1')
+ })
+
+ await waitFor(() => {
+ expect(onUpdateDocList).toHaveBeenCalled()
+ })
+ })
+ })
+
+ describe('Built-in Status', () => {
+ it('should toggle built-in status', async () => {
+ const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
+
+ await act(async () => {
+ await result.current.setBuiltInEnabled(true)
+ })
+
+ expect(mockToggleBuiltInStatus).toHaveBeenCalledWith(true)
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle different datasetIds', () => {
+ const { result, rerender } = renderHook(
+ props => useEditDatasetMetadata(props),
+ { initialProps: defaultProps },
+ )
+
+ expect(result.current).toBeDefined()
+
+ rerender({ ...defaultProps, datasetId: 'ds-2' })
+
+ expect(result.current).toBeDefined()
+ })
+ })
+})
diff --git a/web/app/components/datasets/metadata/hooks/use-metadata-document.spec.ts b/web/app/components/datasets/metadata/hooks/use-metadata-document.spec.ts
new file mode 100644
index 0000000000..bbe84aaf1d
--- /dev/null
+++ b/web/app/components/datasets/metadata/hooks/use-metadata-document.spec.ts
@@ -0,0 +1,587 @@
+import { act, renderHook, waitFor } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { DataType } from '../types'
+import useMetadataDocument from './use-metadata-document'
+
+type DocDetail = {
+ id: string
+ name: string
+ data_source_type: string
+ word_count: number
+ language?: string
+ hit_count?: number
+ segment_count?: number
+}
+
+// Mock service hooks
+const mockMutateAsync = vi.fn().mockResolvedValue({})
+const mockDoAddMetaData = vi.fn().mockResolvedValue({})
+
+vi.mock('@/service/knowledge/use-metadata', () => ({
+ useBatchUpdateDocMetadata: () => ({
+ mutateAsync: mockMutateAsync,
+ }),
+ useCreateMetaData: () => ({
+ mutateAsync: mockDoAddMetaData,
+ }),
+ useDocumentMetaData: () => ({
+ data: {
+ doc_metadata: [
+ { id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
+ { id: '2', name: 'field_two', type: DataType.number, value: 42 },
+ { id: 'built-in', name: 'created_at', type: DataType.time, value: 1609459200 },
+ ],
+ },
+ }),
+ useDatasetMetaData: () => ({
+ data: {
+ built_in_field_enabled: true,
+ },
+ }),
+}))
+
+// Mock useDatasetDetailContext
+vi.mock('@/context/dataset-detail', () => ({
+ useDatasetDetailContext: () => ({
+ dataset: {
+ embedding_available: true,
+ },
+ }),
+}))
+
+// Mock useMetadataMap and useLanguages with comprehensive field definitions
+vi.mock('@/hooks/use-metadata', () => ({
+ useMetadataMap: () => ({
+ originInfo: {
+ subFieldsMap: {
+ data_source_type: { label: 'Source Type', inputType: 'text' },
+ language: { label: 'Language', inputType: 'select' },
+ empty_field: { label: 'Empty Field', inputType: 'text' },
+ },
+ },
+ technicalParameters: {
+ subFieldsMap: {
+ word_count: { label: 'Word Count', inputType: 'text' },
+ hit_count: {
+ label: 'Hit Count',
+ inputType: 'text',
+ render: (val: number, segmentCount?: number) => `${val}/${segmentCount || 0}`,
+ },
+ custom_render: {
+ label: 'Custom Render',
+ inputType: 'text',
+ render: (val: string) => `Rendered: ${val}`,
+ },
+ },
+ },
+ }),
+ useLanguages: () => ({
+ en: 'English',
+ zh: 'Chinese',
+ ja: 'Japanese',
+ }),
+}))
+
+// Mock Toast
+vi.mock('@/app/components/base/toast', () => ({
+ default: {
+ notify: vi.fn(),
+ },
+}))
+
+// Mock useCheckMetadataName
+vi.mock('./use-check-metadata-name', () => ({
+ default: () => ({
+ checkName: (name: string) => ({
+ errorMsg: name && /^[a-z][a-z0-9_]*$/.test(name) ? '' : 'Invalid name',
+ }),
+ }),
+}))
+
+describe('useMetadataDocument', () => {
+ const mockDocDetail: DocDetail = {
+ id: 'doc-1',
+ name: 'Test Document',
+ data_source_type: 'upload_file',
+ word_count: 100,
+ language: 'en',
+ hit_count: 50,
+ segment_count: 10,
+ }
+
+ const defaultProps = {
+ datasetId: 'ds-1',
+ documentId: 'doc-1',
+ docDetail: mockDocDetail as Parameters[0]['docDetail'],
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Hook Initialization', () => {
+ it('should return embeddingAvailable', () => {
+ const { result } = renderHook(() => useMetadataDocument(defaultProps))
+ expect(result.current.embeddingAvailable).toBe(true)
+ })
+
+ it('should return isEdit as false initially', () => {
+ const { result } = renderHook(() => useMetadataDocument(defaultProps))
+ expect(result.current.isEdit).toBe(false)
+ })
+
+ it('should return setIsEdit function', () => {
+ const { result } = renderHook(() => useMetadataDocument(defaultProps))
+ expect(typeof result.current.setIsEdit).toBe('function')
+ })
+
+ it('should return list without built-in items', () => {
+ const { result } = renderHook(() => useMetadataDocument(defaultProps))
+ const hasBuiltIn = result.current.list.some(item => item.id === 'built-in')
+ expect(hasBuiltIn).toBe(false)
+ })
+
+ it('should return builtList with only built-in items', () => {
+ const { result } = renderHook(() => useMetadataDocument(defaultProps))
+ const allBuiltIn = result.current.builtList.every(item => item.id === 'built-in')
+ expect(allBuiltIn).toBe(true)
+ })
+
+ it('should return tempList', () => {
+ const { result } = renderHook(() => useMetadataDocument(defaultProps))
+ expect(Array.isArray(result.current.tempList)).toBe(true)
+ })
+
+ it('should return setTempList function', () => {
+ const { result } = renderHook(() => useMetadataDocument(defaultProps))
+ expect(typeof result.current.setTempList).toBe('function')
+ })
+
+ it('should return hasData based on list length', () => {
+ const { result } = renderHook(() => useMetadataDocument(defaultProps))
+ expect(result.current.hasData).toBe(result.current.list.length > 0)
+ })
+
+ it('should return builtInEnabled', () => {
+ const { result } = renderHook(() => useMetadataDocument(defaultProps))
+ expect(typeof result.current.builtInEnabled).toBe('boolean')
+ })
+
+ it('should return originInfo', () => {
+ const { result } = renderHook(() => useMetadataDocument(defaultProps))
+ expect(Array.isArray(result.current.originInfo)).toBe(true)
+ })
+
+ it('should return technicalParameters', () => {
+ const { result } = renderHook(() => useMetadataDocument(defaultProps))
+ expect(Array.isArray(result.current.technicalParameters)).toBe(true)
+ })
+ })
+
+ describe('Edit Mode', () => {
+ it('should enter edit mode when startToEdit is called', () => {
+ const { result } = renderHook(() => useMetadataDocument(defaultProps))
+
+ act(() => {
+ result.current.startToEdit()
+ })
+
+ expect(result.current.isEdit).toBe(true)
+ })
+
+ it('should exit edit mode when handleCancel is called', () => {
+ const { result } = renderHook(() => useMetadataDocument(defaultProps))
+
+ act(() => {
+ result.current.startToEdit()
+ })
+
+ act(() => {
+ result.current.handleCancel()
+ })
+
+ expect(result.current.isEdit).toBe(false)
+ })
+
+ it('should reset tempList when handleCancel is called', () => {
+ const { result } = renderHook(() => useMetadataDocument(defaultProps))
+
+ act(() => {
+ result.current.startToEdit()
+ })
+
+ const originalLength = result.current.list.length
+
+ act(() => {
+ result.current.setTempList([])
+ })
+
+ act(() => {
+ result.current.handleCancel()
+ })
+
+ expect(result.current.tempList.length).toBe(originalLength)
+ })
+ })
+
+ describe('handleSelectMetaData', () => {
+ it('should add metadata to tempList if not exists', () => {
+ const { result } = renderHook(() => useMetadataDocument(defaultProps))
+
+ act(() => {
+ result.current.startToEdit()
+ })
+
+ const initialLength = result.current.tempList.length
+
+ act(() => {
+ result.current.handleSelectMetaData({
+ id: 'new-id',
+ name: 'new_field',
+ type: DataType.string,
+ value: null,
+ })
+ })
+
+ expect(result.current.tempList.length).toBe(initialLength + 1)
+ })
+
+ it('should not add duplicate metadata', () => {
+ const { result } = renderHook(() => useMetadataDocument(defaultProps))
+
+ act(() => {
+ result.current.startToEdit()
+ })
+
+ const initialLength = result.current.tempList.length
+
+ // Try to add existing item
+ if (result.current.tempList.length > 0) {
+ act(() => {
+ result.current.handleSelectMetaData(result.current.tempList[0])
+ })
+
+ expect(result.current.tempList.length).toBe(initialLength)
+ }
+ })
+ })
+
+ describe('handleAddMetaData', () => {
+ it('should call doAddMetaData with valid name', async () => {
+ const { result } = renderHook(() => useMetadataDocument(defaultProps))
+
+ await act(async () => {
+ await result.current.handleAddMetaData({
+ name: 'valid_field',
+ type: DataType.string,
+ })
+ })
+
+ expect(mockDoAddMetaData).toHaveBeenCalled()
+ })
+
+ it('should reject invalid name', async () => {
+ const { result } = renderHook(() => useMetadataDocument(defaultProps))
+
+ await expect(
+ act(async () => {
+ await result.current.handleAddMetaData({
+ name: '',
+ type: DataType.string,
+ })
+ }),
+ ).rejects.toThrow()
+ })
+ })
+
+ describe('handleSave', () => {
+ it('should call mutateAsync to save metadata', async () => {
+ const { result } = renderHook(() => useMetadataDocument(defaultProps))
+
+ act(() => {
+ result.current.startToEdit()
+ })
+
+ await act(async () => {
+ await result.current.handleSave()
+ })
+
+ expect(mockMutateAsync).toHaveBeenCalled()
+ })
+
+ it('should exit edit mode after save', async () => {
+ const { result } = renderHook(() => useMetadataDocument(defaultProps))
+
+ act(() => {
+ result.current.startToEdit()
+ })
+
+ await act(async () => {
+ await result.current.handleSave()
+ })
+
+ await waitFor(() => {
+ expect(result.current.isEdit).toBe(false)
+ })
+ })
+ })
+
+ describe('getReadOnlyMetaData - originInfo', () => {
+ it('should return origin info with correct structure', () => {
+ const { result } = renderHook(() => useMetadataDocument(defaultProps))
+
+ expect(result.current.originInfo).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ type: DataType.string,
+ }),
+ ]),
+ )
+ })
+
+ it('should use languageMap for language field (select type)', () => {
+ const { result } = renderHook(() => useMetadataDocument(defaultProps))
+
+ // Find language field in originInfo
+ const languageField = result.current.originInfo.find(
+ item => item.name === 'Language',
+ )
+
+ // If language field exists and docDetail has language 'en', value should be 'English'
+ if (languageField)
+ expect(languageField.value).toBe('English')
+ })
+
+ it('should return dash for empty field values', () => {
+ const docDetailWithEmpty: DocDetail = {
+ id: 'doc-1',
+ name: 'Test Document',
+ data_source_type: 'upload_file',
+ word_count: 100,
+ }
+
+ const { result } = renderHook(() =>
+ useMetadataDocument({
+ ...defaultProps,
+ docDetail: docDetailWithEmpty as Parameters[0]['docDetail'],
+ }),
+ )
+
+ // Check if there's any field with '-' value (meaning empty)
+ const hasEmptyField = result.current.originInfo.some(
+ item => item.value === '-',
+ )
+ // language field should return '-' since it's not set
+ expect(hasEmptyField).toBe(true)
+ })
+
+ it('should return empty object for non-language select fields', () => {
+ // This tests the else branch of getTargetMap where field !== 'language'
+ const { result } = renderHook(() => useMetadataDocument(defaultProps))
+
+ // The data_source_type field is a text field, not select
+ const sourceTypeField = result.current.originInfo.find(
+ item => item.name === 'Source Type',
+ )
+
+ // It should return the raw value since it's not a select type
+ if (sourceTypeField)
+ expect(sourceTypeField.value).toBe('upload_file')
+ })
+ })
+
+ describe('getReadOnlyMetaData - technicalParameters', () => {
+ it('should return technical parameters with correct structure', () => {
+ const { result } = renderHook(() => useMetadataDocument(defaultProps))
+
+ expect(result.current.technicalParameters).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ type: DataType.string,
+ }),
+ ]),
+ )
+ })
+
+ it('should use render function when available', () => {
+ const { result } = renderHook(() => useMetadataDocument(defaultProps))
+
+ // Find hit_count field which has a render function
+ const hitCountField = result.current.technicalParameters.find(
+ item => item.name === 'Hit Count',
+ )
+
+ // The render function should format as "val/segmentCount"
+ if (hitCountField)
+ expect(hitCountField.value).toBe('50/10')
+ })
+
+ it('should return raw value when no render function', () => {
+ const { result } = renderHook(() => useMetadataDocument(defaultProps))
+
+ // Find word_count field which has no render function
+ const wordCountField = result.current.technicalParameters.find(
+ item => item.name === 'Word Count',
+ )
+
+ if (wordCountField)
+ expect(wordCountField.value).toBe(100)
+ })
+
+ it('should handle fields with render function and undefined segment_count', () => {
+ const docDetailNoSegment: DocDetail = {
+ id: 'doc-1',
+ name: 'Test Document',
+ data_source_type: 'upload_file',
+ word_count: 100,
+ hit_count: 25,
+ }
+
+ const { result } = renderHook(() =>
+ useMetadataDocument({
+ ...defaultProps,
+ docDetail: docDetailNoSegment as Parameters[0]['docDetail'],
+ }),
+ )
+
+ const hitCountField = result.current.technicalParameters.find(
+ item => item.name === 'Hit Count',
+ )
+
+ // Should use 0 as default for segment_count
+ if (hitCountField)
+ expect(hitCountField.value).toBe('25/0')
+ })
+
+ it('should return dash for null/undefined values', () => {
+ const docDetailWithNull: DocDetail = {
+ id: 'doc-1',
+ name: 'Test Document',
+ data_source_type: '',
+ word_count: 0,
+ }
+
+ const { result } = renderHook(() =>
+ useMetadataDocument({
+ ...defaultProps,
+ docDetail: docDetailWithNull as Parameters[0]['docDetail'],
+ }),
+ )
+
+ // 0 should still be shown, but empty string should show '-'
+ const sourceTypeField = result.current.originInfo.find(
+ item => item.name === 'Source Type',
+ )
+
+ if (sourceTypeField)
+ expect(sourceTypeField.value).toBe('-')
+ })
+
+ it('should handle 0 value correctly (not treated as empty)', () => {
+ const docDetailWithZero: DocDetail = {
+ id: 'doc-1',
+ name: 'Test Document',
+ data_source_type: 'upload_file',
+ word_count: 0,
+ }
+
+ const { result } = renderHook(() =>
+ useMetadataDocument({
+ ...defaultProps,
+ docDetail: docDetailWithZero as Parameters[0]['docDetail'],
+ }),
+ )
+
+ // word_count of 0 should still show 0, not '-'
+ const wordCountField = result.current.technicalParameters.find(
+ item => item.name === 'Word Count',
+ )
+
+ if (wordCountField)
+ expect(wordCountField.value).toBe(0)
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty docDetail', () => {
+ const { result } = renderHook(() =>
+ useMetadataDocument({
+ ...defaultProps,
+ docDetail: {} as Parameters[0]['docDetail'],
+ }),
+ )
+
+ expect(result.current).toBeDefined()
+ })
+
+ it('should handle different datasetIds', () => {
+ const { result, rerender } = renderHook(
+ props => useMetadataDocument(props),
+ { initialProps: defaultProps },
+ )
+
+ expect(result.current).toBeDefined()
+
+ rerender({ ...defaultProps, datasetId: 'ds-2' })
+
+ expect(result.current).toBeDefined()
+ })
+
+ it('should handle docDetail with all fields', () => {
+ const fullDocDetail: DocDetail = {
+ id: 'doc-1',
+ name: 'Full Document',
+ data_source_type: 'website',
+ word_count: 500,
+ language: 'zh',
+ hit_count: 100,
+ segment_count: 20,
+ }
+
+ const { result } = renderHook(() =>
+ useMetadataDocument({
+ ...defaultProps,
+ docDetail: fullDocDetail as Parameters[0]['docDetail'],
+ }),
+ )
+
+ // Language should be mapped
+ const languageField = result.current.originInfo.find(
+ item => item.name === 'Language',
+ )
+ if (languageField)
+ expect(languageField.value).toBe('Chinese')
+
+ // Hit count should be rendered
+ const hitCountField = result.current.technicalParameters.find(
+ item => item.name === 'Hit Count',
+ )
+ if (hitCountField)
+ expect(hitCountField.value).toBe('100/20')
+ })
+
+ it('should handle unknown language', () => {
+ const unknownLangDetail: DocDetail = {
+ id: 'doc-1',
+ name: 'Unknown Lang Document',
+ data_source_type: 'upload_file',
+ word_count: 100,
+ language: 'unknown_lang',
+ }
+
+ const { result } = renderHook(() =>
+ useMetadataDocument({
+ ...defaultProps,
+ docDetail: unknownLangDetail as Parameters[0]['docDetail'],
+ }),
+ )
+
+ // Unknown language should return undefined from the map
+ const languageField = result.current.originInfo.find(
+ item => item.name === 'Language',
+ )
+ // When language is not in map, it returns undefined
+ expect(languageField?.value).toBeUndefined()
+ })
+ })
+})
diff --git a/web/app/components/datasets/metadata/metadata-dataset/create-content.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/create-content.spec.tsx
new file mode 100644
index 0000000000..4931ab4ebc
--- /dev/null
+++ b/web/app/components/datasets/metadata/metadata-dataset/create-content.spec.tsx
@@ -0,0 +1,268 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { DataType } from '../types'
+import CreateContent from './create-content'
+
+type ModalLikeWrapProps = {
+ children: React.ReactNode
+ title: string
+ onClose?: () => void
+ onConfirm: () => void
+ beforeHeader?: React.ReactNode
+}
+
+type OptionCardProps = {
+ title: string
+ selected: boolean
+ onSelect: () => void
+}
+
+type FieldProps = {
+ label: string
+ children: React.ReactNode
+}
+
+// Mock ModalLikeWrap
+vi.mock('../../../base/modal-like-wrap', () => ({
+ default: ({ children, title, onClose, onConfirm, beforeHeader }: ModalLikeWrapProps) => (
+
+
{title}
+ {beforeHeader &&
{beforeHeader}
}
+
{children}
+
+
+
+ ),
+}))
+
+// Mock OptionCard
+vi.mock('../../../workflow/nodes/_base/components/option-card', () => ({
+ default: ({ title, selected, onSelect }: OptionCardProps) => (
+
+ ),
+}))
+
+// Mock Field
+vi.mock('./field', () => ({
+ default: ({ label, children }: FieldProps) => (
+
+
+ {children}
+
+ ),
+}))
+
+describe('CreateContent', () => {
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const handleSave = vi.fn()
+ render()
+ expect(screen.getByTestId('modal-wrap')).toBeInTheDocument()
+ })
+
+ it('should render modal title', () => {
+ const handleSave = vi.fn()
+ render()
+ expect(screen.getByTestId('modal-title')).toBeInTheDocument()
+ })
+
+ it('should render type selection options', () => {
+ const handleSave = vi.fn()
+ render()
+ expect(screen.getByTestId('option-string')).toBeInTheDocument()
+ expect(screen.getByTestId('option-number')).toBeInTheDocument()
+ expect(screen.getByTestId('option-time')).toBeInTheDocument()
+ })
+
+ it('should render name input field', () => {
+ const handleSave = vi.fn()
+ render()
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ })
+
+ it('should render confirm button', () => {
+ const handleSave = vi.fn()
+ render()
+ expect(screen.getByTestId('confirm-btn')).toBeInTheDocument()
+ })
+
+ it('should render close button', () => {
+ const handleSave = vi.fn()
+ render()
+ expect(screen.getByTestId('close-btn')).toBeInTheDocument()
+ })
+ })
+
+ describe('Type Selection', () => {
+ it('should default to string type', () => {
+ const handleSave = vi.fn()
+ render()
+ expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'true')
+ })
+
+ it('should select number type when clicked', () => {
+ const handleSave = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByTestId('option-number'))
+
+ expect(screen.getByTestId('option-number')).toHaveAttribute('data-selected', 'true')
+ })
+
+ it('should select time type when clicked', () => {
+ const handleSave = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByTestId('option-time'))
+
+ expect(screen.getByTestId('option-time')).toHaveAttribute('data-selected', 'true')
+ })
+
+ it('should deselect previous type when new type is selected', () => {
+ const handleSave = vi.fn()
+ render()
+
+ // Initially string is selected
+ expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'true')
+
+ // Select number
+ fireEvent.click(screen.getByTestId('option-number'))
+
+ expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'false')
+ expect(screen.getByTestId('option-number')).toHaveAttribute('data-selected', 'true')
+ })
+ })
+
+ describe('Name Input', () => {
+ it('should update name when typing', () => {
+ const handleSave = vi.fn()
+ render()
+
+ const input = screen.getByRole('textbox')
+ fireEvent.change(input, { target: { value: 'new_field' } })
+
+ expect(input).toHaveValue('new_field')
+ })
+
+ it('should start with empty name', () => {
+ const handleSave = vi.fn()
+ render()
+
+ expect(screen.getByRole('textbox')).toHaveValue('')
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onSave with type and name when confirmed', () => {
+ const handleSave = vi.fn()
+ render()
+
+ const input = screen.getByRole('textbox')
+ fireEvent.change(input, { target: { value: 'test_field' } })
+ fireEvent.click(screen.getByTestId('confirm-btn'))
+
+ expect(handleSave).toHaveBeenCalledWith({
+ type: DataType.string,
+ name: 'test_field',
+ })
+ })
+
+ it('should call onSave with correct type after changing type', () => {
+ const handleSave = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByTestId('option-number'))
+ const input = screen.getByRole('textbox')
+ fireEvent.change(input, { target: { value: 'num_field' } })
+ fireEvent.click(screen.getByTestId('confirm-btn'))
+
+ expect(handleSave).toHaveBeenCalledWith({
+ type: DataType.number,
+ name: 'num_field',
+ })
+ })
+
+ it('should call onClose when close button is clicked', () => {
+ const handleSave = vi.fn()
+ const handleClose = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByTestId('close-btn'))
+
+ expect(handleClose).toHaveBeenCalled()
+ })
+ })
+
+ describe('Back Button', () => {
+ it('should show back button when hasBack is true', () => {
+ const handleSave = vi.fn()
+ render()
+
+ expect(screen.getByTestId('before-header')).toBeInTheDocument()
+ })
+
+ it('should not show back button when hasBack is false', () => {
+ const handleSave = vi.fn()
+ render()
+
+ expect(screen.queryByTestId('before-header')).not.toBeInTheDocument()
+ })
+
+ it('should call onBack when back button is clicked', () => {
+ const handleSave = vi.fn()
+ const handleBack = vi.fn()
+ render()
+
+ const backButton = screen.getByTestId('before-header')
+ // Find the clickable element inside
+ const clickable = backButton.querySelector('.cursor-pointer') || backButton.firstChild
+ if (clickable)
+ fireEvent.click(clickable)
+
+ // The back functionality is tested through the actual implementation
+ expect(screen.getByTestId('before-header')).toBeInTheDocument()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty name submission', () => {
+ const handleSave = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByTestId('confirm-btn'))
+
+ expect(handleSave).toHaveBeenCalledWith({
+ type: DataType.string,
+ name: '',
+ })
+ })
+
+ it('should handle type cycling', () => {
+ const handleSave = vi.fn()
+ render()
+
+ // Cycle through all types
+ fireEvent.click(screen.getByTestId('option-number'))
+ fireEvent.click(screen.getByTestId('option-time'))
+ fireEvent.click(screen.getByTestId('option-string'))
+
+ expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'true')
+ })
+
+ it('should handle special characters in name', () => {
+ const handleSave = vi.fn()
+ render()
+
+ const input = screen.getByRole('textbox')
+ fireEvent.change(input, { target: { value: 'test_field_123' } })
+
+ expect(input).toHaveValue('test_field_123')
+ })
+ })
+})
diff --git a/web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.spec.tsx
new file mode 100644
index 0000000000..5e86521a87
--- /dev/null
+++ b/web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.spec.tsx
@@ -0,0 +1,246 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { DataType } from '../types'
+import CreateMetadataModal from './create-metadata-modal'
+
+type PortalProps = {
+ children: React.ReactNode
+ open: boolean
+}
+
+type TriggerProps = {
+ children: React.ReactNode
+ onClick: () => void
+}
+
+type ContentProps = {
+ children: React.ReactNode
+ className?: string
+}
+
+type CreateContentProps = {
+ onSave: (data: { type: DataType, name: string }) => void
+ onClose?: () => void
+ onBack?: () => void
+ hasBack?: boolean
+}
+
+// Mock PortalToFollowElem components
+vi.mock('../../../base/portal-to-follow-elem', () => ({
+ PortalToFollowElem: ({ children, open }: PortalProps) => (
+ {children}
+ ),
+ PortalToFollowElemTrigger: ({ children, onClick }: TriggerProps) => (
+ {children}
+ ),
+ PortalToFollowElemContent: ({ children, className }: ContentProps) => (
+ {children}
+ ),
+}))
+
+// Mock CreateContent component
+vi.mock('./create-content', () => ({
+ default: ({ onSave, onClose, onBack, hasBack }: CreateContentProps) => (
+
+ {String(hasBack)}
+
+
+ {hasBack && }
+
+ ),
+}))
+
+describe('CreateMetadataModal', () => {
+ const mockTrigger =
+
+ describe('Rendering', () => {
+ it('should render trigger when closed', () => {
+ render(
+ ,
+ )
+ // Portal wrapper should exist but closed
+ expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
+ expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'false')
+ })
+
+ it('should render content when open', () => {
+ render(
+ ,
+ )
+ expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
+ expect(screen.getByTestId('create-content')).toBeInTheDocument()
+ })
+
+ it('should render trigger element', () => {
+ render(
+ ,
+ )
+ expect(screen.getByTestId('trigger-button')).toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should pass hasBack to CreateContent', () => {
+ render(
+ ,
+ )
+ expect(screen.getByTestId('has-back')).toHaveTextContent('true')
+ })
+
+ it('should pass hasBack=undefined when not provided', () => {
+ render(
+ ,
+ )
+ expect(screen.getByTestId('has-back')).toHaveTextContent('undefined')
+ })
+
+ it('should accept custom popupLeft', () => {
+ render(
+ ,
+ )
+ expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should toggle open state when trigger is clicked', () => {
+ const setOpen = vi.fn()
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('portal-trigger'))
+
+ expect(setOpen).toHaveBeenCalledWith(true)
+ })
+
+ it('should call onSave when save button is clicked', () => {
+ const handleSave = vi.fn()
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('save-btn'))
+
+ expect(handleSave).toHaveBeenCalledWith({
+ type: DataType.string,
+ name: 'test',
+ })
+ })
+
+ it('should close modal when close button is clicked', () => {
+ const setOpen = vi.fn()
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('close-btn'))
+
+ expect(setOpen).toHaveBeenCalledWith(false)
+ })
+
+ it('should close modal when back button is clicked', () => {
+ const setOpen = vi.fn()
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('back-btn'))
+
+ expect(setOpen).toHaveBeenCalledWith(false)
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle switching open state', () => {
+ const { rerender } = render(
+ ,
+ )
+
+ expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'false')
+
+ rerender(
+ ,
+ )
+
+ expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'true')
+ })
+
+ it('should handle different trigger elements', () => {
+ const customTrigger = Custom
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.spec.tsx
new file mode 100644
index 0000000000..fc1f0d0990
--- /dev/null
+++ b/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.spec.tsx
@@ -0,0 +1,587 @@
+import type { BuiltInMetadataItem, MetadataItemWithValueLength } from '../types'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { DataType } from '../types'
+import DatasetMetadataDrawer from './dataset-metadata-drawer'
+
+// Mock service/API calls
+vi.mock('@/service/knowledge/use-metadata', () => ({
+ useDatasetMetaData: () => ({
+ data: {
+ doc_metadata: [
+ { id: '1', name: 'existing_field', type: DataType.string },
+ ],
+ },
+ }),
+}))
+
+// Mock check name hook
+vi.mock('../hooks/use-check-metadata-name', () => ({
+ default: () => ({
+ checkName: () => ({ errorMsg: '' }),
+ }),
+}))
+
+// Mock Toast
+const mockToastNotify = vi.fn()
+vi.mock('@/app/components/base/toast', () => ({
+ default: {
+ notify: (args: unknown) => mockToastNotify(args),
+ },
+}))
+
+// Type definitions for mock props
+type CreateModalProps = {
+ open: boolean
+ setOpen: (open: boolean) => void
+ trigger: React.ReactNode
+ onSave: (data: BuiltInMetadataItem) => void
+}
+
+// Mock CreateModal to expose callbacks
+vi.mock('@/app/components/datasets/metadata/metadata-dataset/create-metadata-modal', () => ({
+ default: ({ open, setOpen, trigger, onSave }: CreateModalProps) => (
+
+
setOpen(true)}>{trigger}
+ {open && (
+
+
+
+
+ )}
+
+ ),
+}))
+
+describe('DatasetMetadataDrawer', () => {
+ const mockUserMetadata: MetadataItemWithValueLength[] = [
+ { id: '1', name: 'field_one', type: DataType.string, count: 5 },
+ { id: '2', name: 'field_two', type: DataType.number, count: 3 },
+ ]
+
+ const mockBuiltInMetadata: BuiltInMetadataItem[] = [
+ { name: 'created_at', type: DataType.time },
+ { name: 'modified_at', type: DataType.time },
+ ]
+
+ const defaultProps = {
+ userMetadata: mockUserMetadata,
+ builtInMetadata: mockBuiltInMetadata,
+ isBuiltInEnabled: false,
+ onIsBuiltInEnabledChange: vi.fn(),
+ onClose: vi.fn(),
+ onAdd: vi.fn().mockResolvedValue({}),
+ onRename: vi.fn().mockResolvedValue({}),
+ onRemove: vi.fn().mockResolvedValue({}),
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', async () => {
+ render()
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+ })
+
+ it('should render user metadata items', async () => {
+ render()
+ await waitFor(() => {
+ expect(screen.getByText('field_one')).toBeInTheDocument()
+ expect(screen.getByText('field_two')).toBeInTheDocument()
+ })
+ })
+
+ it('should render built-in metadata items', async () => {
+ render()
+ await waitFor(() => {
+ expect(screen.getByText('created_at')).toBeInTheDocument()
+ expect(screen.getByText('modified_at')).toBeInTheDocument()
+ })
+ })
+
+ it('should render metadata type for each item', async () => {
+ render()
+ await waitFor(() => {
+ expect(screen.getAllByText(DataType.string).length).toBeGreaterThan(0)
+ expect(screen.getAllByText(DataType.number).length).toBeGreaterThan(0)
+ })
+ })
+
+ it('should render add metadata button', async () => {
+ render()
+ await waitFor(() => {
+ expect(screen.getByTestId('create-trigger')).toBeInTheDocument()
+ })
+ })
+
+ it('should render switch for built-in toggle', async () => {
+ render()
+ await waitFor(() => {
+ const switchBtn = screen.getByRole('switch')
+ expect(switchBtn).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onIsBuiltInEnabledChange when switch is toggled', async () => {
+ const onIsBuiltInEnabledChange = vi.fn()
+ render(
+ ,
+ )
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ const switchBtn = screen.getByRole('switch')
+ fireEvent.click(switchBtn)
+
+ expect(onIsBuiltInEnabledChange).toHaveBeenCalled()
+ })
+ })
+
+ describe('Add Metadata', () => {
+ it('should open create modal when add button is clicked', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ const trigger = screen.getByTestId('create-trigger')
+ fireEvent.click(trigger)
+
+ await waitFor(() => {
+ expect(screen.getByTestId('create-modal')).toBeInTheDocument()
+ })
+ })
+
+ it('should call onAdd and show success toast when metadata is added', async () => {
+ const onAdd = vi.fn().mockResolvedValue({})
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ // Open create modal
+ const trigger = screen.getByTestId('create-trigger')
+ fireEvent.click(trigger)
+
+ await waitFor(() => {
+ expect(screen.getByTestId('create-modal')).toBeInTheDocument()
+ })
+
+ // Save new metadata
+ fireEvent.click(screen.getByTestId('create-save'))
+
+ await waitFor(() => {
+ expect(onAdd).toHaveBeenCalled()
+ })
+
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'success',
+ }),
+ )
+ })
+ })
+
+ it('should close create modal after save', async () => {
+ const onAdd = vi.fn().mockResolvedValue({})
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ // Open create modal
+ fireEvent.click(screen.getByTestId('create-trigger'))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('create-modal')).toBeInTheDocument()
+ })
+
+ // Save
+ fireEvent.click(screen.getByTestId('create-save'))
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('create-modal')).not.toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Rename Metadata', () => {
+ it('should open rename modal when edit icon is clicked', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ // Find user metadata items with group/item class (these have edit/delete icons)
+ const dialog = screen.getByRole('dialog')
+ const items = dialog.querySelectorAll('.group\\/item')
+ expect(items.length).toBe(2) // 2 user metadata items
+
+ // Find the hidden container with edit/delete icons
+ const actionsContainer = items[0].querySelector('.hidden.items-center')
+ expect(actionsContainer).toBeTruthy()
+
+ // Find and click the first SVG (edit icon)
+ if (actionsContainer) {
+ const svgs = actionsContainer.querySelectorAll('svg')
+ expect(svgs.length).toBeGreaterThan(0)
+ fireEvent.click(svgs[0])
+ }
+
+ // Wait for rename modal (contains input)
+ await waitFor(() => {
+ const inputs = document.querySelectorAll('input')
+ expect(inputs.length).toBeGreaterThan(0)
+ })
+ })
+
+ it('should call onRename when rename is saved', async () => {
+ const onRename = vi.fn().mockResolvedValue({})
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ // Find and click edit icon
+ const dialog = screen.getByRole('dialog')
+ const items = dialog.querySelectorAll('.group\\/item')
+ const actionsContainer = items[0].querySelector('.hidden.items-center')
+ if (actionsContainer) {
+ const svgs = actionsContainer.querySelectorAll('svg')
+ fireEvent.click(svgs[0])
+ }
+
+ // Change name and save
+ await waitFor(() => {
+ const inputs = document.querySelectorAll('input')
+ expect(inputs.length).toBeGreaterThan(0)
+ })
+
+ const inputs = document.querySelectorAll('input')
+ fireEvent.change(inputs[0], { target: { value: 'renamed_field' } })
+
+ // Find and click save button
+ const saveBtns = screen.getAllByText(/save/i)
+ const primaryBtn = saveBtns.find(btn =>
+ btn.closest('button')?.classList.contains('btn-primary'),
+ )
+ if (primaryBtn)
+ fireEvent.click(primaryBtn)
+
+ await waitFor(() => {
+ expect(onRename).toHaveBeenCalled()
+ })
+
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'success',
+ }),
+ )
+ })
+ })
+
+ it('should close rename modal when cancel is clicked', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ // Find and click edit icon
+ const dialog = screen.getByRole('dialog')
+ const items = dialog.querySelectorAll('.group\\/item')
+ const actionsContainer = items[0].querySelector('.hidden.items-center')
+ if (actionsContainer) {
+ const svgs = actionsContainer.querySelectorAll('svg')
+ fireEvent.click(svgs[0])
+ }
+
+ // Wait for modal and click cancel
+ await waitFor(() => {
+ const inputs = document.querySelectorAll('input')
+ expect(inputs.length).toBeGreaterThan(0)
+ })
+
+ // Change name first
+ const inputs = document.querySelectorAll('input')
+ fireEvent.change(inputs[0], { target: { value: 'changed_name' } })
+
+ // Find and click cancel button
+ const cancelBtns = screen.getAllByText(/cancel/i)
+ const cancelBtn = cancelBtns.find(btn =>
+ !btn.closest('button')?.classList.contains('btn-primary'),
+ )
+ if (cancelBtn)
+ fireEvent.click(cancelBtn)
+
+ // Verify input resets or modal closes
+ await waitFor(() => {
+ const currentInputs = document.querySelectorAll('input')
+ // Either no inputs (modal closed) or value reset
+ expect(currentInputs.length === 0 || currentInputs[0].value !== 'changed_name').toBe(true)
+ })
+ })
+
+ it('should close rename modal when modal close button is clicked', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ // Find and click edit icon
+ const dialog = screen.getByRole('dialog')
+ const items = dialog.querySelectorAll('.group\\/item')
+ const actionsContainer = items[0].querySelector('.hidden.items-center')
+ if (actionsContainer) {
+ const svgs = actionsContainer.querySelectorAll('svg')
+ fireEvent.click(svgs[0])
+ }
+
+ // Wait for rename modal
+ await waitFor(() => {
+ const inputs = document.querySelectorAll('input')
+ expect(inputs.length).toBeGreaterThan(0)
+ })
+
+ // Find and click the modal close button (X button)
+ // The Modal component has a close button in the header
+ const dialogs = screen.getAllByRole('dialog')
+ const renameModal = dialogs.find(d => d.querySelector('input'))
+ if (renameModal) {
+ // Find close button by looking for a button with close-related class or X icon
+ const closeButtons = renameModal.querySelectorAll('button')
+ for (const btn of Array.from(closeButtons)) {
+ // Skip cancel/save buttons
+ if (!btn.textContent?.toLowerCase().includes('cancel')
+ && !btn.textContent?.toLowerCase().includes('save')
+ && btn.querySelector('svg')) {
+ fireEvent.click(btn)
+ break
+ }
+ }
+ }
+ })
+ })
+
+ describe('Delete Metadata', () => {
+ it('should show confirm dialog when delete icon is clicked', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ // Find user metadata items
+ const dialog = screen.getByRole('dialog')
+ const items = dialog.querySelectorAll('.group\\/item')
+
+ // Find the delete container
+ const deleteContainer = items[0].querySelector('.hover\\:text-text-destructive')
+ expect(deleteContainer).toBeTruthy()
+
+ // Click delete icon
+ if (deleteContainer) {
+ const deleteIcon = deleteContainer.querySelector('svg')
+ if (deleteIcon)
+ fireEvent.click(deleteIcon)
+ }
+
+ // Confirm dialog should appear
+ await waitFor(() => {
+ const confirmBtns = screen.getAllByRole('button')
+ const hasConfirmBtn = confirmBtns.some(btn =>
+ btn.textContent?.toLowerCase().includes('confirm'),
+ )
+ expect(hasConfirmBtn).toBe(true)
+ })
+ })
+
+ it('should call onRemove when delete is confirmed', async () => {
+ const onRemove = vi.fn().mockResolvedValue({})
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ // Find and click delete icon
+ const dialog = screen.getByRole('dialog')
+ const items = dialog.querySelectorAll('.group\\/item')
+ const deleteContainer = items[0].querySelector('.hover\\:text-text-destructive')
+ if (deleteContainer) {
+ const deleteIcon = deleteContainer.querySelector('svg')
+ if (deleteIcon)
+ fireEvent.click(deleteIcon)
+ }
+
+ // Wait for confirm dialog
+ await waitFor(() => {
+ const confirmBtns = screen.getAllByRole('button')
+ const hasConfirmBtn = confirmBtns.some(btn =>
+ btn.textContent?.toLowerCase().includes('confirm'),
+ )
+ expect(hasConfirmBtn).toBe(true)
+ })
+
+ // Click confirm
+ const confirmBtns = screen.getAllByRole('button')
+ const confirmBtn = confirmBtns.find(btn =>
+ btn.textContent?.toLowerCase().includes('confirm'),
+ )
+ if (confirmBtn)
+ fireEvent.click(confirmBtn)
+
+ await waitFor(() => {
+ expect(onRemove).toHaveBeenCalledWith('1')
+ })
+
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'success',
+ }),
+ )
+ })
+ })
+
+ it('should close confirm dialog when cancel is clicked', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ // Find and click delete icon
+ const dialog = screen.getByRole('dialog')
+ const items = dialog.querySelectorAll('.group\\/item')
+ const deleteContainer = items[0].querySelector('.hover\\:text-text-destructive')
+ if (deleteContainer) {
+ const deleteIcon = deleteContainer.querySelector('svg')
+ if (deleteIcon)
+ fireEvent.click(deleteIcon)
+ }
+
+ // Wait for confirm dialog
+ await waitFor(() => {
+ const confirmBtns = screen.getAllByRole('button')
+ const hasConfirmBtn = confirmBtns.some(btn =>
+ btn.textContent?.toLowerCase().includes('confirm'),
+ )
+ expect(hasConfirmBtn).toBe(true)
+ })
+
+ // Click cancel
+ const cancelBtns = screen.getAllByRole('button')
+ const cancelBtn = cancelBtns.find(btn =>
+ btn.textContent?.toLowerCase().includes('cancel'),
+ )
+ if (cancelBtn)
+ fireEvent.click(cancelBtn)
+ })
+ })
+
+ describe('Props', () => {
+ it('should handle empty userMetadata', async () => {
+ render()
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+ })
+
+ it('should handle empty builtInMetadata', async () => {
+ render()
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Built-in Items State', () => {
+ it('should show disabled styling when built-in is disabled', async () => {
+ render(
+ ,
+ )
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ const dialog = screen.getByRole('dialog')
+ const disabledItems = dialog.querySelectorAll('.opacity-30')
+ expect(disabledItems.length).toBeGreaterThan(0)
+ })
+
+ it('should not show disabled styling when built-in is enabled', async () => {
+ render(
+ ,
+ )
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle items with special characters in name', async () => {
+ const specialMetadata: MetadataItemWithValueLength[] = [
+ { id: '1', name: 'field_with_underscore', type: DataType.string, count: 1 },
+ ]
+ render()
+ await waitFor(() => {
+ expect(screen.getByText('field_with_underscore')).toBeInTheDocument()
+ })
+ })
+
+ it('should handle single user metadata item', async () => {
+ const singleMetadata: MetadataItemWithValueLength[] = [
+ { id: '1', name: 'only_field', type: DataType.string, count: 10 },
+ ]
+ render()
+ await waitFor(() => {
+ expect(screen.getByText('only_field')).toBeInTheDocument()
+ })
+ })
+
+ it('should handle single built-in metadata item', async () => {
+ const singleBuiltIn: BuiltInMetadataItem[] = [
+ { name: 'created_at', type: DataType.time },
+ ]
+ render()
+ await waitFor(() => {
+ expect(screen.getByText('created_at')).toBeInTheDocument()
+ })
+ })
+
+ it('should handle metadata with zero count', async () => {
+ const zeroCountMetadata: MetadataItemWithValueLength[] = [
+ { id: '1', name: 'empty_field', type: DataType.string, count: 0 },
+ ]
+ render()
+ await waitFor(() => {
+ expect(screen.getByText('empty_field')).toBeInTheDocument()
+ })
+ })
+ })
+})
diff --git a/web/app/components/datasets/metadata/metadata-dataset/field.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/field.spec.tsx
new file mode 100644
index 0000000000..e3a34f9d98
--- /dev/null
+++ b/web/app/components/datasets/metadata/metadata-dataset/field.spec.tsx
@@ -0,0 +1,122 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import Field from './field'
+
+describe('Field', () => {
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render(Content)
+ expect(screen.getByText('Test Label')).toBeInTheDocument()
+ expect(screen.getByText('Content')).toBeInTheDocument()
+ })
+
+ it('should render label with correct styling', () => {
+ render(Content)
+ const labelElement = screen.getByText('My Label')
+ expect(labelElement).toHaveClass('system-sm-semibold', 'py-1', 'text-text-secondary')
+ })
+
+ it('should render children in content container', () => {
+ const { container } = render(Child Content)
+ // The children wrapper has mt-1 class
+ const contentWrapper = container.querySelector('.mt-1')
+ expect(contentWrapper).toBeInTheDocument()
+ expect(contentWrapper).toHaveTextContent('Child Content')
+ })
+ })
+
+ describe('Props', () => {
+ it('should apply custom className', () => {
+ const { container } = render(Content)
+ expect(container.firstChild).toHaveClass('custom-class')
+ })
+
+ it('should render with string children', () => {
+ render(Simple Text)
+ expect(screen.getByText('Simple Text')).toBeInTheDocument()
+ })
+
+ it('should render with complex children', () => {
+ render(
+
+
+ Nested Content
+
+ ,
+ )
+ expect(screen.getByTestId('complex-child')).toBeInTheDocument()
+ expect(screen.getByText('Nested Content')).toBeInTheDocument()
+ })
+
+ it('should render with multiple children', () => {
+ render(
+
+ First
+ Second
+ ,
+ )
+ expect(screen.getByText('First')).toBeInTheDocument()
+ expect(screen.getByText('Second')).toBeInTheDocument()
+ })
+
+ it('should render different labels correctly', () => {
+ const { rerender } = render(Content)
+ expect(screen.getByText('First Label')).toBeInTheDocument()
+
+ rerender(Content)
+ expect(screen.getByText('Second Label')).toBeInTheDocument()
+ })
+ })
+
+ describe('Layout', () => {
+ it('should have label above content', () => {
+ const { container } = render(Content)
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper?.children).toHaveLength(2)
+ })
+
+ it('should render label element first', () => {
+ const { container } = render(Content)
+ const wrapper = container.firstChild as HTMLElement
+ const firstChild = wrapper?.firstChild as HTMLElement
+ expect(firstChild).toHaveClass('system-sm-semibold')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should render with undefined className', () => {
+ render(Content)
+ expect(screen.getByText('Content')).toBeInTheDocument()
+ })
+
+ it('should render with empty className', () => {
+ render(Content)
+ expect(screen.getByText('Content')).toBeInTheDocument()
+ })
+
+ it('should render with empty label', () => {
+ render(Content)
+ expect(screen.getByText('Content')).toBeInTheDocument()
+ })
+
+ it('should render with empty children', () => {
+ const { container } = render()
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render with null children', () => {
+ const { container } = render({null})
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render with number as children', () => {
+ render({42})
+ expect(screen.getByText('42')).toBeInTheDocument()
+ })
+
+ it('should handle special characters in label', () => {
+ render(Content)
+ expect(screen.getByText('Label & "chars"')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.spec.tsx
new file mode 100644
index 0000000000..6e565c0b07
--- /dev/null
+++ b/web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.spec.tsx
@@ -0,0 +1,348 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { DataType } from '../types'
+import SelectMetadataModal from './select-metadata-modal'
+
+type MetadataItem = {
+ id: string
+ name: string
+ type: DataType
+}
+
+type PortalProps = {
+ children: React.ReactNode
+ open: boolean
+}
+
+type TriggerProps = {
+ children: React.ReactNode
+ onClick: () => void
+}
+
+type ContentProps = {
+ children: React.ReactNode
+}
+
+type SelectMetadataProps = {
+ onSelect: (item: MetadataItem) => void
+ onNew: () => void
+ onManage: () => void
+ list: MetadataItem[]
+}
+
+type CreateContentProps = {
+ onSave: (data: { type: DataType, name: string }) => void
+ onBack?: () => void
+ onClose?: () => void
+ hasBack?: boolean
+}
+
+// Mock useDatasetMetaData hook
+vi.mock('@/service/knowledge/use-metadata', () => ({
+ useDatasetMetaData: () => ({
+ data: {
+ doc_metadata: [
+ { id: '1', name: 'field_one', type: DataType.string },
+ { id: '2', name: 'field_two', type: DataType.number },
+ ],
+ },
+ }),
+}))
+
+// Mock PortalToFollowElem components
+vi.mock('../../../base/portal-to-follow-elem', () => ({
+ PortalToFollowElem: ({ children, open }: PortalProps) => (
+ {children}
+ ),
+ PortalToFollowElemTrigger: ({ children, onClick }: TriggerProps) => (
+ {children}
+ ),
+ PortalToFollowElemContent: ({ children }: ContentProps) => (
+ {children}
+ ),
+}))
+
+// Mock SelectMetadata component
+vi.mock('./select-metadata', () => ({
+ default: ({ onSelect, onNew, onManage, list }: SelectMetadataProps) => (
+
+ {list?.length || 0}
+
+
+
+
+ ),
+}))
+
+// Mock CreateContent component
+vi.mock('./create-content', () => ({
+ default: ({ onSave, onBack, onClose, hasBack }: CreateContentProps) => (
+
+
+ {hasBack && }
+
+
+ ),
+}))
+
+describe('SelectMetadataModal', () => {
+ const mockTrigger =
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render(
+ ,
+ )
+ expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
+ })
+
+ it('should render trigger element', () => {
+ render(
+ ,
+ )
+ expect(screen.getByTestId('trigger-button')).toBeInTheDocument()
+ })
+
+ it('should render SelectMetadata by default', () => {
+ render(
+ ,
+ )
+ expect(screen.getByTestId('select-metadata')).toBeInTheDocument()
+ })
+
+ it('should pass dataset metadata to SelectMetadata', () => {
+ render(
+ ,
+ )
+ expect(screen.getByTestId('list-count')).toHaveTextContent('2')
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should toggle open state when trigger is clicked', () => {
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('portal-trigger'))
+
+ // State should toggle
+ expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
+ })
+
+ it('should call onSelect and close when item is selected', () => {
+ const handleSelect = vi.fn()
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('select-item'))
+
+ expect(handleSelect).toHaveBeenCalledWith({
+ id: '1',
+ name: 'field_one',
+ type: DataType.string,
+ })
+ })
+
+ it('should switch to create step when new button is clicked', async () => {
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('new-btn'))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('create-content')).toBeInTheDocument()
+ })
+ })
+
+ it('should call onManage when manage button is clicked', () => {
+ const handleManage = vi.fn()
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('manage-btn'))
+
+ expect(handleManage).toHaveBeenCalled()
+ })
+ })
+
+ describe('Create Flow', () => {
+ it('should switch back to select when back is clicked in create step', async () => {
+ render(
+ ,
+ )
+
+ // Go to create step
+ fireEvent.click(screen.getByTestId('new-btn'))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('create-content')).toBeInTheDocument()
+ })
+
+ // Go back to select step
+ fireEvent.click(screen.getByTestId('back-btn'))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('select-metadata')).toBeInTheDocument()
+ })
+ })
+
+ it('should call onSave and return to select step when save is clicked', async () => {
+ const handleSave = vi.fn().mockResolvedValue(undefined)
+ render(
+ ,
+ )
+
+ // Go to create step
+ fireEvent.click(screen.getByTestId('new-btn'))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('create-content')).toBeInTheDocument()
+ })
+
+ // Save new metadata
+ fireEvent.click(screen.getByTestId('save-btn'))
+
+ await waitFor(() => {
+ expect(handleSave).toHaveBeenCalledWith({
+ type: DataType.string,
+ name: 'new_field',
+ })
+ })
+ })
+ })
+
+ describe('Props', () => {
+ it('should accept custom popupPlacement', () => {
+ render(
+ ,
+ )
+ expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
+ })
+
+ it('should accept custom popupOffset', () => {
+ render(
+ ,
+ )
+ expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle different datasetIds', () => {
+ const { rerender } = render(
+ ,
+ )
+
+ expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
+
+ rerender(
+ ,
+ )
+
+ expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
+ })
+
+ it('should handle empty trigger', () => {
+ render(
+ }
+ onSelect={vi.fn()}
+ onSave={vi.fn()}
+ onManage={vi.fn()}
+ />,
+ )
+ expect(screen.getByTestId('empty-trigger')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/metadata/metadata-dataset/select-metadata.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/select-metadata.spec.tsx
new file mode 100644
index 0000000000..2602fd145f
--- /dev/null
+++ b/web/app/components/datasets/metadata/metadata-dataset/select-metadata.spec.tsx
@@ -0,0 +1,332 @@
+import type { MetadataItem } from '../types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { DataType } from '../types'
+import SelectMetadata from './select-metadata'
+
+type IconProps = {
+ className?: string
+}
+
+// Mock getIcon utility
+vi.mock('../utils/get-icon', () => ({
+ getIcon: () => (props: IconProps) => Icon,
+}))
+
+describe('SelectMetadata', () => {
+ const mockList: MetadataItem[] = [
+ { id: '1', name: 'field_one', type: DataType.string },
+ { id: '2', name: 'field_two', type: DataType.number },
+ { id: '3', name: 'field_three', type: DataType.time },
+ ]
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const { container } = render(
+ ,
+ )
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render search input', () => {
+ render(
+ ,
+ )
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ })
+
+ it('should render all metadata items', () => {
+ render(
+ ,
+ )
+ expect(screen.getByText('field_one')).toBeInTheDocument()
+ expect(screen.getByText('field_two')).toBeInTheDocument()
+ expect(screen.getByText('field_three')).toBeInTheDocument()
+ })
+
+ it('should render new action button', () => {
+ render(
+ ,
+ )
+ // New action button should be present (from i18n)
+ expect(screen.getByText(/new/i)).toBeInTheDocument()
+ })
+
+ it('should render manage action button', () => {
+ render(
+ ,
+ )
+ // Manage action button should be present (from i18n)
+ expect(screen.getByText(/manage/i)).toBeInTheDocument()
+ })
+
+ it('should display type for each item', () => {
+ render(
+ ,
+ )
+ expect(screen.getAllByText(DataType.string).length).toBeGreaterThan(0)
+ expect(screen.getAllByText(DataType.number).length).toBeGreaterThan(0)
+ expect(screen.getAllByText(DataType.time).length).toBeGreaterThan(0)
+ })
+ })
+
+ describe('Search Functionality', () => {
+ it('should filter items based on search query', () => {
+ render(
+ ,
+ )
+
+ const searchInput = screen.getByRole('textbox')
+ fireEvent.change(searchInput, { target: { value: 'one' } })
+
+ expect(screen.getByText('field_one')).toBeInTheDocument()
+ expect(screen.queryByText('field_two')).not.toBeInTheDocument()
+ expect(screen.queryByText('field_three')).not.toBeInTheDocument()
+ })
+
+ it('should be case insensitive search', () => {
+ render(
+ ,
+ )
+
+ const searchInput = screen.getByRole('textbox')
+ fireEvent.change(searchInput, { target: { value: 'ONE' } })
+
+ expect(screen.getByText('field_one')).toBeInTheDocument()
+ })
+
+ it('should show all items when search is cleared', () => {
+ render(
+ ,
+ )
+
+ const searchInput = screen.getByRole('textbox')
+
+ // Search for something
+ fireEvent.change(searchInput, { target: { value: 'one' } })
+ expect(screen.queryByText('field_two')).not.toBeInTheDocument()
+
+ // Clear search
+ fireEvent.change(searchInput, { target: { value: '' } })
+ expect(screen.getByText('field_two')).toBeInTheDocument()
+ })
+
+ it('should show no results when search matches nothing', () => {
+ render(
+ ,
+ )
+
+ const searchInput = screen.getByRole('textbox')
+ fireEvent.change(searchInput, { target: { value: 'xyz' } })
+
+ expect(screen.queryByText('field_one')).not.toBeInTheDocument()
+ expect(screen.queryByText('field_two')).not.toBeInTheDocument()
+ expect(screen.queryByText('field_three')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onSelect with item data when item is clicked', () => {
+ const handleSelect = vi.fn()
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByText('field_one'))
+
+ expect(handleSelect).toHaveBeenCalledWith({
+ id: '1',
+ name: 'field_one',
+ type: DataType.string,
+ })
+ })
+
+ it('should call onNew when new button is clicked', () => {
+ const handleNew = vi.fn()
+ render(
+ ,
+ )
+
+ // Find and click the new action button
+ const newButton = screen.getByText(/new/i)
+ fireEvent.click(newButton.closest('div') || newButton)
+
+ expect(handleNew).toHaveBeenCalled()
+ })
+
+ it('should call onManage when manage button is clicked', () => {
+ const handleManage = vi.fn()
+ render(
+ ,
+ )
+
+ // Find and click the manage action button
+ const manageButton = screen.getByText(/manage/i)
+ fireEvent.click(manageButton.closest('div') || manageButton)
+
+ expect(handleManage).toHaveBeenCalled()
+ })
+ })
+
+ describe('Empty State', () => {
+ it('should render empty list', () => {
+ const { container } = render(
+ ,
+ )
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should still show new and manage buttons with empty list', () => {
+ render(
+ ,
+ )
+ expect(screen.getByText(/new/i)).toBeInTheDocument()
+ expect(screen.getByText(/manage/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('Styling', () => {
+ it('should have correct container styling', () => {
+ const { container } = render(
+ ,
+ )
+ expect(container.firstChild).toHaveClass('w-[320px]', 'rounded-xl')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle single item list', () => {
+ const singleItem: MetadataItem[] = [
+ { id: '1', name: 'only_one', type: DataType.string },
+ ]
+ render(
+ ,
+ )
+ expect(screen.getByText('only_one')).toBeInTheDocument()
+ })
+
+ it('should handle item with long name', () => {
+ const longNameItem: MetadataItem[] = [
+ { id: '1', name: 'this_is_a_very_long_field_name_that_might_overflow', type: DataType.string },
+ ]
+ render(
+ ,
+ )
+ expect(screen.getByText('this_is_a_very_long_field_name_that_might_overflow')).toBeInTheDocument()
+ })
+
+ it('should handle rapid search input changes', () => {
+ render(
+ ,
+ )
+
+ const searchInput = screen.getByRole('textbox')
+
+ // Rapid typing
+ fireEvent.change(searchInput, { target: { value: 'f' } })
+ fireEvent.change(searchInput, { target: { value: 'fi' } })
+ fireEvent.change(searchInput, { target: { value: 'fie' } })
+ fireEvent.change(searchInput, { target: { value: 'fiel' } })
+ fireEvent.change(searchInput, { target: { value: 'field' } })
+
+ expect(screen.getByText('field_one')).toBeInTheDocument()
+ expect(screen.getByText('field_two')).toBeInTheDocument()
+ expect(screen.getByText('field_three')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/metadata/metadata-document/field.spec.tsx b/web/app/components/datasets/metadata/metadata-document/field.spec.tsx
new file mode 100644
index 0000000000..50aad1a6cc
--- /dev/null
+++ b/web/app/components/datasets/metadata/metadata-document/field.spec.tsx
@@ -0,0 +1,113 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import Field from './field'
+
+describe('Field', () => {
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render(Content)
+ expect(screen.getByText('Test Label')).toBeInTheDocument()
+ expect(screen.getByText('Content')).toBeInTheDocument()
+ })
+
+ it('should render label with correct styling', () => {
+ render(Content)
+ const labelElement = screen.getByText('My Label')
+ expect(labelElement).toHaveClass('system-xs-medium', 'w-[128px]', 'shrink-0', 'truncate', 'py-1', 'text-text-tertiary')
+ })
+
+ it('should render children in correct container', () => {
+ const { container } = render(Child Content)
+ // The children are wrapped in a div with w-[244px] class
+ const contentWrapper = container.querySelector('.w-\\[244px\\]')
+ expect(contentWrapper).toBeInTheDocument()
+ expect(contentWrapper).toHaveClass('shrink-0')
+ })
+ })
+
+ describe('Props', () => {
+ it('should render with string children', () => {
+ render(Simple Text)
+ expect(screen.getByText('Simple Text')).toBeInTheDocument()
+ })
+
+ it('should render with complex children', () => {
+ render(
+
+
+ Nested Content
+
+ ,
+ )
+ expect(screen.getByTestId('complex-child')).toBeInTheDocument()
+ expect(screen.getByText('Nested Content')).toBeInTheDocument()
+ })
+
+ it('should render with multiple children', () => {
+ render(
+
+ First
+ Second
+ ,
+ )
+ expect(screen.getByText('First')).toBeInTheDocument()
+ expect(screen.getByText('Second')).toBeInTheDocument()
+ })
+
+ it('should render different labels correctly', () => {
+ const { rerender } = render(Content)
+ expect(screen.getByText('First Label')).toBeInTheDocument()
+
+ rerender(Content)
+ expect(screen.getByText('Second Label')).toBeInTheDocument()
+ })
+ })
+
+ describe('Layout', () => {
+ it('should have flex layout with space between elements', () => {
+ const { container } = render(Content)
+ const wrapper = container.firstChild
+ expect(wrapper).toHaveClass('flex', 'items-start', 'space-x-2')
+ })
+
+ it('should render label and content side by side', () => {
+ const { container } = render(Content)
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper?.children).toHaveLength(2)
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should render with empty label', () => {
+ render(Content)
+ expect(screen.getByText('Content')).toBeInTheDocument()
+ })
+
+ it('should render with long label (truncation)', () => {
+ const longLabel = 'This is a very long label that should be truncated'
+ render(Content)
+ const labelElement = screen.getByText(longLabel)
+ expect(labelElement).toHaveClass('truncate')
+ })
+
+ it('should render with empty children', () => {
+ const { container } = render()
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render with null children', () => {
+ const { container } = render({null})
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render with number as children', () => {
+ render({42})
+ expect(screen.getByText('42')).toBeInTheDocument()
+ })
+
+ it('should handle special characters in label', () => {
+ render(Content)
+ expect(screen.getByText('Label & "chars"')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/metadata/metadata-document/index.spec.tsx b/web/app/components/datasets/metadata/metadata-document/index.spec.tsx
new file mode 100644
index 0000000000..f80b6ca010
--- /dev/null
+++ b/web/app/components/datasets/metadata/metadata-document/index.spec.tsx
@@ -0,0 +1,752 @@
+import type { MetadataItemWithValue } from '../types'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { DataType } from '../types'
+import MetadataDocument from './index'
+
+type MockHookReturn = {
+ embeddingAvailable: boolean
+ isEdit: boolean
+ setIsEdit: ReturnType
+ list: MetadataItemWithValue[]
+ tempList: MetadataItemWithValue[]
+ setTempList: ReturnType
+ handleSelectMetaData: ReturnType
+ handleAddMetaData: ReturnType
+ hasData: boolean
+ builtList: MetadataItemWithValue[]
+ builtInEnabled: boolean
+ startToEdit: ReturnType
+ handleSave: ReturnType
+ handleCancel: ReturnType
+ originInfo: MetadataItemWithValue[]
+ technicalParameters: MetadataItemWithValue[]
+}
+
+// Mock useMetadataDocument hook - need to control state
+const mockUseMetadataDocument = vi.fn<() => MockHookReturn>()
+vi.mock('../hooks/use-metadata-document', () => ({
+ default: () => mockUseMetadataDocument(),
+}))
+
+// Mock service calls
+vi.mock('@/service/knowledge/use-metadata', () => ({
+ useDatasetMetaData: () => ({
+ data: {
+ doc_metadata: [],
+ },
+ }),
+}))
+
+// Mock check name hook
+vi.mock('../hooks/use-check-metadata-name', () => ({
+ default: () => ({
+ checkName: () => ({ errorMsg: '' }),
+ }),
+}))
+
+// Mock next/navigation
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({
+ push: vi.fn(),
+ }),
+}))
+
+describe('MetadataDocument', () => {
+ const mockDocDetail = {
+ id: 'doc-1',
+ name: 'Test Document',
+ data_source_type: 'upload_file',
+ indexing_status: 'completed',
+ created_at: 1609459200,
+ word_count: 100,
+ }
+
+ const mockList: MetadataItemWithValue[] = [
+ { id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
+ { id: '2', name: 'field_two', type: DataType.number, value: 42 },
+ ]
+
+ const defaultHookReturn: MockHookReturn = {
+ embeddingAvailable: true,
+ isEdit: false,
+ setIsEdit: vi.fn(),
+ list: mockList,
+ tempList: mockList,
+ setTempList: vi.fn(),
+ handleSelectMetaData: vi.fn(),
+ handleAddMetaData: vi.fn(),
+ hasData: true,
+ builtList: [],
+ builtInEnabled: false,
+ startToEdit: vi.fn(),
+ handleSave: vi.fn(),
+ handleCancel: vi.fn(),
+ originInfo: [],
+ technicalParameters: [],
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseMetadataDocument.mockReturnValue(defaultHookReturn)
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const { container } = render(
+ [0]['docDetail']}
+ />,
+ )
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render metadata fields when hasData is true', () => {
+ render(
+ [0]['docDetail']}
+ />,
+ )
+ expect(screen.getByText('field_one')).toBeInTheDocument()
+ expect(screen.getByText('field_two')).toBeInTheDocument()
+ })
+
+ it('should render no-data state when hasData is false and not in edit mode', () => {
+ mockUseMetadataDocument.mockReturnValue({
+ ...defaultHookReturn,
+ hasData: false,
+ list: [],
+ tempList: [],
+ isEdit: false,
+ })
+
+ render(
+ [0]['docDetail']}
+ />,
+ )
+ expect(screen.getAllByText(/metadata/i).length).toBeGreaterThan(0)
+ })
+
+ it('should render edit UI when in edit mode', () => {
+ mockUseMetadataDocument.mockReturnValue({
+ ...defaultHookReturn,
+ isEdit: true,
+ })
+
+ render(
+ [0]['docDetail']}
+ />,
+ )
+
+ expect(screen.getByText(/save/i)).toBeInTheDocument()
+ expect(screen.getByText(/cancel/i)).toBeInTheDocument()
+ })
+
+ it('should render built-in section when builtInEnabled is true', () => {
+ mockUseMetadataDocument.mockReturnValue({
+ ...defaultHookReturn,
+ builtInEnabled: true,
+ builtList: [{ id: 'built-in', name: 'created_at', type: DataType.time, value: 1609459200 }],
+ })
+
+ render(
+ [0]['docDetail']}
+ />,
+ )
+
+ expect(screen.getByText('created_at')).toBeInTheDocument()
+ })
+
+ it('should render divider when builtInEnabled is true', () => {
+ mockUseMetadataDocument.mockReturnValue({
+ ...defaultHookReturn,
+ builtInEnabled: true,
+ builtList: [{ id: 'built-in', name: 'created_at', type: DataType.time, value: 1609459200 }],
+ })
+
+ const { container } = render(
+ [0]['docDetail']}
+ />,
+ )
+
+ const divider = container.querySelector('[class*="bg-gradient"]')
+ expect(divider).toBeInTheDocument()
+ })
+
+ it('should render origin info section', () => {
+ mockUseMetadataDocument.mockReturnValue({
+ ...defaultHookReturn,
+ originInfo: [{ id: 'origin-1', name: 'source', type: DataType.string, value: 'upload' }],
+ })
+
+ render(
+ [0]['docDetail']}
+ />,
+ )
+
+ expect(screen.getByText('source')).toBeInTheDocument()
+ })
+
+ it('should render technical parameters section', () => {
+ mockUseMetadataDocument.mockReturnValue({
+ ...defaultHookReturn,
+ technicalParameters: [{ id: 'tech-1', name: 'word_count', type: DataType.number, value: 100 }],
+ })
+
+ render(
+ [0]['docDetail']}
+ />,
+ )
+
+ expect(screen.getByText('word_count')).toBeInTheDocument()
+ })
+
+ it('should render all sections together', () => {
+ mockUseMetadataDocument.mockReturnValue({
+ ...defaultHookReturn,
+ builtInEnabled: true,
+ builtList: [{ id: 'built-1', name: 'created_at', type: DataType.time, value: 1609459200 }],
+ originInfo: [{ id: 'origin-1', name: 'source', type: DataType.string, value: 'upload' }],
+ technicalParameters: [{ id: 'tech-1', name: 'word_count', type: DataType.number, value: 100 }],
+ })
+
+ render(
+ [0]['docDetail']}
+ />,
+ )
+
+ expect(screen.getByText('field_one')).toBeInTheDocument()
+ expect(screen.getByText('created_at')).toBeInTheDocument()
+ expect(screen.getByText('source')).toBeInTheDocument()
+ expect(screen.getByText('word_count')).toBeInTheDocument()
+ })
+ })
+
+ describe('Edit Mode', () => {
+ it('should show edit button when not in edit mode and embedding available', () => {
+ render(
+ [0]['docDetail']}
+ />,
+ )
+ expect(screen.getByText(/edit/i)).toBeInTheDocument()
+ })
+
+ it('should call startToEdit when edit button is clicked', () => {
+ const startToEdit = vi.fn()
+ mockUseMetadataDocument.mockReturnValue({
+ ...defaultHookReturn,
+ isEdit: false,
+ startToEdit,
+ })
+
+ render(
+ [0]['docDetail']}
+ />,
+ )
+
+ fireEvent.click(screen.getByText(/edit/i))
+ expect(startToEdit).toHaveBeenCalled()
+ })
+
+ it('should call handleSave when save button is clicked', () => {
+ const handleSave = vi.fn()
+ mockUseMetadataDocument.mockReturnValue({
+ ...defaultHookReturn,
+ isEdit: true,
+ handleSave,
+ })
+
+ render(
+ [0]['docDetail']}
+ />,
+ )
+
+ fireEvent.click(screen.getByText(/save/i))
+ expect(handleSave).toHaveBeenCalled()
+ })
+
+ it('should call handleCancel when cancel button is clicked', () => {
+ const handleCancel = vi.fn()
+ mockUseMetadataDocument.mockReturnValue({
+ ...defaultHookReturn,
+ isEdit: true,
+ handleCancel,
+ })
+
+ render(
+ [0]['docDetail']}
+ />,
+ )
+
+ fireEvent.click(screen.getByText(/cancel/i))
+ expect(handleCancel).toHaveBeenCalled()
+ })
+
+ it('should call setIsEdit(true) when start button is clicked in no-data state', () => {
+ const setIsEdit = vi.fn()
+ mockUseMetadataDocument.mockReturnValue({
+ ...defaultHookReturn,
+ hasData: false,
+ list: [],
+ tempList: [],
+ isEdit: false,
+ setIsEdit,
+ })
+
+ render(
+ [0]['docDetail']}
+ />,
+ )
+
+ const startBtn = screen.queryByText(/start/i)
+ if (startBtn) {
+ fireEvent.click(startBtn)
+ expect(setIsEdit).toHaveBeenCalledWith(true)
+ }
+ })
+
+ it('should show InfoGroup when in edit mode without data', () => {
+ mockUseMetadataDocument.mockReturnValue({
+ ...defaultHookReturn,
+ hasData: false,
+ list: [],
+ tempList: [],
+ isEdit: true,
+ })
+
+ render(
+ [0]['docDetail']}
+ />,
+ )
+
+ // Should show save/cancel buttons
+ expect(screen.getByText(/save/i)).toBeInTheDocument()
+ expect(screen.getByText(/cancel/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('Data Operations', () => {
+ it('should call setTempList when field value changes', async () => {
+ const setTempList = vi.fn()
+ mockUseMetadataDocument.mockReturnValue({
+ ...defaultHookReturn,
+ isEdit: true,
+ setTempList,
+ })
+
+ const { container } = render(
+ [0]['docDetail']}
+ />,
+ )
+
+ const inputs = container.querySelectorAll('input')
+ if (inputs.length > 0) {
+ fireEvent.change(inputs[0], { target: { value: 'new value' } })
+
+ await waitFor(() => {
+ expect(setTempList).toHaveBeenCalled()
+ })
+ }
+ })
+
+ it('should have handleAddMetaData function available', () => {
+ const handleAddMetaData = vi.fn()
+ mockUseMetadataDocument.mockReturnValue({
+ ...defaultHookReturn,
+ isEdit: true,
+ handleAddMetaData,
+ })
+
+ render(
+ [0]['docDetail']}
+ />,
+ )
+
+ expect(typeof handleAddMetaData).toBe('function')
+ })
+
+ it('should have handleSelectMetaData function available', () => {
+ const handleSelectMetaData = vi.fn()
+ mockUseMetadataDocument.mockReturnValue({
+ ...defaultHookReturn,
+ isEdit: true,
+ handleSelectMetaData,
+ })
+
+ render(
+ [0]['docDetail']}
+ />,
+ )
+
+ expect(typeof handleSelectMetaData).toBe('function')
+ })
+
+ it('should pass onChange callback to InfoGroup', async () => {
+ const setTempList = vi.fn()
+ const tempList = [
+ { id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
+ ]
+ mockUseMetadataDocument.mockReturnValue({
+ ...defaultHookReturn,
+ isEdit: true,
+ tempList,
+ setTempList,
+ })
+
+ const { container } = render(
+ [0]['docDetail']}
+ />,
+ )
+
+ const inputs = container.querySelectorAll('input')
+ if (inputs.length > 0) {
+ fireEvent.change(inputs[0], { target: { value: 'updated' } })
+ await waitFor(() => {
+ expect(setTempList).toHaveBeenCalled()
+ })
+ }
+ })
+
+ it('should pass onDelete callback to InfoGroup', async () => {
+ const setTempList = vi.fn()
+ mockUseMetadataDocument.mockReturnValue({
+ ...defaultHookReturn,
+ isEdit: true,
+ tempList: mockList,
+ setTempList,
+ })
+
+ const { container } = render(
+ [0]['docDetail']}
+ />,
+ )
+
+ // Look for delete buttons - they are inside hover:bg-state-destructive-hover divs
+ const deleteContainers = container.querySelectorAll('.hover\\:bg-state-destructive-hover')
+ expect(deleteContainers.length).toBeGreaterThan(0)
+
+ // Click the delete icon (SVG inside the container)
+ if (deleteContainers.length > 0) {
+ const deleteIcon = deleteContainers[0].querySelector('svg')
+ if (deleteIcon)
+ fireEvent.click(deleteIcon)
+
+ await waitFor(() => {
+ expect(setTempList).toHaveBeenCalled()
+ })
+ }
+ })
+ })
+
+ describe('Props', () => {
+ it('should apply custom className', () => {
+ const { container } = render(
+ [0]['docDetail']}
+ className="custom-class"
+ />,
+ )
+ expect(container.firstChild).toHaveClass('custom-class')
+ })
+
+ it('should use tempList when in edit mode', () => {
+ const tempList = [{ id: 'temp-1', name: 'temp_field', type: DataType.string, value: 'temp' }]
+ mockUseMetadataDocument.mockReturnValue({
+ ...defaultHookReturn,
+ isEdit: true,
+ tempList,
+ list: mockList,
+ })
+
+ render(
+ [0]['docDetail']}
+ />,
+ )
+
+ expect(screen.getByText('temp_field')).toBeInTheDocument()
+ })
+
+ it('should use list when not in edit mode', () => {
+ render(
+ [0]['docDetail']}
+ />,
+ )
+
+ expect(screen.getByText('field_one')).toBeInTheDocument()
+ expect(screen.getByText('field_two')).toBeInTheDocument()
+ })
+
+ it('should pass datasetId to child components', () => {
+ render(
+ [0]['docDetail']}
+ />,
+ )
+ // Component should render without errors
+ expect(screen.getByText('field_one')).toBeInTheDocument()
+ })
+ })
+
+ describe('Embedding Availability', () => {
+ it('should not show edit button when embedding is not available', () => {
+ mockUseMetadataDocument.mockReturnValue({
+ ...defaultHookReturn,
+ embeddingAvailable: false,
+ })
+
+ render(
+ [0]['docDetail']}
+ />,
+ )
+
+ expect(screen.queryByText(/^edit$/i)).not.toBeInTheDocument()
+ })
+
+ it('should not show NoData when embedding is not available', () => {
+ mockUseMetadataDocument.mockReturnValue({
+ ...defaultHookReturn,
+ embeddingAvailable: false,
+ hasData: false,
+ list: [],
+ tempList: [],
+ })
+
+ render(
+ [0]['docDetail']}
+ />,
+ )
+
+ // NoData component should not be rendered
+ expect(screen.queryByText(/start/i)).not.toBeInTheDocument()
+ })
+
+ it('should not show edit buttons in edit mode when embedding not available', () => {
+ mockUseMetadataDocument.mockReturnValue({
+ ...defaultHookReturn,
+ embeddingAvailable: false,
+ isEdit: false,
+ })
+
+ render(
+ [0]['docDetail']}
+ />,
+ )
+
+ // headerRight should be null/undefined
+ expect(screen.queryByText(/^edit$/i)).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty lists', () => {
+ mockUseMetadataDocument.mockReturnValue({
+ ...defaultHookReturn,
+ list: [],
+ tempList: [],
+ hasData: false,
+ })
+
+ const { container } = render(
+ [0]['docDetail']}
+ />,
+ )
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render correctly with minimal props', () => {
+ const { container } = render(
+ [0]['docDetail']}
+ />,
+ )
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should handle switching between view and edit mode', () => {
+ const { unmount } = render(
+ [0]['docDetail']}
+ />,
+ )
+
+ expect(screen.getByText(/edit/i)).toBeInTheDocument()
+
+ unmount()
+
+ mockUseMetadataDocument.mockReturnValue({
+ ...defaultHookReturn,
+ isEdit: true,
+ })
+
+ render(
+ [0]['docDetail']}
+ />,
+ )
+
+ expect(screen.getByText(/save/i)).toBeInTheDocument()
+ expect(screen.getByText(/cancel/i)).toBeInTheDocument()
+ })
+
+ it('should handle multiple items in all sections', () => {
+ mockUseMetadataDocument.mockReturnValue({
+ ...defaultHookReturn,
+ list: [
+ { id: '1', name: 'user_field_1', type: DataType.string, value: 'v1' },
+ { id: '2', name: 'user_field_2', type: DataType.number, value: 42 },
+ ],
+ builtInEnabled: true,
+ builtList: [
+ { id: 'b1', name: 'created_at', type: DataType.time, value: 1609459200 },
+ { id: 'b2', name: 'modified_at', type: DataType.time, value: 1609459200 },
+ ],
+ originInfo: [
+ { id: 'o1', name: 'source', type: DataType.string, value: 'file' },
+ { id: 'o2', name: 'format', type: DataType.string, value: 'txt' },
+ ],
+ technicalParameters: [
+ { id: 't1', name: 'word_count', type: DataType.number, value: 100 },
+ { id: 't2', name: 'char_count', type: DataType.number, value: 500 },
+ ],
+ })
+
+ render(
+ [0]['docDetail']}
+ />,
+ )
+
+ expect(screen.getByText('user_field_1')).toBeInTheDocument()
+ expect(screen.getByText('user_field_2')).toBeInTheDocument()
+ expect(screen.getByText('created_at')).toBeInTheDocument()
+ expect(screen.getByText('source')).toBeInTheDocument()
+ expect(screen.getByText('word_count')).toBeInTheDocument()
+ })
+
+ it('should handle null values in metadata', () => {
+ mockUseMetadataDocument.mockReturnValue({
+ ...defaultHookReturn,
+ list: [
+ { id: '1', name: 'null_field', type: DataType.string, value: null },
+ ],
+ })
+
+ render(
+ [0]['docDetail']}
+ />,
+ )
+
+ expect(screen.getByText('null_field')).toBeInTheDocument()
+ })
+
+ it('should handle undefined values in metadata', () => {
+ mockUseMetadataDocument.mockReturnValue({
+ ...defaultHookReturn,
+ list: [
+ { id: '1', name: 'undefined_field', type: DataType.string, value: undefined as unknown as null },
+ ],
+ })
+
+ render(
+ [0]['docDetail']}
+ />,
+ )
+
+ expect(screen.getByText('undefined_field')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/metadata/metadata-document/info-group.spec.tsx b/web/app/components/datasets/metadata/metadata-document/info-group.spec.tsx
new file mode 100644
index 0000000000..d8585d0170
--- /dev/null
+++ b/web/app/components/datasets/metadata/metadata-document/info-group.spec.tsx
@@ -0,0 +1,341 @@
+import type { MetadataItemWithValue } from '../types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { DataType } from '../types'
+import InfoGroup from './info-group'
+
+type SelectModalProps = {
+ trigger: React.ReactNode
+ onSelect: (item: MetadataItemWithValue) => void
+ onSave: (data: { name: string, type: DataType }) => void
+ onManage: () => void
+}
+
+type FieldProps = {
+ label: string
+ children: React.ReactNode
+}
+
+type InputCombinedProps = {
+ value: string | number | null
+ onChange: (value: string | number) => void
+ type: DataType
+}
+
+// Mock next/navigation
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({
+ push: vi.fn(),
+ }),
+}))
+
+// Mock useTimestamp
+vi.mock('@/hooks/use-timestamp', () => ({
+ default: () => ({
+ formatTime: (timestamp: number) => {
+ if (!timestamp)
+ return ''
+ return new Date(timestamp * 1000).toLocaleDateString()
+ },
+ }),
+}))
+
+// Mock AddMetadataButton
+vi.mock('../add-metadata-button', () => ({
+ default: () => ,
+}))
+
+// Mock InputCombined
+vi.mock('../edit-metadata-batch/input-combined', () => ({
+ default: ({ value, onChange, type }: InputCombinedProps) => (
+ onChange(e.target.value)}
+ />
+ ),
+}))
+
+// Mock SelectMetadataModal
+vi.mock('../metadata-dataset/select-metadata-modal', () => ({
+ default: ({ trigger, onSelect, onSave, onManage }: SelectModalProps) => (
+
+ {trigger}
+
+
+
+
+ ),
+}))
+
+// Mock Field
+vi.mock('./field', () => ({
+ default: ({ label, children }: FieldProps) => (
+
+ ),
+}))
+
+describe('InfoGroup', () => {
+ const mockList: MetadataItemWithValue[] = [
+ { id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
+ { id: '2', name: 'field_two', type: DataType.number, value: 42 },
+ { id: '3', name: 'built-in', type: DataType.time, value: 1609459200 },
+ ]
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const { container } = render(
+ ,
+ )
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render title when provided', () => {
+ render(
+ ,
+ )
+ expect(screen.getByText('Test Title')).toBeInTheDocument()
+ })
+
+ it('should not render header when noHeader is true', () => {
+ render(
+ ,
+ )
+ expect(screen.queryByText('Test Title')).not.toBeInTheDocument()
+ })
+
+ it('should render all list items', () => {
+ render(
+ ,
+ )
+ const fields = screen.getAllByTestId('field')
+ expect(fields).toHaveLength(3)
+ })
+
+ it('should render tooltip when titleTooltip is provided', () => {
+ render(
+ ,
+ )
+ // Tooltip icon should be present
+ const tooltipIcon = screen.getByText('Test').closest('.flex')?.querySelector('svg')
+ expect(tooltipIcon).toBeInTheDocument()
+ })
+
+ it('should render headerRight content', () => {
+ render(
+ Action}
+ />,
+ )
+ expect(screen.getByTestId('header-right-btn')).toBeInTheDocument()
+ })
+ })
+
+ describe('Edit Mode', () => {
+ it('should render add metadata button when isEdit is true', () => {
+ render(
+ ,
+ )
+ expect(screen.getByTestId('add-metadata-btn')).toBeInTheDocument()
+ })
+
+ it('should not render add metadata button when isEdit is false', () => {
+ render(
+ ,
+ )
+ expect(screen.queryByTestId('add-metadata-btn')).not.toBeInTheDocument()
+ })
+
+ it('should render input combined for each item in edit mode', () => {
+ render(
+ ,
+ )
+ const inputs = screen.getAllByTestId('input-combined')
+ expect(inputs).toHaveLength(3)
+ })
+
+ it('should render delete icons in edit mode', () => {
+ const { container } = render(
+ ,
+ )
+ const deleteIcons = container.querySelectorAll('.cursor-pointer svg')
+ expect(deleteIcons.length).toBeGreaterThan(0)
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onChange when input value changes', () => {
+ const handleChange = vi.fn()
+ render(
+ ,
+ )
+
+ const inputs = screen.getAllByTestId('input-combined')
+ fireEvent.change(inputs[0], { target: { value: 'New Value' } })
+
+ expect(handleChange).toHaveBeenCalled()
+ })
+
+ it('should call onDelete when delete icon is clicked', () => {
+ const handleDelete = vi.fn()
+ const { container } = render(
+ ,
+ )
+
+ // Find delete icons (RiDeleteBinLine SVGs inside cursor-pointer divs)
+ const deleteButtons = container.querySelectorAll('svg.size-4')
+ if (deleteButtons.length > 0)
+ fireEvent.click(deleteButtons[0])
+
+ expect(handleDelete).toHaveBeenCalled()
+ })
+
+ it('should call onSelect when metadata is selected', () => {
+ const handleSelect = vi.fn()
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('select-action'))
+
+ expect(handleSelect).toHaveBeenCalledWith({
+ id: '1',
+ name: 'test',
+ type: DataType.string,
+ value: null,
+ })
+ })
+
+ it('should call onAdd when new metadata is saved', () => {
+ const handleAdd = vi.fn()
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('save-action'))
+
+ expect(handleAdd).toHaveBeenCalledWith({
+ name: 'new_field',
+ type: DataType.string,
+ })
+ })
+
+ it('should navigate to documents page when manage is clicked', () => {
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('manage-action'))
+
+ // The onManage callback triggers the navigation
+ expect(screen.getByTestId('manage-action')).toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should apply custom className', () => {
+ const { container } = render(
+ ,
+ )
+ expect(container.firstChild).toHaveClass('custom-class')
+ })
+
+ it('should apply contentClassName', () => {
+ const { container } = render(
+ ,
+ )
+ const contentDiv = container.querySelector('.content-custom')
+ expect(contentDiv).toBeInTheDocument()
+ })
+
+ it('should use uppercase title by default', () => {
+ render(
+ ,
+ )
+ const titleElement = screen.getByText('Test Title')
+ expect(titleElement).toHaveClass('system-xs-semibold-uppercase')
+ })
+
+ it('should not use uppercase when uppercaseTitle is false', () => {
+ render(
+ ,
+ )
+ const titleElement = screen.getByText('Test Title')
+ expect(titleElement).toHaveClass('system-md-semibold')
+ })
+ })
+
+ describe('Value Display', () => {
+ it('should display string value directly', () => {
+ const stringList: MetadataItemWithValue[] = [
+ { id: '1', name: 'field', type: DataType.string, value: 'Test Value' },
+ ]
+ render(
+ ,
+ )
+ expect(screen.getByText('Test Value')).toBeInTheDocument()
+ })
+
+ it('should display number value', () => {
+ const numberList: MetadataItemWithValue[] = [
+ { id: '1', name: 'field', type: DataType.number, value: 123 },
+ ]
+ render(
+ ,
+ )
+ expect(screen.getByText('123')).toBeInTheDocument()
+ })
+
+ it('should format time value', () => {
+ const timeList: MetadataItemWithValue[] = [
+ { id: '1', name: 'field', type: DataType.time, value: 1609459200 },
+ ]
+ render(
+ ,
+ )
+ // The mock formatTime returns formatted date
+ expect(screen.getByTestId('field-content')).toBeInTheDocument()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty list', () => {
+ const { container } = render(
+ ,
+ )
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should handle null value in list', () => {
+ const nullList: MetadataItemWithValue[] = [
+ { id: '1', name: 'field', type: DataType.string, value: null },
+ ]
+ render(
+ ,
+ )
+ expect(screen.getByTestId('field')).toBeInTheDocument()
+ })
+
+ it('should handle items with built-in id', () => {
+ const builtInList: MetadataItemWithValue[] = [
+ { id: 'built-in', name: 'field', type: DataType.string, value: 'test' },
+ ]
+ render(
+ ,
+ )
+ expect(screen.getByTestId('field')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/metadata/metadata-document/no-data.spec.tsx b/web/app/components/datasets/metadata/metadata-document/no-data.spec.tsx
new file mode 100644
index 0000000000..84079cda1d
--- /dev/null
+++ b/web/app/components/datasets/metadata/metadata-document/no-data.spec.tsx
@@ -0,0 +1,131 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import NoData from './no-data'
+
+describe('NoData', () => {
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const handleStart = vi.fn()
+ const { container } = render()
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render with gradient background', () => {
+ const handleStart = vi.fn()
+ const { container } = render()
+ expect(container.firstChild).toHaveClass('rounded-xl', 'bg-gradient-to-r', 'p-4', 'pt-3')
+ })
+
+ it('should render title text', () => {
+ const handleStart = vi.fn()
+ const { container } = render()
+ // Title should have correct styling
+ const title = container.querySelector('.text-xs.font-semibold')
+ expect(title).toBeInTheDocument()
+ })
+
+ it('should render description text', () => {
+ const handleStart = vi.fn()
+ const { container } = render()
+ // Description should have correct styling
+ const description = container.querySelector('.system-xs-regular.mt-1')
+ expect(description).toBeInTheDocument()
+ })
+
+ it('should render start labeling button', () => {
+ const handleStart = vi.fn()
+ render()
+ const button = screen.getByRole('button')
+ expect(button).toBeInTheDocument()
+ })
+
+ it('should render arrow icon in button', () => {
+ const handleStart = vi.fn()
+ const { container } = render()
+ // RiArrowRightLine icon should be present
+ const svg = container.querySelector('svg')
+ expect(svg).toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should accept onStart prop', () => {
+ const handleStart = vi.fn()
+ render()
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onStart when button is clicked', () => {
+ const handleStart = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByRole('button'))
+
+ expect(handleStart).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onStart multiple times on multiple clicks', () => {
+ const handleStart = vi.fn()
+ render()
+
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+ fireEvent.click(button)
+ fireEvent.click(button)
+
+ expect(handleStart).toHaveBeenCalledTimes(3)
+ })
+
+ it('should have been called when button is clicked', () => {
+ const handleStart = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByRole('button'))
+
+ // The onClick handler passes the event to onStart
+ expect(handleStart).toHaveBeenCalled()
+ })
+ })
+
+ describe('Button Styling', () => {
+ it('should have primary variant button', () => {
+ const handleStart = vi.fn()
+ render()
+ const button = screen.getByRole('button')
+ // Button should have primary styling
+ expect(button).toHaveClass('mt-2')
+ })
+ })
+
+ describe('Layout', () => {
+ it('should have correct title styling', () => {
+ const handleStart = vi.fn()
+ const { container } = render()
+ const title = container.querySelector('.text-xs.font-semibold')
+ expect(title).toBeInTheDocument()
+ })
+
+ it('should have correct description styling', () => {
+ const handleStart = vi.fn()
+ const { container } = render()
+ const description = container.querySelector('.system-xs-regular.mt-1')
+ expect(description).toBeInTheDocument()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle rapid clicks', () => {
+ const handleStart = vi.fn()
+ render()
+
+ const button = screen.getByRole('button')
+ for (let i = 0; i < 10; i++) {
+ fireEvent.click(button)
+ }
+
+ expect(handleStart).toHaveBeenCalledTimes(10)
+ })
+ })
+})
diff --git a/web/app/components/datasets/metadata/utils/get-icon.spec.ts b/web/app/components/datasets/metadata/utils/get-icon.spec.ts
new file mode 100644
index 0000000000..f5a34bd264
--- /dev/null
+++ b/web/app/components/datasets/metadata/utils/get-icon.spec.ts
@@ -0,0 +1,45 @@
+import { RiHashtag, RiTextSnippet, RiTimeLine } from '@remixicon/react'
+import { describe, expect, it } from 'vitest'
+import { DataType } from '../types'
+import { getIcon } from './get-icon'
+
+describe('getIcon', () => {
+ describe('Rendering', () => {
+ it('should return RiTextSnippet for DataType.string', () => {
+ const result = getIcon(DataType.string)
+ expect(result).toBe(RiTextSnippet)
+ })
+
+ it('should return RiHashtag for DataType.number', () => {
+ const result = getIcon(DataType.number)
+ expect(result).toBe(RiHashtag)
+ })
+
+ it('should return RiTimeLine for DataType.time', () => {
+ const result = getIcon(DataType.time)
+ expect(result).toBe(RiTimeLine)
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should return RiTextSnippet as fallback for unknown type', () => {
+ const result = getIcon('unknown' as DataType)
+ expect(result).toBe(RiTextSnippet)
+ })
+
+ it('should return RiTextSnippet for undefined type', () => {
+ const result = getIcon(undefined as unknown as DataType)
+ expect(result).toBe(RiTextSnippet)
+ })
+
+ it('should return RiTextSnippet for null type', () => {
+ const result = getIcon(null as unknown as DataType)
+ expect(result).toBe(RiTextSnippet)
+ })
+
+ it('should return RiTextSnippet for empty string type', () => {
+ const result = getIcon('' as DataType)
+ expect(result).toBe(RiTextSnippet)
+ })
+ })
+})
diff --git a/web/app/components/datasets/rename-modal/index.spec.tsx b/web/app/components/datasets/rename-modal/index.spec.tsx
new file mode 100644
index 0000000000..13ab4d25ea
--- /dev/null
+++ b/web/app/components/datasets/rename-modal/index.spec.tsx
@@ -0,0 +1,1173 @@
+import type { DataSet } from '@/models/datasets'
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { IndexingType } from '@/app/components/datasets/create/step-two'
+import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
+import RenameDatasetModal from './index'
+
+// Mock service
+const mockUpdateDatasetSetting = vi.fn()
+vi.mock('@/service/datasets', () => ({
+ updateDatasetSetting: (params: unknown) => mockUpdateDatasetSetting(params),
+}))
+
+// Mock Toast
+const mockToastNotify = vi.fn()
+vi.mock('../../base/toast', () => ({
+ default: {
+ notify: (params: unknown) => mockToastNotify(params),
+ },
+}))
+
+// Mock AppIcon - simplified mock to enable testing onClick callback
+vi.mock('../../base/app-icon', () => ({
+ default: ({ onClick }: { onClick?: () => void }) => (
+
+ ),
+}))
+
+// Mock AppIconPicker - simplified mock to test onSelect and onClose callbacks
+vi.mock('../../base/app-icon-picker', () => ({
+ default: ({ onSelect, onClose }: {
+ onSelect?: (icon: { type: string, icon?: string, background?: string, fileId?: string, url?: string }) => void
+ onClose?: () => void
+ }) => (
+
+
+
+
+
+ ),
+}))
+
+// Note: react-i18next is globally mocked in vitest.setup.ts
+// The mock returns 'ns.key' format, e.g., 'common.operation.cancel'
+
+describe('RenameDatasetModal', () => {
+ // Create a base dataset with emoji icon
+ const createMockDataset = (overrides: Partial = {}): DataSet => ({
+ id: 'dataset-1',
+ name: 'Test Dataset',
+ description: 'Test description',
+ indexing_status: 'completed',
+ icon_info: {
+ icon: '📊',
+ icon_type: 'emoji',
+ icon_background: '#FFEAD5',
+ icon_url: undefined,
+ },
+ permission: DatasetPermission.onlyMe,
+ data_source_type: DataSourceType.FILE,
+ indexing_technique: IndexingType.QUALIFIED,
+ created_by: 'user-1',
+ updated_by: 'user-1',
+ updated_at: Date.now(),
+ app_count: 0,
+ doc_form: ChunkingMode.text,
+ document_count: 5,
+ total_document_count: 5,
+ word_count: 1000,
+ provider: 'openai',
+ embedding_model: 'text-embedding-ada-002',
+ embedding_model_provider: 'openai',
+ embedding_available: true,
+ retrieval_model_dict: {} as DataSet['retrieval_model_dict'],
+ retrieval_model: {} as DataSet['retrieval_model'],
+ tags: [],
+ external_knowledge_info: {
+ external_knowledge_id: '',
+ external_knowledge_api_id: '',
+ external_knowledge_api_name: '',
+ external_knowledge_api_endpoint: '',
+ },
+ external_retrieval_model: {
+ top_k: 3,
+ score_threshold: 0.5,
+ score_threshold_enabled: false,
+ },
+ built_in_field_enabled: false,
+ runtime_mode: 'general',
+ enable_api: true,
+ is_multimodal: false,
+ ...overrides,
+ })
+
+ // Create a dataset with image icon
+ const createMockDatasetWithImageIcon = (): DataSet => createMockDataset({
+ icon_info: {
+ icon: 'file-id-123',
+ icon_type: 'image',
+ icon_background: undefined,
+ icon_url: 'https://example.com/icon.png',
+ },
+ })
+
+ // Create a dataset with external knowledge info
+ const createMockExternalDataset = (): DataSet => createMockDataset({
+ external_knowledge_info: {
+ external_knowledge_id: 'ext-knowledge-1',
+ external_knowledge_api_id: 'ext-api-1',
+ external_knowledge_api_name: 'External API',
+ external_knowledge_api_endpoint: 'https://api.example.com',
+ },
+ })
+
+ const defaultProps = {
+ show: true,
+ dataset: createMockDataset(),
+ onSuccess: vi.fn(),
+ onClose: vi.fn(),
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUpdateDatasetSetting.mockResolvedValue(createMockDataset())
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ // Check title is rendered (translation mock returns 'datasetSettings.title')
+ expect(screen.getByText('datasetSettings.title')).toBeInTheDocument()
+ })
+
+ it('should render modal when show is true', () => {
+ render()
+ expect(screen.getByText('datasetSettings.title')).toBeInTheDocument()
+ })
+
+ it('should render name input with dataset name', () => {
+ render()
+ const nameInput = screen.getByDisplayValue('Test Dataset')
+ expect(nameInput).toBeInTheDocument()
+ })
+
+ it('should render description textarea with dataset description', () => {
+ render()
+ const descriptionTextarea = screen.getByDisplayValue('Test description')
+ expect(descriptionTextarea).toBeInTheDocument()
+ })
+
+ it('should render cancel and save buttons', () => {
+ render()
+ expect(screen.getByText('common.operation.cancel')).toBeInTheDocument()
+ expect(screen.getByText('common.operation.save')).toBeInTheDocument()
+ })
+
+ it('should render close icon button', () => {
+ render()
+ // The modal renders with title and other elements
+ // The close functionality is tested in user interactions
+ expect(screen.getByText('datasetSettings.title')).toBeInTheDocument()
+ })
+
+ it('should render form labels', () => {
+ render()
+ expect(screen.getByText('datasetSettings.form.name')).toBeInTheDocument()
+ expect(screen.getByText('datasetSettings.form.desc')).toBeInTheDocument()
+ })
+ })
+
+ describe('Props Variations', () => {
+ it('should render with emoji icon dataset', () => {
+ const dataset = createMockDataset()
+ render()
+ expect(screen.getByDisplayValue('Test Dataset')).toBeInTheDocument()
+ })
+
+ it('should render with image icon dataset', () => {
+ const dataset = createMockDatasetWithImageIcon()
+ render()
+ expect(screen.getByDisplayValue('Test Dataset')).toBeInTheDocument()
+ })
+
+ it('should render with empty description', () => {
+ const dataset = createMockDataset({ description: '' })
+ render()
+ // Find the textarea by its placeholder
+ const descriptionTextarea = screen.getByPlaceholderText('datasetSettings.form.descPlaceholder')
+ expect(descriptionTextarea).toHaveValue('')
+ })
+
+ it('should render with external knowledge dataset', () => {
+ const dataset = createMockExternalDataset()
+ render()
+ expect(screen.getByDisplayValue('Test Dataset')).toBeInTheDocument()
+ })
+
+ it('should handle undefined onSuccess callback', () => {
+ render()
+ expect(screen.getByDisplayValue('Test Dataset')).toBeInTheDocument()
+ })
+ })
+
+ describe('State Management', () => {
+ it('should initialize name state with dataset name', () => {
+ render()
+ expect(screen.getByDisplayValue('Test Dataset')).toBeInTheDocument()
+ })
+
+ it('should initialize description state with dataset description', () => {
+ render()
+ expect(screen.getByDisplayValue('Test description')).toBeInTheDocument()
+ })
+
+ it('should update name state when input changes', () => {
+ render()
+ const nameInput = screen.getByDisplayValue('Test Dataset')
+
+ fireEvent.change(nameInput, { target: { value: 'New Dataset Name' } })
+
+ expect(screen.getByDisplayValue('New Dataset Name')).toBeInTheDocument()
+ })
+
+ it('should update description state when textarea changes', () => {
+ render()
+ const descriptionTextarea = screen.getByDisplayValue('Test description')
+
+ fireEvent.change(descriptionTextarea, { target: { value: 'New description' } })
+
+ expect(screen.getByDisplayValue('New description')).toBeInTheDocument()
+ })
+
+ it('should clear name when input is cleared', () => {
+ render()
+ const nameInput = screen.getByDisplayValue('Test Dataset')
+
+ fireEvent.change(nameInput, { target: { value: '' } })
+
+ expect(nameInput).toHaveValue('')
+ })
+
+ it('should handle special characters in name', () => {
+ render()
+ const nameInput = screen.getByDisplayValue('Test Dataset')
+
+ fireEvent.change(nameInput, { target: { value: 'Dataset ' } })
+
+ expect(screen.getByDisplayValue('Dataset ')).toBeInTheDocument()
+ })
+
+ it('should handle very long name input', () => {
+ render()
+ const nameInput = screen.getByDisplayValue('Test Dataset')
+ const longName = 'A'.repeat(500)
+
+ fireEvent.change(nameInput, { target: { value: longName } })
+
+ expect(screen.getByDisplayValue(longName)).toBeInTheDocument()
+ })
+
+ it('should handle multiline description', () => {
+ render()
+ const descriptionTextarea = screen.getByDisplayValue('Test description')
+ const multilineDesc = 'Line 1\nLine 2\nLine 3'
+
+ fireEvent.change(descriptionTextarea, { target: { value: multilineDesc } })
+
+ // Verify the textarea contains the multiline value
+ expect(descriptionTextarea).toHaveValue(multilineDesc)
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onClose when cancel button is clicked', () => {
+ const handleClose = vi.fn()
+ render()
+
+ const cancelButton = screen.getByText('common.operation.cancel')
+ fireEvent.click(cancelButton)
+
+ expect(handleClose).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onClose when close icon is clicked', () => {
+ // This test is covered by the cancel button test
+ // The close icon functionality works the same way as cancel button
+ const handleClose = vi.fn()
+ render()
+
+ // Use the cancel button to verify close callback works
+ const cancelButton = screen.getByText('common.operation.cancel')
+ fireEvent.click(cancelButton)
+
+ expect(handleClose).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call API when save button is clicked with valid name', async () => {
+ render()
+
+ const saveButton = screen.getByText('common.operation.save')
+ await act(async () => {
+ fireEvent.click(saveButton)
+ })
+
+ await waitFor(() => {
+ expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
+ datasetId: 'dataset-1',
+ body: expect.objectContaining({
+ name: 'Test Dataset',
+ description: 'Test description',
+ }),
+ })
+ })
+ })
+
+ it('should disable save button while loading', async () => {
+ // Create a promise that we can control
+ let resolvePromise: (value: DataSet) => void
+ mockUpdateDatasetSetting.mockImplementation(() => new Promise((resolve) => {
+ resolvePromise = resolve
+ }))
+
+ render()
+
+ const saveButton = screen.getByText('common.operation.save')
+ await act(async () => {
+ fireEvent.click(saveButton)
+ })
+
+ await waitFor(() => {
+ expect(saveButton).toBeDisabled()
+ })
+
+ // Resolve the promise to clean up
+ await act(async () => {
+ resolvePromise!(createMockDataset())
+ })
+ })
+
+ it('should handle name input focus', () => {
+ render()
+ const nameInput = screen.getByDisplayValue('Test Dataset')
+
+ // Verify the input can receive focus
+ nameInput.focus()
+
+ // Just verify the element is focusable (don't check activeElement as it may differ in test environment)
+ expect(nameInput).not.toBeDisabled()
+ })
+
+ it('should handle description textarea focus', () => {
+ render()
+ const descriptionTextarea = screen.getByDisplayValue('Test description')
+
+ // Verify the textarea can receive focus
+ descriptionTextarea.focus()
+
+ // Just verify the element is focusable
+ expect(descriptionTextarea).not.toBeDisabled()
+ })
+ })
+
+ describe('API Calls', () => {
+ it('should call updateDatasetSetting with correct parameters', async () => {
+ render()
+
+ const nameInput = screen.getByDisplayValue('Test Dataset')
+ fireEvent.change(nameInput, { target: { value: 'Updated Name' } })
+
+ const saveButton = screen.getByText('common.operation.save')
+ await act(async () => {
+ fireEvent.click(saveButton)
+ })
+
+ await waitFor(() => {
+ expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
+ datasetId: 'dataset-1',
+ body: {
+ name: 'Updated Name',
+ description: 'Test description',
+ icon_info: {
+ icon: '📊',
+ icon_type: 'emoji',
+ icon_background: '#FFEAD5',
+ icon_url: undefined,
+ },
+ },
+ })
+ })
+ })
+
+ it('should include external knowledge IDs when present', async () => {
+ const dataset = createMockExternalDataset()
+ render()
+
+ const saveButton = screen.getByText('common.operation.save')
+ await act(async () => {
+ fireEvent.click(saveButton)
+ })
+
+ await waitFor(() => {
+ expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
+ datasetId: 'dataset-1',
+ body: expect.objectContaining({
+ external_knowledge_id: 'ext-knowledge-1',
+ external_knowledge_api_id: 'ext-api-1',
+ }),
+ })
+ })
+ })
+
+ it('should not include external knowledge IDs when not present', async () => {
+ render()
+
+ const saveButton = screen.getByText('common.operation.save')
+ await act(async () => {
+ fireEvent.click(saveButton)
+ })
+
+ await waitFor(() => {
+ expect(mockUpdateDatasetSetting).toHaveBeenCalled()
+ const callArgs = mockUpdateDatasetSetting.mock.calls[0][0]
+ expect(callArgs.body.external_knowledge_id).toBeUndefined()
+ expect(callArgs.body.external_knowledge_api_id).toBeUndefined()
+ })
+ })
+
+ it('should handle image icon correctly in API call', async () => {
+ const dataset = createMockDatasetWithImageIcon()
+ render()
+
+ const saveButton = screen.getByText('common.operation.save')
+ await act(async () => {
+ fireEvent.click(saveButton)
+ })
+
+ await waitFor(() => {
+ expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
+ datasetId: 'dataset-1',
+ body: expect.objectContaining({
+ icon_info: {
+ icon: 'file-id-123',
+ icon_type: 'image',
+ icon_background: undefined,
+ icon_url: 'https://example.com/icon.png',
+ },
+ }),
+ })
+ })
+ })
+
+ it('should call onSuccess and onClose after successful save', async () => {
+ const handleSuccess = vi.fn()
+ const handleClose = vi.fn()
+ render()
+
+ const saveButton = screen.getByText('common.operation.save')
+ await act(async () => {
+ fireEvent.click(saveButton)
+ })
+
+ await waitFor(() => {
+ expect(handleSuccess).toHaveBeenCalledTimes(1)
+ expect(handleClose).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ it('should show success toast after successful save', async () => {
+ render()
+
+ const saveButton = screen.getByText('common.operation.save')
+ await act(async () => {
+ fireEvent.click(saveButton)
+ })
+
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'success',
+ message: 'common.actionMsg.modifiedSuccessfully',
+ })
+ })
+ })
+ })
+
+ describe('Error Handling', () => {
+ it('should show error toast when name is empty', async () => {
+ render()
+
+ const nameInput = screen.getByDisplayValue('Test Dataset')
+ fireEvent.change(nameInput, { target: { value: '' } })
+
+ const saveButton = screen.getByText('common.operation.save')
+ await act(async () => {
+ fireEvent.click(saveButton)
+ })
+
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'datasetSettings.form.nameError',
+ })
+ })
+ })
+
+ it('should show error toast when name is only whitespace', async () => {
+ render()
+
+ const nameInput = screen.getByDisplayValue('Test Dataset')
+ fireEvent.change(nameInput, { target: { value: ' ' } })
+
+ const saveButton = screen.getByText('common.operation.save')
+ await act(async () => {
+ fireEvent.click(saveButton)
+ })
+
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'datasetSettings.form.nameError',
+ })
+ })
+ })
+
+ it('should not call API when name is invalid', async () => {
+ render()
+
+ const nameInput = screen.getByDisplayValue('Test Dataset')
+ fireEvent.change(nameInput, { target: { value: '' } })
+
+ const saveButton = screen.getByText('common.operation.save')
+ await act(async () => {
+ fireEvent.click(saveButton)
+ })
+
+ await waitFor(() => {
+ expect(mockUpdateDatasetSetting).not.toHaveBeenCalled()
+ })
+ })
+
+ it('should show error toast when API call fails', async () => {
+ mockUpdateDatasetSetting.mockRejectedValueOnce(new Error('API Error'))
+
+ render()
+
+ const saveButton = screen.getByText('common.operation.save')
+ await act(async () => {
+ fireEvent.click(saveButton)
+ })
+
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'common.actionMsg.modifiedUnsuccessfully',
+ })
+ })
+ })
+
+ it('should not call onSuccess when API call fails', async () => {
+ mockUpdateDatasetSetting.mockRejectedValueOnce(new Error('API Error'))
+ const handleSuccess = vi.fn()
+
+ render()
+
+ const saveButton = screen.getByText('common.operation.save')
+ await act(async () => {
+ fireEvent.click(saveButton)
+ })
+
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'common.actionMsg.modifiedUnsuccessfully',
+ })
+ })
+
+ expect(handleSuccess).not.toHaveBeenCalled()
+ })
+
+ it('should not call onClose when API call fails', async () => {
+ mockUpdateDatasetSetting.mockRejectedValueOnce(new Error('API Error'))
+ const handleClose = vi.fn()
+
+ render()
+
+ const saveButton = screen.getByText('common.operation.save')
+ await act(async () => {
+ fireEvent.click(saveButton)
+ })
+
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalled()
+ })
+
+ expect(handleClose).not.toHaveBeenCalled()
+ })
+
+ it('should reset loading state after API error', async () => {
+ mockUpdateDatasetSetting.mockRejectedValueOnce(new Error('API Error'))
+
+ render()
+
+ const saveButton = screen.getByText('common.operation.save')
+ await act(async () => {
+ fireEvent.click(saveButton)
+ })
+
+ // Wait for error handling to complete
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalled()
+ })
+
+ // Save button should be enabled again
+ expect(saveButton).not.toBeDisabled()
+ })
+ })
+
+ describe('Callback Stability', () => {
+ it('should call onClose exactly once per click', () => {
+ const handleClose = vi.fn()
+ render()
+
+ const cancelButton = screen.getByText('common.operation.cancel')
+ fireEvent.click(cancelButton)
+ fireEvent.click(cancelButton)
+
+ expect(handleClose).toHaveBeenCalledTimes(2)
+ })
+
+ it('should not call onSuccess when undefined', async () => {
+ render()
+
+ const saveButton = screen.getByText('common.operation.save')
+ await act(async () => {
+ fireEvent.click(saveButton)
+ })
+
+ await waitFor(() => {
+ expect(mockUpdateDatasetSetting).toHaveBeenCalled()
+ })
+
+ // Should not throw error when onSuccess is undefined
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'success',
+ message: 'common.actionMsg.modifiedSuccessfully',
+ })
+ })
+
+ it('should maintain callback identity across renders', async () => {
+ const handleClose = vi.fn()
+ const { rerender } = render()
+
+ // Change input to trigger re-render
+ const nameInput = screen.getByDisplayValue('Test Dataset')
+ fireEvent.change(nameInput, { target: { value: 'New Name' } })
+
+ // Re-render with same callback
+ rerender()
+
+ const cancelButton = screen.getByText('common.operation.cancel')
+ fireEvent.click(cancelButton)
+
+ expect(handleClose).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('Icon Picker Integration', () => {
+ it('should render app icon component', () => {
+ render()
+ // The modal should render with name label and input
+ // AppIcon is rendered alongside the name input
+ expect(screen.getByText('datasetSettings.form.name')).toBeInTheDocument()
+ expect(screen.getByDisplayValue('Test Dataset')).toBeInTheDocument()
+ })
+
+ it('should initialize icon state from dataset', () => {
+ // Test with emoji icon
+ render()
+ // The component initializes with the dataset's icon_info
+ // This is verified by checking the form renders correctly
+ expect(screen.getByDisplayValue('Test Dataset')).toBeInTheDocument()
+ })
+
+ it('should initialize icon state from image icon dataset', () => {
+ // Test with image icon - this triggers the icon_type === 'image' branch
+ const imageDataset = createMockDatasetWithImageIcon()
+ render()
+ expect(screen.getByDisplayValue('Test Dataset')).toBeInTheDocument()
+ // The component should render successfully with image icon dataset
+ })
+
+ it('should save with image icon data when dataset has image icon', async () => {
+ // Verify icon state is correctly initialized from image icon dataset
+ const imageDataset = createMockDatasetWithImageIcon()
+ render()
+
+ // Save directly to verify the icon data is correctly passed
+ const saveButton = screen.getByText('common.operation.save')
+ await act(async () => {
+ fireEvent.click(saveButton)
+ })
+
+ await waitFor(() => {
+ expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
+ datasetId: 'dataset-1',
+ body: expect.objectContaining({
+ icon_info: {
+ icon: 'file-id-123',
+ icon_type: 'image',
+ icon_background: undefined,
+ icon_url: 'https://example.com/icon.png',
+ },
+ }),
+ })
+ })
+ })
+
+ it('should save with emoji icon data when dataset has emoji icon', async () => {
+ // Verify icon state is correctly initialized from emoji icon dataset
+ render()
+
+ // Save directly to verify the icon data is correctly passed
+ const saveButton = screen.getByText('common.operation.save')
+ await act(async () => {
+ fireEvent.click(saveButton)
+ })
+
+ await waitFor(() => {
+ expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
+ datasetId: 'dataset-1',
+ body: expect.objectContaining({
+ icon_info: {
+ icon: '📊',
+ icon_type: 'emoji',
+ icon_background: '#FFEAD5',
+ icon_url: undefined,
+ },
+ }),
+ })
+ })
+ })
+
+ it('should open icon picker when app icon is clicked (handleOpenAppIconPicker)', async () => {
+ render()
+
+ // Initially picker should not be visible
+ expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
+
+ // Click app icon to open picker
+ const appIcon = screen.getByTestId('app-icon')
+ await act(async () => {
+ fireEvent.click(appIcon)
+ })
+
+ // Picker should now be visible
+ expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
+ })
+
+ it('should select emoji icon and close picker (handleSelectAppIcon)', async () => {
+ render()
+
+ // Open picker
+ const appIcon = screen.getByTestId('app-icon')
+ await act(async () => {
+ fireEvent.click(appIcon)
+ })
+
+ // Select emoji
+ const selectEmojiBtn = screen.getByTestId('select-emoji')
+ await act(async () => {
+ fireEvent.click(selectEmojiBtn)
+ })
+
+ // Picker should close after selection
+ expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
+
+ // Save and verify new icon is used
+ const saveButton = screen.getByText('common.operation.save')
+ await act(async () => {
+ fireEvent.click(saveButton)
+ })
+
+ await waitFor(() => {
+ expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
+ datasetId: 'dataset-1',
+ body: expect.objectContaining({
+ icon_info: {
+ icon: '🚀',
+ icon_type: 'emoji',
+ icon_background: '#E0F2FE',
+ icon_url: undefined,
+ },
+ }),
+ })
+ })
+ })
+
+ it('should select image icon and close picker (handleSelectAppIcon)', async () => {
+ render()
+
+ // Open picker
+ const appIcon = screen.getByTestId('app-icon')
+ await act(async () => {
+ fireEvent.click(appIcon)
+ })
+
+ // Select image
+ const selectImageBtn = screen.getByTestId('select-image')
+ await act(async () => {
+ fireEvent.click(selectImageBtn)
+ })
+
+ // Picker should close after selection
+ expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
+
+ // Save and verify new image icon is used
+ const saveButton = screen.getByText('common.operation.save')
+ await act(async () => {
+ fireEvent.click(saveButton)
+ })
+
+ await waitFor(() => {
+ expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
+ datasetId: 'dataset-1',
+ body: expect.objectContaining({
+ icon_info: {
+ icon: 'new-file',
+ icon_type: 'image',
+ icon_background: undefined,
+ icon_url: 'https://new.png',
+ },
+ }),
+ })
+ })
+ })
+
+ it('should restore previous icon when picker is closed (handleCloseAppIconPicker)', async () => {
+ render()
+
+ // Open picker
+ const appIcon = screen.getByTestId('app-icon')
+ await act(async () => {
+ fireEvent.click(appIcon)
+ })
+
+ // Close picker without selecting
+ const closeBtn = screen.getByTestId('close-picker')
+ await act(async () => {
+ fireEvent.click(closeBtn)
+ })
+
+ // Picker should close
+ expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
+
+ // Save and verify original icon is preserved
+ const saveButton = screen.getByText('common.operation.save')
+ await act(async () => {
+ fireEvent.click(saveButton)
+ })
+
+ await waitFor(() => {
+ expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
+ datasetId: 'dataset-1',
+ body: expect.objectContaining({
+ icon_info: {
+ icon: '📊',
+ icon_type: 'emoji',
+ icon_background: '#FFEAD5',
+ icon_url: undefined,
+ },
+ }),
+ })
+ })
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle dataset with null icon_info', () => {
+ const dataset = createMockDataset({
+ icon_info: {
+ icon: '',
+ icon_type: 'emoji',
+ icon_background: '',
+ icon_url: undefined,
+ },
+ })
+
+ render()
+ expect(screen.getByDisplayValue('Test Dataset')).toBeInTheDocument()
+ })
+
+ it('should handle image icon with empty icon_url', async () => {
+ // Test the || '' fallback for icon_url in image icon
+ const dataset = createMockDataset({
+ icon_info: {
+ icon: 'file-id',
+ icon_type: 'image',
+ icon_background: undefined,
+ icon_url: '', // Empty string - triggers || '' fallback
+ },
+ })
+
+ render()
+ expect(screen.getByDisplayValue('Test Dataset')).toBeInTheDocument()
+
+ // Save and verify the icon_url is handled correctly
+ const saveButton = screen.getByText('common.operation.save')
+ await act(async () => {
+ fireEvent.click(saveButton)
+ })
+
+ await waitFor(() => {
+ expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
+ datasetId: 'dataset-1',
+ body: expect.objectContaining({
+ icon_info: expect.objectContaining({
+ icon: 'file-id',
+ icon_type: 'image',
+ icon_url: '',
+ }),
+ }),
+ })
+ })
+ })
+
+ it('should handle image icon with undefined icon', async () => {
+ // Test the || '' fallback for icon (fileId) in image icon
+ const dataset = createMockDataset({
+ icon_info: {
+ icon: '', // Empty string - triggers || '' fallback
+ icon_type: 'image',
+ icon_background: undefined,
+ icon_url: 'https://example.com/icon.png',
+ },
+ })
+
+ render()
+ expect(screen.getByDisplayValue('Test Dataset')).toBeInTheDocument()
+
+ // Save and verify the icon is handled correctly
+ const saveButton = screen.getByText('common.operation.save')
+ await act(async () => {
+ fireEvent.click(saveButton)
+ })
+
+ await waitFor(() => {
+ expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
+ datasetId: 'dataset-1',
+ body: expect.objectContaining({
+ icon_info: expect.objectContaining({
+ icon: '',
+ icon_type: 'image',
+ icon_url: 'https://example.com/icon.png',
+ }),
+ }),
+ })
+ })
+ })
+
+ it('should handle dataset with empty external knowledge info', () => {
+ const dataset = createMockDataset({
+ external_knowledge_info: {
+ external_knowledge_id: '',
+ external_knowledge_api_id: '',
+ external_knowledge_api_name: '',
+ external_knowledge_api_endpoint: '',
+ },
+ })
+
+ render()
+ expect(screen.getByDisplayValue('Test Dataset')).toBeInTheDocument()
+ })
+
+ it('should handle rapid input changes', async () => {
+ render()
+ const nameInput = screen.getByDisplayValue('Test Dataset')
+
+ // Simulate rapid typing
+ fireEvent.change(nameInput, { target: { value: 'N' } })
+ fireEvent.change(nameInput, { target: { value: 'Ne' } })
+ fireEvent.change(nameInput, { target: { value: 'New' } })
+ fireEvent.change(nameInput, { target: { value: 'New ' } })
+ fireEvent.change(nameInput, { target: { value: 'New N' } })
+ fireEvent.change(nameInput, { target: { value: 'New Na' } })
+ fireEvent.change(nameInput, { target: { value: 'New Nam' } })
+ fireEvent.change(nameInput, { target: { value: 'New Name' } })
+
+ expect(screen.getByDisplayValue('New Name')).toBeInTheDocument()
+ })
+
+ it('should handle double click on save button', async () => {
+ // Use a promise we can control to ensure the first click is still "loading"
+ let resolvePromise: (value: DataSet) => void
+ mockUpdateDatasetSetting.mockImplementationOnce(() => new Promise((resolve) => {
+ resolvePromise = resolve
+ }))
+
+ render()
+
+ const saveButton = screen.getByText('common.operation.save')
+
+ // First click
+ await act(async () => {
+ fireEvent.click(saveButton)
+ })
+
+ // Button should be disabled now
+ expect(saveButton).toBeDisabled()
+
+ // Second click should not trigger another API call because button is disabled
+ await act(async () => {
+ fireEvent.click(saveButton)
+ })
+
+ // Only one API call should have been made
+ expect(mockUpdateDatasetSetting).toHaveBeenCalledTimes(1)
+
+ // Clean up by resolving the promise
+ await act(async () => {
+ resolvePromise!(createMockDataset())
+ })
+ })
+
+ it('should handle unicode characters in name', () => {
+ render()
+ const nameInput = screen.getByDisplayValue('Test Dataset')
+
+ fireEvent.change(nameInput, { target: { value: '数据集 🎉 Dataset' } })
+
+ expect(screen.getByDisplayValue('数据集 🎉 Dataset')).toBeInTheDocument()
+ })
+
+ it('should handle unicode characters in description', () => {
+ render()
+ const descriptionTextarea = screen.getByDisplayValue('Test description')
+
+ fireEvent.change(descriptionTextarea, { target: { value: '这是一个测试描述 🚀' } })
+
+ expect(screen.getByDisplayValue('这是一个测试描述 🚀')).toBeInTheDocument()
+ })
+
+ it('should preserve whitespace in description', () => {
+ render()
+ const descriptionTextarea = screen.getByDisplayValue('Test description')
+
+ const testValue = 'Leading spaces with content'
+ fireEvent.change(descriptionTextarea, { target: { value: testValue } })
+
+ expect(descriptionTextarea).toHaveValue(testValue)
+ })
+ })
+
+ describe('Component Re-rendering', () => {
+ it('should update when dataset prop changes', () => {
+ const { rerender } = render()
+
+ expect(screen.getByDisplayValue('Test Dataset')).toBeInTheDocument()
+
+ const newDataset = createMockDataset({ name: 'Different Dataset', description: 'Different description' })
+ rerender()
+
+ // Note: The component uses useState with initial value, so it won't update
+ // This tests that the initial render works correctly with different props
+ expect(screen.getByDisplayValue('Test Dataset')).toBeInTheDocument()
+ })
+
+ it('should handle show prop toggle', () => {
+ const { rerender } = render()
+
+ expect(screen.getByText('datasetSettings.title')).toBeInTheDocument()
+
+ rerender()
+
+ // Modal visibility is controlled by Modal component's isShow prop
+ // The modal content may still be in DOM but hidden
+ })
+ })
+
+ describe('Accessibility', () => {
+ it('should have accessible input elements', () => {
+ render()
+
+ // Check that inputs are present and accessible
+ const nameInput = screen.getByDisplayValue('Test Dataset')
+ expect(nameInput.tagName.toLowerCase()).toBe('input')
+
+ const descriptionTextarea = screen.getByDisplayValue('Test description')
+ expect(descriptionTextarea.tagName.toLowerCase()).toBe('textarea')
+ })
+
+ it('should have clickable buttons', () => {
+ render()
+
+ const cancelButton = screen.getByText('common.operation.cancel')
+ const saveButton = screen.getByText('common.operation.save')
+
+ expect(cancelButton).toBeEnabled()
+ expect(saveButton).toBeEnabled()
+ })
+ })
+
+ describe('Loading State', () => {
+ it('should show loading state during API call', async () => {
+ let resolvePromise: (value: DataSet) => void
+ mockUpdateDatasetSetting.mockImplementation(() => new Promise((resolve) => {
+ resolvePromise = resolve
+ }))
+
+ render()
+
+ const saveButton = screen.getByText('common.operation.save')
+ await act(async () => {
+ fireEvent.click(saveButton)
+ })
+
+ // Button should be disabled during loading
+ await waitFor(() => {
+ expect(saveButton).toBeDisabled()
+ })
+
+ // Resolve promise to complete the test
+ await act(async () => {
+ resolvePromise!(createMockDataset())
+ })
+
+ await waitFor(() => {
+ expect(saveButton).not.toBeDisabled()
+ })
+ })
+
+ it('should re-enable save button after successful save', async () => {
+ render()
+
+ const saveButton = screen.getByText('common.operation.save')
+ await act(async () => {
+ fireEvent.click(saveButton)
+ })
+
+ await waitFor(() => {
+ expect(mockUpdateDatasetSetting).toHaveBeenCalled()
+ })
+
+ // After success, the modal closes, but if it didn't, button would be re-enabled
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'success',
+ message: 'common.actionMsg.modifiedSuccessfully',
+ })
+ })
+
+ it('should re-enable save button after failed save', async () => {
+ mockUpdateDatasetSetting.mockRejectedValueOnce(new Error('API Error'))
+
+ render()
+
+ const saveButton = screen.getByText('common.operation.save')
+ await act(async () => {
+ fireEvent.click(saveButton)
+ })
+
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'common.actionMsg.modifiedUnsuccessfully',
+ })
+ })
+
+ // Button should be re-enabled after error
+ expect(saveButton).not.toBeDisabled()
+ })
+ })
+})
diff --git a/web/app/components/datasets/settings/chunk-structure/hooks.spec.tsx b/web/app/components/datasets/settings/chunk-structure/hooks.spec.tsx
new file mode 100644
index 0000000000..668d2f926f
--- /dev/null
+++ b/web/app/components/datasets/settings/chunk-structure/hooks.spec.tsx
@@ -0,0 +1,239 @@
+import { renderHook } from '@testing-library/react'
+import { useChunkStructure } from './hooks'
+import { EffectColor } from './types'
+
+// Note: react-i18next is globally mocked in vitest.setup.ts
+
+describe('useChunkStructure', () => {
+ describe('Hook Initialization', () => {
+ it('should return options array', () => {
+ const { result } = renderHook(() => useChunkStructure())
+
+ expect(result.current.options).toBeDefined()
+ expect(Array.isArray(result.current.options)).toBe(true)
+ })
+
+ it('should return exactly 3 options', () => {
+ const { result } = renderHook(() => useChunkStructure())
+
+ expect(result.current.options).toHaveLength(3)
+ })
+ })
+
+ describe('General Option', () => {
+ it('should have correct id for General option', () => {
+ const { result } = renderHook(() => useChunkStructure())
+
+ const generalOption = result.current.options[0]
+ expect(generalOption.id).toBe('text_model')
+ })
+
+ it('should have icon for General option', () => {
+ const { result } = renderHook(() => useChunkStructure())
+
+ const generalOption = result.current.options[0]
+ expect(generalOption.icon).toBeDefined()
+ })
+
+ it('should have correct iconActiveColor for General option', () => {
+ const { result } = renderHook(() => useChunkStructure())
+
+ const generalOption = result.current.options[0]
+ expect(generalOption.iconActiveColor).toBe('text-util-colors-indigo-indigo-600')
+ })
+
+ it('should have title for General option', () => {
+ const { result } = renderHook(() => useChunkStructure())
+
+ const generalOption = result.current.options[0]
+ expect(generalOption.title).toBe('General')
+ })
+
+ it('should have description for General option', () => {
+ const { result } = renderHook(() => useChunkStructure())
+
+ const generalOption = result.current.options[0]
+ expect(generalOption.description).toBeDefined()
+ })
+
+ it('should have indigo effectColor for General option', () => {
+ const { result } = renderHook(() => useChunkStructure())
+
+ const generalOption = result.current.options[0]
+ expect(generalOption.effectColor).toBe(EffectColor.indigo)
+ })
+
+ it('should have showEffectColor true for General option', () => {
+ const { result } = renderHook(() => useChunkStructure())
+
+ const generalOption = result.current.options[0]
+ expect(generalOption.showEffectColor).toBe(true)
+ })
+ })
+
+ describe('Parent-Child Option', () => {
+ it('should have correct id for Parent-Child option', () => {
+ const { result } = renderHook(() => useChunkStructure())
+
+ const parentChildOption = result.current.options[1]
+ expect(parentChildOption.id).toBe('hierarchical_model')
+ })
+
+ it('should have icon for Parent-Child option', () => {
+ const { result } = renderHook(() => useChunkStructure())
+
+ const parentChildOption = result.current.options[1]
+ expect(parentChildOption.icon).toBeDefined()
+ })
+
+ it('should have correct iconActiveColor for Parent-Child option', () => {
+ const { result } = renderHook(() => useChunkStructure())
+
+ const parentChildOption = result.current.options[1]
+ expect(parentChildOption.iconActiveColor).toBe('text-util-colors-blue-light-blue-light-500')
+ })
+
+ it('should have title for Parent-Child option', () => {
+ const { result } = renderHook(() => useChunkStructure())
+
+ const parentChildOption = result.current.options[1]
+ expect(parentChildOption.title).toBe('Parent-Child')
+ })
+
+ it('should have description for Parent-Child option', () => {
+ const { result } = renderHook(() => useChunkStructure())
+
+ const parentChildOption = result.current.options[1]
+ expect(parentChildOption.description).toBeDefined()
+ })
+
+ it('should have blueLight effectColor for Parent-Child option', () => {
+ const { result } = renderHook(() => useChunkStructure())
+
+ const parentChildOption = result.current.options[1]
+ expect(parentChildOption.effectColor).toBe(EffectColor.blueLight)
+ })
+
+ it('should have showEffectColor true for Parent-Child option', () => {
+ const { result } = renderHook(() => useChunkStructure())
+
+ const parentChildOption = result.current.options[1]
+ expect(parentChildOption.showEffectColor).toBe(true)
+ })
+ })
+
+ describe('Q&A Option', () => {
+ it('should have correct id for Q&A option', () => {
+ const { result } = renderHook(() => useChunkStructure())
+
+ const qaOption = result.current.options[2]
+ expect(qaOption.id).toBe('qa_model')
+ })
+
+ it('should have icon for Q&A option', () => {
+ const { result } = renderHook(() => useChunkStructure())
+
+ const qaOption = result.current.options[2]
+ expect(qaOption.icon).toBeDefined()
+ })
+
+ it('should have title for Q&A option', () => {
+ const { result } = renderHook(() => useChunkStructure())
+
+ const qaOption = result.current.options[2]
+ expect(qaOption.title).toBe('Q&A')
+ })
+
+ it('should have description for Q&A option', () => {
+ const { result } = renderHook(() => useChunkStructure())
+
+ const qaOption = result.current.options[2]
+ expect(qaOption.description).toBeDefined()
+ })
+
+ it('should not have effectColor for Q&A option', () => {
+ const { result } = renderHook(() => useChunkStructure())
+
+ const qaOption = result.current.options[2]
+ expect(qaOption.effectColor).toBeUndefined()
+ })
+
+ it('should not have showEffectColor for Q&A option', () => {
+ const { result } = renderHook(() => useChunkStructure())
+
+ const qaOption = result.current.options[2]
+ expect(qaOption.showEffectColor).toBeUndefined()
+ })
+
+ it('should not have iconActiveColor for Q&A option', () => {
+ const { result } = renderHook(() => useChunkStructure())
+
+ const qaOption = result.current.options[2]
+ expect(qaOption.iconActiveColor).toBeUndefined()
+ })
+ })
+
+ describe('Options Structure', () => {
+ it('should return options in correct order', () => {
+ const { result } = renderHook(() => useChunkStructure())
+
+ const ids = result.current.options.map(opt => opt.id)
+ expect(ids).toEqual(['text_model', 'hierarchical_model', 'qa_model'])
+ })
+
+ it('should return all options with required id property', () => {
+ const { result } = renderHook(() => useChunkStructure())
+
+ result.current.options.forEach((option) => {
+ expect(option.id).toBeDefined()
+ })
+ })
+
+ it('should return all options with required title property', () => {
+ const { result } = renderHook(() => useChunkStructure())
+
+ result.current.options.forEach((option) => {
+ expect(option.title).toBeDefined()
+ expect(typeof option.title).toBe('string')
+ })
+ })
+
+ it('should return all options with description property', () => {
+ const { result } = renderHook(() => useChunkStructure())
+
+ result.current.options.forEach((option) => {
+ expect(option.description).toBeDefined()
+ })
+ })
+
+ it('should return all options with icon property', () => {
+ const { result } = renderHook(() => useChunkStructure())
+
+ result.current.options.forEach((option) => {
+ expect(option.icon).toBeDefined()
+ })
+ })
+ })
+
+ describe('Hook Stability', () => {
+ it('should return consistent options on multiple renders', () => {
+ const { result, rerender } = renderHook(() => useChunkStructure())
+
+ const firstRenderOptions = result.current.options.map(opt => opt.id)
+ rerender()
+ const secondRenderOptions = result.current.options.map(opt => opt.id)
+
+ expect(firstRenderOptions).toEqual(secondRenderOptions)
+ })
+
+ it('should return options with stable structure', () => {
+ const { result, rerender } = renderHook(() => useChunkStructure())
+
+ const firstLength = result.current.options.length
+ rerender()
+ const secondLength = result.current.options.length
+
+ expect(firstLength).toBe(secondLength)
+ })
+ })
+})
diff --git a/web/app/components/datasets/settings/chunk-structure/index.spec.tsx b/web/app/components/datasets/settings/chunk-structure/index.spec.tsx
index 878018408d..0206617c94 100644
--- a/web/app/components/datasets/settings/chunk-structure/index.spec.tsx
+++ b/web/app/components/datasets/settings/chunk-structure/index.spec.tsx
@@ -2,84 +2,124 @@ import { render, screen } from '@testing-library/react'
import { ChunkingMode } from '@/models/datasets'
import ChunkStructure from './index'
-type MockOptionCardProps = {
- id: string
- title: string
- isActive?: boolean
- disabled?: boolean
-}
-
-// Mock dependencies
-vi.mock('../option-card', () => ({
- default: ({ id, title, isActive, disabled }: MockOptionCardProps) => (
-
- {title}
-
- ),
-}))
-
-// Mock hook
-vi.mock('./hooks', () => ({
- useChunkStructure: () => ({
- options: [
- {
- id: ChunkingMode.text,
- title: 'General',
- description: 'General description',
- icon: ,
- effectColor: 'indigo',
- iconActiveColor: 'indigo',
- },
- {
- id: ChunkingMode.parentChild,
- title: 'Parent-Child',
- description: 'PC description',
- icon: ,
- effectColor: 'blue',
- iconActiveColor: 'blue',
- },
- ],
- }),
-}))
+// Note: react-i18next is globally mocked in vitest.setup.ts
describe('ChunkStructure', () => {
- it('should render all options', () => {
- render()
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByText('General')).toBeInTheDocument()
+ })
- const options = screen.getAllByTestId('option-card')
- expect(options).toHaveLength(2)
- expect(options[0]).toHaveTextContent('General')
- expect(options[1]).toHaveTextContent('Parent-Child')
+ it('should render all three options', () => {
+ render()
+ expect(screen.getByText('General')).toBeInTheDocument()
+ expect(screen.getByText('Parent-Child')).toBeInTheDocument()
+ expect(screen.getByText('Q&A')).toBeInTheDocument()
+ })
+
+ it('should render in a vertical layout', () => {
+ const { container } = render()
+ const wrapper = container.firstChild
+ expect(wrapper).toHaveClass('flex-col')
+ })
})
- it('should set active state correctly', () => {
- // Render with 'text' active
- const { unmount } = render()
+ describe('Active State', () => {
+ it('should mark General option as active when chunkStructure is text', () => {
+ const { container } = render()
+ // The active card has ring styling
+ const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
+ expect(activeCards).toHaveLength(1)
+ })
- const options = screen.getAllByTestId('option-card')
- expect(options[0]).toHaveAttribute('data-active', 'true')
- expect(options[1]).toHaveAttribute('data-active', 'false')
+ it('should mark Parent-Child option as active when chunkStructure is parentChild', () => {
+ const { container } = render()
+ const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
+ expect(activeCards).toHaveLength(1)
+ })
- unmount()
-
- // Render with 'parentChild' active
- render()
- const newOptions = screen.getAllByTestId('option-card')
- expect(newOptions[0]).toHaveAttribute('data-active', 'false')
- expect(newOptions[1]).toHaveAttribute('data-active', 'true')
+ it('should mark Q&A option as active when chunkStructure is qa', () => {
+ const { container } = render()
+ const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
+ expect(activeCards).toHaveLength(1)
+ })
})
- it('should be always disabled', () => {
- render()
+ describe('Disabled State', () => {
+ it('should render all options as disabled', () => {
+ const { container } = render()
+ // All cards should have cursor-not-allowed (disabled)
+ const disabledCards = container.querySelectorAll('.cursor-not-allowed')
+ expect(disabledCards.length).toBeGreaterThan(0)
+ })
+ })
- const options = screen.getAllByTestId('option-card')
- options.forEach((option) => {
- expect(option).toHaveAttribute('data-disabled', 'true')
+ describe('Option Cards', () => {
+ it('should render option cards with correct structure', () => {
+ render()
+
+ // All options should have descriptions
+ expect(screen.getByText(/stepTwo\.generalTip/)).toBeInTheDocument()
+ expect(screen.getByText(/stepTwo\.parentChildTip/)).toBeInTheDocument()
+ expect(screen.getByText(/stepTwo\.qaTip/)).toBeInTheDocument()
+ })
+
+ it('should render icons for all options', () => {
+ const { container } = render()
+ // Each option card should have an icon (SVG elements)
+ const svgs = container.querySelectorAll('svg')
+ expect(svgs.length).toBeGreaterThanOrEqual(3) // At least 3 icons
+ })
+ })
+
+ describe('Effect Colors', () => {
+ it('should show effect color for active General option', () => {
+ const { container } = render()
+ const effectElement = container.querySelector('.bg-util-colors-indigo-indigo-600')
+ expect(effectElement).toBeInTheDocument()
+ })
+
+ it('should show effect color for active Parent-Child option', () => {
+ const { container } = render()
+ const effectElement = container.querySelector('.bg-util-colors-blue-light-blue-light-600')
+ expect(effectElement).toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should update active state when chunkStructure prop changes', () => {
+ const { rerender, container } = render()
+
+ // Initially one card is active
+ let activeCards = container.querySelectorAll('.ring-\\[1px\\]')
+ expect(activeCards).toHaveLength(1)
+
+ // Change to parentChild
+ rerender()
+
+ // Still one card should be active
+ activeCards = container.querySelectorAll('.ring-\\[1px\\]')
+ expect(activeCards).toHaveLength(1)
+
+ // Change to qa
+ rerender()
+
+ // Still one card should be active
+ activeCards = container.querySelectorAll('.ring-\\[1px\\]')
+ expect(activeCards).toHaveLength(1)
+ })
+ })
+
+ describe('Integration with useChunkStructure hook', () => {
+ it('should use options from useChunkStructure hook', () => {
+ render()
+
+ // Verify all expected options are rendered
+ const expectedTitles = ['General', 'Parent-Child', 'Q&A']
+ expectedTitles.forEach((title) => {
+ expect(screen.getByText(title)).toBeInTheDocument()
+ })
})
})
})
diff --git a/web/app/components/datasets/settings/index-method/index.spec.tsx b/web/app/components/datasets/settings/index-method/index.spec.tsx
new file mode 100644
index 0000000000..dbb886c676
--- /dev/null
+++ b/web/app/components/datasets/settings/index-method/index.spec.tsx
@@ -0,0 +1,208 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { IndexingType } from '../../create/step-two'
+import IndexMethod from './index'
+
+// Note: react-i18next is globally mocked in vitest.setup.ts
+
+describe('IndexMethod', () => {
+ const defaultProps = {
+ value: IndexingType.QUALIFIED,
+ onChange: vi.fn(),
+ keywordNumber: 10,
+ onKeywordNumberChange: vi.fn(),
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByText(/stepTwo\.qualified/)).toBeInTheDocument()
+ })
+
+ it('should render High Quality option', () => {
+ render()
+ expect(screen.getByText(/stepTwo\.qualified/)).toBeInTheDocument()
+ })
+
+ it('should render Economy option', () => {
+ render()
+ expect(screen.getAllByText(/form\.indexMethodEconomy/).length).toBeGreaterThan(0)
+ })
+
+ it('should render High Quality description', () => {
+ render()
+ expect(screen.getByText(/form\.indexMethodHighQualityTip/)).toBeInTheDocument()
+ })
+
+ it('should render Economy description', () => {
+ render()
+ expect(screen.getByText(/form\.indexMethodEconomyTip/)).toBeInTheDocument()
+ })
+
+ it('should render recommended badge on High Quality', () => {
+ render()
+ expect(screen.getByText(/stepTwo\.recommend/)).toBeInTheDocument()
+ })
+ })
+
+ describe('Active State', () => {
+ it('should mark High Quality as active when value is QUALIFIED', () => {
+ const { container } = render()
+ const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
+ expect(activeCards).toHaveLength(1)
+ })
+
+ it('should mark Economy as active when value is ECONOMICAL', () => {
+ const { container } = render()
+ const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
+ expect(activeCards).toHaveLength(1)
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onChange with QUALIFIED when High Quality is clicked', () => {
+ const handleChange = vi.fn()
+ render()
+
+ // Find and click High Quality option
+ const highQualityTitle = screen.getByText(/stepTwo\.qualified/)
+ const card = highQualityTitle.closest('div')?.parentElement?.parentElement?.parentElement
+ fireEvent.click(card!)
+
+ expect(handleChange).toHaveBeenCalledWith(IndexingType.QUALIFIED)
+ })
+
+ it('should call onChange with ECONOMICAL when Economy is clicked', () => {
+ const handleChange = vi.fn()
+ render()
+
+ // Find and click Economy option - use getAllByText and get the first one (title)
+ const economyTitles = screen.getAllByText(/form\.indexMethodEconomy/)
+ const economyTitle = economyTitles[0]
+ const card = economyTitle.closest('div')?.parentElement?.parentElement?.parentElement
+ fireEvent.click(card!)
+
+ expect(handleChange).toHaveBeenCalledWith(IndexingType.ECONOMICAL)
+ })
+
+ it('should not call onChange when clicking already active option', () => {
+ const handleChange = vi.fn()
+ render()
+
+ // Click on already active High Quality
+ const highQualityTitle = screen.getByText(/stepTwo\.qualified/)
+ const card = highQualityTitle.closest('div')?.parentElement?.parentElement?.parentElement
+ fireEvent.click(card!)
+
+ expect(handleChange).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Disabled State', () => {
+ it('should disable both options when disabled is true', () => {
+ const { container } = render()
+ const disabledCards = container.querySelectorAll('.cursor-not-allowed')
+ expect(disabledCards.length).toBeGreaterThan(0)
+ })
+
+ it('should disable Economy option when currentValue is QUALIFIED', () => {
+ const handleChange = vi.fn()
+ render()
+
+ // Try to click Economy option - use getAllByText and get the first one (title)
+ const economyTitles = screen.getAllByText(/form\.indexMethodEconomy/)
+ const economyTitle = economyTitles[0]
+ const card = economyTitle.closest('div')?.parentElement?.parentElement?.parentElement
+ fireEvent.click(card!)
+
+ // Should not call onChange because Economy is disabled when current is QUALIFIED
+ expect(handleChange).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('KeywordNumber', () => {
+ it('should render KeywordNumber component inside Economy option', () => {
+ render()
+ // KeywordNumber has a slider
+ expect(screen.getByRole('slider')).toBeInTheDocument()
+ })
+
+ it('should pass keywordNumber to KeywordNumber component', () => {
+ render()
+ const input = screen.getByRole('spinbutton')
+ expect(input).toHaveValue(25)
+ })
+
+ it('should call onKeywordNumberChange when KeywordNumber changes', () => {
+ const handleKeywordChange = vi.fn()
+ render()
+
+ const input = screen.getByRole('spinbutton')
+ fireEvent.change(input, { target: { value: '30' } })
+
+ expect(handleKeywordChange).toHaveBeenCalled()
+ })
+ })
+
+ describe('Tooltip', () => {
+ it('should show tooltip when hovering over disabled Economy option', () => {
+ // The tooltip is shown via PortalToFollowElem when hovering
+ // This is controlled by useHover hook
+ render()
+ // The tooltip content should exist in DOM but may not be visible
+ // We just verify the component renders without error
+ expect(screen.getAllByText(/form\.indexMethodEconomy/).length).toBeGreaterThan(0)
+ })
+ })
+
+ describe('Effect Colors', () => {
+ it('should show orange effect color for High Quality option', () => {
+ const { container } = render()
+ const orangeEffect = container.querySelector('.bg-util-colors-orange-orange-500')
+ expect(orangeEffect).toBeInTheDocument()
+ })
+
+ it('should show indigo effect color for Economy option', () => {
+ const { container } = render()
+ const indigoEffect = container.querySelector('.bg-util-colors-indigo-indigo-600')
+ expect(indigoEffect).toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should update active state when value prop changes', () => {
+ const { rerender, container } = render()
+
+ let activeCards = container.querySelectorAll('.ring-\\[1px\\]')
+ expect(activeCards).toHaveLength(1)
+
+ rerender()
+
+ activeCards = container.querySelectorAll('.ring-\\[1px\\]')
+ expect(activeCards).toHaveLength(1)
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle undefined currentValue', () => {
+ render()
+ // Should render without error
+ expect(screen.getByText(/stepTwo\.qualified/)).toBeInTheDocument()
+ })
+
+ it('should handle keywordNumber of 0', () => {
+ render()
+ const input = screen.getByRole('spinbutton')
+ expect(input).toHaveValue(0)
+ })
+
+ it('should handle max keywordNumber', () => {
+ render()
+ const input = screen.getByRole('spinbutton')
+ expect(input).toHaveValue(50)
+ })
+ })
+})
diff --git a/web/app/components/datasets/settings/index-method/keyword-number.spec.tsx b/web/app/components/datasets/settings/index-method/keyword-number.spec.tsx
new file mode 100644
index 0000000000..f0f7f69de5
--- /dev/null
+++ b/web/app/components/datasets/settings/index-method/keyword-number.spec.tsx
@@ -0,0 +1,171 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import KeyWordNumber from './keyword-number'
+
+// Note: react-i18next is globally mocked in vitest.setup.ts
+
+describe('KeyWordNumber', () => {
+ const defaultProps = {
+ keywordNumber: 10,
+ onKeywordNumberChange: vi.fn(),
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByText(/form\.numberOfKeywords/)).toBeInTheDocument()
+ })
+
+ it('should render label text', () => {
+ render()
+ expect(screen.getByText(/form\.numberOfKeywords/)).toBeInTheDocument()
+ })
+
+ it('should render tooltip with question icon', () => {
+ render()
+ // RiQuestionLine renders as an svg
+ const container = screen.getByText(/form\.numberOfKeywords/).closest('div')?.parentElement
+ const questionIcon = container?.querySelector('svg')
+ expect(questionIcon).toBeInTheDocument()
+ })
+
+ it('should render slider', () => {
+ render()
+ // Slider has a slider role
+ expect(screen.getByRole('slider')).toBeInTheDocument()
+ })
+
+ it('should render input number field', () => {
+ render()
+ expect(screen.getByRole('spinbutton')).toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should display correct keywordNumber value in input', () => {
+ render()
+ const input = screen.getByRole('spinbutton')
+ expect(input).toHaveValue(25)
+ })
+
+ it('should display different keywordNumber values', () => {
+ const values = [1, 10, 25, 50]
+
+ values.forEach((value) => {
+ const { unmount } = render()
+ const input = screen.getByRole('spinbutton')
+ expect(input).toHaveValue(value)
+ unmount()
+ })
+ })
+
+ it('should pass correct value to slider', () => {
+ render()
+ const slider = screen.getByRole('slider')
+ expect(slider).toHaveAttribute('aria-valuenow', '30')
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should render slider that accepts onChange', () => {
+ const handleChange = vi.fn()
+ render()
+
+ const slider = screen.getByRole('slider')
+ // Verify slider is rendered and interactive
+ expect(slider).toBeInTheDocument()
+ expect(slider).not.toBeDisabled()
+ })
+
+ it('should call onKeywordNumberChange when input value changes', () => {
+ const handleChange = vi.fn()
+ render()
+
+ const input = screen.getByRole('spinbutton')
+ fireEvent.change(input, { target: { value: '30' } })
+
+ expect(handleChange).toHaveBeenCalled()
+ })
+
+ it('should not call onKeywordNumberChange with undefined value', () => {
+ const handleChange = vi.fn()
+ render()
+
+ const input = screen.getByRole('spinbutton')
+ fireEvent.change(input, { target: { value: '' } })
+
+ // When value is empty/undefined, handleInputChange should not call onKeywordNumberChange
+ expect(handleChange).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Slider Configuration', () => {
+ it('should have max value of 50', () => {
+ render()
+ const slider = screen.getByRole('slider')
+ expect(slider).toHaveAttribute('aria-valuemax', '50')
+ })
+
+ it('should have min value of 0', () => {
+ render()
+ const slider = screen.getByRole('slider')
+ expect(slider).toHaveAttribute('aria-valuemin', '0')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle minimum value (0)', () => {
+ render()
+ const input = screen.getByRole('spinbutton')
+ expect(input).toHaveValue(0)
+ })
+
+ it('should handle maximum value (50)', () => {
+ render()
+ const input = screen.getByRole('spinbutton')
+ expect(input).toHaveValue(50)
+ })
+
+ it('should handle value updates correctly', () => {
+ const { rerender } = render()
+
+ let input = screen.getByRole('spinbutton')
+ expect(input).toHaveValue(10)
+
+ rerender()
+ input = screen.getByRole('spinbutton')
+ expect(input).toHaveValue(25)
+ })
+
+ it('should handle rapid value changes', () => {
+ const handleChange = vi.fn()
+ render()
+
+ const input = screen.getByRole('spinbutton')
+
+ // Simulate rapid changes via input with different values
+ fireEvent.change(input, { target: { value: '15' } })
+ fireEvent.change(input, { target: { value: '25' } })
+ fireEvent.change(input, { target: { value: '35' } })
+
+ expect(handleChange).toHaveBeenCalledTimes(3)
+ })
+ })
+
+ describe('Accessibility', () => {
+ it('should have accessible slider', () => {
+ render()
+ const slider = screen.getByRole('slider')
+ expect(slider).toBeInTheDocument()
+ })
+
+ it('should have accessible input', () => {
+ render()
+ const input = screen.getByRole('spinbutton')
+ expect(input).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/settings/option-card.spec.tsx b/web/app/components/datasets/settings/option-card.spec.tsx
new file mode 100644
index 0000000000..6fdcd8faa7
--- /dev/null
+++ b/web/app/components/datasets/settings/option-card.spec.tsx
@@ -0,0 +1,317 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { EffectColor } from './chunk-structure/types'
+import OptionCard from './option-card'
+
+// Note: react-i18next is globally mocked in vitest.setup.ts
+
+describe('OptionCard', () => {
+ const defaultProps = {
+ id: 'test-id',
+ title: 'Test Title',
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByText('Test Title')).toBeInTheDocument()
+ })
+
+ it('should render title', () => {
+ render()
+ expect(screen.getByText('Custom Title')).toBeInTheDocument()
+ })
+
+ it('should render description when provided', () => {
+ render()
+ expect(screen.getByText('Test Description')).toBeInTheDocument()
+ })
+
+ it('should not render description when not provided', () => {
+ render()
+ expect(screen.queryByText(/description/i)).not.toBeInTheDocument()
+ })
+
+ it('should render icon when provided', () => {
+ render(Icon} />)
+ expect(screen.getByTestId('test-icon')).toBeInTheDocument()
+ })
+
+ it('should not render icon container when icon is not provided', () => {
+ const { container } = render()
+ const iconContainers = container.querySelectorAll('.size-6')
+ expect(iconContainers).toHaveLength(0)
+ })
+ })
+
+ describe('Active State', () => {
+ it('should apply active styles when isActive is true', () => {
+ const { container } = render()
+ const card = container.firstChild
+ expect(card).toHaveClass('ring-[1px]')
+ })
+
+ it('should not apply active styles when isActive is false', () => {
+ const { container } = render()
+ const card = container.firstChild
+ expect(card).not.toHaveClass('ring-[1px]')
+ })
+
+ it('should apply iconActiveColor when isActive is true and icon is present', () => {
+ const { container } = render(
+ Icon}
+ iconActiveColor="text-red-500"
+ />,
+ )
+ const iconContainer = container.querySelector('.text-red-500')
+ expect(iconContainer).toBeInTheDocument()
+ })
+ })
+
+ describe('Disabled State', () => {
+ it('should apply disabled styles when disabled is true', () => {
+ const { container } = render()
+ const card = container.firstChild
+ expect(card).toHaveClass('cursor-not-allowed')
+ expect(card).toHaveClass('opacity-50')
+ })
+
+ it('should not call onClick when disabled', () => {
+ const handleClick = vi.fn()
+ render()
+
+ const card = screen.getByText('Test Title').closest('div')?.parentElement?.parentElement
+ fireEvent.click(card!)
+
+ expect(handleClick).not.toHaveBeenCalled()
+ })
+
+ it('should not call onClick when isActive', () => {
+ const handleClick = vi.fn()
+ render()
+
+ const card = screen.getByText('Test Title').closest('div')?.parentElement?.parentElement
+ fireEvent.click(card!)
+
+ expect(handleClick).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Recommended Badge', () => {
+ it('should render recommended badge when isRecommended is true', () => {
+ render()
+ // Badge uses translation key
+ expect(screen.getByText(/stepTwo\.recommend/)).toBeInTheDocument()
+ })
+
+ it('should not render recommended badge when isRecommended is false', () => {
+ render()
+ expect(screen.queryByText(/stepTwo\.recommend/)).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Effect Color', () => {
+ it('should render effect color when effectColor and showEffectColor are provided', () => {
+ const { container } = render(
+ ,
+ )
+ const effectElement = container.querySelector('.blur-\\[80px\\]')
+ expect(effectElement).toBeInTheDocument()
+ })
+
+ it('should not render effect color when showEffectColor is false', () => {
+ const { container } = render(
+ ,
+ )
+ const effectElement = container.querySelector('.blur-\\[80px\\]')
+ expect(effectElement).not.toBeInTheDocument()
+ })
+
+ it('should not render effect color when effectColor is not provided', () => {
+ const { container } = render(
+ ,
+ )
+ const effectElement = container.querySelector('.blur-\\[80px\\]')
+ expect(effectElement).not.toBeInTheDocument()
+ })
+
+ it('should apply indigo effect color class', () => {
+ const { container } = render(
+ ,
+ )
+ const effectElement = container.querySelector('.bg-util-colors-indigo-indigo-600')
+ expect(effectElement).toBeInTheDocument()
+ })
+
+ it('should apply blueLight effect color class', () => {
+ const { container } = render(
+ ,
+ )
+ const effectElement = container.querySelector('.bg-util-colors-blue-light-blue-light-600')
+ expect(effectElement).toBeInTheDocument()
+ })
+
+ it('should apply orange effect color class', () => {
+ const { container } = render(
+ ,
+ )
+ const effectElement = container.querySelector('.bg-util-colors-orange-orange-500')
+ expect(effectElement).toBeInTheDocument()
+ })
+
+ it('should apply purple effect color class', () => {
+ const { container } = render(
+ ,
+ )
+ const effectElement = container.querySelector('.bg-util-colors-purple-purple-600')
+ expect(effectElement).toBeInTheDocument()
+ })
+ })
+
+ describe('Children', () => {
+ it('should render children when children and showChildren are provided', () => {
+ render(
+
+ Child Content
+ ,
+ )
+ expect(screen.getByTestId('child-content')).toBeInTheDocument()
+ })
+
+ it('should not render children when showChildren is false', () => {
+ render(
+
+ Child Content
+ ,
+ )
+ expect(screen.queryByTestId('child-content')).not.toBeInTheDocument()
+ })
+
+ it('should not render children container when children is not provided', () => {
+ const { container } = render(
+ ,
+ )
+ const childContainer = container.querySelector('.bg-components-panel-bg')
+ expect(childContainer).not.toBeInTheDocument()
+ })
+
+ it('should render arrow shape when children are shown', () => {
+ const { container } = render(
+
+ Child
+ ,
+ )
+ // ArrowShape renders an SVG
+ const childSection = container.querySelector('.bg-components-panel-bg')
+ expect(childSection).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onClick with id when clicked', () => {
+ const handleClick = vi.fn()
+ render()
+
+ const card = screen.getByText('Test Title').closest('div')?.parentElement?.parentElement
+ fireEvent.click(card!)
+
+ expect(handleClick).toHaveBeenCalledWith('my-id')
+ })
+
+ it('should have cursor-pointer class', () => {
+ const { container } = render()
+ const card = container.firstChild
+ expect(card).toHaveClass('cursor-pointer')
+ })
+ })
+
+ describe('Props', () => {
+ it('should apply custom className', () => {
+ const { container } = render()
+ const innerContainer = container.querySelector('.custom-class')
+ expect(innerContainer).toBeInTheDocument()
+ })
+
+ it('should forward ref', () => {
+ const ref = vi.fn()
+ render()
+ expect(ref).toHaveBeenCalled()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty title', () => {
+ render()
+ // Component should still render
+ const { container } = render()
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should handle complex id types', () => {
+ const handleClick = vi.fn()
+ const complexId = { key: 'value' }
+ render()
+
+ const card = screen.getByText('Test Title').closest('div')?.parentElement?.parentElement
+ fireEvent.click(card!)
+
+ expect(handleClick).toHaveBeenCalledWith(complexId)
+ })
+
+ it('should handle numeric id', () => {
+ const handleClick = vi.fn()
+ render()
+
+ const card = screen.getByText('Test Title').closest('div')?.parentElement?.parentElement
+ fireEvent.click(card!)
+
+ expect(handleClick).toHaveBeenCalledWith(123)
+ })
+
+ it('should handle long title', () => {
+ const longTitle = 'A'.repeat(200)
+ render()
+ expect(screen.getByText(longTitle)).toBeInTheDocument()
+ })
+
+ it('should handle long description', () => {
+ const longDesc = 'B'.repeat(500)
+ render()
+ expect(screen.getByText(longDesc)).toBeInTheDocument()
+ })
+
+ it('should handle all props together', () => {
+ const handleClick = vi.fn()
+ render(
+ Icon}
+ iconActiveColor="text-blue-500"
+ isActive={true}
+ isRecommended={true}
+ effectColor={EffectColor.indigo}
+ showEffectColor={true}
+ disabled={false}
+ onClick={handleClick}
+ className="full-class"
+ showChildren={true}
+ >
+ Children
+ ,
+ )
+
+ expect(screen.getByText('Full Test')).toBeInTheDocument()
+ expect(screen.getByText('Full Description')).toBeInTheDocument()
+ expect(screen.getByTestId('full-icon')).toBeInTheDocument()
+ expect(screen.getByTestId('full-children')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/settings/permission-selector/index.spec.tsx b/web/app/components/datasets/settings/permission-selector/index.spec.tsx
new file mode 100644
index 0000000000..0e8a82c102
--- /dev/null
+++ b/web/app/components/datasets/settings/permission-selector/index.spec.tsx
@@ -0,0 +1,512 @@
+import type { Member } from '@/models/common'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { DatasetPermission } from '@/models/datasets'
+import PermissionSelector from './index'
+
+// Mock app-context
+vi.mock('@/context/app-context', () => ({
+ useSelector: () => ({
+ id: 'user-1',
+ name: 'Current User',
+ email: 'current@example.com',
+ avatar_url: '',
+ role: 'owner',
+ }),
+}))
+
+// Note: react-i18next is globally mocked in vitest.setup.ts
+
+describe('PermissionSelector', () => {
+ const mockMemberList: Member[] = [
+ { id: 'user-1', name: 'Current User', email: 'current@example.com', avatar: '', avatar_url: '', role: 'owner', last_login_at: '', created_at: '', status: 'active' },
+ { id: 'user-2', name: 'John Doe', email: 'john@example.com', avatar: '', avatar_url: '', role: 'admin', last_login_at: '', created_at: '', status: 'active' },
+ { id: 'user-3', name: 'Jane Smith', email: 'jane@example.com', avatar: '', avatar_url: '', role: 'editor', last_login_at: '', created_at: '', status: 'active' },
+ { id: 'user-4', name: 'Dataset Operator', email: 'operator@example.com', avatar: '', avatar_url: '', role: 'dataset_operator', last_login_at: '', created_at: '', status: 'active' },
+ ]
+
+ const defaultProps = {
+ permission: DatasetPermission.onlyMe,
+ value: ['user-1'],
+ memberList: mockMemberList,
+ onChange: vi.fn(),
+ onMemberSelect: vi.fn(),
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByText(/form\.permissionsOnlyMe/)).toBeInTheDocument()
+ })
+
+ it('should render Only Me option when permission is onlyMe', () => {
+ render()
+ expect(screen.getByText(/form\.permissionsOnlyMe/)).toBeInTheDocument()
+ })
+
+ it('should render All Team Members option when permission is allTeamMembers', () => {
+ render()
+ expect(screen.getByText(/form\.permissionsAllMember/)).toBeInTheDocument()
+ })
+
+ it('should render selected member names when permission is partialMembers', () => {
+ render(
+ ,
+ )
+ // Should show member names
+ expect(screen.getByTitle(/Current User/)).toBeInTheDocument()
+ })
+ })
+
+ describe('Dropdown Toggle', () => {
+ it('should open dropdown when clicked', async () => {
+ render()
+
+ const trigger = screen.getByText(/form\.permissionsOnlyMe/)
+ fireEvent.click(trigger)
+
+ await waitFor(() => {
+ // Should show all permission options in dropdown
+ expect(screen.getAllByText(/form\.permissionsOnlyMe/).length).toBeGreaterThanOrEqual(1)
+ })
+ })
+
+ it('should not open dropdown when disabled', () => {
+ render()
+
+ const trigger = screen.getByText(/form\.permissionsOnlyMe/)
+ fireEvent.click(trigger)
+
+ // Dropdown should not open - only the trigger text should be visible
+ expect(screen.getAllByText(/form\.permissionsOnlyMe/).length).toBe(1)
+ })
+ })
+
+ describe('Permission Selection', () => {
+ it('should call onChange with onlyMe when Only Me is selected', async () => {
+ const handleChange = vi.fn()
+ render()
+
+ // Open dropdown
+ const trigger = screen.getByText(/form\.permissionsAllMember/)
+ fireEvent.click(trigger)
+
+ await waitFor(() => {
+ // Click Only Me option
+ const onlyMeOptions = screen.getAllByText(/form\.permissionsOnlyMe/)
+ fireEvent.click(onlyMeOptions[0])
+ })
+
+ expect(handleChange).toHaveBeenCalledWith(DatasetPermission.onlyMe)
+ })
+
+ it('should call onChange with allTeamMembers when All Team Members is selected', async () => {
+ const handleChange = vi.fn()
+ render()
+
+ // Open dropdown
+ const trigger = screen.getByText(/form\.permissionsOnlyMe/)
+ fireEvent.click(trigger)
+
+ await waitFor(() => {
+ // Click All Team Members option
+ const allMemberOptions = screen.getAllByText(/form\.permissionsAllMember/)
+ fireEvent.click(allMemberOptions[0])
+ })
+
+ expect(handleChange).toHaveBeenCalledWith(DatasetPermission.allTeamMembers)
+ })
+
+ it('should call onChange with partialMembers when Invited Members is selected', async () => {
+ const handleChange = vi.fn()
+ const handleMemberSelect = vi.fn()
+ render(
+ ,
+ )
+
+ // Open dropdown
+ const trigger = screen.getByText(/form\.permissionsOnlyMe/)
+ fireEvent.click(trigger)
+
+ await waitFor(() => {
+ // Click Invited Members option
+ const invitedOptions = screen.getAllByText(/form\.permissionsInvitedMembers/)
+ fireEvent.click(invitedOptions[0])
+ })
+
+ expect(handleChange).toHaveBeenCalledWith(DatasetPermission.partialMembers)
+ expect(handleMemberSelect).toHaveBeenCalledWith(['user-1'])
+ })
+ })
+
+ describe('Member Selection', () => {
+ it('should show member list when partialMembers is selected', async () => {
+ render(
+ ,
+ )
+
+ // Open dropdown
+ const trigger = screen.getByTitle(/Current User/)
+ fireEvent.click(trigger)
+
+ await waitFor(() => {
+ // Should show member list
+ expect(screen.getByText('John Doe')).toBeInTheDocument()
+ expect(screen.getByText('Jane Smith')).toBeInTheDocument()
+ })
+ })
+
+ it('should call onMemberSelect when a member is clicked', async () => {
+ const handleMemberSelect = vi.fn()
+ render(
+ ,
+ )
+
+ // Open dropdown
+ const trigger = screen.getByTitle(/Current User/)
+ fireEvent.click(trigger)
+
+ await waitFor(() => {
+ // Click on John Doe
+ const johnDoe = screen.getByText('John Doe')
+ fireEvent.click(johnDoe)
+ })
+
+ expect(handleMemberSelect).toHaveBeenCalledWith(['user-1', 'user-2'])
+ })
+
+ it('should deselect member when clicked again', async () => {
+ const handleMemberSelect = vi.fn()
+ render(
+ ,
+ )
+
+ // Open dropdown
+ const trigger = screen.getByTitle(/Current User/)
+ fireEvent.click(trigger)
+
+ await waitFor(() => {
+ // Click on John Doe to deselect
+ const johnDoe = screen.getByText('John Doe')
+ fireEvent.click(johnDoe)
+ })
+
+ expect(handleMemberSelect).toHaveBeenCalledWith(['user-1'])
+ })
+ })
+
+ describe('Search Functionality', () => {
+ it('should allow typing in search input', async () => {
+ render(
+ ,
+ )
+
+ // Open dropdown
+ const trigger = screen.getByTitle(/Current User/)
+ fireEvent.click(trigger)
+
+ // Wait for dropdown to open
+ const searchInput = await screen.findByRole('textbox')
+
+ // Type in search
+ fireEvent.change(searchInput, { target: { value: 'John' } })
+ expect(searchInput).toHaveValue('John')
+ })
+
+ it('should render search input in partial members mode', async () => {
+ render(
+ ,
+ )
+
+ // Open dropdown
+ const trigger = screen.getByTitle(/Current User/)
+ fireEvent.click(trigger)
+
+ // Wait for dropdown to open and search input to be available
+ const searchInput = await screen.findByRole('textbox')
+ expect(searchInput).toBeInTheDocument()
+ })
+
+ it('should filter members after debounce completes', async () => {
+ render(
+ ,
+ )
+
+ // Open dropdown
+ const trigger = screen.getByTitle(/Current User/)
+ fireEvent.click(trigger)
+
+ // Wait for dropdown to open
+ const searchInput = await screen.findByRole('textbox')
+
+ // Type in search
+ fireEvent.change(searchInput, { target: { value: 'John' } })
+
+ // Wait for debounce (500ms) + buffer
+ await waitFor(
+ () => {
+ expect(screen.getByText('John Doe')).toBeInTheDocument()
+ },
+ { timeout: 1000 },
+ )
+ })
+
+ it('should handle clear search functionality', async () => {
+ render(
+ ,
+ )
+
+ // Open dropdown
+ const trigger = screen.getByTitle(/Current User/)
+ fireEvent.click(trigger)
+
+ // Wait for dropdown to open
+ const searchInput = await screen.findByRole('textbox')
+
+ // Type in search
+ fireEvent.change(searchInput, { target: { value: 'test' } })
+ expect(searchInput).toHaveValue('test')
+
+ // Click the clear button using data-testid
+ const clearButton = screen.getByTestId('input-clear')
+ fireEvent.click(clearButton)
+
+ // After clicking clear, input should be empty
+ await waitFor(() => {
+ expect(searchInput).toHaveValue('')
+ })
+ })
+
+ it('should filter members by email', async () => {
+ render(
+ ,
+ )
+
+ // Open dropdown
+ const trigger = screen.getByTitle(/Current User/)
+ fireEvent.click(trigger)
+
+ // Wait for dropdown to open
+ const searchInput = await screen.findByRole('textbox')
+
+ // Search by email
+ fireEvent.change(searchInput, { target: { value: 'john@example' } })
+
+ // Wait for debounce
+ await waitFor(
+ () => {
+ expect(screen.getByText('John Doe')).toBeInTheDocument()
+ },
+ { timeout: 1000 },
+ )
+ })
+
+ it('should show no results message when search matches nothing', async () => {
+ render(
+ ,
+ )
+
+ // Open dropdown
+ const trigger = screen.getByTitle(/Current User/)
+ fireEvent.click(trigger)
+
+ // Wait for dropdown to open
+ const searchInput = await screen.findByRole('textbox')
+
+ // Search for non-existent member
+ fireEvent.change(searchInput, { target: { value: 'nonexistent12345' } })
+
+ // Wait for debounce and no results message
+ await waitFor(
+ () => {
+ expect(screen.getByText(/form\.onSearchResults/)).toBeInTheDocument()
+ },
+ { timeout: 1000 },
+ )
+ })
+
+ it('should show current user when search matches user name', async () => {
+ render(
+ ,
+ )
+
+ // Open dropdown
+ const trigger = screen.getByTitle(/Current User/)
+ fireEvent.click(trigger)
+
+ // Wait for dropdown to open
+ const searchInput = await screen.findByRole('textbox')
+
+ // Search for current user by name - partial match
+ fireEvent.change(searchInput, { target: { value: 'Current' } })
+
+ // Current user (showMe) should remain visible based on name match
+ // The component uses useMemo to check if userProfile.name.includes(searchKeywords)
+ expect(searchInput).toHaveValue('Current')
+ // Current User label appears multiple times (trigger + member list)
+ expect(screen.getAllByText('Current User').length).toBeGreaterThanOrEqual(1)
+ })
+
+ it('should show current user when search matches user email', async () => {
+ render(
+ ,
+ )
+
+ // Open dropdown
+ const trigger = screen.getByTitle(/Current User/)
+ fireEvent.click(trigger)
+
+ // Wait for dropdown to open
+ const searchInput = await screen.findByRole('textbox')
+
+ // Search for current user by email
+ fireEvent.change(searchInput, { target: { value: 'current@' } })
+
+ // The component checks userProfile.email.includes(searchKeywords)
+ expect(searchInput).toHaveValue('current@')
+ // Current User should remain visible based on email match
+ expect(screen.getAllByText('Current User').length).toBeGreaterThanOrEqual(1)
+ })
+ })
+
+ describe('Disabled State', () => {
+ it('should apply disabled styles when disabled', () => {
+ const { container } = render()
+ // When disabled, the component has !cursor-not-allowed class (escaped in Tailwind)
+ const triggerElement = container.querySelector('[class*="cursor-not-allowed"]')
+ expect(triggerElement).toBeInTheDocument()
+ })
+ })
+
+ describe('Display Variations', () => {
+ it('should display single avatar when only one member selected', () => {
+ render(
+ ,
+ )
+
+ // Should display single avatar
+ expect(screen.getByTitle(/Current User/)).toBeInTheDocument()
+ })
+
+ it('should display two avatars when two or more members selected', () => {
+ render(
+ ,
+ )
+
+ // Should display member names
+ expect(screen.getByTitle(/Current User, John Doe/)).toBeInTheDocument()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty member list', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/form\.permissionsOnlyMe/)).toBeInTheDocument()
+ })
+
+ it('should handle member list with only current user', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/form\.permissionsOnlyMe/)).toBeInTheDocument()
+ })
+
+ it('should only show members with allowed roles', () => {
+ // The component filters members by role in useMemo
+ // Allowed roles are: owner, admin, editor, dataset_operator
+ // This is tested indirectly through the memberList filtering
+ const memberListWithNormalUser: Member[] = [
+ ...mockMemberList,
+ { id: 'user-5', name: 'Normal User', email: 'normal@example.com', avatar: '', avatar_url: '', role: 'normal', last_login_at: '', created_at: '', status: 'active' },
+ ]
+
+ render(
+ ,
+ )
+
+ // The component renders - the filtering logic is internal
+ expect(screen.getByTitle(/Current User/)).toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should update when permission prop changes', () => {
+ const { rerender } = render()
+
+ expect(screen.getByText(/form\.permissionsOnlyMe/)).toBeInTheDocument()
+
+ rerender()
+
+ expect(screen.getByText(/form\.permissionsAllMember/)).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/settings/permission-selector/member-item.spec.tsx b/web/app/components/datasets/settings/permission-selector/member-item.spec.tsx
new file mode 100644
index 0000000000..02d453db7c
--- /dev/null
+++ b/web/app/components/datasets/settings/permission-selector/member-item.spec.tsx
@@ -0,0 +1,195 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import MemberItem from './member-item'
+
+// Note: react-i18next is globally mocked in vitest.setup.ts
+
+describe('MemberItem', () => {
+ const defaultProps = {
+ leftIcon: Avatar,
+ name: 'John Doe',
+ email: 'john@example.com',
+ isSelected: false,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByText('John Doe')).toBeInTheDocument()
+ })
+
+ it('should render left icon (avatar)', () => {
+ render()
+ expect(screen.getByTestId('avatar-icon')).toBeInTheDocument()
+ })
+
+ it('should render member name', () => {
+ render()
+ expect(screen.getByText('John Doe')).toBeInTheDocument()
+ })
+
+ it('should render member email', () => {
+ render()
+ expect(screen.getByText('john@example.com')).toBeInTheDocument()
+ })
+ })
+
+ describe('Selection State', () => {
+ it('should show checkmark icon when selected', () => {
+ render()
+ const container = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
+ const checkIcon = container?.querySelector('svg')
+ expect(checkIcon).toBeInTheDocument()
+ })
+
+ it('should not show checkmark icon when not selected', () => {
+ render()
+ const container = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
+ const checkIcon = container?.querySelector('svg')
+ expect(checkIcon).not.toBeInTheDocument()
+ })
+
+ it('should apply opacity class to checkmark when isMe is true', () => {
+ render()
+ const container = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
+ const checkIcon = container?.querySelector('svg')
+ expect(checkIcon).toHaveClass('opacity-30')
+ })
+ })
+
+ describe('isMe Flag', () => {
+ it('should show me indicator when isMe is true', () => {
+ render()
+ // The translation key is 'form.me' which will be rendered by the mock
+ expect(screen.getByText(/form\.me/)).toBeInTheDocument()
+ })
+
+ it('should not show me indicator when isMe is false', () => {
+ render()
+ expect(screen.queryByText(/form\.me/)).not.toBeInTheDocument()
+ })
+
+ it('should not show me indicator by default', () => {
+ render()
+ expect(screen.queryByText(/form\.me/)).not.toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onClick when clicked', () => {
+ const handleClick = vi.fn()
+ render()
+
+ const item = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
+ fireEvent.click(item!)
+
+ expect(handleClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('should not throw when onClick is not provided', () => {
+ render()
+
+ const item = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
+ expect(() => fireEvent.click(item!)).not.toThrow()
+ })
+
+ it('should have cursor-pointer class for interactivity', () => {
+ render()
+ const item = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
+ expect(item).toHaveClass('cursor-pointer')
+ })
+ })
+
+ describe('Props', () => {
+ it('should render different names', () => {
+ const names = ['Alice', 'Bob', 'Charlie']
+
+ names.forEach((name) => {
+ const { unmount } = render()
+ expect(screen.getByText(name)).toBeInTheDocument()
+ unmount()
+ })
+ })
+
+ it('should render different emails', () => {
+ const emails = ['alice@test.com', 'bob@company.org', 'charlie@domain.net']
+
+ emails.forEach((email) => {
+ const { unmount } = render()
+ expect(screen.getByText(email)).toBeInTheDocument()
+ unmount()
+ })
+ })
+
+ it('should render different left icons', () => {
+ const customIcon =
+ render()
+ expect(screen.getByTestId('custom-avatar')).toBeInTheDocument()
+ })
+
+ it('should handle isSelected toggle correctly', () => {
+ const { rerender } = render()
+
+ // Initially not selected
+ let container = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
+ expect(container?.querySelector('svg')).not.toBeInTheDocument()
+
+ // Update to selected
+ rerender()
+ container = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
+ expect(container?.querySelector('svg')).toBeInTheDocument()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty name', () => {
+ render()
+ expect(screen.getByText('john@example.com')).toBeInTheDocument()
+ })
+
+ it('should handle empty email', () => {
+ render()
+ expect(screen.getByText('John Doe')).toBeInTheDocument()
+ })
+
+ it('should handle long name with truncation', () => {
+ const longName = 'A'.repeat(100)
+ render()
+ const nameElement = screen.getByText(longName)
+ expect(nameElement).toHaveClass('truncate')
+ })
+
+ it('should handle long email with truncation', () => {
+ const longEmail = `${'a'.repeat(50)}@${'b'.repeat(50)}.com`
+ render()
+ const emailElement = screen.getByText(longEmail)
+ expect(emailElement).toHaveClass('truncate')
+ })
+
+ it('should handle special characters in name', () => {
+ const specialName = 'O\'Connor-Smith'
+ render()
+ expect(screen.getByText(specialName)).toBeInTheDocument()
+ })
+
+ it('should handle unicode characters', () => {
+ const unicodeName = '张三'
+ const unicodeEmail = '张三@example.com'
+ render()
+ expect(screen.getByText(unicodeName)).toBeInTheDocument()
+ expect(screen.getByText(unicodeEmail)).toBeInTheDocument()
+ })
+
+ it('should render both isMe and isSelected together', () => {
+ render()
+ expect(screen.getByText(/form\.me/)).toBeInTheDocument()
+ const container = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
+ const checkIcon = container?.querySelector('svg')
+ expect(checkIcon).toBeInTheDocument()
+ expect(checkIcon).toHaveClass('opacity-30')
+ })
+ })
+})
diff --git a/web/app/components/datasets/settings/permission-selector/permission-item.spec.tsx b/web/app/components/datasets/settings/permission-selector/permission-item.spec.tsx
new file mode 100644
index 0000000000..5f6b881bd4
--- /dev/null
+++ b/web/app/components/datasets/settings/permission-selector/permission-item.spec.tsx
@@ -0,0 +1,130 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import PermissionItem from './permission-item'
+
+describe('PermissionItem', () => {
+ const defaultProps = {
+ leftIcon: Icon,
+ text: 'Test Permission',
+ onClick: vi.fn(),
+ isSelected: false,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByText('Test Permission')).toBeInTheDocument()
+ })
+
+ it('should render left icon', () => {
+ render()
+ expect(screen.getByTestId('left-icon')).toBeInTheDocument()
+ })
+
+ it('should render text content', () => {
+ const text = 'Custom Permission Text'
+ render()
+ expect(screen.getByText(text)).toBeInTheDocument()
+ })
+ })
+
+ describe('Selection State', () => {
+ it('should show checkmark icon when selected', () => {
+ render()
+ // RiCheckLine renders as an svg element
+ const container = screen.getByText('Test Permission').closest('div')?.parentElement
+ const checkIcon = container?.querySelector('svg')
+ expect(checkIcon).toBeInTheDocument()
+ })
+
+ it('should not show checkmark icon when not selected', () => {
+ render()
+ const container = screen.getByText('Test Permission').closest('div')?.parentElement
+ const checkIcon = container?.querySelector('svg')
+ expect(checkIcon).not.toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onClick when clicked', () => {
+ const handleClick = vi.fn()
+ render()
+
+ const item = screen.getByText('Test Permission').closest('div')?.parentElement
+ fireEvent.click(item!)
+
+ expect(handleClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('should have cursor-pointer class for interactivity', () => {
+ render()
+ const item = screen.getByText('Test Permission').closest('div')?.parentElement
+ expect(item).toHaveClass('cursor-pointer')
+ })
+ })
+
+ describe('Props', () => {
+ it('should render different left icons', () => {
+ const customIcon = Custom
+ render()
+ expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
+ })
+
+ it('should handle different text values', () => {
+ const texts = ['Only Me', 'All Team Members', 'Invited Members']
+
+ texts.forEach((text) => {
+ const { unmount } = render()
+ expect(screen.getByText(text)).toBeInTheDocument()
+ unmount()
+ })
+ })
+
+ it('should handle isSelected toggle correctly', () => {
+ const { rerender } = render()
+
+ // Initially not selected - no checkmark
+ let container = screen.getByText('Test Permission').closest('div')?.parentElement
+ expect(container?.querySelector('svg')).not.toBeInTheDocument()
+
+ // Update to selected
+ rerender()
+ container = screen.getByText('Test Permission').closest('div')?.parentElement
+ expect(container?.querySelector('svg')).toBeInTheDocument()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty text', () => {
+ render()
+ // The component should still render
+ expect(screen.getByTestId('left-icon')).toBeInTheDocument()
+ })
+
+ it('should handle long text content', () => {
+ const longText = 'A'.repeat(200)
+ render()
+ expect(screen.getByText(longText)).toBeInTheDocument()
+ })
+
+ it('should handle special characters in text', () => {
+ const specialText = ''
+ render()
+ expect(screen.getByText(specialText)).toBeInTheDocument()
+ })
+
+ it('should handle complex left icon nodes', () => {
+ const complexIcon = (
+
+ )
+ render()
+ expect(screen.getByTestId('complex-icon')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/settings/utils/index.spec.ts b/web/app/components/datasets/settings/utils/index.spec.ts
new file mode 100644
index 0000000000..5a9099e51f
--- /dev/null
+++ b/web/app/components/datasets/settings/utils/index.spec.ts
@@ -0,0 +1,297 @@
+import type { DefaultModel, Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
+import { ConfigurationMethodEnum, ModelFeatureEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
+import { IndexingType } from '../../create/step-two'
+import { checkShowMultiModalTip } from './index'
+
+describe('checkShowMultiModalTip', () => {
+ // Helper to create a model item with specific features
+ const createModelItem = (model: string, features: ModelFeatureEnum[] = []): ModelItem => ({
+ model,
+ label: { en_US: model, zh_Hans: model },
+ model_type: ModelTypeEnum.textEmbedding,
+ features,
+ fetch_from: ConfigurationMethodEnum.predefinedModel,
+ status: ModelStatusEnum.active,
+ model_properties: {},
+ load_balancing_enabled: false,
+ deprecated: false,
+ })
+
+ // Helper to create a model provider
+ const createModelProvider = (provider: string, models: ModelItem[]): Model => ({
+ provider,
+ label: { en_US: provider, zh_Hans: provider },
+ icon_small: { en_US: '', zh_Hans: '' },
+ status: ModelStatusEnum.active,
+ models,
+ })
+
+ const defaultProps = {
+ embeddingModel: {
+ provider: 'openai',
+ model: 'text-embedding-ada-002',
+ } as DefaultModel,
+ rerankingEnable: true,
+ rerankModel: {
+ rerankingProviderName: 'cohere',
+ rerankingModelName: 'rerank-english-v2.0',
+ },
+ indexMethod: IndexingType.QUALIFIED,
+ embeddingModelList: [
+ createModelProvider('openai', [
+ createModelItem('text-embedding-ada-002', [ModelFeatureEnum.vision]),
+ ]),
+ ],
+ rerankModelList: [
+ createModelProvider('cohere', [
+ createModelItem('rerank-english-v2.0', []),
+ ]),
+ ],
+ }
+
+ describe('Return false conditions', () => {
+ it('should return false when indexMethod is not QUALIFIED', () => {
+ const result = checkShowMultiModalTip({
+ ...defaultProps,
+ indexMethod: IndexingType.ECONOMICAL,
+ })
+ expect(result).toBe(false)
+ })
+
+ it('should return false when indexMethod is undefined', () => {
+ const result = checkShowMultiModalTip({
+ ...defaultProps,
+ indexMethod: undefined,
+ })
+ expect(result).toBe(false)
+ })
+
+ it('should return false when embeddingModel.provider is empty', () => {
+ const result = checkShowMultiModalTip({
+ ...defaultProps,
+ embeddingModel: { provider: '', model: 'text-embedding-ada-002' },
+ })
+ expect(result).toBe(false)
+ })
+
+ it('should return false when embeddingModel.model is empty', () => {
+ const result = checkShowMultiModalTip({
+ ...defaultProps,
+ embeddingModel: { provider: 'openai', model: '' },
+ })
+ expect(result).toBe(false)
+ })
+
+ it('should return false when embedding model provider is not found', () => {
+ const result = checkShowMultiModalTip({
+ ...defaultProps,
+ embeddingModel: { provider: 'unknown-provider', model: 'text-embedding-ada-002' },
+ })
+ expect(result).toBe(false)
+ })
+
+ it('should return false when embedding model is not found in provider', () => {
+ const result = checkShowMultiModalTip({
+ ...defaultProps,
+ embeddingModel: { provider: 'openai', model: 'unknown-model' },
+ })
+ expect(result).toBe(false)
+ })
+
+ it('should return false when embedding model does not support vision', () => {
+ const result = checkShowMultiModalTip({
+ ...defaultProps,
+ embeddingModelList: [
+ createModelProvider('openai', [
+ createModelItem('text-embedding-ada-002', []), // No vision feature
+ ]),
+ ],
+ })
+ expect(result).toBe(false)
+ })
+
+ it('should return false when rerankingEnable is false', () => {
+ const result = checkShowMultiModalTip({
+ ...defaultProps,
+ rerankingEnable: false,
+ })
+ expect(result).toBe(false)
+ })
+
+ it('should return false when rerankingModelName is empty', () => {
+ const result = checkShowMultiModalTip({
+ ...defaultProps,
+ rerankModel: {
+ rerankingProviderName: 'cohere',
+ rerankingModelName: '',
+ },
+ })
+ expect(result).toBe(false)
+ })
+
+ it('should return false when rerankingProviderName is empty', () => {
+ const result = checkShowMultiModalTip({
+ ...defaultProps,
+ rerankModel: {
+ rerankingProviderName: '',
+ rerankingModelName: 'rerank-english-v2.0',
+ },
+ })
+ expect(result).toBe(false)
+ })
+
+ it('should return false when reranking model provider is not found', () => {
+ const result = checkShowMultiModalTip({
+ ...defaultProps,
+ rerankModel: {
+ rerankingProviderName: 'unknown-provider',
+ rerankingModelName: 'rerank-english-v2.0',
+ },
+ })
+ expect(result).toBe(false)
+ })
+
+ it('should return false when reranking model is not found in provider', () => {
+ const result = checkShowMultiModalTip({
+ ...defaultProps,
+ rerankModel: {
+ rerankingProviderName: 'cohere',
+ rerankingModelName: 'unknown-model',
+ },
+ })
+ expect(result).toBe(false)
+ })
+
+ it('should return false when reranking model supports vision', () => {
+ const result = checkShowMultiModalTip({
+ ...defaultProps,
+ rerankModelList: [
+ createModelProvider('cohere', [
+ createModelItem('rerank-english-v2.0', [ModelFeatureEnum.vision]), // Has vision feature
+ ]),
+ ],
+ })
+ expect(result).toBe(false)
+ })
+ })
+
+ describe('Return true condition', () => {
+ it('should return true when embedding model supports vision but reranking model does not', () => {
+ const result = checkShowMultiModalTip(defaultProps)
+ expect(result).toBe(true)
+ })
+
+ it('should return true with different providers', () => {
+ const result = checkShowMultiModalTip({
+ ...defaultProps,
+ embeddingModel: { provider: 'azure', model: 'azure-embedding' },
+ rerankModel: {
+ rerankingProviderName: 'jina',
+ rerankingModelName: 'jina-reranker',
+ },
+ embeddingModelList: [
+ createModelProvider('azure', [
+ createModelItem('azure-embedding', [ModelFeatureEnum.vision]),
+ ]),
+ ],
+ rerankModelList: [
+ createModelProvider('jina', [
+ createModelItem('jina-reranker', []),
+ ]),
+ ],
+ })
+ expect(result).toBe(true)
+ })
+ })
+
+ describe('Edge cases', () => {
+ it('should handle empty embeddingModelList', () => {
+ const result = checkShowMultiModalTip({
+ ...defaultProps,
+ embeddingModelList: [],
+ })
+ expect(result).toBe(false)
+ })
+
+ it('should handle empty rerankModelList', () => {
+ const result = checkShowMultiModalTip({
+ ...defaultProps,
+ rerankModelList: [],
+ })
+ expect(result).toBe(false)
+ })
+
+ it('should handle model with undefined features', () => {
+ const modelItem: ModelItem = {
+ model: 'test-model',
+ label: { en_US: 'test', zh_Hans: 'test' },
+ model_type: ModelTypeEnum.textEmbedding,
+ features: undefined as unknown as ModelFeatureEnum[],
+ fetch_from: ConfigurationMethodEnum.predefinedModel,
+ status: ModelStatusEnum.active,
+ model_properties: {},
+ load_balancing_enabled: false,
+ deprecated: false,
+ }
+
+ const result = checkShowMultiModalTip({
+ ...defaultProps,
+ embeddingModelList: [
+ createModelProvider('openai', [modelItem]),
+ ],
+ })
+ expect(result).toBe(false)
+ })
+
+ it('should handle model with null features', () => {
+ const modelItem: ModelItem = {
+ model: 'text-embedding-ada-002',
+ label: { en_US: 'test', zh_Hans: 'test' },
+ model_type: ModelTypeEnum.textEmbedding,
+ features: null as unknown as ModelFeatureEnum[],
+ fetch_from: ConfigurationMethodEnum.predefinedModel,
+ status: ModelStatusEnum.active,
+ model_properties: {},
+ load_balancing_enabled: false,
+ deprecated: false,
+ }
+
+ const result = checkShowMultiModalTip({
+ ...defaultProps,
+ embeddingModelList: [
+ createModelProvider('openai', [modelItem]),
+ ],
+ })
+ expect(result).toBe(false)
+ })
+
+ it('should handle multiple models in provider', () => {
+ const result = checkShowMultiModalTip({
+ ...defaultProps,
+ embeddingModelList: [
+ createModelProvider('openai', [
+ createModelItem('text-embedding-1', []),
+ createModelItem('text-embedding-ada-002', [ModelFeatureEnum.vision]),
+ createModelItem('text-embedding-3', []),
+ ]),
+ ],
+ })
+ expect(result).toBe(true)
+ })
+
+ it('should handle multiple providers in list', () => {
+ const result = checkShowMultiModalTip({
+ ...defaultProps,
+ embeddingModelList: [
+ createModelProvider('azure', [
+ createModelItem('azure-model', []),
+ ]),
+ createModelProvider('openai', [
+ createModelItem('text-embedding-ada-002', [ModelFeatureEnum.vision]),
+ ]),
+ ],
+ })
+ expect(result).toBe(true)
+ })
+ })
+})