mirror of https://github.com/langgenius/dify.git
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:
parent
a715c015e7
commit
76b64dda52
|
|
@ -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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue