test: add tests for dataset list (#31231)

Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
This commit is contained in:
Coding On Star 2026-01-20 13:07:00 +08:00 committed by GitHub
parent a715c015e7
commit 76b64dda52
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
56 changed files with 18890 additions and 124 deletions

View File

@ -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(<Form {...defaultProps} />)
expect(container.querySelector('form')).toBeInTheDocument()
})
it('should render all form fields based on formSchemas', () => {
render(<Form {...defaultProps} />)
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(<Form {...defaultProps} />)
const labels = screen.getAllByText('*')
expect(labels.length).toBe(3) // All 3 fields are required
})
it('should render documentation link for endpoint field', () => {
render(<Form {...defaultProps} />)
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(<Form {...defaultProps} />)
const apiKeyInput = screen.getByLabelText(/api key/i)
expect(apiKeyInput).toHaveAttribute('type', 'password')
})
it('should render text type input for text fields', () => {
render(<Form {...defaultProps} />)
const nameInput = screen.getByLabelText(/name/i)
expect(nameInput).toHaveAttribute('type', 'text')
})
})
describe('Props', () => {
it('should apply custom className to form', () => {
const { container } = render(<Form {...defaultProps} className="custom-form-class" />)
expect(container.querySelector('form')).toHaveClass('custom-form-class')
})
it('should apply itemClassName to form items', () => {
const { container } = render(<Form {...defaultProps} itemClassName="custom-item-class" />)
const items = container.querySelectorAll('.custom-item-class')
expect(items.length).toBe(3)
})
it('should apply fieldLabelClassName to labels', () => {
const { container } = render(<Form {...defaultProps} fieldLabelClassName="custom-label-class" />)
const labels = container.querySelectorAll('label.custom-label-class')
expect(labels.length).toBe(3)
})
it('should apply inputClassName to inputs', () => {
render(<Form {...defaultProps} inputClassName="custom-input-class" />)
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(<Form {...defaultProps} value={valueWithData} />)
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(<Form {...defaultProps} onChange={onChange} />)
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(<Form {...defaultProps} onChange={onChange} />)
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(<Form {...defaultProps} onChange={onChange} />)
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(<Form {...defaultProps} value={initialValue} onChange={onChange} />)
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(<Form {...defaultProps} formSchemas={[]} />)
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(<Form {...defaultProps} formSchemas={schemasWithOptional} />)
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(<Form {...defaultProps} formSchemas={schemasWithEnOnly} />)
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(<Form {...defaultProps} value={initialValue} onChange={onChange} />)
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' },
})
})
})
})

View File

@ -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(<AddExternalAPIModal {...defaultProps} />)
expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument()
})
it('should render create title when not in edit mode', () => {
render(<AddExternalAPIModal {...defaultProps} isEditMode={false} />)
expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument()
})
it('should render edit title when in edit mode', () => {
render(<AddExternalAPIModal {...defaultProps} isEditMode={true} data={initialData} />)
expect(screen.getByText('dataset.editExternalAPIFormTitle')).toBeInTheDocument()
})
it('should render form fields', () => {
render(<AddExternalAPIModal {...defaultProps} />)
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(<AddExternalAPIModal {...defaultProps} />)
expect(screen.getByText('dataset.externalAPIForm.cancel')).toBeInTheDocument()
expect(screen.getByText('dataset.externalAPIForm.save')).toBeInTheDocument()
})
it('should render encryption notice', () => {
render(<AddExternalAPIModal {...defaultProps} />)
expect(screen.getByText('PKCS1_OAEP')).toBeInTheDocument()
})
it('should render close button', () => {
render(<AddExternalAPIModal {...defaultProps} />)
// 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(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={datasetBindings}
/>,
)
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(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={[]}
/>,
)
expect(screen.queryByText('dataset.editExternalAPIFormWarning.front')).not.toBeInTheDocument()
})
})
describe('Form Interactions', () => {
it('should update form values when input changes', () => {
render(<AddExternalAPIModal {...defaultProps} />)
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(<AddExternalAPIModal {...defaultProps} isEditMode={true} data={initialData} />)
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(<AddExternalAPIModal {...defaultProps} />)
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')
expect(saveButton).toBeDisabled()
})
it('should enable save button when all fields are filled', () => {
render(<AddExternalAPIModal {...defaultProps} />)
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(<AddExternalAPIModal {...defaultProps} onSave={onSave} onCancel={onCancel} />)
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(<AddExternalAPIModal {...defaultProps} />)
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(<AddExternalAPIModal {...defaultProps} />)
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(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={[]}
onEdit={onEdit}
onCancel={onCancel}
/>,
)
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(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={datasetBindings}
onEdit={onEdit}
/>,
)
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(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={datasetBindings}
onCancel={onCancel}
/>,
)
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(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={datasetBindings}
/>,
)
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(<AddExternalAPIModal {...defaultProps} onCancel={onCancel} />)
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(<AddExternalAPIModal {...defaultProps} onCancel={onCancel} />)
// 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(<AddExternalAPIModal {...defaultProps} isEditMode={true} data={undefined} />)
expect(screen.getByLabelText(/name/i)).toHaveValue('')
})
it('should handle null datasetBindings', () => {
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={undefined}
/>,
)
expect(screen.queryByText('dataset.editExternalAPIFormWarning.front')).not.toBeInTheDocument()
})
it('should render documentation link in encryption notice', () => {
render(<AddExternalAPIModal {...defaultProps} />)
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')
})
})
})

View File

@ -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 }) => (
<div data-testid={`api-card-${api.id}`}>{api.name}</div>
),
}))
// 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(<ExternalAPIPanel {...defaultProps} />)
expect(screen.getByText('dataset.externalAPIPanelTitle')).toBeInTheDocument()
})
it('should render panel title and description', () => {
render(<ExternalAPIPanel {...defaultProps} />)
expect(screen.getByText('dataset.externalAPIPanelTitle')).toBeInTheDocument()
expect(screen.getByText('dataset.externalAPIPanelDescription')).toBeInTheDocument()
})
it('should render documentation link', () => {
render(<ExternalAPIPanel {...defaultProps} />)
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(<ExternalAPIPanel {...defaultProps} />)
expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument()
})
it('should render close button', () => {
const { container } = render(<ExternalAPIPanel {...defaultProps} />)
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(<ExternalAPIPanel {...defaultProps} />)
// 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(<ExternalAPIPanel {...defaultProps} />)
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(<ExternalAPIPanel {...defaultProps} />)
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(<ExternalAPIPanel onClose={onClose} />)
// 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(<ExternalAPIPanel {...defaultProps} />)
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(<ExternalAPIPanel {...defaultProps} />)
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(<ExternalAPIPanel {...defaultProps} />)
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(<ExternalAPIPanel {...defaultProps} />)
expect(screen.getByTestId('api-card-single-api')).toBeInTheDocument()
})
it('should render documentation link with correct target', () => {
render(<ExternalAPIPanel {...defaultProps} />)
const docLink = screen.getByText('dataset.externalAPIPanelDocumentation').closest('a')
expect(docLink).toHaveAttribute('target', '_blank')
})
})
})

View File

@ -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(<ExternalKnowledgeAPICard {...defaultProps} />)
expect(screen.getByText('Test External API')).toBeInTheDocument()
})
it('should render API name', () => {
render(<ExternalKnowledgeAPICard {...defaultProps} />)
expect(screen.getByText('Test External API')).toBeInTheDocument()
})
it('should render API endpoint', () => {
render(<ExternalKnowledgeAPICard {...defaultProps} />)
expect(screen.getByText('https://api.example.com/knowledge')).toBeInTheDocument()
})
it('should render edit and delete buttons', () => {
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const buttons = container.querySelectorAll('button')
expect(buttons.length).toBe(2)
})
it('should render API connection icon', () => {
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
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(<ExternalKnowledgeAPICard {...defaultProps} />)
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(<ExternalKnowledgeAPICard {...defaultProps} />)
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(<ExternalKnowledgeAPICard {...defaultProps} />)
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(<ExternalKnowledgeAPICard {...defaultProps} />)
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(<ExternalKnowledgeAPICard {...defaultProps} />)
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(<ExternalKnowledgeAPICard {...defaultProps} />)
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(<ExternalKnowledgeAPICard {...defaultProps} />)
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(<ExternalKnowledgeAPICard {...defaultProps} />)
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(<ExternalKnowledgeAPICard {...defaultProps} />)
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(<ExternalKnowledgeAPICard {...defaultProps} />)
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(<ExternalKnowledgeAPICard {...defaultProps} />)
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(<ExternalKnowledgeAPICard api={apiWithEmptyEndpoint} />)
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(<ExternalKnowledgeAPICard {...defaultProps} />)
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()
})
})
})

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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> = {}): 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(<CornerLabels dataset={dataset} />)
// 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(<CornerLabels dataset={dataset} />)
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(<CornerLabels dataset={dataset} />)
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(<CornerLabels dataset={dataset} />)
expect(container.firstChild).toBeNull()
})
it('should prioritize unavailable label over pipeline label', () => {
const dataset = createMockDataset({
embedding_available: false,
runtime_mode: 'rag_pipeline',
})
render(<CornerLabels dataset={dataset} />)
// 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(<CornerLabels dataset={dataset} />)
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(<CornerLabels dataset={dataset} />)
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(<CornerLabels dataset={dataset} />)
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(<CornerLabels dataset={dataset} />)
expect(container.firstChild).toBeNull()
})
it('should handle all false conditions', () => {
const dataset = createMockDataset({
embedding_available: true,
runtime_mode: 'general',
})
const { container } = render(<CornerLabels dataset={dataset} />)
expect(container.firstChild).toBeNull()
})
})
})

View File

@ -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> = {}): 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(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('10')).toBeInTheDocument()
})
it('should render document count', () => {
const dataset = createMockDataset({ document_count: 25, total_available_documents: 25 })
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('25')).toBeInTheDocument()
})
it('should render app count for non-external provider', () => {
const dataset = createMockDataset({ app_count: 8, provider: 'vendor' })
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('8')).toBeInTheDocument()
})
it('should not render app count for external provider', () => {
const dataset = createMockDataset({ app_count: 8, provider: 'external' })
render(<DatasetCardFooter dataset={dataset} />)
// 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(<DatasetCardFooter dataset={dataset} />)
// 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(<DatasetCardFooter dataset={dataset} />)
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(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('20')).toBeInTheDocument()
})
it('should handle zero documents', () => {
const dataset = createMockDataset({
document_count: 0,
total_available_documents: 0,
})
render(<DatasetCardFooter dataset={dataset} />)
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(<DatasetCardFooter dataset={dataset} />)
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(<DatasetCardFooter dataset={dataset} />)
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(<DatasetCardFooter dataset={dataset} />)
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(<DatasetCardFooter dataset={dataset} />)
// 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(<DatasetCardFooter dataset={dataset} />)
// 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(<DatasetCardFooter dataset={dataset} />)
// 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(<DatasetCardFooter dataset={dataset} />)
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(<DatasetCardFooter dataset={dataset} />)
// Both document count and app count are shown
const zeros = screen.getAllByText('0')
expect(zeros.length).toBeGreaterThanOrEqual(1)
})
})
})

View File

@ -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> = {}): 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(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should render dataset name', () => {
const dataset = createMockDataset({ name: 'Custom Dataset' })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('Custom Dataset')).toBeInTheDocument()
})
it('should render author name', () => {
const dataset = createMockDataset({ author_name: 'John Doe' })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
it('should render edit time', () => {
const dataset = createMockDataset()
render(<DatasetCardHeader dataset={dataset} />)
// 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(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText(/externalKnowledgeBase/)).toBeInTheDocument()
})
it('should show chunking mode for text_model doc_form', () => {
const dataset = createMockDataset({ doc_form: ChunkingMode.text })
render(<DatasetCardHeader dataset={dataset} />)
// 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(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText(/multimodal/)).toBeInTheDocument()
})
it('should not show multimodal when is_multimodal is false', () => {
const dataset = createMockDataset({ is_multimodal: false })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.queryByText(/^multimodal$/)).not.toBeInTheDocument()
})
})
describe('Icon', () => {
it('should render AppIcon component', () => {
const dataset = createMockDataset()
const { container } = render(<DatasetCardHeader dataset={dataset} />)
// 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(<DatasetCardHeader dataset={dataset} />)
// 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(<DatasetCardHeader dataset={dataset} />)
// 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(<DatasetCardHeader dataset={dataset} />)
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(<DatasetCardHeader dataset={dataset} />)
const header = container.firstChild as HTMLElement
expect(header).not.toHaveClass('opacity-30')
})
it('should have correct base styling', () => {
const dataset = createMockDataset()
const { container } = render(<DatasetCardHeader dataset={dataset} />)
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(<DatasetCardHeader dataset={dataset} />)
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(<DatasetCardHeader dataset={dataset} />)
// 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(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText(/chunkingMode/)).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle missing author_name', () => {
const dataset = createMockDataset({ author_name: undefined })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should handle empty name', () => {
const dataset = createMockDataset({ name: '' })
render(<DatasetCardHeader dataset={dataset} />)
// Should render without crashing
const { container } = render(<DatasetCardHeader dataset={dataset} />)
expect(container).toBeInTheDocument()
})
it('should handle missing retrieval_model_dict', () => {
const dataset = createMockDataset({ retrieval_model_dict: undefined })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should handle undefined doc_form', () => {
const dataset = createMockDataset({ doc_form: undefined })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
})
})

View File

@ -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
? (
<div data-testid="rename-modal">
<button onClick={onClose}>Close Rename</button>
<button onClick={onSuccess}>Success</button>
</div>
)
: 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(<DatasetCardModals {...defaultProps} />)
// Should render empty fragment
expect(container.innerHTML).toBe('')
})
it('should render rename modal when showRenameModal is true', () => {
render(
<DatasetCardModals
{...defaultProps}
modalState={{ ...defaultProps.modalState, showRenameModal: true }}
/>,
)
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
})
it('should render confirm modal when showConfirmDelete is true', () => {
render(
<DatasetCardModals
{...defaultProps}
modalState={{
...defaultProps.modalState,
showConfirmDelete: true,
confirmMessage: 'Are you sure?',
}}
/>,
)
// Confirm modal should be rendered
expect(screen.getByText('Are you sure?')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass dataset to rename modal', () => {
render(
<DatasetCardModals
{...defaultProps}
modalState={{ ...defaultProps.modalState, showRenameModal: true }}
/>,
)
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
})
it('should display confirmMessage in confirm modal', () => {
const confirmMessage = 'This is a custom confirm message'
render(
<DatasetCardModals
{...defaultProps}
modalState={{
...defaultProps.modalState,
showConfirmDelete: true,
confirmMessage,
}}
/>,
)
expect(screen.getByText(confirmMessage)).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onCloseRename when closing rename modal', () => {
const onCloseRename = vi.fn()
render(
<DatasetCardModals
{...defaultProps}
onCloseRename={onCloseRename}
modalState={{ ...defaultProps.modalState, showRenameModal: true }}
/>,
)
fireEvent.click(screen.getByText('Close Rename'))
expect(onCloseRename).toHaveBeenCalledTimes(1)
})
it('should call onConfirmDelete when confirming deletion', () => {
const onConfirmDelete = vi.fn()
render(
<DatasetCardModals
{...defaultProps}
onConfirmDelete={onConfirmDelete}
modalState={{
...defaultProps.modalState,
showConfirmDelete: true,
confirmMessage: 'Delete?',
}}
/>,
)
// 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(
<DatasetCardModals
{...defaultProps}
onCloseConfirm={onCloseConfirm}
modalState={{
...defaultProps.modalState,
showConfirmDelete: true,
confirmMessage: 'Delete?',
}}
/>,
)
// 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(
<DatasetCardModals
{...defaultProps}
modalState={{
showRenameModal: true,
showConfirmDelete: true,
confirmMessage: 'Delete this dataset?',
}}
/>,
)
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
expect(screen.getByText('Delete this dataset?')).toBeInTheDocument()
})
it('should handle empty confirmMessage', () => {
render(
<DatasetCardModals
{...defaultProps}
modalState={{
...defaultProps.modalState,
showConfirmDelete: true,
confirmMessage: '',
}}
/>,
)
// Should still render confirm modal
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
})
})
})

View File

@ -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> = {}): 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(<Description dataset={dataset} />)
expect(screen.getByText('This is a test description')).toBeInTheDocument()
})
it('should render the description text', () => {
const dataset = createMockDataset({ description: 'Custom description text' })
render(<Description dataset={dataset} />)
expect(screen.getByText('Custom description text')).toBeInTheDocument()
})
it('should set title attribute for tooltip', () => {
const dataset = createMockDataset({ description: 'Tooltip description' })
render(<Description dataset={dataset} />)
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(<Description dataset={dataset} />)
expect(screen.getByText(description)).toBeInTheDocument()
})
})
describe('Styles', () => {
it('should have correct base styling when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
render(<Description dataset={dataset} />)
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(<Description dataset={dataset} />)
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(<Description dataset={dataset} />)
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(<Description dataset={dataset} />)
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(<Description dataset={dataset} />)
expect(screen.getByText(longDescription)).toBeInTheDocument()
})
it('should handle description with special characters', () => {
const description = '<script>alert("XSS")</script> & "quotes" \'single\''
const dataset = createMockDataset({ description })
render(<Description dataset={dataset} />)
expect(screen.getByText(description)).toBeInTheDocument()
})
})
})

View File

@ -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> = {}): 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(<OperationsPopover {...defaultProps} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render the more icon button', () => {
const { container } = render(<OperationsPopover {...defaultProps} />)
const moreIcon = container.querySelector('svg')
expect(moreIcon).toBeInTheDocument()
})
it('should render in hidden state initially (group-hover)', () => {
const { container } = render(<OperationsPopover {...defaultProps} />)
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(<OperationsPopover {...defaultProps} isCurrentWorkspaceDatasetOperator={false} />)
// 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(<OperationsPopover {...defaultProps} isCurrentWorkspaceDatasetOperator={true} />)
// 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(<OperationsPopover {...defaultProps} dataset={dataset} />)
// 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(<OperationsPopover {...defaultProps} dataset={dataset} />)
// 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(<OperationsPopover {...defaultProps} />)
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(<OperationsPopover {...defaultProps} />)
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(<OperationsPopover {...defaultProps} openRenameModal={openRenameModal} />)
// 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(<OperationsPopover {...defaultProps} handleExportPipeline={handleExportPipeline} />)
expect(handleExportPipeline).not.toHaveBeenCalled()
})
it('should pass detectIsUsedByApp to Operations', () => {
const detectIsUsedByApp = vi.fn()
render(<OperationsPopover {...defaultProps} detectIsUsedByApp={detectIsUsedByApp} />)
expect(detectIsUsedByApp).not.toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should handle dataset with external provider', () => {
const dataset = createMockDataset({ provider: 'external' })
const { container } = render(<OperationsPopover {...defaultProps} dataset={dataset} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle dataset with undefined runtime_mode', () => {
const dataset = createMockDataset({ runtime_mode: undefined })
const { container } = render(<OperationsPopover {...defaultProps} dataset={dataset} />)
expect(container.firstChild).toBeInTheDocument()
})
})
})

View File

@ -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
}) => (
<div data-testid="tag-selector">
<div data-testid="tag-values">{value.join(',')}</div>
<div data-testid="selected-count">
{selectedTags.length}
{' '}
tags
</div>
<button onClick={() => onCacheUpdate([{ id: 'new-tag', name: 'New Tag', type: 'knowledge', binding_count: 0 }])}>
Update Tags
</button>
<button onClick={onChange}>
Trigger Change
</button>
</div>
),
}))
describe('TagArea', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): 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(<TagArea {...defaultProps} />)
expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
})
it('should render TagSelector with correct value', () => {
render(<TagArea {...defaultProps} />)
expect(screen.getByTestId('tag-values')).toHaveTextContent('tag-1,tag-2')
})
it('should display selected tags count', () => {
render(<TagArea {...defaultProps} />)
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(<TagArea {...defaultProps} dataset={dataset} />)
expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
})
it('should render with empty tags', () => {
render(<TagArea {...defaultProps} tags={[]} />)
expect(screen.getByTestId('selected-count')).toHaveTextContent('0 tags')
})
it('should forward ref correctly', () => {
const TestComponent = () => {
const ref = useRef<HTMLDivElement>(null)
return <TagArea {...defaultProps} ref={ref} />
}
render(<TestComponent />)
expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClick when container is clicked', () => {
const onClick = vi.fn()
const { container } = render(<TagArea {...defaultProps} onClick={onClick} />)
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(<TagArea {...defaultProps} setTags={setTags} />)
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(<TagArea {...defaultProps} onSuccess={onSuccess} />)
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(<TagArea {...defaultProps} dataset={dataset} />)
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(<TagArea {...defaultProps} dataset={dataset} />)
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(<TagArea {...defaultProps} isHoveringTagSelector={false} tags={mockTags} />)
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(<TagArea {...defaultProps} isHoveringTagSelector={true} />)
// 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(<TagArea {...defaultProps} tags={mockTags} />)
const tagSelectorWrapper = container.querySelector('.visible')
expect(tagSelectorWrapper).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle undefined onSuccess', () => {
render(<TagArea {...defaultProps} onSuccess={undefined} />)
// 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(<TagArea {...defaultProps} tags={manyTags} />)
expect(screen.getByTestId('selected-count')).toHaveTextContent('20 tags')
})
})
})

View File

@ -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> = {}): 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)
})
})
})

View File

@ -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<typeof import('ahooks')>()
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> = {}): 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(<DatasetCard {...defaultProps} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should render dataset name', () => {
const dataset = createMockDataset({ name: 'Custom Dataset Name' })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Custom Dataset Name')).toBeInTheDocument()
})
it('should render dataset description', () => {
const dataset = createMockDataset({ description: 'Custom Description' })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Custom Description')).toBeInTheDocument()
})
it('should render document count', () => {
render(<DatasetCard {...defaultProps} />)
expect(screen.getByText('10')).toBeInTheDocument()
})
it('should render app count', () => {
render(<DatasetCard {...defaultProps} />)
expect(screen.getByText('5')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should handle external provider', () => {
const dataset = createMockDataset({ provider: 'external' })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should handle rag_pipeline runtime mode', () => {
const dataset = createMockDataset({ runtime_mode: 'rag_pipeline', is_published: true })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
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(<DatasetCard {...defaultProps} dataset={dataset} />)
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(<DatasetCard {...defaultProps} dataset={dataset} />)
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(<DatasetCard {...defaultProps} dataset={dataset} />)
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(<DatasetCard {...defaultProps} />)
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(<DatasetCard {...defaultProps} />)
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(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should handle embedding not available', () => {
const dataset = createMockDataset({ embedding_available: false })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should handle undefined onSuccess', () => {
render(<DatasetCard dataset={createMockDataset()} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
})
describe('Tag Area Click', () => {
it('should stop propagation and prevent default when tag area is clicked', () => {
render(<DatasetCard {...defaultProps} />)
// 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(<DatasetCard {...defaultProps} />)
// 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)
}
})
})
})

View File

@ -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(<OperationItem {...defaultProps} />)
expect(screen.getByText('Edit')).toBeInTheDocument()
})
it('should render the icon', () => {
const { container } = render(<OperationItem {...defaultProps} />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
expect(icon).toHaveClass('size-4', 'text-text-tertiary')
})
it('should render the name text', () => {
render(<OperationItem {...defaultProps} />)
const nameSpan = screen.getByText('Edit')
expect(nameSpan).toHaveClass('system-md-regular', 'text-text-secondary')
})
})
describe('Props', () => {
it('should render different name', () => {
render(<OperationItem {...defaultProps} name="Delete" />)
expect(screen.getByText('Delete')).toBeInTheDocument()
})
it('should be callable without handleClick', () => {
render(<OperationItem {...defaultProps} />)
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(<OperationItem {...defaultProps} handleClick={handleClick} />)
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(<OperationItem {...defaultProps} handleClick={handleClick} />)
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(<OperationItem {...defaultProps} />)
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(<OperationItem {...defaultProps} name="" />)
const container = document.querySelector('.cursor-pointer')
expect(container).toBeInTheDocument()
})
})
})

View File

@ -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(<Operations {...defaultProps} />)
// Edit operation should always be visible
expect(screen.getByText(/operation\.edit/)).toBeInTheDocument()
})
it('should render edit operation', () => {
render(<Operations {...defaultProps} />)
expect(screen.getByText(/operation\.edit/)).toBeInTheDocument()
})
it('should render export pipeline operation when showExportPipeline is true', () => {
render(<Operations {...defaultProps} showExportPipeline={true} />)
expect(screen.getByText(/exportPipeline/)).toBeInTheDocument()
})
it('should not render export pipeline operation when showExportPipeline is false', () => {
render(<Operations {...defaultProps} showExportPipeline={false} />)
expect(screen.queryByText(/exportPipeline/)).not.toBeInTheDocument()
})
it('should render delete operation when showDelete is true', () => {
render(<Operations {...defaultProps} showDelete={true} />)
expect(screen.getByText(/operation\.delete/)).toBeInTheDocument()
})
it('should not render delete operation when showDelete is false', () => {
render(<Operations {...defaultProps} showDelete={false} />)
expect(screen.queryByText(/operation\.delete/)).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should render divider when showDelete is true', () => {
const { container } = render(<Operations {...defaultProps} showDelete={true} />)
const divider = container.querySelector('.bg-divider-subtle')
expect(divider).toBeInTheDocument()
})
it('should not render divider when showDelete is false', () => {
const { container } = render(<Operations {...defaultProps} showDelete={false} />)
// 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(<Operations {...defaultProps} openRenameModal={openRenameModal} />)
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(<Operations {...defaultProps} handleExportPipeline={handleExportPipeline} />)
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(<Operations {...defaultProps} detectIsUsedByApp={detectIsUsedByApp} />)
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(<Operations {...defaultProps} />)
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(<Operations {...defaultProps} showDelete={false} showExportPipeline={false} />)
expect(screen.getByText(/operation\.edit/)).toBeInTheDocument()
expect(screen.queryByText(/exportPipeline/)).not.toBeInTheDocument()
expect(screen.queryByText(/operation\.delete/)).not.toBeInTheDocument()
})
})
})

View File

@ -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(<DatasetFooter />)
describe('Rendering', () => {
it('should render without crashing', () => {
render(<DatasetFooter />)
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(<DatasetFooter />)
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(<DatasetFooter />)
// The paragraph contains multiple text spans
expect(screen.getByText(/intro1/)).toBeInTheDocument()
})
})
it('should have correct styling', () => {
const { container } = render(<DatasetFooter />)
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(<DatasetFooter />)
const footer = screen.getByRole('contentinfo')
expect(footer).toHaveClass('shrink-0', 'px-12', 'py-6')
})
it('should have gradient text on heading', () => {
render(<DatasetFooter />)
const heading = screen.getByRole('heading', { level: 3 })
expect(heading).toHaveClass('text-gradient')
})
})
describe('Content Structure', () => {
it('should render accent spans for highlighted text', () => {
render(<DatasetFooter />)
const accentSpans = document.querySelectorAll('.text-text-accent')
expect(accentSpans.length).toBe(2)
})
})
})

