Compare commits

...

12 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
266e887fb1 chore: temporarily remove lint-staged to enable incremental i18n fixes
Co-authored-by: hyoban <38493346+hyoban@users.noreply.github.com>
2026-01-20 10:10:10 +00:00
copilot-swe-agent[bot]
337701badb Changes before error encountered
Co-authored-by: hyoban <38493346+hyoban@users.noreply.github.com>
2026-01-20 10:00:18 +00:00
copilot-swe-agent[bot]
0d11745909 Changes before error encountered
Co-authored-by: hyoban <38493346+hyoban@users.noreply.github.com>
2026-01-20 09:48:20 +00:00
copilot-swe-agent[bot]
6a6997094d fix: correct i18n placeholders while preserving all translations
Co-authored-by: hyoban <38493346+hyoban@users.noreply.github.com>
2026-01-20 09:33:30 +00:00
copilot-swe-agent[bot]
3f0719e50f fix: sync i18n placeholder mismatches with en-US
Co-authored-by: hyoban <38493346+hyoban@users.noreply.github.com>
2026-01-20 09:21:58 +00:00
copilot-swe-agent[bot]
67bab85fa6 Initial plan 2026-01-20 09:14:16 +00:00
Stephen Zhou
84a1d6a948 chore: lint for i18n place holder 2026-01-20 17:02:27 +08:00
Stephen Zhou
3ebe53ada1 ci: label web changes (#31261)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-20 13:46:23 +08:00
Coding On Star
76b64dda52 test: add tests for dataset list (#31231)
Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-01-20 13:07:00 +08:00
cxhello
a715c015e7 chore(web): remove redundant optimizePackageImports config (#31257) 2026-01-20 12:24:16 +08:00
Stephen Zhou
45b8d033be chore: init tsslint (#31209)
Co-authored-by: Johnson Chu <johnsoncodehk@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-20 11:08:50 +08:00
kurokobo
cb51a449d3 fix: correct i18n for stepOne.uploader.tip (#31177) 2026-01-20 09:30:50 +08:00
102 changed files with 19354 additions and 199 deletions

3
.github/labeler.yml vendored Normal file
View File

@@ -0,0 +1,3 @@
web:
- changed-files:
- any-glob-to-any-file: 'web/**'

14
.github/workflows/labeler.yml vendored Normal file
View 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

View File

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

View File

@@ -1,6 +1,8 @@
{
"recommendations": [
"bradlc.vscode-tailwindcss",
"kisstkondoros.vscode-codemetrics"
"kisstkondoros.vscode-codemetrics",
"johnsoncodehk.vscode-tsslint",
"dbaeumer.vscode-eslint"
]
}

View File

@@ -0,0 +1,239 @@
import type { CreateExternalAPIReq, FormSchema } from '../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Form from './Form'
// Mock context for i18n doc link
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
describe('Form', () => {
const defaultFormSchemas: FormSchema[] = [
{
variable: 'name',
type: 'text',
label: { en_US: 'Name', zh_CN: '名称' },
required: true,
},
{
variable: 'endpoint',
type: 'text',
label: { en_US: 'API Endpoint', zh_CN: 'API 端点' },
required: true,
},
{
variable: 'api_key',
type: 'secret',
label: { en_US: 'API Key', zh_CN: 'API 密钥' },
required: true,
},
]
const defaultValue: CreateExternalAPIReq = {
name: '',
settings: {
endpoint: '',
api_key: '',
},
}
const defaultProps = {
value: defaultValue,
onChange: vi.fn(),
formSchemas: defaultFormSchemas,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<Form {...defaultProps} />)
expect(container.querySelector('form')).toBeInTheDocument()
})
it('should render all form fields based on formSchemas', () => {
render(<Form {...defaultProps} />)
expect(screen.getByLabelText(/name/i)).toBeInTheDocument()
expect(screen.getByLabelText(/api endpoint/i)).toBeInTheDocument()
expect(screen.getByLabelText(/api key/i)).toBeInTheDocument()
})
it('should render required indicator for required fields', () => {
render(<Form {...defaultProps} />)
const labels = screen.getAllByText('*')
expect(labels.length).toBe(3) // All 3 fields are required
})
it('should render documentation link for endpoint field', () => {
render(<Form {...defaultProps} />)
const docLink = screen.getByText('dataset.externalAPIPanelDocumentation')
expect(docLink).toBeInTheDocument()
expect(docLink.closest('a')).toHaveAttribute('href', expect.stringContaining('docs.example.com'))
})
it('should render password type input for secret fields', () => {
render(<Form {...defaultProps} />)
const apiKeyInput = screen.getByLabelText(/api key/i)
expect(apiKeyInput).toHaveAttribute('type', 'password')
})
it('should render text type input for text fields', () => {
render(<Form {...defaultProps} />)
const nameInput = screen.getByLabelText(/name/i)
expect(nameInput).toHaveAttribute('type', 'text')
})
})
describe('Props', () => {
it('should apply custom className to form', () => {
const { container } = render(<Form {...defaultProps} className="custom-form-class" />)
expect(container.querySelector('form')).toHaveClass('custom-form-class')
})
it('should apply itemClassName to form items', () => {
const { container } = render(<Form {...defaultProps} itemClassName="custom-item-class" />)
const items = container.querySelectorAll('.custom-item-class')
expect(items.length).toBe(3)
})
it('should apply fieldLabelClassName to labels', () => {
const { container } = render(<Form {...defaultProps} fieldLabelClassName="custom-label-class" />)
const labels = container.querySelectorAll('label.custom-label-class')
expect(labels.length).toBe(3)
})
it('should apply inputClassName to inputs', () => {
render(<Form {...defaultProps} inputClassName="custom-input-class" />)
const inputs = screen.getAllByRole('textbox')
inputs.forEach((input) => {
expect(input).toHaveClass('custom-input-class')
})
})
it('should display initial values', () => {
const valueWithData: CreateExternalAPIReq = {
name: 'Test API',
settings: {
endpoint: 'https://api.example.com',
api_key: 'secret-key',
},
}
render(<Form {...defaultProps} value={valueWithData} />)
expect(screen.getByLabelText(/name/i)).toHaveValue('Test API')
expect(screen.getByLabelText(/api endpoint/i)).toHaveValue('https://api.example.com')
expect(screen.getByLabelText(/api key/i)).toHaveValue('secret-key')
})
})
describe('User Interactions', () => {
it('should call onChange when name field changes', () => {
const onChange = vi.fn()
render(<Form {...defaultProps} onChange={onChange} />)
const nameInput = screen.getByLabelText(/name/i)
fireEvent.change(nameInput, { target: { value: 'New API Name' } })
expect(onChange).toHaveBeenCalledWith({
name: 'New API Name',
settings: { endpoint: '', api_key: '' },
})
})
it('should call onChange when endpoint field changes', () => {
const onChange = vi.fn()
render(<Form {...defaultProps} onChange={onChange} />)
const endpointInput = screen.getByLabelText(/api endpoint/i)
fireEvent.change(endpointInput, { target: { value: 'https://new-api.example.com' } })
expect(onChange).toHaveBeenCalledWith({
name: '',
settings: { endpoint: 'https://new-api.example.com', api_key: '' },
})
})
it('should call onChange when api_key field changes', () => {
const onChange = vi.fn()
render(<Form {...defaultProps} onChange={onChange} />)
const apiKeyInput = screen.getByLabelText(/api key/i)
fireEvent.change(apiKeyInput, { target: { value: 'new-secret-key' } })
expect(onChange).toHaveBeenCalledWith({
name: '',
settings: { endpoint: '', api_key: 'new-secret-key' },
})
})
it('should update settings without affecting name', () => {
const onChange = vi.fn()
const initialValue: CreateExternalAPIReq = {
name: 'Existing Name',
settings: { endpoint: '', api_key: '' },
}
render(<Form {...defaultProps} value={initialValue} onChange={onChange} />)
const endpointInput = screen.getByLabelText(/api endpoint/i)
fireEvent.change(endpointInput, { target: { value: 'https://api.example.com' } })
expect(onChange).toHaveBeenCalledWith({
name: 'Existing Name',
settings: { endpoint: 'https://api.example.com', api_key: '' },
})
})
})
describe('Edge Cases', () => {
it('should handle empty formSchemas', () => {
const { container } = render(<Form {...defaultProps} formSchemas={[]} />)
expect(container.querySelector('form')).toBeInTheDocument()
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should handle optional field (required: false)', () => {
const schemasWithOptional: FormSchema[] = [
{
variable: 'description',
type: 'text',
label: { en_US: 'Description' },
required: false,
},
]
render(<Form {...defaultProps} formSchemas={schemasWithOptional} />)
expect(screen.queryByText('*')).not.toBeInTheDocument()
})
it('should fallback to en_US label when current language label is not available', () => {
const schemasWithEnOnly: FormSchema[] = [
{
variable: 'test',
type: 'text',
label: { en_US: 'Test Field' },
required: false,
},
]
render(<Form {...defaultProps} formSchemas={schemasWithEnOnly} />)
expect(screen.getByLabelText(/test field/i)).toBeInTheDocument()
})
it('should preserve existing settings when updating one field', () => {
const onChange = vi.fn()
const initialValue: CreateExternalAPIReq = {
name: '',
settings: { endpoint: 'https://existing.com', api_key: 'existing-key' },
}
render(<Form {...defaultProps} value={initialValue} onChange={onChange} />)
const endpointInput = screen.getByLabelText(/api endpoint/i)
fireEvent.change(endpointInput, { target: { value: 'https://new.com' } })
expect(onChange).toHaveBeenCalledWith({
name: '',
settings: { endpoint: 'https://new.com', api_key: 'existing-key' },
})
})
})
})

View File

@@ -0,0 +1,424 @@
import type { CreateExternalAPIReq } from '../declarations'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Import mocked service
import { createExternalAPI } from '@/service/datasets'
import AddExternalAPIModal from './index'
// Mock API service
vi.mock('@/service/datasets', () => ({
createExternalAPI: vi.fn(),
}))
// Mock toast context
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
describe('AddExternalAPIModal', () => {
const defaultProps = {
onSave: vi.fn(),
onCancel: vi.fn(),
isEditMode: false,
}
const initialData: CreateExternalAPIReq = {
name: 'Test API',
settings: {
endpoint: 'https://api.example.com',
api_key: 'test-key-12345',
},
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<AddExternalAPIModal {...defaultProps} />)
expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument()
})
it('should render create title when not in edit mode', () => {
render(<AddExternalAPIModal {...defaultProps} isEditMode={false} />)
expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument()
})
it('should render edit title when in edit mode', () => {
render(<AddExternalAPIModal {...defaultProps} isEditMode={true} data={initialData} />)
expect(screen.getByText('dataset.editExternalAPIFormTitle')).toBeInTheDocument()
})
it('should render form fields', () => {
render(<AddExternalAPIModal {...defaultProps} />)
expect(screen.getByLabelText(/name/i)).toBeInTheDocument()
expect(screen.getByLabelText(/api endpoint/i)).toBeInTheDocument()
expect(screen.getByLabelText(/api key/i)).toBeInTheDocument()
})
it('should render cancel and save buttons', () => {
render(<AddExternalAPIModal {...defaultProps} />)
expect(screen.getByText('dataset.externalAPIForm.cancel')).toBeInTheDocument()
expect(screen.getByText('dataset.externalAPIForm.save')).toBeInTheDocument()
})
it('should render encryption notice', () => {
render(<AddExternalAPIModal {...defaultProps} />)
expect(screen.getByText('PKCS1_OAEP')).toBeInTheDocument()
})
it('should render close button', () => {
render(<AddExternalAPIModal {...defaultProps} />)
// Close button is rendered in a portal
const closeButton = document.body.querySelector('.action-btn')
expect(closeButton).toBeInTheDocument()
})
})
describe('Edit Mode with Dataset Bindings', () => {
it('should show warning when editing with dataset bindings', () => {
const datasetBindings = [
{ id: 'ds-1', name: 'Dataset 1' },
{ id: 'ds-2', name: 'Dataset 2' },
]
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={datasetBindings}
/>,
)
expect(screen.getByText('dataset.editExternalAPIFormWarning.front')).toBeInTheDocument()
// Verify the count is displayed in the warning section
const warningElement = screen.getByText('dataset.editExternalAPIFormWarning.front').parentElement
expect(warningElement?.textContent).toContain('2')
})
it('should not show warning when no dataset bindings', () => {
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={[]}
/>,
)
expect(screen.queryByText('dataset.editExternalAPIFormWarning.front')).not.toBeInTheDocument()
})
})
describe('Form Interactions', () => {
it('should update form values when input changes', () => {
render(<AddExternalAPIModal {...defaultProps} />)
const nameInput = screen.getByLabelText(/name/i)
fireEvent.change(nameInput, { target: { value: 'New API Name' } })
expect(nameInput).toHaveValue('New API Name')
})
it('should initialize form with data in edit mode', () => {
render(<AddExternalAPIModal {...defaultProps} isEditMode={true} data={initialData} />)
expect(screen.getByLabelText(/name/i)).toHaveValue('Test API')
expect(screen.getByLabelText(/api endpoint/i)).toHaveValue('https://api.example.com')
expect(screen.getByLabelText(/api key/i)).toHaveValue('test-key-12345')
})
it('should disable save button when form has empty inputs', () => {
render(<AddExternalAPIModal {...defaultProps} />)
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')
expect(saveButton).toBeDisabled()
})
it('should enable save button when all fields are filled', () => {
render(<AddExternalAPIModal {...defaultProps} />)
const nameInput = screen.getByLabelText(/name/i)
const endpointInput = screen.getByLabelText(/api endpoint/i)
const apiKeyInput = screen.getByLabelText(/api key/i)
fireEvent.change(nameInput, { target: { value: 'Test' } })
fireEvent.change(endpointInput, { target: { value: 'https://test.com' } })
fireEvent.change(apiKeyInput, { target: { value: 'key12345' } })
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')
expect(saveButton).not.toBeDisabled()
})
})
describe('Create Mode - Save', () => {
it('should create API and call onSave on success', async () => {
const mockResponse = {
id: 'new-api-123',
tenant_id: 'tenant-1',
name: 'Test',
description: '',
settings: { endpoint: 'https://test.com', api_key: 'key12345' },
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
}
vi.mocked(createExternalAPI).mockResolvedValue(mockResponse)
const onSave = vi.fn()
const onCancel = vi.fn()
render(<AddExternalAPIModal {...defaultProps} onSave={onSave} onCancel={onCancel} />)
const nameInput = screen.getByLabelText(/name/i)
const endpointInput = screen.getByLabelText(/api endpoint/i)
const apiKeyInput = screen.getByLabelText(/api key/i)
fireEvent.change(nameInput, { target: { value: 'Test' } })
fireEvent.change(endpointInput, { target: { value: 'https://test.com' } })
fireEvent.change(apiKeyInput, { target: { value: 'key12345' } })
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(createExternalAPI).toHaveBeenCalledWith({
body: {
name: 'Test',
settings: { endpoint: 'https://test.com', api_key: 'key12345' },
},
})
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
message: 'External API saved successfully',
})
expect(onSave).toHaveBeenCalledWith(mockResponse)
expect(onCancel).toHaveBeenCalled()
})
})
it('should show error notification when API key is too short', async () => {
render(<AddExternalAPIModal {...defaultProps} />)
const nameInput = screen.getByLabelText(/name/i)
const endpointInput = screen.getByLabelText(/api endpoint/i)
const apiKeyInput = screen.getByLabelText(/api key/i)
fireEvent.change(nameInput, { target: { value: 'Test' } })
fireEvent.change(endpointInput, { target: { value: 'https://test.com' } })
fireEvent.change(apiKeyInput, { target: { value: 'key' } }) // Less than 5 characters
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'common.apiBasedExtension.modal.apiKey.lengthError',
})
})
})
it('should handle create API error', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(createExternalAPI).mockRejectedValue(new Error('Create failed'))
render(<AddExternalAPIModal {...defaultProps} />)
const nameInput = screen.getByLabelText(/name/i)
const endpointInput = screen.getByLabelText(/api endpoint/i)
const apiKeyInput = screen.getByLabelText(/api key/i)
fireEvent.change(nameInput, { target: { value: 'Test' } })
fireEvent.change(endpointInput, { target: { value: 'https://test.com' } })
fireEvent.change(apiKeyInput, { target: { value: 'key12345' } })
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'Failed to save/update External API',
})
})
consoleSpy.mockRestore()
})
})
describe('Edit Mode - Save', () => {
it('should call onEdit directly when editing without dataset bindings', async () => {
const onEdit = vi.fn().mockResolvedValue(undefined)
const onCancel = vi.fn()
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={[]}
onEdit={onEdit}
onCancel={onCancel}
/>,
)
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
// When no datasetBindings, onEdit is called directly with original form data
expect(onEdit).toHaveBeenCalledWith({
name: 'Test API',
settings: {
endpoint: 'https://api.example.com',
api_key: 'test-key-12345',
},
})
})
})
it('should show confirm dialog when editing with dataset bindings', async () => {
const datasetBindings = [{ id: 'ds-1', name: 'Dataset 1' }]
const onEdit = vi.fn().mockResolvedValue(undefined)
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={datasetBindings}
onEdit={onEdit}
/>,
)
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
})
})
it('should proceed with save after confirming in edit mode with bindings', async () => {
vi.mocked(createExternalAPI).mockResolvedValue({
id: 'api-123',
tenant_id: 'tenant-1',
name: 'Test API',
description: '',
settings: { endpoint: 'https://api.example.com', api_key: 'test-key-12345' },
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
})
const datasetBindings = [{ id: 'ds-1', name: 'Dataset 1' }]
const onCancel = vi.fn()
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={datasetBindings}
onCancel={onCancel}
/>,
)
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
})
const confirmButton = screen.getByRole('button', { name: /confirm/i })
fireEvent.click(confirmButton)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'success' }),
)
})
})
it('should close confirm dialog when cancel is clicked', async () => {
const datasetBindings = [{ id: 'ds-1', name: 'Dataset 1' }]
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={datasetBindings}
/>,
)
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
})
// There are multiple cancel buttons, find the one in the confirm dialog
const cancelButtons = screen.getAllByRole('button', { name: /cancel/i })
const confirmDialogCancelButton = cancelButtons[cancelButtons.length - 1]
fireEvent.click(confirmDialogCancelButton)
await waitFor(() => {
// Confirm button should be gone after canceling
expect(screen.queryAllByRole('button', { name: /confirm/i })).toHaveLength(0)
})
})
})
describe('Cancel', () => {
it('should call onCancel when cancel button is clicked', () => {
const onCancel = vi.fn()
render(<AddExternalAPIModal {...defaultProps} onCancel={onCancel} />)
const cancelButton = screen.getByText('dataset.externalAPIForm.cancel').closest('button')!
fireEvent.click(cancelButton)
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should call onCancel when close button is clicked', () => {
const onCancel = vi.fn()
render(<AddExternalAPIModal {...defaultProps} onCancel={onCancel} />)
// Close button is rendered in a portal
const closeButton = document.body.querySelector('.action-btn')!
fireEvent.click(closeButton)
expect(onCancel).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {
it('should handle undefined data in edit mode', () => {
render(<AddExternalAPIModal {...defaultProps} isEditMode={true} data={undefined} />)
expect(screen.getByLabelText(/name/i)).toHaveValue('')
})
it('should handle null datasetBindings', () => {
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={undefined}
/>,
)
expect(screen.queryByText('dataset.editExternalAPIFormWarning.front')).not.toBeInTheDocument()
})
it('should render documentation link in encryption notice', () => {
render(<AddExternalAPIModal {...defaultProps} />)
const link = screen.getByRole('link', { name: 'PKCS1_OAEP' })
expect(link).toHaveAttribute('href', 'https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html')
expect(link).toHaveAttribute('target', '_blank')
})
})
})

View File

@@ -0,0 +1,207 @@
import type { ExternalAPIItem } from '@/models/datasets'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ExternalAPIPanel from './index'
// Mock external contexts (only mock context providers, not base components)
const mockSetShowExternalKnowledgeAPIModal = vi.fn()
const mockMutateExternalKnowledgeApis = vi.fn()
let mockIsLoading = false
let mockExternalKnowledgeApiList: ExternalAPIItem[] = []
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowExternalKnowledgeAPIModal: mockSetShowExternalKnowledgeAPIModal,
}),
}))
vi.mock('@/context/external-knowledge-api-context', () => ({
useExternalKnowledgeApi: () => ({
externalKnowledgeApiList: mockExternalKnowledgeApiList,
mutateExternalKnowledgeApis: mockMutateExternalKnowledgeApis,
isLoading: mockIsLoading,
}),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
// Mock the ExternalKnowledgeAPICard to avoid mocking its internal dependencies
vi.mock('../external-knowledge-api-card', () => ({
default: ({ api }: { api: ExternalAPIItem }) => (
<div data-testid={`api-card-${api.id}`}>{api.name}</div>
),
}))
// i18n mock returns 'namespace.key' format
describe('ExternalAPIPanel', () => {
const defaultProps = {
onClose: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
mockIsLoading = false
mockExternalKnowledgeApiList = []
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<ExternalAPIPanel {...defaultProps} />)
expect(screen.getByText('dataset.externalAPIPanelTitle')).toBeInTheDocument()
})
it('should render panel title and description', () => {
render(<ExternalAPIPanel {...defaultProps} />)
expect(screen.getByText('dataset.externalAPIPanelTitle')).toBeInTheDocument()
expect(screen.getByText('dataset.externalAPIPanelDescription')).toBeInTheDocument()
})
it('should render documentation link', () => {
render(<ExternalAPIPanel {...defaultProps} />)
const docLink = screen.getByText('dataset.externalAPIPanelDocumentation')
expect(docLink).toBeInTheDocument()
expect(docLink.closest('a')).toHaveAttribute('href', 'https://docs.example.com/guides/knowledge-base/connect-external-knowledge-base')
})
it('should render create button', () => {
render(<ExternalAPIPanel {...defaultProps} />)
expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument()
})
it('should render close button', () => {
const { container } = render(<ExternalAPIPanel {...defaultProps} />)
const closeButton = container.querySelector('[class*="action-button"]') || screen.getAllByRole('button')[0]
expect(closeButton).toBeInTheDocument()
})
})
describe('Loading State', () => {
it('should render loading indicator when isLoading is true', () => {
mockIsLoading = true
const { container } = render(<ExternalAPIPanel {...defaultProps} />)
// Loading component should be rendered
const loadingElement = container.querySelector('[class*="loading"]')
|| container.querySelector('.animate-spin')
|| screen.queryByRole('status')
expect(loadingElement || container.textContent).toBeTruthy()
})
})
describe('API List Rendering', () => {
it('should render empty list when no APIs exist', () => {
mockExternalKnowledgeApiList = []
render(<ExternalAPIPanel {...defaultProps} />)
expect(screen.queryByTestId(/api-card-/)).not.toBeInTheDocument()
})
it('should render API cards when APIs exist', () => {
mockExternalKnowledgeApiList = [
{
id: 'api-1',
tenant_id: 'tenant-1',
name: 'Test API 1',
description: '',
settings: { endpoint: 'https://api1.example.com', api_key: 'key1' },
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
},
{
id: 'api-2',
tenant_id: 'tenant-1',
name: 'Test API 2',
description: '',
settings: { endpoint: 'https://api2.example.com', api_key: 'key2' },
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
},
]
render(<ExternalAPIPanel {...defaultProps} />)
expect(screen.getByTestId('api-card-api-1')).toBeInTheDocument()
expect(screen.getByTestId('api-card-api-2')).toBeInTheDocument()
expect(screen.getByText('Test API 1')).toBeInTheDocument()
expect(screen.getByText('Test API 2')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClose when close button is clicked', () => {
const onClose = vi.fn()
render(<ExternalAPIPanel onClose={onClose} />)
// Find the close button (ActionButton with close icon)
const buttons = screen.getAllByRole('button')
const closeButton = buttons.find(btn => btn.querySelector('svg[class*="ri-close"]'))
|| buttons[0]
fireEvent.click(closeButton)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should open external API modal when create button is clicked', async () => {
render(<ExternalAPIPanel {...defaultProps} />)
const createButton = screen.getByText('dataset.createExternalAPI').closest('button')!
fireEvent.click(createButton)
await waitFor(() => {
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledTimes(1)
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledWith(
expect.objectContaining({
payload: { name: '', settings: { endpoint: '', api_key: '' } },
datasetBindings: [],
isEditMode: false,
}),
)
})
})
it('should call mutateExternalKnowledgeApis in onSaveCallback', async () => {
render(<ExternalAPIPanel {...defaultProps} />)
const createButton = screen.getByText('dataset.createExternalAPI').closest('button')!
fireEvent.click(createButton)
const callArgs = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0]
callArgs.onSaveCallback()
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
})
it('should call mutateExternalKnowledgeApis in onCancelCallback', async () => {
render(<ExternalAPIPanel {...defaultProps} />)
const createButton = screen.getByText('dataset.createExternalAPI').closest('button')!
fireEvent.click(createButton)
const callArgs = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0]
callArgs.onCancelCallback()
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should handle single API in list', () => {
mockExternalKnowledgeApiList = [
{
id: 'single-api',
tenant_id: 'tenant-1',
name: 'Single API',
description: '',
settings: { endpoint: 'https://single.example.com', api_key: 'key' },
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
},
]
render(<ExternalAPIPanel {...defaultProps} />)
expect(screen.getByTestId('api-card-single-api')).toBeInTheDocument()
})
it('should render documentation link with correct target', () => {
render(<ExternalAPIPanel {...defaultProps} />)
const docLink = screen.getByText('dataset.externalAPIPanelDocumentation').closest('a')
expect(docLink).toHaveAttribute('target', '_blank')
})
})
})

View File

@@ -0,0 +1,382 @@
import type { ExternalAPIItem } from '@/models/datasets'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Import mocked services
import { checkUsageExternalAPI, deleteExternalAPI, fetchExternalAPI } from '@/service/datasets'
import ExternalKnowledgeAPICard from './index'
// Mock API services
vi.mock('@/service/datasets', () => ({
fetchExternalAPI: vi.fn(),
updateExternalAPI: vi.fn(),
deleteExternalAPI: vi.fn(),
checkUsageExternalAPI: vi.fn(),
}))
// Mock contexts
const mockSetShowExternalKnowledgeAPIModal = vi.fn()
const mockMutateExternalKnowledgeApis = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowExternalKnowledgeAPIModal: mockSetShowExternalKnowledgeAPIModal,
}),
}))
vi.mock('@/context/external-knowledge-api-context', () => ({
useExternalKnowledgeApi: () => ({
mutateExternalKnowledgeApis: mockMutateExternalKnowledgeApis,
}),
}))
describe('ExternalKnowledgeAPICard', () => {
const mockApi: ExternalAPIItem = {
id: 'api-123',
tenant_id: 'tenant-1',
name: 'Test External API',
description: 'Test API description',
settings: {
endpoint: 'https://api.example.com/knowledge',
api_key: 'secret-key-123',
},
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
}
const defaultProps = {
api: mockApi,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<ExternalKnowledgeAPICard {...defaultProps} />)
expect(screen.getByText('Test External API')).toBeInTheDocument()
})
it('should render API name', () => {
render(<ExternalKnowledgeAPICard {...defaultProps} />)
expect(screen.getByText('Test External API')).toBeInTheDocument()
})
it('should render API endpoint', () => {
render(<ExternalKnowledgeAPICard {...defaultProps} />)
expect(screen.getByText('https://api.example.com/knowledge')).toBeInTheDocument()
})
it('should render edit and delete buttons', () => {
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const buttons = container.querySelectorAll('button')
expect(buttons.length).toBe(2)
})
it('should render API connection icon', () => {
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})
})
describe('User Interactions - Edit', () => {
it('should fetch API details and open modal when edit button is clicked', async () => {
const mockResponse: ExternalAPIItem = {
id: 'api-123',
tenant_id: 'tenant-1',
name: 'Test External API',
description: 'Test API description',
settings: {
endpoint: 'https://api.example.com/knowledge',
api_key: 'secret-key-123',
},
dataset_bindings: [{ id: 'ds-1', name: 'Dataset 1' }],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
}
vi.mocked(fetchExternalAPI).mockResolvedValue(mockResponse)
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const buttons = container.querySelectorAll('button')
const editButton = buttons[0]
fireEvent.click(editButton)
await waitFor(() => {
expect(fetchExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' })
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledWith(
expect.objectContaining({
payload: {
name: 'Test External API',
settings: {
endpoint: 'https://api.example.com/knowledge',
api_key: 'secret-key-123',
},
},
isEditMode: true,
datasetBindings: [{ id: 'ds-1', name: 'Dataset 1' }],
}),
)
})
})
it('should handle fetch error gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(fetchExternalAPI).mockRejectedValue(new Error('Fetch failed'))
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const buttons = container.querySelectorAll('button')
const editButton = buttons[0]
fireEvent.click(editButton)
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith(
'Error fetching external knowledge API data:',
expect.any(Error),
)
})
consoleSpy.mockRestore()
})
it('should call mutate on save callback', async () => {
const mockResponse: ExternalAPIItem = {
id: 'api-123',
tenant_id: 'tenant-1',
name: 'Test External API',
description: 'Test API description',
settings: {
endpoint: 'https://api.example.com/knowledge',
api_key: 'secret-key-123',
},
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
}
vi.mocked(fetchExternalAPI).mockResolvedValue(mockResponse)
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const editButton = container.querySelectorAll('button')[0]
fireEvent.click(editButton)
await waitFor(() => {
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalled()
})
// Simulate save callback
const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0]
modalCall.onSaveCallback()
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
})
it('should call mutate on cancel callback', async () => {
const mockResponse: ExternalAPIItem = {
id: 'api-123',
tenant_id: 'tenant-1',
name: 'Test External API',
description: 'Test API description',
settings: {
endpoint: 'https://api.example.com/knowledge',
api_key: 'secret-key-123',
},
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
}
vi.mocked(fetchExternalAPI).mockResolvedValue(mockResponse)
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const editButton = container.querySelectorAll('button')[0]
fireEvent.click(editButton)
await waitFor(() => {
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalled()
})
// Simulate cancel callback
const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0]
modalCall.onCancelCallback()
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
})
})
describe('User Interactions - Delete', () => {
it('should check usage and show confirm dialog when delete button is clicked', async () => {
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const buttons = container.querySelectorAll('button')
const deleteButton = buttons[1]
fireEvent.click(deleteButton)
await waitFor(() => {
expect(checkUsageExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' })
})
// Confirm dialog should be shown
await waitFor(() => {
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
})
})
it('should show usage count in confirm dialog when API is in use', async () => {
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: true, count: 3 })
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = container.querySelectorAll('button')[1]
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByText(/3/)).toBeInTheDocument()
})
})
it('should delete API and refresh list when confirmed', async () => {
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
vi.mocked(deleteExternalAPI).mockResolvedValue({ result: 'success' })
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = container.querySelectorAll('button')[1]
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
})
const confirmButton = screen.getByRole('button', { name: /confirm/i })
fireEvent.click(confirmButton)
await waitFor(() => {
expect(deleteExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' })
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
})
})
it('should close confirm dialog when cancel is clicked', async () => {
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = container.querySelectorAll('button')[1]
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
})
const cancelButton = screen.getByRole('button', { name: /cancel/i })
fireEvent.click(cancelButton)
await waitFor(() => {
expect(screen.queryByRole('button', { name: /confirm/i })).not.toBeInTheDocument()
})
})
it('should handle delete error gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
vi.mocked(deleteExternalAPI).mockRejectedValue(new Error('Delete failed'))
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = container.querySelectorAll('button')[1]
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
})
const confirmButton = screen.getByRole('button', { name: /confirm/i })
fireEvent.click(confirmButton)
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith(
'Error deleting external knowledge API:',
expect.any(Error),
)
})
consoleSpy.mockRestore()
})
it('should handle check usage error gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(checkUsageExternalAPI).mockRejectedValue(new Error('Check failed'))
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = container.querySelectorAll('button')[1]
fireEvent.click(deleteButton)
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith(
'Error checking external API usage:',
expect.any(Error),
)
})
consoleSpy.mockRestore()
})
})
describe('Hover State', () => {
it('should apply hover styles when delete button is hovered', () => {
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = container.querySelectorAll('button')[1]
const cardContainer = container.querySelector('[class*="shadows-shadow"]')
fireEvent.mouseEnter(deleteButton)
expect(cardContainer).toHaveClass('border-state-destructive-border')
expect(cardContainer).toHaveClass('bg-state-destructive-hover')
fireEvent.mouseLeave(deleteButton)
expect(cardContainer).not.toHaveClass('border-state-destructive-border')
})
})
describe('Edge Cases', () => {
it('should handle API with empty endpoint', () => {
const apiWithEmptyEndpoint: ExternalAPIItem = {
...mockApi,
settings: { endpoint: '', api_key: 'key' },
}
render(<ExternalKnowledgeAPICard api={apiWithEmptyEndpoint} />)
expect(screen.getByText('Test External API')).toBeInTheDocument()
})
it('should handle delete response with unsuccessful result', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
vi.mocked(deleteExternalAPI).mockResolvedValue({ result: 'error' })
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = container.querySelectorAll('button')[1]
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
})
const confirmButton = screen.getByRole('button', { name: /confirm/i })
fireEvent.click(confirmButton)
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith('Failed to delete external API')
})
consoleSpy.mockRestore()
})
})
})

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,125 @@
import type { DataSet } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import CornerLabels from './corner-labels'
describe('CornerLabels', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
created_at: 1609459200,
updated_at: 1609545600,
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
runtime_mode: 'general',
...overrides,
} as DataSet)
describe('Rendering', () => {
it('should render without crashing when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
const { container } = render(<CornerLabels dataset={dataset} />)
// Should render null when embedding is available and not pipeline
expect(container.firstChild).toBeNull()
})
it('should render unavailable label when embedding is not available', () => {
const dataset = createMockDataset({ embedding_available: false })
render(<CornerLabels dataset={dataset} />)
expect(screen.getByText(/unavailable/i)).toBeInTheDocument()
})
it('should render pipeline label when runtime_mode is rag_pipeline', () => {
const dataset = createMockDataset({
embedding_available: true,
runtime_mode: 'rag_pipeline',
})
render(<CornerLabels dataset={dataset} />)
expect(screen.getByText(/pipeline/i)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should not render when embedding is available and not pipeline', () => {
const dataset = createMockDataset({
embedding_available: true,
runtime_mode: 'general',
})
const { container } = render(<CornerLabels dataset={dataset} />)
expect(container.firstChild).toBeNull()
})
it('should prioritize unavailable label over pipeline label', () => {
const dataset = createMockDataset({
embedding_available: false,
runtime_mode: 'rag_pipeline',
})
render(<CornerLabels dataset={dataset} />)
// Should show unavailable since embedding_available is checked first
expect(screen.getByText(/unavailable/i)).toBeInTheDocument()
expect(screen.queryByText(/pipeline/i)).not.toBeInTheDocument()
})
})
describe('Styles', () => {
it('should have correct positioning for unavailable label', () => {
const dataset = createMockDataset({ embedding_available: false })
const { container } = render(<CornerLabels dataset={dataset} />)
const labelContainer = container.firstChild as HTMLElement
expect(labelContainer).toHaveClass('absolute', 'right-0', 'top-0', 'z-10')
})
it('should have correct positioning for pipeline label', () => {
const dataset = createMockDataset({
embedding_available: true,
runtime_mode: 'rag_pipeline',
})
const { container } = render(<CornerLabels dataset={dataset} />)
const labelContainer = container.firstChild as HTMLElement
expect(labelContainer).toHaveClass('absolute', 'right-0', 'top-0', 'z-10')
})
})
describe('Edge Cases', () => {
it('should handle undefined runtime_mode', () => {
const dataset = createMockDataset({
embedding_available: true,
runtime_mode: undefined,
})
const { container } = render(<CornerLabels dataset={dataset} />)
expect(container.firstChild).toBeNull()
})
it('should handle empty string runtime_mode', () => {
const dataset = createMockDataset({
embedding_available: true,
runtime_mode: '' as DataSet['runtime_mode'],
})
const { container } = render(<CornerLabels dataset={dataset} />)
expect(container.firstChild).toBeNull()
})
it('should handle all false conditions', () => {
const dataset = createMockDataset({
embedding_available: true,
runtime_mode: 'general',
})
const { container } = render(<CornerLabels dataset={dataset} />)
expect(container.firstChild).toBeNull()
})
})
})

View File

@@ -0,0 +1,177 @@
import type { DataSet } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import DatasetCardFooter from './dataset-card-footer'
// Mock the useFormatTimeFromNow hook
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: vi.fn((timestamp: number) => {
const date = new Date(timestamp)
return `${date.toLocaleDateString()}`
}),
}),
}))
describe('DatasetCardFooter', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
created_at: 1609459200,
updated_at: 1609545600,
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
total_available_documents: 10,
...overrides,
} as DataSet)
describe('Rendering', () => {
it('should render without crashing', () => {
const dataset = createMockDataset()
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('10')).toBeInTheDocument()
})
it('should render document count', () => {
const dataset = createMockDataset({ document_count: 25, total_available_documents: 25 })
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('25')).toBeInTheDocument()
})
it('should render app count for non-external provider', () => {
const dataset = createMockDataset({ app_count: 8, provider: 'vendor' })
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('8')).toBeInTheDocument()
})
it('should not render app count for external provider', () => {
const dataset = createMockDataset({ app_count: 8, provider: 'external' })
render(<DatasetCardFooter dataset={dataset} />)
// App count should not be rendered
const appCounts = screen.queryAllByText('8')
expect(appCounts.length).toBe(0)
})
it('should render update time', () => {
const dataset = createMockDataset()
render(<DatasetCardFooter dataset={dataset} />)
// Check for "updated" text with i18n key
expect(screen.getByText(/updated/)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should show partial document count when total_available_documents < document_count', () => {
const dataset = createMockDataset({
document_count: 20,
total_available_documents: 15,
})
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('15 / 20')).toBeInTheDocument()
})
it('should show full document count when all documents are available', () => {
const dataset = createMockDataset({
document_count: 20,
total_available_documents: 20,
})
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('20')).toBeInTheDocument()
})
it('should handle zero documents', () => {
const dataset = createMockDataset({
document_count: 0,
total_available_documents: 0,
})
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('0')).toBeInTheDocument()
})
})
describe('Styles', () => {
it('should have correct base styling when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
const { container } = render(<DatasetCardFooter dataset={dataset} />)
const footer = container.firstChild as HTMLElement
expect(footer).toHaveClass('flex', 'items-center', 'gap-x-3', 'px-4')
})
it('should have opacity class when embedding is not available', () => {
const dataset = createMockDataset({ embedding_available: false })
const { container } = render(<DatasetCardFooter dataset={dataset} />)
const footer = container.firstChild as HTMLElement
expect(footer).toHaveClass('opacity-30')
})
it('should not have opacity class when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
const { container } = render(<DatasetCardFooter dataset={dataset} />)
const footer = container.firstChild as HTMLElement
expect(footer).not.toHaveClass('opacity-30')
})
})
describe('Icons', () => {
it('should render document icon', () => {
const dataset = createMockDataset()
const { container } = render(<DatasetCardFooter dataset={dataset} />)
// RiFileTextFill icon
const icons = container.querySelectorAll('svg')
expect(icons.length).toBeGreaterThanOrEqual(1)
})
it('should render robot icon for non-external provider', () => {
const dataset = createMockDataset({ provider: 'vendor' })
const { container } = render(<DatasetCardFooter dataset={dataset} />)
// Should have both file and robot icons
const icons = container.querySelectorAll('svg')
expect(icons.length).toBe(2)
})
})
describe('Edge Cases', () => {
it('should handle undefined total_available_documents', () => {
const dataset = createMockDataset({
document_count: 10,
total_available_documents: undefined,
})
render(<DatasetCardFooter dataset={dataset} />)
// Should show 0 / 10 since total_available_documents defaults to 0
expect(screen.getByText('0 / 10')).toBeInTheDocument()
})
it('should handle very large numbers', () => {
const dataset = createMockDataset({
document_count: 999999,
total_available_documents: 999999,
app_count: 888888,
})
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('999999')).toBeInTheDocument()
expect(screen.getByText('888888')).toBeInTheDocument()
})
it('should handle zero app count', () => {
const dataset = createMockDataset({ app_count: 0, document_count: 5, total_available_documents: 5 })
render(<DatasetCardFooter dataset={dataset} />)
// Both document count and app count are shown
const zeros = screen.getAllByText('0')
expect(zeros.length).toBeGreaterThanOrEqual(1)
})
})
})

View File

@@ -0,0 +1,254 @@
import type { DataSet } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import DatasetCardHeader from './dataset-card-header'
// Mock useFormatTimeFromNow hook
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: (timestamp: number) => {
const date = new Date(timestamp)
return date.toLocaleDateString()
},
}),
}))
// Mock useKnowledge hook
vi.mock('@/hooks/use-knowledge', () => ({
useKnowledge: () => ({
formatIndexingTechniqueAndMethod: (technique: string, _method: string) => {
if (technique === 'high_quality')
return 'High Quality'
if (technique === 'economy')
return 'Economy'
return ''
},
}),
}))
describe('DatasetCardHeader', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
indexing_status: 'completed',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
total_document_count: 10,
word_count: 1000,
updated_at: 1609545600,
updated_by: 'user-1',
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
runtime_mode: 'general',
is_published: true,
enable_api: true,
is_multimodal: false,
built_in_field_enabled: false,
icon_info: {
icon: '📙',
icon_type: 'emoji' as const,
icon_background: '#FFF4ED',
icon_url: '',
},
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
} as DataSet['retrieval_model_dict'],
retrieval_model: {
search_method: RETRIEVE_METHOD.semantic,
} as DataSet['retrieval_model'],
external_knowledge_info: {
external_knowledge_id: '',
external_knowledge_api_id: '',
external_knowledge_api_name: '',
external_knowledge_api_endpoint: '',
},
external_retrieval_model: {
top_k: 3,
score_threshold: 0.5,
score_threshold_enabled: false,
},
author_name: 'Test User',
...overrides,
})
describe('Rendering', () => {
it('should render without crashing', () => {
const dataset = createMockDataset()
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should render dataset name', () => {
const dataset = createMockDataset({ name: 'Custom Dataset' })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('Custom Dataset')).toBeInTheDocument()
})
it('should render author name', () => {
const dataset = createMockDataset({ author_name: 'John Doe' })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
it('should render edit time', () => {
const dataset = createMockDataset()
render(<DatasetCardHeader dataset={dataset} />)
// Should contain the formatted time
expect(screen.getByText(/segment\.editedAt/)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should show external knowledge base text for external provider', () => {
const dataset = createMockDataset({ provider: 'external' })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText(/externalKnowledgeBase/)).toBeInTheDocument()
})
it('should show chunking mode for text_model doc_form', () => {
const dataset = createMockDataset({ doc_form: ChunkingMode.text })
render(<DatasetCardHeader dataset={dataset} />)
// text_model maps to 'general' in DOC_FORM_TEXT
expect(screen.getByText(/chunkingMode\.general/)).toBeInTheDocument()
})
it('should show multimodal text when is_multimodal is true', () => {
const dataset = createMockDataset({ is_multimodal: true })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText(/multimodal/)).toBeInTheDocument()
})
it('should not show multimodal when is_multimodal is false', () => {
const dataset = createMockDataset({ is_multimodal: false })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.queryByText(/^multimodal$/)).not.toBeInTheDocument()
})
})
describe('Icon', () => {
it('should render AppIcon component', () => {
const dataset = createMockDataset()
const { container } = render(<DatasetCardHeader dataset={dataset} />)
// AppIcon should be rendered
const iconContainer = container.querySelector('.relative.shrink-0')
expect(iconContainer).toBeInTheDocument()
})
it('should use default icon when icon_info is missing', () => {
const dataset = createMockDataset({ icon_info: undefined })
render(<DatasetCardHeader dataset={dataset} />)
// Should still render without crashing
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should render chunking mode icon for published pipeline', () => {
const dataset = createMockDataset({
doc_form: ChunkingMode.text,
runtime_mode: 'rag_pipeline',
is_published: true,
})
const { container } = render(<DatasetCardHeader dataset={dataset} />)
// Should have the icon badge
const iconBadge = container.querySelector('.absolute.-bottom-1.-right-1')
expect(iconBadge).toBeInTheDocument()
})
})
describe('Styles', () => {
it('should have opacity class when embedding is not available', () => {
const dataset = createMockDataset({ embedding_available: false })
const { container } = render(<DatasetCardHeader dataset={dataset} />)
const header = container.firstChild as HTMLElement
expect(header).toHaveClass('opacity-30')
})
it('should not have opacity class when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
const { container } = render(<DatasetCardHeader dataset={dataset} />)
const header = container.firstChild as HTMLElement
expect(header).not.toHaveClass('opacity-30')
})
it('should have correct base styling', () => {
const dataset = createMockDataset()
const { container } = render(<DatasetCardHeader dataset={dataset} />)
const header = container.firstChild as HTMLElement
expect(header).toHaveClass('flex', 'items-center', 'gap-x-3', 'px-4')
})
})
describe('DocModeInfo', () => {
it('should show doc mode info when all conditions are met', () => {
const dataset = createMockDataset({
doc_form: ChunkingMode.text,
indexing_technique: IndexingType.QUALIFIED,
retrieval_model_dict: { search_method: RETRIEVE_METHOD.semantic } as DataSet['retrieval_model_dict'],
runtime_mode: 'general',
})
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText(/chunkingMode/)).toBeInTheDocument()
})
it('should not show doc mode info for unpublished pipeline', () => {
const dataset = createMockDataset({
runtime_mode: 'rag_pipeline',
is_published: false,
})
render(<DatasetCardHeader dataset={dataset} />)
// DocModeInfo should not be rendered since isShowDocModeInfo is false
expect(screen.queryByText(/High Quality/)).not.toBeInTheDocument()
})
it('should show doc mode info for published pipeline', () => {
const dataset = createMockDataset({
doc_form: ChunkingMode.text,
indexing_technique: IndexingType.QUALIFIED,
retrieval_model_dict: { search_method: RETRIEVE_METHOD.semantic } as DataSet['retrieval_model_dict'],
runtime_mode: 'rag_pipeline',
is_published: true,
})
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText(/chunkingMode/)).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle missing author_name', () => {
const dataset = createMockDataset({ author_name: undefined })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should handle empty name', () => {
const dataset = createMockDataset({ name: '' })
render(<DatasetCardHeader dataset={dataset} />)
// Should render without crashing
const { container } = render(<DatasetCardHeader dataset={dataset} />)
expect(container).toBeInTheDocument()
})
it('should handle missing retrieval_model_dict', () => {
const dataset = createMockDataset({ retrieval_model_dict: undefined })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should handle undefined doc_form', () => {
const dataset = createMockDataset({ doc_form: undefined })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,237 @@
import type { DataSet } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import DatasetCardModals from './dataset-card-modals'
// Mock RenameDatasetModal since it's from a different feature folder
vi.mock('../../../rename-modal', () => ({
default: ({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess?: () => void }) => (
show
? (
<div data-testid="rename-modal">
<button onClick={onClose}>Close Rename</button>
<button onClick={onSuccess}>Success</button>
</div>
)
: null
),
}))
describe('DatasetCardModals', () => {
const mockDataset: DataSet = {
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
indexing_status: 'completed',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
total_document_count: 10,
word_count: 1000,
updated_at: 1609545600,
updated_by: 'user-1',
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
runtime_mode: 'general',
enable_api: true,
is_multimodal: false,
built_in_field_enabled: false,
icon_info: {
icon: '📙',
icon_type: 'emoji' as const,
icon_background: '#FFF4ED',
icon_url: '',
},
retrieval_model_dict: {} as DataSet['retrieval_model_dict'],
retrieval_model: {} as DataSet['retrieval_model'],
external_knowledge_info: {
external_knowledge_id: '',
external_knowledge_api_id: '',
external_knowledge_api_name: '',
external_knowledge_api_endpoint: '',
},
external_retrieval_model: {
top_k: 3,
score_threshold: 0.5,
score_threshold_enabled: false,
},
}
const defaultProps = {
dataset: mockDataset,
modalState: {
showRenameModal: false,
showConfirmDelete: false,
confirmMessage: '',
},
onCloseRename: vi.fn(),
onCloseConfirm: vi.fn(),
onConfirmDelete: vi.fn(),
onSuccess: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing when no modals are shown', () => {
const { container } = render(<DatasetCardModals {...defaultProps} />)
// Should render empty fragment
expect(container.innerHTML).toBe('')
})
it('should render rename modal when showRenameModal is true', () => {
render(
<DatasetCardModals
{...defaultProps}
modalState={{ ...defaultProps.modalState, showRenameModal: true }}
/>,
)
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
})
it('should render confirm modal when showConfirmDelete is true', () => {
render(
<DatasetCardModals
{...defaultProps}
modalState={{
...defaultProps.modalState,
showConfirmDelete: true,
confirmMessage: 'Are you sure?',
}}
/>,
)
// Confirm modal should be rendered
expect(screen.getByText('Are you sure?')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass dataset to rename modal', () => {
render(
<DatasetCardModals
{...defaultProps}
modalState={{ ...defaultProps.modalState, showRenameModal: true }}
/>,
)
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
})
it('should display confirmMessage in confirm modal', () => {
const confirmMessage = 'This is a custom confirm message'
render(
<DatasetCardModals
{...defaultProps}
modalState={{
...defaultProps.modalState,
showConfirmDelete: true,
confirmMessage,
}}
/>,
)
expect(screen.getByText(confirmMessage)).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onCloseRename when closing rename modal', () => {
const onCloseRename = vi.fn()
render(
<DatasetCardModals
{...defaultProps}
onCloseRename={onCloseRename}
modalState={{ ...defaultProps.modalState, showRenameModal: true }}
/>,
)
fireEvent.click(screen.getByText('Close Rename'))
expect(onCloseRename).toHaveBeenCalledTimes(1)
})
it('should call onConfirmDelete when confirming deletion', () => {
const onConfirmDelete = vi.fn()
render(
<DatasetCardModals
{...defaultProps}
onConfirmDelete={onConfirmDelete}
modalState={{
...defaultProps.modalState,
showConfirmDelete: true,
confirmMessage: 'Delete?',
}}
/>,
)
// Find and click the confirm button
const confirmButton = screen.getByRole('button', { name: /confirm|ok|delete/i })
|| screen.getAllByRole('button').find(btn => btn.textContent?.toLowerCase().includes('confirm'))
if (confirmButton)
fireEvent.click(confirmButton)
expect(onConfirmDelete).toHaveBeenCalledTimes(1)
})
it('should call onCloseConfirm when canceling deletion', () => {
const onCloseConfirm = vi.fn()
render(
<DatasetCardModals
{...defaultProps}
onCloseConfirm={onCloseConfirm}
modalState={{
...defaultProps.modalState,
showConfirmDelete: true,
confirmMessage: 'Delete?',
}}
/>,
)
// Find and click the cancel button
const cancelButton = screen.getByRole('button', { name: /cancel/i })
fireEvent.click(cancelButton)
expect(onCloseConfirm).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {
it('should handle both modals being true (render both)', () => {
render(
<DatasetCardModals
{...defaultProps}
modalState={{
showRenameModal: true,
showConfirmDelete: true,
confirmMessage: 'Delete this dataset?',
}}
/>,
)
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
expect(screen.getByText('Delete this dataset?')).toBeInTheDocument()
})
it('should handle empty confirmMessage', () => {
render(
<DatasetCardModals
{...defaultProps}
modalState={{
...defaultProps.modalState,
showConfirmDelete: true,
confirmMessage: '',
}}
/>,
)
// Should still render confirm modal
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,107 @@
import type { DataSet } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import Description from './description'
describe('Description', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'This is a test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
created_at: 1609459200,
updated_at: 1609545600,
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
...overrides,
} as DataSet)
describe('Rendering', () => {
it('should render without crashing', () => {
const dataset = createMockDataset()
render(<Description dataset={dataset} />)
expect(screen.getByText('This is a test description')).toBeInTheDocument()
})
it('should render the description text', () => {
const dataset = createMockDataset({ description: 'Custom description text' })
render(<Description dataset={dataset} />)
expect(screen.getByText('Custom description text')).toBeInTheDocument()
})
it('should set title attribute for tooltip', () => {
const dataset = createMockDataset({ description: 'Tooltip description' })
render(<Description dataset={dataset} />)
const descDiv = screen.getByTitle('Tooltip description')
expect(descDiv).toBeInTheDocument()
})
})
describe('Props', () => {
it('should display dataset description', () => {
const description = 'A very detailed description of this dataset'
const dataset = createMockDataset({ description })
render(<Description dataset={dataset} />)
expect(screen.getByText(description)).toBeInTheDocument()
})
})
describe('Styles', () => {
it('should have correct base styling when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
render(<Description dataset={dataset} />)
const descDiv = screen.getByTitle(dataset.description)
expect(descDiv).toHaveClass('system-xs-regular', 'line-clamp-2', 'h-10', 'px-4', 'py-1', 'text-text-tertiary')
})
it('should have opacity class when embedding is not available', () => {
const dataset = createMockDataset({ embedding_available: false })
render(<Description dataset={dataset} />)
const descDiv = screen.getByTitle(dataset.description)
expect(descDiv).toHaveClass('opacity-30')
})
it('should not have opacity class when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
render(<Description dataset={dataset} />)
const descDiv = screen.getByTitle(dataset.description)
expect(descDiv).not.toHaveClass('opacity-30')
})
})
describe('Edge Cases', () => {
it('should handle empty description', () => {
const dataset = createMockDataset({ description: '' })
render(<Description dataset={dataset} />)
const descDiv = screen.getByTitle('')
expect(descDiv).toBeInTheDocument()
expect(descDiv).toHaveTextContent('')
})
it('should handle very long description', () => {
const longDescription = 'A'.repeat(500)
const dataset = createMockDataset({ description: longDescription })
render(<Description dataset={dataset} />)
expect(screen.getByText(longDescription)).toBeInTheDocument()
})
it('should handle description with special characters', () => {
const description = '<script>alert("XSS")</script> & "quotes" \'single\''
const dataset = createMockDataset({ description })
render(<Description dataset={dataset} />)
expect(screen.getByText(description)).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,162 @@
import type { DataSet } from '@/models/datasets'
import { fireEvent, render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import OperationsPopover from './operations-popover'
describe('OperationsPopover', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
updated_at: 1609545600,
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
runtime_mode: 'general',
...overrides,
} as DataSet)
const defaultProps = {
dataset: createMockDataset(),
isCurrentWorkspaceDatasetOperator: false,
openRenameModal: vi.fn(),
handleExportPipeline: vi.fn(),
detectIsUsedByApp: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<OperationsPopover {...defaultProps} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render the more icon button', () => {
const { container } = render(<OperationsPopover {...defaultProps} />)
const moreIcon = container.querySelector('svg')
expect(moreIcon).toBeInTheDocument()
})
it('should render in hidden state initially (group-hover)', () => {
const { container } = render(<OperationsPopover {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('hidden', 'group-hover:block')
})
})
describe('Props', () => {
it('should show delete option when not workspace dataset operator', () => {
render(<OperationsPopover {...defaultProps} isCurrentWorkspaceDatasetOperator={false} />)
// Click to open popover
const triggerButton = document.querySelector('[class*="cursor-pointer"]')
if (triggerButton)
fireEvent.click(triggerButton)
// showDelete should be true (inverse of isCurrentWorkspaceDatasetOperator)
// This means delete operation will be visible
})
it('should hide delete option when is workspace dataset operator', () => {
render(<OperationsPopover {...defaultProps} isCurrentWorkspaceDatasetOperator={true} />)
// Click to open popover
const triggerButton = document.querySelector('[class*="cursor-pointer"]')
if (triggerButton)
fireEvent.click(triggerButton)
// showDelete should be false
})
it('should show export pipeline when runtime_mode is rag_pipeline', () => {
const dataset = createMockDataset({ runtime_mode: 'rag_pipeline' })
render(<OperationsPopover {...defaultProps} dataset={dataset} />)
// Click to open popover
const triggerButton = document.querySelector('[class*="cursor-pointer"]')
if (triggerButton)
fireEvent.click(triggerButton)
// showExportPipeline should be true
})
it('should hide export pipeline when runtime_mode is not rag_pipeline', () => {
const dataset = createMockDataset({ runtime_mode: 'general' })
render(<OperationsPopover {...defaultProps} dataset={dataset} />)
// Click to open popover
const triggerButton = document.querySelector('[class*="cursor-pointer"]')
if (triggerButton)
fireEvent.click(triggerButton)
// showExportPipeline should be false
})
})
describe('Styles', () => {
it('should have correct positioning styles', () => {
const { container } = render(<OperationsPopover {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('absolute', 'right-2', 'top-2', 'z-[15]')
})
it('should have icon with correct size classes', () => {
const { container } = render(<OperationsPopover {...defaultProps} />)
const icon = container.querySelector('svg')
expect(icon).toHaveClass('h-5', 'w-5', 'text-text-tertiary')
})
})
describe('User Interactions', () => {
it('should pass openRenameModal to Operations', () => {
const openRenameModal = vi.fn()
render(<OperationsPopover {...defaultProps} openRenameModal={openRenameModal} />)
// The openRenameModal should be passed to Operations component
expect(openRenameModal).not.toHaveBeenCalled() // Initially not called
})
it('should pass handleExportPipeline to Operations', () => {
const handleExportPipeline = vi.fn()
render(<OperationsPopover {...defaultProps} handleExportPipeline={handleExportPipeline} />)
expect(handleExportPipeline).not.toHaveBeenCalled()
})
it('should pass detectIsUsedByApp to Operations', () => {
const detectIsUsedByApp = vi.fn()
render(<OperationsPopover {...defaultProps} detectIsUsedByApp={detectIsUsedByApp} />)
expect(detectIsUsedByApp).not.toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should handle dataset with external provider', () => {
const dataset = createMockDataset({ provider: 'external' })
const { container } = render(<OperationsPopover {...defaultProps} dataset={dataset} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle dataset with undefined runtime_mode', () => {
const dataset = createMockDataset({ runtime_mode: undefined })
const { container } = render(<OperationsPopover {...defaultProps} dataset={dataset} />)
expect(container.firstChild).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,198 @@
import type { Tag } from '@/app/components/base/tag-management/constant'
import type { DataSet } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { useRef } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import TagArea from './tag-area'
// Mock TagSelector as it's a complex component from base
vi.mock('@/app/components/base/tag-management/selector', () => ({
default: ({ value, selectedTags, onCacheUpdate, onChange }: {
value: string[]
selectedTags: Tag[]
onCacheUpdate: (tags: Tag[]) => void
onChange?: () => void
}) => (
<div data-testid="tag-selector">
<div data-testid="tag-values">{value.join(',')}</div>
<div data-testid="selected-count">
{selectedTags.length}
{' '}
tags
</div>
<button onClick={() => onCacheUpdate([{ id: 'new-tag', name: 'New Tag', type: 'knowledge', binding_count: 0 }])}>
Update Tags
</button>
<button onClick={onChange}>
Trigger Change
</button>
</div>
),
}))
describe('TagArea', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
updated_at: 1609545600,
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
...overrides,
} as DataSet)
const mockTags: Tag[] = [
{ id: 'tag-1', name: 'Tag 1', type: 'knowledge', binding_count: 0 },
{ id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 },
]
const defaultProps = {
dataset: createMockDataset(),
tags: mockTags,
setTags: vi.fn(),
onSuccess: vi.fn(),
isHoveringTagSelector: false,
onClick: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<TagArea {...defaultProps} />)
expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
})
it('should render TagSelector with correct value', () => {
render(<TagArea {...defaultProps} />)
expect(screen.getByTestId('tag-values')).toHaveTextContent('tag-1,tag-2')
})
it('should display selected tags count', () => {
render(<TagArea {...defaultProps} />)
expect(screen.getByTestId('selected-count')).toHaveTextContent('2 tags')
})
})
describe('Props', () => {
it('should pass dataset id to TagSelector', () => {
const dataset = createMockDataset({ id: 'custom-dataset-id' })
render(<TagArea {...defaultProps} dataset={dataset} />)
expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
})
it('should render with empty tags', () => {
render(<TagArea {...defaultProps} tags={[]} />)
expect(screen.getByTestId('selected-count')).toHaveTextContent('0 tags')
})
it('should forward ref correctly', () => {
const TestComponent = () => {
const ref = useRef<HTMLDivElement>(null)
return <TagArea {...defaultProps} ref={ref} />
}
render(<TestComponent />)
expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClick when container is clicked', () => {
const onClick = vi.fn()
const { container } = render(<TagArea {...defaultProps} onClick={onClick} />)
const wrapper = container.firstChild as HTMLElement
fireEvent.click(wrapper)
expect(onClick).toHaveBeenCalledTimes(1)
})
it('should call setTags when tags are updated', () => {
const setTags = vi.fn()
render(<TagArea {...defaultProps} setTags={setTags} />)
fireEvent.click(screen.getByText('Update Tags'))
expect(setTags).toHaveBeenCalledWith([{ id: 'new-tag', name: 'New Tag', type: 'knowledge', binding_count: 0 }])
})
it('should call onSuccess when onChange is triggered', () => {
const onSuccess = vi.fn()
render(<TagArea {...defaultProps} onSuccess={onSuccess} />)
fireEvent.click(screen.getByText('Trigger Change'))
expect(onSuccess).toHaveBeenCalledTimes(1)
})
})
describe('Styles', () => {
it('should have opacity class when embedding is not available', () => {
const dataset = createMockDataset({ embedding_available: false })
const { container } = render(<TagArea {...defaultProps} dataset={dataset} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('opacity-30')
})
it('should not have opacity class when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
const { container } = render(<TagArea {...defaultProps} dataset={dataset} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).not.toHaveClass('opacity-30')
})
it('should show mask when not hovering and has tags', () => {
const { container } = render(<TagArea {...defaultProps} isHoveringTagSelector={false} tags={mockTags} />)
const maskDiv = container.querySelector('.bg-tag-selector-mask-bg')
expect(maskDiv).toBeInTheDocument()
expect(maskDiv).not.toHaveClass('hidden')
})
it('should hide mask when hovering', () => {
const { container } = render(<TagArea {...defaultProps} isHoveringTagSelector={true} />)
// When hovering, the mask div should have 'hidden' class
const maskDiv = container.querySelector('.absolute.right-0.top-0')
expect(maskDiv).toHaveClass('hidden')
})
it('should make TagSelector visible when tags exist', () => {
const { container } = render(<TagArea {...defaultProps} tags={mockTags} />)
const tagSelectorWrapper = container.querySelector('.visible')
expect(tagSelectorWrapper).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle undefined onSuccess', () => {
render(<TagArea {...defaultProps} onSuccess={undefined} />)
// Should not throw when clicking Trigger Change
expect(() => fireEvent.click(screen.getByText('Trigger Change'))).not.toThrow()
})
it('should handle many tags', () => {
const manyTags: Tag[] = Array.from({ length: 20 }, (_, i) => ({
id: `tag-${i}`,
name: `Tag ${i}`,
type: 'knowledge' as const,
binding_count: 0,
}))
render(<TagArea {...defaultProps} tags={manyTags} />)
expect(screen.getByTestId('selected-count')).toHaveTextContent('20 tags')
})
})
})

View File

@@ -0,0 +1,427 @@
import type { DataSet } from '@/models/datasets'
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { useDatasetCardState } from './use-dataset-card-state'
// Mock Toast
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
// Mock service hooks
const mockCheckUsage = vi.fn()
const mockDeleteDataset = vi.fn()
const mockExportPipeline = vi.fn()
vi.mock('@/service/use-dataset-card', () => ({
useCheckDatasetUsage: () => ({ mutateAsync: mockCheckUsage }),
useDeleteDataset: () => ({ mutateAsync: mockDeleteDataset }),
}))
vi.mock('@/service/use-pipeline', () => ({
useExportPipelineDSL: () => ({ mutateAsync: mockExportPipeline }),
}))
describe('useDatasetCardState', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
created_at: 1609459200,
updated_at: 1609545600,
tags: [{ id: 'tag-1', name: 'Tag 1', type: 'knowledge', binding_count: 0 }],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
pipeline_id: 'pipeline-1',
...overrides,
} as DataSet)
beforeEach(() => {
vi.clearAllMocks()
mockCheckUsage.mockResolvedValue({ is_using: false })
mockDeleteDataset.mockResolvedValue({})
mockExportPipeline.mockResolvedValue({ data: 'yaml content' })
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('Initial State', () => {
it('should return tags from dataset', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
expect(result.current.tags).toEqual(dataset.tags)
})
it('should have initial modal state closed', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
expect(result.current.modalState.showRenameModal).toBe(false)
expect(result.current.modalState.showConfirmDelete).toBe(false)
expect(result.current.modalState.confirmMessage).toBe('')
})
it('should not be exporting initially', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
expect(result.current.exporting).toBe(false)
})
})
describe('Tags State', () => {
it('should update tags when setTags is called', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
act(() => {
result.current.setTags([{ id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 }])
})
expect(result.current.tags).toEqual([{ id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 }])
})
it('should sync tags when dataset tags change', () => {
const dataset = createMockDataset()
const { result, rerender } = renderHook(
({ dataset }) => useDatasetCardState({ dataset, onSuccess: vi.fn() }),
{ initialProps: { dataset } },
)
const newTags = [{ id: 'tag-3', name: 'Tag 3', type: 'knowledge', binding_count: 0 }]
const updatedDataset = createMockDataset({ tags: newTags })
rerender({ dataset: updatedDataset })
expect(result.current.tags).toEqual(newTags)
})
})
describe('Modal Handlers', () => {
it('should open rename modal when openRenameModal is called', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
act(() => {
result.current.openRenameModal()
})
expect(result.current.modalState.showRenameModal).toBe(true)
})
it('should close rename modal when closeRenameModal is called', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
act(() => {
result.current.openRenameModal()
})
act(() => {
result.current.closeRenameModal()
})
expect(result.current.modalState.showRenameModal).toBe(false)
})
it('should close confirm delete modal when closeConfirmDelete is called', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
// First trigger show confirm delete
act(() => {
result.current.detectIsUsedByApp()
})
waitFor(() => {
expect(result.current.modalState.showConfirmDelete).toBe(true)
})
act(() => {
result.current.closeConfirmDelete()
})
expect(result.current.modalState.showConfirmDelete).toBe(false)
})
})
describe('detectIsUsedByApp', () => {
it('should check usage and show confirm modal with not-in-use message', async () => {
mockCheckUsage.mockResolvedValue({ is_using: false })
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.detectIsUsedByApp()
})
expect(mockCheckUsage).toHaveBeenCalledWith('dataset-1')
expect(result.current.modalState.showConfirmDelete).toBe(true)
expect(result.current.modalState.confirmMessage).toContain('deleteDatasetConfirmContent')
})
it('should show in-use message when dataset is used by app', async () => {
mockCheckUsage.mockResolvedValue({ is_using: true })
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.detectIsUsedByApp()
})
expect(result.current.modalState.confirmMessage).toContain('datasetUsedByApp')
})
})
describe('onConfirmDelete', () => {
it('should delete dataset and call onSuccess', async () => {
const onSuccess = vi.fn()
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess }),
)
await act(async () => {
await result.current.onConfirmDelete()
})
expect(mockDeleteDataset).toHaveBeenCalledWith('dataset-1')
expect(onSuccess).toHaveBeenCalled()
})
it('should close confirm modal after delete', async () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
// First open confirm modal
await act(async () => {
await result.current.detectIsUsedByApp()
})
await act(async () => {
await result.current.onConfirmDelete()
})
expect(result.current.modalState.showConfirmDelete).toBe(false)
})
})
describe('handleExportPipeline', () => {
it('should not export if pipeline_id is missing', async () => {
const dataset = createMockDataset({ pipeline_id: undefined })
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.handleExportPipeline()
})
expect(mockExportPipeline).not.toHaveBeenCalled()
})
it('should export pipeline with correct parameters', async () => {
const dataset = createMockDataset({ pipeline_id: 'pipeline-1', name: 'Test Pipeline' })
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.handleExportPipeline(true)
})
expect(mockExportPipeline).toHaveBeenCalledWith({
pipelineId: 'pipeline-1',
include: true,
})
})
})
describe('Edge Cases', () => {
it('should handle empty tags array', () => {
const dataset = createMockDataset({ tags: [] })
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
expect(result.current.tags).toEqual([])
})
it('should handle undefined onSuccess', async () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset }),
)
// Should not throw when onSuccess is undefined
await act(async () => {
await result.current.onConfirmDelete()
})
expect(mockDeleteDataset).toHaveBeenCalled()
})
})
describe('Error Handling', () => {
it('should show error toast when export pipeline fails', async () => {
const Toast = await import('@/app/components/base/toast')
mockExportPipeline.mockRejectedValue(new Error('Export failed'))
const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.handleExportPipeline()
})
expect(Toast.default.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
})
})
it('should handle Response error in detectIsUsedByApp', async () => {
const Toast = await import('@/app/components/base/toast')
const mockResponse = new Response(JSON.stringify({ message: 'API Error' }), {
status: 400,
})
mockCheckUsage.mockRejectedValue(mockResponse)
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.detectIsUsedByApp()
})
expect(Toast.default.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.stringContaining('API Error'),
})
})
it('should handle generic Error in detectIsUsedByApp', async () => {
const Toast = await import('@/app/components/base/toast')
mockCheckUsage.mockRejectedValue(new Error('Network error'))
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.detectIsUsedByApp()
})
expect(Toast.default.notify).toHaveBeenCalledWith({
type: 'error',
message: 'Network error',
})
})
it('should handle error without message in detectIsUsedByApp', async () => {
const Toast = await import('@/app/components/base/toast')
mockCheckUsage.mockRejectedValue({})
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.detectIsUsedByApp()
})
expect(Toast.default.notify).toHaveBeenCalledWith({
type: 'error',
message: 'Unknown error',
})
})
it('should handle exporting state correctly', async () => {
const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
// Exporting should initially be false
expect(result.current.exporting).toBe(false)
// Export should work when not exporting
await act(async () => {
await result.current.handleExportPipeline()
})
expect(mockExportPipeline).toHaveBeenCalled()
})
it('should reset exporting state after export completes', async () => {
const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.handleExportPipeline()
})
expect(result.current.exporting).toBe(false)
})
it('should reset exporting state even when export fails', async () => {
mockExportPipeline.mockRejectedValue(new Error('Export failed'))
const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.handleExportPipeline()
})
expect(result.current.exporting).toBe(false)
})
})
})

View File

@@ -0,0 +1,256 @@
import type { DataSet } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import DatasetCard from './index'
// Mock next/navigation
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}))
// Mock ahooks useHover
vi.mock('ahooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('ahooks')>()
return {
...actual,
useHover: () => false,
}
})
// Mock app context
vi.mock('@/context/app-context', () => ({
useSelector: () => false,
}))
// Mock the useDatasetCardState hook
vi.mock('./hooks/use-dataset-card-state', () => ({
useDatasetCardState: () => ({
tags: [],
setTags: vi.fn(),
modalState: {
showRenameModal: false,
showConfirmDelete: false,
confirmMessage: '',
},
openRenameModal: vi.fn(),
closeRenameModal: vi.fn(),
closeConfirmDelete: vi.fn(),
handleExportPipeline: vi.fn(),
detectIsUsedByApp: vi.fn(),
onConfirmDelete: vi.fn(),
}),
}))
// Mock the RenameDatasetModal
vi.mock('../../rename-modal', () => ({
default: () => null,
}))
// Mock useFormatTimeFromNow hook
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: (timestamp: number) => {
const date = new Date(timestamp)
return date.toLocaleDateString()
},
}),
}))
// Mock useKnowledge hook
vi.mock('@/hooks/use-knowledge', () => ({
useKnowledge: () => ({
formatIndexingTechniqueAndMethod: () => 'High Quality',
}),
}))
describe('DatasetCard', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
created_at: 1609459200,
updated_at: 1609545600,
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
runtime_mode: 'general',
is_published: true,
total_available_documents: 10,
icon_info: {
icon: '📙',
icon_type: 'emoji' as const,
icon_background: '#FFF4ED',
icon_url: '',
},
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
},
author_name: 'Test User',
...overrides,
} as DataSet)
const defaultProps = {
dataset: createMockDataset(),
onSuccess: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<DatasetCard {...defaultProps} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should render dataset name', () => {
const dataset = createMockDataset({ name: 'Custom Dataset Name' })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Custom Dataset Name')).toBeInTheDocument()
})
it('should render dataset description', () => {
const dataset = createMockDataset({ description: 'Custom Description' })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Custom Description')).toBeInTheDocument()
})
it('should render document count', () => {
render(<DatasetCard {...defaultProps} />)
expect(screen.getByText('10')).toBeInTheDocument()
})
it('should render app count', () => {
render(<DatasetCard {...defaultProps} />)
expect(screen.getByText('5')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should handle external provider', () => {
const dataset = createMockDataset({ provider: 'external' })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should handle rag_pipeline runtime mode', () => {
const dataset = createMockDataset({ runtime_mode: 'rag_pipeline', is_published: true })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should navigate to documents page on click for regular dataset', () => {
const dataset = createMockDataset({ provider: 'vendor' })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
fireEvent.click(card!)
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents')
})
it('should navigate to hitTesting page on click for external provider', () => {
const dataset = createMockDataset({ provider: 'external' })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
fireEvent.click(card!)
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/hitTesting')
})
it('should navigate to pipeline page when pipeline is unpublished', () => {
const dataset = createMockDataset({ runtime_mode: 'rag_pipeline', is_published: false })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
fireEvent.click(card!)
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/pipeline')
})
})
describe('Styles', () => {
it('should have correct card styling', () => {
render(<DatasetCard {...defaultProps} />)
const card = screen.getByText('Test Dataset').closest('.group')
expect(card).toHaveClass('h-[190px]', 'cursor-pointer', 'flex-col', 'rounded-xl')
})
it('should have data-disable-nprogress attribute', () => {
render(<DatasetCard {...defaultProps} />)
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
expect(card).toHaveAttribute('data-disable-nprogress', 'true')
})
})
describe('Edge Cases', () => {
it('should handle dataset without description', () => {
const dataset = createMockDataset({ description: '' })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should handle embedding not available', () => {
const dataset = createMockDataset({ embedding_available: false })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should handle undefined onSuccess', () => {
render(<DatasetCard dataset={createMockDataset()} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
})
describe('Tag Area Click', () => {
it('should stop propagation and prevent default when tag area is clicked', () => {
render(<DatasetCard {...defaultProps} />)
// Find tag area element (it's inside the card)
const tagAreaWrapper = document.querySelector('[class*="px-3"]')
if (tagAreaWrapper) {
const stopPropagationSpy = vi.fn()
const preventDefaultSpy = vi.fn()
const clickEvent = new MouseEvent('click', { bubbles: true })
Object.defineProperty(clickEvent, 'stopPropagation', { value: stopPropagationSpy })
Object.defineProperty(clickEvent, 'preventDefault', { value: preventDefaultSpy })
tagAreaWrapper.dispatchEvent(clickEvent)
expect(stopPropagationSpy).toHaveBeenCalled()
expect(preventDefaultSpy).toHaveBeenCalled()
}
})
it('should not navigate when clicking on tag area', () => {
render(<DatasetCard {...defaultProps} />)
// Click on tag area should not trigger card navigation
const tagArea = document.querySelector('[class*="px-3"]')
if (tagArea) {
fireEvent.click(tagArea)
// mockPush should NOT be called when clicking tag area
// (stopPropagation prevents it from reaching the card click handler)
}
})
})
})

View File

@@ -0,0 +1,87 @@
import { RiEditLine } from '@remixicon/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import OperationItem from './operation-item'
describe('OperationItem', () => {
const defaultProps = {
Icon: RiEditLine,
name: 'Edit',
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<OperationItem {...defaultProps} />)
expect(screen.getByText('Edit')).toBeInTheDocument()
})
it('should render the icon', () => {
const { container } = render(<OperationItem {...defaultProps} />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
expect(icon).toHaveClass('size-4', 'text-text-tertiary')
})
it('should render the name text', () => {
render(<OperationItem {...defaultProps} />)
const nameSpan = screen.getByText('Edit')
expect(nameSpan).toHaveClass('system-md-regular', 'text-text-secondary')
})
})
describe('Props', () => {
it('should render different name', () => {
render(<OperationItem {...defaultProps} name="Delete" />)
expect(screen.getByText('Delete')).toBeInTheDocument()
})
it('should be callable without handleClick', () => {
render(<OperationItem {...defaultProps} />)
const item = screen.getByText('Edit').closest('div')
expect(() => fireEvent.click(item!)).not.toThrow()
})
})
describe('User Interactions', () => {
it('should call handleClick when clicked', () => {
const handleClick = vi.fn()
render(<OperationItem {...defaultProps} handleClick={handleClick} />)
const item = screen.getByText('Edit').closest('div')
fireEvent.click(item!)
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should prevent default and stop propagation on click', () => {
const handleClick = vi.fn()
render(<OperationItem {...defaultProps} handleClick={handleClick} />)
const item = screen.getByText('Edit').closest('div')
const clickEvent = new MouseEvent('click', { bubbles: true })
const preventDefaultSpy = vi.spyOn(clickEvent, 'preventDefault')
const stopPropagationSpy = vi.spyOn(clickEvent, 'stopPropagation')
item!.dispatchEvent(clickEvent)
expect(preventDefaultSpy).toHaveBeenCalled()
expect(stopPropagationSpy).toHaveBeenCalled()
})
})
describe('Styles', () => {
it('should have correct container styling', () => {
render(<OperationItem {...defaultProps} />)
const item = screen.getByText('Edit').closest('div')
expect(item).toHaveClass('flex', 'cursor-pointer', 'items-center', 'gap-x-1', 'rounded-lg')
})
})
describe('Edge Cases', () => {
it('should handle empty name', () => {
render(<OperationItem {...defaultProps} name="" />)
const container = document.querySelector('.cursor-pointer')
expect(container).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,119 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import Operations from './operations'
describe('Operations', () => {
const defaultProps = {
showDelete: true,
showExportPipeline: true,
openRenameModal: vi.fn(),
handleExportPipeline: vi.fn(),
detectIsUsedByApp: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Operations {...defaultProps} />)
// Edit operation should always be visible
expect(screen.getByText(/operation\.edit/)).toBeInTheDocument()
})
it('should render edit operation', () => {
render(<Operations {...defaultProps} />)
expect(screen.getByText(/operation\.edit/)).toBeInTheDocument()
})
it('should render export pipeline operation when showExportPipeline is true', () => {
render(<Operations {...defaultProps} showExportPipeline={true} />)
expect(screen.getByText(/exportPipeline/)).toBeInTheDocument()
})
it('should not render export pipeline operation when showExportPipeline is false', () => {
render(<Operations {...defaultProps} showExportPipeline={false} />)
expect(screen.queryByText(/exportPipeline/)).not.toBeInTheDocument()
})
it('should render delete operation when showDelete is true', () => {
render(<Operations {...defaultProps} showDelete={true} />)
expect(screen.getByText(/operation\.delete/)).toBeInTheDocument()
})
it('should not render delete operation when showDelete is false', () => {
render(<Operations {...defaultProps} showDelete={false} />)
expect(screen.queryByText(/operation\.delete/)).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should render divider when showDelete is true', () => {
const { container } = render(<Operations {...defaultProps} showDelete={true} />)
const divider = container.querySelector('.bg-divider-subtle')
expect(divider).toBeInTheDocument()
})
it('should not render divider when showDelete is false', () => {
const { container } = render(<Operations {...defaultProps} showDelete={false} />)
// Should not have the divider-subtle one (the separator before delete)
expect(container.querySelector('.bg-divider-subtle')).toBeNull()
})
})
describe('User Interactions', () => {
it('should call openRenameModal when edit is clicked', () => {
const openRenameModal = vi.fn()
render(<Operations {...defaultProps} openRenameModal={openRenameModal} />)
const editItem = screen.getByText(/operation\.edit/).closest('div')
fireEvent.click(editItem!)
expect(openRenameModal).toHaveBeenCalledTimes(1)
})
it('should call handleExportPipeline when export is clicked', () => {
const handleExportPipeline = vi.fn()
render(<Operations {...defaultProps} handleExportPipeline={handleExportPipeline} />)
const exportItem = screen.getByText(/exportPipeline/).closest('div')
fireEvent.click(exportItem!)
expect(handleExportPipeline).toHaveBeenCalledTimes(1)
})
it('should call detectIsUsedByApp when delete is clicked', () => {
const detectIsUsedByApp = vi.fn()
render(<Operations {...defaultProps} detectIsUsedByApp={detectIsUsedByApp} />)
const deleteItem = screen.getByText(/operation\.delete/).closest('div')
fireEvent.click(deleteItem!)
expect(detectIsUsedByApp).toHaveBeenCalledTimes(1)
})
})
describe('Styles', () => {
it('should have correct container styling', () => {
const { container } = render(<Operations {...defaultProps} />)
const operationsContainer = container.firstChild
expect(operationsContainer).toHaveClass(
'relative',
'flex',
'w-full',
'flex-col',
'rounded-xl',
)
})
})
describe('Edge Cases', () => {
it('should render only edit when both showDelete and showExportPipeline are false', () => {
render(<Operations {...defaultProps} showDelete={false} showExportPipeline={false} />)
expect(screen.getByText(/operation\.edit/)).toBeInTheDocument()
expect(screen.queryByText(/exportPipeline/)).not.toBeInTheDocument()
expect(screen.queryByText(/operation\.delete/)).not.toBeInTheDocument()
})
})
})

View File

@@ -1,30 +1,52 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import DatasetFooter from './index'
describe('DatasetFooter', () => {
it('should render correctly', () => {
render(<DatasetFooter />)
describe('Rendering', () => {
it('should render without crashing', () => {
render(<DatasetFooter />)
expect(screen.getByRole('contentinfo')).toBeInTheDocument()
})
// Check main title (mocked i18n returns ns:key or key)
// The code uses t('didYouKnow', { ns: 'dataset' })
// With default mock it likely returns 'dataset.didYouKnow'
expect(screen.getByText('dataset.didYouKnow')).toBeInTheDocument()
it('should render the main heading', () => {
render(<DatasetFooter />)
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument()
})
// Check paragraph content
expect(screen.getByText(/dataset.intro1/)).toBeInTheDocument()
expect(screen.getByText(/dataset.intro2/)).toBeInTheDocument()
expect(screen.getByText(/dataset.intro3/)).toBeInTheDocument()
expect(screen.getByText(/dataset.intro4/)).toBeInTheDocument()
expect(screen.getByText(/dataset.intro5/)).toBeInTheDocument()
expect(screen.getByText(/dataset.intro6/)).toBeInTheDocument()
it('should render description paragraph', () => {
render(<DatasetFooter />)
// The paragraph contains multiple text spans
expect(screen.getByText(/intro1/)).toBeInTheDocument()
})
})
it('should have correct styling', () => {
const { container } = render(<DatasetFooter />)
const footer = container.querySelector('footer')
expect(footer).toHaveClass('shrink-0', 'px-12', 'py-6')
describe('Props', () => {
it('should be memoized', () => {
// DatasetFooter is wrapped with React.memo
expect(DatasetFooter).toBeDefined()
})
})
const h3 = container.querySelector('h3')
expect(h3).toHaveClass('text-gradient')
describe('Styles', () => {
it('should have correct footer styling', () => {
render(<DatasetFooter />)
const footer = screen.getByRole('contentinfo')
expect(footer).toHaveClass('shrink-0', 'px-12', 'py-6')
})
it('should have gradient text on heading', () => {
render(<DatasetFooter />)
const heading = screen.getByRole('heading', { level: 3 })
expect(heading).toHaveClass('text-gradient')
})
})
describe('Content Structure', () => {
it('should render accent spans for highlighted text', () => {
render(<DatasetFooter />)
const accentSpans = document.querySelectorAll('.text-text-accent')
expect(accentSpans.length).toBe(2)
})
})
})

View File

@@ -0,0 +1,485 @@
import type { DataSet } from '@/models/datasets'
import { render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import Datasets from './datasets'
// Mock next/navigation
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
}))
// Mock ahooks
vi.mock('ahooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('ahooks')>()
return {
...actual,
useHover: () => false,
}
})
// Mock useFormatTimeFromNow hook
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: (timestamp: number) => new Date(timestamp).toLocaleDateString(),
}),
}))
// Mock useKnowledge hook
vi.mock('@/hooks/use-knowledge', () => ({
useKnowledge: () => ({
formatIndexingTechniqueAndMethod: () => 'High Quality',
}),
}))
// Mock service hooks - will be overridden in individual tests
const mockFetchNextPage = vi.fn()
const mockInvalidDatasetList = vi.fn()
vi.mock('@/service/knowledge/use-dataset', () => ({
useDatasetList: vi.fn(() => ({
data: {
pages: [
{
data: [
createMockDataset({ id: 'dataset-1', name: 'Dataset 1' }),
createMockDataset({ id: 'dataset-2', name: 'Dataset 2' }),
],
},
],
},
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
})),
useInvalidDatasetList: () => mockInvalidDatasetList,
}))
// Mock app context - will be overridden in tests
vi.mock('@/context/app-context', () => ({
useSelector: vi.fn(() => true),
}))
// Mock useDatasetCardState hook
vi.mock('./dataset-card/hooks/use-dataset-card-state', () => ({
useDatasetCardState: () => ({
tags: [],
setTags: vi.fn(),
modalState: {
showRenameModal: false,
showConfirmDelete: false,
confirmMessage: '',
},
openRenameModal: vi.fn(),
closeRenameModal: vi.fn(),
closeConfirmDelete: vi.fn(),
handleExportPipeline: vi.fn(),
detectIsUsedByApp: vi.fn(),
onConfirmDelete: vi.fn(),
}),
}))
// Mock RenameDatasetModal
vi.mock('../rename-modal', () => ({
default: () => null,
}))
function createMockDataset(overrides: Partial<DataSet> = {}): DataSet {
return {
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
created_at: 1609459200,
updated_at: 1609545600,
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
runtime_mode: 'general',
is_published: true,
total_available_documents: 10,
icon_info: {
icon: '📙',
icon_type: 'emoji' as const,
icon_background: '#FFF4ED',
icon_url: '',
},
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
},
author_name: 'Test User',
...overrides,
} as DataSet
}
// Store IntersectionObserver callbacks for testing
let intersectionObserverCallback: IntersectionObserverCallback | null = null
const mockObserve = vi.fn()
const mockDisconnect = vi.fn()
const mockUnobserve = vi.fn()
// Custom IntersectionObserver mock
class MockIntersectionObserver {
constructor(callback: IntersectionObserverCallback) {
intersectionObserverCallback = callback
}
observe = mockObserve
disconnect = mockDisconnect
unobserve = mockUnobserve
root = null
rootMargin = ''
thresholds = []
takeRecords = () => []
}
describe('Datasets', () => {
const defaultProps = {
tags: [],
keywords: '',
includeAll: false,
}
beforeEach(() => {
vi.clearAllMocks()
intersectionObserverCallback = null
document.title = ''
// Setup IntersectionObserver mock
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Datasets {...defaultProps} />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
it('should render NewDatasetCard when user is editor', async () => {
const { useSelector } = await import('@/context/app-context')
vi.mocked(useSelector).mockReturnValue(true)
render(<Datasets {...defaultProps} />)
expect(screen.getByText(/createDataset/)).toBeInTheDocument()
})
it('should NOT render NewDatasetCard when user is NOT editor', async () => {
const { useSelector } = await import('@/context/app-context')
vi.mocked(useSelector).mockReturnValue(false)
render(<Datasets {...defaultProps} />)
expect(screen.queryByText(/createDataset/)).not.toBeInTheDocument()
})
it('should render dataset cards from data', () => {
render(<Datasets {...defaultProps} />)
expect(screen.getByText('Dataset 1')).toBeInTheDocument()
expect(screen.getByText('Dataset 2')).toBeInTheDocument()
})
it('should render anchor div for infinite scroll', () => {
render(<Datasets {...defaultProps} />)
const anchor = document.querySelector('.h-0')
expect(anchor).toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass tags to useDatasetList', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
render(<Datasets {...defaultProps} tags={['tag-1', 'tag-2']} />)
expect(useDatasetList).toHaveBeenCalledWith(
expect.objectContaining({
tag_ids: ['tag-1', 'tag-2'],
}),
)
})
it('should pass keywords to useDatasetList', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
render(<Datasets {...defaultProps} keywords="search term" />)
expect(useDatasetList).toHaveBeenCalledWith(
expect.objectContaining({
keyword: 'search term',
}),
)
})
it('should pass includeAll to useDatasetList', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
render(<Datasets {...defaultProps} includeAll={true} />)
expect(useDatasetList).toHaveBeenCalledWith(
expect.objectContaining({
include_all: true,
}),
)
})
})
describe('Document Title', () => {
it('should set document title on mount', async () => {
render(<Datasets {...defaultProps} />)
await waitFor(() => {
expect(document.title).toContain('dataset.knowledge')
})
})
})
describe('Loading States', () => {
it('should show Loading component when isFetchingNextPage is true', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [] }] },
fetchNextPage: mockFetchNextPage,
hasNextPage: true,
isFetching: false,
isFetchingNextPage: true,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
// Loading component renders a div with loading classes
const nav = screen.getByRole('navigation')
expect(nav).toBeInTheDocument()
})
it('should NOT show Loading component when isFetchingNextPage is false', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [] }] },
fetchNextPage: mockFetchNextPage,
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
})
describe('DatasetList null handling', () => {
it('should handle null datasetList gracefully', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: null,
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
it('should handle undefined datasetList gracefully', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: undefined,
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
it('should handle empty pages array', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [] },
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
})
describe('IntersectionObserver', () => {
it('should setup IntersectionObserver on mount', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [] }] },
fetchNextPage: mockFetchNextPage,
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
// Should observe the anchor element
expect(mockObserve).toHaveBeenCalled()
})
it('should call fetchNextPage when isIntersecting, hasNextPage, and not isFetching', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [] }] },
fetchNextPage: mockFetchNextPage,
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
// Simulate intersection
if (intersectionObserverCallback) {
intersectionObserverCallback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
}
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
})
it('should NOT call fetchNextPage when isIntersecting is false', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [] }] },
fetchNextPage: mockFetchNextPage,
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
if (intersectionObserverCallback) {
intersectionObserverCallback(
[{ isIntersecting: false } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
}
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
it('should NOT call fetchNextPage when hasNextPage is false', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [] }] },
fetchNextPage: mockFetchNextPage,
hasNextPage: false, // No more pages
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
if (intersectionObserverCallback) {
intersectionObserverCallback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
}
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
it('should NOT call fetchNextPage when isFetching is true', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [] }] },
fetchNextPage: mockFetchNextPage,
hasNextPage: true,
isFetching: true, // Already fetching
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
if (intersectionObserverCallback) {
intersectionObserverCallback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
}
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
it('should disconnect observer on unmount', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [] }] },
fetchNextPage: mockFetchNextPage,
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
const { unmount } = render(<Datasets {...defaultProps} />)
// Unmount the component
unmount()
// disconnect should be called during cleanup
expect(mockDisconnect).toHaveBeenCalled()
})
})
describe('Styles', () => {
it('should have correct grid styling', () => {
render(<Datasets {...defaultProps} />)
const nav = screen.getByRole('navigation')
expect(nav).toHaveClass('grid', 'grow', 'gap-3', 'px-12')
})
})
describe('Edge Cases', () => {
it('should handle empty tags array', () => {
render(<Datasets {...defaultProps} tags={[]} />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
it('should handle empty keywords', () => {
render(<Datasets {...defaultProps} keywords="" />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
it('should handle multiple pages of data', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: {
pages: [
{ data: [createMockDataset({ id: 'ds-1', name: 'Page 1 Dataset' })] },
{ data: [createMockDataset({ id: 'ds-2', name: 'Page 2 Dataset' })] },
],
},
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
expect(screen.getByText('Page 1 Dataset')).toBeInTheDocument()
expect(screen.getByText('Page 2 Dataset')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,368 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import List from './index'
// Mock next/navigation
const mockPush = vi.fn()
const mockReplace = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
replace: mockReplace,
}),
}))
// Mock ahooks
vi.mock('ahooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('ahooks')>()
return {
...actual,
useBoolean: () => [false, { toggle: vi.fn(), setTrue: vi.fn(), setFalse: vi.fn() }],
useDebounceFn: (fn: () => void) => ({ run: fn }),
useHover: () => false,
}
})
// Mock app context
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
currentWorkspace: { role: 'admin' },
isCurrentWorkspaceOwner: true,
}),
useSelector: () => true,
}))
// Mock global public context
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: () => ({
systemFeatures: {
branding: { enabled: false },
},
}),
}))
// Mock external api panel context
const mockSetShowExternalApiPanel = vi.fn()
vi.mock('@/context/external-api-panel-context', () => ({
useExternalApiPanel: () => ({
showExternalApiPanel: false,
setShowExternalApiPanel: mockSetShowExternalApiPanel,
}),
}))
// Mock tag management store
vi.mock('@/app/components/base/tag-management/store', () => ({
useStore: () => false,
}))
// Mock useDocumentTitle hook
vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
// Mock useFormatTimeFromNow hook
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: (timestamp: number) => new Date(timestamp).toLocaleDateString(),
}),
}))
// Mock useKnowledge hook
vi.mock('@/hooks/use-knowledge', () => ({
useKnowledge: () => ({
formatIndexingTechniqueAndMethod: () => 'High Quality',
}),
}))
// Mock service hooks
vi.mock('@/service/knowledge/use-dataset', () => ({
useDatasetList: vi.fn(() => ({
data: { pages: [{ data: [] }] },
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
})),
useInvalidDatasetList: () => vi.fn(),
useDatasetApiBaseUrl: () => ({
data: { api_base_url: 'https://api.example.com' },
}),
}))
// Mock Datasets component
vi.mock('./datasets', () => ({
default: ({ tags, keywords, includeAll }: { tags: string[], keywords: string, includeAll: boolean }) => (
<div data-testid="datasets-component">
<span data-testid="tags">{tags.join(',')}</span>
<span data-testid="keywords">{keywords}</span>
<span data-testid="include-all">{includeAll ? 'true' : 'false'}</span>
</div>
),
}))
// Mock DatasetFooter component
vi.mock('./dataset-footer', () => ({
default: () => <footer data-testid="dataset-footer">Footer</footer>,
}))
// Mock ExternalAPIPanel component
vi.mock('../external-api/external-api-panel', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<div data-testid="external-api-panel">
<button onClick={onClose}>Close Panel</button>
</div>
),
}))
// Mock TagManagementModal
vi.mock('@/app/components/base/tag-management', () => ({
default: () => <div data-testid="tag-management-modal" />,
}))
// Mock TagFilter
vi.mock('@/app/components/base/tag-management/filter', () => ({
default: ({ onChange }: { value: string[], onChange: (val: string[]) => void }) => (
<div data-testid="tag-filter">
<button onClick={() => onChange(['tag-1', 'tag-2'])}>Select Tags</button>
</div>
),
}))
// Mock CheckboxWithLabel
vi.mock('@/app/components/datasets/create/website/base/checkbox-with-label', () => ({
default: ({ isChecked, onChange, label }: { isChecked: boolean, onChange: () => void, label: string }) => (
<label>
<input
type="checkbox"
checked={isChecked}
onChange={onChange}
data-testid="include-all-checkbox"
/>
{label}
</label>
),
}))
describe('List', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<List />)
expect(screen.getByTestId('datasets-component')).toBeInTheDocument()
})
it('should render the search input', () => {
render(<List />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render tag filter', () => {
render(<List />)
expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
})
it('should render external API panel button', () => {
render(<List />)
expect(screen.getByText(/externalAPIPanelTitle/)).toBeInTheDocument()
})
it('should render dataset footer when branding is disabled', () => {
render(<List />)
expect(screen.getByTestId('dataset-footer')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass includeAll prop to Datasets', () => {
render(<List />)
expect(screen.getByTestId('include-all')).toHaveTextContent('false')
})
it('should pass empty keywords initially', () => {
render(<List />)
expect(screen.getByTestId('keywords')).toHaveTextContent('')
})
it('should pass empty tags initially', () => {
render(<List />)
expect(screen.getByTestId('tags')).toHaveTextContent('')
})
})
describe('User Interactions', () => {
it('should open external API panel when button is clicked', () => {
render(<List />)
const button = screen.getByText(/externalAPIPanelTitle/)
fireEvent.click(button)
expect(mockSetShowExternalApiPanel).toHaveBeenCalledWith(true)
})
it('should update search input value', () => {
render(<List />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test search' } })
expect(input).toHaveValue('test search')
})
it('should trigger tag filter change', () => {
render(<List />)
// Tag filter is rendered and interactive
const selectTagsBtn = screen.getByText('Select Tags')
expect(selectTagsBtn).toBeInTheDocument()
fireEvent.click(selectTagsBtn)
// The onChange callback was triggered (debounced)
})
})
describe('Conditional Rendering', () => {
it('should show include all checkbox for workspace owner', () => {
render(<List />)
expect(screen.getByTestId('include-all-checkbox')).toBeInTheDocument()
})
})
describe('Styles', () => {
it('should have correct container styling', () => {
const { container } = render(<List />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toHaveClass('scroll-container', 'relative', 'flex', 'grow', 'flex-col')
})
})
describe('Edge Cases', () => {
it('should handle empty state gracefully', () => {
render(<List />)
// Should render without errors even with empty data
expect(screen.getByTestId('datasets-component')).toBeInTheDocument()
})
})
describe('Branch Coverage', () => {
it('should redirect normal role users to /apps', async () => {
// Re-mock useAppContext with normal role
vi.doMock('@/context/app-context', () => ({
useAppContext: () => ({
currentWorkspace: { role: 'normal' },
isCurrentWorkspaceOwner: false,
}),
useSelector: () => true,
}))
// Clear module cache and re-import
vi.resetModules()
const { default: ListComponent } = await import('./index')
render(<ListComponent />)
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/apps')
})
})
it('should clear search input when onClear is called', () => {
render(<List />)
const input = screen.getByRole('textbox')
// First set a value
fireEvent.change(input, { target: { value: 'test search' } })
expect(input).toHaveValue('test search')
// Find and click the clear button
const clearButton = document.querySelector('[class*="clear"], button[aria-label*="clear"]')
if (clearButton) {
fireEvent.click(clearButton)
expect(input).toHaveValue('')
}
})
it('should show ExternalAPIPanel when showExternalApiPanel is true', async () => {
// Re-mock to show external API panel
vi.doMock('@/context/external-api-panel-context', () => ({
useExternalApiPanel: () => ({
showExternalApiPanel: true,
setShowExternalApiPanel: mockSetShowExternalApiPanel,
}),
}))
vi.resetModules()
const { default: ListComponent } = await import('./index')
render(<ListComponent />)
expect(screen.getByTestId('external-api-panel')).toBeInTheDocument()
})
it('should close ExternalAPIPanel when onClose is called', async () => {
vi.doMock('@/context/external-api-panel-context', () => ({
useExternalApiPanel: () => ({
showExternalApiPanel: true,
setShowExternalApiPanel: mockSetShowExternalApiPanel,
}),
}))
vi.resetModules()
const { default: ListComponent } = await import('./index')
render(<ListComponent />)
const closeButton = screen.getByText('Close Panel')
fireEvent.click(closeButton)
expect(mockSetShowExternalApiPanel).toHaveBeenCalledWith(false)
})
it('should show TagManagementModal when showTagManagementModal is true', async () => {
vi.doMock('@/app/components/base/tag-management/store', () => ({
useStore: () => true, // showTagManagementModal is true
}))
vi.resetModules()
const { default: ListComponent } = await import('./index')
render(<ListComponent />)
expect(screen.getByTestId('tag-management-modal')).toBeInTheDocument()
})
it('should not show DatasetFooter when branding is enabled', async () => {
vi.doMock('@/context/global-public-context', () => ({
useGlobalPublicStore: () => ({
systemFeatures: {
branding: { enabled: true },
},
}),
}))
vi.resetModules()
const { default: ListComponent } = await import('./index')
render(<ListComponent />)
expect(screen.queryByTestId('dataset-footer')).not.toBeInTheDocument()
})
it('should not show include all checkbox when not workspace owner', async () => {
vi.doMock('@/context/app-context', () => ({
useAppContext: () => ({
currentWorkspace: { role: 'editor' },
isCurrentWorkspaceOwner: false,
}),
useSelector: () => true,
}))
vi.resetModules()
const { default: ListComponent } = await import('./index')
render(<ListComponent />)
expect(screen.queryByTestId('include-all-checkbox')).not.toBeInTheDocument()
})
})
})

View File

@@ -1,49 +1,76 @@
import { render, screen } from '@testing-library/react'
import NewDatasetCard from './index'
import { describe, expect, it } from 'vitest'
import CreateAppCard from './index'
type MockOptionProps = {
text: string
href: string
}
describe('CreateAppCard', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<CreateAppCard />)
expect(screen.getAllByRole('link')).toHaveLength(3)
})
// Mock dependencies
vi.mock('./option', () => ({
default: ({ text, href }: MockOptionProps) => (
<a data-testid="option-link" href={href}>
{text}
</a>
),
}))
it('should render create dataset option', () => {
render(<CreateAppCard />)
expect(screen.getByText(/createDataset/)).toBeInTheDocument()
})
vi.mock('@remixicon/react', () => ({
RiAddLine: () => <svg data-testid="icon-add" />,
RiFunctionAddLine: () => <svg data-testid="icon-function" />,
}))
it('should render create from pipeline option', () => {
render(<CreateAppCard />)
expect(screen.getByText(/createFromPipeline/)).toBeInTheDocument()
})
vi.mock('@/app/components/base/icons/src/vender/solid/development', () => ({
ApiConnectionMod: () => <svg data-testid="icon-api" />,
}))
it('should render connect dataset option', () => {
render(<CreateAppCard />)
expect(screen.getByText(/connectDataset/)).toBeInTheDocument()
})
})
describe('NewDatasetCard', () => {
it('should render all options', () => {
render(<NewDatasetCard />)
describe('Props', () => {
it('should have correct displayName', () => {
expect(CreateAppCard.displayName).toBe('CreateAppCard')
})
})
const options = screen.getAllByTestId('option-link')
expect(options).toHaveLength(3)
describe('Links', () => {
it('should have correct href for create dataset', () => {
render(<CreateAppCard />)
const links = screen.getAllByRole('link')
expect(links[0]).toHaveAttribute('href', '/datasets/create')
})
// Check first option (Create Dataset)
const createDataset = options[0]
expect(createDataset).toHaveAttribute('href', '/datasets/create')
expect(createDataset).toHaveTextContent('dataset.createDataset')
it('should have correct href for create from pipeline', () => {
render(<CreateAppCard />)
const links = screen.getAllByRole('link')
expect(links[1]).toHaveAttribute('href', '/datasets/create-from-pipeline')
})
// Check second option (Create from Pipeline)
const createFromPipeline = options[1]
expect(createFromPipeline).toHaveAttribute('href', '/datasets/create-from-pipeline')
expect(createFromPipeline).toHaveTextContent('dataset.createFromPipeline')
it('should have correct href for connect dataset', () => {
render(<CreateAppCard />)
const links = screen.getAllByRole('link')
expect(links[2]).toHaveAttribute('href', '/datasets/connect')
})
})
// Check third option (Connect Dataset)
const connectDataset = options[2]
expect(connectDataset).toHaveAttribute('href', '/datasets/connect')
expect(connectDataset).toHaveTextContent('dataset.connectDataset')
describe('Styles', () => {
it('should have correct card styling', () => {
const { container } = render(<CreateAppCard />)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('h-[190px]', 'flex', 'flex-col', 'rounded-xl')
})
it('should have border separator for connect option', () => {
const { container } = render(<CreateAppCard />)
const borderDiv = container.querySelector('.border-t-\\[0\\.5px\\]')
expect(borderDiv).toBeInTheDocument()
})
})
describe('Icons', () => {
it('should render three icons for three options', () => {
const { container } = render(<CreateAppCard />)
// Each option has an icon
const icons = container.querySelectorAll('svg')
expect(icons.length).toBeGreaterThanOrEqual(3)
})
})
})

View File

@@ -0,0 +1,78 @@
import { RiAddLine } from '@remixicon/react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import Option from './option'
describe('Option', () => {
const defaultProps = {
Icon: RiAddLine,
text: 'Test Option',
href: '/test-path',
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Option {...defaultProps} />)
expect(screen.getByRole('link')).toBeInTheDocument()
})
it('should render the text content', () => {
render(<Option {...defaultProps} />)
expect(screen.getByText('Test Option')).toBeInTheDocument()
})
it('should render the icon', () => {
render(<Option {...defaultProps} />)
// Icon should be rendered with correct size class
const icon = document.querySelector('.h-4.w-4')
expect(icon).toBeInTheDocument()
})
})
describe('Props', () => {
it('should have correct href attribute', () => {
render(<Option {...defaultProps} />)
const link = screen.getByRole('link')
expect(link).toHaveAttribute('href', '/test-path')
})
it('should render different text based on props', () => {
render(<Option {...defaultProps} text="Different Text" />)
expect(screen.getByText('Different Text')).toBeInTheDocument()
})
it('should render different href based on props', () => {
render(<Option {...defaultProps} href="/different-path" />)
const link = screen.getByRole('link')
expect(link).toHaveAttribute('href', '/different-path')
})
})
describe('Styles', () => {
it('should have correct base styling', () => {
render(<Option {...defaultProps} />)
const link = screen.getByRole('link')
expect(link).toHaveClass('flex', 'w-full', 'items-center', 'gap-x-2', 'rounded-lg')
})
it('should have text span with correct styling', () => {
render(<Option {...defaultProps} />)
const textSpan = screen.getByText('Test Option')
expect(textSpan).toHaveClass('system-sm-medium', 'grow', 'text-left')
})
})
describe('Edge Cases', () => {
it('should handle empty text', () => {
render(<Option {...defaultProps} text="" />)
const link = screen.getByRole('link')
expect(link).toBeInTheDocument()
})
it('should handle long text', () => {
const longText = 'A'.repeat(100)
render(<Option {...defaultProps} text={longText} />)
expect(screen.getByText(longText)).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,92 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import AddedMetadataButton from './add-metadata-button'
describe('AddedMetadataButton', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<AddedMetadataButton />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render with translated text', () => {
render(<AddedMetadataButton />)
// The button should contain text from i18n
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render add icon', () => {
const { container } = render(<AddedMetadataButton />)
// Check if there's an SVG element (the RiAddLine icon)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
render(<AddedMetadataButton className="custom-class" />)
const button = screen.getByRole('button')
expect(button).toHaveClass('custom-class')
})
it('should apply default classes', () => {
render(<AddedMetadataButton />)
const button = screen.getByRole('button')
expect(button).toHaveClass('flex', 'w-full', 'items-center')
})
it('should merge custom className with default classes', () => {
render(<AddedMetadataButton className="my-custom-class" />)
const button = screen.getByRole('button')
expect(button).toHaveClass('flex', 'w-full', 'items-center', 'my-custom-class')
})
})
describe('User Interactions', () => {
it('should call onClick when button is clicked', () => {
const handleClick = vi.fn()
render(<AddedMetadataButton onClick={handleClick} />)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should not throw when onClick is not provided and button is clicked', () => {
render(<AddedMetadataButton />)
expect(() => {
fireEvent.click(screen.getByRole('button'))
}).not.toThrow()
})
it('should call onClick multiple times on multiple clicks', () => {
const handleClick = vi.fn()
render(<AddedMetadataButton onClick={handleClick} />)
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(3)
})
})
describe('Edge Cases', () => {
it('should render with undefined className', () => {
render(<AddedMetadataButton className={undefined} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render with empty className', () => {
render(<AddedMetadataButton className="" />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render with undefined onClick', () => {
render(<AddedMetadataButton onClick={undefined} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,287 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import WrappedDatePicker from './date-picker'
type TriggerArgs = {
handleClickTrigger: () => void
}
type DatePickerProps = {
onChange: (value: Date | null) => void
onClear: () => void
renderTrigger: (args: TriggerArgs) => React.ReactNode
value?: Date
}
// Mock the base date picker component
vi.mock('@/app/components/base/date-and-time-picker/date-picker', () => ({
default: ({ onChange, onClear, renderTrigger, value }: DatePickerProps) => {
const trigger = renderTrigger({
handleClickTrigger: () => {},
})
return (
<div data-testid="date-picker-wrapper">
{trigger}
<button data-testid="select-date" onClick={() => onChange(value || null)}>
Select Date
</button>
<button data-testid="clear-date" onClick={() => onClear()}>
Clear
</button>
</div>
)
},
}))
// Mock useTimestamp hook
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: (timestamp: number) => {
if (!timestamp)
return ''
return new Date(timestamp * 1000).toLocaleDateString()
},
}),
}))
describe('WrappedDatePicker', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const handleChange = vi.fn()
render(<WrappedDatePicker onChange={handleChange} />)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
it('should render placeholder text when no value', () => {
const handleChange = vi.fn()
render(<WrappedDatePicker onChange={handleChange} />)
// When no value, should show placeholder from i18n
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
it('should render formatted date when value is provided', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
it('should render calendar icon', () => {
const handleChange = vi.fn()
const { container } = render(<WrappedDatePicker onChange={handleChange} />)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should render select date button', () => {
const handleChange = vi.fn()
render(<WrappedDatePicker onChange={handleChange} />)
expect(screen.getByTestId('select-date')).toBeInTheDocument()
})
it('should render clear date button', () => {
const handleChange = vi.fn()
render(<WrappedDatePicker onChange={handleChange} />)
expect(screen.getByTestId('clear-date')).toBeInTheDocument()
})
it('should render close icon for clearing', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
const { container } = render(
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
)
// RiCloseCircleFill should be rendered
const closeIcon = container.querySelectorAll('svg')
expect(closeIcon.length).toBeGreaterThan(0)
})
})
describe('Props', () => {
it('should apply custom className', () => {
const handleChange = vi.fn()
const { container } = render(
<WrappedDatePicker className="custom-class" onChange={handleChange} />,
)
const triggerElement = container.querySelector('.custom-class')
expect(triggerElement).toBeInTheDocument()
})
it('should accept undefined value', () => {
const handleChange = vi.fn()
render(<WrappedDatePicker value={undefined} onChange={handleChange} />)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
it('should accept number value', () => {
const handleChange = vi.fn()
const timestamp = 1609459200 // 2021-01-01
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onChange with timestamp when date is selected', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
fireEvent.click(screen.getByTestId('select-date'))
expect(handleChange).toHaveBeenCalled()
})
it('should call onChange with null when date is cleared via onClear', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
fireEvent.click(screen.getByTestId('clear-date'))
expect(handleChange).toHaveBeenCalledWith(null)
})
it('should call onChange with null when close icon is clicked directly', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
const { container } = render(
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
)
// Find the RiCloseCircleFill icon (it has specific classes)
const closeIcon = container.querySelector('.cursor-pointer.hover\\:text-components-input-text-filled')
if (closeIcon) {
fireEvent.click(closeIcon)
expect(handleChange).toHaveBeenCalledWith(null)
}
})
it('should show close button on hover when value exists', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
const { container } = render(
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
)
// The close icon should be present but hidden initially
const triggerGroup = container.querySelector('.group')
expect(triggerGroup).toBeInTheDocument()
})
it('should handle clicking on trigger element', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
const { container } = render(
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
)
const trigger = container.querySelector('.group.flex')
if (trigger)
fireEvent.click(trigger)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have tertiary text color when no value', () => {
const handleChange = vi.fn()
const { container } = render(<WrappedDatePicker onChange={handleChange} />)
const textElement = container.querySelector('.text-text-tertiary')
expect(textElement).toBeInTheDocument()
})
it('should have secondary text color when value exists', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
const { container } = render(
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
)
const textElement = container.querySelector('.text-text-secondary')
expect(textElement).toBeInTheDocument()
})
it('should have input background styling', () => {
const handleChange = vi.fn()
const { container } = render(<WrappedDatePicker onChange={handleChange} />)
const bgElement = container.querySelector('.bg-components-input-bg-normal')
expect(bgElement).toBeInTheDocument()
})
it('should have quaternary text color for close icon when value exists', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
const { container } = render(
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
)
const closeIcon = container.querySelector('.text-text-quaternary')
expect(closeIcon).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle timestamp of 0', () => {
const handleChange = vi.fn()
render(<WrappedDatePicker value={0} onChange={handleChange} />)
// 0 is falsy but is a valid timestamp (epoch)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
it('should handle very large timestamp', () => {
const handleChange = vi.fn()
const farFuture = 4102444800 // 2100-01-01
render(<WrappedDatePicker value={farFuture} onChange={handleChange} />)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
it('should handle switching between no value and value', () => {
const handleChange = vi.fn()
const { rerender } = render(
<WrappedDatePicker onChange={handleChange} />,
)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
const timestamp = Math.floor(Date.now() / 1000)
rerender(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
it('should handle clearing date multiple times', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
fireEvent.click(screen.getByTestId('clear-date'))
fireEvent.click(screen.getByTestId('clear-date'))
fireEvent.click(screen.getByTestId('clear-date'))
expect(handleChange).toHaveBeenCalledTimes(3)
})
it('should handle rapid date selections', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
fireEvent.click(screen.getByTestId('select-date'))
fireEvent.click(screen.getByTestId('select-date'))
fireEvent.click(screen.getByTestId('select-date'))
expect(handleChange).toHaveBeenCalledTimes(3)
})
it('should handle onChange with date object that has unix method', () => {
const handleChange = vi.fn()
render(<WrappedDatePicker onChange={handleChange} />)
// The mock triggers onChange with the value prop
fireEvent.click(screen.getByTestId('select-date'))
// onChange should have been called
expect(handleChange).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,257 @@
import type { MetadataItemWithEdit } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../types'
import AddRow from './add-row'
type InputCombinedProps = {
type: DataType
value: string | number | null
onChange: (value: string | number) => void
}
type LabelProps = {
text: string
}
// Mock InputCombined component
vi.mock('./input-combined', () => ({
default: ({ type, value, onChange }: InputCombinedProps) => (
<input
data-testid="input-combined"
data-type={type}
value={value || ''}
onChange={e => onChange(e.target.value)}
/>
),
}))
// Mock Label component
vi.mock('./label', () => ({
default: ({ text }: LabelProps) => <div data-testid="label">{text}</div>,
}))
describe('AddRow', () => {
const mockPayload: MetadataItemWithEdit = {
id: 'test-id',
name: 'test_field',
type: DataType.string,
value: 'test value',
}
describe('Rendering', () => {
it('should render without crashing', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const { container } = render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render label with payload name', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(screen.getByTestId('label')).toHaveTextContent('test_field')
})
it('should render input combined component', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(screen.getByTestId('input-combined')).toBeInTheDocument()
})
it('should render remove button icon', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const { container } = render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should pass correct type to input combined', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.string)
})
it('should pass correct value to input combined', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(screen.getByTestId('input-combined')).toHaveValue('test value')
})
})
describe('Props', () => {
it('should apply custom className', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const { container } = render(
<AddRow
payload={mockPayload}
onChange={handleChange}
onRemove={handleRemove}
className="custom-class"
/>,
)
expect(container.firstChild).toHaveClass('custom-class')
})
it('should have default flex styling', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const { container } = render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(container.firstChild).toHaveClass('flex', 'h-6', 'items-center', 'space-x-0.5')
})
it('should handle different data types', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const numberPayload: MetadataItemWithEdit = {
...mockPayload,
type: DataType.number,
value: 42,
}
render(
<AddRow payload={numberPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.number)
})
})
describe('User Interactions', () => {
it('should call onChange with updated payload when input changes', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
fireEvent.change(screen.getByTestId('input-combined'), { target: { value: 'new value' } })
expect(handleChange).toHaveBeenCalledWith({
...mockPayload,
value: 'new value',
})
})
it('should call onRemove when remove button is clicked', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const { container } = render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
const removeButton = container.querySelector('.cursor-pointer')
if (removeButton)
fireEvent.click(removeButton)
expect(handleRemove).toHaveBeenCalledTimes(1)
})
it('should preserve other payload properties on change', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
fireEvent.change(screen.getByTestId('input-combined'), { target: { value: 'updated' } })
expect(handleChange).toHaveBeenCalledWith(
expect.objectContaining({
id: 'test-id',
name: 'test_field',
type: DataType.string,
}),
)
})
})
describe('Remove Button Styling', () => {
it('should have hover styling on remove button', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const { container } = render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
const removeButton = container.querySelector('.cursor-pointer')
expect(removeButton).toHaveClass('hover:bg-state-destructive-hover', 'hover:text-text-destructive')
})
})
describe('Edge Cases', () => {
it('should handle null value', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const nullPayload: MetadataItemWithEdit = {
...mockPayload,
value: null,
}
render(
<AddRow payload={nullPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(screen.getByTestId('input-combined')).toBeInTheDocument()
})
it('should handle empty string value', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const emptyPayload: MetadataItemWithEdit = {
...mockPayload,
value: '',
}
render(
<AddRow payload={emptyPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(screen.getByTestId('input-combined')).toHaveValue('')
})
it('should handle time type payload', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const timePayload: MetadataItemWithEdit = {
...mockPayload,
type: DataType.time,
value: 1609459200,
}
render(
<AddRow payload={timePayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.time)
})
it('should handle multiple onRemove calls', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const { container } = render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
const removeButton = container.querySelector('.cursor-pointer')
if (removeButton) {
fireEvent.click(removeButton)
fireEvent.click(removeButton)
fireEvent.click(removeButton)
}
expect(handleRemove).toHaveBeenCalledTimes(3)
})
})
})

View File

@@ -0,0 +1,395 @@
import type { MetadataItemWithEdit } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType, UpdateType } from '../types'
import EditMetadatabatchItem from './edit-row'
type InputCombinedProps = {
type: DataType
value: string | number | null
onChange: (value: string | number) => void
readOnly?: boolean
}
type MultipleValueInputProps = {
onClear: () => void
readOnly?: boolean
}
type LabelProps = {
text: string
isDeleted?: boolean
}
type EditedBeaconProps = {
onReset: () => void
}
// Mock InputCombined component
vi.mock('./input-combined', () => ({
default: ({ type, value, onChange, readOnly }: InputCombinedProps) => (
<input
data-testid="input-combined"
data-type={type}
value={value || ''}
onChange={e => onChange(e.target.value)}
readOnly={readOnly}
/>
),
}))
// Mock InputHasSetMultipleValue component
vi.mock('./input-has-set-multiple-value', () => ({
default: ({ onClear, readOnly }: MultipleValueInputProps) => (
<div data-testid="multiple-value-input" data-readonly={readOnly}>
<button data-testid="clear-multiple" onClick={onClear}>Clear Multiple</button>
</div>
),
}))
// Mock Label component
vi.mock('./label', () => ({
default: ({ text, isDeleted }: LabelProps) => (
<div data-testid="label" data-deleted={isDeleted}>{text}</div>
),
}))
// Mock EditedBeacon component
vi.mock('./edited-beacon', () => ({
default: ({ onReset }: EditedBeaconProps) => (
<button data-testid="edited-beacon" onClick={onReset}>Reset</button>
),
}))
describe('EditMetadatabatchItem', () => {
const mockPayload: MetadataItemWithEdit = {
id: 'test-id',
name: 'test_field',
type: DataType.string,
value: 'test value',
isMultipleValue: false,
isUpdated: false,
}
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(
<EditMetadatabatchItem
payload={mockPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render label with payload name', () => {
render(
<EditMetadatabatchItem
payload={mockPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('label')).toHaveTextContent('test_field')
})
it('should render input combined for single value', () => {
render(
<EditMetadatabatchItem
payload={mockPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('input-combined')).toBeInTheDocument()
})
it('should render multiple value input when isMultipleValue is true', () => {
const multiplePayload: MetadataItemWithEdit = {
...mockPayload,
isMultipleValue: true,
}
render(
<EditMetadatabatchItem
payload={multiplePayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('multiple-value-input')).toBeInTheDocument()
})
it('should render delete button icon', () => {
const { container } = render(
<EditMetadatabatchItem
payload={mockPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
})
describe('Updated State', () => {
it('should show edited beacon when isUpdated is true', () => {
const updatedPayload: MetadataItemWithEdit = {
...mockPayload,
isUpdated: true,
}
render(
<EditMetadatabatchItem
payload={updatedPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('edited-beacon')).toBeInTheDocument()
})
it('should not show edited beacon when isUpdated is false', () => {
render(
<EditMetadatabatchItem
payload={mockPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.queryByTestId('edited-beacon')).not.toBeInTheDocument()
})
})
describe('Deleted State', () => {
it('should pass isDeleted to label when updateType is delete', () => {
const deletedPayload: MetadataItemWithEdit = {
...mockPayload,
updateType: UpdateType.delete,
}
render(
<EditMetadatabatchItem
payload={deletedPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('label')).toHaveAttribute('data-deleted', 'true')
})
it('should set readOnly on input when deleted', () => {
const deletedPayload: MetadataItemWithEdit = {
...mockPayload,
updateType: UpdateType.delete,
}
render(
<EditMetadatabatchItem
payload={deletedPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('input-combined')).toHaveAttribute('readonly')
})
it('should have destructive styling on delete button when deleted', () => {
const deletedPayload: MetadataItemWithEdit = {
...mockPayload,
updateType: UpdateType.delete,
}
const { container } = render(
<EditMetadatabatchItem
payload={deletedPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
const deleteButton = container.querySelector('.bg-state-destructive-hover')
expect(deleteButton).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onChange with updated payload when input changes', () => {
const handleChange = vi.fn()
render(
<EditMetadatabatchItem
payload={mockPayload}
onChange={handleChange}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
fireEvent.change(screen.getByTestId('input-combined'), { target: { value: 'new value' } })
expect(handleChange).toHaveBeenCalledWith(
expect.objectContaining({
...mockPayload,
value: 'new value',
}),
)
})
it('should call onRemove with id when delete button is clicked', () => {
const handleRemove = vi.fn()
const { container } = render(
<EditMetadatabatchItem
payload={mockPayload}
onChange={vi.fn()}
onRemove={handleRemove}
onReset={vi.fn()}
/>,
)
const deleteButton = container.querySelector('.cursor-pointer')
if (deleteButton)
fireEvent.click(deleteButton)
expect(handleRemove).toHaveBeenCalledWith('test-id')
})
it('should call onReset with id when reset beacon is clicked', () => {
const handleReset = vi.fn()
const updatedPayload: MetadataItemWithEdit = {
...mockPayload,
isUpdated: true,
}
render(
<EditMetadatabatchItem
payload={updatedPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={handleReset}
/>,
)
fireEvent.click(screen.getByTestId('edited-beacon'))
expect(handleReset).toHaveBeenCalledWith('test-id')
})
it('should call onChange to clear multiple value', () => {
const handleChange = vi.fn()
const multiplePayload: MetadataItemWithEdit = {
...mockPayload,
isMultipleValue: true,
}
render(
<EditMetadatabatchItem
payload={multiplePayload}
onChange={handleChange}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('clear-multiple'))
expect(handleChange).toHaveBeenCalledWith(
expect.objectContaining({
value: null,
isMultipleValue: false,
}),
)
})
})
describe('Multiple Value State', () => {
it('should render multiple value input when isMultipleValue is true', () => {
const multiplePayload: MetadataItemWithEdit = {
...mockPayload,
isMultipleValue: true,
}
render(
<EditMetadatabatchItem
payload={multiplePayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('multiple-value-input')).toBeInTheDocument()
expect(screen.queryByTestId('input-combined')).not.toBeInTheDocument()
})
it('should pass readOnly to multiple value input when deleted', () => {
const multipleDeletedPayload: MetadataItemWithEdit = {
...mockPayload,
isMultipleValue: true,
updateType: UpdateType.delete,
}
render(
<EditMetadatabatchItem
payload={multipleDeletedPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('multiple-value-input')).toHaveAttribute('data-readonly', 'true')
})
})
describe('Edge Cases', () => {
it('should handle payload with number type', () => {
const numberPayload: MetadataItemWithEdit = {
...mockPayload,
type: DataType.number,
value: 42,
}
render(
<EditMetadatabatchItem
payload={numberPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.number)
})
it('should handle payload with time type', () => {
const timePayload: MetadataItemWithEdit = {
...mockPayload,
type: DataType.time,
value: 1609459200,
}
render(
<EditMetadatabatchItem
payload={timePayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.time)
})
it('should handle null value', () => {
const nullPayload: MetadataItemWithEdit = {
...mockPayload,
value: null,
}
render(
<EditMetadatabatchItem
payload={nullPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('input-combined')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,179 @@
import { fireEvent, render, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import EditedBeacon from './edited-beacon'
describe('EditedBeacon', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with correct size', () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
expect(container.firstChild).toHaveClass('size-4', 'cursor-pointer')
})
it('should render beacon dot by default (not hovering)', () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
// When not hovering, should show the small beacon dot
const beaconDot = container.querySelector('.size-1')
expect(beaconDot).toBeInTheDocument()
})
})
describe('Hover State', () => {
it('should show reset icon on hover', async () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
const wrapper = container.firstChild as HTMLElement
fireEvent.mouseEnter(wrapper)
await waitFor(() => {
// On hover, should show the reset icon (RiResetLeftLine)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
})
it('should show beacon dot when not hovering', () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
// By default (not hovering), should show beacon dot
const beaconDot = container.querySelector('.size-1.rounded-full.bg-text-accent-secondary')
expect(beaconDot).toBeInTheDocument()
})
it('should hide beacon dot on hover', async () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
const wrapper = container.firstChild as HTMLElement
fireEvent.mouseEnter(wrapper)
await waitFor(() => {
// On hover, the small beacon dot should be hidden
const beaconDot = container.querySelector('.size-1.rounded-full.bg-text-accent-secondary')
expect(beaconDot).not.toBeInTheDocument()
})
})
it('should show beacon dot again on mouse leave', async () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
const wrapper = container.firstChild as HTMLElement
// Hover
fireEvent.mouseEnter(wrapper)
await waitFor(() => {
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
// Leave
fireEvent.mouseLeave(wrapper)
await waitFor(() => {
const beaconDot = container.querySelector('.size-1.rounded-full.bg-text-accent-secondary')
expect(beaconDot).toBeInTheDocument()
})
})
})
describe('User Interactions', () => {
it('should call onReset when reset button is clicked', async () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
const wrapper = container.firstChild as HTMLElement
// Hover to show reset button
fireEvent.mouseEnter(wrapper)
await waitFor(() => {
const resetButton = container.querySelector('.bg-text-accent-secondary')
expect(resetButton).toBeInTheDocument()
})
// Find and click the reset button (the clickable element with onClick)
const clickableElement = container.querySelector('.flex.size-4.items-center.justify-center.rounded-full.bg-text-accent-secondary')
if (clickableElement) {
fireEvent.click(clickableElement)
}
expect(handleReset).toHaveBeenCalledTimes(1)
})
it('should not call onReset when clicking beacon dot (not hovering)', () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
// Click on the wrapper when not hovering
const wrapper = container.firstChild as HTMLElement
fireEvent.click(wrapper)
// onReset should not be called because we're not hovering
expect(handleReset).not.toHaveBeenCalled()
})
})
describe('Tooltip', () => {
it('should render tooltip on hover', async () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
const wrapper = container.firstChild as HTMLElement
fireEvent.mouseEnter(wrapper)
// Tooltip should be rendered (it wraps the reset button)
await waitFor(() => {
const resetIcon = container.querySelector('svg')
expect(resetIcon).toBeInTheDocument()
})
})
})
describe('Edge Cases', () => {
it('should handle multiple hover/leave cycles', async () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
const wrapper = container.firstChild as HTMLElement
for (let i = 0; i < 3; i++) {
fireEvent.mouseEnter(wrapper)
await waitFor(() => {
expect(container.querySelector('svg')).toBeInTheDocument()
})
fireEvent.mouseLeave(wrapper)
await waitFor(() => {
expect(container.querySelector('.size-1.rounded-full')).toBeInTheDocument()
})
}
})
it('should handle rapid hover/leave', async () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
const wrapper = container.firstChild as HTMLElement
// Rapid hover/leave
fireEvent.mouseEnter(wrapper)
fireEvent.mouseLeave(wrapper)
fireEvent.mouseEnter(wrapper)
await waitFor(() => {
expect(container.querySelector('svg')).toBeInTheDocument()
})
})
})
})

View File

@@ -0,0 +1,269 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../types'
import InputCombined from './input-combined'
type DatePickerProps = {
value: number | null
onChange: (value: number) => void
className?: string
}
// Mock the base date-picker component
vi.mock('../base/date-picker', () => ({
default: ({ value, onChange, className }: DatePickerProps) => (
<div data-testid="date-picker" className={className} onClick={() => onChange(Date.now())}>
{value || 'Pick date'}
</div>
),
}))
describe('InputCombined', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const handleChange = vi.fn()
const { container } = render(
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render text input for string type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value="test" onChange={handleChange} />,
)
const input = screen.getByDisplayValue('test')
expect(input).toBeInTheDocument()
expect(input.tagName.toLowerCase()).toBe('input')
})
it('should render number input for number type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={42} onChange={handleChange} />,
)
const input = screen.getByDisplayValue('42')
expect(input).toBeInTheDocument()
})
it('should render date picker for time type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.time} value={Date.now()} onChange={handleChange} />,
)
expect(screen.getByTestId('date-picker')).toBeInTheDocument()
})
})
describe('String Input', () => {
it('should call onChange with input value for string type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'new value' } })
expect(handleChange).toHaveBeenCalledWith('new value')
})
it('should display current value for string type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value="existing value" onChange={handleChange} />,
)
expect(screen.getByDisplayValue('existing value')).toBeInTheDocument()
})
it('should apply readOnly prop to string input', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value="test" onChange={handleChange} readOnly />,
)
const input = screen.getByRole('textbox')
expect(input).toHaveAttribute('readonly')
})
})
describe('Number Input', () => {
it('should call onChange with number value for number type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
)
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '123' } })
expect(handleChange).toHaveBeenCalled()
})
it('should display current value for number type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={999} onChange={handleChange} />,
)
expect(screen.getByDisplayValue('999')).toBeInTheDocument()
})
it('should apply readOnly prop to number input', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={42} onChange={handleChange} readOnly />,
)
const input = screen.getByRole('spinbutton')
expect(input).toHaveAttribute('readonly')
})
})
describe('Time/Date Input', () => {
it('should render date picker for time type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.time} value={1234567890} onChange={handleChange} />,
)
expect(screen.getByTestId('date-picker')).toBeInTheDocument()
})
it('should call onChange when date is selected', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.time} value={null} onChange={handleChange} />,
)
fireEvent.click(screen.getByTestId('date-picker'))
expect(handleChange).toHaveBeenCalled()
})
})
describe('Props', () => {
it('should apply custom className', () => {
const handleChange = vi.fn()
const { container } = render(
<InputCombined
type={DataType.string}
value=""
onChange={handleChange}
className="custom-class"
/>,
)
// Check that custom class is applied to wrapper
const wrapper = container.querySelector('.custom-class')
expect(wrapper).toBeInTheDocument()
})
it('should handle null value for string type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value={null} onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
})
it('should handle undefined value for string type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value={undefined as unknown as string} onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
})
it('should handle null value for number type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={null} onChange={handleChange} />,
)
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have correct base styling for string input', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
expect(input).toHaveClass('h-6', 'grow', 'p-0.5', 'text-xs', 'rounded-md')
})
it('should have correct styling for number input', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
)
const input = screen.getByRole('spinbutton')
expect(input).toHaveClass('rounded-l-md')
})
})
describe('Edge Cases', () => {
it('should handle empty string value', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
expect(input).toHaveValue('')
})
it('should handle zero value for number', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
)
expect(screen.getByDisplayValue('0')).toBeInTheDocument()
})
it('should handle negative number', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={-100} onChange={handleChange} />,
)
expect(screen.getByDisplayValue('-100')).toBeInTheDocument()
})
it('should handle special characters in string', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value={'<script>alert("xss")</script>'} onChange={handleChange} />,
)
expect(screen.getByDisplayValue('<script>alert("xss")</script>')).toBeInTheDocument()
})
it('should handle switching between types', () => {
const handleChange = vi.fn()
const { rerender } = render(
<InputCombined type={DataType.string} value="test" onChange={handleChange} />,
)
expect(screen.getByRole('textbox')).toBeInTheDocument()
rerender(
<InputCombined type={DataType.number} value={42} onChange={handleChange} />,
)
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,147 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import InputHasSetMultipleValue from './input-has-set-multiple-value'
describe('InputHasSetMultipleValue', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with correct wrapper styling', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
expect(container.firstChild).toHaveClass('h-6', 'grow', 'rounded-md', 'bg-components-input-bg-normal', 'p-0.5')
})
it('should render multiple value text', () => {
const handleClear = vi.fn()
render(<InputHasSetMultipleValue onClear={handleClear} />)
// The text should come from i18n
expect(screen.getByText(/multipleValue|Multiple/i)).toBeInTheDocument()
})
it('should render close icon when not readOnly', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
// Should have close icon (RiCloseLine)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
})
describe('Props', () => {
it('should not show close icon when readOnly is true', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly />)
// Should not have close icon
const svg = container.querySelector('svg')
expect(svg).not.toBeInTheDocument()
})
it('should show close icon when readOnly is false', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly={false} />)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should show close icon when readOnly is undefined', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly={undefined} />)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should apply pr-1.5 padding when readOnly', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly />)
const badge = container.querySelector('.inline-flex')
expect(badge).toHaveClass('pr-1.5')
})
it('should apply pr-0.5 padding when not readOnly', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
const badge = container.querySelector('.inline-flex')
expect(badge).toHaveClass('pr-0.5')
})
})
describe('User Interactions', () => {
it('should call onClear when close icon is clicked', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
const closeIcon = container.querySelector('svg')
expect(closeIcon).toBeInTheDocument()
if (closeIcon) {
fireEvent.click(closeIcon)
}
expect(handleClear).toHaveBeenCalledTimes(1)
})
it('should not call onClear when readOnly and clicking on component', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly />)
// Click on the wrapper
fireEvent.click(container.firstChild as HTMLElement)
expect(handleClear).not.toHaveBeenCalled()
})
it('should call onClear multiple times on multiple clicks', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
const closeIcon = container.querySelector('svg')
if (closeIcon) {
fireEvent.click(closeIcon)
fireEvent.click(closeIcon)
fireEvent.click(closeIcon)
}
expect(handleClear).toHaveBeenCalledTimes(3)
})
})
describe('Styling', () => {
it('should have badge styling', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
const badge = container.querySelector('.inline-flex')
expect(badge).toHaveClass('h-5', 'items-center', 'rounded-[5px]', 'border-[0.5px]')
})
it('should have hover styles on close button wrapper', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
const closeWrapper = container.querySelector('.cursor-pointer')
expect(closeWrapper).toHaveClass('hover:bg-state-base-hover', 'hover:text-text-secondary')
})
})
describe('Edge Cases', () => {
it('should render correctly when switching readOnly state', () => {
const handleClear = vi.fn()
const { container, rerender } = render(<InputHasSetMultipleValue onClear={handleClear} />)
// Initially not readOnly
expect(container.querySelector('svg')).toBeInTheDocument()
// Switch to readOnly
rerender(<InputHasSetMultipleValue onClear={handleClear} readOnly />)
expect(container.querySelector('svg')).not.toBeInTheDocument()
// Switch back to not readOnly
rerender(<InputHasSetMultipleValue onClear={handleClear} readOnly={false} />)
expect(container.querySelector('svg')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,113 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import Label from './label'
describe('Label', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Label text="Test Label" />)
expect(screen.getByText('Test Label')).toBeInTheDocument()
})
it('should render text with correct styling', () => {
render(<Label text="My Label" />)
const labelElement = screen.getByText('My Label')
expect(labelElement).toHaveClass('system-xs-medium', 'w-[136px]', 'shrink-0', 'truncate', 'text-text-tertiary')
})
it('should not have deleted styling by default', () => {
render(<Label text="Label" />)
const labelElement = screen.getByText('Label')
expect(labelElement).not.toHaveClass('text-text-quaternary', 'line-through')
})
})
describe('Props', () => {
it('should apply custom className', () => {
render(<Label text="Label" className="custom-class" />)
const labelElement = screen.getByText('Label')
expect(labelElement).toHaveClass('custom-class')
})
it('should merge custom className with default classes', () => {
render(<Label text="Label" className="my-custom-class" />)
const labelElement = screen.getByText('Label')
expect(labelElement).toHaveClass('system-xs-medium', 'my-custom-class')
})
it('should apply deleted styling when isDeleted is true', () => {
render(<Label text="Label" isDeleted />)
const labelElement = screen.getByText('Label')
expect(labelElement).toHaveClass('text-text-quaternary', 'line-through')
})
it('should not apply deleted styling when isDeleted is false', () => {
render(<Label text="Label" isDeleted={false} />)
const labelElement = screen.getByText('Label')
expect(labelElement).not.toHaveClass('text-text-quaternary', 'line-through')
})
it('should render different text values', () => {
const { rerender } = render(<Label text="First" />)
expect(screen.getByText('First')).toBeInTheDocument()
rerender(<Label text="Second" />)
expect(screen.getByText('Second')).toBeInTheDocument()
})
})
describe('Deleted State', () => {
it('should have strikethrough when deleted', () => {
render(<Label text="Deleted Label" isDeleted />)
const labelElement = screen.getByText('Deleted Label')
expect(labelElement).toHaveClass('line-through')
})
it('should have quaternary text color when deleted', () => {
render(<Label text="Deleted Label" isDeleted />)
const labelElement = screen.getByText('Deleted Label')
expect(labelElement).toHaveClass('text-text-quaternary')
})
it('should combine deleted styling with custom className', () => {
render(<Label text="Label" isDeleted className="custom" />)
const labelElement = screen.getByText('Label')
expect(labelElement).toHaveClass('line-through', 'custom')
})
})
describe('Edge Cases', () => {
it('should render with empty text', () => {
const { container } = render(<Label text="" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with long text (truncation)', () => {
const longText = 'This is a very long label text that should be truncated'
render(<Label text={longText} />)
const labelElement = screen.getByText(longText)
expect(labelElement).toHaveClass('truncate')
})
it('should render with undefined className', () => {
render(<Label text="Label" className={undefined} />)
expect(screen.getByText('Label')).toBeInTheDocument()
})
it('should render with undefined isDeleted', () => {
render(<Label text="Label" isDeleted={undefined} />)
const labelElement = screen.getByText('Label')
expect(labelElement).not.toHaveClass('line-through')
})
it('should handle special characters in text', () => {
render(<Label text={'Label & "chars"'} />)
expect(screen.getByText('Label & "chars"')).toBeInTheDocument()
})
it('should handle numbers in text', () => {
render(<Label text="Label 123" />)
expect(screen.getByText('Label 123')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,548 @@
import type { MetadataItemInBatchEdit, MetadataItemWithEdit } from '../types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType, UpdateType } from '../types'
import EditMetadataBatchModal from './modal'
// Mock service/API calls
const mockDoAddMetaData = vi.fn().mockResolvedValue({})
vi.mock('@/service/knowledge/use-metadata', () => ({
useCreateMetaData: () => ({
mutate: mockDoAddMetaData,
}),
useDatasetMetaData: () => ({
data: {
doc_metadata: [
{ id: 'existing-1', name: 'existing_field', type: DataType.string },
{ id: 'existing-2', name: 'another_field', type: DataType.number },
],
},
}),
}))
// Mock check name hook to control validation
let mockCheckNameResult = { errorMsg: '' }
vi.mock('../hooks/use-check-metadata-name', () => ({
default: () => ({
checkName: () => mockCheckNameResult,
}),
}))
// Mock Toast to verify notifications
const mockToastNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: (args: unknown) => mockToastNotify(args),
},
}))
// Type definitions for mock props
type EditRowProps = {
payload: MetadataItemWithEdit
onChange: (item: MetadataItemWithEdit) => void
onRemove: (id: string) => void
onReset: (id: string) => void
}
type AddRowProps = {
payload: MetadataItemWithEdit
onChange: (item: MetadataItemWithEdit) => void
onRemove: () => void
}
type SelectModalProps = {
trigger: React.ReactNode
onSelect: (item: MetadataItemInBatchEdit) => void
onSave: (data: { name: string, type: DataType }) => Promise<void>
onManage: () => void
}
// Mock child components with callback exposure
vi.mock('./edit-row', () => ({
default: ({ payload, onChange, onRemove, onReset }: EditRowProps) => (
<div data-testid="edit-row" data-id={payload.id}>
<span data-testid="edit-row-name">{payload.name}</span>
<button data-testid={`change-${payload.id}`} onClick={() => onChange({ ...payload, value: 'changed', isUpdated: true, updateType: UpdateType.changeValue })}>Change</button>
<button data-testid={`remove-${payload.id}`} onClick={() => onRemove(payload.id)}>Remove</button>
<button data-testid={`reset-${payload.id}`} onClick={() => onReset(payload.id)}>Reset</button>
</div>
),
}))
vi.mock('./add-row', () => ({
default: ({ payload, onChange, onRemove }: AddRowProps) => (
<div data-testid="add-row" data-id={payload.id}>
<span data-testid="add-row-name">{payload.name}</span>
<button data-testid={`add-change-${payload.id}`} onClick={() => onChange({ ...payload, value: 'new_value' })}>Change</button>
<button data-testid="add-remove" onClick={onRemove}>Remove</button>
</div>
),
}))
vi.mock('../metadata-dataset/select-metadata-modal', () => ({
default: ({ trigger, onSelect, onSave, onManage }: SelectModalProps) => (
<div data-testid="select-modal">
{trigger}
<button data-testid="select-metadata" onClick={() => onSelect({ id: 'new-1', name: 'new_field', type: DataType.string, value: null, isMultipleValue: false })}>Select</button>
<button data-testid="save-metadata" onClick={() => onSave({ name: 'created_field', type: DataType.string }).catch(() => {})}>Save</button>
<button data-testid="manage-metadata" onClick={onManage}>Manage</button>
</div>
),
}))
describe('EditMetadataBatchModal', () => {
const mockList: MetadataItemInBatchEdit[] = [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1', isMultipleValue: false },
{ id: '2', name: 'field_two', type: DataType.number, value: 42, isMultipleValue: false },
]
const defaultProps = {
datasetId: 'ds-1',
documentNum: 5,
list: mockList,
onSave: vi.fn(),
onHide: vi.fn(),
onShowManage: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
mockCheckNameResult = { errorMsg: '' }
})
describe('Rendering', () => {
it('should render without crashing', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
it('should render document count', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByText(/5/)).toBeInTheDocument()
})
})
it('should render all edit rows for existing items', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
const editRows = screen.getAllByTestId('edit-row')
expect(editRows).toHaveLength(2)
})
})
it('should render field names for existing items', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByText('field_one')).toBeInTheDocument()
expect(screen.getByText('field_two')).toBeInTheDocument()
})
})
it('should render checkbox for apply to all', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
const checkboxes = document.querySelectorAll('[data-testid*="checkbox"]')
expect(checkboxes.length).toBeGreaterThan(0)
})
})
it('should render select metadata modal', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByTestId('select-modal')).toBeInTheDocument()
})
})
})
describe('User Interactions', () => {
it('should call onHide when cancel button is clicked', async () => {
const onHide = vi.fn()
render(<EditMetadataBatchModal {...defaultProps} onHide={onHide} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
const cancelButton = screen.getByText(/cancel/i)
fireEvent.click(cancelButton)
expect(onHide).toHaveBeenCalled()
})
it('should call onSave when save button is clicked', async () => {
const onSave = vi.fn()
render(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Find the primary save button (not the one in SelectMetadataModal)
const saveButtons = screen.getAllByText(/save/i)
const modalSaveButton = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
if (modalSaveButton)
fireEvent.click(modalSaveButton)
expect(onSave).toHaveBeenCalled()
})
it('should toggle apply to all checkbox', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
const checkboxContainer = document.querySelector('[data-testid*="checkbox"]')
expect(checkboxContainer).toBeInTheDocument()
if (checkboxContainer) {
fireEvent.click(checkboxContainer)
await waitFor(() => {
const checkIcon = checkboxContainer.querySelector('svg')
expect(checkIcon).toBeInTheDocument()
})
}
})
it('should call onHide when modal close button is clicked', async () => {
const onHide = vi.fn()
render(<EditMetadataBatchModal {...defaultProps} onHide={onHide} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
})
describe('Edit Row Operations', () => {
it('should update item value when change is triggered', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('change-1'))
// The component should update internally
expect(screen.getAllByTestId('edit-row').length).toBe(2)
})
it('should mark item as deleted when remove is clicked', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('remove-1'))
// The component should update internally - item marked as deleted
expect(screen.getAllByTestId('edit-row').length).toBe(2)
})
it('should reset item when reset is clicked', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// First change the item
fireEvent.click(screen.getByTestId('change-1'))
// Then reset it
fireEvent.click(screen.getByTestId('reset-1'))
expect(screen.getAllByTestId('edit-row').length).toBe(2)
})
})
describe('Add Metadata Operations', () => {
it('should add new item when metadata is selected', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('select-metadata'))
// Should now have add-row for the new item
await waitFor(() => {
expect(screen.getByTestId('add-row')).toBeInTheDocument()
})
})
it('should remove added item when remove is clicked', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// First add an item
fireEvent.click(screen.getByTestId('select-metadata'))
await waitFor(() => {
expect(screen.getByTestId('add-row')).toBeInTheDocument()
})
// Then remove it
fireEvent.click(screen.getByTestId('add-remove'))
await waitFor(() => {
expect(screen.queryByTestId('add-row')).not.toBeInTheDocument()
})
})
it('should update added item when change is triggered', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// First add an item
fireEvent.click(screen.getByTestId('select-metadata'))
await waitFor(() => {
expect(screen.getByTestId('add-row')).toBeInTheDocument()
})
// Then change it
fireEvent.click(screen.getByTestId('add-change-new-1'))
expect(screen.getByTestId('add-row')).toBeInTheDocument()
})
it('should call doAddMetaData when saving new metadata with valid name', async () => {
mockCheckNameResult = { errorMsg: '' }
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('save-metadata'))
await waitFor(() => {
expect(mockDoAddMetaData).toHaveBeenCalled()
})
})
it('should show success toast when saving with valid name', async () => {
mockCheckNameResult = { errorMsg: '' }
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('save-metadata'))
await waitFor(() => {
expect(mockDoAddMetaData).toHaveBeenCalled()
})
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
}),
)
})
})
it('should show error toast when saving with invalid name', async () => {
mockCheckNameResult = { errorMsg: 'Name already exists' }
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('save-metadata'))
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
message: 'Name already exists',
}),
)
})
})
it('should call onShowManage when manage is clicked', async () => {
const onShowManage = vi.fn()
render(<EditMetadataBatchModal {...defaultProps} onShowManage={onShowManage} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('manage-metadata'))
expect(onShowManage).toHaveBeenCalled()
})
})
describe('Props', () => {
it('should pass correct datasetId', async () => {
render(<EditMetadataBatchModal {...defaultProps} datasetId="custom-ds" />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
it('should display correct document number', async () => {
render(<EditMetadataBatchModal {...defaultProps} documentNum={10} />)
await waitFor(() => {
expect(screen.getByText(/10/)).toBeInTheDocument()
})
})
it('should handle empty list', async () => {
render(<EditMetadataBatchModal {...defaultProps} list={[]} />)
await waitFor(() => {
expect(screen.queryByTestId('edit-row')).not.toBeInTheDocument()
})
})
})
describe('Edge Cases', () => {
it('should handle list with multiple value items', async () => {
const multipleValueList: MetadataItemInBatchEdit[] = [
{ id: '1', name: 'field', type: DataType.string, value: null, isMultipleValue: true },
]
render(<EditMetadataBatchModal {...defaultProps} list={multipleValueList} />)
await waitFor(() => {
expect(screen.getByTestId('edit-row')).toBeInTheDocument()
})
})
it('should handle rapid save clicks', async () => {
const onSave = vi.fn()
render(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Find the primary save button
const saveButtons = screen.getAllByText(/save/i)
const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
if (saveBtn) {
fireEvent.click(saveBtn)
fireEvent.click(saveBtn)
fireEvent.click(saveBtn)
}
expect(onSave).toHaveBeenCalledTimes(3)
})
it('should pass correct arguments to onSave', async () => {
const onSave = vi.fn()
render(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
const saveButtons = screen.getAllByText(/save/i)
const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
if (saveBtn)
fireEvent.click(saveBtn)
expect(onSave).toHaveBeenCalledWith(
expect.any(Array),
expect.any(Array),
expect.any(Boolean),
)
})
it('should pass isApplyToAllSelectDocument as true when checked', async () => {
const onSave = vi.fn()
render(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
const checkboxContainer = document.querySelector('[data-testid*="checkbox"]')
if (checkboxContainer)
fireEvent.click(checkboxContainer)
const saveButtons = screen.getAllByText(/save/i)
const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
if (saveBtn)
fireEvent.click(saveBtn)
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(
expect.any(Array),
expect.any(Array),
true,
)
})
})
it('should filter out deleted items when saving', async () => {
const onSave = vi.fn()
render(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Remove an item
fireEvent.click(screen.getByTestId('remove-1'))
// Save
const saveButtons = screen.getAllByText(/save/i)
const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
if (saveBtn)
fireEvent.click(saveBtn)
expect(onSave).toHaveBeenCalled()
// The first argument should not contain the deleted item (id '1')
const savedList = onSave.mock.calls[0][0] as MetadataItemInBatchEdit[]
const hasDeletedItem = savedList.some(item => item.id === '1')
expect(hasDeletedItem).toBe(false)
})
it('should handle multiple add and remove operations', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Add first item
fireEvent.click(screen.getByTestId('select-metadata'))
await waitFor(() => {
expect(screen.getByTestId('add-row')).toBeInTheDocument()
})
// Remove it
fireEvent.click(screen.getByTestId('add-remove'))
await waitFor(() => {
expect(screen.queryByTestId('add-row')).not.toBeInTheDocument()
})
// Add again
fireEvent.click(screen.getByTestId('select-metadata'))
await waitFor(() => {
expect(screen.getByTestId('add-row')).toBeInTheDocument()
})
})
})
})

View File

@@ -0,0 +1,647 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType, UpdateType } from '../types'
import useBatchEditDocumentMetadata from './use-batch-edit-document-metadata'
type DocMetadataItem = {
id: string
name: string
type: DataType
value: string | number | null
}
type DocListItem = {
id: string
name?: string
doc_metadata?: DocMetadataItem[] | null
}
type MetadataItemWithEdit = {
id: string
name: string
type: DataType
value: string | number | null
isMultipleValue?: boolean
updateType?: UpdateType
}
// Mock useBatchUpdateDocMetadata
const mockMutateAsync = vi.fn().mockResolvedValue({})
vi.mock('@/service/knowledge/use-metadata', () => ({
useBatchUpdateDocMetadata: () => ({
mutateAsync: mockMutateAsync,
}),
}))
// Mock Toast
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
describe('useBatchEditDocumentMetadata', () => {
const mockDocList: DocListItem[] = [
{
id: 'doc-1',
name: 'Document 1',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
{ id: '2', name: 'field_two', type: DataType.number, value: 42 },
],
},
{
id: 'doc-2',
name: 'Document 2',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 2' },
],
},
]
const defaultProps = {
datasetId: 'ds-1',
docList: mockDocList as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
onUpdate: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Hook Initialization', () => {
it('should initialize with isShowEditModal as false', () => {
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
expect(result.current.isShowEditModal).toBe(false)
})
it('should return showEditModal function', () => {
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
expect(typeof result.current.showEditModal).toBe('function')
})
it('should return hideEditModal function', () => {
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
expect(typeof result.current.hideEditModal).toBe('function')
})
it('should return originalList', () => {
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
expect(Array.isArray(result.current.originalList)).toBe(true)
})
it('should return handleSave function', () => {
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
expect(typeof result.current.handleSave).toBe('function')
})
})
describe('Modal Control', () => {
it('should show modal when showEditModal is called', () => {
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
act(() => {
result.current.showEditModal()
})
expect(result.current.isShowEditModal).toBe(true)
})
it('should hide modal when hideEditModal is called', () => {
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
act(() => {
result.current.showEditModal()
})
act(() => {
result.current.hideEditModal()
})
expect(result.current.isShowEditModal).toBe(false)
})
})
describe('Original List Processing', () => {
it('should compute originalList from docList metadata', () => {
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
expect(result.current.originalList.length).toBeGreaterThan(0)
})
it('should filter out built-in metadata', () => {
const docListWithBuiltIn: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: 'built-in', name: 'created_at', type: DataType.time, value: 123 },
{ id: '1', name: 'custom', type: DataType.string, value: 'test' },
],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListWithBuiltIn as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
const hasBuiltIn = result.current.originalList.some(item => item.id === 'built-in')
expect(hasBuiltIn).toBe(false)
})
it('should mark items with multiple values', () => {
const docListWithDifferentValues: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field', type: DataType.string, value: 'Value A' },
],
},
{
id: 'doc-2',
doc_metadata: [
{ id: '1', name: 'field', type: DataType.string, value: 'Value B' },
],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListWithDifferentValues as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
const fieldItem = result.current.originalList.find(item => item.id === '1')
expect(fieldItem?.isMultipleValue).toBe(true)
})
it('should not mark items with same values as multiple', () => {
const docListWithSameValues: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field', type: DataType.string, value: 'Same Value' },
],
},
{
id: 'doc-2',
doc_metadata: [
{ id: '1', name: 'field', type: DataType.string, value: 'Same Value' },
],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListWithSameValues as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
const fieldItem = result.current.originalList.find(item => item.id === '1')
expect(fieldItem?.isMultipleValue).toBe(false)
})
it('should skip already marked multiple value items', () => {
// Three docs with same field but different values
const docListThreeDocs: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field', type: DataType.string, value: 'Value A' },
],
},
{
id: 'doc-2',
doc_metadata: [
{ id: '1', name: 'field', type: DataType.string, value: 'Value B' },
],
},
{
id: 'doc-3',
doc_metadata: [
{ id: '1', name: 'field', type: DataType.string, value: 'Value C' },
],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListThreeDocs as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
// Should only have one item for field '1', marked as multiple
const fieldItems = result.current.originalList.filter(item => item.id === '1')
expect(fieldItems.length).toBe(1)
expect(fieldItems[0].isMultipleValue).toBe(true)
})
})
describe('handleSave', () => {
it('should call mutateAsync with correct data', async () => {
const onUpdate = vi.fn()
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({ ...defaultProps, onUpdate }),
)
await act(async () => {
await result.current.handleSave([], [], false)
})
expect(mockMutateAsync).toHaveBeenCalled()
})
it('should call onUpdate after successful save', async () => {
const onUpdate = vi.fn()
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({ ...defaultProps, onUpdate }),
)
await act(async () => {
await result.current.handleSave([], [], false)
})
await waitFor(() => {
expect(onUpdate).toHaveBeenCalled()
})
})
it('should hide modal after successful save', async () => {
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
act(() => {
result.current.showEditModal()
})
expect(result.current.isShowEditModal).toBe(true)
await act(async () => {
await result.current.handleSave([], [], false)
})
await waitFor(() => {
expect(result.current.isShowEditModal).toBe(false)
})
})
it('should handle edited items with changeValue updateType', async () => {
const docListSingleDoc: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Old Value' },
],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
const editedList: MetadataItemWithEdit[] = [
{
id: '1',
name: 'field_one',
type: DataType.string,
value: 'New Value',
updateType: UpdateType.changeValue,
},
]
await act(async () => {
await result.current.handleSave(editedList, [], false)
})
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
metadata_list: expect.arrayContaining([
expect.objectContaining({
document_id: 'doc-1',
metadata_list: expect.arrayContaining([
expect.objectContaining({
id: '1',
value: 'New Value',
}),
]),
}),
]),
}),
)
})
it('should handle removed items', async () => {
const docListSingleDoc: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
{ id: '2', name: 'field_two', type: DataType.number, value: 42 },
],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
// Only pass field_one in editedList, field_two should be removed
const editedList: MetadataItemWithEdit[] = [
{
id: '1',
name: 'field_one',
type: DataType.string,
value: 'Value 1',
},
]
await act(async () => {
await result.current.handleSave(editedList, [], false)
})
expect(mockMutateAsync).toHaveBeenCalled()
})
it('should handle added items', async () => {
const docListSingleDoc: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
const addedList = [
{
id: 'new-1',
name: 'new_field',
type: DataType.string,
value: 'New Value',
isMultipleValue: false,
},
]
await act(async () => {
await result.current.handleSave([], addedList, false)
})
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
metadata_list: expect.arrayContaining([
expect.objectContaining({
metadata_list: expect.arrayContaining([
expect.objectContaining({
name: 'new_field',
}),
]),
}),
]),
}),
)
})
it('should add missing metadata when isApplyToAllSelectDocument is true', async () => {
// Doc 1 has field, Doc 2 doesn't have it
const docListMissingField: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
],
},
{
id: 'doc-2',
doc_metadata: [],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListMissingField as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
const editedList: MetadataItemWithEdit[] = [
{
id: '1',
name: 'field_one',
type: DataType.string,
value: 'Updated Value',
isMultipleValue: false,
updateType: UpdateType.changeValue,
},
]
await act(async () => {
await result.current.handleSave(editedList, [], true)
})
// Both documents should have the field after applying to all
expect(mockMutateAsync).toHaveBeenCalled()
const callArgs = mockMutateAsync.mock.calls[0][0]
expect(callArgs.metadata_list.length).toBe(2)
})
it('should not add missing metadata for multiple value items when isApplyToAllSelectDocument is true', async () => {
// Two docs with different values for same field
const docListDifferentValues: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value A' },
],
},
{
id: 'doc-2',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value B' },
],
},
{
id: 'doc-3',
doc_metadata: [],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListDifferentValues as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
// Mark it as multiple value item - should not be added to doc-3
const editedList: MetadataItemWithEdit[] = [
{
id: '1',
name: 'field_one',
type: DataType.string,
value: null,
isMultipleValue: true,
updateType: UpdateType.changeValue,
},
]
await act(async () => {
await result.current.handleSave(editedList, [], true)
})
expect(mockMutateAsync).toHaveBeenCalled()
})
it('should update existing items in the list', async () => {
const docListSingleDoc: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Old Value' },
{ id: '2', name: 'field_two', type: DataType.number, value: 100 },
],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
// Edit both items
const editedList: MetadataItemWithEdit[] = [
{
id: '1',
name: 'field_one',
type: DataType.string,
value: 'New Value 1',
updateType: UpdateType.changeValue,
},
{
id: '2',
name: 'field_two',
type: DataType.number,
value: 200,
updateType: UpdateType.changeValue,
},
]
await act(async () => {
await result.current.handleSave(editedList, [], false)
})
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
metadata_list: expect.arrayContaining([
expect.objectContaining({
metadata_list: expect.arrayContaining([
expect.objectContaining({ id: '1', value: 'New Value 1' }),
expect.objectContaining({ id: '2', value: 200 }),
]),
}),
]),
}),
)
})
})
describe('Selected Document IDs', () => {
it('should use selectedDocumentIds when provided', async () => {
const selectedIds = ['doc-1']
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
selectedDocumentIds: selectedIds,
}),
)
await act(async () => {
await result.current.handleSave([], [], false)
})
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
dataset_id: 'ds-1',
metadata_list: expect.arrayContaining([
expect.objectContaining({
document_id: 'doc-1',
}),
]),
}),
)
})
it('should handle selectedDocumentIds not in docList', async () => {
// Select a document that's not in docList
const selectedIds = ['doc-1', 'doc-not-in-list']
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
selectedDocumentIds: selectedIds,
}),
)
await act(async () => {
await result.current.handleSave([], [], false)
})
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
metadata_list: expect.arrayContaining([
expect.objectContaining({
document_id: 'doc-not-in-list',
partial_update: true,
}),
]),
}),
)
})
})
describe('Edge Cases', () => {
it('should handle empty docList', () => {
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: [] as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
expect(result.current.originalList).toEqual([])
})
it('should handle documents without metadata', () => {
const docListNoMetadata: DocListItem[] = [
{ id: 'doc-1', name: 'Doc 1' },
{ id: 'doc-2', name: 'Doc 2', doc_metadata: null },
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListNoMetadata as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
expect(result.current.originalList).toEqual([])
})
})
})

View File

@@ -0,0 +1,166 @@
import { renderHook } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import useCheckMetadataName from './use-check-metadata-name'
describe('useCheckMetadataName', () => {
describe('Hook Initialization', () => {
it('should return an object with checkName function', () => {
const { result } = renderHook(() => useCheckMetadataName())
expect(result.current).toHaveProperty('checkName')
expect(typeof result.current.checkName).toBe('function')
})
})
describe('checkName - Empty Name Validation', () => {
it('should return error for empty string', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('')
expect(errorMsg).toBeTruthy()
})
it('should return error for whitespace-only string', () => {
const { result } = renderHook(() => useCheckMetadataName())
// Whitespace is not valid since it doesn't match the pattern
const { errorMsg } = result.current.checkName(' ')
expect(errorMsg).toBeTruthy()
})
})
describe('checkName - Pattern Validation', () => {
it('should return error for name starting with number', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('1name')
expect(errorMsg).toBeTruthy()
})
it('should return error for name starting with uppercase', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('Name')
expect(errorMsg).toBeTruthy()
})
it('should return error for name starting with underscore', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('_name')
expect(errorMsg).toBeTruthy()
})
it('should return error for name with spaces', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('my name')
expect(errorMsg).toBeTruthy()
})
it('should return error for name with special characters', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('name-with-dash')
expect(errorMsg).toBeTruthy()
})
it('should return error for name with dots', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('name.with.dot')
expect(errorMsg).toBeTruthy()
})
it('should accept valid name starting with lowercase letter', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('validname')
expect(errorMsg).toBe('')
})
it('should accept valid name with numbers after first character', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('name123')
expect(errorMsg).toBe('')
})
it('should accept valid name with underscores after first character', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('name_with_underscore')
expect(errorMsg).toBe('')
})
it('should accept single lowercase letter', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('a')
expect(errorMsg).toBe('')
})
})
describe('checkName - Length Validation', () => {
it('should return error for name longer than 255 characters', () => {
const { result } = renderHook(() => useCheckMetadataName())
const longName = 'a'.repeat(256)
const { errorMsg } = result.current.checkName(longName)
expect(errorMsg).toBeTruthy()
})
it('should accept name with exactly 255 characters', () => {
const { result } = renderHook(() => useCheckMetadataName())
const maxLengthName = 'a'.repeat(255)
const { errorMsg } = result.current.checkName(maxLengthName)
expect(errorMsg).toBe('')
})
it('should accept name with less than 255 characters', () => {
const { result } = renderHook(() => useCheckMetadataName())
const shortName = 'a'.repeat(100)
const { errorMsg } = result.current.checkName(shortName)
expect(errorMsg).toBe('')
})
})
describe('checkName - Edge Cases', () => {
it('should validate all lowercase letters', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('abcdefghijklmnopqrstuvwxyz')
expect(errorMsg).toBe('')
})
it('should validate name with mixed numbers and underscores', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('a1_2_3_test')
expect(errorMsg).toBe('')
})
it('should reject uppercase letters anywhere in name', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('nameWithUppercase')
expect(errorMsg).toBeTruthy()
})
it('should reject unicode characters', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('名字')
expect(errorMsg).toBeTruthy()
})
it('should reject emoji characters', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('name😀')
expect(errorMsg).toBeTruthy()
})
})
describe('Return Value Structure', () => {
it('should return object with errorMsg property', () => {
const { result } = renderHook(() => useCheckMetadataName())
const returnValue = result.current.checkName('test')
expect(returnValue).toHaveProperty('errorMsg')
})
it('should return empty string for valid name', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('valid_name')
expect(errorMsg).toBe('')
})
it('should return non-empty string for invalid name', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('')
expect(typeof errorMsg).toBe('string')
expect(errorMsg.length).toBeGreaterThan(0)
})
})
})

View File

@@ -0,0 +1,308 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../types'
import useEditDatasetMetadata from './use-edit-dataset-metadata'
// Mock service hooks
const mockDoAddMetaData = vi.fn().mockResolvedValue({})
const mockDoRenameMetaData = vi.fn().mockResolvedValue({})
const mockDoDeleteMetaData = vi.fn().mockResolvedValue({})
const mockToggleBuiltInStatus = vi.fn().mockResolvedValue({})
vi.mock('@/service/knowledge/use-metadata', () => ({
useDatasetMetaData: () => ({
data: {
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, count: 5 },
{ id: '2', name: 'field_two', type: DataType.number, count: 3 },
],
built_in_field_enabled: false,
},
}),
useCreateMetaData: () => ({
mutate: mockDoAddMetaData,
}),
useRenameMeta: () => ({
mutate: mockDoRenameMetaData,
}),
useDeleteMetaData: () => ({
mutateAsync: mockDoDeleteMetaData,
}),
useUpdateBuiltInStatus: () => ({
mutateAsync: mockToggleBuiltInStatus,
}),
useBuiltInMetaDataFields: () => ({
data: {
fields: [
{ name: 'created_at', type: DataType.time },
{ name: 'modified_at', type: DataType.time },
],
},
}),
}))
// Mock Toast
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
// Mock useCheckMetadataName
vi.mock('./use-check-metadata-name', () => ({
default: () => ({
checkName: (name: string) => ({
errorMsg: name && /^[a-z][a-z0-9_]*$/.test(name) ? '' : 'Invalid name',
}),
}),
}))
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
}
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
describe('useEditDatasetMetadata', () => {
const defaultProps = {
datasetId: 'ds-1',
onUpdateDocList: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
localStorageMock.getItem.mockReturnValue(null)
})
describe('Hook Initialization', () => {
it('should initialize with isShowEditModal as false', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(result.current.isShowEditModal).toBe(false)
})
it('should return showEditModal function', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(typeof result.current.showEditModal).toBe('function')
})
it('should return hideEditModal function', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(typeof result.current.hideEditModal).toBe('function')
})
it('should return datasetMetaData', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(result.current.datasetMetaData).toBeDefined()
})
it('should return handleAddMetaData function', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(typeof result.current.handleAddMetaData).toBe('function')
})
it('should return handleRename function', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(typeof result.current.handleRename).toBe('function')
})
it('should return handleDeleteMetaData function', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(typeof result.current.handleDeleteMetaData).toBe('function')
})
it('should return builtInMetaData', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(result.current.builtInMetaData).toBeDefined()
})
it('should return builtInEnabled', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(typeof result.current.builtInEnabled).toBe('boolean')
})
it('should return setBuiltInEnabled function', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(typeof result.current.setBuiltInEnabled).toBe('function')
})
})
describe('Modal Control', () => {
it('should show modal when showEditModal is called', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
act(() => {
result.current.showEditModal()
})
expect(result.current.isShowEditModal).toBe(true)
})
it('should hide modal when hideEditModal is called', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
act(() => {
result.current.showEditModal()
})
act(() => {
result.current.hideEditModal()
})
expect(result.current.isShowEditModal).toBe(false)
})
it('should handle toggle of modal state', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
// Initially closed
expect(result.current.isShowEditModal).toBe(false)
// Show, hide, show
act(() => result.current.showEditModal())
expect(result.current.isShowEditModal).toBe(true)
act(() => result.current.hideEditModal())
expect(result.current.isShowEditModal).toBe(false)
act(() => result.current.showEditModal())
expect(result.current.isShowEditModal).toBe(true)
})
})
describe('handleAddMetaData', () => {
it('should call doAddMetaData with valid name', async () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
await act(async () => {
await result.current.handleAddMetaData({
name: 'valid_name',
type: DataType.string,
})
})
expect(mockDoAddMetaData).toHaveBeenCalled()
})
it('should reject invalid name', async () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
await expect(
act(async () => {
await result.current.handleAddMetaData({
name: '',
type: DataType.string,
})
}),
).rejects.toThrow()
})
})
describe('handleRename', () => {
it('should call doRenameMetaData with valid name', async () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
await act(async () => {
await result.current.handleRename({
id: '1',
name: 'new_valid_name',
type: DataType.string,
count: 5,
})
})
expect(mockDoRenameMetaData).toHaveBeenCalled()
})
it('should call onUpdateDocList after rename', async () => {
const onUpdateDocList = vi.fn()
const { result } = renderHook(() =>
useEditDatasetMetadata({ ...defaultProps, onUpdateDocList }),
)
await act(async () => {
await result.current.handleRename({
id: '1',
name: 'renamed',
type: DataType.string,
count: 5,
})
})
await waitFor(() => {
expect(onUpdateDocList).toHaveBeenCalled()
})
})
it('should reject invalid name for rename', async () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
await expect(
act(async () => {
await result.current.handleRename({
id: '1',
name: 'Invalid Name',
type: DataType.string,
count: 5,
})
}),
).rejects.toThrow()
})
})
describe('handleDeleteMetaData', () => {
it('should call doDeleteMetaData', async () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
await act(async () => {
await result.current.handleDeleteMetaData('1')
})
expect(mockDoDeleteMetaData).toHaveBeenCalledWith('1')
})
it('should call onUpdateDocList after delete', async () => {
const onUpdateDocList = vi.fn()
const { result } = renderHook(() =>
useEditDatasetMetadata({ ...defaultProps, onUpdateDocList }),
)
await act(async () => {
await result.current.handleDeleteMetaData('1')
})
await waitFor(() => {
expect(onUpdateDocList).toHaveBeenCalled()
})
})
})
describe('Built-in Status', () => {
it('should toggle built-in status', async () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
await act(async () => {
await result.current.setBuiltInEnabled(true)
})
expect(mockToggleBuiltInStatus).toHaveBeenCalledWith(true)
})
})
describe('Edge Cases', () => {
it('should handle different datasetIds', () => {
const { result, rerender } = renderHook(
props => useEditDatasetMetadata(props),
{ initialProps: defaultProps },
)
expect(result.current).toBeDefined()
rerender({ ...defaultProps, datasetId: 'ds-2' })
expect(result.current).toBeDefined()
})
})
})

View File

@@ -0,0 +1,587 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../types'
import useMetadataDocument from './use-metadata-document'
type DocDetail = {
id: string
name: string
data_source_type: string
word_count: number
language?: string
hit_count?: number
segment_count?: number
}
// Mock service hooks
const mockMutateAsync = vi.fn().mockResolvedValue({})
const mockDoAddMetaData = vi.fn().mockResolvedValue({})
vi.mock('@/service/knowledge/use-metadata', () => ({
useBatchUpdateDocMetadata: () => ({
mutateAsync: mockMutateAsync,
}),
useCreateMetaData: () => ({
mutateAsync: mockDoAddMetaData,
}),
useDocumentMetaData: () => ({
data: {
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
{ id: '2', name: 'field_two', type: DataType.number, value: 42 },
{ id: 'built-in', name: 'created_at', type: DataType.time, value: 1609459200 },
],
},
}),
useDatasetMetaData: () => ({
data: {
built_in_field_enabled: true,
},
}),
}))
// Mock useDatasetDetailContext
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContext: () => ({
dataset: {
embedding_available: true,
},
}),
}))
// Mock useMetadataMap and useLanguages with comprehensive field definitions
vi.mock('@/hooks/use-metadata', () => ({
useMetadataMap: () => ({
originInfo: {
subFieldsMap: {
data_source_type: { label: 'Source Type', inputType: 'text' },
language: { label: 'Language', inputType: 'select' },
empty_field: { label: 'Empty Field', inputType: 'text' },
},
},
technicalParameters: {
subFieldsMap: {
word_count: { label: 'Word Count', inputType: 'text' },
hit_count: {
label: 'Hit Count',
inputType: 'text',
render: (val: number, segmentCount?: number) => `${val}/${segmentCount || 0}`,
},
custom_render: {
label: 'Custom Render',
inputType: 'text',
render: (val: string) => `Rendered: ${val}`,
},
},
},
}),
useLanguages: () => ({
en: 'English',
zh: 'Chinese',
ja: 'Japanese',
}),
}))
// Mock Toast
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
// Mock useCheckMetadataName
vi.mock('./use-check-metadata-name', () => ({
default: () => ({
checkName: (name: string) => ({
errorMsg: name && /^[a-z][a-z0-9_]*$/.test(name) ? '' : 'Invalid name',
}),
}),
}))
describe('useMetadataDocument', () => {
const mockDocDetail: DocDetail = {
id: 'doc-1',
name: 'Test Document',
data_source_type: 'upload_file',
word_count: 100,
language: 'en',
hit_count: 50,
segment_count: 10,
}
const defaultProps = {
datasetId: 'ds-1',
documentId: 'doc-1',
docDetail: mockDocDetail as Parameters<typeof useMetadataDocument>[0]['docDetail'],
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Hook Initialization', () => {
it('should return embeddingAvailable', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(result.current.embeddingAvailable).toBe(true)
})
it('should return isEdit as false initially', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(result.current.isEdit).toBe(false)
})
it('should return setIsEdit function', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(typeof result.current.setIsEdit).toBe('function')
})
it('should return list without built-in items', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
const hasBuiltIn = result.current.list.some(item => item.id === 'built-in')
expect(hasBuiltIn).toBe(false)
})
it('should return builtList with only built-in items', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
const allBuiltIn = result.current.builtList.every(item => item.id === 'built-in')
expect(allBuiltIn).toBe(true)
})
it('should return tempList', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(Array.isArray(result.current.tempList)).toBe(true)
})
it('should return setTempList function', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(typeof result.current.setTempList).toBe('function')
})
it('should return hasData based on list length', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(result.current.hasData).toBe(result.current.list.length > 0)
})
it('should return builtInEnabled', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(typeof result.current.builtInEnabled).toBe('boolean')
})
it('should return originInfo', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(Array.isArray(result.current.originInfo)).toBe(true)
})
it('should return technicalParameters', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(Array.isArray(result.current.technicalParameters)).toBe(true)
})
})
describe('Edit Mode', () => {
it('should enter edit mode when startToEdit is called', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
act(() => {
result.current.startToEdit()
})
expect(result.current.isEdit).toBe(true)
})
it('should exit edit mode when handleCancel is called', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
act(() => {
result.current.startToEdit()
})
act(() => {
result.current.handleCancel()
})
expect(result.current.isEdit).toBe(false)
})
it('should reset tempList when handleCancel is called', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
act(() => {
result.current.startToEdit()
})
const originalLength = result.current.list.length
act(() => {
result.current.setTempList([])
})
act(() => {
result.current.handleCancel()
})
expect(result.current.tempList.length).toBe(originalLength)
})
})
describe('handleSelectMetaData', () => {
it('should add metadata to tempList if not exists', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
act(() => {
result.current.startToEdit()
})
const initialLength = result.current.tempList.length
act(() => {
result.current.handleSelectMetaData({
id: 'new-id',
name: 'new_field',
type: DataType.string,
value: null,
})
})
expect(result.current.tempList.length).toBe(initialLength + 1)
})
it('should not add duplicate metadata', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
act(() => {
result.current.startToEdit()
})
const initialLength = result.current.tempList.length
// Try to add existing item
if (result.current.tempList.length > 0) {
act(() => {
result.current.handleSelectMetaData(result.current.tempList[0])
})
expect(result.current.tempList.length).toBe(initialLength)
}
})
})
describe('handleAddMetaData', () => {
it('should call doAddMetaData with valid name', async () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
await act(async () => {
await result.current.handleAddMetaData({
name: 'valid_field',
type: DataType.string,
})
})
expect(mockDoAddMetaData).toHaveBeenCalled()
})
it('should reject invalid name', async () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
await expect(
act(async () => {
await result.current.handleAddMetaData({
name: '',
type: DataType.string,
})
}),
).rejects.toThrow()
})
})
describe('handleSave', () => {
it('should call mutateAsync to save metadata', async () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
act(() => {
result.current.startToEdit()
})
await act(async () => {
await result.current.handleSave()
})
expect(mockMutateAsync).toHaveBeenCalled()
})
it('should exit edit mode after save', async () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
act(() => {
result.current.startToEdit()
})
await act(async () => {
await result.current.handleSave()
})
await waitFor(() => {
expect(result.current.isEdit).toBe(false)
})
})
})
describe('getReadOnlyMetaData - originInfo', () => {
it('should return origin info with correct structure', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(result.current.originInfo).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: DataType.string,
}),
]),
)
})
it('should use languageMap for language field (select type)', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
// Find language field in originInfo
const languageField = result.current.originInfo.find(
item => item.name === 'Language',
)
// If language field exists and docDetail has language 'en', value should be 'English'
if (languageField)
expect(languageField.value).toBe('English')
})
it('should return dash for empty field values', () => {
const docDetailWithEmpty: DocDetail = {
id: 'doc-1',
name: 'Test Document',
data_source_type: 'upload_file',
word_count: 100,
}
const { result } = renderHook(() =>
useMetadataDocument({
...defaultProps,
docDetail: docDetailWithEmpty as Parameters<typeof useMetadataDocument>[0]['docDetail'],
}),
)
// Check if there's any field with '-' value (meaning empty)
const hasEmptyField = result.current.originInfo.some(
item => item.value === '-',
)
// language field should return '-' since it's not set
expect(hasEmptyField).toBe(true)
})
it('should return empty object for non-language select fields', () => {
// This tests the else branch of getTargetMap where field !== 'language'
const { result } = renderHook(() => useMetadataDocument(defaultProps))
// The data_source_type field is a text field, not select
const sourceTypeField = result.current.originInfo.find(
item => item.name === 'Source Type',
)
// It should return the raw value since it's not a select type
if (sourceTypeField)
expect(sourceTypeField.value).toBe('upload_file')
})
})
describe('getReadOnlyMetaData - technicalParameters', () => {
it('should return technical parameters with correct structure', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(result.current.technicalParameters).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: DataType.string,
}),
]),
)
})
it('should use render function when available', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
// Find hit_count field which has a render function
const hitCountField = result.current.technicalParameters.find(
item => item.name === 'Hit Count',
)
// The render function should format as "val/segmentCount"
if (hitCountField)
expect(hitCountField.value).toBe('50/10')
})
it('should return raw value when no render function', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
// Find word_count field which has no render function
const wordCountField = result.current.technicalParameters.find(
item => item.name === 'Word Count',
)
if (wordCountField)
expect(wordCountField.value).toBe(100)
})
it('should handle fields with render function and undefined segment_count', () => {
const docDetailNoSegment: DocDetail = {
id: 'doc-1',
name: 'Test Document',
data_source_type: 'upload_file',
word_count: 100,
hit_count: 25,
}
const { result } = renderHook(() =>
useMetadataDocument({
...defaultProps,
docDetail: docDetailNoSegment as Parameters<typeof useMetadataDocument>[0]['docDetail'],
}),
)
const hitCountField = result.current.technicalParameters.find(
item => item.name === 'Hit Count',
)
// Should use 0 as default for segment_count
if (hitCountField)
expect(hitCountField.value).toBe('25/0')
})
it('should return dash for null/undefined values', () => {
const docDetailWithNull: DocDetail = {
id: 'doc-1',
name: 'Test Document',
data_source_type: '',
word_count: 0,
}
const { result } = renderHook(() =>
useMetadataDocument({
...defaultProps,
docDetail: docDetailWithNull as Parameters<typeof useMetadataDocument>[0]['docDetail'],
}),
)
// 0 should still be shown, but empty string should show '-'
const sourceTypeField = result.current.originInfo.find(
item => item.name === 'Source Type',
)
if (sourceTypeField)
expect(sourceTypeField.value).toBe('-')
})
it('should handle 0 value correctly (not treated as empty)', () => {
const docDetailWithZero: DocDetail = {
id: 'doc-1',
name: 'Test Document',
data_source_type: 'upload_file',
word_count: 0,
}
const { result } = renderHook(() =>
useMetadataDocument({
...defaultProps,
docDetail: docDetailWithZero as Parameters<typeof useMetadataDocument>[0]['docDetail'],
}),
)
// word_count of 0 should still show 0, not '-'
const wordCountField = result.current.technicalParameters.find(
item => item.name === 'Word Count',
)
if (wordCountField)
expect(wordCountField.value).toBe(0)
})
})
describe('Edge Cases', () => {
it('should handle empty docDetail', () => {
const { result } = renderHook(() =>
useMetadataDocument({
...defaultProps,
docDetail: {} as Parameters<typeof useMetadataDocument>[0]['docDetail'],
}),
)
expect(result.current).toBeDefined()
})
it('should handle different datasetIds', () => {
const { result, rerender } = renderHook(
props => useMetadataDocument(props),
{ initialProps: defaultProps },
)
expect(result.current).toBeDefined()
rerender({ ...defaultProps, datasetId: 'ds-2' })
expect(result.current).toBeDefined()
})
it('should handle docDetail with all fields', () => {
const fullDocDetail: DocDetail = {
id: 'doc-1',
name: 'Full Document',
data_source_type: 'website',
word_count: 500,
language: 'zh',
hit_count: 100,
segment_count: 20,
}
const { result } = renderHook(() =>
useMetadataDocument({
...defaultProps,
docDetail: fullDocDetail as Parameters<typeof useMetadataDocument>[0]['docDetail'],
}),
)
// Language should be mapped
const languageField = result.current.originInfo.find(
item => item.name === 'Language',
)
if (languageField)
expect(languageField.value).toBe('Chinese')
// Hit count should be rendered
const hitCountField = result.current.technicalParameters.find(
item => item.name === 'Hit Count',
)
if (hitCountField)
expect(hitCountField.value).toBe('100/20')
})
it('should handle unknown language', () => {
const unknownLangDetail: DocDetail = {
id: 'doc-1',
name: 'Unknown Lang Document',
data_source_type: 'upload_file',
word_count: 100,
language: 'unknown_lang',
}
const { result } = renderHook(() =>
useMetadataDocument({
...defaultProps,
docDetail: unknownLangDetail as Parameters<typeof useMetadataDocument>[0]['docDetail'],
}),
)
// Unknown language should return undefined from the map
const languageField = result.current.originInfo.find(
item => item.name === 'Language',
)
// When language is not in map, it returns undefined
expect(languageField?.value).toBeUndefined()
})
})
})

View File

@@ -0,0 +1,268 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../types'
import CreateContent from './create-content'
type ModalLikeWrapProps = {
children: React.ReactNode
title: string
onClose?: () => void
onConfirm: () => void
beforeHeader?: React.ReactNode
}
type OptionCardProps = {
title: string
selected: boolean
onSelect: () => void
}
type FieldProps = {
label: string
children: React.ReactNode
}
// Mock ModalLikeWrap
vi.mock('../../../base/modal-like-wrap', () => ({
default: ({ children, title, onClose, onConfirm, beforeHeader }: ModalLikeWrapProps) => (
<div data-testid="modal-wrap">
<div data-testid="modal-title">{title}</div>
{beforeHeader && <div data-testid="before-header">{beforeHeader}</div>}
<div data-testid="modal-content">{children}</div>
<button data-testid="close-btn" onClick={onClose}>Close</button>
<button data-testid="confirm-btn" onClick={onConfirm}>Confirm</button>
</div>
),
}))
// Mock OptionCard
vi.mock('../../../workflow/nodes/_base/components/option-card', () => ({
default: ({ title, selected, onSelect }: OptionCardProps) => (
<button
data-testid={`option-${title.toLowerCase()}`}
data-selected={selected}
onClick={onSelect}
>
{title}
</button>
),
}))
// Mock Field
vi.mock('./field', () => ({
default: ({ label, children }: FieldProps) => (
<div data-testid="field">
<label data-testid="field-label">{label}</label>
{children}
</div>
),
}))
describe('CreateContent', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByTestId('modal-wrap')).toBeInTheDocument()
})
it('should render modal title', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByTestId('modal-title')).toBeInTheDocument()
})
it('should render type selection options', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByTestId('option-string')).toBeInTheDocument()
expect(screen.getByTestId('option-number')).toBeInTheDocument()
expect(screen.getByTestId('option-time')).toBeInTheDocument()
})
it('should render name input field', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render confirm button', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByTestId('confirm-btn')).toBeInTheDocument()
})
it('should render close button', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByTestId('close-btn')).toBeInTheDocument()
})
})
describe('Type Selection', () => {
it('should default to string type', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'true')
})
it('should select number type when clicked', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
fireEvent.click(screen.getByTestId('option-number'))
expect(screen.getByTestId('option-number')).toHaveAttribute('data-selected', 'true')
})
it('should select time type when clicked', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
fireEvent.click(screen.getByTestId('option-time'))
expect(screen.getByTestId('option-time')).toHaveAttribute('data-selected', 'true')
})
it('should deselect previous type when new type is selected', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
// Initially string is selected
expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'true')
// Select number
fireEvent.click(screen.getByTestId('option-number'))
expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'false')
expect(screen.getByTestId('option-number')).toHaveAttribute('data-selected', 'true')
})
})
describe('Name Input', () => {
it('should update name when typing', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'new_field' } })
expect(input).toHaveValue('new_field')
})
it('should start with empty name', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByRole('textbox')).toHaveValue('')
})
})
describe('User Interactions', () => {
it('should call onSave with type and name when confirmed', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test_field' } })
fireEvent.click(screen.getByTestId('confirm-btn'))
expect(handleSave).toHaveBeenCalledWith({
type: DataType.string,
name: 'test_field',
})
})
it('should call onSave with correct type after changing type', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
fireEvent.click(screen.getByTestId('option-number'))
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'num_field' } })
fireEvent.click(screen.getByTestId('confirm-btn'))
expect(handleSave).toHaveBeenCalledWith({
type: DataType.number,
name: 'num_field',
})
})
it('should call onClose when close button is clicked', () => {
const handleSave = vi.fn()
const handleClose = vi.fn()
render(<CreateContent onSave={handleSave} onClose={handleClose} />)
fireEvent.click(screen.getByTestId('close-btn'))
expect(handleClose).toHaveBeenCalled()
})
})
describe('Back Button', () => {
it('should show back button when hasBack is true', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} hasBack />)
expect(screen.getByTestId('before-header')).toBeInTheDocument()
})
it('should not show back button when hasBack is false', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} hasBack={false} />)
expect(screen.queryByTestId('before-header')).not.toBeInTheDocument()
})
it('should call onBack when back button is clicked', () => {
const handleSave = vi.fn()
const handleBack = vi.fn()
render(<CreateContent onSave={handleSave} hasBack onBack={handleBack} />)
const backButton = screen.getByTestId('before-header')
// Find the clickable element inside
const clickable = backButton.querySelector('.cursor-pointer') || backButton.firstChild
if (clickable)
fireEvent.click(clickable)
// The back functionality is tested through the actual implementation
expect(screen.getByTestId('before-header')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty name submission', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
fireEvent.click(screen.getByTestId('confirm-btn'))
expect(handleSave).toHaveBeenCalledWith({
type: DataType.string,
name: '',
})
})
it('should handle type cycling', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
// Cycle through all types
fireEvent.click(screen.getByTestId('option-number'))
fireEvent.click(screen.getByTestId('option-time'))
fireEvent.click(screen.getByTestId('option-string'))
expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'true')
})
it('should handle special characters in name', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test_field_123' } })
expect(input).toHaveValue('test_field_123')
})
})
})

View File

@@ -0,0 +1,246 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../types'
import CreateMetadataModal from './create-metadata-modal'
type PortalProps = {
children: React.ReactNode
open: boolean
}
type TriggerProps = {
children: React.ReactNode
onClick: () => void
}
type ContentProps = {
children: React.ReactNode
className?: string
}
type CreateContentProps = {
onSave: (data: { type: DataType, name: string }) => void
onClose?: () => void
onBack?: () => void
hasBack?: boolean
}
// Mock PortalToFollowElem components
vi.mock('../../../base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: PortalProps) => (
<div data-testid="portal-wrapper" data-open={open}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: TriggerProps) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children, className }: ContentProps) => (
<div data-testid="portal-content" className={className}>{children}</div>
),
}))
// Mock CreateContent component
vi.mock('./create-content', () => ({
default: ({ onSave, onClose, onBack, hasBack }: CreateContentProps) => (
<div data-testid="create-content">
<span data-testid="has-back">{String(hasBack)}</span>
<button data-testid="save-btn" onClick={() => onSave({ type: DataType.string, name: 'test' })}>Save</button>
<button data-testid="close-btn" onClick={onClose}>Close</button>
{hasBack && <button data-testid="back-btn" onClick={onBack}>Back</button>}
</div>
),
}))
describe('CreateMetadataModal', () => {
const mockTrigger = <button data-testid="trigger-button">Open Modal</button>
describe('Rendering', () => {
it('should render trigger when closed', () => {
render(
<CreateMetadataModal
open={false}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
// Portal wrapper should exist but closed
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'false')
})
it('should render content when open', () => {
render(
<CreateMetadataModal
open={true}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
expect(screen.getByTestId('create-content')).toBeInTheDocument()
})
it('should render trigger element', () => {
render(
<CreateMetadataModal
open={false}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('trigger-button')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass hasBack to CreateContent', () => {
render(
<CreateMetadataModal
open={true}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
hasBack
/>,
)
expect(screen.getByTestId('has-back')).toHaveTextContent('true')
})
it('should pass hasBack=undefined when not provided', () => {
render(
<CreateMetadataModal
open={true}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('has-back')).toHaveTextContent('undefined')
})
it('should accept custom popupLeft', () => {
render(
<CreateMetadataModal
open={true}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
popupLeft={50}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should toggle open state when trigger is clicked', () => {
const setOpen = vi.fn()
render(
<CreateMetadataModal
open={false}
setOpen={setOpen}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(setOpen).toHaveBeenCalledWith(true)
})
it('should call onSave when save button is clicked', () => {
const handleSave = vi.fn()
render(
<CreateMetadataModal
open={true}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={handleSave}
/>,
)
fireEvent.click(screen.getByTestId('save-btn'))
expect(handleSave).toHaveBeenCalledWith({
type: DataType.string,
name: 'test',
})
})
it('should close modal when close button is clicked', () => {
const setOpen = vi.fn()
render(
<CreateMetadataModal
open={true}
setOpen={setOpen}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('close-btn'))
expect(setOpen).toHaveBeenCalledWith(false)
})
it('should close modal when back button is clicked', () => {
const setOpen = vi.fn()
render(
<CreateMetadataModal
open={true}
setOpen={setOpen}
trigger={mockTrigger}
onSave={vi.fn()}
hasBack
/>,
)
fireEvent.click(screen.getByTestId('back-btn'))
expect(setOpen).toHaveBeenCalledWith(false)
})
})
describe('Edge Cases', () => {
it('should handle switching open state', () => {
const { rerender } = render(
<CreateMetadataModal
open={false}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'false')
rerender(
<CreateMetadataModal
open={true}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'true')
})
it('should handle different trigger elements', () => {
const customTrigger = <div data-testid="custom-trigger">Custom</div>
render(
<CreateMetadataModal
open={false}
setOpen={vi.fn()}
trigger={customTrigger}
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,587 @@
import type { BuiltInMetadataItem, MetadataItemWithValueLength } from '../types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../types'
import DatasetMetadataDrawer from './dataset-metadata-drawer'
// Mock service/API calls
vi.mock('@/service/knowledge/use-metadata', () => ({
useDatasetMetaData: () => ({
data: {
doc_metadata: [
{ id: '1', name: 'existing_field', type: DataType.string },
],
},
}),
}))
// Mock check name hook
vi.mock('../hooks/use-check-metadata-name', () => ({
default: () => ({
checkName: () => ({ errorMsg: '' }),
}),
}))
// Mock Toast
const mockToastNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: (args: unknown) => mockToastNotify(args),
},
}))
// Type definitions for mock props
type CreateModalProps = {
open: boolean
setOpen: (open: boolean) => void
trigger: React.ReactNode
onSave: (data: BuiltInMetadataItem) => void
}
// Mock CreateModal to expose callbacks
vi.mock('@/app/components/datasets/metadata/metadata-dataset/create-metadata-modal', () => ({
default: ({ open, setOpen, trigger, onSave }: CreateModalProps) => (
<div data-testid="create-modal-wrapper">
<div data-testid="create-trigger" onClick={() => setOpen(true)}>{trigger}</div>
{open && (
<div data-testid="create-modal">
<button data-testid="create-save" onClick={() => onSave({ name: 'new_field', type: DataType.string })}>
Save
</button>
<button data-testid="create-close" onClick={() => setOpen(false)}>Close</button>
</div>
)}
</div>
),
}))
describe('DatasetMetadataDrawer', () => {
const mockUserMetadata: MetadataItemWithValueLength[] = [
{ id: '1', name: 'field_one', type: DataType.string, count: 5 },
{ id: '2', name: 'field_two', type: DataType.number, count: 3 },
]
const mockBuiltInMetadata: BuiltInMetadataItem[] = [
{ name: 'created_at', type: DataType.time },
{ name: 'modified_at', type: DataType.time },
]
const defaultProps = {
userMetadata: mockUserMetadata,
builtInMetadata: mockBuiltInMetadata,
isBuiltInEnabled: false,
onIsBuiltInEnabledChange: vi.fn(),
onClose: vi.fn(),
onAdd: vi.fn().mockResolvedValue({}),
onRename: vi.fn().mockResolvedValue({}),
onRemove: vi.fn().mockResolvedValue({}),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
it('should render user metadata items', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
expect(screen.getByText('field_one')).toBeInTheDocument()
expect(screen.getByText('field_two')).toBeInTheDocument()
})
})
it('should render built-in metadata items', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
expect(screen.getByText('created_at')).toBeInTheDocument()
expect(screen.getByText('modified_at')).toBeInTheDocument()
})
})
it('should render metadata type for each item', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
expect(screen.getAllByText(DataType.string).length).toBeGreaterThan(0)
expect(screen.getAllByText(DataType.number).length).toBeGreaterThan(0)
})
})
it('should render add metadata button', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
expect(screen.getByTestId('create-trigger')).toBeInTheDocument()
})
})
it('should render switch for built-in toggle', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
const switchBtn = screen.getByRole('switch')
expect(switchBtn).toBeInTheDocument()
})
})
})
describe('User Interactions', () => {
it('should call onIsBuiltInEnabledChange when switch is toggled', async () => {
const onIsBuiltInEnabledChange = vi.fn()
render(
<DatasetMetadataDrawer
{...defaultProps}
onIsBuiltInEnabledChange={onIsBuiltInEnabledChange}
/>,
)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
const switchBtn = screen.getByRole('switch')
fireEvent.click(switchBtn)
expect(onIsBuiltInEnabledChange).toHaveBeenCalled()
})
})
describe('Add Metadata', () => {
it('should open create modal when add button is clicked', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
const trigger = screen.getByTestId('create-trigger')
fireEvent.click(trigger)
await waitFor(() => {
expect(screen.getByTestId('create-modal')).toBeInTheDocument()
})
})
it('should call onAdd and show success toast when metadata is added', async () => {
const onAdd = vi.fn().mockResolvedValue({})
render(<DatasetMetadataDrawer {...defaultProps} onAdd={onAdd} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Open create modal
const trigger = screen.getByTestId('create-trigger')
fireEvent.click(trigger)
await waitFor(() => {
expect(screen.getByTestId('create-modal')).toBeInTheDocument()
})
// Save new metadata
fireEvent.click(screen.getByTestId('create-save'))
await waitFor(() => {
expect(onAdd).toHaveBeenCalled()
})
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
}),
)
})
})
it('should close create modal after save', async () => {
const onAdd = vi.fn().mockResolvedValue({})
render(<DatasetMetadataDrawer {...defaultProps} onAdd={onAdd} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Open create modal
fireEvent.click(screen.getByTestId('create-trigger'))
await waitFor(() => {
expect(screen.getByTestId('create-modal')).toBeInTheDocument()
})
// Save
fireEvent.click(screen.getByTestId('create-save'))
await waitFor(() => {
expect(screen.queryByTestId('create-modal')).not.toBeInTheDocument()
})
})
})
describe('Rename Metadata', () => {
it('should open rename modal when edit icon is clicked', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Find user metadata items with group/item class (these have edit/delete icons)
const dialog = screen.getByRole('dialog')
const items = dialog.querySelectorAll('.group\\/item')
expect(items.length).toBe(2) // 2 user metadata items
// Find the hidden container with edit/delete icons
const actionsContainer = items[0].querySelector('.hidden.items-center')
expect(actionsContainer).toBeTruthy()
// Find and click the first SVG (edit icon)
if (actionsContainer) {
const svgs = actionsContainer.querySelectorAll('svg')
expect(svgs.length).toBeGreaterThan(0)
fireEvent.click(svgs[0])
}
// Wait for rename modal (contains input)
await waitFor(() => {
const inputs = document.querySelectorAll('input')
expect(inputs.length).toBeGreaterThan(0)
})
})
it('should call onRename when rename is saved', async () => {
const onRename = vi.fn().mockResolvedValue({})
render(<DatasetMetadataDrawer {...defaultProps} onRename={onRename} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Find and click edit icon
const dialog = screen.getByRole('dialog')
const items = dialog.querySelectorAll('.group\\/item')
const actionsContainer = items[0].querySelector('.hidden.items-center')
if (actionsContainer) {
const svgs = actionsContainer.querySelectorAll('svg')
fireEvent.click(svgs[0])
}
// Change name and save
await waitFor(() => {
const inputs = document.querySelectorAll('input')
expect(inputs.length).toBeGreaterThan(0)
})
const inputs = document.querySelectorAll('input')
fireEvent.change(inputs[0], { target: { value: 'renamed_field' } })
// Find and click save button
const saveBtns = screen.getAllByText(/save/i)
const primaryBtn = saveBtns.find(btn =>
btn.closest('button')?.classList.contains('btn-primary'),
)
if (primaryBtn)
fireEvent.click(primaryBtn)
await waitFor(() => {
expect(onRename).toHaveBeenCalled()
})
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
}),
)
})
})
it('should close rename modal when cancel is clicked', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Find and click edit icon
const dialog = screen.getByRole('dialog')
const items = dialog.querySelectorAll('.group\\/item')
const actionsContainer = items[0].querySelector('.hidden.items-center')
if (actionsContainer) {
const svgs = actionsContainer.querySelectorAll('svg')
fireEvent.click(svgs[0])
}
// Wait for modal and click cancel
await waitFor(() => {
const inputs = document.querySelectorAll('input')
expect(inputs.length).toBeGreaterThan(0)
})
// Change name first
const inputs = document.querySelectorAll('input')
fireEvent.change(inputs[0], { target: { value: 'changed_name' } })
// Find and click cancel button
const cancelBtns = screen.getAllByText(/cancel/i)
const cancelBtn = cancelBtns.find(btn =>
!btn.closest('button')?.classList.contains('btn-primary'),
)
if (cancelBtn)
fireEvent.click(cancelBtn)
// Verify input resets or modal closes
await waitFor(() => {
const currentInputs = document.querySelectorAll('input')
// Either no inputs (modal closed) or value reset
expect(currentInputs.length === 0 || currentInputs[0].value !== 'changed_name').toBe(true)
})
})
it('should close rename modal when modal close button is clicked', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Find and click edit icon
const dialog = screen.getByRole('dialog')
const items = dialog.querySelectorAll('.group\\/item')
const actionsContainer = items[0].querySelector('.hidden.items-center')
if (actionsContainer) {
const svgs = actionsContainer.querySelectorAll('svg')
fireEvent.click(svgs[0])
}
// Wait for rename modal
await waitFor(() => {
const inputs = document.querySelectorAll('input')
expect(inputs.length).toBeGreaterThan(0)
})
// Find and click the modal close button (X button)
// The Modal component has a close button in the header
const dialogs = screen.getAllByRole('dialog')
const renameModal = dialogs.find(d => d.querySelector('input'))
if (renameModal) {
// Find close button by looking for a button with close-related class or X icon
const closeButtons = renameModal.querySelectorAll('button')
for (const btn of Array.from(closeButtons)) {
// Skip cancel/save buttons
if (!btn.textContent?.toLowerCase().includes('cancel')
&& !btn.textContent?.toLowerCase().includes('save')
&& btn.querySelector('svg')) {
fireEvent.click(btn)
break
}
}
}
})
})
describe('Delete Metadata', () => {
it('should show confirm dialog when delete icon is clicked', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Find user metadata items
const dialog = screen.getByRole('dialog')
const items = dialog.querySelectorAll('.group\\/item')
// Find the delete container
const deleteContainer = items[0].querySelector('.hover\\:text-text-destructive')
expect(deleteContainer).toBeTruthy()
// Click delete icon
if (deleteContainer) {
const deleteIcon = deleteContainer.querySelector('svg')
if (deleteIcon)
fireEvent.click(deleteIcon)
}
// Confirm dialog should appear
await waitFor(() => {
const confirmBtns = screen.getAllByRole('button')
const hasConfirmBtn = confirmBtns.some(btn =>
btn.textContent?.toLowerCase().includes('confirm'),
)
expect(hasConfirmBtn).toBe(true)
})
})
it('should call onRemove when delete is confirmed', async () => {
const onRemove = vi.fn().mockResolvedValue({})
render(<DatasetMetadataDrawer {...defaultProps} onRemove={onRemove} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Find and click delete icon
const dialog = screen.getByRole('dialog')
const items = dialog.querySelectorAll('.group\\/item')
const deleteContainer = items[0].querySelector('.hover\\:text-text-destructive')
if (deleteContainer) {
const deleteIcon = deleteContainer.querySelector('svg')
if (deleteIcon)
fireEvent.click(deleteIcon)
}
// Wait for confirm dialog
await waitFor(() => {
const confirmBtns = screen.getAllByRole('button')
const hasConfirmBtn = confirmBtns.some(btn =>
btn.textContent?.toLowerCase().includes('confirm'),
)
expect(hasConfirmBtn).toBe(true)
})
// Click confirm
const confirmBtns = screen.getAllByRole('button')
const confirmBtn = confirmBtns.find(btn =>
btn.textContent?.toLowerCase().includes('confirm'),
)
if (confirmBtn)
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(onRemove).toHaveBeenCalledWith('1')
})
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
}),
)
})
})
it('should close confirm dialog when cancel is clicked', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Find and click delete icon
const dialog = screen.getByRole('dialog')
const items = dialog.querySelectorAll('.group\\/item')
const deleteContainer = items[0].querySelector('.hover\\:text-text-destructive')
if (deleteContainer) {
const deleteIcon = deleteContainer.querySelector('svg')
if (deleteIcon)
fireEvent.click(deleteIcon)
}
// Wait for confirm dialog
await waitFor(() => {
const confirmBtns = screen.getAllByRole('button')
const hasConfirmBtn = confirmBtns.some(btn =>
btn.textContent?.toLowerCase().includes('confirm'),
)
expect(hasConfirmBtn).toBe(true)
})
// Click cancel
const cancelBtns = screen.getAllByRole('button')
const cancelBtn = cancelBtns.find(btn =>
btn.textContent?.toLowerCase().includes('cancel'),
)
if (cancelBtn)
fireEvent.click(cancelBtn)
})
})
describe('Props', () => {
it('should handle empty userMetadata', async () => {
render(<DatasetMetadataDrawer {...defaultProps} userMetadata={[]} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
it('should handle empty builtInMetadata', async () => {
render(<DatasetMetadataDrawer {...defaultProps} builtInMetadata={[]} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
})
describe('Built-in Items State', () => {
it('should show disabled styling when built-in is disabled', async () => {
render(
<DatasetMetadataDrawer {...defaultProps} isBuiltInEnabled={false} />,
)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
const dialog = screen.getByRole('dialog')
const disabledItems = dialog.querySelectorAll('.opacity-30')
expect(disabledItems.length).toBeGreaterThan(0)
})
it('should not show disabled styling when built-in is enabled', async () => {
render(
<DatasetMetadataDrawer {...defaultProps} isBuiltInEnabled />,
)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
})
describe('Edge Cases', () => {
it('should handle items with special characters in name', async () => {
const specialMetadata: MetadataItemWithValueLength[] = [
{ id: '1', name: 'field_with_underscore', type: DataType.string, count: 1 },
]
render(<DatasetMetadataDrawer {...defaultProps} userMetadata={specialMetadata} />)
await waitFor(() => {
expect(screen.getByText('field_with_underscore')).toBeInTheDocument()
})
})
it('should handle single user metadata item', async () => {
const singleMetadata: MetadataItemWithValueLength[] = [
{ id: '1', name: 'only_field', type: DataType.string, count: 10 },
]
render(<DatasetMetadataDrawer {...defaultProps} userMetadata={singleMetadata} />)
await waitFor(() => {
expect(screen.getByText('only_field')).toBeInTheDocument()
})
})
it('should handle single built-in metadata item', async () => {
const singleBuiltIn: BuiltInMetadataItem[] = [
{ name: 'created_at', type: DataType.time },
]
render(<DatasetMetadataDrawer {...defaultProps} builtInMetadata={singleBuiltIn} />)
await waitFor(() => {
expect(screen.getByText('created_at')).toBeInTheDocument()
})
})
it('should handle metadata with zero count', async () => {
const zeroCountMetadata: MetadataItemWithValueLength[] = [
{ id: '1', name: 'empty_field', type: DataType.string, count: 0 },
]
render(<DatasetMetadataDrawer {...defaultProps} userMetadata={zeroCountMetadata} />)
await waitFor(() => {
expect(screen.getByText('empty_field')).toBeInTheDocument()
})
})
})
})

View File

@@ -0,0 +1,122 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import Field from './field'
describe('Field', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Field label="Test Label">Content</Field>)
expect(screen.getByText('Test Label')).toBeInTheDocument()
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('should render label with correct styling', () => {
render(<Field label="My Label">Content</Field>)
const labelElement = screen.getByText('My Label')
expect(labelElement).toHaveClass('system-sm-semibold', 'py-1', 'text-text-secondary')
})
it('should render children in content container', () => {
const { container } = render(<Field label="Label">Child Content</Field>)
// The children wrapper has mt-1 class
const contentWrapper = container.querySelector('.mt-1')
expect(contentWrapper).toBeInTheDocument()
expect(contentWrapper).toHaveTextContent('Child Content')
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(<Field label="Label" className="custom-class">Content</Field>)
expect(container.firstChild).toHaveClass('custom-class')
})
it('should render with string children', () => {
render(<Field label="Label">Simple Text</Field>)
expect(screen.getByText('Simple Text')).toBeInTheDocument()
})
it('should render with complex children', () => {
render(
<Field label="Label">
<div data-testid="complex-child">
<span>Nested Content</span>
</div>
</Field>,
)
expect(screen.getByTestId('complex-child')).toBeInTheDocument()
expect(screen.getByText('Nested Content')).toBeInTheDocument()
})
it('should render with multiple children', () => {
render(
<Field label="Label">
<span>First</span>
<span>Second</span>
</Field>,
)
expect(screen.getByText('First')).toBeInTheDocument()
expect(screen.getByText('Second')).toBeInTheDocument()
})
it('should render different labels correctly', () => {
const { rerender } = render(<Field label="First Label">Content</Field>)
expect(screen.getByText('First Label')).toBeInTheDocument()
rerender(<Field label="Second Label">Content</Field>)
expect(screen.getByText('Second Label')).toBeInTheDocument()
})
})
describe('Layout', () => {
it('should have label above content', () => {
const { container } = render(<Field label="Label">Content</Field>)
const wrapper = container.firstChild as HTMLElement
expect(wrapper?.children).toHaveLength(2)
})
it('should render label element first', () => {
const { container } = render(<Field label="Label">Content</Field>)
const wrapper = container.firstChild as HTMLElement
const firstChild = wrapper?.firstChild as HTMLElement
expect(firstChild).toHaveClass('system-sm-semibold')
})
})
describe('Edge Cases', () => {
it('should render with undefined className', () => {
render(<Field label="Label" className={undefined}>Content</Field>)
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('should render with empty className', () => {
render(<Field label="Label" className="">Content</Field>)
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('should render with empty label', () => {
render(<Field label="">Content</Field>)
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('should render with empty children', () => {
const { container } = render(<Field label="Label"><span></span></Field>)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with null children', () => {
const { container } = render(<Field label="Label">{null}</Field>)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with number as children', () => {
render(<Field label="Label">{42}</Field>)
expect(screen.getByText('42')).toBeInTheDocument()
})
it('should handle special characters in label', () => {
render(<Field label={'Label & "chars"'}>Content</Field>)
expect(screen.getByText('Label & "chars"')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,348 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../types'
import SelectMetadataModal from './select-metadata-modal'
type MetadataItem = {
id: string
name: string
type: DataType
}
type PortalProps = {
children: React.ReactNode
open: boolean
}
type TriggerProps = {
children: React.ReactNode
onClick: () => void
}
type ContentProps = {
children: React.ReactNode
}
type SelectMetadataProps = {
onSelect: (item: MetadataItem) => void
onNew: () => void
onManage: () => void
list: MetadataItem[]
}
type CreateContentProps = {
onSave: (data: { type: DataType, name: string }) => void
onBack?: () => void
onClose?: () => void
hasBack?: boolean
}
// Mock useDatasetMetaData hook
vi.mock('@/service/knowledge/use-metadata', () => ({
useDatasetMetaData: () => ({
data: {
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string },
{ id: '2', name: 'field_two', type: DataType.number },
],
},
}),
}))
// Mock PortalToFollowElem components
vi.mock('../../../base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: PortalProps) => (
<div data-testid="portal-wrapper" data-open={open}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: TriggerProps) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: ContentProps) => (
<div data-testid="portal-content">{children}</div>
),
}))
// Mock SelectMetadata component
vi.mock('./select-metadata', () => ({
default: ({ onSelect, onNew, onManage, list }: SelectMetadataProps) => (
<div data-testid="select-metadata">
<span data-testid="list-count">{list?.length || 0}</span>
<button data-testid="select-item" onClick={() => onSelect({ id: '1', name: 'field_one', type: DataType.string })}>Select</button>
<button data-testid="new-btn" onClick={onNew}>New</button>
<button data-testid="manage-btn" onClick={onManage}>Manage</button>
</div>
),
}))
// Mock CreateContent component
vi.mock('./create-content', () => ({
default: ({ onSave, onBack, onClose, hasBack }: CreateContentProps) => (
<div data-testid="create-content">
<button data-testid="save-btn" onClick={() => onSave({ type: DataType.string, name: 'new_field' })}>Save</button>
{hasBack && <button data-testid="back-btn" onClick={onBack}>Back</button>}
<button data-testid="close-btn" onClick={onClose}>Close</button>
</div>
),
}))
describe('SelectMetadataModal', () => {
const mockTrigger = <button data-testid="trigger-button">Select Metadata</button>
describe('Rendering', () => {
it('should render without crashing', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
})
it('should render trigger element', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByTestId('trigger-button')).toBeInTheDocument()
})
it('should render SelectMetadata by default', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByTestId('select-metadata')).toBeInTheDocument()
})
it('should pass dataset metadata to SelectMetadata', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByTestId('list-count')).toHaveTextContent('2')
})
})
describe('User Interactions', () => {
it('should toggle open state when trigger is clicked', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
// State should toggle
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
})
it('should call onSelect and close when item is selected', () => {
const handleSelect = vi.fn()
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={handleSelect}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('select-item'))
expect(handleSelect).toHaveBeenCalledWith({
id: '1',
name: 'field_one',
type: DataType.string,
})
})
it('should switch to create step when new button is clicked', async () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('new-btn'))
await waitFor(() => {
expect(screen.getByTestId('create-content')).toBeInTheDocument()
})
})
it('should call onManage when manage button is clicked', () => {
const handleManage = vi.fn()
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={handleManage}
/>,
)
fireEvent.click(screen.getByTestId('manage-btn'))
expect(handleManage).toHaveBeenCalled()
})
})
describe('Create Flow', () => {
it('should switch back to select when back is clicked in create step', async () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
// Go to create step
fireEvent.click(screen.getByTestId('new-btn'))
await waitFor(() => {
expect(screen.getByTestId('create-content')).toBeInTheDocument()
})
// Go back to select step
fireEvent.click(screen.getByTestId('back-btn'))
await waitFor(() => {
expect(screen.getByTestId('select-metadata')).toBeInTheDocument()
})
})
it('should call onSave and return to select step when save is clicked', async () => {
const handleSave = vi.fn().mockResolvedValue(undefined)
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={handleSave}
onManage={vi.fn()}
/>,
)
// Go to create step
fireEvent.click(screen.getByTestId('new-btn'))
await waitFor(() => {
expect(screen.getByTestId('create-content')).toBeInTheDocument()
})
// Save new metadata
fireEvent.click(screen.getByTestId('save-btn'))
await waitFor(() => {
expect(handleSave).toHaveBeenCalledWith({
type: DataType.string,
name: 'new_field',
})
})
})
})
describe('Props', () => {
it('should accept custom popupPlacement', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
popupPlacement="bottom-start"
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
})
it('should accept custom popupOffset', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
popupOffset={{ mainAxis: 10, crossAxis: 5 }}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle different datasetIds', () => {
const { rerender } = render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
rerender(
<SelectMetadataModal
datasetId="dataset-2"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
})
it('should handle empty trigger', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={<span data-testid="empty-trigger" />}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByTestId('empty-trigger')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,332 @@
import type { MetadataItem } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../types'
import SelectMetadata from './select-metadata'
type IconProps = {
className?: string
}
// Mock getIcon utility
vi.mock('../utils/get-icon', () => ({
getIcon: () => (props: IconProps) => <span data-testid="icon" className={props.className}>Icon</span>,
}))
describe('SelectMetadata', () => {
const mockList: MetadataItem[] = [
{ id: '1', name: 'field_one', type: DataType.string },
{ id: '2', name: 'field_two', type: DataType.number },
{ id: '3', name: 'field_three', type: DataType.time },
]
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render search input', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render all metadata items', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByText('field_one')).toBeInTheDocument()
expect(screen.getByText('field_two')).toBeInTheDocument()
expect(screen.getByText('field_three')).toBeInTheDocument()
})
it('should render new action button', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
// New action button should be present (from i18n)
expect(screen.getByText(/new/i)).toBeInTheDocument()
})
it('should render manage action button', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
// Manage action button should be present (from i18n)
expect(screen.getByText(/manage/i)).toBeInTheDocument()
})
it('should display type for each item', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getAllByText(DataType.string).length).toBeGreaterThan(0)
expect(screen.getAllByText(DataType.number).length).toBeGreaterThan(0)
expect(screen.getAllByText(DataType.time).length).toBeGreaterThan(0)
})
})
describe('Search Functionality', () => {
it('should filter items based on search query', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: 'one' } })
expect(screen.getByText('field_one')).toBeInTheDocument()
expect(screen.queryByText('field_two')).not.toBeInTheDocument()
expect(screen.queryByText('field_three')).not.toBeInTheDocument()
})
it('should be case insensitive search', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: 'ONE' } })
expect(screen.getByText('field_one')).toBeInTheDocument()
})
it('should show all items when search is cleared', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
const searchInput = screen.getByRole('textbox')
// Search for something
fireEvent.change(searchInput, { target: { value: 'one' } })
expect(screen.queryByText('field_two')).not.toBeInTheDocument()
// Clear search
fireEvent.change(searchInput, { target: { value: '' } })
expect(screen.getByText('field_two')).toBeInTheDocument()
})
it('should show no results when search matches nothing', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: 'xyz' } })
expect(screen.queryByText('field_one')).not.toBeInTheDocument()
expect(screen.queryByText('field_two')).not.toBeInTheDocument()
expect(screen.queryByText('field_three')).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onSelect with item data when item is clicked', () => {
const handleSelect = vi.fn()
render(
<SelectMetadata
list={mockList}
onSelect={handleSelect}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
fireEvent.click(screen.getByText('field_one'))
expect(handleSelect).toHaveBeenCalledWith({
id: '1',
name: 'field_one',
type: DataType.string,
})
})
it('should call onNew when new button is clicked', () => {
const handleNew = vi.fn()
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={handleNew}
onManage={vi.fn()}
/>,
)
// Find and click the new action button
const newButton = screen.getByText(/new/i)
fireEvent.click(newButton.closest('div') || newButton)
expect(handleNew).toHaveBeenCalled()
})
it('should call onManage when manage button is clicked', () => {
const handleManage = vi.fn()
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={handleManage}
/>,
)
// Find and click the manage action button
const manageButton = screen.getByText(/manage/i)
fireEvent.click(manageButton.closest('div') || manageButton)
expect(handleManage).toHaveBeenCalled()
})
})
describe('Empty State', () => {
it('should render empty list', () => {
const { container } = render(
<SelectMetadata
list={[]}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should still show new and manage buttons with empty list', () => {
render(
<SelectMetadata
list={[]}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByText(/new/i)).toBeInTheDocument()
expect(screen.getByText(/manage/i)).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have correct container styling', () => {
const { container } = render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(container.firstChild).toHaveClass('w-[320px]', 'rounded-xl')
})
})
describe('Edge Cases', () => {
it('should handle single item list', () => {
const singleItem: MetadataItem[] = [
{ id: '1', name: 'only_one', type: DataType.string },
]
render(
<SelectMetadata
list={singleItem}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByText('only_one')).toBeInTheDocument()
})
it('should handle item with long name', () => {
const longNameItem: MetadataItem[] = [
{ id: '1', name: 'this_is_a_very_long_field_name_that_might_overflow', type: DataType.string },
]
render(
<SelectMetadata
list={longNameItem}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByText('this_is_a_very_long_field_name_that_might_overflow')).toBeInTheDocument()
})
it('should handle rapid search input changes', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
const searchInput = screen.getByRole('textbox')
// Rapid typing
fireEvent.change(searchInput, { target: { value: 'f' } })
fireEvent.change(searchInput, { target: { value: 'fi' } })
fireEvent.change(searchInput, { target: { value: 'fie' } })
fireEvent.change(searchInput, { target: { value: 'fiel' } })
fireEvent.change(searchInput, { target: { value: 'field' } })
expect(screen.getByText('field_one')).toBeInTheDocument()
expect(screen.getByText('field_two')).toBeInTheDocument()
expect(screen.getByText('field_three')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,113 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import Field from './field'
describe('Field', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Field label="Test Label">Content</Field>)
expect(screen.getByText('Test Label')).toBeInTheDocument()
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('should render label with correct styling', () => {
render(<Field label="My Label">Content</Field>)
const labelElement = screen.getByText('My Label')
expect(labelElement).toHaveClass('system-xs-medium', 'w-[128px]', 'shrink-0', 'truncate', 'py-1', 'text-text-tertiary')
})
it('should render children in correct container', () => {
const { container } = render(<Field label="Label">Child Content</Field>)
// The children are wrapped in a div with w-[244px] class
const contentWrapper = container.querySelector('.w-\\[244px\\]')
expect(contentWrapper).toBeInTheDocument()
expect(contentWrapper).toHaveClass('shrink-0')
})
})
describe('Props', () => {
it('should render with string children', () => {
render(<Field label="Label">Simple Text</Field>)
expect(screen.getByText('Simple Text')).toBeInTheDocument()
})
it('should render with complex children', () => {
render(
<Field label="Label">
<div data-testid="complex-child">
<span>Nested Content</span>
</div>
</Field>,
)
expect(screen.getByTestId('complex-child')).toBeInTheDocument()
expect(screen.getByText('Nested Content')).toBeInTheDocument()
})
it('should render with multiple children', () => {
render(
<Field label="Label">
<span>First</span>
<span>Second</span>
</Field>,
)
expect(screen.getByText('First')).toBeInTheDocument()
expect(screen.getByText('Second')).toBeInTheDocument()
})
it('should render different labels correctly', () => {
const { rerender } = render(<Field label="First Label">Content</Field>)
expect(screen.getByText('First Label')).toBeInTheDocument()
rerender(<Field label="Second Label">Content</Field>)
expect(screen.getByText('Second Label')).toBeInTheDocument()
})
})
describe('Layout', () => {
it('should have flex layout with space between elements', () => {
const { container } = render(<Field label="Label">Content</Field>)
const wrapper = container.firstChild
expect(wrapper).toHaveClass('flex', 'items-start', 'space-x-2')
})
it('should render label and content side by side', () => {
const { container } = render(<Field label="Label">Content</Field>)
const wrapper = container.firstChild as HTMLElement
expect(wrapper?.children).toHaveLength(2)
})
})
describe('Edge Cases', () => {
it('should render with empty label', () => {
render(<Field label="">Content</Field>)
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('should render with long label (truncation)', () => {
const longLabel = 'This is a very long label that should be truncated'
render(<Field label={longLabel}>Content</Field>)
const labelElement = screen.getByText(longLabel)
expect(labelElement).toHaveClass('truncate')
})
it('should render with empty children', () => {
const { container } = render(<Field label="Label"><span></span></Field>)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with null children', () => {
const { container } = render(<Field label="Label">{null}</Field>)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with number as children', () => {
render(<Field label="Label">{42}</Field>)
expect(screen.getByText('42')).toBeInTheDocument()
})
it('should handle special characters in label', () => {
render(<Field label={'Label & "chars"'}>Content</Field>)
expect(screen.getByText('Label & "chars"')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,752 @@
import type { MetadataItemWithValue } from '../types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../types'
import MetadataDocument from './index'
type MockHookReturn = {
embeddingAvailable: boolean
isEdit: boolean
setIsEdit: ReturnType<typeof vi.fn>
list: MetadataItemWithValue[]
tempList: MetadataItemWithValue[]
setTempList: ReturnType<typeof vi.fn>
handleSelectMetaData: ReturnType<typeof vi.fn>
handleAddMetaData: ReturnType<typeof vi.fn>
hasData: boolean
builtList: MetadataItemWithValue[]
builtInEnabled: boolean
startToEdit: ReturnType<typeof vi.fn>
handleSave: ReturnType<typeof vi.fn>
handleCancel: ReturnType<typeof vi.fn>
originInfo: MetadataItemWithValue[]
technicalParameters: MetadataItemWithValue[]
}
// Mock useMetadataDocument hook - need to control state
const mockUseMetadataDocument = vi.fn<() => MockHookReturn>()
vi.mock('../hooks/use-metadata-document', () => ({
default: () => mockUseMetadataDocument(),
}))
// Mock service calls
vi.mock('@/service/knowledge/use-metadata', () => ({
useDatasetMetaData: () => ({
data: {
doc_metadata: [],
},
}),
}))
// Mock check name hook
vi.mock('../hooks/use-check-metadata-name', () => ({
default: () => ({
checkName: () => ({ errorMsg: '' }),
}),
}))
// Mock next/navigation
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
}),
}))
describe('MetadataDocument', () => {
const mockDocDetail = {
id: 'doc-1',
name: 'Test Document',
data_source_type: 'upload_file',
indexing_status: 'completed',
created_at: 1609459200,
word_count: 100,
}
const mockList: MetadataItemWithValue[] = [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
{ id: '2', name: 'field_two', type: DataType.number, value: 42 },
]
const defaultHookReturn: MockHookReturn = {
embeddingAvailable: true,
isEdit: false,
setIsEdit: vi.fn(),
list: mockList,
tempList: mockList,
setTempList: vi.fn(),
handleSelectMetaData: vi.fn(),
handleAddMetaData: vi.fn(),
hasData: true,
builtList: [],
builtInEnabled: false,
startToEdit: vi.fn(),
handleSave: vi.fn(),
handleCancel: vi.fn(),
originInfo: [],
technicalParameters: [],
}
beforeEach(() => {
vi.clearAllMocks()
mockUseMetadataDocument.mockReturnValue(defaultHookReturn)
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render metadata fields when hasData is true', () => {
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText('field_one')).toBeInTheDocument()
expect(screen.getByText('field_two')).toBeInTheDocument()
})
it('should render no-data state when hasData is false and not in edit mode', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
hasData: false,
list: [],
tempList: [],
isEdit: false,
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getAllByText(/metadata/i).length).toBeGreaterThan(0)
})
it('should render edit UI when in edit mode', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
isEdit: true,
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText(/save/i)).toBeInTheDocument()
expect(screen.getByText(/cancel/i)).toBeInTheDocument()
})
it('should render built-in section when builtInEnabled is true', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
builtInEnabled: true,
builtList: [{ id: 'built-in', name: 'created_at', type: DataType.time, value: 1609459200 }],
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText('created_at')).toBeInTheDocument()
})
it('should render divider when builtInEnabled is true', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
builtInEnabled: true,
builtList: [{ id: 'built-in', name: 'created_at', type: DataType.time, value: 1609459200 }],
})
const { container } = render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
const divider = container.querySelector('[class*="bg-gradient"]')
expect(divider).toBeInTheDocument()
})
it('should render origin info section', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
originInfo: [{ id: 'origin-1', name: 'source', type: DataType.string, value: 'upload' }],
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText('source')).toBeInTheDocument()
})
it('should render technical parameters section', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
technicalParameters: [{ id: 'tech-1', name: 'word_count', type: DataType.number, value: 100 }],
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText('word_count')).toBeInTheDocument()
})
it('should render all sections together', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
builtInEnabled: true,
builtList: [{ id: 'built-1', name: 'created_at', type: DataType.time, value: 1609459200 }],
originInfo: [{ id: 'origin-1', name: 'source', type: DataType.string, value: 'upload' }],
technicalParameters: [{ id: 'tech-1', name: 'word_count', type: DataType.number, value: 100 }],
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText('field_one')).toBeInTheDocument()
expect(screen.getByText('created_at')).toBeInTheDocument()
expect(screen.getByText('source')).toBeInTheDocument()
expect(screen.getByText('word_count')).toBeInTheDocument()
})
})
describe('Edit Mode', () => {
it('should show edit button when not in edit mode and embedding available', () => {
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText(/edit/i)).toBeInTheDocument()
})
it('should call startToEdit when edit button is clicked', () => {
const startToEdit = vi.fn()
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
isEdit: false,
startToEdit,
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
fireEvent.click(screen.getByText(/edit/i))
expect(startToEdit).toHaveBeenCalled()
})
it('should call handleSave when save button is clicked', () => {
const handleSave = vi.fn()
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
isEdit: true,
handleSave,
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
fireEvent.click(screen.getByText(/save/i))
expect(handleSave).toHaveBeenCalled()
})
it('should call handleCancel when cancel button is clicked', () => {
const handleCancel = vi.fn()
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
isEdit: true,
handleCancel,
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
fireEvent.click(screen.getByText(/cancel/i))
expect(handleCancel).toHaveBeenCalled()
})
it('should call setIsEdit(true) when start button is clicked in no-data state', () => {
const setIsEdit = vi.fn()
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
hasData: false,
list: [],
tempList: [],
isEdit: false,
setIsEdit,
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
const startBtn = screen.queryByText(/start/i)
if (startBtn) {
fireEvent.click(startBtn)
expect(setIsEdit).toHaveBeenCalledWith(true)
}
})
it('should show InfoGroup when in edit mode without data', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
hasData: false,
list: [],
tempList: [],
isEdit: true,
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
// Should show save/cancel buttons
expect(screen.getByText(/save/i)).toBeInTheDocument()
expect(screen.getByText(/cancel/i)).toBeInTheDocument()
})
})
describe('Data Operations', () => {
it('should call setTempList when field value changes', async () => {
const setTempList = vi.fn()
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
isEdit: true,
setTempList,
})
const { container } = render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
const inputs = container.querySelectorAll('input')
if (inputs.length > 0) {
fireEvent.change(inputs[0], { target: { value: 'new value' } })
await waitFor(() => {
expect(setTempList).toHaveBeenCalled()
})
}
})
it('should have handleAddMetaData function available', () => {
const handleAddMetaData = vi.fn()
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
isEdit: true,
handleAddMetaData,
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(typeof handleAddMetaData).toBe('function')
})
it('should have handleSelectMetaData function available', () => {
const handleSelectMetaData = vi.fn()
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
isEdit: true,
handleSelectMetaData,
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(typeof handleSelectMetaData).toBe('function')
})
it('should pass onChange callback to InfoGroup', async () => {
const setTempList = vi.fn()
const tempList = [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
]
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
isEdit: true,
tempList,
setTempList,
})
const { container } = render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
const inputs = container.querySelectorAll('input')
if (inputs.length > 0) {
fireEvent.change(inputs[0], { target: { value: 'updated' } })
await waitFor(() => {
expect(setTempList).toHaveBeenCalled()
})
}
})
it('should pass onDelete callback to InfoGroup', async () => {
const setTempList = vi.fn()
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
isEdit: true,
tempList: mockList,
setTempList,
})
const { container } = render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
// Look for delete buttons - they are inside hover:bg-state-destructive-hover divs
const deleteContainers = container.querySelectorAll('.hover\\:bg-state-destructive-hover')
expect(deleteContainers.length).toBeGreaterThan(0)
// Click the delete icon (SVG inside the container)
if (deleteContainers.length > 0) {
const deleteIcon = deleteContainers[0].querySelector('svg')
if (deleteIcon)
fireEvent.click(deleteIcon)
await waitFor(() => {
expect(setTempList).toHaveBeenCalled()
})
}
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
className="custom-class"
/>,
)
expect(container.firstChild).toHaveClass('custom-class')
})
it('should use tempList when in edit mode', () => {
const tempList = [{ id: 'temp-1', name: 'temp_field', type: DataType.string, value: 'temp' }]
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
isEdit: true,
tempList,
list: mockList,
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText('temp_field')).toBeInTheDocument()
})
it('should use list when not in edit mode', () => {
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText('field_one')).toBeInTheDocument()
expect(screen.getByText('field_two')).toBeInTheDocument()
})
it('should pass datasetId to child components', () => {
render(
<MetadataDocument
datasetId="custom-ds-id"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
// Component should render without errors
expect(screen.getByText('field_one')).toBeInTheDocument()
})
})
describe('Embedding Availability', () => {
it('should not show edit button when embedding is not available', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
embeddingAvailable: false,
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.queryByText(/^edit$/i)).not.toBeInTheDocument()
})
it('should not show NoData when embedding is not available', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
embeddingAvailable: false,
hasData: false,
list: [],
tempList: [],
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
// NoData component should not be rendered
expect(screen.queryByText(/start/i)).not.toBeInTheDocument()
})
it('should not show edit buttons in edit mode when embedding not available', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
embeddingAvailable: false,
isEdit: false,
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
// headerRight should be null/undefined
expect(screen.queryByText(/^edit$/i)).not.toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty lists', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
list: [],
tempList: [],
hasData: false,
})
const { container } = render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render correctly with minimal props', () => {
const { container } = render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle switching between view and edit mode', () => {
const { unmount } = render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText(/edit/i)).toBeInTheDocument()
unmount()
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
isEdit: true,
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText(/save/i)).toBeInTheDocument()
expect(screen.getByText(/cancel/i)).toBeInTheDocument()
})
it('should handle multiple items in all sections', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
list: [
{ id: '1', name: 'user_field_1', type: DataType.string, value: 'v1' },
{ id: '2', name: 'user_field_2', type: DataType.number, value: 42 },
],
builtInEnabled: true,
builtList: [
{ id: 'b1', name: 'created_at', type: DataType.time, value: 1609459200 },
{ id: 'b2', name: 'modified_at', type: DataType.time, value: 1609459200 },
],
originInfo: [
{ id: 'o1', name: 'source', type: DataType.string, value: 'file' },
{ id: 'o2', name: 'format', type: DataType.string, value: 'txt' },
],
technicalParameters: [
{ id: 't1', name: 'word_count', type: DataType.number, value: 100 },
{ id: 't2', name: 'char_count', type: DataType.number, value: 500 },
],
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText('user_field_1')).toBeInTheDocument()
expect(screen.getByText('user_field_2')).toBeInTheDocument()
expect(screen.getByText('created_at')).toBeInTheDocument()
expect(screen.getByText('source')).toBeInTheDocument()
expect(screen.getByText('word_count')).toBeInTheDocument()
})
it('should handle null values in metadata', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
list: [
{ id: '1', name: 'null_field', type: DataType.string, value: null },
],
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText('null_field')).toBeInTheDocument()
})
it('should handle undefined values in metadata', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
list: [
{ id: '1', name: 'undefined_field', type: DataType.string, value: undefined as unknown as null },
],
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText('undefined_field')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,341 @@
import type { MetadataItemWithValue } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../types'
import InfoGroup from './info-group'
type SelectModalProps = {
trigger: React.ReactNode
onSelect: (item: MetadataItemWithValue) => void
onSave: (data: { name: string, type: DataType }) => void
onManage: () => void
}
type FieldProps = {
label: string
children: React.ReactNode
}
type InputCombinedProps = {
value: string | number | null
onChange: (value: string | number) => void
type: DataType
}
// Mock next/navigation
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
}),
}))
// Mock useTimestamp
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: (timestamp: number) => {
if (!timestamp)
return ''
return new Date(timestamp * 1000).toLocaleDateString()
},
}),
}))
// Mock AddMetadataButton
vi.mock('../add-metadata-button', () => ({
default: () => <button data-testid="add-metadata-btn">Add Metadata</button>,
}))
// Mock InputCombined
vi.mock('../edit-metadata-batch/input-combined', () => ({
default: ({ value, onChange, type }: InputCombinedProps) => (
<input
data-testid="input-combined"
data-type={type}
value={value || ''}
onChange={e => onChange(e.target.value)}
/>
),
}))
// Mock SelectMetadataModal
vi.mock('../metadata-dataset/select-metadata-modal', () => ({
default: ({ trigger, onSelect, onSave, onManage }: SelectModalProps) => (
<div data-testid="select-metadata-modal">
{trigger}
<button data-testid="select-action" onClick={() => onSelect({ id: '1', name: 'test', type: DataType.string, value: null })}>Select</button>
<button data-testid="save-action" onClick={() => onSave({ name: 'new_field', type: DataType.string })}>Save</button>
<button data-testid="manage-action" onClick={onManage}>Manage</button>
</div>
),
}))
// Mock Field
vi.mock('./field', () => ({
default: ({ label, children }: FieldProps) => (
<div data-testid="field">
<span data-testid="field-label">{label}</span>
<div data-testid="field-content">{children}</div>
</div>
),
}))
describe('InfoGroup', () => {
const mockList: MetadataItemWithValue[] = [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
{ id: '2', name: 'field_two', type: DataType.number, value: 42 },
{ id: '3', name: 'built-in', type: DataType.time, value: 1609459200 },
]
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(
<InfoGroup dataSetId="ds-1" list={mockList} />,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render title when provided', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} title="Test Title" />,
)
expect(screen.getByText('Test Title')).toBeInTheDocument()
})
it('should not render header when noHeader is true', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} title="Test Title" noHeader />,
)
expect(screen.queryByText('Test Title')).not.toBeInTheDocument()
})
it('should render all list items', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} />,
)
const fields = screen.getAllByTestId('field')
expect(fields).toHaveLength(3)
})
it('should render tooltip when titleTooltip is provided', () => {
render(
<InfoGroup
dataSetId="ds-1"
list={mockList}
title="Test"
titleTooltip="This is a tooltip"
/>,
)
// Tooltip icon should be present
const tooltipIcon = screen.getByText('Test').closest('.flex')?.querySelector('svg')
expect(tooltipIcon).toBeInTheDocument()
})
it('should render headerRight content', () => {
render(
<InfoGroup
dataSetId="ds-1"
list={mockList}
title="Test"
headerRight={<button data-testid="header-right-btn">Action</button>}
/>,
)
expect(screen.getByTestId('header-right-btn')).toBeInTheDocument()
})
})
describe('Edit Mode', () => {
it('should render add metadata button when isEdit is true', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit />,
)
expect(screen.getByTestId('add-metadata-btn')).toBeInTheDocument()
})
it('should not render add metadata button when isEdit is false', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit={false} />,
)
expect(screen.queryByTestId('add-metadata-btn')).not.toBeInTheDocument()
})
it('should render input combined for each item in edit mode', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit />,
)
const inputs = screen.getAllByTestId('input-combined')
expect(inputs).toHaveLength(3)
})
it('should render delete icons in edit mode', () => {
const { container } = render(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit />,
)
const deleteIcons = container.querySelectorAll('.cursor-pointer svg')
expect(deleteIcons.length).toBeGreaterThan(0)
})
})
describe('User Interactions', () => {
it('should call onChange when input value changes', () => {
const handleChange = vi.fn()
render(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit onChange={handleChange} />,
)
const inputs = screen.getAllByTestId('input-combined')
fireEvent.change(inputs[0], { target: { value: 'New Value' } })
expect(handleChange).toHaveBeenCalled()
})
it('should call onDelete when delete icon is clicked', () => {
const handleDelete = vi.fn()
const { container } = render(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit onDelete={handleDelete} />,
)
// Find delete icons (RiDeleteBinLine SVGs inside cursor-pointer divs)
const deleteButtons = container.querySelectorAll('svg.size-4')
if (deleteButtons.length > 0)
fireEvent.click(deleteButtons[0])
expect(handleDelete).toHaveBeenCalled()
})
it('should call onSelect when metadata is selected', () => {
const handleSelect = vi.fn()
render(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit onSelect={handleSelect} />,
)
fireEvent.click(screen.getByTestId('select-action'))
expect(handleSelect).toHaveBeenCalledWith({
id: '1',
name: 'test',
type: DataType.string,
value: null,
})
})
it('should call onAdd when new metadata is saved', () => {
const handleAdd = vi.fn()
render(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit onAdd={handleAdd} />,
)
fireEvent.click(screen.getByTestId('save-action'))
expect(handleAdd).toHaveBeenCalledWith({
name: 'new_field',
type: DataType.string,
})
})
it('should navigate to documents page when manage is clicked', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit />,
)
fireEvent.click(screen.getByTestId('manage-action'))
// The onManage callback triggers the navigation
expect(screen.getByTestId('manage-action')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(
<InfoGroup dataSetId="ds-1" list={mockList} className="custom-class" />,
)
expect(container.firstChild).toHaveClass('custom-class')
})
it('should apply contentClassName', () => {
const { container } = render(
<InfoGroup dataSetId="ds-1" list={mockList} contentClassName="content-custom" />,
)
const contentDiv = container.querySelector('.content-custom')
expect(contentDiv).toBeInTheDocument()
})
it('should use uppercase title by default', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} title="Test Title" />,
)
const titleElement = screen.getByText('Test Title')
expect(titleElement).toHaveClass('system-xs-semibold-uppercase')
})
it('should not use uppercase when uppercaseTitle is false', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} title="Test Title" uppercaseTitle={false} />,
)
const titleElement = screen.getByText('Test Title')
expect(titleElement).toHaveClass('system-md-semibold')
})
})
describe('Value Display', () => {
it('should display string value directly', () => {
const stringList: MetadataItemWithValue[] = [
{ id: '1', name: 'field', type: DataType.string, value: 'Test Value' },
]
render(
<InfoGroup dataSetId="ds-1" list={stringList} />,
)
expect(screen.getByText('Test Value')).toBeInTheDocument()
})
it('should display number value', () => {
const numberList: MetadataItemWithValue[] = [
{ id: '1', name: 'field', type: DataType.number, value: 123 },
]
render(
<InfoGroup dataSetId="ds-1" list={numberList} />,
)
expect(screen.getByText('123')).toBeInTheDocument()
})
it('should format time value', () => {
const timeList: MetadataItemWithValue[] = [
{ id: '1', name: 'field', type: DataType.time, value: 1609459200 },
]
render(
<InfoGroup dataSetId="ds-1" list={timeList} />,
)
// The mock formatTime returns formatted date
expect(screen.getByTestId('field-content')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty list', () => {
const { container } = render(
<InfoGroup dataSetId="ds-1" list={[]} />,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle null value in list', () => {
const nullList: MetadataItemWithValue[] = [
{ id: '1', name: 'field', type: DataType.string, value: null },
]
render(
<InfoGroup dataSetId="ds-1" list={nullList} />,
)
expect(screen.getByTestId('field')).toBeInTheDocument()
})
it('should handle items with built-in id', () => {
const builtInList: MetadataItemWithValue[] = [
{ id: 'built-in', name: 'field', type: DataType.string, value: 'test' },
]
render(
<InfoGroup dataSetId="ds-1" list={builtInList} />,
)
expect(screen.getByTestId('field')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,131 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import NoData from './no-data'
describe('NoData', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const handleStart = vi.fn()
const { container } = render(<NoData onStart={handleStart} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with gradient background', () => {
const handleStart = vi.fn()
const { container } = render(<NoData onStart={handleStart} />)
expect(container.firstChild).toHaveClass('rounded-xl', 'bg-gradient-to-r', 'p-4', 'pt-3')
})
it('should render title text', () => {
const handleStart = vi.fn()
const { container } = render(<NoData onStart={handleStart} />)
// Title should have correct styling
const title = container.querySelector('.text-xs.font-semibold')
expect(title).toBeInTheDocument()
})
it('should render description text', () => {
const handleStart = vi.fn()
const { container } = render(<NoData onStart={handleStart} />)
// Description should have correct styling
const description = container.querySelector('.system-xs-regular.mt-1')
expect(description).toBeInTheDocument()
})
it('should render start labeling button', () => {
const handleStart = vi.fn()
render(<NoData onStart={handleStart} />)
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})
it('should render arrow icon in button', () => {
const handleStart = vi.fn()
const { container } = render(<NoData onStart={handleStart} />)
// RiArrowRightLine icon should be present
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
})
describe('Props', () => {
it('should accept onStart prop', () => {
const handleStart = vi.fn()
render(<NoData onStart={handleStart} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onStart when button is clicked', () => {
const handleStart = vi.fn()
render(<NoData onStart={handleStart} />)
fireEvent.click(screen.getByRole('button'))
expect(handleStart).toHaveBeenCalledTimes(1)
})
it('should call onStart multiple times on multiple clicks', () => {
const handleStart = vi.fn()
render(<NoData onStart={handleStart} />)
const button = screen.getByRole('button')
fireEvent.click(button)
fireEvent.click(button)
fireEvent.click(button)
expect(handleStart).toHaveBeenCalledTimes(3)
})
it('should have been called when button is clicked', () => {
const handleStart = vi.fn()
render(<NoData onStart={handleStart} />)
fireEvent.click(screen.getByRole('button'))
// The onClick handler passes the event to onStart
expect(handleStart).toHaveBeenCalled()
})
})
describe('Button Styling', () => {
it('should have primary variant button', () => {
const handleStart = vi.fn()
render(<NoData onStart={handleStart} />)
const button = screen.getByRole('button')
// Button should have primary styling
expect(button).toHaveClass('mt-2')
})
})
describe('Layout', () => {
it('should have correct title styling', () => {
const handleStart = vi.fn()
const { container } = render(<NoData onStart={handleStart} />)
const title = container.querySelector('.text-xs.font-semibold')
expect(title).toBeInTheDocument()
})
it('should have correct description styling', () => {
const handleStart = vi.fn()
const { container } = render(<NoData onStart={handleStart} />)
const description = container.querySelector('.system-xs-regular.mt-1')
expect(description).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle rapid clicks', () => {
const handleStart = vi.fn()
render(<NoData onStart={handleStart} />)
const button = screen.getByRole('button')
for (let i = 0; i < 10; i++) {
fireEvent.click(button)
}
expect(handleStart).toHaveBeenCalledTimes(10)
})
})
})

View File

@@ -0,0 +1,45 @@
import { RiHashtag, RiTextSnippet, RiTimeLine } from '@remixicon/react'
import { describe, expect, it } from 'vitest'
import { DataType } from '../types'
import { getIcon } from './get-icon'
describe('getIcon', () => {
describe('Rendering', () => {
it('should return RiTextSnippet for DataType.string', () => {
const result = getIcon(DataType.string)
expect(result).toBe(RiTextSnippet)
})
it('should return RiHashtag for DataType.number', () => {
const result = getIcon(DataType.number)
expect(result).toBe(RiHashtag)
})
it('should return RiTimeLine for DataType.time', () => {
const result = getIcon(DataType.time)
expect(result).toBe(RiTimeLine)
})
})
describe('Edge Cases', () => {
it('should return RiTextSnippet as fallback for unknown type', () => {
const result = getIcon('unknown' as DataType)
expect(result).toBe(RiTextSnippet)
})
it('should return RiTextSnippet for undefined type', () => {
const result = getIcon(undefined as unknown as DataType)
expect(result).toBe(RiTextSnippet)
})
it('should return RiTextSnippet for null type', () => {
const result = getIcon(null as unknown as DataType)
expect(result).toBe(RiTextSnippet)
})
it('should return RiTextSnippet for empty string type', () => {
const result = getIcon('' as DataType)
expect(result).toBe(RiTextSnippet)
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,239 @@
import { renderHook } from '@testing-library/react'
import { useChunkStructure } from './hooks'
import { EffectColor } from './types'
// Note: react-i18next is globally mocked in vitest.setup.ts
describe('useChunkStructure', () => {
describe('Hook Initialization', () => {
it('should return options array', () => {
const { result } = renderHook(() => useChunkStructure())
expect(result.current.options).toBeDefined()
expect(Array.isArray(result.current.options)).toBe(true)
})
it('should return exactly 3 options', () => {
const { result } = renderHook(() => useChunkStructure())
expect(result.current.options).toHaveLength(3)
})
})
describe('General Option', () => {
it('should have correct id for General option', () => {
const { result } = renderHook(() => useChunkStructure())
const generalOption = result.current.options[0]
expect(generalOption.id).toBe('text_model')
})
it('should have icon for General option', () => {
const { result } = renderHook(() => useChunkStructure())
const generalOption = result.current.options[0]
expect(generalOption.icon).toBeDefined()
})
it('should have correct iconActiveColor for General option', () => {
const { result } = renderHook(() => useChunkStructure())
const generalOption = result.current.options[0]
expect(generalOption.iconActiveColor).toBe('text-util-colors-indigo-indigo-600')
})
it('should have title for General option', () => {
const { result } = renderHook(() => useChunkStructure())
const generalOption = result.current.options[0]
expect(generalOption.title).toBe('General')
})
it('should have description for General option', () => {
const { result } = renderHook(() => useChunkStructure())
const generalOption = result.current.options[0]
expect(generalOption.description).toBeDefined()
})
it('should have indigo effectColor for General option', () => {
const { result } = renderHook(() => useChunkStructure())
const generalOption = result.current.options[0]
expect(generalOption.effectColor).toBe(EffectColor.indigo)
})
it('should have showEffectColor true for General option', () => {
const { result } = renderHook(() => useChunkStructure())
const generalOption = result.current.options[0]
expect(generalOption.showEffectColor).toBe(true)
})
})
describe('Parent-Child Option', () => {
it('should have correct id for Parent-Child option', () => {
const { result } = renderHook(() => useChunkStructure())
const parentChildOption = result.current.options[1]
expect(parentChildOption.id).toBe('hierarchical_model')
})
it('should have icon for Parent-Child option', () => {
const { result } = renderHook(() => useChunkStructure())
const parentChildOption = result.current.options[1]
expect(parentChildOption.icon).toBeDefined()
})
it('should have correct iconActiveColor for Parent-Child option', () => {
const { result } = renderHook(() => useChunkStructure())
const parentChildOption = result.current.options[1]
expect(parentChildOption.iconActiveColor).toBe('text-util-colors-blue-light-blue-light-500')
})
it('should have title for Parent-Child option', () => {
const { result } = renderHook(() => useChunkStructure())
const parentChildOption = result.current.options[1]
expect(parentChildOption.title).toBe('Parent-Child')
})
it('should have description for Parent-Child option', () => {
const { result } = renderHook(() => useChunkStructure())
const parentChildOption = result.current.options[1]
expect(parentChildOption.description).toBeDefined()
})
it('should have blueLight effectColor for Parent-Child option', () => {
const { result } = renderHook(() => useChunkStructure())
const parentChildOption = result.current.options[1]
expect(parentChildOption.effectColor).toBe(EffectColor.blueLight)
})
it('should have showEffectColor true for Parent-Child option', () => {
const { result } = renderHook(() => useChunkStructure())
const parentChildOption = result.current.options[1]
expect(parentChildOption.showEffectColor).toBe(true)
})
})
describe('Q&A Option', () => {
it('should have correct id for Q&A option', () => {
const { result } = renderHook(() => useChunkStructure())
const qaOption = result.current.options[2]
expect(qaOption.id).toBe('qa_model')
})
it('should have icon for Q&A option', () => {
const { result } = renderHook(() => useChunkStructure())
const qaOption = result.current.options[2]
expect(qaOption.icon).toBeDefined()
})
it('should have title for Q&A option', () => {
const { result } = renderHook(() => useChunkStructure())
const qaOption = result.current.options[2]
expect(qaOption.title).toBe('Q&A')
})
it('should have description for Q&A option', () => {
const { result } = renderHook(() => useChunkStructure())
const qaOption = result.current.options[2]
expect(qaOption.description).toBeDefined()
})
it('should not have effectColor for Q&A option', () => {
const { result } = renderHook(() => useChunkStructure())
const qaOption = result.current.options[2]
expect(qaOption.effectColor).toBeUndefined()
})
it('should not have showEffectColor for Q&A option', () => {
const { result } = renderHook(() => useChunkStructure())
const qaOption = result.current.options[2]
expect(qaOption.showEffectColor).toBeUndefined()
})
it('should not have iconActiveColor for Q&A option', () => {
const { result } = renderHook(() => useChunkStructure())
const qaOption = result.current.options[2]
expect(qaOption.iconActiveColor).toBeUndefined()
})
})
describe('Options Structure', () => {
it('should return options in correct order', () => {
const { result } = renderHook(() => useChunkStructure())
const ids = result.current.options.map(opt => opt.id)
expect(ids).toEqual(['text_model', 'hierarchical_model', 'qa_model'])
})
it('should return all options with required id property', () => {
const { result } = renderHook(() => useChunkStructure())
result.current.options.forEach((option) => {
expect(option.id).toBeDefined()
})
})
it('should return all options with required title property', () => {
const { result } = renderHook(() => useChunkStructure())
result.current.options.forEach((option) => {
expect(option.title).toBeDefined()
expect(typeof option.title).toBe('string')
})
})
it('should return all options with description property', () => {
const { result } = renderHook(() => useChunkStructure())
result.current.options.forEach((option) => {
expect(option.description).toBeDefined()
})
})
it('should return all options with icon property', () => {
const { result } = renderHook(() => useChunkStructure())
result.current.options.forEach((option) => {
expect(option.icon).toBeDefined()
})
})
})
describe('Hook Stability', () => {
it('should return consistent options on multiple renders', () => {
const { result, rerender } = renderHook(() => useChunkStructure())
const firstRenderOptions = result.current.options.map(opt => opt.id)
rerender()
const secondRenderOptions = result.current.options.map(opt => opt.id)
expect(firstRenderOptions).toEqual(secondRenderOptions)
})
it('should return options with stable structure', () => {
const { result, rerender } = renderHook(() => useChunkStructure())
const firstLength = result.current.options.length
rerender()
const secondLength = result.current.options.length
expect(firstLength).toBe(secondLength)
})
})
})

View File

@@ -2,84 +2,124 @@ import { render, screen } from '@testing-library/react'
import { ChunkingMode } from '@/models/datasets'
import ChunkStructure from './index'
type MockOptionCardProps = {
id: string
title: string
isActive?: boolean
disabled?: boolean
}
// Mock dependencies
vi.mock('../option-card', () => ({
default: ({ id, title, isActive, disabled }: MockOptionCardProps) => (
<div
data-testid="option-card"
data-id={id}
data-active={isActive}
data-disabled={disabled}
>
{title}
</div>
),
}))
// Mock hook
vi.mock('./hooks', () => ({
useChunkStructure: () => ({
options: [
{
id: ChunkingMode.text,
title: 'General',
description: 'General description',
icon: <svg />,
effectColor: 'indigo',
iconActiveColor: 'indigo',
},
{
id: ChunkingMode.parentChild,
title: 'Parent-Child',
description: 'PC description',
icon: <svg />,
effectColor: 'blue',
iconActiveColor: 'blue',
},
],
}),
}))
// Note: react-i18next is globally mocked in vitest.setup.ts
describe('ChunkStructure', () => {
it('should render all options', () => {
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
describe('Rendering', () => {
it('should render without crashing', () => {
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
expect(screen.getByText('General')).toBeInTheDocument()
})
const options = screen.getAllByTestId('option-card')
expect(options).toHaveLength(2)
expect(options[0]).toHaveTextContent('General')
expect(options[1]).toHaveTextContent('Parent-Child')
it('should render all three options', () => {
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
expect(screen.getByText('General')).toBeInTheDocument()
expect(screen.getByText('Parent-Child')).toBeInTheDocument()
expect(screen.getByText('Q&A')).toBeInTheDocument()
})
it('should render in a vertical layout', () => {
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
const wrapper = container.firstChild
expect(wrapper).toHaveClass('flex-col')
})
})
it('should set active state correctly', () => {
// Render with 'text' active
const { unmount } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
describe('Active State', () => {
it('should mark General option as active when chunkStructure is text', () => {
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
// The active card has ring styling
const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
})
const options = screen.getAllByTestId('option-card')
expect(options[0]).toHaveAttribute('data-active', 'true')
expect(options[1]).toHaveAttribute('data-active', 'false')
it('should mark Parent-Child option as active when chunkStructure is parentChild', () => {
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.parentChild} />)
const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
})
unmount()
// Render with 'parentChild' active
render(<ChunkStructure chunkStructure={ChunkingMode.parentChild} />)
const newOptions = screen.getAllByTestId('option-card')
expect(newOptions[0]).toHaveAttribute('data-active', 'false')
expect(newOptions[1]).toHaveAttribute('data-active', 'true')
it('should mark Q&A option as active when chunkStructure is qa', () => {
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.qa} />)
const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
})
})
it('should be always disabled', () => {
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
describe('Disabled State', () => {
it('should render all options as disabled', () => {
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
// All cards should have cursor-not-allowed (disabled)
const disabledCards = container.querySelectorAll('.cursor-not-allowed')
expect(disabledCards.length).toBeGreaterThan(0)
})
})
const options = screen.getAllByTestId('option-card')
options.forEach((option) => {
expect(option).toHaveAttribute('data-disabled', 'true')
describe('Option Cards', () => {
it('should render option cards with correct structure', () => {
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
// All options should have descriptions
expect(screen.getByText(/stepTwo\.generalTip/)).toBeInTheDocument()
expect(screen.getByText(/stepTwo\.parentChildTip/)).toBeInTheDocument()
expect(screen.getByText(/stepTwo\.qaTip/)).toBeInTheDocument()
})
it('should render icons for all options', () => {
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
// Each option card should have an icon (SVG elements)
const svgs = container.querySelectorAll('svg')
expect(svgs.length).toBeGreaterThanOrEqual(3) // At least 3 icons
})
})
describe('Effect Colors', () => {
it('should show effect color for active General option', () => {
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
const effectElement = container.querySelector('.bg-util-colors-indigo-indigo-600')
expect(effectElement).toBeInTheDocument()
})
it('should show effect color for active Parent-Child option', () => {
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.parentChild} />)
const effectElement = container.querySelector('.bg-util-colors-blue-light-blue-light-600')
expect(effectElement).toBeInTheDocument()
})
})
describe('Props', () => {
it('should update active state when chunkStructure prop changes', () => {
const { rerender, container } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
// Initially one card is active
let activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
// Change to parentChild
rerender(<ChunkStructure chunkStructure={ChunkingMode.parentChild} />)
// Still one card should be active
activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
// Change to qa
rerender(<ChunkStructure chunkStructure={ChunkingMode.qa} />)
// Still one card should be active
activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
})
})
describe('Integration with useChunkStructure hook', () => {
it('should use options from useChunkStructure hook', () => {
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
// Verify all expected options are rendered
const expectedTitles = ['General', 'Parent-Child', 'Q&A']
expectedTitles.forEach((title) => {
expect(screen.getByText(title)).toBeInTheDocument()
})
})
})
})

View File

@@ -0,0 +1,208 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { IndexingType } from '../../create/step-two'
import IndexMethod from './index'
// Note: react-i18next is globally mocked in vitest.setup.ts
describe('IndexMethod', () => {
const defaultProps = {
value: IndexingType.QUALIFIED,
onChange: vi.fn(),
keywordNumber: 10,
onKeywordNumberChange: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<IndexMethod {...defaultProps} />)
expect(screen.getByText(/stepTwo\.qualified/)).toBeInTheDocument()
})
it('should render High Quality option', () => {
render(<IndexMethod {...defaultProps} />)
expect(screen.getByText(/stepTwo\.qualified/)).toBeInTheDocument()
})
it('should render Economy option', () => {
render(<IndexMethod {...defaultProps} />)
expect(screen.getAllByText(/form\.indexMethodEconomy/).length).toBeGreaterThan(0)
})
it('should render High Quality description', () => {
render(<IndexMethod {...defaultProps} />)
expect(screen.getByText(/form\.indexMethodHighQualityTip/)).toBeInTheDocument()
})
it('should render Economy description', () => {
render(<IndexMethod {...defaultProps} />)
expect(screen.getByText(/form\.indexMethodEconomyTip/)).toBeInTheDocument()
})
it('should render recommended badge on High Quality', () => {
render(<IndexMethod {...defaultProps} />)
expect(screen.getByText(/stepTwo\.recommend/)).toBeInTheDocument()
})
})
describe('Active State', () => {
it('should mark High Quality as active when value is QUALIFIED', () => {
const { container } = render(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} />)
const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
})
it('should mark Economy as active when value is ECONOMICAL', () => {
const { container } = render(<IndexMethod {...defaultProps} value={IndexingType.ECONOMICAL} />)
const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
})
})
describe('User Interactions', () => {
it('should call onChange with QUALIFIED when High Quality is clicked', () => {
const handleChange = vi.fn()
render(<IndexMethod {...defaultProps} value={IndexingType.ECONOMICAL} onChange={handleChange} />)
// Find and click High Quality option
const highQualityTitle = screen.getByText(/stepTwo\.qualified/)
const card = highQualityTitle.closest('div')?.parentElement?.parentElement?.parentElement
fireEvent.click(card!)
expect(handleChange).toHaveBeenCalledWith(IndexingType.QUALIFIED)
})
it('should call onChange with ECONOMICAL when Economy is clicked', () => {
const handleChange = vi.fn()
render(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} onChange={handleChange} currentValue={IndexingType.ECONOMICAL} />)
// Find and click Economy option - use getAllByText and get the first one (title)
const economyTitles = screen.getAllByText(/form\.indexMethodEconomy/)
const economyTitle = economyTitles[0]
const card = economyTitle.closest('div')?.parentElement?.parentElement?.parentElement
fireEvent.click(card!)
expect(handleChange).toHaveBeenCalledWith(IndexingType.ECONOMICAL)
})
it('should not call onChange when clicking already active option', () => {
const handleChange = vi.fn()
render(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} onChange={handleChange} />)
// Click on already active High Quality
const highQualityTitle = screen.getByText(/stepTwo\.qualified/)
const card = highQualityTitle.closest('div')?.parentElement?.parentElement?.parentElement
fireEvent.click(card!)
expect(handleChange).not.toHaveBeenCalled()
})
})
describe('Disabled State', () => {
it('should disable both options when disabled is true', () => {
const { container } = render(<IndexMethod {...defaultProps} disabled={true} />)
const disabledCards = container.querySelectorAll('.cursor-not-allowed')
expect(disabledCards.length).toBeGreaterThan(0)
})
it('should disable Economy option when currentValue is QUALIFIED', () => {
const handleChange = vi.fn()
render(<IndexMethod {...defaultProps} currentValue={IndexingType.QUALIFIED} onChange={handleChange} value={IndexingType.ECONOMICAL} />)
// Try to click Economy option - use getAllByText and get the first one (title)
const economyTitles = screen.getAllByText(/form\.indexMethodEconomy/)
const economyTitle = economyTitles[0]
const card = economyTitle.closest('div')?.parentElement?.parentElement?.parentElement
fireEvent.click(card!)
// Should not call onChange because Economy is disabled when current is QUALIFIED
expect(handleChange).not.toHaveBeenCalled()
})
})
describe('KeywordNumber', () => {
it('should render KeywordNumber component inside Economy option', () => {
render(<IndexMethod {...defaultProps} />)
// KeywordNumber has a slider
expect(screen.getByRole('slider')).toBeInTheDocument()
})
it('should pass keywordNumber to KeywordNumber component', () => {
render(<IndexMethod {...defaultProps} keywordNumber={25} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(25)
})
it('should call onKeywordNumberChange when KeywordNumber changes', () => {
const handleKeywordChange = vi.fn()
render(<IndexMethod {...defaultProps} onKeywordNumberChange={handleKeywordChange} />)
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '30' } })
expect(handleKeywordChange).toHaveBeenCalled()
})
})
describe('Tooltip', () => {
it('should show tooltip when hovering over disabled Economy option', () => {
// The tooltip is shown via PortalToFollowElem when hovering
// This is controlled by useHover hook
render(<IndexMethod {...defaultProps} currentValue={IndexingType.QUALIFIED} />)
// The tooltip content should exist in DOM but may not be visible
// We just verify the component renders without error
expect(screen.getAllByText(/form\.indexMethodEconomy/).length).toBeGreaterThan(0)
})
})
describe('Effect Colors', () => {
it('should show orange effect color for High Quality option', () => {
const { container } = render(<IndexMethod {...defaultProps} />)
const orangeEffect = container.querySelector('.bg-util-colors-orange-orange-500')
expect(orangeEffect).toBeInTheDocument()
})
it('should show indigo effect color for Economy option', () => {
const { container } = render(<IndexMethod {...defaultProps} />)
const indigoEffect = container.querySelector('.bg-util-colors-indigo-indigo-600')
expect(indigoEffect).toBeInTheDocument()
})
})
describe('Props', () => {
it('should update active state when value prop changes', () => {
const { rerender, container } = render(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} />)
let activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
rerender(<IndexMethod {...defaultProps} value={IndexingType.ECONOMICAL} currentValue={IndexingType.ECONOMICAL} />)
activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
})
})
describe('Edge Cases', () => {
it('should handle undefined currentValue', () => {
render(<IndexMethod {...defaultProps} currentValue={undefined} />)
// Should render without error
expect(screen.getByText(/stepTwo\.qualified/)).toBeInTheDocument()
})
it('should handle keywordNumber of 0', () => {
render(<IndexMethod {...defaultProps} keywordNumber={0} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(0)
})
it('should handle max keywordNumber', () => {
render(<IndexMethod {...defaultProps} keywordNumber={50} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(50)
})
})
})

View File

@@ -0,0 +1,171 @@
import { fireEvent, render, screen } from '@testing-library/react'
import KeyWordNumber from './keyword-number'
// Note: react-i18next is globally mocked in vitest.setup.ts
describe('KeyWordNumber', () => {
const defaultProps = {
keywordNumber: 10,
onKeywordNumberChange: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<KeyWordNumber {...defaultProps} />)
expect(screen.getByText(/form\.numberOfKeywords/)).toBeInTheDocument()
})
it('should render label text', () => {
render(<KeyWordNumber {...defaultProps} />)
expect(screen.getByText(/form\.numberOfKeywords/)).toBeInTheDocument()
})
it('should render tooltip with question icon', () => {
render(<KeyWordNumber {...defaultProps} />)
// RiQuestionLine renders as an svg
const container = screen.getByText(/form\.numberOfKeywords/).closest('div')?.parentElement
const questionIcon = container?.querySelector('svg')
expect(questionIcon).toBeInTheDocument()
})
it('should render slider', () => {
render(<KeyWordNumber {...defaultProps} />)
// Slider has a slider role
expect(screen.getByRole('slider')).toBeInTheDocument()
})
it('should render input number field', () => {
render(<KeyWordNumber {...defaultProps} />)
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should display correct keywordNumber value in input', () => {
render(<KeyWordNumber {...defaultProps} keywordNumber={25} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(25)
})
it('should display different keywordNumber values', () => {
const values = [1, 10, 25, 50]
values.forEach((value) => {
const { unmount } = render(<KeyWordNumber {...defaultProps} keywordNumber={value} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(value)
unmount()
})
})
it('should pass correct value to slider', () => {
render(<KeyWordNumber {...defaultProps} keywordNumber={30} />)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuenow', '30')
})
})
describe('User Interactions', () => {
it('should render slider that accepts onChange', () => {
const handleChange = vi.fn()
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
const slider = screen.getByRole('slider')
// Verify slider is rendered and interactive
expect(slider).toBeInTheDocument()
expect(slider).not.toBeDisabled()
})
it('should call onKeywordNumberChange when input value changes', () => {
const handleChange = vi.fn()
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '30' } })
expect(handleChange).toHaveBeenCalled()
})
it('should not call onKeywordNumberChange with undefined value', () => {
const handleChange = vi.fn()
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '' } })
// When value is empty/undefined, handleInputChange should not call onKeywordNumberChange
expect(handleChange).not.toHaveBeenCalled()
})
})
describe('Slider Configuration', () => {
it('should have max value of 50', () => {
render(<KeyWordNumber {...defaultProps} />)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemax', '50')
})
it('should have min value of 0', () => {
render(<KeyWordNumber {...defaultProps} />)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemin', '0')
})
})
describe('Edge Cases', () => {
it('should handle minimum value (0)', () => {
render(<KeyWordNumber {...defaultProps} keywordNumber={0} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(0)
})
it('should handle maximum value (50)', () => {
render(<KeyWordNumber {...defaultProps} keywordNumber={50} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(50)
})
it('should handle value updates correctly', () => {
const { rerender } = render(<KeyWordNumber {...defaultProps} keywordNumber={10} />)
let input = screen.getByRole('spinbutton')
expect(input).toHaveValue(10)
rerender(<KeyWordNumber {...defaultProps} keywordNumber={25} />)
input = screen.getByRole('spinbutton')
expect(input).toHaveValue(25)
})
it('should handle rapid value changes', () => {
const handleChange = vi.fn()
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
const input = screen.getByRole('spinbutton')
// Simulate rapid changes via input with different values
fireEvent.change(input, { target: { value: '15' } })
fireEvent.change(input, { target: { value: '25' } })
fireEvent.change(input, { target: { value: '35' } })
expect(handleChange).toHaveBeenCalledTimes(3)
})
})
describe('Accessibility', () => {
it('should have accessible slider', () => {
render(<KeyWordNumber {...defaultProps} />)
const slider = screen.getByRole('slider')
expect(slider).toBeInTheDocument()
})
it('should have accessible input', () => {
render(<KeyWordNumber {...defaultProps} />)
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,317 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { EffectColor } from './chunk-structure/types'
import OptionCard from './option-card'
// Note: react-i18next is globally mocked in vitest.setup.ts
describe('OptionCard', () => {
const defaultProps = {
id: 'test-id',
title: 'Test Title',
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<OptionCard {...defaultProps} />)
expect(screen.getByText('Test Title')).toBeInTheDocument()
})
it('should render title', () => {
render(<OptionCard {...defaultProps} title="Custom Title" />)
expect(screen.getByText('Custom Title')).toBeInTheDocument()
})
it('should render description when provided', () => {
render(<OptionCard {...defaultProps} description="Test Description" />)
expect(screen.getByText('Test Description')).toBeInTheDocument()
})
it('should not render description when not provided', () => {
render(<OptionCard {...defaultProps} />)
expect(screen.queryByText(/description/i)).not.toBeInTheDocument()
})
it('should render icon when provided', () => {
render(<OptionCard {...defaultProps} icon={<span data-testid="test-icon">Icon</span>} />)
expect(screen.getByTestId('test-icon')).toBeInTheDocument()
})
it('should not render icon container when icon is not provided', () => {
const { container } = render(<OptionCard {...defaultProps} />)
const iconContainers = container.querySelectorAll('.size-6')
expect(iconContainers).toHaveLength(0)
})
})
describe('Active State', () => {
it('should apply active styles when isActive is true', () => {
const { container } = render(<OptionCard {...defaultProps} isActive={true} />)
const card = container.firstChild
expect(card).toHaveClass('ring-[1px]')
})
it('should not apply active styles when isActive is false', () => {
const { container } = render(<OptionCard {...defaultProps} isActive={false} />)
const card = container.firstChild
expect(card).not.toHaveClass('ring-[1px]')
})
it('should apply iconActiveColor when isActive is true and icon is present', () => {
const { container } = render(
<OptionCard
{...defaultProps}
isActive={true}
icon={<span>Icon</span>}
iconActiveColor="text-red-500"
/>,
)
const iconContainer = container.querySelector('.text-red-500')
expect(iconContainer).toBeInTheDocument()
})
})
describe('Disabled State', () => {
it('should apply disabled styles when disabled is true', () => {
const { container } = render(<OptionCard {...defaultProps} disabled={true} />)
const card = container.firstChild
expect(card).toHaveClass('cursor-not-allowed')
expect(card).toHaveClass('opacity-50')
})
it('should not call onClick when disabled', () => {
const handleClick = vi.fn()
render(<OptionCard {...defaultProps} disabled={true} onClick={handleClick} />)
const card = screen.getByText('Test Title').closest('div')?.parentElement?.parentElement
fireEvent.click(card!)
expect(handleClick).not.toHaveBeenCalled()
})
it('should not call onClick when isActive', () => {
const handleClick = vi.fn()
render(<OptionCard {...defaultProps} isActive={true} onClick={handleClick} />)
const card = screen.getByText('Test Title').closest('div')?.parentElement?.parentElement
fireEvent.click(card!)
expect(handleClick).not.toHaveBeenCalled()
})
})
describe('Recommended Badge', () => {
it('should render recommended badge when isRecommended is true', () => {
render(<OptionCard {...defaultProps} isRecommended={true} />)
// Badge uses translation key
expect(screen.getByText(/stepTwo\.recommend/)).toBeInTheDocument()
})
it('should not render recommended badge when isRecommended is false', () => {
render(<OptionCard {...defaultProps} isRecommended={false} />)
expect(screen.queryByText(/stepTwo\.recommend/)).not.toBeInTheDocument()
})
})
describe('Effect Color', () => {
it('should render effect color when effectColor and showEffectColor are provided', () => {
const { container } = render(
<OptionCard {...defaultProps} effectColor={EffectColor.indigo} showEffectColor={true} />,
)
const effectElement = container.querySelector('.blur-\\[80px\\]')
expect(effectElement).toBeInTheDocument()
})
it('should not render effect color when showEffectColor is false', () => {
const { container } = render(
<OptionCard {...defaultProps} effectColor={EffectColor.indigo} showEffectColor={false} />,
)
const effectElement = container.querySelector('.blur-\\[80px\\]')
expect(effectElement).not.toBeInTheDocument()
})
it('should not render effect color when effectColor is not provided', () => {
const { container } = render(
<OptionCard {...defaultProps} showEffectColor={true} />,
)
const effectElement = container.querySelector('.blur-\\[80px\\]')
expect(effectElement).not.toBeInTheDocument()
})
it('should apply indigo effect color class', () => {
const { container } = render(
<OptionCard {...defaultProps} effectColor={EffectColor.indigo} showEffectColor={true} />,
)
const effectElement = container.querySelector('.bg-util-colors-indigo-indigo-600')
expect(effectElement).toBeInTheDocument()
})
it('should apply blueLight effect color class', () => {
const { container } = render(
<OptionCard {...defaultProps} effectColor={EffectColor.blueLight} showEffectColor={true} />,
)
const effectElement = container.querySelector('.bg-util-colors-blue-light-blue-light-600')
expect(effectElement).toBeInTheDocument()
})
it('should apply orange effect color class', () => {
const { container } = render(
<OptionCard {...defaultProps} effectColor={EffectColor.orange} showEffectColor={true} />,
)
const effectElement = container.querySelector('.bg-util-colors-orange-orange-500')
expect(effectElement).toBeInTheDocument()
})
it('should apply purple effect color class', () => {
const { container } = render(
<OptionCard {...defaultProps} effectColor={EffectColor.purple} showEffectColor={true} />,
)
const effectElement = container.querySelector('.bg-util-colors-purple-purple-600')
expect(effectElement).toBeInTheDocument()
})
})
describe('Children', () => {
it('should render children when children and showChildren are provided', () => {
render(
<OptionCard {...defaultProps} showChildren={true}>
<div data-testid="child-content">Child Content</div>
</OptionCard>,
)
expect(screen.getByTestId('child-content')).toBeInTheDocument()
})
it('should not render children when showChildren is false', () => {
render(
<OptionCard {...defaultProps} showChildren={false}>
<div data-testid="child-content">Child Content</div>
</OptionCard>,
)
expect(screen.queryByTestId('child-content')).not.toBeInTheDocument()
})
it('should not render children container when children is not provided', () => {
const { container } = render(
<OptionCard {...defaultProps} showChildren={true} />,
)
const childContainer = container.querySelector('.bg-components-panel-bg')
expect(childContainer).not.toBeInTheDocument()
})
it('should render arrow shape when children are shown', () => {
const { container } = render(
<OptionCard {...defaultProps} showChildren={true}>
<div>Child</div>
</OptionCard>,
)
// ArrowShape renders an SVG
const childSection = container.querySelector('.bg-components-panel-bg')
expect(childSection).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClick with id when clicked', () => {
const handleClick = vi.fn()
render(<OptionCard {...defaultProps} id="my-id" onClick={handleClick} />)
const card = screen.getByText('Test Title').closest('div')?.parentElement?.parentElement
fireEvent.click(card!)
expect(handleClick).toHaveBeenCalledWith('my-id')
})
it('should have cursor-pointer class', () => {
const { container } = render(<OptionCard {...defaultProps} />)
const card = container.firstChild
expect(card).toHaveClass('cursor-pointer')
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(<OptionCard {...defaultProps} className="custom-class" />)
const innerContainer = container.querySelector('.custom-class')
expect(innerContainer).toBeInTheDocument()
})
it('should forward ref', () => {
const ref = vi.fn()
render(<OptionCard {...defaultProps} ref={ref} />)
expect(ref).toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should handle empty title', () => {
render(<OptionCard {...defaultProps} title="" />)
// Component should still render
const { container } = render(<OptionCard {...defaultProps} title="" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle complex id types', () => {
const handleClick = vi.fn()
const complexId = { key: 'value' }
render(<OptionCard {...defaultProps} id={complexId} onClick={handleClick} />)
const card = screen.getByText('Test Title').closest('div')?.parentElement?.parentElement
fireEvent.click(card!)
expect(handleClick).toHaveBeenCalledWith(complexId)
})
it('should handle numeric id', () => {
const handleClick = vi.fn()
render(<OptionCard {...defaultProps} id={123} onClick={handleClick} />)
const card = screen.getByText('Test Title').closest('div')?.parentElement?.parentElement
fireEvent.click(card!)
expect(handleClick).toHaveBeenCalledWith(123)
})
it('should handle long title', () => {
const longTitle = 'A'.repeat(200)
render(<OptionCard {...defaultProps} title={longTitle} />)
expect(screen.getByText(longTitle)).toBeInTheDocument()
})
it('should handle long description', () => {
const longDesc = 'B'.repeat(500)
render(<OptionCard {...defaultProps} description={longDesc} />)
expect(screen.getByText(longDesc)).toBeInTheDocument()
})
it('should handle all props together', () => {
const handleClick = vi.fn()
render(
<OptionCard
id="full-test"
title="Full Test"
description="Full Description"
icon={<span data-testid="full-icon">Icon</span>}
iconActiveColor="text-blue-500"
isActive={true}
isRecommended={true}
effectColor={EffectColor.indigo}
showEffectColor={true}
disabled={false}
onClick={handleClick}
className="full-class"
showChildren={true}
>
<div data-testid="full-children">Children</div>
</OptionCard>,
)
expect(screen.getByText('Full Test')).toBeInTheDocument()
expect(screen.getByText('Full Description')).toBeInTheDocument()
expect(screen.getByTestId('full-icon')).toBeInTheDocument()
expect(screen.getByTestId('full-children')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,512 @@
import type { Member } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { DatasetPermission } from '@/models/datasets'
import PermissionSelector from './index'
// Mock app-context
vi.mock('@/context/app-context', () => ({
useSelector: () => ({
id: 'user-1',
name: 'Current User',
email: 'current@example.com',
avatar_url: '',
role: 'owner',
}),
}))
// Note: react-i18next is globally mocked in vitest.setup.ts
describe('PermissionSelector', () => {
const mockMemberList: Member[] = [
{ id: 'user-1', name: 'Current User', email: 'current@example.com', avatar: '', avatar_url: '', role: 'owner', last_login_at: '', created_at: '', status: 'active' },
{ id: 'user-2', name: 'John Doe', email: 'john@example.com', avatar: '', avatar_url: '', role: 'admin', last_login_at: '', created_at: '', status: 'active' },
{ id: 'user-3', name: 'Jane Smith', email: 'jane@example.com', avatar: '', avatar_url: '', role: 'editor', last_login_at: '', created_at: '', status: 'active' },
{ id: 'user-4', name: 'Dataset Operator', email: 'operator@example.com', avatar: '', avatar_url: '', role: 'dataset_operator', last_login_at: '', created_at: '', status: 'active' },
]
const defaultProps = {
permission: DatasetPermission.onlyMe,
value: ['user-1'],
memberList: mockMemberList,
onChange: vi.fn(),
onMemberSelect: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<PermissionSelector {...defaultProps} />)
expect(screen.getByText(/form\.permissionsOnlyMe/)).toBeInTheDocument()
})
it('should render Only Me option when permission is onlyMe', () => {
render(<PermissionSelector {...defaultProps} permission={DatasetPermission.onlyMe} />)
expect(screen.getByText(/form\.permissionsOnlyMe/)).toBeInTheDocument()
})
it('should render All Team Members option when permission is allTeamMembers', () => {
render(<PermissionSelector {...defaultProps} permission={DatasetPermission.allTeamMembers} />)
expect(screen.getByText(/form\.permissionsAllMember/)).toBeInTheDocument()
})
it('should render selected member names when permission is partialMembers', () => {
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
value={['user-1', 'user-2']}
/>,
)
// Should show member names
expect(screen.getByTitle(/Current User/)).toBeInTheDocument()
})
})
describe('Dropdown Toggle', () => {
it('should open dropdown when clicked', async () => {
render(<PermissionSelector {...defaultProps} />)
const trigger = screen.getByText(/form\.permissionsOnlyMe/)
fireEvent.click(trigger)
await waitFor(() => {
// Should show all permission options in dropdown
expect(screen.getAllByText(/form\.permissionsOnlyMe/).length).toBeGreaterThanOrEqual(1)
})
})
it('should not open dropdown when disabled', () => {
render(<PermissionSelector {...defaultProps} disabled={true} />)
const trigger = screen.getByText(/form\.permissionsOnlyMe/)
fireEvent.click(trigger)
// Dropdown should not open - only the trigger text should be visible
expect(screen.getAllByText(/form\.permissionsOnlyMe/).length).toBe(1)
})
})
describe('Permission Selection', () => {
it('should call onChange with onlyMe when Only Me is selected', async () => {
const handleChange = vi.fn()
render(<PermissionSelector {...defaultProps} onChange={handleChange} permission={DatasetPermission.allTeamMembers} />)
// Open dropdown
const trigger = screen.getByText(/form\.permissionsAllMember/)
fireEvent.click(trigger)
await waitFor(() => {
// Click Only Me option
const onlyMeOptions = screen.getAllByText(/form\.permissionsOnlyMe/)
fireEvent.click(onlyMeOptions[0])
})
expect(handleChange).toHaveBeenCalledWith(DatasetPermission.onlyMe)
})
it('should call onChange with allTeamMembers when All Team Members is selected', async () => {
const handleChange = vi.fn()
render(<PermissionSelector {...defaultProps} onChange={handleChange} />)
// Open dropdown
const trigger = screen.getByText(/form\.permissionsOnlyMe/)
fireEvent.click(trigger)
await waitFor(() => {
// Click All Team Members option
const allMemberOptions = screen.getAllByText(/form\.permissionsAllMember/)
fireEvent.click(allMemberOptions[0])
})
expect(handleChange).toHaveBeenCalledWith(DatasetPermission.allTeamMembers)
})
it('should call onChange with partialMembers when Invited Members is selected', async () => {
const handleChange = vi.fn()
const handleMemberSelect = vi.fn()
render(
<PermissionSelector
{...defaultProps}
onChange={handleChange}
onMemberSelect={handleMemberSelect}
/>,
)
// Open dropdown
const trigger = screen.getByText(/form\.permissionsOnlyMe/)
fireEvent.click(trigger)
await waitFor(() => {
// Click Invited Members option
const invitedOptions = screen.getAllByText(/form\.permissionsInvitedMembers/)
fireEvent.click(invitedOptions[0])
})
expect(handleChange).toHaveBeenCalledWith(DatasetPermission.partialMembers)
expect(handleMemberSelect).toHaveBeenCalledWith(['user-1'])
})
})
describe('Member Selection', () => {
it('should show member list when partialMembers is selected', async () => {
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
/>,
)
// Open dropdown
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
await waitFor(() => {
// Should show member list
expect(screen.getByText('John Doe')).toBeInTheDocument()
expect(screen.getByText('Jane Smith')).toBeInTheDocument()
})
})
it('should call onMemberSelect when a member is clicked', async () => {
const handleMemberSelect = vi.fn()
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
onMemberSelect={handleMemberSelect}
/>,
)
// Open dropdown
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
await waitFor(() => {
// Click on John Doe
const johnDoe = screen.getByText('John Doe')
fireEvent.click(johnDoe)
})
expect(handleMemberSelect).toHaveBeenCalledWith(['user-1', 'user-2'])
})
it('should deselect member when clicked again', async () => {
const handleMemberSelect = vi.fn()
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
value={['user-1', 'user-2']}
onMemberSelect={handleMemberSelect}
/>,
)
// Open dropdown
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
await waitFor(() => {
// Click on John Doe to deselect
const johnDoe = screen.getByText('John Doe')
fireEvent.click(johnDoe)
})
expect(handleMemberSelect).toHaveBeenCalledWith(['user-1'])
})
})
describe('Search Functionality', () => {
it('should allow typing in search input', async () => {
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
/>,
)
// Open dropdown
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
// Wait for dropdown to open
const searchInput = await screen.findByRole('textbox')
// Type in search
fireEvent.change(searchInput, { target: { value: 'John' } })
expect(searchInput).toHaveValue('John')
})
it('should render search input in partial members mode', async () => {
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
/>,
)
// Open dropdown
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
// Wait for dropdown to open and search input to be available
const searchInput = await screen.findByRole('textbox')
expect(searchInput).toBeInTheDocument()
})
it('should filter members after debounce completes', async () => {
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
/>,
)
// Open dropdown
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
// Wait for dropdown to open
const searchInput = await screen.findByRole('textbox')
// Type in search
fireEvent.change(searchInput, { target: { value: 'John' } })
// Wait for debounce (500ms) + buffer
await waitFor(
() => {
expect(screen.getByText('John Doe')).toBeInTheDocument()
},
{ timeout: 1000 },
)
})
it('should handle clear search functionality', async () => {
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
/>,
)
// Open dropdown
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
// Wait for dropdown to open
const searchInput = await screen.findByRole('textbox')
// Type in search
fireEvent.change(searchInput, { target: { value: 'test' } })
expect(searchInput).toHaveValue('test')
// Click the clear button using data-testid
const clearButton = screen.getByTestId('input-clear')
fireEvent.click(clearButton)
// After clicking clear, input should be empty
await waitFor(() => {
expect(searchInput).toHaveValue('')
})
})
it('should filter members by email', async () => {
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
/>,
)
// Open dropdown
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
// Wait for dropdown to open
const searchInput = await screen.findByRole('textbox')
// Search by email
fireEvent.change(searchInput, { target: { value: 'john@example' } })
// Wait for debounce
await waitFor(
() => {
expect(screen.getByText('John Doe')).toBeInTheDocument()
},
{ timeout: 1000 },
)
})
it('should show no results message when search matches nothing', async () => {
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
/>,
)
// Open dropdown
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
// Wait for dropdown to open
const searchInput = await screen.findByRole('textbox')
// Search for non-existent member
fireEvent.change(searchInput, { target: { value: 'nonexistent12345' } })
// Wait for debounce and no results message
await waitFor(
() => {
expect(screen.getByText(/form\.onSearchResults/)).toBeInTheDocument()
},
{ timeout: 1000 },
)
})
it('should show current user when search matches user name', async () => {
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
/>,
)
// Open dropdown
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
// Wait for dropdown to open
const searchInput = await screen.findByRole('textbox')
// Search for current user by name - partial match
fireEvent.change(searchInput, { target: { value: 'Current' } })
// Current user (showMe) should remain visible based on name match
// The component uses useMemo to check if userProfile.name.includes(searchKeywords)
expect(searchInput).toHaveValue('Current')
// Current User label appears multiple times (trigger + member list)
expect(screen.getAllByText('Current User').length).toBeGreaterThanOrEqual(1)
})
it('should show current user when search matches user email', async () => {
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
/>,
)
// Open dropdown
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
// Wait for dropdown to open
const searchInput = await screen.findByRole('textbox')
// Search for current user by email
fireEvent.change(searchInput, { target: { value: 'current@' } })
// The component checks userProfile.email.includes(searchKeywords)
expect(searchInput).toHaveValue('current@')
// Current User should remain visible based on email match
expect(screen.getAllByText('Current User').length).toBeGreaterThanOrEqual(1)
})
})
describe('Disabled State', () => {
it('should apply disabled styles when disabled', () => {
const { container } = render(<PermissionSelector {...defaultProps} disabled={true} />)
// When disabled, the component has !cursor-not-allowed class (escaped in Tailwind)
const triggerElement = container.querySelector('[class*="cursor-not-allowed"]')
expect(triggerElement).toBeInTheDocument()
})
})
describe('Display Variations', () => {
it('should display single avatar when only one member selected', () => {
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
value={['user-1']}
/>,
)
// Should display single avatar
expect(screen.getByTitle(/Current User/)).toBeInTheDocument()
})
it('should display two avatars when two or more members selected', () => {
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
value={['user-1', 'user-2']}
/>,
)
// Should display member names
expect(screen.getByTitle(/Current User, John Doe/)).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty member list', () => {
render(
<PermissionSelector
{...defaultProps}
memberList={[]}
/>,
)
expect(screen.getByText(/form\.permissionsOnlyMe/)).toBeInTheDocument()
})
it('should handle member list with only current user', () => {
render(
<PermissionSelector
{...defaultProps}
memberList={[mockMemberList[0]]}
/>,
)
expect(screen.getByText(/form\.permissionsOnlyMe/)).toBeInTheDocument()
})
it('should only show members with allowed roles', () => {
// The component filters members by role in useMemo
// Allowed roles are: owner, admin, editor, dataset_operator
// This is tested indirectly through the memberList filtering
const memberListWithNormalUser: Member[] = [
...mockMemberList,
{ id: 'user-5', name: 'Normal User', email: 'normal@example.com', avatar: '', avatar_url: '', role: 'normal', last_login_at: '', created_at: '', status: 'active' },
]
render(
<PermissionSelector
{...defaultProps}
memberList={memberListWithNormalUser}
permission={DatasetPermission.partialMembers}
/>,
)
// The component renders - the filtering logic is internal
expect(screen.getByTitle(/Current User/)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should update when permission prop changes', () => {
const { rerender } = render(<PermissionSelector {...defaultProps} permission={DatasetPermission.onlyMe} />)
expect(screen.getByText(/form\.permissionsOnlyMe/)).toBeInTheDocument()
rerender(<PermissionSelector {...defaultProps} permission={DatasetPermission.allTeamMembers} />)
expect(screen.getByText(/form\.permissionsAllMember/)).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,195 @@
import { fireEvent, render, screen } from '@testing-library/react'
import MemberItem from './member-item'
// Note: react-i18next is globally mocked in vitest.setup.ts
describe('MemberItem', () => {
const defaultProps = {
leftIcon: <span data-testid="avatar-icon">Avatar</span>,
name: 'John Doe',
email: 'john@example.com',
isSelected: false,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<MemberItem {...defaultProps} />)
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
it('should render left icon (avatar)', () => {
render(<MemberItem {...defaultProps} />)
expect(screen.getByTestId('avatar-icon')).toBeInTheDocument()
})
it('should render member name', () => {
render(<MemberItem {...defaultProps} />)
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
it('should render member email', () => {
render(<MemberItem {...defaultProps} />)
expect(screen.getByText('john@example.com')).toBeInTheDocument()
})
})
describe('Selection State', () => {
it('should show checkmark icon when selected', () => {
render(<MemberItem {...defaultProps} isSelected={true} />)
const container = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
const checkIcon = container?.querySelector('svg')
expect(checkIcon).toBeInTheDocument()
})
it('should not show checkmark icon when not selected', () => {
render(<MemberItem {...defaultProps} isSelected={false} />)
const container = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
const checkIcon = container?.querySelector('svg')
expect(checkIcon).not.toBeInTheDocument()
})
it('should apply opacity class to checkmark when isMe is true', () => {
render(<MemberItem {...defaultProps} isSelected={true} isMe={true} />)
const container = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
const checkIcon = container?.querySelector('svg')
expect(checkIcon).toHaveClass('opacity-30')
})
})
describe('isMe Flag', () => {
it('should show me indicator when isMe is true', () => {
render(<MemberItem {...defaultProps} isMe={true} />)
// The translation key is 'form.me' which will be rendered by the mock
expect(screen.getByText(/form\.me/)).toBeInTheDocument()
})
it('should not show me indicator when isMe is false', () => {
render(<MemberItem {...defaultProps} isMe={false} />)
expect(screen.queryByText(/form\.me/)).not.toBeInTheDocument()
})
it('should not show me indicator by default', () => {
render(<MemberItem {...defaultProps} />)
expect(screen.queryByText(/form\.me/)).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClick when clicked', () => {
const handleClick = vi.fn()
render(<MemberItem {...defaultProps} onClick={handleClick} />)
const item = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
fireEvent.click(item!)
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should not throw when onClick is not provided', () => {
render(<MemberItem {...defaultProps} />)
const item = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
expect(() => fireEvent.click(item!)).not.toThrow()
})
it('should have cursor-pointer class for interactivity', () => {
render(<MemberItem {...defaultProps} />)
const item = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
expect(item).toHaveClass('cursor-pointer')
})
})
describe('Props', () => {
it('should render different names', () => {
const names = ['Alice', 'Bob', 'Charlie']
names.forEach((name) => {
const { unmount } = render(<MemberItem {...defaultProps} name={name} />)
expect(screen.getByText(name)).toBeInTheDocument()
unmount()
})
})
it('should render different emails', () => {
const emails = ['alice@test.com', 'bob@company.org', 'charlie@domain.net']
emails.forEach((email) => {
const { unmount } = render(<MemberItem {...defaultProps} email={email} />)
expect(screen.getByText(email)).toBeInTheDocument()
unmount()
})
})
it('should render different left icons', () => {
const customIcon = <img data-testid="custom-avatar" alt="avatar" />
render(<MemberItem {...defaultProps} leftIcon={customIcon} />)
expect(screen.getByTestId('custom-avatar')).toBeInTheDocument()
})
it('should handle isSelected toggle correctly', () => {
const { rerender } = render(<MemberItem {...defaultProps} isSelected={false} />)
// Initially not selected
let container = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
expect(container?.querySelector('svg')).not.toBeInTheDocument()
// Update to selected
rerender(<MemberItem {...defaultProps} isSelected={true} />)
container = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
expect(container?.querySelector('svg')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty name', () => {
render(<MemberItem {...defaultProps} name="" />)
expect(screen.getByText('john@example.com')).toBeInTheDocument()
})
it('should handle empty email', () => {
render(<MemberItem {...defaultProps} email="" />)
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
it('should handle long name with truncation', () => {
const longName = 'A'.repeat(100)
render(<MemberItem {...defaultProps} name={longName} />)
const nameElement = screen.getByText(longName)
expect(nameElement).toHaveClass('truncate')
})
it('should handle long email with truncation', () => {
const longEmail = `${'a'.repeat(50)}@${'b'.repeat(50)}.com`
render(<MemberItem {...defaultProps} email={longEmail} />)
const emailElement = screen.getByText(longEmail)
expect(emailElement).toHaveClass('truncate')
})
it('should handle special characters in name', () => {
const specialName = 'O\'Connor-Smith'
render(<MemberItem {...defaultProps} name={specialName} />)
expect(screen.getByText(specialName)).toBeInTheDocument()
})
it('should handle unicode characters', () => {
const unicodeName = '张三'
const unicodeEmail = '张三@example.com'
render(<MemberItem {...defaultProps} name={unicodeName} email={unicodeEmail} />)
expect(screen.getByText(unicodeName)).toBeInTheDocument()
expect(screen.getByText(unicodeEmail)).toBeInTheDocument()
})
it('should render both isMe and isSelected together', () => {
render(<MemberItem {...defaultProps} isMe={true} isSelected={true} />)
expect(screen.getByText(/form\.me/)).toBeInTheDocument()
const container = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
const checkIcon = container?.querySelector('svg')
expect(checkIcon).toBeInTheDocument()
expect(checkIcon).toHaveClass('opacity-30')
})
})
})

View File

@@ -0,0 +1,130 @@
import { fireEvent, render, screen } from '@testing-library/react'
import PermissionItem from './permission-item'
describe('PermissionItem', () => {
const defaultProps = {
leftIcon: <span data-testid="left-icon">Icon</span>,
text: 'Test Permission',
onClick: vi.fn(),
isSelected: false,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<PermissionItem {...defaultProps} />)
expect(screen.getByText('Test Permission')).toBeInTheDocument()
})
it('should render left icon', () => {
render(<PermissionItem {...defaultProps} />)
expect(screen.getByTestId('left-icon')).toBeInTheDocument()
})
it('should render text content', () => {
const text = 'Custom Permission Text'
render(<PermissionItem {...defaultProps} text={text} />)
expect(screen.getByText(text)).toBeInTheDocument()
})
})
describe('Selection State', () => {
it('should show checkmark icon when selected', () => {
render(<PermissionItem {...defaultProps} isSelected={true} />)
// RiCheckLine renders as an svg element
const container = screen.getByText('Test Permission').closest('div')?.parentElement
const checkIcon = container?.querySelector('svg')
expect(checkIcon).toBeInTheDocument()
})
it('should not show checkmark icon when not selected', () => {
render(<PermissionItem {...defaultProps} isSelected={false} />)
const container = screen.getByText('Test Permission').closest('div')?.parentElement
const checkIcon = container?.querySelector('svg')
expect(checkIcon).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClick when clicked', () => {
const handleClick = vi.fn()
render(<PermissionItem {...defaultProps} onClick={handleClick} />)
const item = screen.getByText('Test Permission').closest('div')?.parentElement
fireEvent.click(item!)
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should have cursor-pointer class for interactivity', () => {
render(<PermissionItem {...defaultProps} />)
const item = screen.getByText('Test Permission').closest('div')?.parentElement
expect(item).toHaveClass('cursor-pointer')
})
})
describe('Props', () => {
it('should render different left icons', () => {
const customIcon = <span data-testid="custom-icon">Custom</span>
render(<PermissionItem {...defaultProps} leftIcon={customIcon} />)
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
})
it('should handle different text values', () => {
const texts = ['Only Me', 'All Team Members', 'Invited Members']
texts.forEach((text) => {
const { unmount } = render(<PermissionItem {...defaultProps} text={text} />)
expect(screen.getByText(text)).toBeInTheDocument()
unmount()
})
})
it('should handle isSelected toggle correctly', () => {
const { rerender } = render(<PermissionItem {...defaultProps} isSelected={false} />)
// Initially not selected - no checkmark
let container = screen.getByText('Test Permission').closest('div')?.parentElement
expect(container?.querySelector('svg')).not.toBeInTheDocument()
// Update to selected
rerender(<PermissionItem {...defaultProps} isSelected={true} />)
container = screen.getByText('Test Permission').closest('div')?.parentElement
expect(container?.querySelector('svg')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty text', () => {
render(<PermissionItem {...defaultProps} text="" />)
// The component should still render
expect(screen.getByTestId('left-icon')).toBeInTheDocument()
})
it('should handle long text content', () => {
const longText = 'A'.repeat(200)
render(<PermissionItem {...defaultProps} text={longText} />)
expect(screen.getByText(longText)).toBeInTheDocument()
})
it('should handle special characters in text', () => {
const specialText = '<script>alert("xss")</script>'
render(<PermissionItem {...defaultProps} text={specialText} />)
expect(screen.getByText(specialText)).toBeInTheDocument()
})
it('should handle complex left icon nodes', () => {
const complexIcon = (
<div data-testid="complex-icon">
<span>Nested</span>
<div>Content</div>
</div>
)
render(<PermissionItem {...defaultProps} leftIcon={complexIcon} />)
expect(screen.getByTestId('complex-icon')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,297 @@
import type { DefaultModel, Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { ConfigurationMethodEnum, ModelFeatureEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { IndexingType } from '../../create/step-two'
import { checkShowMultiModalTip } from './index'
describe('checkShowMultiModalTip', () => {
// Helper to create a model item with specific features
const createModelItem = (model: string, features: ModelFeatureEnum[] = []): ModelItem => ({
model,
label: { en_US: model, zh_Hans: model },
model_type: ModelTypeEnum.textEmbedding,
features,
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: {},
load_balancing_enabled: false,
deprecated: false,
})
// Helper to create a model provider
const createModelProvider = (provider: string, models: ModelItem[]): Model => ({
provider,
label: { en_US: provider, zh_Hans: provider },
icon_small: { en_US: '', zh_Hans: '' },
status: ModelStatusEnum.active,
models,
})
const defaultProps = {
embeddingModel: {
provider: 'openai',
model: 'text-embedding-ada-002',
} as DefaultModel,
rerankingEnable: true,
rerankModel: {
rerankingProviderName: 'cohere',
rerankingModelName: 'rerank-english-v2.0',
},
indexMethod: IndexingType.QUALIFIED,
embeddingModelList: [
createModelProvider('openai', [
createModelItem('text-embedding-ada-002', [ModelFeatureEnum.vision]),
]),
],
rerankModelList: [
createModelProvider('cohere', [
createModelItem('rerank-english-v2.0', []),
]),
],
}
describe('Return false conditions', () => {
it('should return false when indexMethod is not QUALIFIED', () => {
const result = checkShowMultiModalTip({
...defaultProps,
indexMethod: IndexingType.ECONOMICAL,
})
expect(result).toBe(false)
})
it('should return false when indexMethod is undefined', () => {
const result = checkShowMultiModalTip({
...defaultProps,
indexMethod: undefined,
})
expect(result).toBe(false)
})
it('should return false when embeddingModel.provider is empty', () => {
const result = checkShowMultiModalTip({
...defaultProps,
embeddingModel: { provider: '', model: 'text-embedding-ada-002' },
})
expect(result).toBe(false)
})
it('should return false when embeddingModel.model is empty', () => {
const result = checkShowMultiModalTip({
...defaultProps,
embeddingModel: { provider: 'openai', model: '' },
})
expect(result).toBe(false)
})
it('should return false when embedding model provider is not found', () => {
const result = checkShowMultiModalTip({
...defaultProps,
embeddingModel: { provider: 'unknown-provider', model: 'text-embedding-ada-002' },
})
expect(result).toBe(false)
})
it('should return false when embedding model is not found in provider', () => {
const result = checkShowMultiModalTip({
...defaultProps,
embeddingModel: { provider: 'openai', model: 'unknown-model' },
})
expect(result).toBe(false)
})
it('should return false when embedding model does not support vision', () => {
const result = checkShowMultiModalTip({
...defaultProps,
embeddingModelList: [
createModelProvider('openai', [
createModelItem('text-embedding-ada-002', []), // No vision feature
]),
],
})
expect(result).toBe(false)
})
it('should return false when rerankingEnable is false', () => {
const result = checkShowMultiModalTip({
...defaultProps,
rerankingEnable: false,
})
expect(result).toBe(false)
})
it('should return false when rerankingModelName is empty', () => {
const result = checkShowMultiModalTip({
...defaultProps,
rerankModel: {
rerankingProviderName: 'cohere',
rerankingModelName: '',
},
})
expect(result).toBe(false)
})
it('should return false when rerankingProviderName is empty', () => {
const result = checkShowMultiModalTip({
...defaultProps,
rerankModel: {
rerankingProviderName: '',
rerankingModelName: 'rerank-english-v2.0',
},
})
expect(result).toBe(false)
})
it('should return false when reranking model provider is not found', () => {
const result = checkShowMultiModalTip({
...defaultProps,
rerankModel: {
rerankingProviderName: 'unknown-provider',
rerankingModelName: 'rerank-english-v2.0',
},
})
expect(result).toBe(false)
})
it('should return false when reranking model is not found in provider', () => {
const result = checkShowMultiModalTip({
...defaultProps,
rerankModel: {
rerankingProviderName: 'cohere',
rerankingModelName: 'unknown-model',
},
})
expect(result).toBe(false)
})
it('should return false when reranking model supports vision', () => {
const result = checkShowMultiModalTip({
...defaultProps,
rerankModelList: [
createModelProvider('cohere', [
createModelItem('rerank-english-v2.0', [ModelFeatureEnum.vision]), // Has vision feature
]),
],
})
expect(result).toBe(false)
})
})
describe('Return true condition', () => {
it('should return true when embedding model supports vision but reranking model does not', () => {
const result = checkShowMultiModalTip(defaultProps)
expect(result).toBe(true)
})
it('should return true with different providers', () => {
const result = checkShowMultiModalTip({
...defaultProps,
embeddingModel: { provider: 'azure', model: 'azure-embedding' },
rerankModel: {
rerankingProviderName: 'jina',
rerankingModelName: 'jina-reranker',
},
embeddingModelList: [
createModelProvider('azure', [
createModelItem('azure-embedding', [ModelFeatureEnum.vision]),
]),
],
rerankModelList: [
createModelProvider('jina', [
createModelItem('jina-reranker', []),
]),
],
})
expect(result).toBe(true)
})
})
describe('Edge cases', () => {
it('should handle empty embeddingModelList', () => {
const result = checkShowMultiModalTip({
...defaultProps,
embeddingModelList: [],
})
expect(result).toBe(false)
})
it('should handle empty rerankModelList', () => {
const result = checkShowMultiModalTip({
...defaultProps,
rerankModelList: [],
})
expect(result).toBe(false)
})
it('should handle model with undefined features', () => {
const modelItem: ModelItem = {
model: 'test-model',
label: { en_US: 'test', zh_Hans: 'test' },
model_type: ModelTypeEnum.textEmbedding,
features: undefined as unknown as ModelFeatureEnum[],
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: {},
load_balancing_enabled: false,
deprecated: false,
}
const result = checkShowMultiModalTip({
...defaultProps,
embeddingModelList: [
createModelProvider('openai', [modelItem]),
],
})
expect(result).toBe(false)
})
it('should handle model with null features', () => {
const modelItem: ModelItem = {
model: 'text-embedding-ada-002',
label: { en_US: 'test', zh_Hans: 'test' },
model_type: ModelTypeEnum.textEmbedding,
features: null as unknown as ModelFeatureEnum[],
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: {},
load_balancing_enabled: false,
deprecated: false,
}
const result = checkShowMultiModalTip({
...defaultProps,
embeddingModelList: [
createModelProvider('openai', [modelItem]),
],
})
expect(result).toBe(false)
})
it('should handle multiple models in provider', () => {
const result = checkShowMultiModalTip({
...defaultProps,
embeddingModelList: [
createModelProvider('openai', [
createModelItem('text-embedding-1', []),
createModelItem('text-embedding-ada-002', [ModelFeatureEnum.vision]),
createModelItem('text-embedding-3', []),
]),
],
})
expect(result).toBe(true)
})
it('should handle multiple providers in list', () => {
const result = checkShowMultiModalTip({
...defaultProps,
embeddingModelList: [
createModelProvider('azure', [
createModelItem('azure-model', []),
]),
createModelProvider('openai', [
createModelItem('text-embedding-ada-002', [ModelFeatureEnum.vision]),
]),
],
})
expect(result).toBe(true)
})
})
})

View File

@@ -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,

View 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,
})
}
}
},
}
},
}

View File

@@ -131,6 +131,7 @@ export default antfu(
'dify-i18n/valid-i18n-keys': 'error',
'dify-i18n/no-extra-keys': 'error',
'dify-i18n/consistent-placeholders': 'error',
},
},
)

View File

@@ -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": "تثبيت الإضافات",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "دستورات",

View File

@@ -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": "پیشنمایش بخش فعلی در قالب متن است، تغییر به پیشنمایش قالب پرسش و پاسخ",

View File

@@ -147,7 +147,7 @@
"parentMode.paragraph": "پاراگراف",
"partialEnabled_one": "مجموعاً {{count}} سند، {{num}} موجود",
"partialEnabled_other": "مجموع {{count}} سند، {{num}} موجود",
"preprocessDocument": "{{عدد}} اسناد پیش پردازش",
"preprocessDocument": "{{num}} اسناد پیش پردازش",
"rerankSettings": "تنظیمات دوباره رتبه‌بندی",
"retrieval.change": "تغییر",
"retrieval.changeRetrievalMethod": "تغییر روش بازیابی",

View File

@@ -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",

View File

@@ -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": "ورودی",

View File

@@ -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",

View File

@@ -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 lAPIKey est normal",
"modelProvider.auth.addApiKey": "Ajouter une clé API",
"modelProvider.auth.addCredential": "Ajouter un identifiant",

View File

@@ -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}}.",

View File

@@ -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 loutil",
@@ -236,7 +236,7 @@
"task.installSuccess": "{{successLength}} plugins installed successfully",
"task.installed": "Installed",
"task.installedError": "{{errorLength}} les plugins nont 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",

View File

@@ -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": "आदेश",

View File

@@ -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": "वर्तमान खंड पूर्वावलोकन पाठ प्रारूप में है, प्रश्न-उत्तर प्रारूप में स्विच करने से",

View File

@@ -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": "उपकरण का विवरण",

View File

@@ -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}}.",

View File

@@ -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}}個)に達しました。",

View File

@@ -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}}개) 에 도달했습니다.",

View File

@@ -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}}.",

View File

@@ -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}}.",

View File

@@ -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.",

View File

@@ -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}} файлов.",

View File

@@ -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.",

View File

@@ -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}} แล้ว",

View File

@@ -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.",

View File

@@ -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}}.",

View File

@@ -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.",

View File

@@ -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}}。",

View File

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

View File

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