test(web): add and enhance frontend automated tests across multiple modules (#32268)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
Coding On Star
2026-02-13 10:27:48 +08:00
committed by GitHub
parent 16df9851a2
commit b6d506828b
75 changed files with 5652 additions and 4081 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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