View File

@ -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<typeof import('ahooks')>()
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> = {}): 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(<Datasets {...defaultProps} />)
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(<Datasets {...defaultProps} />)
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(<Datasets {...defaultProps} />)
expect(screen.queryByText(/createDataset/)).not.toBeInTheDocument()
})
it('should render dataset cards from data', () => {
render(<Datasets {...defaultProps} />)
expect(screen.getByText('Dataset 1')).toBeInTheDocument()
expect(screen.getByText('Dataset 2')).toBeInTheDocument()
})
it('should render anchor div for infinite scroll', () => {
render(<Datasets {...defaultProps} />)
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(<Datasets {...defaultProps} tags={['tag-1', 'tag-2']} />)
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(<Datasets {...defaultProps} keywords="search term" />)
expect(useDatasetList).toHaveBeenCalledWith(
expect.objectContaining({
keyword: 'search term',
}),
)
})
it('should pass includeAll to useDatasetList', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
render(<Datasets {...defaultProps} includeAll={true} />)
expect(useDatasetList).toHaveBeenCalledWith(
expect.objectContaining({
include_all: true,
}),
)
})
})
describe('Document Title', () => {
it('should set document title on mount', async () => {
render(<Datasets {...defaultProps} />)
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<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
// 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<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
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<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
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<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
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<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
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<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
// 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<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
// 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<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
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<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
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<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
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<typeof useDatasetList>)
const { unmount } = render(<Datasets {...defaultProps} />)
// Unmount the component
unmount()
// disconnect should be called during cleanup
expect(mockDisconnect).toHaveBeenCalled()
})
})
describe('Styles', () => {
it('should have correct grid styling', () => {
render(<Datasets {...defaultProps} />)
const nav = screen.getByRole('navigation')
expect(nav).toHaveClass('grid', 'grow', 'gap-3', 'px-12')
})
})
describe('Edge Cases', () => {
it('should handle empty tags array', () => {
render(<Datasets {...defaultProps} tags={[]} />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
it('should handle empty keywords', () => {
render(<Datasets {...defaultProps} keywords="" />)
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<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
expect(screen.getByText('Page 1 Dataset')).toBeInTheDocument()
expect(screen.getByText('Page 2 Dataset')).toBeInTheDocument()
})
})
})

View File

@ -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<typeof import('ahooks')>()
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 }) => (
<div data-testid="datasets-component">
<span data-testid="tags">{tags.join(',')}</span>
<span data-testid="keywords">{keywords}</span>
<span data-testid="include-all">{includeAll ? 'true' : 'false'}</span>
</div>
),
}))
// Mock DatasetFooter component
vi.mock('./dataset-footer', () => ({
default: () => <footer data-testid="dataset-footer">Footer</footer>,
}))
// Mock ExternalAPIPanel component
vi.mock('../external-api/external-api-panel', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<div data-testid="external-api-panel">
<button onClick={onClose}>Close Panel</button>
</div>
),
}))
// Mock TagManagementModal
vi.mock('@/app/components/base/tag-management', () => ({
default: () => <div data-testid="tag-management-modal" />,
}))
// Mock TagFilter
vi.mock('@/app/components/base/tag-management/filter', () => ({
default: ({ onChange }: { value: string[], onChange: (val: string[]) => void }) => (
<div data-testid="tag-filter">
<button onClick={() => onChange(['tag-1', 'tag-2'])}>Select Tags</button>
</div>
),
}))
// Mock CheckboxWithLabel
vi.mock('@/app/components/datasets/create/website/base/checkbox-with-label', () => ({
default: ({ isChecked, onChange, label }: { isChecked: boolean, onChange: () => void, label: string }) => (
<label>
<input
type="checkbox"
checked={isChecked}
onChange={onChange}
data-testid="include-all-checkbox"
/>
{label}
</label>
),
}))
describe('List', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<List />)
expect(screen.getByTestId('datasets-component')).toBeInTheDocument()
})
it('should render the search input', () => {
render(<List />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render tag filter', () => {
render(<List />)
expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
})
it('should render external API panel button', () => {
render(<List />)
expect(screen.getByText(/externalAPIPanelTitle/)).toBeInTheDocument()
})
it('should render dataset footer when branding is disabled', () => {
render(<List />)
expect(screen.getByTestId('dataset-footer')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass includeAll prop to Datasets', () => {
render(<List />)
expect(screen.getByTestId('include-all')).toHaveTextContent('false')
})
it('should pass empty keywords initially', () => {
render(<List />)
expect(screen.getByTestId('keywords')).toHaveTextContent('')
})
it('should pass empty tags initially', () => {
render(<List />)
expect(screen.getByTestId('tags')).toHaveTextContent('')
})
})
describe('User Interactions', () => {
it('should open external API panel when button is clicked', () => {
render(<List />)
const button = screen.getByText(/externalAPIPanelTitle/)
fireEvent.click(button)
expect(mockSetShowExternalApiPanel).toHaveBeenCalledWith(true)
})
it('should update search input value', () => {
render(<List />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test search' } })
expect(input).toHaveValue('test search')
})
it('should trigger tag filter change', () => {
render(<List />)
// 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(<List />)
expect(screen.getByTestId('include-all-checkbox')).toBeInTheDocument()
})
})
describe('Styles', () => {
it('should have correct container styling', () => {
const { container } = render(<List />)
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(<List />)
// 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(<ListComponent />)
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/apps')
})
})
it('should clear search input when onClear is called', () => {
render(<List />)
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(<ListComponent />)
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(<ListComponent />)
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(<ListComponent />)
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(<ListComponent />)
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(<ListComponent />)
expect(screen.queryByTestId('include-all-checkbox')).not.toBeInTheDocument()
})
})
})

View File

@ -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(<CreateAppCard />)
expect(screen.getAllByRole('link')).toHaveLength(3)
})
// Mock dependencies
vi.mock('./option', () => ({
default: ({ text, href }: MockOptionProps) => (
<a data-testid="option-link" href={href}>
{text}
</a>
),
}))
it('should render create dataset option', () => {
render(<CreateAppCard />)
expect(screen.getByText(/createDataset/)).toBeInTheDocument()
})
vi.mock('@remixicon/react', () => ({
RiAddLine: () => <svg data-testid="icon-add" />,
RiFunctionAddLine: () => <svg data-testid="icon-function" />,
}))
it('should render create from pipeline option', () => {
render(<CreateAppCard />)
expect(screen.getByText(/createFromPipeline/)).toBeInTheDocument()
})
vi.mock('@/app/components/base/icons/src/vender/solid/development', () => ({
ApiConnectionMod: () => <svg data-testid="icon-api" />,
}))
it('should render connect dataset option', () => {
render(<CreateAppCard />)
expect(screen.getByText(/connectDataset/)).toBeInTheDocument()
})
})
describe('NewDatasetCard', () => {
it('should render all options', () => {
render(<NewDatasetCard />)
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(<CreateAppCard />)
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(<CreateAppCard />)
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(<CreateAppCard />)
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(<CreateAppCard />)
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(<CreateAppCard />)
const borderDiv = container.querySelector('.border-t-\\[0\\.5px\\]')
expect(borderDiv).toBeInTheDocument()
})
})
describe('Icons', () => {
it('should render three icons for three options', () => {
const { container } = render(<CreateAppCard />)
// Each option has an icon
const icons = container.querySelectorAll('svg')
expect(icons.length).toBeGreaterThanOrEqual(3)
})
})
})

View File

@ -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(<Option {...defaultProps} />)
expect(screen.getByRole('link')).toBeInTheDocument()
})
it('should render the text content', () => {
render(<Option {...defaultProps} />)
expect(screen.getByText('Test Option')).toBeInTheDocument()
})
it('should render the icon', () => {
render(<Option {...defaultProps} />)
// 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(<Option {...defaultProps} />)
const link = screen.getByRole('link')
expect(link).toHaveAttribute('href', '/test-path')
})
it('should render different text based on props', () => {
render(<Option {...defaultProps} text="Different Text" />)
expect(screen.getByText('Different Text')).toBeInTheDocument()
})
it('should render different href based on props', () => {
render(<Option {...defaultProps} href="/different-path" />)
const link = screen.getByRole('link')
expect(link).toHaveAttribute('href', '/different-path')
})
})
describe('Styles', () => {
it('should have correct base styling', () => {
render(<Option {...defaultProps} />)
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(<Option {...defaultProps} />)
const textSpan = screen.getByText('Test Option')
expect(textSpan).toHaveClass('system-sm-medium', 'grow', 'text-left')
})
})
describe('Edge Cases', () => {
it('should handle empty text', () => {
render(<Option {...defaultProps} text="" />)
const link = screen.getByRole('link')
expect(link).toBeInTheDocument()
})
it('should handle long text', () => {
const longText = 'A'.repeat(100)
render(<Option {...defaultProps} text={longText} />)
expect(screen.getByText(longText)).toBeInTheDocument()
})
})
})

View File

