mirror of
https://github.com/langgenius/dify.git
synced 2026-04-05 16:39:26 +08:00
test(web): add and enhance frontend automated tests across multiple modules (#32268)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
import type { SimpleDocumentDetail } from '@/models/datasets'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useDocumentSort } from '../document-list/hooks'
|
||||
import DocumentList from '../list'
|
||||
|
||||
// Mock hooks used by DocumentList
|
||||
const mockHandleSort = vi.fn()
|
||||
const mockOnSelectAll = vi.fn()
|
||||
const mockOnSelectOne = vi.fn()
|
||||
const mockClearSelection = vi.fn()
|
||||
const mockHandleAction = vi.fn(() => vi.fn())
|
||||
const mockHandleBatchReIndex = vi.fn()
|
||||
const mockHandleBatchDownload = vi.fn()
|
||||
const mockShowEditModal = vi.fn()
|
||||
const mockHideEditModal = vi.fn()
|
||||
const mockHandleSave = vi.fn()
|
||||
|
||||
vi.mock('../document-list/hooks', () => ({
|
||||
useDocumentSort: vi.fn(() => ({
|
||||
sortField: null,
|
||||
sortOrder: null,
|
||||
handleSort: mockHandleSort,
|
||||
sortedDocuments: [],
|
||||
})),
|
||||
useDocumentSelection: vi.fn(() => ({
|
||||
isAllSelected: false,
|
||||
isSomeSelected: false,
|
||||
onSelectAll: mockOnSelectAll,
|
||||
onSelectOne: mockOnSelectOne,
|
||||
hasErrorDocumentsSelected: false,
|
||||
downloadableSelectedIds: [],
|
||||
clearSelection: mockClearSelection,
|
||||
})),
|
||||
useDocumentActions: vi.fn(() => ({
|
||||
handleAction: mockHandleAction,
|
||||
handleBatchReIndex: mockHandleBatchReIndex,
|
||||
handleBatchDownload: mockHandleBatchDownload,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata', () => ({
|
||||
default: vi.fn(() => ({
|
||||
isShowEditModal: false,
|
||||
showEditModal: mockShowEditModal,
|
||||
hideEditModal: mockHideEditModal,
|
||||
originalList: [],
|
||||
handleSave: mockHandleSave,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/dataset-detail', () => ({
|
||||
useDatasetDetailContextWithSelector: () => ({
|
||||
doc_form: 'text_model',
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock child components that are complex
|
||||
vi.mock('../document-list/components', () => ({
|
||||
DocumentTableRow: ({ doc, index }: { doc: SimpleDocumentDetail, index: number }) => (
|
||||
<tr data-testid={`doc-row-${doc.id}`}>
|
||||
<td>{index + 1}</td>
|
||||
<td>{doc.name}</td>
|
||||
</tr>
|
||||
),
|
||||
renderTdValue: (val: string) => val || '-',
|
||||
SortHeader: ({ field, label, onSort }: { field: string, label: string, onSort: (f: string) => void }) => (
|
||||
<button data-testid={`sort-${field}`} onClick={() => onSort(field)}>{label}</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../detail/completed/common/batch-action', () => ({
|
||||
default: ({ selectedIds, onCancel }: { selectedIds: string[], onCancel: () => void }) => (
|
||||
<div data-testid="batch-action">
|
||||
<span data-testid="selected-count">{selectedIds.length}</span>
|
||||
<button data-testid="cancel-selection" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../rename-modal', () => ({
|
||||
default: ({ name, onClose }: { name: string, onClose: () => void }) => (
|
||||
<div data-testid="rename-modal">
|
||||
<span>{name}</span>
|
||||
<button onClick={onClose}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/metadata/edit-metadata-batch/modal', () => ({
|
||||
default: ({ onHide }: { onHide: () => void }) => (
|
||||
<div data-testid="edit-metadata-modal">
|
||||
<button onClick={onHide}>Hide</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
function createDoc(overrides: Partial<SimpleDocumentDetail> = {}): SimpleDocumentDetail {
|
||||
return {
|
||||
id: `doc-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: 'Test Doc',
|
||||
position: 1,
|
||||
data_source_type: 'upload_file',
|
||||
word_count: 100,
|
||||
hit_count: 5,
|
||||
indexing_status: 'completed',
|
||||
enabled: true,
|
||||
disabled_at: null,
|
||||
disabled_by: null,
|
||||
archived: false,
|
||||
display_status: 'available',
|
||||
created_from: 'web',
|
||||
created_at: 1234567890,
|
||||
...overrides,
|
||||
} as SimpleDocumentDetail
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
embeddingAvailable: true,
|
||||
documents: [] as SimpleDocumentDetail[],
|
||||
selectedIds: [] as string[],
|
||||
onSelectedIdChange: vi.fn(),
|
||||
datasetId: 'ds-1',
|
||||
pagination: { total: 0, current: 1, limit: 10, onChange: vi.fn() },
|
||||
onUpdate: vi.fn(),
|
||||
onManageMetadata: vi.fn(),
|
||||
statusFilterValue: 'all',
|
||||
remoteSortValue: '',
|
||||
}
|
||||
|
||||
describe('DocumentList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Verify the table renders with column headers
|
||||
describe('Rendering', () => {
|
||||
it('should render the document table with headers', () => {
|
||||
render(<DocumentList {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('#')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sort-name')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sort-word_count')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sort-hit_count')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sort-created_at')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render select-all area when embeddingAvailable is true', () => {
|
||||
const { container } = render(<DocumentList {...defaultProps} embeddingAvailable={true} />)
|
||||
|
||||
// Checkbox component renders inside the first td
|
||||
const firstTd = container.querySelector('thead td')
|
||||
expect(firstTd?.textContent).toContain('#')
|
||||
})
|
||||
|
||||
it('should still render # column when embeddingAvailable is false', () => {
|
||||
const { container } = render(<DocumentList {...defaultProps} embeddingAvailable={false} />)
|
||||
|
||||
const firstTd = container.querySelector('thead td')
|
||||
expect(firstTd?.textContent).toContain('#')
|
||||
})
|
||||
|
||||
it('should render document rows from sortedDocuments', () => {
|
||||
const docs = [createDoc({ id: 'a', name: 'Doc A' }), createDoc({ id: 'b', name: 'Doc B' })]
|
||||
vi.mocked(useDocumentSort).mockReturnValue({
|
||||
sortField: null,
|
||||
sortOrder: 'desc',
|
||||
handleSort: mockHandleSort,
|
||||
sortedDocuments: docs,
|
||||
} as unknown as ReturnType<typeof useDocumentSort>)
|
||||
|
||||
render(<DocumentList {...defaultProps} documents={docs} />)
|
||||
|
||||
expect(screen.getByTestId('doc-row-a')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('doc-row-b')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Verify sort headers trigger sort handler
|
||||
describe('Sorting', () => {
|
||||
it('should call handleSort when sort header is clicked', () => {
|
||||
render(<DocumentList {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('sort-name'))
|
||||
|
||||
expect(mockHandleSort).toHaveBeenCalledWith('name')
|
||||
})
|
||||
})
|
||||
|
||||
// Verify batch action bar appears when items selected
|
||||
describe('Batch Actions', () => {
|
||||
it('should show batch action bar when selectedIds is non-empty', () => {
|
||||
render(<DocumentList {...defaultProps} selectedIds={['doc-1']} />)
|
||||
|
||||
expect(screen.getByTestId('batch-action')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('selected-count')).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('should not show batch action bar when no items selected', () => {
|
||||
render(<DocumentList {...defaultProps} selectedIds={[]} />)
|
||||
|
||||
expect(screen.queryByTestId('batch-action')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call clearSelection when cancel is clicked in batch bar', () => {
|
||||
render(<DocumentList {...defaultProps} selectedIds={['doc-1']} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('cancel-selection'))
|
||||
|
||||
expect(mockClearSelection).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Verify pagination renders when total > 0
|
||||
describe('Pagination', () => {
|
||||
it('should not render pagination when total is 0', () => {
|
||||
const { container } = render(<DocumentList {...defaultProps} />)
|
||||
|
||||
expect(container.querySelector('[class*="pagination"]')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Verify empty state
|
||||
describe('Edge Cases', () => {
|
||||
it('should render table with no document rows when sortedDocuments is empty', () => {
|
||||
// Reset sort mock to return empty sorted list
|
||||
vi.mocked(useDocumentSort).mockReturnValue({
|
||||
sortField: null,
|
||||
sortOrder: 'desc',
|
||||
handleSort: mockHandleSort,
|
||||
sortedDocuments: [],
|
||||
} as unknown as ReturnType<typeof useDocumentSort>)
|
||||
|
||||
render(<DocumentList {...defaultProps} documents={[]} />)
|
||||
|
||||
expect(screen.queryByTestId(/^doc-row-/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { BaseConfiguration } from '@/app/components/base/form/form-scenarios/base/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
|
||||
import Form from '../form'
|
||||
|
||||
// Mock the Header component (sibling component, not a base component)
|
||||
vi.mock('../header', () => ({
|
||||
default: ({ onReset, resetDisabled, onPreview, previewDisabled }: {
|
||||
onReset: () => void
|
||||
resetDisabled: boolean
|
||||
onPreview: () => void
|
||||
previewDisabled: boolean
|
||||
}) => (
|
||||
<div data-testid="form-header">
|
||||
<button data-testid="reset-btn" onClick={onReset} disabled={resetDisabled}>Reset</button>
|
||||
<button data-testid="preview-btn" onClick={onPreview} disabled={previewDisabled}>Preview</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
value: z.string().optional(),
|
||||
})
|
||||
|
||||
const defaultConfigs: BaseConfiguration[] = [
|
||||
{ variable: 'name', type: 'text-input', label: 'Name', required: true, showConditions: [] } as BaseConfiguration,
|
||||
{ variable: 'value', type: 'text-input', label: 'Value', required: false, showConditions: [] } as BaseConfiguration,
|
||||
]
|
||||
|
||||
const defaultProps = {
|
||||
initialData: { name: 'test', value: '' },
|
||||
configurations: defaultConfigs,
|
||||
schema,
|
||||
onSubmit: vi.fn(),
|
||||
onPreview: vi.fn(),
|
||||
ref: { current: null },
|
||||
isRunning: false,
|
||||
}
|
||||
|
||||
describe('Form (process-documents)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
|
||||
})
|
||||
|
||||
// Verify basic rendering of form structure
|
||||
describe('Rendering', () => {
|
||||
it('should render form with header and fields', () => {
|
||||
render(<Form {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('form-header')).toBeInTheDocument()
|
||||
expect(screen.getByText('Name')).toBeInTheDocument()
|
||||
expect(screen.getByText('Value')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all configuration fields', () => {
|
||||
const configs: BaseConfiguration[] = [
|
||||
{ variable: 'a', type: 'text-input', label: 'A', required: false, showConditions: [] } as BaseConfiguration,
|
||||
{ variable: 'b', type: 'text-input', label: 'B', required: false, showConditions: [] } as BaseConfiguration,
|
||||
{ variable: 'c', type: 'text-input', label: 'C', required: false, showConditions: [] } as BaseConfiguration,
|
||||
]
|
||||
|
||||
render(<Form {...defaultProps} configurations={configs} initialData={{ a: '', b: '', c: '' }} />)
|
||||
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
expect(screen.getByText('B')).toBeInTheDocument()
|
||||
expect(screen.getByText('C')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Verify form submission behavior
|
||||
describe('Form Submission', () => {
|
||||
it('should call onSubmit with valid data on form submit', async () => {
|
||||
render(<Form {...defaultProps} />)
|
||||
const form = screen.getByTestId('form-header').closest('form')!
|
||||
|
||||
fireEvent.submit(form)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onSubmit).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onSubmit with valid data via imperative handle', async () => {
|
||||
const ref = { current: null as { submit: () => void } | null }
|
||||
render(<Form {...defaultProps} ref={ref} />)
|
||||
|
||||
ref.current?.submit()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onSubmit).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Verify validation shows Toast on error
|
||||
describe('Validation', () => {
|
||||
it('should show toast error when validation fails', async () => {
|
||||
render(<Form {...defaultProps} initialData={{ name: '', value: '' }} />)
|
||||
const form = screen.getByTestId('form-header').closest('form')!
|
||||
|
||||
fireEvent.submit(form)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Toast.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not show toast error when validation passes', async () => {
|
||||
render(<Form {...defaultProps} />)
|
||||
const form = screen.getByTestId('form-header').closest('form')!
|
||||
|
||||
fireEvent.submit(form)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onSubmit).toHaveBeenCalled()
|
||||
})
|
||||
expect(Toast.notify).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Verify header button states
|
||||
describe('Header Controls', () => {
|
||||
it('should pass isRunning to previewDisabled', () => {
|
||||
render(<Form {...defaultProps} isRunning={true} />)
|
||||
|
||||
expect(screen.getByTestId('preview-btn')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should call onPreview when preview button is clicked', () => {
|
||||
render(<Form {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('preview-btn'))
|
||||
|
||||
expect(defaultProps.onPreview).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render reset button (disabled when form is not dirty)', () => {
|
||||
render(<Form {...defaultProps} />)
|
||||
|
||||
// Reset button is rendered but disabled since form is not dirty initially
|
||||
expect(screen.getByTestId('reset-btn')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('reset-btn')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// Verify edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with empty configurations array', () => {
|
||||
render(<Form {...defaultProps} configurations={[]} />)
|
||||
|
||||
expect(screen.getByTestId('form-header')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty initialData', () => {
|
||||
render(<Form {...defaultProps} initialData={{}} configurations={[]} />)
|
||||
|
||||
expect(screen.getByTestId('form-header')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import DocTypeSelector, { DocumentTypeDisplay } from '../doc-type-selector'
|
||||
|
||||
vi.mock('@/hooks/use-metadata', () => ({
|
||||
useMetadataMap: () => ({
|
||||
book: { text: 'Book', iconName: 'book' },
|
||||
web_page: { text: 'Web Page', iconName: 'web' },
|
||||
paper: { text: 'Paper', iconName: 'paper' },
|
||||
social_media_post: { text: 'Social Media Post', iconName: 'social' },
|
||||
personal_document: { text: 'Personal Document', iconName: 'personal' },
|
||||
business_document: { text: 'Business Document', iconName: 'business' },
|
||||
wikipedia_entry: { text: 'Wikipedia', iconName: 'wiki' },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/models/datasets', async (importOriginal) => {
|
||||
const actual = await importOriginal() as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
CUSTOMIZABLE_DOC_TYPES: ['book', 'web_page', 'paper'],
|
||||
}
|
||||
})
|
||||
|
||||
describe('DocTypeSelector', () => {
|
||||
const defaultProps = {
|
||||
docType: '' as '' | 'book',
|
||||
documentType: undefined as '' | 'book' | undefined,
|
||||
tempDocType: '' as '' | 'book' | 'web_page',
|
||||
onTempDocTypeChange: vi.fn(),
|
||||
onConfirm: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Verify first-time setup UI (no existing doc type)
|
||||
describe('First Time Selection', () => {
|
||||
it('should render description and selection title when no doc type exists', () => {
|
||||
render(<DocTypeSelector {...defaultProps} docType="" documentType={undefined} />)
|
||||
|
||||
expect(screen.getByText(/metadata\.desc/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/metadata\.docTypeSelectTitle/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon buttons for each doc type', () => {
|
||||
const { container } = render(<DocTypeSelector {...defaultProps} />)
|
||||
|
||||
// Each doc type renders an IconButton wrapped in Radio
|
||||
const iconButtons = container.querySelectorAll('button[type="button"]')
|
||||
// 3 doc types + 1 confirm button = 4 buttons
|
||||
expect(iconButtons.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('should render confirm button disabled when tempDocType is empty', () => {
|
||||
render(<DocTypeSelector {...defaultProps} tempDocType="" />)
|
||||
|
||||
const confirmBtn = screen.getByText(/metadata\.firstMetaAction/)
|
||||
expect(confirmBtn.closest('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should render confirm button enabled when tempDocType is set', () => {
|
||||
render(<DocTypeSelector {...defaultProps} tempDocType="book" />)
|
||||
|
||||
const confirmBtn = screen.getByText(/metadata\.firstMetaAction/)
|
||||
expect(confirmBtn.closest('button')).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should call onConfirm when confirm button is clicked', () => {
|
||||
render(<DocTypeSelector {...defaultProps} tempDocType="book" />)
|
||||
|
||||
fireEvent.click(screen.getByText(/metadata\.firstMetaAction/))
|
||||
|
||||
expect(defaultProps.onConfirm).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Verify change-type UI (has existing doc type)
|
||||
describe('Change Doc Type', () => {
|
||||
it('should render change title and warning when documentType exists', () => {
|
||||
render(<DocTypeSelector {...defaultProps} docType="book" documentType="book" />)
|
||||
|
||||
expect(screen.getByText(/metadata\.docTypeChangeTitle/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/metadata\.docTypeSelectWarning/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render save and cancel buttons when documentType exists', () => {
|
||||
render(<DocTypeSelector {...defaultProps} docType="book" documentType="book" />)
|
||||
|
||||
expect(screen.getByText(/operation\.save/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onCancel when cancel button is clicked', () => {
|
||||
render(<DocTypeSelector {...defaultProps} docType="book" documentType="book" />)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.cancel/))
|
||||
|
||||
expect(defaultProps.onCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('DocumentTypeDisplay', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Verify read-only display of current doc type
|
||||
describe('Rendering', () => {
|
||||
it('should render the doc type text', () => {
|
||||
render(<DocumentTypeDisplay displayType="book" />)
|
||||
|
||||
expect(screen.getByText('Book')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show change link when showChangeLink is true', () => {
|
||||
render(<DocumentTypeDisplay displayType="book" showChangeLink={true} />)
|
||||
|
||||
expect(screen.getByText(/operation\.change/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show change link when showChangeLink is false', () => {
|
||||
render(<DocumentTypeDisplay displayType="book" showChangeLink={false} />)
|
||||
|
||||
expect(screen.queryByText(/operation\.change/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChangeClick when change link is clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
render(<DocumentTypeDisplay displayType="book" showChangeLink={true} onChangeClick={onClick} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.change/))
|
||||
|
||||
expect(onClick).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fallback to "book" display when displayType is empty and no change link', () => {
|
||||
render(<DocumentTypeDisplay displayType="" showChangeLink={false} />)
|
||||
|
||||
expect(screen.getByText('Book')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import FieldInfo from '../field-info'
|
||||
|
||||
vi.mock('@/utils', () => ({
|
||||
getTextWidthWithCanvas: (text: string) => text.length * 8,
|
||||
}))
|
||||
|
||||
describe('FieldInfo', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Verify read-only rendering
|
||||
describe('Read-Only Mode', () => {
|
||||
it('should render label and displayed value', () => {
|
||||
render(<FieldInfo label="Title" displayedValue="My Document" />)
|
||||
|
||||
expect(screen.getByText('Title')).toBeInTheDocument()
|
||||
expect(screen.getByText('My Document')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render value icon when provided', () => {
|
||||
render(
|
||||
<FieldInfo
|
||||
label="Status"
|
||||
displayedValue="Active"
|
||||
valueIcon={<span data-testid="icon">*</span>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render displayedValue as plain text when not editing', () => {
|
||||
render(<FieldInfo label="Author" displayedValue="John" showEdit={false} />)
|
||||
|
||||
expect(screen.getByText('John')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Verify edit mode rendering for each inputType
|
||||
describe('Edit Mode', () => {
|
||||
it('should render input field by default in edit mode', () => {
|
||||
render(<FieldInfo label="Title" value="Test" showEdit={true} inputType="input" />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input).toHaveValue('Test')
|
||||
})
|
||||
|
||||
it('should render textarea when inputType is textarea', () => {
|
||||
render(<FieldInfo label="Desc" value="Long text" showEdit={true} inputType="textarea" />)
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea).toBeInTheDocument()
|
||||
expect(textarea).toHaveValue('Long text')
|
||||
})
|
||||
|
||||
it('should render select when inputType is select', () => {
|
||||
const options = [
|
||||
{ value: 'en', name: 'English' },
|
||||
{ value: 'zh', name: 'Chinese' },
|
||||
]
|
||||
render(
|
||||
<FieldInfo
|
||||
label="Language"
|
||||
value="en"
|
||||
showEdit={true}
|
||||
inputType="select"
|
||||
selectOptions={options}
|
||||
/>,
|
||||
)
|
||||
|
||||
// SimpleSelect renders a button-like trigger
|
||||
expect(screen.getByText('English')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onUpdate when input value changes', () => {
|
||||
const onUpdate = vi.fn()
|
||||
render(<FieldInfo label="Title" value="" showEdit={true} inputType="input" onUpdate={onUpdate} />)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New' } })
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith('New')
|
||||
})
|
||||
|
||||
it('should call onUpdate when textarea value changes', () => {
|
||||
const onUpdate = vi.fn()
|
||||
render(<FieldInfo label="Desc" value="" showEdit={true} inputType="textarea" onUpdate={onUpdate} />)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Updated' } })
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith('Updated')
|
||||
})
|
||||
})
|
||||
|
||||
// Verify edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with empty value and label', () => {
|
||||
render(<FieldInfo label="" value="" displayedValue="" />)
|
||||
|
||||
// Should not crash
|
||||
const container = document.querySelector('.flex.min-h-5')
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with default value prop', () => {
|
||||
render(<FieldInfo label="Field" showEdit={true} inputType="input" defaultValue="default" />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import MetadataFieldList from '../metadata-field-list'
|
||||
|
||||
vi.mock('@/hooks/use-metadata', () => ({
|
||||
useMetadataMap: () => ({
|
||||
book: {
|
||||
text: 'Book',
|
||||
subFieldsMap: {
|
||||
title: { label: 'Title', inputType: 'input' },
|
||||
language: { label: 'Language', inputType: 'select' },
|
||||
author: { label: 'Author', inputType: 'input' },
|
||||
},
|
||||
},
|
||||
originInfo: {
|
||||
text: 'Origin Info',
|
||||
subFieldsMap: {
|
||||
source: { label: 'Source', inputType: 'input' },
|
||||
hit_count: { label: 'Hit Count', inputType: 'input', render: (val: number, segCount?: number) => `${val} / ${segCount}` },
|
||||
},
|
||||
},
|
||||
}),
|
||||
useLanguages: () => ({ en: 'English', zh: 'Chinese' }),
|
||||
useBookCategories: () => ({ fiction: 'Fiction', nonfiction: 'Non-fiction' }),
|
||||
usePersonalDocCategories: () => ({}),
|
||||
useBusinessDocCategories: () => ({}),
|
||||
}))
|
||||
|
||||
describe('MetadataFieldList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Verify rendering of metadata fields based on mainField
|
||||
describe('Rendering', () => {
|
||||
it('should render all fields for the given mainField', () => {
|
||||
render(
|
||||
<MetadataFieldList
|
||||
mainField="book"
|
||||
metadata={{ title: 'Test Book', language: 'en', author: 'John' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Title')).toBeInTheDocument()
|
||||
expect(screen.getByText('Language')).toBeInTheDocument()
|
||||
expect(screen.getByText('Author')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return null when mainField is empty', () => {
|
||||
const { container } = render(
|
||||
<MetadataFieldList mainField="" metadata={{}} />,
|
||||
)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should display "-" for missing field values', () => {
|
||||
render(
|
||||
<MetadataFieldList
|
||||
mainField="book"
|
||||
metadata={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
// All three fields should show "-"
|
||||
const dashes = screen.getAllByText('-')
|
||||
expect(dashes.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('should resolve select values to their display name', () => {
|
||||
render(
|
||||
<MetadataFieldList
|
||||
mainField="book"
|
||||
metadata={{ language: 'en' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('English')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Verify edit mode passes correct props
|
||||
describe('Edit Mode', () => {
|
||||
it('should render fields in edit mode when canEdit is true', () => {
|
||||
render(
|
||||
<MetadataFieldList
|
||||
mainField="book"
|
||||
canEdit={true}
|
||||
metadata={{ title: 'Book Title' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
// In edit mode, FieldInfo renders input elements
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
expect(inputs.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should call onFieldUpdate when a field value changes', () => {
|
||||
const onUpdate = vi.fn()
|
||||
render(
|
||||
<MetadataFieldList
|
||||
mainField="book"
|
||||
canEdit={true}
|
||||
metadata={{ title: '' }}
|
||||
onFieldUpdate={onUpdate}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Find the first textbox and type in it
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
fireEvent.change(inputs[0], { target: { value: 'New Title' } })
|
||||
|
||||
expect(onUpdate).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Verify fixed field types use docDetail as source
|
||||
describe('Fixed Field Types', () => {
|
||||
it('should use docDetail as source data for originInfo type', () => {
|
||||
const docDetail = { source: 'Web', hit_count: 42, segment_count: 10 }
|
||||
|
||||
render(
|
||||
<MetadataFieldList
|
||||
mainField="originInfo"
|
||||
docDetail={docDetail as never}
|
||||
metadata={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Source')).toBeInTheDocument()
|
||||
expect(screen.getByText('Web')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom render function output for fields with render', () => {
|
||||
const docDetail = { source: 'API', hit_count: 15, segment_count: 5 }
|
||||
|
||||
render(
|
||||
<MetadataFieldList
|
||||
mainField="originInfo"
|
||||
docDetail={docDetail as never}
|
||||
metadata={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('15 / 5')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { FullDocumentDetail } from '@/models/datasets'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
|
||||
import { useMetadataState } from '../use-metadata-state'
|
||||
|
||||
const { mockNotify, mockModifyDocMetadata } = vi.hoisted(() => ({
|
||||
mockNotify: vi.fn(),
|
||||
mockModifyDocMetadata: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../../context', () => ({
|
||||
useDocumentContext: (selector: (state: { datasetId: string, documentId: string }) => unknown) =>
|
||||
selector({ datasetId: 'ds-1', documentId: 'doc-1' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
modifyDocMetadata: (...args: unknown[]) => mockModifyDocMetadata(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-metadata', () => ({ useMetadataMap: () => ({}) }))
|
||||
|
||||
vi.mock('@/utils', () => ({
|
||||
asyncRunSafe: async (promise: Promise<unknown>) => {
|
||||
try {
|
||||
return [null, await promise]
|
||||
}
|
||||
catch (e) { return [e] }
|
||||
},
|
||||
}))
|
||||
|
||||
// Wrapper that provides ToastContext with the mock notify function
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
React.createElement(ToastContext.Provider, { value: { notify: mockNotify, close: vi.fn() }, children })
|
||||
|
||||
type DocDetail = Parameters<typeof useMetadataState>[0]['docDetail']
|
||||
|
||||
const makeDoc = (overrides: Partial<FullDocumentDetail> = {}): DocDetail =>
|
||||
({ doc_type: 'book', doc_metadata: { title: 'Test Book', author: 'Author' }, ...overrides } as DocDetail)
|
||||
|
||||
describe('useMetadataState', () => {
|
||||
// Verify all metadata editing workflows using a stable docDetail reference
|
||||
it('should manage the full metadata editing lifecycle', async () => {
|
||||
mockModifyDocMetadata.mockResolvedValue({ result: 'ok' })
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
// IMPORTANT: Create a stable reference outside the render callback
|
||||
// to prevent useEffect infinite loops on docDetail?.doc_metadata
|
||||
const stableDocDetail = makeDoc()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMetadataState({ docDetail: stableDocDetail, onUpdate }), { wrapper })
|
||||
|
||||
// --- Initialization ---
|
||||
expect(result.current.docType).toBe('book')
|
||||
expect(result.current.editStatus).toBe(false)
|
||||
expect(result.current.showDocTypes).toBe(false)
|
||||
expect(result.current.metadataParams.documentType).toBe('book')
|
||||
expect(result.current.metadataParams.metadata).toEqual({ title: 'Test Book', author: 'Author' })
|
||||
|
||||
// --- Enable editing ---
|
||||
act(() => {
|
||||
result.current.enableEdit()
|
||||
})
|
||||
expect(result.current.editStatus).toBe(true)
|
||||
|
||||
// --- Update individual field ---
|
||||
act(() => {
|
||||
result.current.updateMetadataField('title', 'Modified Title')
|
||||
})
|
||||
expect(result.current.metadataParams.metadata.title).toBe('Modified Title')
|
||||
expect(result.current.metadataParams.metadata.author).toBe('Author')
|
||||
|
||||
// --- Cancel edit restores original data ---
|
||||
act(() => {
|
||||
result.current.cancelEdit()
|
||||
})
|
||||
expect(result.current.metadataParams.metadata.title).toBe('Test Book')
|
||||
expect(result.current.editStatus).toBe(false)
|
||||
|
||||
// --- Doc type selection: cancel restores previous ---
|
||||
act(() => {
|
||||
result.current.enableEdit()
|
||||
})
|
||||
act(() => {
|
||||
result.current.setShowDocTypes(true)
|
||||
})
|
||||
act(() => {
|
||||
result.current.setTempDocType('web_page')
|
||||
})
|
||||
act(() => {
|
||||
result.current.cancelDocType()
|
||||
})
|
||||
expect(result.current.tempDocType).toBe('book')
|
||||
expect(result.current.showDocTypes).toBe(false)
|
||||
|
||||
// --- Confirm different doc type clears metadata ---
|
||||
act(() => {
|
||||
result.current.setShowDocTypes(true)
|
||||
})
|
||||
act(() => {
|
||||
result.current.setTempDocType('web_page')
|
||||
})
|
||||
act(() => {
|
||||
result.current.confirmDocType()
|
||||
})
|
||||
expect(result.current.metadataParams.documentType).toBe('web_page')
|
||||
expect(result.current.metadataParams.metadata).toEqual({})
|
||||
|
||||
// --- Save succeeds ---
|
||||
await act(async () => {
|
||||
await result.current.saveMetadata()
|
||||
})
|
||||
expect(mockModifyDocMetadata).toHaveBeenCalledWith({
|
||||
datasetId: 'ds-1',
|
||||
documentId: 'doc-1',
|
||||
body: { doc_type: 'web_page', doc_metadata: {} },
|
||||
})
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }))
|
||||
expect(onUpdate).toHaveBeenCalled()
|
||||
expect(result.current.editStatus).toBe(false)
|
||||
expect(result.current.saveLoading).toBe(false)
|
||||
|
||||
// --- Save failure notifies error ---
|
||||
mockNotify.mockClear()
|
||||
mockModifyDocMetadata.mockRejectedValue(new Error('fail'))
|
||||
act(() => {
|
||||
result.current.enableEdit()
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.saveMetadata()
|
||||
})
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
// Verify empty doc type starts in editing mode
|
||||
it('should initialize in editing mode when no doc type exists', () => {
|
||||
const stableDocDetail = makeDoc({ doc_type: '' as FullDocumentDetail['doc_type'], doc_metadata: {} as FullDocumentDetail['doc_metadata'] })
|
||||
const { result } = renderHook(() => useMetadataState({ docDetail: stableDocDetail }), { wrapper })
|
||||
|
||||
expect(result.current.docType).toBe('')
|
||||
expect(result.current.editStatus).toBe(true)
|
||||
expect(result.current.showDocTypes).toBe(true)
|
||||
})
|
||||
|
||||
// Verify "others" normalization
|
||||
it('should normalize "others" doc_type to empty string', () => {
|
||||
const stableDocDetail = makeDoc({ doc_type: 'others' as FullDocumentDetail['doc_type'] })
|
||||
const { result } = renderHook(() => useMetadataState({ docDetail: stableDocDetail }), { wrapper })
|
||||
|
||||
expect(result.current.docType).toBe('')
|
||||
})
|
||||
|
||||
// Verify undefined docDetail handling
|
||||
it('should handle undefined docDetail gracefully', () => {
|
||||
const { result } = renderHook(() => useMetadataState({ docDetail: undefined }), { wrapper })
|
||||
|
||||
expect(result.current.docType).toBe('')
|
||||
expect(result.current.editStatus).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user