mirror of
https://github.com/langgenius/dify.git
synced 2026-01-21 06:24:01 +00:00
Compare commits
12 Commits
refactor/p
...
copilot/su
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
266e887fb1 | ||
|
|
337701badb | ||
|
|
0d11745909 | ||
|
|
6a6997094d | ||
|
|
3f0719e50f | ||
|
|
67bab85fa6 | ||
|
|
84a1d6a948 | ||
|
|
3ebe53ada1 | ||
|
|
76b64dda52 | ||
|
|
a715c015e7 | ||
|
|
45b8d033be | ||
|
|
cb51a449d3 |
3
.github/labeler.yml
vendored
Normal file
3
.github/labeler.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
web:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: 'web/**'
|
||||
14
.github/workflows/labeler.yml
vendored
Normal file
14
.github/workflows/labeler.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
name: "Pull Request Labeler"
|
||||
on:
|
||||
pull_request_target:
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@v6
|
||||
with:
|
||||
sync-labels: true
|
||||
5
.github/workflows/style.yml
vendored
5
.github/workflows/style.yml
vendored
@@ -117,6 +117,11 @@ jobs:
|
||||
# eslint-report: web/eslint_report.json
|
||||
# github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Web tsslint
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
working-directory: ./web
|
||||
run: pnpm run lint:tss
|
||||
|
||||
- name: Web type check
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
working-directory: ./web
|
||||
|
||||
4
web/.vscode/extensions.json
vendored
4
web/.vscode/extensions.json
vendored
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"kisstkondoros.vscode-codemetrics"
|
||||
"kisstkondoros.vscode-codemetrics",
|
||||
"johnsoncodehk.vscode-tsslint",
|
||||
"dbaeumer.vscode-eslint"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
1169
web/app/components/datasets/extra-info/index.spec.tsx
Normal file
1169
web/app/components/datasets/extra-info/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
2654
web/app/components/datasets/hit-testing/index.spec.tsx
Normal file
2654
web/app/components/datasets/hit-testing/index.spec.tsx
Normal file
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
256
web/app/components/datasets/list/dataset-card/index.spec.tsx
Normal file
256
web/app/components/datasets/list/dataset-card/index.spec.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import DatasetCard from './index'
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
// Mock ahooks useHover
|
||||
vi.mock('ahooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ahooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useHover: () => false,
|
||||
}
|
||||
})
|
||||
|
||||
// Mock app context
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: () => false,
|
||||
}))
|
||||
|
||||
// Mock the useDatasetCardState hook
|
||||
vi.mock('./hooks/use-dataset-card-state', () => ({
|
||||
useDatasetCardState: () => ({
|
||||
tags: [],
|
||||
setTags: vi.fn(),
|
||||
modalState: {
|
||||
showRenameModal: false,
|
||||
showConfirmDelete: false,
|
||||
confirmMessage: '',
|
||||
},
|
||||
openRenameModal: vi.fn(),
|
||||
closeRenameModal: vi.fn(),
|
||||
closeConfirmDelete: vi.fn(),
|
||||
handleExportPipeline: vi.fn(),
|
||||
detectIsUsedByApp: vi.fn(),
|
||||
onConfirmDelete: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock the RenameDatasetModal
|
||||
vi.mock('../../rename-modal', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
// Mock useFormatTimeFromNow hook
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: (timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleDateString()
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useKnowledge hook
|
||||
vi.mock('@/hooks/use-knowledge', () => ({
|
||||
useKnowledge: () => ({
|
||||
formatIndexingTechniqueAndMethod: () => 'High Quality',
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('DatasetCard', () => {
|
||||
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
description: 'Test description',
|
||||
provider: 'vendor',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
embedding_available: true,
|
||||
app_count: 5,
|
||||
document_count: 10,
|
||||
word_count: 1000,
|
||||
created_at: 1609459200,
|
||||
updated_at: 1609545600,
|
||||
tags: [],
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
created_by: 'user-1',
|
||||
doc_form: ChunkingMode.text,
|
||||
runtime_mode: 'general',
|
||||
is_published: true,
|
||||
total_available_documents: 10,
|
||||
icon_info: {
|
||||
icon: '📙',
|
||||
icon_type: 'emoji' as const,
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
},
|
||||
retrieval_model_dict: {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
},
|
||||
author_name: 'Test User',
|
||||
...overrides,
|
||||
} as DataSet)
|
||||
|
||||
const defaultProps = {
|
||||
dataset: createMockDataset(),
|
||||
onSuccess: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<DatasetCard {...defaultProps} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dataset name', () => {
|
||||
const dataset = createMockDataset({ name: 'Custom Dataset Name' })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
expect(screen.getByText('Custom Dataset Name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dataset description', () => {
|
||||
const dataset = createMockDataset({ description: 'Custom Description' })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
expect(screen.getByText('Custom Description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render document count', () => {
|
||||
render(<DatasetCard {...defaultProps} />)
|
||||
expect(screen.getByText('10')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app count', () => {
|
||||
render(<DatasetCard {...defaultProps} />)
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should handle external provider', () => {
|
||||
const dataset = createMockDataset({ provider: 'external' })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle rag_pipeline runtime mode', () => {
|
||||
const dataset = createMockDataset({ runtime_mode: 'rag_pipeline', is_published: true })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should navigate to documents page on click for regular dataset', () => {
|
||||
const dataset = createMockDataset({ provider: 'vendor' })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
|
||||
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents')
|
||||
})
|
||||
|
||||
it('should navigate to hitTesting page on click for external provider', () => {
|
||||
const dataset = createMockDataset({ provider: 'external' })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
|
||||
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/hitTesting')
|
||||
})
|
||||
|
||||
it('should navigate to pipeline page when pipeline is unpublished', () => {
|
||||
const dataset = createMockDataset({ runtime_mode: 'rag_pipeline', is_published: false })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
|
||||
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/pipeline')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct card styling', () => {
|
||||
render(<DatasetCard {...defaultProps} />)
|
||||
const card = screen.getByText('Test Dataset').closest('.group')
|
||||
expect(card).toHaveClass('h-[190px]', 'cursor-pointer', 'flex-col', 'rounded-xl')
|
||||
})
|
||||
|
||||
it('should have data-disable-nprogress attribute', () => {
|
||||
render(<DatasetCard {...defaultProps} />)
|
||||
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
|
||||
expect(card).toHaveAttribute('data-disable-nprogress', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle dataset without description', () => {
|
||||
const dataset = createMockDataset({ description: '' })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle embedding not available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: false })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined onSuccess', () => {
|
||||
render(<DatasetCard dataset={createMockDataset()} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tag Area Click', () => {
|
||||
it('should stop propagation and prevent default when tag area is clicked', () => {
|
||||
render(<DatasetCard {...defaultProps} />)
|
||||
|
||||
// Find tag area element (it's inside the card)
|
||||
const tagAreaWrapper = document.querySelector('[class*="px-3"]')
|
||||
if (tagAreaWrapper) {
|
||||
const stopPropagationSpy = vi.fn()
|
||||
const preventDefaultSpy = vi.fn()
|
||||
|
||||
const clickEvent = new MouseEvent('click', { bubbles: true })
|
||||
Object.defineProperty(clickEvent, 'stopPropagation', { value: stopPropagationSpy })
|
||||
Object.defineProperty(clickEvent, 'preventDefault', { value: preventDefaultSpy })
|
||||
|
||||
tagAreaWrapper.dispatchEvent(clickEvent)
|
||||
|
||||
expect(stopPropagationSpy).toHaveBeenCalled()
|
||||
expect(preventDefaultSpy).toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('should not navigate when clicking on tag area', () => {
|
||||
render(<DatasetCard {...defaultProps} />)
|
||||
|
||||
// Click on tag area should not trigger card navigation
|
||||
const tagArea = document.querySelector('[class*="px-3"]')
|
||||
if (tagArea) {
|
||||
fireEvent.click(tagArea)
|
||||
// mockPush should NOT be called when clicking tag area
|
||||
// (stopPropagation prevents it from reaching the card click handler)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
485
web/app/components/datasets/list/datasets.spec.tsx
Normal file
485
web/app/components/datasets/list/datasets.spec.tsx
Normal file
@@ -0,0 +1,485 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import Datasets from './datasets'
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}))
|
||||
|
||||
// Mock ahooks
|
||||
vi.mock('ahooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ahooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useHover: () => false,
|
||||
}
|
||||
})
|
||||
|
||||
// Mock useFormatTimeFromNow hook
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: (timestamp: number) => new Date(timestamp).toLocaleDateString(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useKnowledge hook
|
||||
vi.mock('@/hooks/use-knowledge', () => ({
|
||||
useKnowledge: () => ({
|
||||
formatIndexingTechniqueAndMethod: () => 'High Quality',
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock service hooks - will be overridden in individual tests
|
||||
const mockFetchNextPage = vi.fn()
|
||||
const mockInvalidDatasetList = vi.fn()
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useDatasetList: vi.fn(() => ({
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
data: [
|
||||
createMockDataset({ id: 'dataset-1', name: 'Dataset 1' }),
|
||||
createMockDataset({ id: 'dataset-2', name: 'Dataset 2' }),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
})),
|
||||
useInvalidDatasetList: () => mockInvalidDatasetList,
|
||||
}))
|
||||
|
||||
// Mock app context - will be overridden in tests
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: vi.fn(() => true),
|
||||
}))
|
||||
|
||||
// Mock useDatasetCardState hook
|
||||
vi.mock('./dataset-card/hooks/use-dataset-card-state', () => ({
|
||||
useDatasetCardState: () => ({
|
||||
tags: [],
|
||||
setTags: vi.fn(),
|
||||
modalState: {
|
||||
showRenameModal: false,
|
||||
showConfirmDelete: false,
|
||||
confirmMessage: '',
|
||||
},
|
||||
openRenameModal: vi.fn(),
|
||||
closeRenameModal: vi.fn(),
|
||||
closeConfirmDelete: vi.fn(),
|
||||
handleExportPipeline: vi.fn(),
|
||||
detectIsUsedByApp: vi.fn(),
|
||||
onConfirmDelete: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock RenameDatasetModal
|
||||
vi.mock('../rename-modal', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
function createMockDataset(overrides: Partial<DataSet> = {}): DataSet {
|
||||
return {
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
description: 'Test description',
|
||||
provider: 'vendor',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
embedding_available: true,
|
||||
app_count: 5,
|
||||
document_count: 10,
|
||||
word_count: 1000,
|
||||
created_at: 1609459200,
|
||||
updated_at: 1609545600,
|
||||
tags: [],
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
created_by: 'user-1',
|
||||
doc_form: ChunkingMode.text,
|
||||
runtime_mode: 'general',
|
||||
is_published: true,
|
||||
total_available_documents: 10,
|
||||
icon_info: {
|
||||
icon: '📙',
|
||||
icon_type: 'emoji' as const,
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
},
|
||||
retrieval_model_dict: {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
},
|
||||
author_name: 'Test User',
|
||||
...overrides,
|
||||
} as DataSet
|
||||
}
|
||||
|
||||
// Store IntersectionObserver callbacks for testing
|
||||
let intersectionObserverCallback: IntersectionObserverCallback | null = null
|
||||
const mockObserve = vi.fn()
|
||||
const mockDisconnect = vi.fn()
|
||||
const mockUnobserve = vi.fn()
|
||||
|
||||
// Custom IntersectionObserver mock
|
||||
class MockIntersectionObserver {
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
intersectionObserverCallback = callback
|
||||
}
|
||||
|
||||
observe = mockObserve
|
||||
disconnect = mockDisconnect
|
||||
unobserve = mockUnobserve
|
||||
root = null
|
||||
rootMargin = ''
|
||||
thresholds = []
|
||||
takeRecords = () => []
|
||||
}
|
||||
|
||||
describe('Datasets', () => {
|
||||
const defaultProps = {
|
||||
tags: [],
|
||||
keywords: '',
|
||||
includeAll: false,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
intersectionObserverCallback = null
|
||||
document.title = ''
|
||||
|
||||
// Setup IntersectionObserver mock
|
||||
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render NewDatasetCard when user is editor', async () => {
|
||||
const { useSelector } = await import('@/context/app-context')
|
||||
vi.mocked(useSelector).mockReturnValue(true)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByText(/createDataset/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should NOT render NewDatasetCard when user is NOT editor', async () => {
|
||||
const { useSelector } = await import('@/context/app-context')
|
||||
vi.mocked(useSelector).mockReturnValue(false)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.queryByText(/createDataset/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dataset cards from data', () => {
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByText('Dataset 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Dataset 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render anchor div for infinite scroll', () => {
|
||||
render(<Datasets {...defaultProps} />)
|
||||
const anchor = document.querySelector('.h-0')
|
||||
expect(anchor).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should pass tags to useDatasetList', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
render(<Datasets {...defaultProps} tags={['tag-1', 'tag-2']} />)
|
||||
expect(useDatasetList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tag_ids: ['tag-1', 'tag-2'],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass keywords to useDatasetList', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
render(<Datasets {...defaultProps} keywords="search term" />)
|
||||
expect(useDatasetList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
keyword: 'search term',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass includeAll to useDatasetList', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
render(<Datasets {...defaultProps} includeAll={true} />)
|
||||
expect(useDatasetList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
include_all: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Document Title', () => {
|
||||
it('should set document title on mount', async () => {
|
||||
render(<Datasets {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
expect(document.title).toContain('dataset.knowledge')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading States', () => {
|
||||
it('should show Loading component when isFetchingNextPage is true', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: true,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
// Loading component renders a div with loading classes
|
||||
const nav = screen.getByRole('navigation')
|
||||
expect(nav).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should NOT show Loading component when isFetchingNextPage is false', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DatasetList null handling', () => {
|
||||
it('should handle null datasetList gracefully', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: null,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined datasetList gracefully', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: undefined,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty pages array', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('IntersectionObserver', () => {
|
||||
it('should setup IntersectionObserver on mount', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
|
||||
// Should observe the anchor element
|
||||
expect(mockObserve).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call fetchNextPage when isIntersecting, hasNextPage, and not isFetching', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
|
||||
// Simulate intersection
|
||||
if (intersectionObserverCallback) {
|
||||
intersectionObserverCallback(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
}
|
||||
|
||||
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should NOT call fetchNextPage when isIntersecting is false', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
|
||||
if (intersectionObserverCallback) {
|
||||
intersectionObserverCallback(
|
||||
[{ isIntersecting: false } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
}
|
||||
|
||||
expect(mockFetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should NOT call fetchNextPage when hasNextPage is false', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: false, // No more pages
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
|
||||
if (intersectionObserverCallback) {
|
||||
intersectionObserverCallback(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
}
|
||||
|
||||
expect(mockFetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should NOT call fetchNextPage when isFetching is true', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetching: true, // Already fetching
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
|
||||
if (intersectionObserverCallback) {
|
||||
intersectionObserverCallback(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
}
|
||||
|
||||
expect(mockFetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should disconnect observer on unmount', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
const { unmount } = render(<Datasets {...defaultProps} />)
|
||||
|
||||
// Unmount the component
|
||||
unmount()
|
||||
|
||||
// disconnect should be called during cleanup
|
||||
expect(mockDisconnect).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct grid styling', () => {
|
||||
render(<Datasets {...defaultProps} />)
|
||||
const nav = screen.getByRole('navigation')
|
||||
expect(nav).toHaveClass('grid', 'grow', 'gap-3', 'px-12')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty tags array', () => {
|
||||
render(<Datasets {...defaultProps} tags={[]} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty keywords', () => {
|
||||
render(<Datasets {...defaultProps} keywords="" />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple pages of data', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: {
|
||||
pages: [
|
||||
{ data: [createMockDataset({ id: 'ds-1', name: 'Page 1 Dataset' })] },
|
||||
{ data: [createMockDataset({ id: 'ds-2', name: 'Page 2 Dataset' })] },
|
||||
],
|
||||
},
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByText('Page 1 Dataset')).toBeInTheDocument()
|
||||
expect(screen.getByText('Page 2 Dataset')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
368
web/app/components/datasets/list/index.spec.tsx
Normal file
368
web/app/components/datasets/list/index.spec.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import List from './index'
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn()
|
||||
const mockReplace = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
replace: mockReplace,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock ahooks
|
||||
vi.mock('ahooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ahooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useBoolean: () => [false, { toggle: vi.fn(), setTrue: vi.fn(), setFalse: vi.fn() }],
|
||||
useDebounceFn: (fn: () => void) => ({ run: fn }),
|
||||
useHover: () => false,
|
||||
}
|
||||
})
|
||||
|
||||
// Mock app context
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
currentWorkspace: { role: 'admin' },
|
||||
isCurrentWorkspaceOwner: true,
|
||||
}),
|
||||
useSelector: () => true,
|
||||
}))
|
||||
|
||||
// Mock global public context
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: () => ({
|
||||
systemFeatures: {
|
||||
branding: { enabled: false },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock external api panel context
|
||||
const mockSetShowExternalApiPanel = vi.fn()
|
||||
vi.mock('@/context/external-api-panel-context', () => ({
|
||||
useExternalApiPanel: () => ({
|
||||
showExternalApiPanel: false,
|
||||
setShowExternalApiPanel: mockSetShowExternalApiPanel,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock tag management store
|
||||
vi.mock('@/app/components/base/tag-management/store', () => ({
|
||||
useStore: () => false,
|
||||
}))
|
||||
|
||||
// Mock useDocumentTitle hook
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock useFormatTimeFromNow hook
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: (timestamp: number) => new Date(timestamp).toLocaleDateString(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useKnowledge hook
|
||||
vi.mock('@/hooks/use-knowledge', () => ({
|
||||
useKnowledge: () => ({
|
||||
formatIndexingTechniqueAndMethod: () => 'High Quality',
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock service hooks
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useDatasetList: vi.fn(() => ({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
})),
|
||||
useInvalidDatasetList: () => vi.fn(),
|
||||
useDatasetApiBaseUrl: () => ({
|
||||
data: { api_base_url: 'https://api.example.com' },
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock Datasets component
|
||||
vi.mock('./datasets', () => ({
|
||||
default: ({ tags, keywords, includeAll }: { tags: string[], keywords: string, includeAll: boolean }) => (
|
||||
<div data-testid="datasets-component">
|
||||
<span data-testid="tags">{tags.join(',')}</span>
|
||||
<span data-testid="keywords">{keywords}</span>
|
||||
<span data-testid="include-all">{includeAll ? 'true' : 'false'}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock DatasetFooter component
|
||||
vi.mock('./dataset-footer', () => ({
|
||||
default: () => <footer data-testid="dataset-footer">Footer</footer>,
|
||||
}))
|
||||
|
||||
// Mock ExternalAPIPanel component
|
||||
vi.mock('../external-api/external-api-panel', () => ({
|
||||
default: ({ onClose }: { onClose: () => void }) => (
|
||||
<div data-testid="external-api-panel">
|
||||
<button onClick={onClose}>Close Panel</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock TagManagementModal
|
||||
vi.mock('@/app/components/base/tag-management', () => ({
|
||||
default: () => <div data-testid="tag-management-modal" />,
|
||||
}))
|
||||
|
||||
// Mock TagFilter
|
||||
vi.mock('@/app/components/base/tag-management/filter', () => ({
|
||||
default: ({ onChange }: { value: string[], onChange: (val: string[]) => void }) => (
|
||||
<div data-testid="tag-filter">
|
||||
<button onClick={() => onChange(['tag-1', 'tag-2'])}>Select Tags</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock CheckboxWithLabel
|
||||
vi.mock('@/app/components/datasets/create/website/base/checkbox-with-label', () => ({
|
||||
default: ({ isChecked, onChange, label }: { isChecked: boolean, onChange: () => void, label: string }) => (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={onChange}
|
||||
data-testid="include-all-checkbox"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('List', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('datasets-component')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the search input', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tag filter', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render external API panel button', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByText(/externalAPIPanelTitle/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dataset footer when branding is disabled', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('dataset-footer')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should pass includeAll prop to Datasets', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('include-all')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should pass empty keywords initially', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('keywords')).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('should pass empty tags initially', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('tags')).toHaveTextContent('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should open external API panel when button is clicked', () => {
|
||||
render(<List />)
|
||||
|
||||
const button = screen.getByText(/externalAPIPanelTitle/)
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(mockSetShowExternalApiPanel).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should update search input value', () => {
|
||||
render(<List />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'test search' } })
|
||||
|
||||
expect(input).toHaveValue('test search')
|
||||
})
|
||||
|
||||
it('should trigger tag filter change', () => {
|
||||
render(<List />)
|
||||
// Tag filter is rendered and interactive
|
||||
const selectTagsBtn = screen.getByText('Select Tags')
|
||||
expect(selectTagsBtn).toBeInTheDocument()
|
||||
fireEvent.click(selectTagsBtn)
|
||||
// The onChange callback was triggered (debounced)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Conditional Rendering', () => {
|
||||
it('should show include all checkbox for workspace owner', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('include-all-checkbox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct container styling', () => {
|
||||
const { container } = render(<List />)
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer).toHaveClass('scroll-container', 'relative', 'flex', 'grow', 'flex-col')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty state gracefully', () => {
|
||||
render(<List />)
|
||||
// Should render without errors even with empty data
|
||||
expect(screen.getByTestId('datasets-component')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Branch Coverage', () => {
|
||||
it('should redirect normal role users to /apps', async () => {
|
||||
// Re-mock useAppContext with normal role
|
||||
vi.doMock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
currentWorkspace: { role: 'normal' },
|
||||
isCurrentWorkspaceOwner: false,
|
||||
}),
|
||||
useSelector: () => true,
|
||||
}))
|
||||
|
||||
// Clear module cache and re-import
|
||||
vi.resetModules()
|
||||
const { default: ListComponent } = await import('./index')
|
||||
|
||||
render(<ListComponent />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/apps')
|
||||
})
|
||||
})
|
||||
|
||||
it('should clear search input when onClear is called', () => {
|
||||
render(<List />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
// First set a value
|
||||
fireEvent.change(input, { target: { value: 'test search' } })
|
||||
expect(input).toHaveValue('test search')
|
||||
|
||||
// Find and click the clear button
|
||||
const clearButton = document.querySelector('[class*="clear"], button[aria-label*="clear"]')
|
||||
if (clearButton) {
|
||||
fireEvent.click(clearButton)
|
||||
expect(input).toHaveValue('')
|
||||
}
|
||||
})
|
||||
|
||||
it('should show ExternalAPIPanel when showExternalApiPanel is true', async () => {
|
||||
// Re-mock to show external API panel
|
||||
vi.doMock('@/context/external-api-panel-context', () => ({
|
||||
useExternalApiPanel: () => ({
|
||||
showExternalApiPanel: true,
|
||||
setShowExternalApiPanel: mockSetShowExternalApiPanel,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.resetModules()
|
||||
const { default: ListComponent } = await import('./index')
|
||||
|
||||
render(<ListComponent />)
|
||||
|
||||
expect(screen.getByTestId('external-api-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close ExternalAPIPanel when onClose is called', async () => {
|
||||
vi.doMock('@/context/external-api-panel-context', () => ({
|
||||
useExternalApiPanel: () => ({
|
||||
showExternalApiPanel: true,
|
||||
setShowExternalApiPanel: mockSetShowExternalApiPanel,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.resetModules()
|
||||
const { default: ListComponent } = await import('./index')
|
||||
|
||||
render(<ListComponent />)
|
||||
|
||||
const closeButton = screen.getByText('Close Panel')
|
||||
fireEvent.click(closeButton)
|
||||
|
||||
expect(mockSetShowExternalApiPanel).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should show TagManagementModal when showTagManagementModal is true', async () => {
|
||||
vi.doMock('@/app/components/base/tag-management/store', () => ({
|
||||
useStore: () => true, // showTagManagementModal is true
|
||||
}))
|
||||
|
||||
vi.resetModules()
|
||||
const { default: ListComponent } = await import('./index')
|
||||
|
||||
render(<ListComponent />)
|
||||
|
||||
expect(screen.getByTestId('tag-management-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show DatasetFooter when branding is enabled', async () => {
|
||||
vi.doMock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: () => ({
|
||||
systemFeatures: {
|
||||
branding: { enabled: true },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.resetModules()
|
||||
const { default: ListComponent } = await import('./index')
|
||||
|
||||
render(<ListComponent />)
|
||||
|
||||
expect(screen.queryByTestId('dataset-footer')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show include all checkbox when not workspace owner', async () => {
|
||||
vi.doMock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
currentWorkspace: { role: 'editor' },
|
||||
isCurrentWorkspaceOwner: false,
|
||||
}),
|
||||
useSelector: () => true,
|
||||
}))
|
||||
|
||||
vi.resetModules()
|
||||
const { default: ListComponent } = await import('./index')
|
||||
|
||||
render(<ListComponent />)
|
||||
|
||||
expect(screen.queryByTestId('include-all-checkbox')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
287
web/app/components/datasets/metadata/base/date-picker.spec.tsx
Normal file
287
web/app/components/datasets/metadata/base/date-picker.spec.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import WrappedDatePicker from './date-picker'
|
||||
|
||||
type TriggerArgs = {
|
||||
handleClickTrigger: () => void
|
||||
}
|
||||
|
||||
type DatePickerProps = {
|
||||
onChange: (value: Date | null) => void
|
||||
onClear: () => void
|
||||
renderTrigger: (args: TriggerArgs) => React.ReactNode
|
||||
value?: Date
|
||||
}
|
||||
|
||||
// Mock the base date picker component
|
||||
vi.mock('@/app/components/base/date-and-time-picker/date-picker', () => ({
|
||||
default: ({ onChange, onClear, renderTrigger, value }: DatePickerProps) => {
|
||||
const trigger = renderTrigger({
|
||||
handleClickTrigger: () => {},
|
||||
})
|
||||
return (
|
||||
<div data-testid="date-picker-wrapper">
|
||||
{trigger}
|
||||
<button data-testid="select-date" onClick={() => onChange(value || null)}>
|
||||
Select Date
|
||||
</button>
|
||||
<button data-testid="clear-date" onClick={() => onClear()}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock useTimestamp hook
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({
|
||||
formatTime: (timestamp: number) => {
|
||||
if (!timestamp)
|
||||
return ''
|
||||
return new Date(timestamp * 1000).toLocaleDateString()
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('WrappedDatePicker', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<WrappedDatePicker onChange={handleChange} />)
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render placeholder text when no value', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<WrappedDatePicker onChange={handleChange} />)
|
||||
// When no value, should show placeholder from i18n
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render formatted date when value is provided', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render calendar icon', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { container } = render(<WrappedDatePicker onChange={handleChange} />)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render select date button', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<WrappedDatePicker onChange={handleChange} />)
|
||||
expect(screen.getByTestId('select-date')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render clear date button', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<WrappedDatePicker onChange={handleChange} />)
|
||||
expect(screen.getByTestId('clear-date')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render close icon for clearing', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const { container } = render(
|
||||
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
|
||||
)
|
||||
// RiCloseCircleFill should be rendered
|
||||
const closeIcon = container.querySelectorAll('svg')
|
||||
expect(closeIcon.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { container } = render(
|
||||
<WrappedDatePicker className="custom-class" onChange={handleChange} />,
|
||||
)
|
||||
const triggerElement = container.querySelector('.custom-class')
|
||||
expect(triggerElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should accept undefined value', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<WrappedDatePicker value={undefined} onChange={handleChange} />)
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should accept number value', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = 1609459200 // 2021-01-01
|
||||
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange with timestamp when date is selected', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('select-date'))
|
||||
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onChange with null when date is cleared via onClear', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('clear-date'))
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('should call onChange with null when close icon is clicked directly', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const { container } = render(
|
||||
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
// Find the RiCloseCircleFill icon (it has specific classes)
|
||||
const closeIcon = container.querySelector('.cursor-pointer.hover\\:text-components-input-text-filled')
|
||||
if (closeIcon) {
|
||||
fireEvent.click(closeIcon)
|
||||
expect(handleChange).toHaveBeenCalledWith(null)
|
||||
}
|
||||
})
|
||||
|
||||
it('should show close button on hover when value exists', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const { container } = render(
|
||||
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
// The close icon should be present but hidden initially
|
||||
const triggerGroup = container.querySelector('.group')
|
||||
expect(triggerGroup).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle clicking on trigger element', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const { container } = render(
|
||||
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const trigger = container.querySelector('.group.flex')
|
||||
if (trigger)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have tertiary text color when no value', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { container } = render(<WrappedDatePicker onChange={handleChange} />)
|
||||
const textElement = container.querySelector('.text-text-tertiary')
|
||||
expect(textElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have secondary text color when value exists', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const { container } = render(
|
||||
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
|
||||
)
|
||||
const textElement = container.querySelector('.text-text-secondary')
|
||||
expect(textElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have input background styling', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { container } = render(<WrappedDatePicker onChange={handleChange} />)
|
||||
const bgElement = container.querySelector('.bg-components-input-bg-normal')
|
||||
expect(bgElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have quaternary text color for close icon when value exists', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const { container } = render(
|
||||
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
|
||||
)
|
||||
const closeIcon = container.querySelector('.text-text-quaternary')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle timestamp of 0', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<WrappedDatePicker value={0} onChange={handleChange} />)
|
||||
// 0 is falsy but is a valid timestamp (epoch)
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle very large timestamp', () => {
|
||||
const handleChange = vi.fn()
|
||||
const farFuture = 4102444800 // 2100-01-01
|
||||
render(<WrappedDatePicker value={farFuture} onChange={handleChange} />)
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle switching between no value and value', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<WrappedDatePicker onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
rerender(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
|
||||
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle clearing date multiple times', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('clear-date'))
|
||||
fireEvent.click(screen.getByTestId('clear-date'))
|
||||
fireEvent.click(screen.getByTestId('clear-date'))
|
||||
|
||||
expect(handleChange).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should handle rapid date selections', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('select-date'))
|
||||
fireEvent.click(screen.getByTestId('select-date'))
|
||||
fireEvent.click(screen.getByTestId('select-date'))
|
||||
|
||||
expect(handleChange).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should handle onChange with date object that has unix method', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<WrappedDatePicker onChange={handleChange} />)
|
||||
|
||||
// The mock triggers onChange with the value prop
|
||||
fireEvent.click(screen.getByTestId('select-date'))
|
||||
|
||||
// onChange should have been called
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
45
web/app/components/datasets/metadata/utils/get-icon.spec.ts
Normal file
45
web/app/components/datasets/metadata/utils/get-icon.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { RiHashtag, RiTextSnippet, RiTimeLine } from '@remixicon/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DataType } from '../types'
|
||||
import { getIcon } from './get-icon'
|
||||
|
||||
describe('getIcon', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should return RiTextSnippet for DataType.string', () => {
|
||||
const result = getIcon(DataType.string)
|
||||
expect(result).toBe(RiTextSnippet)
|
||||
})
|
||||
|
||||
it('should return RiHashtag for DataType.number', () => {
|
||||
const result = getIcon(DataType.number)
|
||||
expect(result).toBe(RiHashtag)
|
||||
})
|
||||
|
||||
it('should return RiTimeLine for DataType.time', () => {
|
||||
const result = getIcon(DataType.time)
|
||||
expect(result).toBe(RiTimeLine)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should return RiTextSnippet as fallback for unknown type', () => {
|
||||
const result = getIcon('unknown' as DataType)
|
||||
expect(result).toBe(RiTextSnippet)
|
||||
})
|
||||
|
||||
it('should return RiTextSnippet for undefined type', () => {
|
||||
const result = getIcon(undefined as unknown as DataType)
|
||||
expect(result).toBe(RiTextSnippet)
|
||||
})
|
||||
|
||||
it('should return RiTextSnippet for null type', () => {
|
||||
const result = getIcon(null as unknown as DataType)
|
||||
expect(result).toBe(RiTextSnippet)
|
||||
})
|
||||
|
||||
it('should return RiTextSnippet for empty string type', () => {
|
||||
const result = getIcon('' as DataType)
|
||||
expect(result).toBe(RiTextSnippet)
|
||||
})
|
||||
})
|
||||
})
|
||||
1173
web/app/components/datasets/rename-modal/index.spec.tsx
Normal file
1173
web/app/components/datasets/rename-modal/index.spec.tsx
Normal file
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
208
web/app/components/datasets/settings/index-method/index.spec.tsx
Normal file
208
web/app/components/datasets/settings/index-method/index.spec.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { IndexingType } from '../../create/step-two'
|
||||
import IndexMethod from './index'
|
||||
|
||||
// Note: react-i18next is globally mocked in vitest.setup.ts
|
||||
|
||||
describe('IndexMethod', () => {
|
||||
const defaultProps = {
|
||||
value: IndexingType.QUALIFIED,
|
||||
onChange: vi.fn(),
|
||||
keywordNumber: 10,
|
||||
onKeywordNumberChange: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<IndexMethod {...defaultProps} />)
|
||||
expect(screen.getByText(/stepTwo\.qualified/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render High Quality option', () => {
|
||||
render(<IndexMethod {...defaultProps} />)
|
||||
expect(screen.getByText(/stepTwo\.qualified/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Economy option', () => {
|
||||
render(<IndexMethod {...defaultProps} />)
|
||||
expect(screen.getAllByText(/form\.indexMethodEconomy/).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render High Quality description', () => {
|
||||
render(<IndexMethod {...defaultProps} />)
|
||||
expect(screen.getByText(/form\.indexMethodHighQualityTip/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Economy description', () => {
|
||||
render(<IndexMethod {...defaultProps} />)
|
||||
expect(screen.getByText(/form\.indexMethodEconomyTip/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render recommended badge on High Quality', () => {
|
||||
render(<IndexMethod {...defaultProps} />)
|
||||
expect(screen.getByText(/stepTwo\.recommend/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Active State', () => {
|
||||
it('should mark High Quality as active when value is QUALIFIED', () => {
|
||||
const { container } = render(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} />)
|
||||
const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
|
||||
expect(activeCards).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should mark Economy as active when value is ECONOMICAL', () => {
|
||||
const { container } = render(<IndexMethod {...defaultProps} value={IndexingType.ECONOMICAL} />)
|
||||
const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
|
||||
expect(activeCards).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange with QUALIFIED when High Quality is clicked', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<IndexMethod {...defaultProps} value={IndexingType.ECONOMICAL} onChange={handleChange} />)
|
||||
|
||||
// Find and click High Quality option
|
||||
const highQualityTitle = screen.getByText(/stepTwo\.qualified/)
|
||||
const card = highQualityTitle.closest('div')?.parentElement?.parentElement?.parentElement
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(IndexingType.QUALIFIED)
|
||||
})
|
||||
|
||||
it('should call onChange with ECONOMICAL when Economy is clicked', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} onChange={handleChange} currentValue={IndexingType.ECONOMICAL} />)
|
||||
|
||||
// Find and click Economy option - use getAllByText and get the first one (title)
|
||||
const economyTitles = screen.getAllByText(/form\.indexMethodEconomy/)
|
||||
const economyTitle = economyTitles[0]
|
||||
const card = economyTitle.closest('div')?.parentElement?.parentElement?.parentElement
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(IndexingType.ECONOMICAL)
|
||||
})
|
||||
|
||||
it('should not call onChange when clicking already active option', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} onChange={handleChange} />)
|
||||
|
||||
// Click on already active High Quality
|
||||
const highQualityTitle = screen.getByText(/stepTwo\.qualified/)
|
||||
const card = highQualityTitle.closest('div')?.parentElement?.parentElement?.parentElement
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(handleChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled State', () => {
|
||||
it('should disable both options when disabled is true', () => {
|
||||
const { container } = render(<IndexMethod {...defaultProps} disabled={true} />)
|
||||
const disabledCards = container.querySelectorAll('.cursor-not-allowed')
|
||||
expect(disabledCards.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should disable Economy option when currentValue is QUALIFIED', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<IndexMethod {...defaultProps} currentValue={IndexingType.QUALIFIED} onChange={handleChange} value={IndexingType.ECONOMICAL} />)
|
||||
|
||||
// Try to click Economy option - use getAllByText and get the first one (title)
|
||||
const economyTitles = screen.getAllByText(/form\.indexMethodEconomy/)
|
||||
const economyTitle = economyTitles[0]
|
||||
const card = economyTitle.closest('div')?.parentElement?.parentElement?.parentElement
|
||||
fireEvent.click(card!)
|
||||
|
||||
// Should not call onChange because Economy is disabled when current is QUALIFIED
|
||||
expect(handleChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('KeywordNumber', () => {
|
||||
it('should render KeywordNumber component inside Economy option', () => {
|
||||
render(<IndexMethod {...defaultProps} />)
|
||||
// KeywordNumber has a slider
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass keywordNumber to KeywordNumber component', () => {
|
||||
render(<IndexMethod {...defaultProps} keywordNumber={25} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(25)
|
||||
})
|
||||
|
||||
it('should call onKeywordNumberChange when KeywordNumber changes', () => {
|
||||
const handleKeywordChange = vi.fn()
|
||||
render(<IndexMethod {...defaultProps} onKeywordNumberChange={handleKeywordChange} />)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
fireEvent.change(input, { target: { value: '30' } })
|
||||
|
||||
expect(handleKeywordChange).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tooltip', () => {
|
||||
it('should show tooltip when hovering over disabled Economy option', () => {
|
||||
// The tooltip is shown via PortalToFollowElem when hovering
|
||||
// This is controlled by useHover hook
|
||||
render(<IndexMethod {...defaultProps} currentValue={IndexingType.QUALIFIED} />)
|
||||
// The tooltip content should exist in DOM but may not be visible
|
||||
// We just verify the component renders without error
|
||||
expect(screen.getAllByText(/form\.indexMethodEconomy/).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Effect Colors', () => {
|
||||
it('should show orange effect color for High Quality option', () => {
|
||||
const { container } = render(<IndexMethod {...defaultProps} />)
|
||||
const orangeEffect = container.querySelector('.bg-util-colors-orange-orange-500')
|
||||
expect(orangeEffect).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show indigo effect color for Economy option', () => {
|
||||
const { container } = render(<IndexMethod {...defaultProps} />)
|
||||
const indigoEffect = container.querySelector('.bg-util-colors-indigo-indigo-600')
|
||||
expect(indigoEffect).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should update active state when value prop changes', () => {
|
||||
const { rerender, container } = render(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} />)
|
||||
|
||||
let activeCards = container.querySelectorAll('.ring-\\[1px\\]')
|
||||
expect(activeCards).toHaveLength(1)
|
||||
|
||||
rerender(<IndexMethod {...defaultProps} value={IndexingType.ECONOMICAL} currentValue={IndexingType.ECONOMICAL} />)
|
||||
|
||||
activeCards = container.querySelectorAll('.ring-\\[1px\\]')
|
||||
expect(activeCards).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined currentValue', () => {
|
||||
render(<IndexMethod {...defaultProps} currentValue={undefined} />)
|
||||
// Should render without error
|
||||
expect(screen.getByText(/stepTwo\.qualified/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle keywordNumber of 0', () => {
|
||||
render(<IndexMethod {...defaultProps} keywordNumber={0} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(0)
|
||||
})
|
||||
|
||||
it('should handle max keywordNumber', () => {
|
||||
render(<IndexMethod {...defaultProps} keywordNumber={50} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(50)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
317
web/app/components/datasets/settings/option-card.spec.tsx
Normal file
317
web/app/components/datasets/settings/option-card.spec.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { EffectColor } from './chunk-structure/types'
|
||||
import OptionCard from './option-card'
|
||||
|
||||
// Note: react-i18next is globally mocked in vitest.setup.ts
|
||||
|
||||
describe('OptionCard', () => {
|
||||
const defaultProps = {
|
||||
id: 'test-id',
|
||||
title: 'Test Title',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<OptionCard {...defaultProps} />)
|
||||
expect(screen.getByText('Test Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render title', () => {
|
||||
render(<OptionCard {...defaultProps} title="Custom Title" />)
|
||||
expect(screen.getByText('Custom Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description when provided', () => {
|
||||
render(<OptionCard {...defaultProps} description="Test Description" />)
|
||||
expect(screen.getByText('Test Description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render description when not provided', () => {
|
||||
render(<OptionCard {...defaultProps} />)
|
||||
expect(screen.queryByText(/description/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon when provided', () => {
|
||||
render(<OptionCard {...defaultProps} icon={<span data-testid="test-icon">Icon</span>} />)
|
||||
expect(screen.getByTestId('test-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render icon container when icon is not provided', () => {
|
||||
const { container } = render(<OptionCard {...defaultProps} />)
|
||||
const iconContainers = container.querySelectorAll('.size-6')
|
||||
expect(iconContainers).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Active State', () => {
|
||||
it('should apply active styles when isActive is true', () => {
|
||||
const { container } = render(<OptionCard {...defaultProps} isActive={true} />)
|
||||
const card = container.firstChild
|
||||
expect(card).toHaveClass('ring-[1px]')
|
||||
})
|
||||
|
||||
it('should not apply active styles when isActive is false', () => {
|
||||
const { container } = render(<OptionCard {...defaultProps} isActive={false} />)
|
||||
const card = container.firstChild
|
||||
expect(card).not.toHaveClass('ring-[1px]')
|
||||
})
|
||||
|
||||
it('should apply iconActiveColor when isActive is true and icon is present', () => {
|
||||
const { container } = render(
|
||||
<OptionCard
|
||||
{...defaultProps}
|
||||
isActive={true}
|
||||
icon={<span>Icon</span>}
|
||||
iconActiveColor="text-red-500"
|
||||
/>,
|
||||
)
|
||||
const iconContainer = container.querySelector('.text-red-500')
|
||||
expect(iconContainer).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled State', () => {
|
||||
it('should apply disabled styles when disabled is true', () => {
|
||||
const { container } = render(<OptionCard {...defaultProps} disabled={true} />)
|
||||
const card = container.firstChild
|
||||
expect(card).toHaveClass('cursor-not-allowed')
|
||||
expect(card).toHaveClass('opacity-50')
|
||||
})
|
||||
|
||||
it('should not call onClick when disabled', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(<OptionCard {...defaultProps} disabled={true} onClick={handleClick} />)
|
||||
|
||||
const card = screen.getByText('Test Title').closest('div')?.parentElement?.parentElement
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(handleClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call onClick when isActive', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(<OptionCard {...defaultProps} isActive={true} onClick={handleClick} />)
|
||||
|
||||
const card = screen.getByText('Test Title').closest('div')?.parentElement?.parentElement
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(handleClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Recommended Badge', () => {
|
||||
it('should render recommended badge when isRecommended is true', () => {
|
||||
render(<OptionCard {...defaultProps} isRecommended={true} />)
|
||||
// Badge uses translation key
|
||||
expect(screen.getByText(/stepTwo\.recommend/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render recommended badge when isRecommended is false', () => {
|
||||
render(<OptionCard {...defaultProps} isRecommended={false} />)
|
||||
expect(screen.queryByText(/stepTwo\.recommend/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Effect Color', () => {
|
||||
it('should render effect color when effectColor and showEffectColor are provided', () => {
|
||||
const { container } = render(
|
||||
<OptionCard {...defaultProps} effectColor={EffectColor.indigo} showEffectColor={true} />,
|
||||
)
|
||||
const effectElement = container.querySelector('.blur-\\[80px\\]')
|
||||
expect(effectElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render effect color when showEffectColor is false', () => {
|
||||
const { container } = render(
|
||||
<OptionCard {...defaultProps} effectColor={EffectColor.indigo} showEffectColor={false} />,
|
||||
)
|
||||
const effectElement = container.querySelector('.blur-\\[80px\\]')
|
||||
expect(effectElement).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render effect color when effectColor is not provided', () => {
|
||||
const { container } = render(
|
||||
<OptionCard {...defaultProps} showEffectColor={true} />,
|
||||
)
|
||||
const effectElement = container.querySelector('.blur-\\[80px\\]')
|
||||
expect(effectElement).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply indigo effect color class', () => {
|
||||
const { container } = render(
|
||||
<OptionCard {...defaultProps} effectColor={EffectColor.indigo} showEffectColor={true} />,
|
||||
)
|
||||
const effectElement = container.querySelector('.bg-util-colors-indigo-indigo-600')
|
||||
expect(effectElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply blueLight effect color class', () => {
|
||||
const { container } = render(
|
||||
<OptionCard {...defaultProps} effectColor={EffectColor.blueLight} showEffectColor={true} />,
|
||||
)
|
||||
const effectElement = container.querySelector('.bg-util-colors-blue-light-blue-light-600')
|
||||
expect(effectElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply orange effect color class', () => {
|
||||
const { container } = render(
|
||||
<OptionCard {...defaultProps} effectColor={EffectColor.orange} showEffectColor={true} />,
|
||||
)
|
||||
const effectElement = container.querySelector('.bg-util-colors-orange-orange-500')
|
||||
expect(effectElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply purple effect color class', () => {
|
||||
const { container } = render(
|
||||
<OptionCard {...defaultProps} effectColor={EffectColor.purple} showEffectColor={true} />,
|
||||
)
|
||||
const effectElement = container.querySelector('.bg-util-colors-purple-purple-600')
|
||||
expect(effectElement).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Children', () => {
|
||||
it('should render children when children and showChildren are provided', () => {
|
||||
render(
|
||||
<OptionCard {...defaultProps} showChildren={true}>
|
||||
<div data-testid="child-content">Child Content</div>
|
||||
</OptionCard>,
|
||||
)
|
||||
expect(screen.getByTestId('child-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render children when showChildren is false', () => {
|
||||
render(
|
||||
<OptionCard {...defaultProps} showChildren={false}>
|
||||
<div data-testid="child-content">Child Content</div>
|
||||
</OptionCard>,
|
||||
)
|
||||
expect(screen.queryByTestId('child-content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render children container when children is not provided', () => {
|
||||
const { container } = render(
|
||||
<OptionCard {...defaultProps} showChildren={true} />,
|
||||
)
|
||||
const childContainer = container.querySelector('.bg-components-panel-bg')
|
||||
expect(childContainer).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render arrow shape when children are shown', () => {
|
||||
const { container } = render(
|
||||
<OptionCard {...defaultProps} showChildren={true}>
|
||||
<div>Child</div>
|
||||
</OptionCard>,
|
||||
)
|
||||
// ArrowShape renders an SVG
|
||||
const childSection = container.querySelector('.bg-components-panel-bg')
|
||||
expect(childSection).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClick with id when clicked', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(<OptionCard {...defaultProps} id="my-id" onClick={handleClick} />)
|
||||
|
||||
const card = screen.getByText('Test Title').closest('div')?.parentElement?.parentElement
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(handleClick).toHaveBeenCalledWith('my-id')
|
||||
})
|
||||
|
||||
it('should have cursor-pointer class', () => {
|
||||
const { container } = render(<OptionCard {...defaultProps} />)
|
||||
const card = container.firstChild
|
||||
expect(card).toHaveClass('cursor-pointer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<OptionCard {...defaultProps} className="custom-class" />)
|
||||
const innerContainer = container.querySelector('.custom-class')
|
||||
expect(innerContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should forward ref', () => {
|
||||
const ref = vi.fn()
|
||||
render(<OptionCard {...defaultProps} ref={ref} />)
|
||||
expect(ref).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty title', () => {
|
||||
render(<OptionCard {...defaultProps} title="" />)
|
||||
// Component should still render
|
||||
const { container } = render(<OptionCard {...defaultProps} title="" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle complex id types', () => {
|
||||
const handleClick = vi.fn()
|
||||
const complexId = { key: 'value' }
|
||||
render(<OptionCard {...defaultProps} id={complexId} onClick={handleClick} />)
|
||||
|
||||
const card = screen.getByText('Test Title').closest('div')?.parentElement?.parentElement
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(handleClick).toHaveBeenCalledWith(complexId)
|
||||
})
|
||||
|
||||
it('should handle numeric id', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(<OptionCard {...defaultProps} id={123} onClick={handleClick} />)
|
||||
|
||||
const card = screen.getByText('Test Title').closest('div')?.parentElement?.parentElement
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(handleClick).toHaveBeenCalledWith(123)
|
||||
})
|
||||
|
||||
it('should handle long title', () => {
|
||||
const longTitle = 'A'.repeat(200)
|
||||
render(<OptionCard {...defaultProps} title={longTitle} />)
|
||||
expect(screen.getByText(longTitle)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle long description', () => {
|
||||
const longDesc = 'B'.repeat(500)
|
||||
render(<OptionCard {...defaultProps} description={longDesc} />)
|
||||
expect(screen.getByText(longDesc)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle all props together', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(
|
||||
<OptionCard
|
||||
id="full-test"
|
||||
title="Full Test"
|
||||
description="Full Description"
|
||||
icon={<span data-testid="full-icon">Icon</span>}
|
||||
iconActiveColor="text-blue-500"
|
||||
isActive={true}
|
||||
isRecommended={true}
|
||||
effectColor={EffectColor.indigo}
|
||||
showEffectColor={true}
|
||||
disabled={false}
|
||||
onClick={handleClick}
|
||||
className="full-class"
|
||||
showChildren={true}
|
||||
>
|
||||
<div data-testid="full-children">Children</div>
|
||||
</OptionCard>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Full Test')).toBeInTheDocument()
|
||||
expect(screen.getByText('Full Description')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('full-icon')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('full-children')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
297
web/app/components/datasets/settings/utils/index.spec.ts
Normal file
297
web/app/components/datasets/settings/utils/index.spec.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import type { DefaultModel, Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { ConfigurationMethodEnum, ModelFeatureEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { IndexingType } from '../../create/step-two'
|
||||
import { checkShowMultiModalTip } from './index'
|
||||
|
||||
describe('checkShowMultiModalTip', () => {
|
||||
// Helper to create a model item with specific features
|
||||
const createModelItem = (model: string, features: ModelFeatureEnum[] = []): ModelItem => ({
|
||||
model,
|
||||
label: { en_US: model, zh_Hans: model },
|
||||
model_type: ModelTypeEnum.textEmbedding,
|
||||
features,
|
||||
fetch_from: ConfigurationMethodEnum.predefinedModel,
|
||||
status: ModelStatusEnum.active,
|
||||
model_properties: {},
|
||||
load_balancing_enabled: false,
|
||||
deprecated: false,
|
||||
})
|
||||
|
||||
// Helper to create a model provider
|
||||
const createModelProvider = (provider: string, models: ModelItem[]): Model => ({
|
||||
provider,
|
||||
label: { en_US: provider, zh_Hans: provider },
|
||||
icon_small: { en_US: '', zh_Hans: '' },
|
||||
status: ModelStatusEnum.active,
|
||||
models,
|
||||
})
|
||||
|
||||
const defaultProps = {
|
||||
embeddingModel: {
|
||||
provider: 'openai',
|
||||
model: 'text-embedding-ada-002',
|
||||
} as DefaultModel,
|
||||
rerankingEnable: true,
|
||||
rerankModel: {
|
||||
rerankingProviderName: 'cohere',
|
||||
rerankingModelName: 'rerank-english-v2.0',
|
||||
},
|
||||
indexMethod: IndexingType.QUALIFIED,
|
||||
embeddingModelList: [
|
||||
createModelProvider('openai', [
|
||||
createModelItem('text-embedding-ada-002', [ModelFeatureEnum.vision]),
|
||||
]),
|
||||
],
|
||||
rerankModelList: [
|
||||
createModelProvider('cohere', [
|
||||
createModelItem('rerank-english-v2.0', []),
|
||||
]),
|
||||
],
|
||||
}
|
||||
|
||||
describe('Return false conditions', () => {
|
||||
it('should return false when indexMethod is not QUALIFIED', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
indexMethod: IndexingType.ECONOMICAL,
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when indexMethod is undefined', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
indexMethod: undefined,
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when embeddingModel.provider is empty', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
embeddingModel: { provider: '', model: 'text-embedding-ada-002' },
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when embeddingModel.model is empty', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
embeddingModel: { provider: 'openai', model: '' },
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when embedding model provider is not found', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
embeddingModel: { provider: 'unknown-provider', model: 'text-embedding-ada-002' },
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when embedding model is not found in provider', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
embeddingModel: { provider: 'openai', model: 'unknown-model' },
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when embedding model does not support vision', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
embeddingModelList: [
|
||||
createModelProvider('openai', [
|
||||
createModelItem('text-embedding-ada-002', []), // No vision feature
|
||||
]),
|
||||
],
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when rerankingEnable is false', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
rerankingEnable: false,
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when rerankingModelName is empty', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
rerankModel: {
|
||||
rerankingProviderName: 'cohere',
|
||||
rerankingModelName: '',
|
||||
},
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when rerankingProviderName is empty', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
rerankModel: {
|
||||
rerankingProviderName: '',
|
||||
rerankingModelName: 'rerank-english-v2.0',
|
||||
},
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when reranking model provider is not found', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
rerankModel: {
|
||||
rerankingProviderName: 'unknown-provider',
|
||||
rerankingModelName: 'rerank-english-v2.0',
|
||||
},
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when reranking model is not found in provider', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
rerankModel: {
|
||||
rerankingProviderName: 'cohere',
|
||||
rerankingModelName: 'unknown-model',
|
||||
},
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when reranking model supports vision', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
rerankModelList: [
|
||||
createModelProvider('cohere', [
|
||||
createModelItem('rerank-english-v2.0', [ModelFeatureEnum.vision]), // Has vision feature
|
||||
]),
|
||||
],
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Return true condition', () => {
|
||||
it('should return true when embedding model supports vision but reranking model does not', () => {
|
||||
const result = checkShowMultiModalTip(defaultProps)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true with different providers', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
embeddingModel: { provider: 'azure', model: 'azure-embedding' },
|
||||
rerankModel: {
|
||||
rerankingProviderName: 'jina',
|
||||
rerankingModelName: 'jina-reranker',
|
||||
},
|
||||
embeddingModelList: [
|
||||
createModelProvider('azure', [
|
||||
createModelItem('azure-embedding', [ModelFeatureEnum.vision]),
|
||||
]),
|
||||
],
|
||||
rerankModelList: [
|
||||
createModelProvider('jina', [
|
||||
createModelItem('jina-reranker', []),
|
||||
]),
|
||||
],
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle empty embeddingModelList', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
embeddingModelList: [],
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle empty rerankModelList', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
rerankModelList: [],
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle model with undefined features', () => {
|
||||
const modelItem: ModelItem = {
|
||||
model: 'test-model',
|
||||
label: { en_US: 'test', zh_Hans: 'test' },
|
||||
model_type: ModelTypeEnum.textEmbedding,
|
||||
features: undefined as unknown as ModelFeatureEnum[],
|
||||
fetch_from: ConfigurationMethodEnum.predefinedModel,
|
||||
status: ModelStatusEnum.active,
|
||||
model_properties: {},
|
||||
load_balancing_enabled: false,
|
||||
deprecated: false,
|
||||
}
|
||||
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
embeddingModelList: [
|
||||
createModelProvider('openai', [modelItem]),
|
||||
],
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle model with null features', () => {
|
||||
const modelItem: ModelItem = {
|
||||
model: 'text-embedding-ada-002',
|
||||
label: { en_US: 'test', zh_Hans: 'test' },
|
||||
model_type: ModelTypeEnum.textEmbedding,
|
||||
features: null as unknown as ModelFeatureEnum[],
|
||||
fetch_from: ConfigurationMethodEnum.predefinedModel,
|
||||
status: ModelStatusEnum.active,
|
||||
model_properties: {},
|
||||
load_balancing_enabled: false,
|
||||
deprecated: false,
|
||||
}
|
||||
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
embeddingModelList: [
|
||||
createModelProvider('openai', [modelItem]),
|
||||
],
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle multiple models in provider', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
embeddingModelList: [
|
||||
createModelProvider('openai', [
|
||||
createModelItem('text-embedding-1', []),
|
||||
createModelItem('text-embedding-ada-002', [ModelFeatureEnum.vision]),
|
||||
createModelItem('text-embedding-3', []),
|
||||
]),
|
||||
],
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle multiple providers in list', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
embeddingModelList: [
|
||||
createModelProvider('azure', [
|
||||
createModelItem('azure-model', []),
|
||||
]),
|
||||
createModelProvider('openai', [
|
||||
createModelItem('text-embedding-ada-002', [ModelFeatureEnum.vision]),
|
||||
]),
|
||||
],
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import consistentPlaceholders from './rules/consistent-placeholders.js'
|
||||
import noAsAnyInT from './rules/no-as-any-in-t.js'
|
||||
import noExtraKeys from './rules/no-extra-keys.js'
|
||||
import noLegacyNamespacePrefix from './rules/no-legacy-namespace-prefix.js'
|
||||
@@ -11,6 +12,7 @@ const plugin = {
|
||||
version: '1.0.0',
|
||||
},
|
||||
rules: {
|
||||
'consistent-placeholders': consistentPlaceholders,
|
||||
'no-as-any-in-t': noAsAnyInT,
|
||||
'no-extra-keys': noExtraKeys,
|
||||
'no-legacy-namespace-prefix': noLegacyNamespacePrefix,
|
||||
|
||||
109
web/eslint-rules/rules/consistent-placeholders.js
Normal file
109
web/eslint-rules/rules/consistent-placeholders.js
Normal file
@@ -0,0 +1,109 @@
|
||||
import fs from 'node:fs'
|
||||
import path, { normalize, sep } from 'node:path'
|
||||
import { cleanJsonText } from '../utils.js'
|
||||
|
||||
/**
|
||||
* Extract placeholders from a string
|
||||
* Matches patterns like {{name}}, {{count}}, etc.
|
||||
* @param {string} str
|
||||
* @returns {string[]} Sorted array of placeholder names
|
||||
*/
|
||||
function extractPlaceholders(str) {
|
||||
const matches = str.match(/\{\{\w+\}\}/g) || []
|
||||
return matches.map(m => m.slice(2, -2)).sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two arrays and return if they're equal
|
||||
* @param {string[]} arr1
|
||||
* @param {string[]} arr2
|
||||
* @returns {boolean} True if arrays contain the same elements in the same order
|
||||
*/
|
||||
function arraysEqual(arr1, arr2) {
|
||||
if (arr1.length !== arr2.length)
|
||||
return false
|
||||
return arr1.every((val, i) => val === arr2[i])
|
||||
}
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
export default {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Ensure placeholders in translations match the en-US source',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
Program(node) {
|
||||
const { filename, sourceCode } = context
|
||||
|
||||
if (!filename.endsWith('.json'))
|
||||
return
|
||||
|
||||
const parts = normalize(filename).split(sep)
|
||||
const jsonFile = parts.at(-1)
|
||||
const lang = parts.at(-2)
|
||||
|
||||
// Skip English files - they are the source of truth
|
||||
if (lang === 'en-US')
|
||||
return
|
||||
|
||||
let currentJson = {}
|
||||
let englishJson = {}
|
||||
|
||||
try {
|
||||
currentJson = JSON.parse(cleanJsonText(sourceCode.text))
|
||||
const englishFilePath = path.join(path.dirname(filename), '..', 'en-US', jsonFile ?? '')
|
||||
englishJson = JSON.parse(fs.readFileSync(englishFilePath, 'utf8'))
|
||||
}
|
||||
catch (error) {
|
||||
context.report({
|
||||
node,
|
||||
message: `Error parsing JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check each key in the current translation
|
||||
for (const key of Object.keys(currentJson)) {
|
||||
// Skip if the key doesn't exist in English (handled by no-extra-keys rule)
|
||||
if (!Object.prototype.hasOwnProperty.call(englishJson, key))
|
||||
continue
|
||||
|
||||
const currentValue = currentJson[key]
|
||||
const englishValue = englishJson[key]
|
||||
|
||||
// Skip non-string values
|
||||
if (typeof currentValue !== 'string' || typeof englishValue !== 'string')
|
||||
continue
|
||||
|
||||
const currentPlaceholders = extractPlaceholders(currentValue)
|
||||
const englishPlaceholders = extractPlaceholders(englishValue)
|
||||
|
||||
if (!arraysEqual(currentPlaceholders, englishPlaceholders)) {
|
||||
const missing = englishPlaceholders.filter(p => !currentPlaceholders.includes(p))
|
||||
const extra = currentPlaceholders.filter(p => !englishPlaceholders.includes(p))
|
||||
|
||||
let message = `Placeholder mismatch in "${key}": `
|
||||
const details = []
|
||||
|
||||
if (missing.length > 0)
|
||||
details.push(`missing {{${missing.join('}}, {{')}}}`)
|
||||
|
||||
if (extra.length > 0)
|
||||
details.push(`extra {{${extra.join('}}, {{')}}}`)
|
||||
|
||||
message += details.join('; ')
|
||||
message += `. Expected: {{${englishPlaceholders.join('}}, {{') || 'none'}}}`
|
||||
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -131,6 +131,7 @@ export default antfu(
|
||||
|
||||
'dify-i18n/valid-i18n-keys': 'error',
|
||||
'dify-i18n/no-extra-keys': 'error',
|
||||
'dify-i18n/consistent-placeholders': 'error',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -236,7 +236,7 @@
|
||||
"task.installSuccess": "تم تثبيت {{successLength}} من الإضافات بنجاح",
|
||||
"task.installed": "مثبت",
|
||||
"task.installedError": "{{errorLength}} إضافات فشل تثبيتها",
|
||||
"task.installing": "تثبيت {{installingLength}} إضافات، 0 تم.",
|
||||
"task.installing": "تثبيت إضافات، 0 تم.",
|
||||
"task.installingWithError": "تثبيت {{installingLength}} إضافات، {{successLength}} نجاح، {{errorLength}} فشل",
|
||||
"task.installingWithSuccess": "تثبيت {{installingLength}} إضافات، {{successLength}} نجاح.",
|
||||
"task.runningPlugins": "تثبيت الإضافات",
|
||||
|
||||
@@ -251,10 +251,10 @@
|
||||
"openingStatement.notIncludeKey": "Das Anfangsprompt enthält nicht die Variable: {{key}}. Bitte fügen Sie sie dem Anfangsprompt hinzu.",
|
||||
"openingStatement.openingQuestion": "Eröffnungsfragen",
|
||||
"openingStatement.openingQuestionPlaceholder": "Sie können Variablen verwenden, versuchen Sie {{variable}} einzugeben.",
|
||||
"openingStatement.placeholder": "Schreiben Sie hier Ihre Eröffnungsnachricht, Sie können Variablen verwenden, versuchen Sie {{Variable}} zu tippen.",
|
||||
"openingStatement.placeholder": "Schreiben Sie hier Ihre Eröffnungsnachricht, Sie können Variablen verwenden, versuchen Sie {{variable}} zu tippen.",
|
||||
"openingStatement.title": "Gesprächseröffner",
|
||||
"openingStatement.tooShort": "Für die Erzeugung von Eröffnungsbemerkungen für das Gespräch werden mindestens 20 Wörter des Anfangsprompts benötigt.",
|
||||
"openingStatement.varTip": "Sie können Variablen verwenden, versuchen Sie {{Variable}} zu tippen",
|
||||
"openingStatement.varTip": "Sie können Variablen verwenden, versuchen Sie {{variable}} zu tippen",
|
||||
"openingStatement.writeOpener": "Eröffnung schreiben",
|
||||
"operation.addFeature": "Funktion hinzufügen",
|
||||
"operation.agree": "gefällt mir",
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
"gotoAnything.emptyState.noKnowledgeBasesFound": "Keine Wissensdatenbanken gefunden",
|
||||
"gotoAnything.emptyState.noPluginsFound": "Keine Plugins gefunden",
|
||||
"gotoAnything.emptyState.noWorkflowNodesFound": "Keine Workflow-Knoten gefunden",
|
||||
"gotoAnything.emptyState.tryDifferentTerm": "Versuchen Sie einen anderen Suchbegriff oder entfernen Sie den {{mode}}-Filter",
|
||||
"gotoAnything.emptyState.tryDifferentTerm": "Versuchen Sie einen anderen Suchbegriff oder entfernen Sie den Filter",
|
||||
"gotoAnything.emptyState.trySpecificSearch": "Versuchen Sie {{shortcuts}} für spezifische Suchen",
|
||||
"gotoAnything.groups.apps": "Apps",
|
||||
"gotoAnything.groups.commands": "Befehle",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"stepOne.uploader.cancel": "Abbrechen",
|
||||
"stepOne.uploader.change": "Ändern",
|
||||
"stepOne.uploader.failed": "Hochladen fehlgeschlagen",
|
||||
"stepOne.uploader.tip": "Unterstützt {{supportTypes}}. Maximal {{size}}MB pro Datei.",
|
||||
"stepOne.uploader.tip": "Unterstützt {{supportTypes}}. Maximal {{batchCount}} Dateien pro Batch und {{size}} MB pro Datei. Insgesamt maximal {{totalCount}} Dateien.",
|
||||
"stepOne.uploader.title": "Textdatei hochladen",
|
||||
"stepOne.uploader.validation.count": "Mehrere Dateien nicht unterstützt",
|
||||
"stepOne.uploader.validation.filesNumber": "Sie haben das Limit für die Stapelverarbeitung von {{filesNumber}} erreicht.",
|
||||
@@ -140,7 +140,7 @@
|
||||
"stepTwo.preview": "Bestätigen & Vorschau",
|
||||
"stepTwo.previewButton": "Umschalten zum Frage-und-Antwort-Format",
|
||||
"stepTwo.previewChunk": "Vorschau Chunk",
|
||||
"stepTwo.previewChunkCount": "{{Anzahl}} Geschätzte Chunks",
|
||||
"stepTwo.previewChunkCount": "{{count}} Geschätzte Chunks",
|
||||
"stepTwo.previewChunkTip": "Klicken Sie auf die Schaltfläche \"Preview Chunk\" auf der linken Seite, um die Vorschau zu laden",
|
||||
"stepTwo.previewSwitchTipEnd": " zusätzliche Tokens verbrauchen",
|
||||
"stepTwo.previewSwitchTipStart": "Die aktuelle Chunk-Vorschau ist im Textformat, ein Wechsel zur Vorschau im Frage-und-Antwort-Format wird",
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
"debugInfo.title": "Debuggen",
|
||||
"debugInfo.viewDocs": "Dokumente anzeigen",
|
||||
"deprecated": "Abgelehnt",
|
||||
"detailPanel.actionNum": "{{num}} {{Aktion}} IINKLUSIVE",
|
||||
"detailPanel.actionNum": "{{num}} {{action}} IINKLUSIVE",
|
||||
"detailPanel.categoryTip.debugging": "Debuggen-Plugin",
|
||||
"detailPanel.categoryTip.github": "Installiert von Github",
|
||||
"detailPanel.categoryTip.local": "Lokales Plugin",
|
||||
@@ -116,7 +116,7 @@
|
||||
"detailPanel.operation.update": "Aktualisieren",
|
||||
"detailPanel.operation.viewDetail": "Im Detail sehen",
|
||||
"detailPanel.serviceOk": "Service in Ordnung",
|
||||
"detailPanel.strategyNum": "{{num}} {{Strategie}} IINKLUSIVE",
|
||||
"detailPanel.strategyNum": "{{num}} {{strategy}} IINKLUSIVE",
|
||||
"detailPanel.switchVersion": "Version wechseln",
|
||||
"detailPanel.toolSelector.auto": "Auto",
|
||||
"detailPanel.toolSelector.descriptionLabel": "Beschreibung des Werkzeugs",
|
||||
@@ -236,7 +236,7 @@
|
||||
"task.installSuccess": "{{successLength}} plugins installed successfully",
|
||||
"task.installed": "Installed",
|
||||
"task.installedError": "{{errorLength}} Plugins konnten nicht installiert werden",
|
||||
"task.installing": "Installation von {{installingLength}} Plugins, 0 erledigt.",
|
||||
"task.installing": "Installation von Plugins, 0 erledigt.",
|
||||
"task.installingWithError": "Installation von {{installingLength}} Plugins, {{successLength}} erfolgreich, {{errorLength}} fehlgeschlagen",
|
||||
"task.installingWithSuccess": "Installation von {{installingLength}} Plugins, {{successLength}} erfolgreich.",
|
||||
"task.runningPlugins": "Installing Plugins",
|
||||
|
||||
@@ -363,7 +363,7 @@
|
||||
"nodes.agent.strategyNotFoundDescAndSwitchVersion": "Die installierte Plugin-Version bietet diese Strategie nicht. Klicken Sie hier, um die Version zu wechseln.",
|
||||
"nodes.agent.strategyNotInstallTooltip": "{{strategy}} ist nicht installiert",
|
||||
"nodes.agent.strategyNotSet": "Agentische Strategie nicht festgelegt",
|
||||
"nodes.agent.toolNotAuthorizedTooltip": "{{Werkzeug}} Nicht autorisiert",
|
||||
"nodes.agent.toolNotAuthorizedTooltip": "{{tool}} Nicht autorisiert",
|
||||
"nodes.agent.toolNotInstallTooltip": "{{tool}} ist nicht installiert",
|
||||
"nodes.agent.toolbox": "Werkzeugkasten",
|
||||
"nodes.agent.tools": "Werkzeuge",
|
||||
@@ -549,8 +549,8 @@
|
||||
"nodes.iteration.deleteDesc": "Das Löschen des Iterationsknotens löscht alle untergeordneten Knoten",
|
||||
"nodes.iteration.deleteTitle": "Iterationsknoten löschen?",
|
||||
"nodes.iteration.errorResponseMethod": "Methode der Fehlerantwort",
|
||||
"nodes.iteration.error_one": "{{Anzahl}} Fehler",
|
||||
"nodes.iteration.error_other": "{{Anzahl}} Irrtümer",
|
||||
"nodes.iteration.error_one": "{{count}} Fehler",
|
||||
"nodes.iteration.error_other": "{{count}} Irrtümer",
|
||||
"nodes.iteration.flattenOutput": "Ausgabe abflachen",
|
||||
"nodes.iteration.flattenOutputDesc": "Wenn aktiviert, werden alle Iterationsergebnisse, die Arrays sind, in ein einzelnes Array zusammengeführt. Wenn deaktiviert, behalten die Ergebnisse eine verschachtelte Array-Struktur bei.",
|
||||
"nodes.iteration.input": "Eingabe",
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
"gotoAnything.emptyState.noKnowledgeBasesFound": "No se han encontrado bases de conocimiento",
|
||||
"gotoAnything.emptyState.noPluginsFound": "No se encontraron complementos",
|
||||
"gotoAnything.emptyState.noWorkflowNodesFound": "No se encontraron nodos de flujo de trabajo",
|
||||
"gotoAnything.emptyState.tryDifferentTerm": "Intenta un término de búsqueda diferente o elimina el filtro {{mode}}",
|
||||
"gotoAnything.emptyState.tryDifferentTerm": "Intenta un término de búsqueda diferente o elimina el filtro",
|
||||
"gotoAnything.emptyState.trySpecificSearch": "Prueba {{shortcuts}} para búsquedas específicas",
|
||||
"gotoAnything.groups.apps": "Aplicaciones",
|
||||
"gotoAnything.groups.commands": "Comandos",
|
||||
@@ -161,8 +161,8 @@
|
||||
"newApp.dropDSLToCreateApp": "Suelta el archivo DSL aquí para crear la aplicación",
|
||||
"newApp.forAdvanced": "PARA USUARIOS AVANZADOS",
|
||||
"newApp.forBeginners": "Tipos de aplicación más básicos",
|
||||
"newApp.foundResult": "{{conteo}} Resultado",
|
||||
"newApp.foundResults": "{{conteo}} Resultados",
|
||||
"newApp.foundResult": "{{count}} Resultado",
|
||||
"newApp.foundResults": "{{count}} Resultados",
|
||||
"newApp.hideTemplates": "Volver a la selección de modo",
|
||||
"newApp.import": "Importación",
|
||||
"newApp.learnMore": "Aprende más",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"stepOne.uploader.cancel": "Cancelar",
|
||||
"stepOne.uploader.change": "Cambiar",
|
||||
"stepOne.uploader.failed": "Error al cargar",
|
||||
"stepOne.uploader.tip": "Soporta {{supportTypes}}. Máximo {{size}}MB cada uno.",
|
||||
"stepOne.uploader.tip": "Soporta {{supportTypes}}. Máximo {{batchCount}} archivos por lote y {{size}} MB cada uno. Total máximo de {{totalCount}} archivos.",
|
||||
"stepOne.uploader.title": "Cargar archivo",
|
||||
"stepOne.uploader.validation.count": "No se admiten varios archivos",
|
||||
"stepOne.uploader.validation.filesNumber": "Has alcanzado el límite de carga por lotes de {{filesNumber}}.",
|
||||
@@ -140,7 +140,7 @@
|
||||
"stepTwo.preview": "Confirmar y vista previa",
|
||||
"stepTwo.previewButton": "Cambiar a formato de pregunta y respuesta",
|
||||
"stepTwo.previewChunk": "Fragmento de vista previa",
|
||||
"stepTwo.previewChunkCount": "{{conteo}} Fragmentos estimados",
|
||||
"stepTwo.previewChunkCount": "{{count}} Fragmentos estimados",
|
||||
"stepTwo.previewChunkTip": "Haga clic en el botón 'Vista previa de fragmento' a la izquierda para cargar la vista previa",
|
||||
"stepTwo.previewSwitchTipEnd": " consumirá tokens adicionales",
|
||||
"stepTwo.previewSwitchTipStart": "La vista previa actual del fragmento está en formato de texto, cambiar a una vista previa en formato de pregunta y respuesta",
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
"debugInfo.title": "Depuración",
|
||||
"debugInfo.viewDocs": "Ver documentos",
|
||||
"deprecated": "Obsoleto",
|
||||
"detailPanel.actionNum": "{{num}} {{acción}} INCLUIDO",
|
||||
"detailPanel.actionNum": "{{num}} {{action}} INCLUIDO",
|
||||
"detailPanel.categoryTip.debugging": "Complemento de depuración",
|
||||
"detailPanel.categoryTip.github": "Instalado desde Github",
|
||||
"detailPanel.categoryTip.local": "Plugin Local",
|
||||
@@ -96,7 +96,7 @@
|
||||
"detailPanel.deprecation.reason.noMaintainer": "sin mantenedor",
|
||||
"detailPanel.deprecation.reason.ownershipTransferred": "propiedad transferida",
|
||||
"detailPanel.disabled": "Deshabilitado",
|
||||
"detailPanel.endpointDeleteContent": "¿Te gustaría eliminar {{nombre}}?",
|
||||
"detailPanel.endpointDeleteContent": "¿Te gustaría eliminar {{name}}?",
|
||||
"detailPanel.endpointDeleteTip": "Eliminar punto de conexión",
|
||||
"detailPanel.endpointDisableContent": "¿Te gustaría desactivar {{name}}?",
|
||||
"detailPanel.endpointDisableTip": "Deshabilitar punto de conexión",
|
||||
@@ -116,7 +116,7 @@
|
||||
"detailPanel.operation.update": "Actualizar",
|
||||
"detailPanel.operation.viewDetail": "Ver Detalle",
|
||||
"detailPanel.serviceOk": "Servicio OK",
|
||||
"detailPanel.strategyNum": "{{num}} {{estrategia}} INCLUIDO",
|
||||
"detailPanel.strategyNum": "{{num}} {{strategy}} INCLUIDO",
|
||||
"detailPanel.switchVersion": "Versión del interruptor",
|
||||
"detailPanel.toolSelector.auto": "Auto",
|
||||
"detailPanel.toolSelector.descriptionLabel": "Descripción de la herramienta",
|
||||
@@ -236,7 +236,7 @@
|
||||
"task.installSuccess": "{{successLength}} plugins installed successfully",
|
||||
"task.installed": "Installed",
|
||||
"task.installedError": "Los complementos {{errorLength}} no se pudieron instalar",
|
||||
"task.installing": "Instalando plugins {{installingLength}}, 0 hecho.",
|
||||
"task.installing": "Instalando plugins, 0 hecho.",
|
||||
"task.installingWithError": "Instalando plugins {{installingLength}}, {{successLength}} éxito, {{errorLength}} fallido",
|
||||
"task.installingWithSuccess": "Instalando plugins {{installingLength}}, {{successLength}} éxito.",
|
||||
"task.runningPlugins": "Installing Plugins",
|
||||
|
||||
@@ -303,10 +303,10 @@
|
||||
"errorMsg.fields.visionVariable": "Variable de visión",
|
||||
"errorMsg.invalidJson": "{{field}} no es un JSON válido",
|
||||
"errorMsg.invalidVariable": "Variable no válida",
|
||||
"errorMsg.noValidTool": "{{campo}} no se ha seleccionado ninguna herramienta válida",
|
||||
"errorMsg.noValidTool": "{{field}} no se ha seleccionado ninguna herramienta válida",
|
||||
"errorMsg.rerankModelRequired": "Antes de activar el modelo de reclasificación, confirme que el modelo se ha configurado correctamente en la configuración.",
|
||||
"errorMsg.startNodeRequired": "Por favor, agregue primero un nodo de inicio antes de {{operation}}",
|
||||
"errorMsg.toolParameterRequired": "{{campo}}: el parámetro [{{param}}] es obligatorio",
|
||||
"errorMsg.toolParameterRequired": "{{field}}: el parámetro [{{param}}] es obligatorio",
|
||||
"globalVar.description": "Las variables del sistema son variables globales que cualquier nodo puede usar sin conexiones cuando el tipo es correcto, como el ID del usuario final y el ID del flujo de trabajo.",
|
||||
"globalVar.fieldsDescription.appId": "ID de la aplicación",
|
||||
"globalVar.fieldsDescription.conversationId": "ID de la conversación",
|
||||
@@ -361,10 +361,10 @@
|
||||
"nodes.agent.strategy.tooltip": "Diferentes estrategias agentic determinan cómo el sistema planifica y ejecuta las llamadas a herramientas de varios pasos",
|
||||
"nodes.agent.strategyNotFoundDesc": "La versión del plugin instalado no proporciona esta estrategia.",
|
||||
"nodes.agent.strategyNotFoundDescAndSwitchVersion": "La versión del plugin instalado no proporciona esta estrategia. Haga clic para cambiar de versión.",
|
||||
"nodes.agent.strategyNotInstallTooltip": "{{estrategia}} no está instalado",
|
||||
"nodes.agent.strategyNotInstallTooltip": "{{strategy}} no está instalado",
|
||||
"nodes.agent.strategyNotSet": "Estrategia agentica No establecida",
|
||||
"nodes.agent.toolNotAuthorizedTooltip": "{{herramienta}} No autorizado",
|
||||
"nodes.agent.toolNotInstallTooltip": "{{herramienta}} no está instalada",
|
||||
"nodes.agent.toolNotAuthorizedTooltip": "{{tool}} No autorizado",
|
||||
"nodes.agent.toolNotInstallTooltip": "{{tool}} no está instalada",
|
||||
"nodes.agent.toolbox": "caja de herramientas",
|
||||
"nodes.agent.tools": "Herramientas",
|
||||
"nodes.agent.unsupportedStrategy": "Estrategia no respaldada",
|
||||
@@ -438,7 +438,7 @@
|
||||
"nodes.common.retry.retries": "{{num}} Reintentos",
|
||||
"nodes.common.retry.retry": "Reintentar",
|
||||
"nodes.common.retry.retryFailed": "Error en el reintento",
|
||||
"nodes.common.retry.retryFailedTimes": "{{veces}} reintentos fallidos",
|
||||
"nodes.common.retry.retryFailedTimes": "{{times}} reintentos fallidos",
|
||||
"nodes.common.retry.retryInterval": "Intervalo de reintento",
|
||||
"nodes.common.retry.retryOnFailure": "Volver a intentarlo en caso de error",
|
||||
"nodes.common.retry.retrySuccessful": "Volver a intentarlo correctamente",
|
||||
@@ -453,7 +453,7 @@
|
||||
"nodes.docExtractor.inputVar": "Variable de entrada",
|
||||
"nodes.docExtractor.learnMore": "Aprende más",
|
||||
"nodes.docExtractor.outputVars.text": "Texto extraído",
|
||||
"nodes.docExtractor.supportFileTypes": "Tipos de archivos de soporte: {{tipos}}.",
|
||||
"nodes.docExtractor.supportFileTypes": "Tipos de archivos de soporte: {{types}}.",
|
||||
"nodes.end.output.type": "tipo de salida",
|
||||
"nodes.end.output.variable": "variable de salida",
|
||||
"nodes.end.outputs": "Salidas",
|
||||
@@ -549,8 +549,8 @@
|
||||
"nodes.iteration.deleteDesc": "Eliminar el nodo de iteración eliminará todos los nodos secundarios",
|
||||
"nodes.iteration.deleteTitle": "¿Eliminar nodo de iteración?",
|
||||
"nodes.iteration.errorResponseMethod": "Método de respuesta a errores",
|
||||
"nodes.iteration.error_one": "{{conteo}} Error",
|
||||
"nodes.iteration.error_other": "{{conteo}} Errores",
|
||||
"nodes.iteration.error_one": "{{count}} Error",
|
||||
"nodes.iteration.error_other": "{{count}} Errores",
|
||||
"nodes.iteration.flattenOutput": "Aplanar salida",
|
||||
"nodes.iteration.flattenOutputDesc": "Cuando está habilitado, si todas las salidas de la iteración son arrays, se aplanarán en un solo array. Cuando está deshabilitado, las salidas mantendrán una estructura de array anidada.",
|
||||
"nodes.iteration.input": "Entrada",
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
"gotoAnything.emptyState.noKnowledgeBasesFound": "هیچ پایگاه دانش یافت نشد",
|
||||
"gotoAnything.emptyState.noPluginsFound": "هیچ افزونه ای یافت نشد",
|
||||
"gotoAnything.emptyState.noWorkflowNodesFound": "هیچ گره گردش کاری یافت نشد",
|
||||
"gotoAnything.emptyState.tryDifferentTerm": "یک عبارت جستجوی متفاوت را امتحان کنید یا فیلتر {{mode}} را حذف کنید",
|
||||
"gotoAnything.emptyState.tryDifferentTerm": "یک عبارت جستجوی متفاوت را امتحان کنید یا فیلتر را حذف کنید",
|
||||
"gotoAnything.emptyState.trySpecificSearch": "{{shortcuts}} را برای جستجوهای خاص امتحان کنید",
|
||||
"gotoAnything.groups.apps": "برنامهها",
|
||||
"gotoAnything.groups.commands": "دستورات",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"stepOne.uploader.cancel": "لغو",
|
||||
"stepOne.uploader.change": "تغییر",
|
||||
"stepOne.uploader.failed": "بارگذاری ناموفق بود",
|
||||
"stepOne.uploader.tip": "پشتیبانی از {{supportTypes}}. حداکثر {{size}}MB هر کدام.",
|
||||
"stepOne.uploader.tip": "پشتیبانی از {{supportTypes}}. حداکثر {{batchCount}} فایل در هر دسته و {{size}} مگابایت برای هر فایل. حداکثر کل {{totalCount}} فایل.",
|
||||
"stepOne.uploader.title": "بارگذاری فایل",
|
||||
"stepOne.uploader.validation.count": "چندین فایل پشتیبانی نمیشود",
|
||||
"stepOne.uploader.validation.filesNumber": "شما به حد مجاز بارگذاری دستهای {{filesNumber}} رسیدهاید.",
|
||||
@@ -140,7 +140,7 @@
|
||||
"stepTwo.preview": "تأیید و پیشنمایش",
|
||||
"stepTwo.previewButton": "تغییر به قالب پرسش و پاسخ",
|
||||
"stepTwo.previewChunk": "پیش نمایش تکه",
|
||||
"stepTwo.previewChunkCount": "{{تعداد}} تکه های تخمینی",
|
||||
"stepTwo.previewChunkCount": "{{count}} تکه های تخمینی",
|
||||
"stepTwo.previewChunkTip": "روی دکمه \"پیش نمایش قطعه\" در سمت چپ کلیک کنید تا پیش نمایش بارگیری شود",
|
||||
"stepTwo.previewSwitchTipEnd": " توکنهای اضافی مصرف خواهد کرد",
|
||||
"stepTwo.previewSwitchTipStart": "پیشنمایش بخش فعلی در قالب متن است، تغییر به پیشنمایش قالب پرسش و پاسخ",
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
"parentMode.paragraph": "پاراگراف",
|
||||
"partialEnabled_one": "مجموعاً {{count}} سند، {{num}} موجود",
|
||||
"partialEnabled_other": "مجموع {{count}} سند، {{num}} موجود",
|
||||
"preprocessDocument": "{{عدد}} اسناد پیش پردازش",
|
||||
"preprocessDocument": "{{num}} اسناد پیش پردازش",
|
||||
"rerankSettings": "تنظیمات دوباره رتبهبندی",
|
||||
"retrieval.change": "تغییر",
|
||||
"retrieval.changeRetrievalMethod": "تغییر روش بازیابی",
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
"debugInfo.title": "اشکال زدایی",
|
||||
"debugInfo.viewDocs": "مشاهده اسناد",
|
||||
"deprecated": "منسوخ شده",
|
||||
"detailPanel.actionNum": "{{عدد}} {{اقدام}} شامل",
|
||||
"detailPanel.actionNum": "{{num}} {{action}} شامل",
|
||||
"detailPanel.categoryTip.debugging": "اشکال زدایی پلاگین",
|
||||
"detailPanel.categoryTip.github": "نصب شده از Github",
|
||||
"detailPanel.categoryTip.local": "پلاگین محلی",
|
||||
@@ -106,7 +106,7 @@
|
||||
"detailPanel.endpointsDocLink": "مشاهده سند",
|
||||
"detailPanel.endpointsEmpty": "برای افزودن نقطه پایانی روی دکمه \"+\" کلیک کنید",
|
||||
"detailPanel.endpointsTip": "این افزونه عملکردهای خاصی را از طریق نقاط پایانی ارائه می دهد و می توانید چندین مجموعه نقطه پایانی را برای فضای کاری فعلی پیکربندی کنید.",
|
||||
"detailPanel.modelNum": "{{عدد}} مدل های گنجانده شده است",
|
||||
"detailPanel.modelNum": "{{num}} مدل های گنجانده شده است",
|
||||
"detailPanel.operation.back": "بازگشت",
|
||||
"detailPanel.operation.checkUpdate": "به روز رسانی را بررسی کنید",
|
||||
"detailPanel.operation.detail": "جزئیات",
|
||||
@@ -116,7 +116,7 @@
|
||||
"detailPanel.operation.update": "روز رسانی",
|
||||
"detailPanel.operation.viewDetail": "نمایش جزئیات",
|
||||
"detailPanel.serviceOk": "خدمات خوب",
|
||||
"detailPanel.strategyNum": "{{عدد}} {{استراتژی}} شامل",
|
||||
"detailPanel.strategyNum": "{{num}} {{strategy}} شامل",
|
||||
"detailPanel.switchVersion": "نسخه سوئیچ",
|
||||
"detailPanel.toolSelector.auto": "خودکار",
|
||||
"detailPanel.toolSelector.descriptionLabel": "توضیحات ابزار",
|
||||
@@ -236,7 +236,7 @@
|
||||
"task.installSuccess": "{{successLength}} plugins installed successfully",
|
||||
"task.installed": "Installed",
|
||||
"task.installedError": "افزونه های {{errorLength}} نصب نشدند",
|
||||
"task.installing": "نصب پلاگین های {{installingLength}}، 0 انجام شد.",
|
||||
"task.installing": "نصب پلاگین های ، 0 انجام شد.",
|
||||
"task.installingWithError": "نصب پلاگین های {{installingLength}}، {{successLength}} با موفقیت مواجه شد، {{errorLength}} ناموفق بود",
|
||||
"task.installingWithSuccess": "نصب پلاگین های {{installingLength}}، {{successLength}} موفقیت آمیز است.",
|
||||
"task.runningPlugins": "Installing Plugins",
|
||||
|
||||
@@ -363,8 +363,8 @@
|
||||
"nodes.agent.strategyNotFoundDescAndSwitchVersion": "نسخه افزونه نصب شده این استراتژی را ارائه نمی دهد. برای تغییر نسخه کلیک کنید.",
|
||||
"nodes.agent.strategyNotInstallTooltip": "{{strategy}} نصب نشده است",
|
||||
"nodes.agent.strategyNotSet": "استراتژی عامل تنظیم نشده است",
|
||||
"nodes.agent.toolNotAuthorizedTooltip": "{{ابزار}} مجاز نیست",
|
||||
"nodes.agent.toolNotInstallTooltip": "{{ابزار}} نصب نشده است",
|
||||
"nodes.agent.toolNotAuthorizedTooltip": "{{tool}} مجاز نیست",
|
||||
"nodes.agent.toolNotInstallTooltip": "{{tool}} نصب نشده است",
|
||||
"nodes.agent.toolbox": "جعبه ابزار",
|
||||
"nodes.agent.tools": "ابزار",
|
||||
"nodes.agent.unsupportedStrategy": "استراتژی پشتیبانی نشده",
|
||||
@@ -435,10 +435,10 @@
|
||||
"nodes.common.pluginNotInstalled": "افزونه نصب نشده است",
|
||||
"nodes.common.retry.maxRetries": "حداکثر تلاش مجدد",
|
||||
"nodes.common.retry.ms": "خانم",
|
||||
"nodes.common.retry.retries": "{{عدد}} تلاش های مجدد",
|
||||
"nodes.common.retry.retries": "{{num}} تلاش های مجدد",
|
||||
"nodes.common.retry.retry": "دوباره",
|
||||
"nodes.common.retry.retryFailed": "تلاش مجدد ناموفق بود",
|
||||
"nodes.common.retry.retryFailedTimes": "{{بار}} تلاش های مجدد ناموفق بود",
|
||||
"nodes.common.retry.retryFailedTimes": "{{times}} تلاش های مجدد ناموفق بود",
|
||||
"nodes.common.retry.retryInterval": "فاصله تلاش مجدد",
|
||||
"nodes.common.retry.retryOnFailure": "در مورد شکست دوباره امتحان کنید",
|
||||
"nodes.common.retry.retrySuccessful": "امتحان مجدد با موفقیت انجام دهید",
|
||||
@@ -549,8 +549,8 @@
|
||||
"nodes.iteration.deleteDesc": "حذف نود تکرار باعث حذف تمام نودهای فرزند خواهد شد",
|
||||
"nodes.iteration.deleteTitle": "حذف نود تکرار؟",
|
||||
"nodes.iteration.errorResponseMethod": "روش پاسخ به خطا",
|
||||
"nodes.iteration.error_one": "{{تعداد}} خطا",
|
||||
"nodes.iteration.error_other": "{{تعداد}} خطاهای",
|
||||
"nodes.iteration.error_one": "{{count}} خطا",
|
||||
"nodes.iteration.error_other": "{{count}} خطاهای",
|
||||
"nodes.iteration.flattenOutput": "صاف کردن خروجی",
|
||||
"nodes.iteration.flattenOutputDesc": "هنگامی که فعال باشد، اگر تمام خروجیهای تکرار آرایه باشند، آنها به یک آرایهٔ واحد تبدیل خواهند شد. هنگامی که غیرفعال باشد، خروجیها ساختار آرایهٔ تو در تو را حفظ میکنند.",
|
||||
"nodes.iteration.input": "ورودی",
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
"gotoAnything.emptyState.noKnowledgeBasesFound": "Aucune base de connaissances trouvée",
|
||||
"gotoAnything.emptyState.noPluginsFound": "Aucun plugin trouvé",
|
||||
"gotoAnything.emptyState.noWorkflowNodesFound": "Aucun nœud de workflow trouvé",
|
||||
"gotoAnything.emptyState.tryDifferentTerm": "Essayez un terme de recherche différent ou supprimez le filtre {{mode}}",
|
||||
"gotoAnything.emptyState.tryDifferentTerm": "Essayez un terme de recherche différent ou supprimez le filtre",
|
||||
"gotoAnything.emptyState.trySpecificSearch": "Essayez {{shortcuts}} pour des recherches spécifiques",
|
||||
"gotoAnything.groups.apps": "Applications",
|
||||
"gotoAnything.groups.commands": "Commandes",
|
||||
|
||||
@@ -305,7 +305,7 @@
|
||||
"modelProvider.addModel": "Ajouter un modèle",
|
||||
"modelProvider.addMoreModelProvider": "AJOUTER PLUS DE FOURNISSEUR DE MODÈLE",
|
||||
"modelProvider.apiKey": "API-KEY",
|
||||
"modelProvider.apiKeyRateLimit": "La limite de débit a été atteinte, disponible après {{secondes}}s",
|
||||
"modelProvider.apiKeyRateLimit": "La limite de débit a été atteinte, disponible après {{seconds}}s",
|
||||
"modelProvider.apiKeyStatusNormal": "L’état de l’APIKey est normal",
|
||||
"modelProvider.auth.addApiKey": "Ajouter une clé API",
|
||||
"modelProvider.auth.addCredential": "Ajouter un identifiant",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"stepOne.uploader.cancel": "Annuler",
|
||||
"stepOne.uploader.change": "Changer",
|
||||
"stepOne.uploader.failed": "Le téléchargement a échoué",
|
||||
"stepOne.uploader.tip": "Prend en charge {{supportTypes}}. Max {{size}}MB chacun.",
|
||||
"stepOne.uploader.tip": "Prend en charge {{supportTypes}}. Maximum {{batchCount}} fichiers par lot et {{size}} MB chacun. Maximum total de {{totalCount}} fichiers.",
|
||||
"stepOne.uploader.title": "Télécharger le fichier texte",
|
||||
"stepOne.uploader.validation.count": "Plusieurs fichiers non pris en charge",
|
||||
"stepOne.uploader.validation.filesNumber": "Vous avez atteint la limite de téléchargement par lot de {{filesNumber}}.",
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
"detailPanel.operation.update": "Mettre à jour",
|
||||
"detailPanel.operation.viewDetail": "Voir les détails",
|
||||
"detailPanel.serviceOk": "Service OK",
|
||||
"detailPanel.strategyNum": "{{num}} {{stratégie}} INCLUS",
|
||||
"detailPanel.strategyNum": "{{num}} {{strategy}} INCLUS",
|
||||
"detailPanel.switchVersion": "Version du commutateur",
|
||||
"detailPanel.toolSelector.auto": "Auto",
|
||||
"detailPanel.toolSelector.descriptionLabel": "Description de l’outil",
|
||||
@@ -236,7 +236,7 @@
|
||||
"task.installSuccess": "{{successLength}} plugins installed successfully",
|
||||
"task.installed": "Installed",
|
||||
"task.installedError": "{{errorLength}} les plugins n’ont pas pu être installés",
|
||||
"task.installing": "Installation des plugins {{installingLength}}, 0 fait.",
|
||||
"task.installing": "Installation des plugins, 0 fait.",
|
||||
"task.installingWithError": "Installation des plugins {{installingLength}}, succès de {{successLength}}, échec de {{errorLength}}",
|
||||
"task.installingWithSuccess": "Installation des plugins {{installingLength}}, succès de {{successLength}}.",
|
||||
"task.runningPlugins": "Installing Plugins",
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
"gotoAnything.emptyState.noKnowledgeBasesFound": "कोई ज्ञान आधार नहीं मिले",
|
||||
"gotoAnything.emptyState.noPluginsFound": "कोई प्लगइन नहीं मिले",
|
||||
"gotoAnything.emptyState.noWorkflowNodesFound": "कोई कार्यप्रवाह नोड नहीं मिला",
|
||||
"gotoAnything.emptyState.tryDifferentTerm": "एक अलग खोज शब्द आज़माएं या {{mode}} फ़िल्टर हटा दें",
|
||||
"gotoAnything.emptyState.tryDifferentTerm": "एक अलग खोज शब्द आज़माएं या फ़िल्टर हटा दें",
|
||||
"gotoAnything.emptyState.trySpecificSearch": "विशिष्ट खोज के लिए {{shortcuts}} आज़माएं",
|
||||
"gotoAnything.groups.apps": "ऐप्स",
|
||||
"gotoAnything.groups.commands": "आदेश",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"stepOne.uploader.cancel": "रद्द करें",
|
||||
"stepOne.uploader.change": "बदलें",
|
||||
"stepOne.uploader.failed": "अपलोड विफल रहा",
|
||||
"stepOne.uploader.tip": "समर्थित {{supportTypes}}। प्रत्येक अधिकतम {{size}}MB।",
|
||||
"stepOne.uploader.tip": "{{supportTypes}} समर्थित है। एक बैच में अधिकतम {{batchCount}} फ़ाइलें और प्रत्येक {{size}} MB। कुल अधिकतम {{totalCount}} फ़ाइलें।",
|
||||
"stepOne.uploader.title": "फ़ाइल अपलोड करें",
|
||||
"stepOne.uploader.validation.count": "एकाधिक फ़ाइलें समर्थित नहीं हैं",
|
||||
"stepOne.uploader.validation.filesNumber": "आपने {{filesNumber}} की बैच अपलोड सीमा तक पहुँच गए हैं।",
|
||||
@@ -140,7 +140,7 @@
|
||||
"stepTwo.preview": "पुष्टि करें और पूर्वावलोकन करें",
|
||||
"stepTwo.previewButton": "प्रश्न-उत्तर प्रारूप में स्विच करना",
|
||||
"stepTwo.previewChunk": "पूर्वावलोकन चंक",
|
||||
"stepTwo.previewChunkCount": "{{गिनती}} अनुमानित खंड",
|
||||
"stepTwo.previewChunkCount": "{{count}} अनुमानित खंड",
|
||||
"stepTwo.previewChunkTip": "पूर्वावलोकन लोड करने के लिए बाईं ओर 'पूर्वावलोकन चंक' बटन पर क्लिक करें",
|
||||
"stepTwo.previewSwitchTipEnd": " अतिरिक्त टोकन खर्च होंगे",
|
||||
"stepTwo.previewSwitchTipStart": "वर्तमान खंड पूर्वावलोकन पाठ प्रारूप में है, प्रश्न-उत्तर प्रारूप में स्विच करने से",
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
"detailPanel.operation.update": "अपडेट",
|
||||
"detailPanel.operation.viewDetail": "विवरण देखें",
|
||||
"detailPanel.serviceOk": "सेवा ठीक है",
|
||||
"detailPanel.strategyNum": "{{num}} {{रणनीति}} शामिल",
|
||||
"detailPanel.strategyNum": "{{num}} {{strategy}} शामिल",
|
||||
"detailPanel.switchVersion": "स्विच संस्करण",
|
||||
"detailPanel.toolSelector.auto": "स्वचालित",
|
||||
"detailPanel.toolSelector.descriptionLabel": "उपकरण का विवरण",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"stepOne.uploader.cancel": "Annulla",
|
||||
"stepOne.uploader.change": "Cambia",
|
||||
"stepOne.uploader.failed": "Caricamento fallito",
|
||||
"stepOne.uploader.tip": "Supporta {{supportTypes}}. Max {{size}}MB ciascuno.",
|
||||
"stepOne.uploader.tip": "Supporta {{supportTypes}}. Massimo {{batchCount}} file per batch e {{size}} MB ciascuno. Totale massimo {{totalCount}} file.",
|
||||
"stepOne.uploader.title": "Carica file",
|
||||
"stepOne.uploader.validation.count": "Più file non supportati",
|
||||
"stepOne.uploader.validation.filesNumber": "Hai raggiunto il limite di caricamento batch di {{filesNumber}}.",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"stepOne.uploader.cancel": "キャンセル",
|
||||
"stepOne.uploader.change": "変更",
|
||||
"stepOne.uploader.failed": "アップロードに失敗しました",
|
||||
"stepOne.uploader.tip": "{{supportTypes}}をサポートしています。1 つあたりの最大サイズは{{size}}MB です。",
|
||||
"stepOne.uploader.tip": "{{supportTypes}}をサポートしています。1バッチあたり最大{{batchCount}}ファイル、各ファイル{{size}}MB まで。合計最大{{totalCount}}ファイル。",
|
||||
"stepOne.uploader.title": "テキストファイルをアップロード",
|
||||
"stepOne.uploader.validation.count": "複数のファイルはサポートされていません",
|
||||
"stepOne.uploader.validation.filesNumber": "バッチアップロードの制限({{filesNumber}}個)に達しました。",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"stepOne.uploader.cancel": "취소",
|
||||
"stepOne.uploader.change": "변경",
|
||||
"stepOne.uploader.failed": "업로드에 실패했습니다",
|
||||
"stepOne.uploader.tip": "{{supportTypes}}을 (를) 지원합니다. 파일당 최대 크기는 {{size}}MB 입니다.",
|
||||
"stepOne.uploader.tip": "{{supportTypes}}을(를) 지원합니다. 배치당 최대 {{batchCount}}개 파일, 각 파일당 {{size}}MB까지. 총 최대 {{totalCount}}개 파일.",
|
||||
"stepOne.uploader.title": "텍스트 파일 업로드",
|
||||
"stepOne.uploader.validation.count": "여러 파일은 지원되지 않습니다",
|
||||
"stepOne.uploader.validation.filesNumber": "일괄 업로드 제한 ({{filesNumber}}개) 에 도달했습니다.",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"stepOne.uploader.cancel": "Anuluj",
|
||||
"stepOne.uploader.change": "Zmień",
|
||||
"stepOne.uploader.failed": "Przesyłanie nie powiodło się",
|
||||
"stepOne.uploader.tip": "Obsługuje {{supportTypes}}. Maksymalnie {{size}}MB każdy.",
|
||||
"stepOne.uploader.tip": "Obsługuje {{supportTypes}}. Maksymalnie {{batchCount}} plików w partii, każdy do {{size}} MB. Łącznie maksymalnie {{totalCount}} plików.",
|
||||
"stepOne.uploader.title": "Prześlij plik tekstowy",
|
||||
"stepOne.uploader.validation.count": "Nieobsługiwane przesyłanie wielu plików",
|
||||
"stepOne.uploader.validation.filesNumber": "Osiągnąłeś limit przesłania partii {{filesNumber}}.",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"stepOne.uploader.cancel": "Cancelar",
|
||||
"stepOne.uploader.change": "Alterar",
|
||||
"stepOne.uploader.failed": "Falha no envio",
|
||||
"stepOne.uploader.tip": "Suporta {{supportTypes}}. Máximo de {{size}}MB cada.",
|
||||
"stepOne.uploader.tip": "Suporta {{supportTypes}}. Máximo de {{batchCount}} arquivos por lote e {{size}} MB cada. Total máximo de {{totalCount}} arquivos.",
|
||||
"stepOne.uploader.title": "Enviar arquivo de texto",
|
||||
"stepOne.uploader.validation.count": "Vários arquivos não suportados",
|
||||
"stepOne.uploader.validation.filesNumber": "Limite de upload em massa {{filesNumber}}.",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"stepOne.uploader.cancel": "Anulează",
|
||||
"stepOne.uploader.change": "Schimbă",
|
||||
"stepOne.uploader.failed": "Încărcarea a eșuat",
|
||||
"stepOne.uploader.tip": "Acceptă {{supportTypes}}. Maxim {{size}}MB fiecare.",
|
||||
"stepOne.uploader.tip": "Acceptă {{supportTypes}}. Maxim {{batchCount}} fișiere pe lot și {{size}} MB fiecare. Total maxim {{totalCount}} fișiere.",
|
||||
"stepOne.uploader.title": "Încărcați fișier text",
|
||||
"stepOne.uploader.validation.count": "Nu se acceptă mai multe fișiere",
|
||||
"stepOne.uploader.validation.filesNumber": "Ați atins limita de încărcare în lot de {{filesNumber}} fișiere.",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"stepOne.uploader.cancel": "Отмена",
|
||||
"stepOne.uploader.change": "Изменить",
|
||||
"stepOne.uploader.failed": "Ошибка загрузки",
|
||||
"stepOne.uploader.tip": "Поддерживаются {{supportTypes}}. Максимум {{size}} МБ каждый.",
|
||||
"stepOne.uploader.tip": "Поддерживаются {{supportTypes}}. Максимум {{batchCount}} файлов за раз, каждый до {{size}} МБ. Всего максимум {{totalCount}} файлов.",
|
||||
"stepOne.uploader.title": "Загрузить файл",
|
||||
"stepOne.uploader.validation.count": "Несколько файлов не поддерживаются",
|
||||
"stepOne.uploader.validation.filesNumber": "Вы достигли лимита пакетной загрузки {{filesNumber}} файлов.",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"stepOne.uploader.cancel": "Prekliči",
|
||||
"stepOne.uploader.change": "Zamenjaj",
|
||||
"stepOne.uploader.failed": "Nalaganje ni uspelo",
|
||||
"stepOne.uploader.tip": "Podprti tipi datotek: {{supportTypes}}. Največ {{size}}MB na datoteko.",
|
||||
"stepOne.uploader.tip": "Podpira {{supportTypes}}. Največje število datotek v seriji: {{batchCount}}, vsaka do {{size}} MB. Skupaj največ {{totalCount}} datotek.",
|
||||
"stepOne.uploader.title": "Naloži datoteko",
|
||||
"stepOne.uploader.validation.count": "Podprta je le ena datoteka",
|
||||
"stepOne.uploader.validation.filesNumber": "Dosegli ste omejitev za pošiljanje {{filesNumber}} datotek.",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"stepOne.uploader.cancel": "ยกเลิก",
|
||||
"stepOne.uploader.change": "เปลี่ยน",
|
||||
"stepOne.uploader.failed": "อัปโหลดล้มเหลว",
|
||||
"stepOne.uploader.tip": "รองรับ {{supportTypes}} สูงสุด {{size}}MB แต่ละตัว",
|
||||
"stepOne.uploader.tip": "รองรับ {{supportTypes}} สูงสุด {{batchCount}} ไฟล์ต่อชุดและ {{size}} MB แต่ละไฟล์ รวมสูงสุด {{totalCount}} ไฟล์",
|
||||
"stepOne.uploader.title": "อัปโหลดไฟล์",
|
||||
"stepOne.uploader.validation.count": "ไม่รองรับหลายไฟล์",
|
||||
"stepOne.uploader.validation.filesNumber": "คุณถึงขีดจํากัดการอัปโหลดเป็นชุดของ {{filesNumber}} แล้ว",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"stepOne.uploader.cancel": "İptal",
|
||||
"stepOne.uploader.change": "Değiştir",
|
||||
"stepOne.uploader.failed": "Yükleme başarısız",
|
||||
"stepOne.uploader.tip": "Destekler {{supportTypes}}. Her biri en fazla {{size}}MB.",
|
||||
"stepOne.uploader.tip": "{{supportTypes}} destekler. Parti başına en fazla {{batchCount}} dosya ve her biri {{size}} MB. Toplam en fazla {{totalCount}} dosya.",
|
||||
"stepOne.uploader.title": "Dosya yükle",
|
||||
"stepOne.uploader.validation.count": "Birden fazla dosya desteklenmiyor",
|
||||
"stepOne.uploader.validation.filesNumber": "Toplu yükleme sınırına ulaştınız, {{filesNumber}} dosya.",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"stepOne.uploader.cancel": "Скасувати",
|
||||
"stepOne.uploader.change": "Змінити",
|
||||
"stepOne.uploader.failed": "Завантаження не вдалося",
|
||||
"stepOne.uploader.tip": "Підтримуються {{supportTypes}}. Максимум {{size}} МБ кожен.",
|
||||
"stepOne.uploader.tip": "Підтримуються {{supportTypes}}. Максимум {{batchCount}} файлів за раз, кожен до {{size}} МБ. Загалом максимум {{totalCount}} файлів.",
|
||||
"stepOne.uploader.title": "Завантажити текстовий файл",
|
||||
"stepOne.uploader.validation.count": "Не підтримується завантаження кількох файлів",
|
||||
"stepOne.uploader.validation.filesNumber": "Ліміт масового завантаження {{filesNumber}}.",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"stepOne.uploader.cancel": "Hủy",
|
||||
"stepOne.uploader.change": "Thay đổi",
|
||||
"stepOne.uploader.failed": "Tải lên thất bại",
|
||||
"stepOne.uploader.tip": "Hỗ trợ {{supportTypes}}. Tối đa {{size}}MB mỗi tệp.",
|
||||
"stepOne.uploader.tip": "Hỗ trợ {{supportTypes}}. Tối đa {{batchCount}} tệp trong một lô và {{size}} MB mỗi tệp. Tổng tối đa {{totalCount}} tệp.",
|
||||
"stepOne.uploader.title": "Tải lên tệp văn bản",
|
||||
"stepOne.uploader.validation.count": "Không hỗ trợ tải lên nhiều tệp",
|
||||
"stepOne.uploader.validation.filesNumber": "Bạn đã đạt đến giới hạn tải lên lô của {{filesNumber}} tệp.",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"stepOne.uploader.cancel": "取消",
|
||||
"stepOne.uploader.change": "更改檔案",
|
||||
"stepOne.uploader.failed": "上傳失敗",
|
||||
"stepOne.uploader.tip": "已支援 {{supportTypes}},每個檔案不超過 {{size}}MB。",
|
||||
"stepOne.uploader.tip": "支援 {{supportTypes}}。每批最多 {{batchCount}} 個檔案,每個檔案不超過 {{size}} MB,總數不超過 {{totalCount}} 個檔案。",
|
||||
"stepOne.uploader.title": "上傳文字檔案",
|
||||
"stepOne.uploader.validation.count": "暫不支援多個檔案",
|
||||
"stepOne.uploader.validation.filesNumber": "批次上傳限制 {{filesNumber}}。",
|
||||
|
||||
@@ -48,11 +48,6 @@ const nextConfig = {
|
||||
search: '',
|
||||
})),
|
||||
},
|
||||
experimental: {
|
||||
optimizePackageImports: [
|
||||
'@heroicons/react',
|
||||
],
|
||||
},
|
||||
// fix all before production. Now it slow the develop speed.
|
||||
eslint: {
|
||||
// Warning: This allows production builds to successfully complete even if
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"lint:quiet": "pnpm lint --quiet",
|
||||
"lint:complexity": "pnpm lint --rule 'complexity: [error, {max: 15}]' --quiet",
|
||||
"lint:report": "pnpm lint --output-file eslint_report.json --format json",
|
||||
"lint:tss": "tsslint --project tsconfig.json",
|
||||
"type-check": "tsc --noEmit",
|
||||
"type-check:tsgo": "tsgo --noEmit",
|
||||
"prepare": "cd ../ && node -e \"if (process.env.NODE_ENV !== 'production'){process.exit(1)} \" || husky ./web/.husky",
|
||||
@@ -178,6 +179,9 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@tsslint/cli": "^3.0.1",
|
||||
"@tsslint/compat-eslint": "^3.0.1",
|
||||
"@tsslint/config": "^3.0.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/negotiator": "^0.6.4",
|
||||
@@ -282,8 +286,5 @@
|
||||
"pbkdf2": "~3.1.3",
|
||||
"prismjs": "~1.30",
|
||||
"string-width": "~4.2.3"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*": "eslint --fix"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user