@ -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(<AddedMetadataButton />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render with translated text', () => {
render(<AddedMetadataButton />)
// The button should contain text from i18n
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render add icon', () => {
const { container } = render(<AddedMetadataButton />)
// 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(<AddedMetadataButton className="custom-class" />)
const button = screen.getByRole('button')
expect(button).toHaveClass('custom-class')
})
it('should apply default classes', () => {
render(<AddedMetadataButton />)
const button = screen.getByRole('button')
expect(button).toHaveClass('flex', 'w-full', 'items-center')
})
it('should merge custom className with default classes', () => {
render(<AddedMetadataButton className="my-custom-class" />)
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(<AddedMetadataButton onClick={handleClick} />)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should not throw when onClick is not provided and button is clicked', () => {
render(<AddedMetadataButton />)
expect(() => {
fireEvent.click(screen.getByRole('button'))
}).not.toThrow()
})
it('should call onClick multiple times on multiple clicks', () => {
const handleClick = vi.fn()
render(<AddedMetadataButton onClick={handleClick} />)
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(<AddedMetadataButton className={undefined} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render with empty className', () => {
render(<AddedMetadataButton className="" />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render with undefined onClick', () => {
render(<AddedMetadataButton onClick={undefined} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
})

View File

@ -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 (
<div data-testid="date-picker-wrapper">
{trigger}
<button data-testid="select-date" onClick={() => onChange(value || null)}>
Select Date
</button>
<button data-testid="clear-date" onClick={() => onClear()}>
Clear
</button>
</div>
)
},
}))
// 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(<WrappedDatePicker onChange={handleChange} />)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
it('should render placeholder text when no value', () => {
const handleChange = vi.fn()
render(<WrappedDatePicker onChange={handleChange} />)
// 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(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
it('should render calendar icon', () => {
const handleChange = vi.fn()
const { container } = render(<WrappedDatePicker onChange={handleChange} />)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should render select date button', () => {
const handleChange = vi.fn()
render(<WrappedDatePicker onChange={handleChange} />)
expect(screen.getByTestId('select-date')).toBeInTheDocument()
})
it('should render clear date button', () => {
const handleChange = vi.fn()
render(<WrappedDatePicker onChange={handleChange} />)
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(
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
)
// 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(
<WrappedDatePicker className="custom-class" onChange={handleChange} />,
)
const triggerElement = container.querySelector('.custom-class')
expect(triggerElement).toBeInTheDocument()
})
it('should accept undefined value', () => {
const handleChange = vi.fn()
render(<WrappedDatePicker value={undefined} onChange={handleChange} />)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
it('should accept number value', () => {
const handleChange = vi.fn()
const timestamp = 1609459200 // 2021-01-01
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
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(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
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(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
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(
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
)
// 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(
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
)
// 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(
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
)
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(<WrappedDatePicker onChange={handleChange} />)
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(
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
)
const textElement = container.querySelector('.text-text-secondary')
expect(textElement).toBeInTheDocument()
})
it('should have input background styling', () => {
const handleChange = vi.fn()
const { container } = render(<WrappedDatePicker onChange={handleChange} />)
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(
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
)
const closeIcon = container.querySelector('.text-text-quaternary')
expect(closeIcon).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle timestamp of 0', () => {
const handleChange = vi.fn()
render(<WrappedDatePicker value={0} onChange={handleChange} />)
// 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(<WrappedDatePicker value={farFuture} onChange={handleChange} />)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
it('should handle switching between no value and value', () => {
const handleChange = vi.fn()
const { rerender } = render(
<WrappedDatePicker onChange={handleChange} />,
)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
const timestamp = Math.floor(Date.now() / 1000)
rerender(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
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(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
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(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
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(<WrappedDatePicker onChange={handleChange} />)
// The mock triggers onChange with the value prop
fireEvent.click(screen.getByTestId('select-date'))
// onChange should have been called
expect(handleChange).toHaveBeenCalled()
})
})
})

View File

@ -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) => (
<input
data-testid="input-combined"
data-type={type}
value={value || ''}
onChange={e => onChange(e.target.value)}
/>
),
}))
// Mock Label component
vi.mock('./label', () => ({
default: ({ text }: LabelProps) => <div data-testid="label">{text}</div>,
}))
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(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render label with payload name', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(screen.getByTestId('label')).toHaveTextContent('test_field')
})
it('should render input combined component', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(screen.getByTestId('input-combined')).toBeInTheDocument()
})
it('should render remove button icon', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const { container } = render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
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(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
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(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
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(
<AddRow
payload={mockPayload}
onChange={handleChange}
onRemove={handleRemove}
className="custom-class"
/>,
)
expect(container.firstChild).toHaveClass('custom-class')
})
it('should have default flex styling', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const { container } = render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
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(
<AddRow payload={numberPayload} onChange={handleChange} onRemove={handleRemove} />,
)
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(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
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(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
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(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
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(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
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(
<AddRow payload={nullPayload} onChange={handleChange} onRemove={handleRemove} />,
)
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(
<AddRow payload={emptyPayload} onChange={handleChange} onRemove={handleRemove} />,
)
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(
<AddRow payload={timePayload} onChange={handleChange} onRemove={handleRemove} />,
)
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(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
const removeButton = container.querySelector('.cursor-pointer')
if (removeButton) {
fireEvent.click(removeButton)
fireEvent.click(removeButton)
fireEvent.click(removeButton)
}
expect(handleRemove).toHaveBeenCalledTimes(3)
})
})
})

View File

@ -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) => (
<input
data-testid="input-combined"
data-type={type}
value={value || ''}
onChange={e => onChange(e.target.value)}
readOnly={readOnly}
/>
),
}))
// Mock InputHasSetMultipleValue component
vi.mock('./input-has-set-multiple-value', () => ({
default: ({ onClear, readOnly }: MultipleValueInputProps) => (
<div data-testid="multiple-value-input" data-readonly={readOnly}>
<button data-testid="clear-multiple" onClick={onClear}>Clear Multiple</button>
</div>
),
}))
// Mock Label component
vi.mock('./label', () => ({
default: ({ text, isDeleted }: LabelProps) => (
<div data-testid="label" data-deleted={isDeleted}>{text}</div>
),
}))
// Mock EditedBeacon component
vi.mock('./edited-beacon', () => ({
default: ({ onReset }: EditedBeaconProps) => (
<button data-testid="edited-beacon" onClick={onReset}>Reset</button>
),
}))
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(
<EditMetadatabatchItem
payload={mockPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render label with payload name', () => {
render(
<EditMetadatabatchItem
payload={mockPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('label')).toHaveTextContent('test_field')
})
it('should render input combined for single value', () => {
render(
<EditMetadatabatchItem
payload={mockPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('input-combined')).toBeInTheDocument()
})
it('should render multiple value input when isMultipleValue is true', () => {
const multiplePayload: MetadataItemWithEdit = {
...mockPayload,
isMultipleValue: true,
}
render(
<EditMetadatabatchItem
payload={multiplePayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('multiple-value-input')).toBeInTheDocument()
})
it('should render delete button icon', () => {
const { container } = render(
<EditMetadatabatchItem
payload={mockPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
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(
<EditMetadatabatchItem
payload={updatedPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('edited-beacon')).toBeInTheDocument()
})
it('should not show edited beacon when isUpdated is false', () => {
render(
<EditMetadatabatchItem
payload={mockPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
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(
<EditMetadatabatchItem
payload={deletedPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('label')).toHaveAttribute('data-deleted', 'true')
})
it('should set readOnly on input when deleted', () => {
const deletedPayload: MetadataItemWithEdit = {
...mockPayload,
updateType: UpdateType.delete,
}
render(
<EditMetadatabatchItem
payload={deletedPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
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(
<EditMetadatabatchItem
payload={deletedPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
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(
<EditMetadatabatchItem
payload={mockPayload}
onChange={handleChange}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
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(
<EditMetadatabatchItem
payload={mockPayload}
onChange={vi.fn()}
onRemove={handleRemove}
onReset={vi.fn()}
/>,
)
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(
<EditMetadatabatchItem
payload={updatedPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={handleReset}
/>,
)
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(
<EditMetadatabatchItem
payload={multiplePayload}
onChange={handleChange}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
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(
<EditMetadatabatchItem
payload={multiplePayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
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(
<EditMetadatabatchItem
payload={multipleDeletedPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
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(
<EditMetadatabatchItem
payload={numberPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
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(
<EditMetadatabatchItem
payload={timePayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.time)
})
it('should handle null value', () => {
const nullPayload: MetadataItemWithEdit = {
...mockPayload,
value: null,
}
render(
<EditMetadatabatchItem
payload={nullPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('input-combined')).toBeInTheDocument()
})
})
})

View File

@ -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(<EditedBeacon onReset={handleReset} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with correct size', () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
expect(container.firstChild).toHaveClass('size-4', 'cursor-pointer')
})
it('should render beacon dot by default (not hovering)', () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
// 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(<EditedBeacon onReset={handleReset} />)
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(<EditedBeacon onReset={handleReset} />)
// 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(<EditedBeacon onReset={handleReset} />)
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(<EditedBeacon onReset={handleReset} />)
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(<EditedBeacon onReset={handleReset} />)
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(<EditedBeacon onReset={handleReset} />)
// 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(<EditedBeacon onReset={handleReset} />)
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(<EditedBeacon onReset={handleReset} />)
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(<EditedBeacon onReset={handleReset} />)
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()
})
})
})
})

View File

@ -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) => (
<div data-testid="date-picker" className={className} onClick={() => onChange(Date.now())}>
{value || 'Pick date'}
</div>
),
}))
describe('InputCombined', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const handleChange = vi.fn()
const { container } = render(
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render text input for string type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value="test" onChange={handleChange} />,
)
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(
<InputCombined type={DataType.number} value={42} onChange={handleChange} />,
)
const input = screen.getByDisplayValue('42')
expect(input).toBeInTheDocument()
})
it('should render date picker for time type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.time} value={Date.now()} onChange={handleChange} />,
)
expect(screen.getByTestId('date-picker')).toBeInTheDocument()
})
})
describe('String Input', () => {
it('should call onChange with input value for string type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
)
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(
<InputCombined type={DataType.string} value="existing value" onChange={handleChange} />,
)
expect(screen.getByDisplayValue('existing value')).toBeInTheDocument()
})
it('should apply readOnly prop to string input', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value="test" onChange={handleChange} readOnly />,
)
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(
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
)
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(
<InputCombined type={DataType.number} value={999} onChange={handleChange} />,
)
expect(screen.getByDisplayValue('999')).toBeInTheDocument()
})
it('should apply readOnly prop to number input', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={42} onChange={handleChange} readOnly />,
)
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(
<InputCombined type={DataType.time} value={1234567890} onChange={handleChange} />,
)
expect(screen.getByTestId('date-picker')).toBeInTheDocument()
})
it('should call onChange when date is selected', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.time} value={null} onChange={handleChange} />,
)
fireEvent.click(screen.getByTestId('date-picker'))
expect(handleChange).toHaveBeenCalled()
})
})
describe('Props', () => {
it('should apply custom className', () => {
const handleChange = vi.fn()
const { container } = render(
<InputCombined
type={DataType.string}
value=""
onChange={handleChange}
className="custom-class"
/>,
)
// 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(
<InputCombined type={DataType.string} value={null} onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
})
it('should handle undefined value for string type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value={undefined as unknown as string} onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
})
it('should handle null value for number type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={null} onChange={handleChange} />,
)
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have correct base styling for string input', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
)
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(
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
)
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(
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
expect(input).toHaveValue('')
})
it('should handle zero value for number', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
)
expect(screen.getByDisplayValue('0')).toBeInTheDocument()
})
it('should handle negative number', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={-100} onChange={handleChange} />,
)
expect(screen.getByDisplayValue('-100')).toBeInTheDocument()
})
it('should handle special characters in string', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value={'<script>alert("xss")</script>'} onChange={handleChange} />,
)
expect(screen.getByDisplayValue('<script>alert("xss")</script>')).toBeInTheDocument()
})
it('should handle switching between types', () => {
const handleChange = vi.fn()
const { rerender } = render(
<InputCombined type={DataType.string} value="test" onChange={handleChange} />,
)
expect(screen.getByRole('textbox')).toBeInTheDocument()
rerender(
<InputCombined type={DataType.number} value={42} onChange={handleChange} />,
)
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
})
})
})

View File

@ -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(<InputHasSetMultipleValue onClear={handleClear} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with correct wrapper styling', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
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(<InputHasSetMultipleValue onClear={handleClear} />)
// 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(<InputHasSetMultipleValue onClear={handleClear} />)
// 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(<InputHasSetMultipleValue onClear={handleClear} readOnly />)
// 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(<InputHasSetMultipleValue onClear={handleClear} readOnly={false} />)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should show close icon when readOnly is undefined', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly={undefined} />)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should apply pr-1.5 padding when readOnly', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly />)
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(<InputHasSetMultipleValue onClear={handleClear} />)
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(<InputHasSetMultipleValue onClear={handleClear} />)
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(<InputHasSetMultipleValue onClear={handleClear} readOnly />)
// 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(<InputHasSetMultipleValue onClear={handleClear} />)
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(<InputHasSetMultipleValue onClear={handleClear} />)
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(<InputHasSetMultipleValue onClear={handleClear} />)
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(<InputHasSetMultipleValue onClear={handleClear} />)
// Initially not readOnly
expect(container.querySelector('svg')).toBeInTheDocument()
// Switch to readOnly
rerender(<InputHasSetMultipleValue onClear={handleClear} readOnly />)
expect(container.querySelector('svg')).not.toBeInTheDocument()
// Switch back to not readOnly
rerender(<InputHasSetMultipleValue onClear={handleClear} readOnly={false} />)
expect(container.querySelector('svg')).toBeInTheDocument()
})
})
})

View File

@ -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(<Label text="Test Label" />)
expect(screen.getByText('Test Label')).toBeInTheDocument()
})
it('should render text with correct styling', () => {
render(<Label text="My Label" />)
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(<Label text="Label" />)
const labelElement = screen.getByText('Label')
expect(labelElement).not.toHaveClass('text-text-quaternary', 'line-through')
})
})
describe('Props', () => {
it('should apply custom className', () => {
render(<Label text="Label" className="custom-class" />)
const labelElement = screen.getByText('Label')
expect(labelElement).toHaveClass('custom-class')
})
it('should merge custom className with default classes', () => {
render(<Label text="Label" className="my-custom-class" />)
const labelElement = screen.getByText('Label')
expect(labelElement).toHaveClass('system-xs-medium', 'my-custom-class')
})
it('should apply deleted styling when isDeleted is true', () => {
render(<Label text="Label" isDeleted />)
const labelElement = screen.getByText('Label')
expect(labelElement).toHaveClass('text-text-quaternary', 'line-through')
})
it('should not apply deleted styling when isDeleted is false', () => {
render(<Label text="Label" isDeleted={false} />)
const labelElement = screen.getByText('Label')
expect(labelElement).not.toHaveClass('text-text-quaternary', 'line-through')
})
it('should render different text values', () => {
const { rerender } = render(<Label text="First" />)
expect(screen.getByText('First')).toBeInTheDocument()
rerender(<Label text="Second" />)
expect(screen.getByText('Second')).toBeInTheDocument()
})
})
describe('Deleted State', () => {
it('should have strikethrough when deleted', () => {
render(<Label text="Deleted Label" isDeleted />)
const labelElement = screen.getByText('Deleted Label')
expect(labelElement).toHaveClass('line-through')
})
it('should have quaternary text color when deleted', () => {
render(<Label text="Deleted Label" isDeleted />)
const labelElement = screen.getByText('Deleted Label')
expect(labelElement).toHaveClass('text-text-quaternary')
})
it('should combine deleted styling with custom className', () => {
render(<Label text="Label" isDeleted className="custom" />)
const labelElement = screen.getByText('Label')
expect(labelElement).toHaveClass('line-through', 'custom')
})
})
describe('Edge Cases', () => {
it('should render with empty text', () => {
const { container } = render(<Label text="" />)
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(<Label text={longText} />)
const labelElement = screen.getByText(longText)
expect(labelElement).toHaveClass('truncate')
})
it('should render with undefined className', () => {
render(<Label text="Label" className={undefined} />)
expect(screen.getByText('Label')).toBeInTheDocument()
})
it('should render with undefined isDeleted', () => {
render(<Label text="Label" isDeleted={undefined} />)
const labelElement = screen.getByText('Label')
expect(labelElement).not.toHaveClass('line-through')
})
it('should handle special characters in text', () => {
render(<Label text={'Label & "chars"'} />)
expect(screen.getByText('Label & "chars"')).toBeInTheDocument()
})
it('should handle numbers in text', () => {
render(<Label text="Label 123" />)
expect(screen.getByText('Label 123')).toBeInTheDocument()
})
})
})

View File

@ -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<void>
onManage: () => void
}
// Mock child components with callback exposure
vi.mock('./edit-row', () => ({
default: ({ payload, onChange, onRemove, onReset }: EditRowProps) => (
<div data-testid="edit-row" data-id={payload.id}>
<span data-testid="edit-row-name">{payload.name}</span>
<button data-testid={`change-${payload.id}`} onClick={() => onChange({ ...payload, value: 'changed', isUpdated: true, updateType: UpdateType.changeValue })}>Change</button>
<button data-testid={`remove-${payload.id}`} onClick={() => onRemove(payload.id)}>Remove</button>
<button data-testid={`reset-${payload.id}`} onClick={() => onReset(payload.id)}>Reset</button>
</div>
),
}))
vi.mock('./add-row', () => ({
default: ({ payload, onChange, onRemove }: AddRowProps) => (
<div data-testid="add-row" data-id={payload.id}>
<span data-testid="add-row-name">{payload.name}</span>
<button data-testid={`add-change-${payload.id}`} onClick={() => onChange({ ...payload, value: 'new_value' })}>Change</button>
<button data-testid="add-remove" onClick={onRemove}>Remove</button>
</div>
),
}))
vi.mock('../metadata-dataset/select-metadata-modal', () => ({
default: ({ trigger, onSelect, onSave, onManage }: SelectModalProps) => (
<div data-testid="select-modal">
{trigger}
<button data-testid="select-metadata" onClick={() => onSelect({ id: 'new-1', name: 'new_field', type: DataType.string, value: null, isMultipleValue: false })}>Select</button>
<button data-testid="save-metadata" onClick={() => onSave({ name: 'created_field', type: DataType.string }).catch(() => {})}>Save</button>
<button data-testid="manage-metadata" onClick={onManage}>Manage</button>
</div>
),
}))
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(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
it('should render document count', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByText(/5/)).toBeInTheDocument()
})
})
it('should render all edit rows for existing items', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
const editRows = screen.getAllByTestId('edit-row')
expect(editRows).toHaveLength(2)
})
})
it('should render field names for existing items', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByText('field_one')).toBeInTheDocument()
expect(screen.getByText('field_two')).toBeInTheDocument()
})
})
it('should render checkbox for apply to all', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
const checkboxes = document.querySelectorAll('[data-testid*="checkbox"]')
expect(checkboxes.length).toBeGreaterThan(0)
})
})
it('should render select metadata modal', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
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(<EditMetadataBatchModal {...defaultProps} onHide={onHide} />)
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(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
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(<EditMetadataBatchModal {...defaultProps} />)
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(<EditMetadataBatchModal {...defaultProps} onHide={onHide} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
})
describe('Edit Row Operations', () => {
it('should update item value when change is triggered', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
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(<EditMetadataBatchModal {...defaultProps} />)
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(<EditMetadataBatchModal {...defaultProps} />)
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(<EditMetadataBatchModal {...defaultProps} />)
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(<EditMetadataBatchModal {...defaultProps} />)
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(<EditMetadataBatchModal {...defaultProps} />)
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(<EditMetadataBatchModal {...defaultProps} />)
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(<EditMetadataBatchModal {...defaultProps} />)
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(<EditMetadataBatchModal {...defaultProps} />)
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(<EditMetadataBatchModal {...defaultProps} onShowManage={onShowManage} />)
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(<EditMetadataBatchModal {...defaultProps} datasetId="custom-ds" />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
it('should display correct document number', async () => {
render(<EditMetadataBatchModal {...defaultProps} documentNum={10} />)
await waitFor(() => {
expect(screen.getByText(/10/)).toBeInTheDocument()
})
})
it('should handle empty list', async () => {
render(<EditMetadataBatchModal {...defaultProps} list={[]} />)
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(<EditMetadataBatchModal {...defaultProps} list={multipleValueList} />)
await waitFor(() => {
expect(screen.getByTestId('edit-row')).toBeInTheDocument()
})
})
it('should handle rapid save clicks', async () => {
const onSave = vi.fn()
render(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
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(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
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(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
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(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
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(<EditMetadataBatchModal {...defaultProps} />)
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()
})
})
})
})

View File

@ -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<typeof useBatchEditDocumentMetadata>[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<typeof useBatchEditDocumentMetadata>[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<typeof useBatchEditDocumentMetadata>[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<typeof useBatchEditDocumentMetadata>[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<typeof useBatchEditDocumentMetadata>[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<typeof useBatchEditDocumentMetadata>[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<typeof useBatchEditDocumentMetadata>[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<typeof useBatchEditDocumentMetadata>[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<typeof useBatchEditDocumentMetadata>[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<typeof useBatchEditDocumentMetadata>[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<typeof useBatchEditDocumentMetadata>[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<typeof useBatchEditDocumentMetadata>[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<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
expect(result.current.originalList).toEqual([])
})
})
})

View File

@ -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)
})
})
})

View File

@ -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()
})
})
})

View File

@ -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<typeof useMetadataDocument>[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<typeof useMetadataDocument>[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<typeof useMetadataDocument>[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<typeof useMetadataDocument>[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<typeof useMetadataDocument>[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<typeof useMetadataDocument>[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<typeof useMetadataDocument>[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<typeof useMetadataDocument>[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()
})
})
})

View File

@ -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) => (
<div data-testid="modal-wrap">
<div data-testid="modal-title">{title}</div>
{beforeHeader && <div data-testid="before-header">{beforeHeader}</div>}
<div data-testid="modal-content">{children}</div>
<button data-testid="close-btn" onClick={onClose}>Close</button>
<button data-testid="confirm-btn" onClick={onConfirm}>Confirm</button>
</div>
),
}))
// Mock OptionCard
vi.mock('../../../workflow/nodes/_base/components/option-card', () => ({
default: ({ title, selected, onSelect }: OptionCardProps) => (
<button
data-testid={`option-${title.toLowerCase()}`}
data-selected={selected}
onClick={onSelect}
>
{title}
</button>
),
}))
// Mock Field
vi.mock('./field', () => ({
default: ({ label, children }: FieldProps) => (
<div data-testid="field">
<label data-testid="field-label">{label}</label>
{children}
</div>
),
}))
describe('CreateContent', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByTestId('modal-wrap')).toBeInTheDocument()
})
it('should render modal title', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByTestId('modal-title')).toBeInTheDocument()
})
it('should render type selection options', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
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(<CreateContent onSave={handleSave} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render confirm button', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByTestId('confirm-btn')).toBeInTheDocument()
})
it('should render close button', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByTestId('close-btn')).toBeInTheDocument()
})
})
describe('Type Selection', () => {
it('should default to string type', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'true')
})
it('should select number type when clicked', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
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(<CreateContent onSave={handleSave} />)
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(<CreateContent onSave={handleSave} />)
// 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(<CreateContent onSave={handleSave} />)
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(<CreateContent onSave={handleSave} />)
expect(screen.getByRole('textbox')).toHaveValue('')
})
})
describe('User Interactions', () => {
it('should call onSave with type and name when confirmed', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
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(<CreateContent onSave={handleSave} />)
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(<CreateContent onSave={handleSave} onClose={handleClose} />)
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(<CreateContent onSave={handleSave} hasBack />)
expect(screen.getByTestId('before-header')).toBeInTheDocument()
})
it('should not show back button when hasBack is false', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} hasBack={false} />)
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(<CreateContent onSave={handleSave} hasBack onBack={handleBack} />)
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(<CreateContent onSave={handleSave} />)
fireEvent.click(screen.getByTestId('confirm-btn'))
expect(handleSave).toHaveBeenCalledWith({
type: DataType.string,
name: '',
})
})
it('should handle type cycling', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
// 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(<CreateContent onSave={handleSave} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test_field_123' } })
expect(input).toHaveValue('test_field_123')
})
})
})

View File

@ -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) => (
<div data-testid="portal-wrapper" data-open={open}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: TriggerProps) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children, className }: ContentProps) => (
<div data-testid="portal-content" className={className}>{children}</div>
),
}))
// Mock CreateContent component
vi.mock('./create-content', () => ({
default: ({ onSave, onClose, onBack, hasBack }: CreateContentProps) => (
<div data-testid="create-content">
<span data-testid="has-back">{String(hasBack)}</span>
<button data-testid="save-btn" onClick={() => onSave({ type: DataType.string, name: 'test' })}>Save</button>
<button data-testid="close-btn" onClick={onClose}>Close</button>
{hasBack && <button data-testid="back-btn" onClick={onBack}>Back</button>}
</div>
),
}))
describe('CreateMetadataModal', () => {
const mockTrigger = <button data-testid="trigger-button">Open Modal</button>
describe('Rendering', () => {
it('should render trigger when closed', () => {
render(
<CreateMetadataModal
open={false}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
// 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(
<CreateMetadataModal
open={true}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
expect(screen.getByTestId('create-content')).toBeInTheDocument()
})
it('should render trigger element', () => {
render(
<CreateMetadataModal
open={false}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('trigger-button')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass hasBack to CreateContent', () => {
render(
<CreateMetadataModal
open={true}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
hasBack
/>,
)
expect(screen.getByTestId('has-back')).toHaveTextContent('true')
})
it('should pass hasBack=undefined when not provided', () => {
render(
<CreateMetadataModal
open={true}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('has-back')).toHaveTextContent('undefined')
})
it('should accept custom popupLeft', () => {
render(
<CreateMetadataModal
open={true}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
popupLeft={50}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should toggle open state when trigger is clicked', () => {
const setOpen = vi.fn()
render(
<CreateMetadataModal
open={false}
setOpen={setOpen}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(setOpen).toHaveBeenCalledWith(true)
})
it('should call onSave when save button is clicked', () => {
const handleSave = vi.fn()
render(
<CreateMetadataModal
open={true}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={handleSave}
/>,
)
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(
<CreateMetadataModal
open={true}
setOpen={setOpen}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('close-btn'))
expect(setOpen).toHaveBeenCalledWith(false)
})
it('should close modal when back button is clicked', () => {
const setOpen = vi.fn()
render(
<CreateMetadataModal
open={true}
setOpen={setOpen}
trigger={mockTrigger}
onSave={vi.fn()}
hasBack
/>,
)
fireEvent.click(screen.getByTestId('back-btn'))
expect(setOpen).toHaveBeenCalledWith(false)
})
})
describe('Edge Cases', () => {
it('should handle switching open state', () => {
const { rerender } = render(
<CreateMetadataModal
open={false}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'false')
rerender(
<CreateMetadataModal
open={true}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'true')
})
it('should handle different trigger elements', () => {
const customTrigger = <div data-testid="custom-trigger">Custom</div>
render(
<CreateMetadataModal
open={false}
setOpen={vi.fn()}
trigger={customTrigger}
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
})
})
})

View File

@ -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) => (
<div data-testid="create-modal-wrapper">
<div data-testid="create-trigger" onClick={() => setOpen(true)}>{trigger}</div>
{open && (
<div data-testid="create-modal">
<button data-testid="create-save" onClick={() => onSave({ name: 'new_field', type: DataType.string })}>
Save
</button>
<button data-testid="create-close" onClick={() => setOpen(false)}>Close</button>
</div>
)}
</div>
),
}))
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(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
it('should render user metadata items', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
expect(screen.getByText('field_one')).toBeInTheDocument()
expect(screen.getByText('field_two')).toBeInTheDocument()
})
})
it('should render built-in metadata items', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
expect(screen.getByText('created_at')).toBeInTheDocument()
expect(screen.getByText('modified_at')).toBeInTheDocument()
})
})
it('should render metadata type for each item', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
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(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
expect(screen.getByTestId('create-trigger')).toBeInTheDocument()
})
})
it('should render switch for built-in toggle', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
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(
<DatasetMetadataDrawer
{...defaultProps}
onIsBuiltInEnabledChange={onIsBuiltInEnabledChange}
/>,
)
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(<DatasetMetadataDrawer {...defaultProps} />)
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(<DatasetMetadataDrawer {...defaultProps} onAdd={onAdd} />)
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(<DatasetMetadataDrawer {...defaultProps} onAdd={onAdd} />)
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(<DatasetMetadataDrawer {...defaultProps} />)
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(<DatasetMetadataDrawer {...defaultProps} onRename={onRename} />)
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(<DatasetMetadataDrawer {...defaultProps} />)
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(<DatasetMetadataDrawer {...defaultProps} />)
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(<DatasetMetadataDrawer {...defaultProps} />)
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(<DatasetMetadataDrawer {...defaultProps} onRemove={onRemove} />)
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(<DatasetMetadataDrawer {...defaultProps} />)
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(<DatasetMetadataDrawer {...defaultProps} userMetadata={[]} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
it('should handle empty builtInMetadata', async () => {
render(<DatasetMetadataDrawer {...defaultProps} builtInMetadata={[]} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
})
describe('Built-in Items State', () => {
it('should show disabled styling when built-in is disabled', async () => {
render(
<DatasetMetadataDrawer {...defaultProps} isBuiltInEnabled={false} />,
)
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(
<DatasetMetadataDrawer {...defaultProps} isBuiltInEnabled />,
)
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(<DatasetMetadataDrawer {...defaultProps} userMetadata={specialMetadata} />)
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(<DatasetMetadataDrawer {...defaultProps} userMetadata={singleMetadata} />)
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(<DatasetMetadataDrawer {...defaultProps} builtInMetadata={singleBuiltIn} />)
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(<DatasetMetadataDrawer {...defaultProps} userMetadata={zeroCountMetadata} />)
await waitFor(() => {
expect(screen.getByText('empty_field')).toBeInTheDocument()
})
})
})
})

View File

@ -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(<Field label="Test Label">Content</Field>)
expect(screen.getByText('Test Label')).toBeInTheDocument()
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('should render label with correct styling', () => {
render(<Field label="My Label">Content</Field>)
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(<Field label="Label">Child Content</Field>)
// 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(<Field label="Label" className="custom-class">Content</Field>)
expect(container.firstChild).toHaveClass('custom-class')
})
it('should render with string children', () => {
render(<Field label="Label">Simple Text</Field>)
expect(screen.getByText('Simple Text')).toBeInTheDocument()
})
it('should render with complex children', () => {
render(
<Field label="Label">
<div data-testid="complex-child">
<span>Nested Content</span>
</div>
</Field>,
)
expect(screen.getByTestId('complex-child')).toBeInTheDocument()
expect(screen.getByText('Nested Content')).toBeInTheDocument()
})
it('should render with multiple children', () => {
render(
<Field label="Label">
<span>First</span>
<span>Second</span>
</Field>,
)
expect(screen.getByText('First')).toBeInTheDocument()
expect(screen.getByText('Second')).toBeInTheDocument()
})
it('should render different labels correctly', () => {
const { rerender } = render(<Field label="First Label">Content</Field>)
expect(screen.getByText('First Label')).toBeInTheDocument()
rerender(<Field label="Second Label">Content</Field>)
expect(screen.getByText('Second Label')).toBeInTheDocument()
})
})
describe('Layout', () => {
it('should have label above content', () => {
const { container } = render(<Field label="Label">Content</Field>)
const wrapper = container.firstChild as HTMLElement
expect(wrapper?.children).toHaveLength(2)
})
it('should render label element first', () => {
const { container } = render(<Field label="Label">Content</Field>)
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(<Field label="Label" className={undefined}>Content</Field>)
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('should render with empty className', () => {
render(<Field label="Label" className="">Content</Field>)
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('should render with empty label', () => {
render(<Field label="">Content</Field>)
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('should render with empty children', () => {
const { container } = render(<Field label="Label"><span></span></Field>)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with null children', () => {
const { container } = render(<Field label="Label">{null}</Field>)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with number as children', () => {
render(<Field label="Label">{42}</Field>)
expect(screen.getByText('42')).toBeInTheDocument()
})
it('should handle special characters in label', () => {
render(<Field label={'Label & "chars"'}>Content</Field>)
expect(screen.getByText('Label & "chars"')).toBeInTheDocument()
})
})
})

View File

@ -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) => (
<div data-testid="portal-wrapper" data-open={open}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: TriggerProps) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: ContentProps) => (
<div data-testid="portal-content">{children}</div>
),
}))
// Mock SelectMetadata component
vi.mock('./select-metadata', () => ({
default: ({ onSelect, onNew, onManage, list }: SelectMetadataProps) => (
<div data-testid="select-metadata">
<span data-testid="list-count">{list?.length || 0}</span>
<button data-testid="select-item" onClick={() => onSelect({ id: '1', name: 'field_one', type: DataType.string })}>Select</button>
<button data-testid="new-btn" onClick={onNew}>New</button>
<button data-testid="manage-btn" onClick={onManage}>Manage</button>
</div>
),
}))
// Mock CreateContent component
vi.mock('./create-content', () => ({
default: ({ onSave, onBack, onClose, hasBack }: CreateContentProps) => (
<div data-testid="create-content">
<button data-testid="save-btn" onClick={() => onSave({ type: DataType.string, name: 'new_field' })}>Save</button>
{hasBack && <button data-testid="back-btn" onClick={onBack}>Back</button>}
<button data-testid="close-btn" onClick={onClose}>Close</button>
</div>
),
}))
describe('SelectMetadataModal', () => {
const mockTrigger = <button data-testid="trigger-button">Select Metadata</button>
describe('Rendering', () => {
it('should render without crashing', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
})
it('should render trigger element', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByTestId('trigger-button')).toBeInTheDocument()
})
it('should render SelectMetadata by default', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByTestId('select-metadata')).toBeInTheDocument()
})
it('should pass dataset metadata to SelectMetadata', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByTestId('list-count')).toHaveTextContent('2')
})
})
describe('User Interactions', () => {
it('should toggle open state when trigger is clicked', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
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(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={handleSelect}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
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(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
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(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={handleManage}
/>,
)
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(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
// 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(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={handleSave}
onManage={vi.fn()}
/>,
)
// 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(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
popupPlacement="bottom-start"
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
})
it('should accept custom popupOffset', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
popupOffset={{ mainAxis: 10, crossAxis: 5 }}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle different datasetIds', () => {
const { rerender } = render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
rerender(
<SelectMetadataModal
datasetId="dataset-2"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
})
it('should handle empty trigger', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={<span data-testid="empty-trigger" />}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByTestId('empty-trigger')).toBeInTheDocument()
})
})
})

View File

@ -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) => <span data-testid="icon" className={props.className}>Icon</span>,
}))
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(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render search input', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render all metadata items', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
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(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
// New action button should be present (from i18n)
expect(screen.getByText(/new/i)).toBeInTheDocument()
})
it('should render manage action button', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
// Manage action button should be present (from i18n)
expect(screen.getByText(/manage/i)).toBeInTheDocument()
})
it('should display type for each item', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
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(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
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(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
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(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
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(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
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(
<SelectMetadata
list={mockList}
onSelect={handleSelect}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
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(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={handleNew}
onManage={vi.fn()}
/>,
)
// 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(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={handleManage}
/>,
)
// 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(
<SelectMetadata
list={[]}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should still show new and manage buttons with empty list', () => {
render(
<SelectMetadata
list={[]}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByText(/new/i)).toBeInTheDocument()
expect(screen.getByText(/manage/i)).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have correct container styling', () => {
const { container } = render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
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(
<SelectMetadata
list={singleItem}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
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(
<SelectMetadata
list={longNameItem}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByText('this_is_a_very_long_field_name_that_might_overflow')).toBeInTheDocument()
})
it('should handle rapid search input changes', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
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()
})
})
})

View File

@ -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(<Field label="Test Label">Content</Field>)
expect(screen.getByText('Test Label')).toBeInTheDocument()
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('should render label with correct styling', () => {
render(<Field label="My Label">Content</Field>)
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(<Field label="Label">Child Content</Field>)
// 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(<Field label="Label">Simple Text</Field>)
expect(screen.getByText('Simple Text')).toBeInTheDocument()
})
it('should render with complex children', () => {
render(
<Field label="Label">
<div data-testid="complex-child">
<span>Nested Content</span>
</div>
</Field>,
)
expect(screen.getByTestId('complex-child')).toBeInTheDocument()
expect(screen.getByText('Nested Content')).toBeInTheDocument()
})
it('should render with multiple children', () => {
render(
<Field label="Label">
<span>First</span>
<span>Second</span>
</Field>,
)
expect(screen.getByText('First')).toBeInTheDocument()
expect(screen.getByText('Second')).toBeInTheDocument()
})
it('should render different labels correctly', () => {
const { rerender } = render(<Field label="First Label">Content</Field>)
expect(screen.getByText('First Label')).toBeInTheDocument()
rerender(<Field label="Second Label">Content</Field>)
expect(screen.getByText('Second Label')).toBeInTheDocument()
})
})
describe('Layout', () => {
it('should have flex layout with space between elements', () => {
const { container } = render(<Field label="Label">Content</Field>)
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(<Field label="Label">Content</Field>)
const wrapper = container.firstChild as HTMLElement
expect(wrapper?.children).toHaveLength(2)
})
})
describe('Edge Cases', () => {
it('should render with empty label', () => {
render(<Field label="">Content</Field>)
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(<Field label={longLabel}>Content</Field>)
const labelElement = screen.getByText(longLabel)
expect(labelElement).toHaveClass('truncate')
})
it('should render with empty children', () => {
const { container } = render(<Field label="Label"><span></span></Field>)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with null children', () => {
const { container } = render(<Field label="Label">{null}</Field>)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with number as children', () => {
render(<Field label="Label">{42}</Field>)
expect(screen.getByText('42')).toBeInTheDocument()
})
it('should handle special characters in label', () => {
render(<Field label={'Label & "chars"'}>Content</Field>)
expect(screen.getByText('Label & "chars"')).toBeInTheDocument()
})
})
})

View File

@ -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<typeof vi.fn>
list: MetadataItemWithValue[]
tempList: MetadataItemWithValue[]
setTempList: ReturnType<typeof vi.fn>
handleSelectMetaData: ReturnType<typeof vi.fn>
handleAddMetaData: ReturnType<typeof vi.fn>
hasData: boolean
builtList: MetadataItemWithValue[]
builtInEnabled: boolean
startToEdit: ReturnType<typeof vi.fn>
handleSave: ReturnType<typeof vi.fn>
handleCancel: ReturnType<typeof vi.fn>
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(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render metadata fields when hasData is true', () => {
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[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(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[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(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[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(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[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(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[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(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[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(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[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(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[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(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[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(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[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(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[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(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[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(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[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(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[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(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[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(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(typeof handleAddMetaData).toBe('function')
})
it('should have handleSelectMetaData function available', () => {
const handleSelectMetaData = vi.fn()
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
isEdit: true,
handleSelectMetaData,
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[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(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[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(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[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(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[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(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText('temp_field')).toBeInTheDocument()
})
it('should use list when not in edit mode', () => {
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText('field_one')).toBeInTheDocument()
expect(screen.getByText('field_two')).toBeInTheDocument()
})
it('should pass datasetId to child components', () => {
render(
<MetadataDocument
datasetId="custom-ds-id"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[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(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[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(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[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(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[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(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render correctly with minimal props', () => {
const { container } = render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle switching between view and edit mode', () => {
const { unmount } = render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText(/edit/i)).toBeInTheDocument()
unmount()
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
isEdit: true,
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[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(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[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(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[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(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText('undefined_field')).toBeInTheDocument()
})
})
})

View File

@ -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: () => <button data-testid="add-metadata-btn">Add Metadata</button>,
}))
// Mock InputCombined
vi.mock('../edit-metadata-batch/input-combined', () => ({
default: ({ value, onChange, type }: InputCombinedProps) => (
<input
data-testid="input-combined"
data-type={type}
value={value || ''}
onChange={e => onChange(e.target.value)}
/>
),
}))
// Mock SelectMetadataModal
vi.mock('../metadata-dataset/select-metadata-modal', () => ({
default: ({ trigger, onSelect, onSave, onManage }: SelectModalProps) => (
<div data-testid="select-metadata-modal">
{trigger}
<button data-testid="select-action" onClick={() => onSelect({ id: '1', name: 'test', type: DataType.string, value: null })}>Select</button>
<button data-testid="save-action" onClick={() => onSave({ name: 'new_field', type: DataType.string })}>Save</button>
<button data-testid="manage-action" onClick={onManage}>Manage</button>
</div>
),
}))
// Mock Field
vi.mock('./field', () => ({
default: ({ label, children }: FieldProps) => (
<div data-testid="field">
<span data-testid="field-label">{label}</span>
<div data-testid="field-content">{children}</div>
</div>
),
}))
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(
<InfoGroup dataSetId="ds-1" list={mockList} />,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render title when provided', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} title="Test Title" />,
)
expect(screen.getByText('Test Title')).toBeInTheDocument()
})
it('should not render header when noHeader is true', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} title="Test Title" noHeader />,
)
expect(screen.queryByText('Test Title')).not.toBeInTheDocument()
})
it('should render all list items', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} />,
)
const fields = screen.getAllByTestId('field')
expect(fields).toHaveLength(3)
})
it('should render tooltip when titleTooltip is provided', () => {
render(
<InfoGroup
dataSetId="ds-1"
list={mockList}
title="Test"
titleTooltip="This is a tooltip"
/>,
)
// Tooltip icon should be present
const tooltipIcon = screen.getByText('Test').closest('.flex')?.querySelector('svg')
expect(tooltipIcon).toBeInTheDocument()
})
it('should render headerRight content', () => {
render(
<InfoGroup
dataSetId="ds-1"
list={mockList}
title="Test"
headerRight={<button data-testid="header-right-btn">Action</button>}
/>,
)
expect(screen.getByTestId('header-right-btn')).toBeInTheDocument()
})
})
describe('Edit Mode', () => {
it('should render add metadata button when isEdit is true', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit />,
)
expect(screen.getByTestId('add-metadata-btn')).toBeInTheDocument()
})
it('should not render add metadata button when isEdit is false', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit={false} />,
)
expect(screen.queryByTestId('add-metadata-btn')).not.toBeInTheDocument()
})
it('should render input combined for each item in edit mode', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit />,
)
const inputs = screen.getAllByTestId('input-combined')
expect(inputs).toHaveLength(3)
})
it('should render delete icons in edit mode', () => {
const { container } = render(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit />,
)
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(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit onChange={handleChange} />,
)
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(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit onDelete={handleDelete} />,
)
// 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(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit onSelect={handleSelect} />,
)
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(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit onAdd={handleAdd} />,
)
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(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit />,
)
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(
<InfoGroup dataSetId="ds-1" list={mockList} className="custom-class" />,
)
expect(container.firstChild).toHaveClass('custom-class')
})
it('should apply contentClassName', () => {
const { container } = render(
<InfoGroup dataSetId="ds-1" list={mockList} contentClassName="content-custom" />,
)
const contentDiv = container.querySelector('.content-custom')
expect(contentDiv).toBeInTheDocument()
})
it('should use uppercase title by default', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} title="Test Title" />,
)
const titleElement = screen.getByText('Test Title')
expect(titleElement).toHaveClass('system-xs-semibold-uppercase')
})
it('should not use uppercase when uppercaseTitle is false', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} title="Test Title" uppercaseTitle={false} />,
)
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(
<InfoGroup dataSetId="ds-1" list={stringList} />,
)
expect(screen.getByText('Test Value')).toBeInTheDocument()
})
it('should display number value', () => {
const numberList: MetadataItemWithValue[] = [
{ id: '1', name: 'field', type: DataType.number, value: 123 },
]
render(
<InfoGroup dataSetId="ds-1" list={numberList} />,
)
expect(screen.getByText('123')).toBeInTheDocument()
})
it('should format time value', () => {
const timeList: MetadataItemWithValue[] = [
{ id: '1', name: 'field', type: DataType.time, value: 1609459200 },
]
render(
<InfoGroup dataSetId="ds-1" list={timeList} />,
)
// The mock formatTime returns formatted date
expect(screen.getByTestId('field-content')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty list', () => {
const { container } = render(
<InfoGroup dataSetId="ds-1" list={[]} />,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle null value in list', () => {
const nullList: MetadataItemWithValue[] = [
{ id: '1', name: 'field', type: DataType.string, value: null },
]
render(
<InfoGroup dataSetId="ds-1" list={nullList} />,
)
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(
<InfoGroup dataSetId="ds-1" list={builtInList} />,
)
expect(screen.getByTestId('field')).toBeInTheDocument()
})
})
})

View File

@ -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(<NoData onStart={handleStart} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with gradient background', () => {
const handleStart = vi.fn()
const { container } = render(<NoData onStart={handleStart} />)
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(<NoData onStart={handleStart} />)
// 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(<NoData onStart={handleStart} />)
// 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(<NoData onStart={handleStart} />)
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})
it('should render arrow icon in button', () => {
const handleStart = vi.fn()
const { container } = render(<NoData onStart={handleStart} />)
// 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(<NoData onStart={handleStart} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onStart when button is clicked', () => {
const handleStart = vi.fn()
render(<NoData onStart={handleStart} />)
fireEvent.click(screen.getByRole('button'))
expect(handleStart).toHaveBeenCalledTimes(1)
})
it('should call onStart multiple times on multiple clicks', () => {
const handleStart = vi.fn()
render(<NoData onStart={handleStart} />)
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(<NoData onStart={handleStart} />)
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(<NoData onStart={handleStart} />)
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(<NoData onStart={handleStart} />)
const title = container.querySelector('.text-xs.font-semibold')
expect(title).toBeInTheDocument()
})
it('should have correct description styling', () => {
const handleStart = vi.fn()
const { container } = render(<NoData onStart={handleStart} />)
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(<NoData onStart={handleStart} />)
const button = screen.getByRole('button')
for (let i = 0; i < 10; i++) {
fireEvent.click(button)
}
expect(handleStart).toHaveBeenCalledTimes(10)
})
})
})

View File

@ -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)
})
})
})

File diff suppressed because it is too large Load Diff

View File

@ -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)
})
})
})

View File

@ -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) => (
<div
data-testid="option-card"
data-id={id}
data-active={isActive}
data-disabled={disabled}
>
{title}
</div>
),
}))
// Mock hook
vi.mock('./hooks', () => ({
useChunkStructure: () => ({
options: [
{
id: ChunkingMode.text,
title: 'General',
description: 'General description',
icon: <svg />,
effectColor: 'indigo',
iconActiveColor: 'indigo',
},
{
id: ChunkingMode.parentChild,
title: 'Parent-Child',
description: 'PC description',
icon: <svg />,
effectColor: 'blue',
iconActiveColor: 'blue',
},
],
}),
}))
// Note: react-i18next is globally mocked in vitest.setup.ts
describe('ChunkStructure', () => {
it('should render all options', () => {
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
describe('Rendering', () => {
it('should render without crashing', () => {
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
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(<ChunkStructure chunkStructure={ChunkingMode.text} />)
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(<ChunkStructure chunkStructure={ChunkingMode.text} />)
const wrapper = container.firstChild
expect(wrapper).toHaveClass('flex-col')
})
})
it('should set active state correctly', () => {
// Render with 'text' active
const { unmount } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
describe('Active State', () => {
it('should mark General option as active when chunkStructure is text', () => {
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
// 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(<ChunkStructure chunkStructure={ChunkingMode.parentChild} />)
const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
})
unmount()
// Render with 'parentChild' active
render(<ChunkStructure chunkStructure={ChunkingMode.parentChild} />)
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(<ChunkStructure chunkStructure={ChunkingMode.qa} />)
const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
})
})
it('should be always disabled', () => {
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
describe('Disabled State', () => {
it('should render all options as disabled', () => {
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
// 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(<ChunkStructure chunkStructure={ChunkingMode.text} />)
// 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(<ChunkStructure chunkStructure={ChunkingMode.text} />)
// 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(<ChunkStructure chunkStructure={ChunkingMode.text} />)
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(<ChunkStructure chunkStructure={ChunkingMode.parentChild} />)
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(<ChunkStructure chunkStructure={ChunkingMode.text} />)
// Initially one card is active
let activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
// Change to parentChild
rerender(<ChunkStructure chunkStructure={ChunkingMode.parentChild} />)
// Still one card should be active
activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
// Change to qa
rerender(<ChunkStructure chunkStructure={ChunkingMode.qa} />)
// 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(<ChunkStructure chunkStructure={ChunkingMode.text} />)
// Verify all expected options are rendered
const expectedTitles = ['General', 'Parent-Child', 'Q&A']
expectedTitles.forEach((title) => {
expect(screen.getByText(title)).toBeInTheDocument()
})
})
})
})

View File

@ -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(<IndexMethod {...defaultProps} />)
expect(screen.getByText(/stepTwo\.qualified/)).toBeInTheDocument()
})
it('should render High Quality option', () => {
render(<IndexMethod {...defaultProps} />)
expect(screen.getByText(/stepTwo\.qualified/)).toBeInTheDocument()
})
it('should render Economy option', () => {
render(<IndexMethod {...defaultProps} />)
expect(screen.getAllByText(/form\.indexMethodEconomy/).length).toBeGreaterThan(0)
})
it('should render High Quality description', () => {
render(<IndexMethod {...defaultProps} />)
expect(screen.getByText(/form\.indexMethodHighQualityTip/)).toBeInTheDocument()
})
it('should render Economy description', () => {
render(<IndexMethod {...defaultProps} />)
expect(screen.getByText(/form\.indexMethodEconomyTip/)).toBeInTheDocument()
})
it('should render recommended badge on High Quality', () => {
render(<IndexMethod {...defaultProps} />)
expect(screen.getByText(/stepTwo\.recommend/)).toBeInTheDocument()
})
})
describe('Active State', () => {
it('should mark High Quality as active when value is QUALIFIED', () => {
const { container } = render(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} />)
const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
})
it('should mark Economy as active when value is ECONOMICAL', () => {
const { container } = render(<IndexMethod {...defaultProps} value={IndexingType.ECONOMICAL} />)
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(<IndexMethod {...defaultProps} value={IndexingType.ECONOMICAL} onChange={handleChange} />)
// 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(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} onChange={handleChange} currentValue={IndexingType.ECONOMICAL} />)
// 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(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} onChange={handleChange} />)
// 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(<IndexMethod {...defaultProps} disabled={true} />)
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(<IndexMethod {...defaultProps} currentValue={IndexingType.QUALIFIED} onChange={handleChange} value={IndexingType.ECONOMICAL} />)
// 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(<IndexMethod {...defaultProps} />)
// KeywordNumber has a slider
expect(screen.getByRole('slider')).toBeInTheDocument()
})
it('should pass keywordNumber to KeywordNumber component', () => {
render(<IndexMethod {...defaultProps} keywordNumber={25} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(25)
})
it('should call onKeywordNumberChange when KeywordNumber changes', () => {
const handleKeywordChange = vi.fn()
render(<IndexMethod {...defaultProps} onKeywordNumberChange={handleKeywordChange} />)
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(<IndexMethod {...defaultProps} currentValue={IndexingType.QUALIFIED} />)
// 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(<IndexMethod {...defaultProps} />)
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(<IndexMethod {...defaultProps} />)
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(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} />)
let activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
rerender(<IndexMethod {...defaultProps} value={IndexingType.ECONOMICAL} currentValue={IndexingType.ECONOMICAL} />)
activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
})
})
describe('Edge Cases', () => {
it('should handle undefined currentValue', () => {
render(<IndexMethod {...defaultProps} currentValue={undefined} />)
// Should render without error
expect(screen.getByText(/stepTwo\.qualified/)).toBeInTheDocument()
})
it('should handle keywordNumber of 0', () => {
render(<IndexMethod {...defaultProps} keywordNumber={0} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(0)
})
it('should handle max keywordNumber', () => {
render(<IndexMethod {...defaultProps} keywordNumber={50} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(50)
})
})
})

View File

@ -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(<KeyWordNumber {...defaultProps} />)
expect(screen.getByText(/form\.numberOfKeywords/)).toBeInTheDocument()
})
it('should render label text', () => {
render(<KeyWordNumber {...defaultProps} />)
expect(screen.getByText(/form\.numberOfKeywords/)).toBeInTheDocument()
})
it('should render tooltip with question icon', () => {
render(<KeyWordNumber {...defaultProps} />)
// 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(<KeyWordNumber {...defaultProps} />)
// Slider has a slider role
expect(screen.getByRole('slider')).toBeInTheDocument()
})
it('should render input number field', () => {
render(<KeyWordNumber {...defaultProps} />)
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should display correct keywordNumber value in input', () => {
render(<KeyWordNumber {...defaultProps} keywordNumber={25} />)
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(<KeyWordNumber {...defaultProps} keywordNumber={value} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(value)
unmount()
})
})
it('should pass correct value to slider', () => {
render(<KeyWordNumber {...defaultProps} keywordNumber={30} />)
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(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
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(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
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(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
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(<KeyWordNumber {...defaultProps} />)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemax', '50')
})
it('should have min value of 0', () => {
render(<KeyWordNumber {...defaultProps} />)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemin', '0')
})
})
describe('Edge Cases', () => {
it('should handle minimum value (0)', () => {
render(<KeyWordNumber {...defaultProps} keywordNumber={0} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(0)
})
it('should handle maximum value (50)', () => {
render(<KeyWordNumber {...defaultProps} keywordNumber={50} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(50)
})
it('should handle value updates correctly', () => {
const { rerender } = render(<KeyWordNumber {...defaultProps} keywordNumber={10} />)
let input = screen.getByRole('spinbutton')
expect(input).toHaveValue(10)
rerender(<KeyWordNumber {...defaultProps} keywordNumber={25} />)
input = screen.getByRole('spinbutton')
expect(input).toHaveValue(25)
})
it('should handle rapid value changes', () => {
const handleChange = vi.fn()
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
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(<KeyWordNumber {...defaultProps} />)
const slider = screen.getByRole('slider')
expect(slider).toBeInTheDocument()
})
it('should have accessible input', () => {
render(<KeyWordNumber {...defaultProps} />)
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
})
})
})

View File

@ -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(<OptionCard {...defaultProps} />)
expect(screen.getByText('Test Title')).toBeInTheDocument()
})
it('should render title', () => {
render(<OptionCard {...defaultProps} title="Custom Title" />)
expect(screen.getByText('Custom Title')).toBeInTheDocument()
})
it('should render description when provided', () => {
render(<OptionCard {...defaultProps} description="Test Description" />)
expect(screen.getByText('Test Description')).toBeInTheDocument()
})
it('should not render description when not provided', () => {
render(<OptionCard {...defaultProps} />)
expect(screen.queryByText(/description/i)).not.toBeInTheDocument()
})
it('should render icon when provided', () => {
render(<OptionCard {...defaultProps} icon={<span data-testid="test-icon">Icon</span>} />)
expect(screen.getByTestId('test-icon')).toBeInTheDocument()
})
it('should not render icon container when icon is not provided', () => {
const { container } = render(<OptionCard {...defaultProps} />)
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(<OptionCard {...defaultProps} isActive={true} />)
const card = container.firstChild
expect(card).toHaveClass('ring-[1px]')
})
it('should not apply active styles when isActive is false', () => {
const { container } = render(<OptionCard {...defaultProps} isActive={false} />)
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(
<OptionCard
{...defaultProps}
isActive={true}
icon={<span>Icon</span>}
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(<OptionCard {...defaultProps} disabled={true} />)
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(<OptionCard {...defaultProps} disabled={true} onClick={handleClick} />)
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(<OptionCard {...defaultProps} isActive={true} onClick={handleClick} />)
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(<OptionCard {...defaultProps} isRecommended={true} />)
// Badge uses translation key
expect(screen.getByText(/stepTwo\.recommend/)).toBeInTheDocument()
})
it('should not render recommended badge when isRecommended is false', () => {
render(<OptionCard {...defaultProps} isRecommended={false} />)
expect(screen.queryByText(/stepTwo\.recommend/)).not.toBeInTheDocument()
})
})
describe('Effect Color', () => {
it('should render effect color when effectColor and showEffectColor are provided', () => {
const { container } = render(
<OptionCard {...defaultProps} effectColor={EffectColor.indigo} showEffectColor={true} />,
)
const effectElement = container.querySelector('.blur-\\[80px\\]')
expect(effectElement).toBeInTheDocument()
})
it('should not render effect color when showEffectColor is false', () => {
const { container } = render(
<OptionCard {...defaultProps} effectColor={EffectColor.indigo} showEffectColor={false} />,
)
const effectElement = container.querySelector('.blur-\\[80px\\]')
expect(effectElement).not.toBeInTheDocument()
})
it('should not render effect color when effectColor is not provided', () => {
const { container } = render(
<OptionCard {...defaultProps} showEffectColor={true} />,
)
const effectElement = container.querySelector('.blur-\\[80px\\]')
expect(effectElement).not.toBeInTheDocument()
})
it('should apply indigo effect color class', () => {
const { container } = render(
<OptionCard {...defaultProps} effectColor={EffectColor.indigo} showEffectColor={true} />,
)
const effectElement = container.querySelector('.bg-util-colors-indigo-indigo-600')
expect(effectElement).toBeInTheDocument()
})
it('should apply blueLight effect color class', () => {
const { container } = render(
<OptionCard {...defaultProps} effectColor={EffectColor.blueLight} showEffectColor={true} />,
)
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(
<OptionCard {...defaultProps} effectColor={EffectColor.orange} showEffectColor={true} />,
)
const effectElement = container.querySelector('.bg-util-colors-orange-orange-500')
expect(effectElement).toBeInTheDocument()
})
it('should apply purple effect color class', () => {
const { container } = render(
<OptionCard {...defaultProps} effectColor={EffectColor.purple} showEffectColor={true} />,
)
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(
<OptionCard {...defaultProps} showChildren={true}>
<div data-testid="child-content">Child Content</div>
</OptionCard>,
)
expect(screen.getByTestId('child-content')).toBeInTheDocument()
})
it('should not render children when showChildren is false', () => {
render(
<OptionCard {...defaultProps} showChildren={false}>
<div data-testid="child-content">Child Content</div>
</OptionCard>,
)
expect(screen.queryByTestId('child-content')).not.toBeInTheDocument()
})
it('should not render children container when children is not provided', () => {
const { container } = render(
<OptionCard {...defaultProps} showChildren={true} />,
)
const childContainer = container.querySelector('.bg-components-panel-bg')
expect(childContainer).not.toBeInTheDocument()
})
it('should render arrow shape when children are shown', () => {
const { container } = render(
<OptionCard {...defaultProps} showChildren={true}>
<div>Child</div>
</OptionCard>,
)
// 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(<OptionCard {...defaultProps} id="my-id" onClick={handleClick} />)
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(<OptionCard {...defaultProps} />)
const card = container.firstChild
expect(card).toHaveClass('cursor-pointer')
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(<OptionCard {...defaultProps} className="custom-class" />)
const innerContainer = container.querySelector('.custom-class')
expect(innerContainer).toBeInTheDocument()
})
it('should forward ref', () => {
const ref = vi.fn()
render(<OptionCard {...defaultProps} ref={ref} />)
expect(ref).toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should handle empty title', () => {
render(<OptionCard {...defaultProps} title="" />)
// Component should still render
const { container } = render(<OptionCard {...defaultProps} title="" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle complex id types', () => {
const handleClick = vi.fn()
const complexId = { key: 'value' }
render(<OptionCard {...defaultProps} id={complexId} onClick={handleClick} />)
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(<OptionCard {...defaultProps} id={123} onClick={handleClick} />)
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(<OptionCard {...defaultProps} title={longTitle} />)
expect(screen.getByText(longTitle)).toBeInTheDocument()
})
it('should handle long description', () => {
const longDesc = 'B'.repeat(500)
render(<OptionCard {...defaultProps} description={longDesc} />)
expect(screen.getByText(longDesc)).toBeInTheDocument()
})
it('should handle all props together', () => {
const handleClick = vi.fn()
render(
<OptionCard
id="full-test"
title="Full Test"
description="Full Description"
icon={<span data-testid="full-icon">Icon</span>}
iconActiveColor="text-blue-500"
isActive={true}
isRecommended={true}
effectColor={EffectColor.indigo}
showEffectColor={true}
disabled={false}
onClick={handleClick}
className="full-class"
showChildren={true}
>
<div data-testid="full-children">Children</div>
</OptionCard>,
)
expect(screen.getByText('Full Test')).toBeInTheDocument()
expect(screen.getByText('Full Description')).toBeInTheDocument()
expect(screen.getByTestId('full-icon')).toBeInTheDocument()
expect(screen.getByTestId('full-children')).toBeInTheDocument()
})
})
})

View File

@ -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(<PermissionSelector {...defaultProps} />)
expect(screen.getByText(/form\.permissionsOnlyMe/)).toBeInTheDocument()
})
it('should render Only Me option when permission is onlyMe', () => {
render(<PermissionSelector {...defaultProps} permission={DatasetPermission.onlyMe} />)
expect(screen.getByText(/form\.permissionsOnlyMe/)).toBeInTheDocument()
})
it('should render All Team Members option when permission is allTeamMembers', () => {
render(<PermissionSelector {...defaultProps} permission={DatasetPermission.allTeamMembers} />)
expect(screen.getByText(/form\.permissionsAllMember/)).toBeInTheDocument()
})
it('should render selected member names when permission is partialMembers', () => {
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
value={['user-1', 'user-2']}
/>,
)
// Should show member names
expect(screen.getByTitle(/Current User/)).toBeInTheDocument()
})
})
describe('Dropdown Toggle', () => {
it('should open dropdown when clicked', async () => {
render(<PermissionSelector {...defaultProps} />)
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(<PermissionSelector {...defaultProps} disabled={true} />)
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(<PermissionSelector {...defaultProps} onChange={handleChange} permission={DatasetPermission.allTeamMembers} />)
// 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(<PermissionSelector {...defaultProps} onChange={handleChange} />)
// 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(
<PermissionSelector
{...defaultProps}
onChange={handleChange}
onMemberSelect={handleMemberSelect}
/>,
)
// 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(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
/>,
)
// 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(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
onMemberSelect={handleMemberSelect}
/>,
)
// 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(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
value={['user-1', 'user-2']}
onMemberSelect={handleMemberSelect}
/>,
)
// 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(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
/>,
)
// 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(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
/>,
)
// 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(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
/>,
)
// 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(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
/>,
)
// 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(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
/>,
)
// 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(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
/>,
)
// 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(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
/>,
)
// 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(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
/>,
)
// 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(<PermissionSelector {...defaultProps} disabled={true} />)
// 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(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
value={['user-1']}
/>,
)
// Should display single avatar
expect(screen.getByTitle(/Current User/)).toBeInTheDocument()
})
it('should display two avatars when two or more members selected', () => {
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
value={['user-1', 'user-2']}
/>,
)
// Should display member names
expect(screen.getByTitle(/Current User, John Doe/)).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty member list', () => {
render(
<PermissionSelector
{...defaultProps}
memberList={[]}
/>,
)
expect(screen.getByText(/form\.permissionsOnlyMe/)).toBeInTheDocument()
})
it('should handle member list with only current user', () => {
render(
<PermissionSelector
{...defaultProps}
memberList={[mockMemberList[0]]}
/>,
)
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(
<PermissionSelector
{...defaultProps}
memberList={memberListWithNormalUser}
permission={DatasetPermission.partialMembers}
/>,
)
// 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(<PermissionSelector {...defaultProps} permission={DatasetPermission.onlyMe} />)
expect(screen.getByText(/form\.permissionsOnlyMe/)).toBeInTheDocument()
rerender(<PermissionSelector {...defaultProps} permission={DatasetPermission.allTeamMembers} />)
expect(screen.getByText(/form\.permissionsAllMember/)).toBeInTheDocument()
})
})
})

View File

@ -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: <span data-testid="avatar-icon">Avatar</span>,
name: 'John Doe',
email: 'john@example.com',
isSelected: false,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<MemberItem {...defaultProps} />)
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
it('should render left icon (avatar)', () => {
render(<MemberItem {...defaultProps} />)
expect(screen.getByTestId('avatar-icon')).toBeInTheDocument()
})
it('should render member name', () => {
render(<MemberItem {...defaultProps} />)
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
it('should render member email', () => {
render(<MemberItem {...defaultProps} />)
expect(screen.getByText('john@example.com')).toBeInTheDocument()
})
})
describe('Selection State', () => {
it('should show checkmark icon when selected', () => {
render(<MemberItem {...defaultProps} isSelected={true} />)
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(<MemberItem {...defaultProps} isSelected={false} />)
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(<MemberItem {...defaultProps} isSelected={true} isMe={true} />)
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(<MemberItem {...defaultProps} isMe={true} />)
// 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(<MemberItem {...defaultProps} isMe={false} />)
expect(screen.queryByText(/form\.me/)).not.toBeInTheDocument()
})
it('should not show me indicator by default', () => {
render(<MemberItem {...defaultProps} />)
expect(screen.queryByText(/form\.me/)).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClick when clicked', () => {
const handleClick = vi.fn()
render(<MemberItem {...defaultProps} onClick={handleClick} />)
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(<MemberItem {...defaultProps} />)
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(<MemberItem {...defaultProps} />)
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(<MemberItem {...defaultProps} name={name} />)
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(<MemberItem {...defaultProps} email={email} />)
expect(screen.getByText(email)).toBeInTheDocument()
unmount()
})
})
it('should render different left icons', () => {
const customIcon = <img data-testid="custom-avatar" alt="avatar" />
render(<MemberItem {...defaultProps} leftIcon={customIcon} />)
expect(screen.getByTestId('custom-avatar')).toBeInTheDocument()
})
it('should handle isSelected toggle correctly', () => {
const { rerender } = render(<MemberItem {...defaultProps} isSelected={false} />)
// Initially not selected
let container = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
expect(container?.querySelector('svg')).not.toBeInTheDocument()
// Update to selected
rerender(<MemberItem {...defaultProps} isSelected={true} />)
container = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
expect(container?.querySelector('svg')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty name', () => {
render(<MemberItem {...defaultProps} name="" />)
expect(screen.getByText('john@example.com')).toBeInTheDocument()
})
it('should handle empty email', () => {
render(<MemberItem {...defaultProps} email="" />)
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
it('should handle long name with truncation', () => {
const longName = 'A'.repeat(100)
render(<MemberItem {...defaultProps} name={longName} />)
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(<MemberItem {...defaultProps} email={longEmail} />)
const emailElement = screen.getByText(longEmail)
expect(emailElement).toHaveClass('truncate')
})
it('should handle special characters in name', () => {
const specialName = 'O\'Connor-Smith'
render(<MemberItem {...defaultProps} name={specialName} />)
expect(screen.getByText(specialName)).toBeInTheDocument()
})
it('should handle unicode characters', () => {
const unicodeName = '张三'
const unicodeEmail = '张三@example.com'
render(<MemberItem {...defaultProps} name={unicodeName} email={unicodeEmail} />)
expect(screen.getByText(unicodeName)).toBeInTheDocument()
expect(screen.getByText(unicodeEmail)).toBeInTheDocument()
})
it('should render both isMe and isSelected together', () => {
render(<MemberItem {...defaultProps} isMe={true} isSelected={true} />)
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')
})
})
})

View File

@ -0,0 +1,130 @@
import { fireEvent, render, screen } from '@testing-library/react'
import PermissionItem from './permission-item'
describe('PermissionItem', () => {
const defaultProps = {
leftIcon: <span data-testid="left-icon">Icon</span>,
text: 'Test Permission',
onClick: vi.fn(),
isSelected: false,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<PermissionItem {...defaultProps} />)
expect(screen.getByText('Test Permission')).toBeInTheDocument()
})
it('should render left icon', () => {
render(<PermissionItem {...defaultProps} />)
expect(screen.getByTestId('left-icon')).toBeInTheDocument()
})
it('should render text content', () => {
const text = 'Custom Permission Text'
render(<PermissionItem {...defaultProps} text={text} />)
expect(screen.getByText(text)).toBeInTheDocument()
})
})
describe('Selection State', () => {
it('should show checkmark icon when selected', () => {
render(<PermissionItem {...defaultProps} isSelected={true} />)
// 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(<PermissionItem {...defaultProps} isSelected={false} />)
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(<PermissionItem {...defaultProps} onClick={handleClick} />)
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(<PermissionItem {...defaultProps} />)
const item = screen.getByText('Test Permission').closest('div')?.parentElement
expect(item).toHaveClass('cursor-pointer')
})
})
describe('Props', () => {
it('should render different left icons', () => {
const customIcon = <span data-testid="custom-icon">Custom</span>
render(<PermissionItem {...defaultProps} leftIcon={customIcon} />)
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(<PermissionItem {...defaultProps} text={text} />)
expect(screen.getByText(text)).toBeInTheDocument()
unmount()
})
})
it('should handle isSelected toggle correctly', () => {
const { rerender } = render(<PermissionItem {...defaultProps} isSelected={false} />)
// Initially not selected - no checkmark
let container = screen.getByText('Test Permission').closest('div')?.parentElement
expect(container?.querySelector('svg')).not.toBeInTheDocument()
// Update to selected
rerender(<PermissionItem {...defaultProps} isSelected={true} />)
container = screen.getByText('Test Permission').closest('div')?.parentElement
expect(container?.querySelector('svg')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty text', () => {
render(<PermissionItem {...defaultProps} text="" />)
// The component should still render
expect(screen.getByTestId('left-icon')).toBeInTheDocument()
})
it('should handle long text content', () => {
const longText = 'A'.repeat(200)
render(<PermissionItem {...defaultProps} text={longText} />)
expect(screen.getByText(longText)).toBeInTheDocument()
})
it('should handle special characters in text', () => {
const specialText = '<script>alert("xss")</script>'
render(<PermissionItem {...defaultProps} text={specialText} />)
expect(screen.getByText(specialText)).toBeInTheDocument()
})
it('should handle complex left icon nodes', () => {
const complexIcon = (
<div data-testid="complex-icon">
<span>Nested</span>
<div>Content</div>
</div>
)
render(<PermissionItem {...defaultProps} leftIcon={complexIcon} />)
expect(screen.getByTestId('complex-icon')).toBeInTheDocument()
})
})
})

View File

@ -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)
})
})
})