diff --git a/web/app/components/app/annotation/batch-action.spec.tsx b/web/app/components/app/annotation/batch-action.spec.tsx
new file mode 100644
index 0000000000..36440fc044
--- /dev/null
+++ b/web/app/components/app/annotation/batch-action.spec.tsx
@@ -0,0 +1,42 @@
+import React from 'react'
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import BatchAction from './batch-action'
+
+describe('BatchAction', () => {
+ const baseProps = {
+ selectedIds: ['1', '2', '3'],
+ onBatchDelete: jest.fn(),
+ onCancel: jest.fn(),
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('should show the selected count and trigger cancel action', () => {
+ render()
+
+ expect(screen.getByText('3')).toBeInTheDocument()
+ expect(screen.getByText('appAnnotation.batchAction.selected')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
+
+ expect(baseProps.onCancel).toHaveBeenCalledTimes(1)
+ })
+
+ it('should confirm before running batch delete', async () => {
+ const onBatchDelete = jest.fn().mockResolvedValue(undefined)
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'common.operation.delete' }))
+ await screen.findByText('appAnnotation.list.delete.title')
+
+ await act(async () => {
+ fireEvent.click(screen.getAllByRole('button', { name: 'common.operation.delete' })[1])
+ })
+
+ await waitFor(() => {
+ expect(onBatchDelete).toHaveBeenCalledTimes(1)
+ })
+ })
+})
diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/csv-downloader.spec.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/csv-downloader.spec.tsx
new file mode 100644
index 0000000000..7d360cfc1b
--- /dev/null
+++ b/web/app/components/app/annotation/batch-add-annotation-modal/csv-downloader.spec.tsx
@@ -0,0 +1,72 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import CSVDownload from './csv-downloader'
+import I18nContext from '@/context/i18n'
+import { LanguagesSupported } from '@/i18n-config/language'
+import type { Locale } from '@/i18n-config'
+
+const downloaderProps: any[] = []
+
+jest.mock('react-papaparse', () => ({
+ useCSVDownloader: jest.fn(() => ({
+ CSVDownloader: ({ children, ...props }: any) => {
+ downloaderProps.push(props)
+ return
{children}
+ },
+ Type: { Link: 'link' },
+ })),
+}))
+
+const renderWithLocale = (locale: Locale) => {
+ return render(
+
+
+ ,
+ )
+}
+
+describe('CSVDownload', () => {
+ const englishTemplate = [
+ ['question', 'answer'],
+ ['question1', 'answer1'],
+ ['question2', 'answer2'],
+ ]
+ const chineseTemplate = [
+ ['问题', '答案'],
+ ['问题 1', '答案 1'],
+ ['问题 2', '答案 2'],
+ ]
+
+ beforeEach(() => {
+ downloaderProps.length = 0
+ })
+
+ it('should render the structure preview and pass English template data by default', () => {
+ renderWithLocale('en-US' as Locale)
+
+ expect(screen.getByText('share.generation.csvStructureTitle')).toBeInTheDocument()
+ expect(screen.getByText('appAnnotation.batchModal.template')).toBeInTheDocument()
+
+ expect(downloaderProps[0]).toMatchObject({
+ filename: 'template-en-US',
+ type: 'link',
+ bom: true,
+ data: englishTemplate,
+ })
+ })
+
+ it('should switch to the Chinese template when locale matches the secondary language', () => {
+ const locale = LanguagesSupported[1] as Locale
+ renderWithLocale(locale)
+
+ expect(downloaderProps[0]).toMatchObject({
+ filename: `template-${locale}`,
+ data: chineseTemplate,
+ })
+ })
+})
diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/index.spec.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/index.spec.tsx
new file mode 100644
index 0000000000..5527340895
--- /dev/null
+++ b/web/app/components/app/annotation/batch-add-annotation-modal/index.spec.tsx
@@ -0,0 +1,164 @@
+import React from 'react'
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import BatchModal, { ProcessStatus } from './index'
+import { useProviderContext } from '@/context/provider-context'
+import { annotationBatchImport, checkAnnotationBatchImportProgress } from '@/service/annotation'
+import type { IBatchModalProps } from './index'
+import Toast from '@/app/components/base/toast'
+
+jest.mock('@/app/components/base/toast', () => ({
+ __esModule: true,
+ default: {
+ notify: jest.fn(),
+ },
+}))
+
+jest.mock('@/service/annotation', () => ({
+ annotationBatchImport: jest.fn(),
+ checkAnnotationBatchImportProgress: jest.fn(),
+}))
+
+jest.mock('@/context/provider-context', () => ({
+ useProviderContext: jest.fn(),
+}))
+
+jest.mock('./csv-downloader', () => ({
+ __esModule: true,
+ default: () => ,
+}))
+
+let lastUploadedFile: File | undefined
+
+jest.mock('./csv-uploader', () => ({
+ __esModule: true,
+ default: ({ file, updateFile }: { file?: File; updateFile: (file?: File) => void }) => (
+
+
+ {file && {file.name}}
+
+ ),
+}))
+
+jest.mock('@/app/components/billing/annotation-full', () => ({
+ __esModule: true,
+ default: () => ,
+}))
+
+const mockNotify = Toast.notify as jest.Mock
+const useProviderContextMock = useProviderContext as jest.Mock
+const annotationBatchImportMock = annotationBatchImport as jest.Mock
+const checkAnnotationBatchImportProgressMock = checkAnnotationBatchImportProgress as jest.Mock
+
+const renderComponent = (props: Partial = {}) => {
+ const mergedProps: IBatchModalProps = {
+ appId: 'app-id',
+ isShow: true,
+ onCancel: jest.fn(),
+ onAdded: jest.fn(),
+ ...props,
+ }
+ return {
+ ...render(),
+ props: mergedProps,
+ }
+}
+
+describe('BatchModal', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ lastUploadedFile = undefined
+ useProviderContextMock.mockReturnValue({
+ plan: {
+ usage: { annotatedResponse: 0 },
+ total: { annotatedResponse: 10 },
+ },
+ enableBilling: false,
+ })
+ })
+
+ it('should disable run action and show billing hint when annotation quota is full', () => {
+ useProviderContextMock.mockReturnValue({
+ plan: {
+ usage: { annotatedResponse: 10 },
+ total: { annotatedResponse: 10 },
+ },
+ enableBilling: true,
+ })
+
+ renderComponent()
+
+ expect(screen.getByTestId('annotation-full')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'appAnnotation.batchModal.run' })).toBeDisabled()
+ })
+
+ it('should reset uploader state when modal closes and allow manual cancellation', () => {
+ const { rerender, props } = renderComponent()
+
+ fireEvent.click(screen.getByTestId('mock-uploader'))
+ expect(screen.getByTestId('selected-file')).toHaveTextContent('batch.csv')
+
+ rerender()
+ rerender()
+
+ expect(screen.queryByTestId('selected-file')).toBeNull()
+
+ fireEvent.click(screen.getByRole('button', { name: 'appAnnotation.batchModal.cancel' }))
+ expect(props.onCancel).toHaveBeenCalledTimes(1)
+ })
+
+ it('should submit the csv file, poll status, and notify when import completes', async () => {
+ jest.useFakeTimers()
+ const { props } = renderComponent()
+ const fileTrigger = screen.getByTestId('mock-uploader')
+ fireEvent.click(fileTrigger)
+
+ const runButton = screen.getByRole('button', { name: 'appAnnotation.batchModal.run' })
+ expect(runButton).not.toBeDisabled()
+
+ annotationBatchImportMock.mockResolvedValue({ job_id: 'job-1', job_status: ProcessStatus.PROCESSING })
+ checkAnnotationBatchImportProgressMock
+ .mockResolvedValueOnce({ job_id: 'job-1', job_status: ProcessStatus.PROCESSING })
+ .mockResolvedValueOnce({ job_id: 'job-1', job_status: ProcessStatus.COMPLETED })
+
+ await act(async () => {
+ fireEvent.click(runButton)
+ })
+
+ await waitFor(() => {
+ expect(annotationBatchImportMock).toHaveBeenCalledTimes(1)
+ })
+
+ const formData = annotationBatchImportMock.mock.calls[0][0].body as FormData
+ expect(formData.get('file')).toBe(lastUploadedFile)
+
+ await waitFor(() => {
+ expect(checkAnnotationBatchImportProgressMock).toHaveBeenCalledTimes(1)
+ })
+
+ await act(async () => {
+ jest.runOnlyPendingTimers()
+ })
+
+ await waitFor(() => {
+ expect(checkAnnotationBatchImportProgressMock).toHaveBeenCalledTimes(2)
+ })
+
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith({
+ type: 'success',
+ message: 'appAnnotation.batchModal.completed',
+ })
+ expect(props.onAdded).toHaveBeenCalledTimes(1)
+ expect(props.onCancel).toHaveBeenCalledTimes(1)
+ })
+ jest.useRealTimers()
+ })
+})
diff --git a/web/app/components/app/annotation/empty-element.spec.tsx b/web/app/components/app/annotation/empty-element.spec.tsx
new file mode 100644
index 0000000000..56ebb96121
--- /dev/null
+++ b/web/app/components/app/annotation/empty-element.spec.tsx
@@ -0,0 +1,13 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import EmptyElement from './empty-element'
+
+describe('EmptyElement', () => {
+ it('should render the empty state copy and supporting icon', () => {
+ const { container } = render()
+
+ expect(screen.getByText('appAnnotation.noData.title')).toBeInTheDocument()
+ expect(screen.getByText('appAnnotation.noData.description')).toBeInTheDocument()
+ expect(container.querySelector('svg')).not.toBeNull()
+ })
+})
diff --git a/web/app/components/app/annotation/filter.spec.tsx b/web/app/components/app/annotation/filter.spec.tsx
new file mode 100644
index 0000000000..6260ff7668
--- /dev/null
+++ b/web/app/components/app/annotation/filter.spec.tsx
@@ -0,0 +1,70 @@
+import React from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import Filter, { type QueryParam } from './filter'
+import useSWR from 'swr'
+
+jest.mock('swr', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}))
+
+jest.mock('@/service/log', () => ({
+ fetchAnnotationsCount: jest.fn(),
+}))
+
+const mockUseSWR = useSWR as unknown as jest.Mock
+
+describe('Filter', () => {
+ const appId = 'app-1'
+ const childContent = 'child-content'
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('should render nothing until annotation count is fetched', () => {
+ mockUseSWR.mockReturnValue({ data: undefined })
+
+ const { container } = render(
+
+ {childContent}
+ ,
+ )
+
+ expect(container.firstChild).toBeNull()
+ expect(mockUseSWR).toHaveBeenCalledWith(
+ { url: `/apps/${appId}/annotations/count` },
+ expect.any(Function),
+ )
+ })
+
+ it('should propagate keyword changes and clearing behavior', () => {
+ mockUseSWR.mockReturnValue({ data: { total: 20 } })
+ const queryParams: QueryParam = { keyword: 'prefill' }
+ const setQueryParams = jest.fn()
+
+ const { container } = render(
+
+ {childContent}
+ ,
+ )
+
+ const input = screen.getByPlaceholderText('common.operation.search') as HTMLInputElement
+ fireEvent.change(input, { target: { value: 'updated' } })
+ expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: 'updated' })
+
+ const clearButton = input.parentElement?.querySelector('div.cursor-pointer') as HTMLElement
+ fireEvent.click(clearButton)
+ expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: '' })
+
+ expect(container).toHaveTextContent(childContent)
+ })
+})
diff --git a/web/app/components/app/annotation/header-opts/index.spec.tsx b/web/app/components/app/annotation/header-opts/index.spec.tsx
new file mode 100644
index 0000000000..8c640c2790
--- /dev/null
+++ b/web/app/components/app/annotation/header-opts/index.spec.tsx
@@ -0,0 +1,323 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import type { ComponentProps } from 'react'
+import HeaderOptions from './index'
+import I18NContext from '@/context/i18n'
+import { LanguagesSupported } from '@/i18n-config/language'
+import type { AnnotationItemBasic } from '../type'
+import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation'
+
+let lastCSVDownloaderProps: Record | undefined
+const mockCSVDownloader = jest.fn(({ children, ...props }) => {
+ lastCSVDownloaderProps = props
+ return (
+
+ {children}
+
+ )
+})
+
+jest.mock('react-papaparse', () => ({
+ useCSVDownloader: () => ({
+ CSVDownloader: (props: any) => mockCSVDownloader(props),
+ Type: { Link: 'link' },
+ }),
+}))
+
+jest.mock('@/service/annotation', () => ({
+ fetchExportAnnotationList: jest.fn(),
+ clearAllAnnotations: jest.fn(),
+}))
+
+jest.mock('@/context/provider-context', () => ({
+ useProviderContext: () => ({
+ plan: {
+ usage: { annotatedResponse: 0 },
+ total: { annotatedResponse: 10 },
+ },
+ enableBilling: false,
+ }),
+}))
+
+jest.mock('@/app/components/billing/annotation-full', () => ({
+ __esModule: true,
+ default: () => ,
+}))
+
+type HeaderOptionsProps = ComponentProps
+
+const renderComponent = (
+ props: Partial = {},
+ locale: string = LanguagesSupported[0] as string,
+) => {
+ const defaultProps: HeaderOptionsProps = {
+ appId: 'test-app-id',
+ onAdd: jest.fn(),
+ onAdded: jest.fn(),
+ controlUpdateList: 0,
+ ...props,
+ }
+
+ return render(
+
+
+ ,
+ )
+}
+
+const openOperationsPopover = async (user: ReturnType) => {
+ const trigger = document.querySelector('button.btn.btn-secondary') as HTMLButtonElement
+ expect(trigger).toBeTruthy()
+ await user.click(trigger)
+}
+
+const expandExportMenu = async (user: ReturnType) => {
+ await openOperationsPopover(user)
+ const exportLabel = await screen.findByText('appAnnotation.table.header.bulkExport')
+ const exportButton = exportLabel.closest('button') as HTMLButtonElement
+ expect(exportButton).toBeTruthy()
+ await user.click(exportButton)
+}
+
+const getExportButtons = async () => {
+ const csvLabel = await screen.findByText('CSV')
+ const jsonLabel = await screen.findByText('JSONL')
+ const csvButton = csvLabel.closest('button') as HTMLButtonElement
+ const jsonButton = jsonLabel.closest('button') as HTMLButtonElement
+ expect(csvButton).toBeTruthy()
+ expect(jsonButton).toBeTruthy()
+ return {
+ csvButton,
+ jsonButton,
+ }
+}
+
+const clickOperationAction = async (
+ user: ReturnType,
+ translationKey: string,
+) => {
+ const label = await screen.findByText(translationKey)
+ const button = label.closest('button') as HTMLButtonElement
+ expect(button).toBeTruthy()
+ await user.click(button)
+}
+
+const mockAnnotations: AnnotationItemBasic[] = [
+ {
+ question: 'Question 1',
+ answer: 'Answer 1',
+ },
+]
+
+const mockedFetchAnnotations = jest.mocked(fetchExportAnnotationList)
+const mockedClearAllAnnotations = jest.mocked(clearAllAnnotations)
+
+describe('HeaderOptions', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockCSVDownloader.mockClear()
+ lastCSVDownloaderProps = undefined
+ mockedFetchAnnotations.mockResolvedValue({ data: [] })
+ })
+
+ it('should fetch annotations on mount and render enabled export actions when data exist', async () => {
+ mockedFetchAnnotations.mockResolvedValue({ data: mockAnnotations })
+ const user = userEvent.setup()
+ renderComponent()
+
+ await waitFor(() => {
+ expect(mockedFetchAnnotations).toHaveBeenCalledWith('test-app-id')
+ })
+
+ await expandExportMenu(user)
+
+ const { csvButton, jsonButton } = await getExportButtons()
+
+ expect(csvButton).not.toBeDisabled()
+ expect(jsonButton).not.toBeDisabled()
+
+ await waitFor(() => {
+ expect(lastCSVDownloaderProps).toMatchObject({
+ bom: true,
+ filename: 'annotations-en-US',
+ type: 'link',
+ data: [
+ ['Question', 'Answer'],
+ ['Question 1', 'Answer 1'],
+ ],
+ })
+ })
+ })
+
+ it('should disable export actions when there are no annotations', async () => {
+ const user = userEvent.setup()
+ renderComponent()
+
+ await expandExportMenu(user)
+
+ const { csvButton, jsonButton } = await getExportButtons()
+
+ expect(csvButton).toBeDisabled()
+ expect(jsonButton).toBeDisabled()
+
+ expect(lastCSVDownloaderProps).toMatchObject({
+ data: [['Question', 'Answer']],
+ })
+ })
+
+ it('should open the add annotation modal and forward the onAdd callback', async () => {
+ mockedFetchAnnotations.mockResolvedValue({ data: mockAnnotations })
+ const user = userEvent.setup()
+ const onAdd = jest.fn().mockResolvedValue(undefined)
+ renderComponent({ onAdd })
+
+ await waitFor(() => expect(mockedFetchAnnotations).toHaveBeenCalled())
+
+ await user.click(
+ screen.getByRole('button', { name: 'appAnnotation.table.header.addAnnotation' }),
+ )
+
+ await screen.findByText('appAnnotation.addModal.title')
+ const questionInput = screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder')
+ const answerInput = screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder')
+
+ await user.type(questionInput, 'Integration question')
+ await user.type(answerInput, 'Integration answer')
+ await user.click(screen.getByRole('button', { name: 'common.operation.add' }))
+
+ await waitFor(() => {
+ expect(onAdd).toHaveBeenCalledWith({
+ question: 'Integration question',
+ answer: 'Integration answer',
+ })
+ })
+ })
+
+ it('should allow bulk import through the batch modal', async () => {
+ const user = userEvent.setup()
+ const onAdded = jest.fn()
+ renderComponent({ onAdded })
+
+ await openOperationsPopover(user)
+ await clickOperationAction(user, 'appAnnotation.table.header.bulkImport')
+
+ expect(await screen.findByText('appAnnotation.batchModal.title')).toBeInTheDocument()
+ await user.click(
+ screen.getByRole('button', { name: 'appAnnotation.batchModal.cancel' }),
+ )
+ expect(onAdded).not.toHaveBeenCalled()
+ })
+
+ it('should trigger JSONL download with locale-specific filename', async () => {
+ mockedFetchAnnotations.mockResolvedValue({ data: mockAnnotations })
+ const user = userEvent.setup()
+ const originalCreateElement = document.createElement.bind(document)
+ const anchor = originalCreateElement('a') as HTMLAnchorElement
+ const clickSpy = jest.spyOn(anchor, 'click').mockImplementation(jest.fn())
+ const createElementSpy = jest
+ .spyOn(document, 'createElement')
+ .mockImplementation((tagName: Parameters[0]) => {
+ if (tagName === 'a')
+ return anchor
+ return originalCreateElement(tagName)
+ })
+ const objectURLSpy = jest
+ .spyOn(URL, 'createObjectURL')
+ .mockReturnValue('blob://mock-url')
+ const revokeSpy = jest.spyOn(URL, 'revokeObjectURL').mockImplementation(jest.fn())
+
+ renderComponent({}, LanguagesSupported[1] as string)
+
+ await expandExportMenu(user)
+
+ await waitFor(() => expect(mockCSVDownloader).toHaveBeenCalled())
+
+ const { jsonButton } = await getExportButtons()
+ await user.click(jsonButton)
+
+ expect(createElementSpy).toHaveBeenCalled()
+ expect(anchor.download).toBe(`annotations-${LanguagesSupported[1]}.jsonl`)
+ expect(clickSpy).toHaveBeenCalled()
+ expect(revokeSpy).toHaveBeenCalledWith('blob://mock-url')
+
+ const blobArg = objectURLSpy.mock.calls[0][0] as Blob
+ await expect(blobArg.text()).resolves.toContain('"Question 1"')
+
+ clickSpy.mockRestore()
+ createElementSpy.mockRestore()
+ objectURLSpy.mockRestore()
+ revokeSpy.mockRestore()
+ })
+
+ it('should clear all annotations when confirmation succeeds', async () => {
+ mockedClearAllAnnotations.mockResolvedValue(undefined)
+ const user = userEvent.setup()
+ const onAdded = jest.fn()
+ renderComponent({ onAdded })
+
+ await openOperationsPopover(user)
+ await clickOperationAction(user, 'appAnnotation.table.header.clearAll')
+
+ await screen.findByText('appAnnotation.table.header.clearAllConfirm')
+ const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' })
+ await user.click(confirmButton)
+
+ await waitFor(() => {
+ expect(mockedClearAllAnnotations).toHaveBeenCalledWith('test-app-id')
+ expect(onAdded).toHaveBeenCalled()
+ })
+ })
+
+ it('should handle clear all failures gracefully', async () => {
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn())
+ mockedClearAllAnnotations.mockRejectedValue(new Error('network'))
+ const user = userEvent.setup()
+ const onAdded = jest.fn()
+ renderComponent({ onAdded })
+
+ await openOperationsPopover(user)
+ await clickOperationAction(user, 'appAnnotation.table.header.clearAll')
+ await screen.findByText('appAnnotation.table.header.clearAllConfirm')
+ const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' })
+ await user.click(confirmButton)
+
+ await waitFor(() => {
+ expect(mockedClearAllAnnotations).toHaveBeenCalled()
+ expect(onAdded).not.toHaveBeenCalled()
+ expect(consoleSpy).toHaveBeenCalled()
+ })
+
+ consoleSpy.mockRestore()
+ })
+
+ it('should refetch annotations when controlUpdateList changes', async () => {
+ const view = renderComponent({ controlUpdateList: 0 })
+
+ await waitFor(() => expect(mockedFetchAnnotations).toHaveBeenCalledTimes(1))
+
+ view.rerender(
+
+
+ ,
+ )
+
+ await waitFor(() => expect(mockedFetchAnnotations).toHaveBeenCalledTimes(2))
+ })
+})
diff --git a/web/app/components/app/annotation/index.spec.tsx b/web/app/components/app/annotation/index.spec.tsx
new file mode 100644
index 0000000000..4971f5173c
--- /dev/null
+++ b/web/app/components/app/annotation/index.spec.tsx
@@ -0,0 +1,233 @@
+import React from 'react'
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import Annotation from './index'
+import type { AnnotationItem } from './type'
+import { JobStatus } from './type'
+import { type App, AppModeEnum } from '@/types/app'
+import {
+ addAnnotation,
+ delAnnotation,
+ delAnnotations,
+ fetchAnnotationConfig,
+ fetchAnnotationList,
+ queryAnnotationJobStatus,
+} from '@/service/annotation'
+import { useProviderContext } from '@/context/provider-context'
+import Toast from '@/app/components/base/toast'
+
+jest.mock('@/app/components/base/toast', () => ({
+ __esModule: true,
+ default: { notify: jest.fn() },
+}))
+
+jest.mock('ahooks', () => ({
+ useDebounce: (value: any) => value,
+}))
+
+jest.mock('@/service/annotation', () => ({
+ addAnnotation: jest.fn(),
+ delAnnotation: jest.fn(),
+ delAnnotations: jest.fn(),
+ fetchAnnotationConfig: jest.fn(),
+ editAnnotation: jest.fn(),
+ fetchAnnotationList: jest.fn(),
+ queryAnnotationJobStatus: jest.fn(),
+ updateAnnotationScore: jest.fn(),
+ updateAnnotationStatus: jest.fn(),
+}))
+
+jest.mock('@/context/provider-context', () => ({
+ useProviderContext: jest.fn(),
+}))
+
+jest.mock('./filter', () => ({ children }: { children: React.ReactNode }) => (
+ {children}
+))
+
+jest.mock('./empty-element', () => () => )
+
+jest.mock('./header-opts', () => (props: any) => (
+
+
+
+))
+
+let latestListProps: any
+
+jest.mock('./list', () => (props: any) => {
+ latestListProps = props
+ if (!props.list.length)
+ return
+ return (
+
+
+
+
+
+ )
+})
+
+jest.mock('./view-annotation-modal', () => (props: any) => {
+ if (!props.isShow)
+ return null
+ return (
+
+
{props.item.question}
+
+
+
+ )
+})
+
+jest.mock('@/app/components/base/pagination', () => () => )
+jest.mock('@/app/components/base/loading', () => () => )
+jest.mock('@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal', () => (props: any) => props.isShow ? : null)
+jest.mock('@/app/components/billing/annotation-full/modal', () => (props: any) => props.show ? : null)
+
+const mockNotify = Toast.notify as jest.Mock
+const addAnnotationMock = addAnnotation as jest.Mock
+const delAnnotationMock = delAnnotation as jest.Mock
+const delAnnotationsMock = delAnnotations as jest.Mock
+const fetchAnnotationConfigMock = fetchAnnotationConfig as jest.Mock
+const fetchAnnotationListMock = fetchAnnotationList as jest.Mock
+const queryAnnotationJobStatusMock = queryAnnotationJobStatus as jest.Mock
+const useProviderContextMock = useProviderContext as jest.Mock
+
+const appDetail = {
+ id: 'app-id',
+ mode: AppModeEnum.CHAT,
+} as App
+
+const createAnnotation = (overrides: Partial = {}): AnnotationItem => ({
+ id: overrides.id ?? 'annotation-1',
+ question: overrides.question ?? 'Question 1',
+ answer: overrides.answer ?? 'Answer 1',
+ created_at: overrides.created_at ?? 1700000000,
+ hit_count: overrides.hit_count ?? 0,
+})
+
+const renderComponent = () => render()
+
+describe('Annotation', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ latestListProps = undefined
+ fetchAnnotationConfigMock.mockResolvedValue({
+ id: 'config-id',
+ enabled: false,
+ embedding_model: {
+ embedding_model_name: 'model',
+ embedding_provider_name: 'provider',
+ },
+ score_threshold: 0.5,
+ })
+ fetchAnnotationListMock.mockResolvedValue({ data: [], total: 0 })
+ queryAnnotationJobStatusMock.mockResolvedValue({ job_status: JobStatus.completed })
+ useProviderContextMock.mockReturnValue({
+ plan: {
+ usage: { annotatedResponse: 0 },
+ total: { annotatedResponse: 10 },
+ },
+ enableBilling: false,
+ })
+ })
+
+ it('should render empty element when no annotations are returned', async () => {
+ renderComponent()
+
+ expect(await screen.findByTestId('empty-element')).toBeInTheDocument()
+ expect(fetchAnnotationListMock).toHaveBeenCalledWith(appDetail.id, expect.objectContaining({
+ page: 1,
+ keyword: '',
+ }))
+ })
+
+ it('should handle annotation creation and refresh list data', async () => {
+ const annotation = createAnnotation()
+ fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 })
+ addAnnotationMock.mockResolvedValue(undefined)
+
+ renderComponent()
+
+ await screen.findByTestId('list')
+ fireEvent.click(screen.getByTestId('trigger-add'))
+
+ await waitFor(() => {
+ expect(addAnnotationMock).toHaveBeenCalledWith(appDetail.id, { question: 'new question', answer: 'new answer' })
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
+ message: 'common.api.actionSuccess',
+ type: 'success',
+ }))
+ })
+ expect(fetchAnnotationListMock).toHaveBeenCalledTimes(2)
+ })
+
+ it('should support viewing items and running batch deletion success flow', async () => {
+ const annotation = createAnnotation()
+ fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 })
+ delAnnotationsMock.mockResolvedValue(undefined)
+ delAnnotationMock.mockResolvedValue(undefined)
+
+ renderComponent()
+ await screen.findByTestId('list')
+
+ await act(async () => {
+ latestListProps.onSelectedIdsChange([annotation.id])
+ })
+ await waitFor(() => {
+ expect(latestListProps.selectedIds).toEqual([annotation.id])
+ })
+
+ await act(async () => {
+ await latestListProps.onBatchDelete()
+ })
+ await waitFor(() => {
+ expect(delAnnotationsMock).toHaveBeenCalledWith(appDetail.id, [annotation.id])
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'success',
+ }))
+ expect(latestListProps.selectedIds).toEqual([])
+ })
+
+ fireEvent.click(screen.getByTestId('list-view'))
+ expect(screen.getByTestId('view-modal')).toBeInTheDocument()
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('view-modal-remove'))
+ })
+ await waitFor(() => {
+ expect(delAnnotationMock).toHaveBeenCalledWith(appDetail.id, annotation.id)
+ })
+ })
+
+ it('should show an error notification when batch deletion fails', async () => {
+ const annotation = createAnnotation()
+ fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 })
+ const error = new Error('failed')
+ delAnnotationsMock.mockRejectedValue(error)
+
+ renderComponent()
+ await screen.findByTestId('list')
+
+ await act(async () => {
+ latestListProps.onSelectedIdsChange([annotation.id])
+ })
+ await waitFor(() => {
+ expect(latestListProps.selectedIds).toEqual([annotation.id])
+ })
+
+ await act(async () => {
+ await latestListProps.onBatchDelete()
+ })
+
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: error.message,
+ })
+ expect(latestListProps.selectedIds).toEqual([annotation.id])
+ })
+ })
+})
diff --git a/web/app/components/app/annotation/list.spec.tsx b/web/app/components/app/annotation/list.spec.tsx
new file mode 100644
index 0000000000..9f8d4c8855
--- /dev/null
+++ b/web/app/components/app/annotation/list.spec.tsx
@@ -0,0 +1,116 @@
+import React from 'react'
+import { fireEvent, render, screen, within } from '@testing-library/react'
+import List from './list'
+import type { AnnotationItem } from './type'
+
+const mockFormatTime = jest.fn(() => 'formatted-time')
+
+jest.mock('@/hooks/use-timestamp', () => ({
+ __esModule: true,
+ default: () => ({
+ formatTime: mockFormatTime,
+ }),
+}))
+
+const createAnnotation = (overrides: Partial = {}): AnnotationItem => ({
+ id: overrides.id ?? 'annotation-id',
+ question: overrides.question ?? 'question 1',
+ answer: overrides.answer ?? 'answer 1',
+ created_at: overrides.created_at ?? 1700000000,
+ hit_count: overrides.hit_count ?? 2,
+})
+
+const getCheckboxes = (container: HTMLElement) => container.querySelectorAll('[data-testid^="checkbox"]')
+
+describe('List', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('should render annotation rows and call onView when clicking a row', () => {
+ const item = createAnnotation()
+ const onView = jest.fn()
+
+ render(
+
,
+ )
+
+ fireEvent.click(screen.getByText(item.question))
+
+ expect(onView).toHaveBeenCalledWith(item)
+ expect(mockFormatTime).toHaveBeenCalledWith(item.created_at, 'appLog.dateTimeFormat')
+ })
+
+ it('should toggle single and bulk selection states', () => {
+ const list = [createAnnotation({ id: 'a', question: 'A' }), createAnnotation({ id: 'b', question: 'B' })]
+ const onSelectedIdsChange = jest.fn()
+ const { container, rerender } = render(
+
,
+ )
+
+ const checkboxes = getCheckboxes(container)
+ fireEvent.click(checkboxes[1])
+ expect(onSelectedIdsChange).toHaveBeenCalledWith(['a'])
+
+ rerender(
+
,
+ )
+ const updatedCheckboxes = getCheckboxes(container)
+ fireEvent.click(updatedCheckboxes[1])
+ expect(onSelectedIdsChange).toHaveBeenCalledWith([])
+
+ fireEvent.click(updatedCheckboxes[0])
+ expect(onSelectedIdsChange).toHaveBeenCalledWith(['a', 'b'])
+ })
+
+ it('should confirm before removing an annotation and expose batch actions', async () => {
+ const item = createAnnotation({ id: 'to-delete', question: 'Delete me' })
+ const onRemove = jest.fn()
+ render(
+
,
+ )
+
+ const row = screen.getByText(item.question).closest('tr') as HTMLTableRowElement
+ const actionButtons = within(row).getAllByRole('button')
+ fireEvent.click(actionButtons[1])
+
+ expect(await screen.findByText('appDebug.feature.annotation.removeConfirm')).toBeInTheDocument()
+ const confirmButton = await screen.findByRole('button', { name: 'common.operation.confirm' })
+ fireEvent.click(confirmButton)
+ expect(onRemove).toHaveBeenCalledWith(item.id)
+
+ expect(screen.getByText('appAnnotation.batchAction.selected')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/app/annotation/view-annotation-modal/index.spec.tsx b/web/app/components/app/annotation/view-annotation-modal/index.spec.tsx
new file mode 100644
index 0000000000..b5e3241fff
--- /dev/null
+++ b/web/app/components/app/annotation/view-annotation-modal/index.spec.tsx
@@ -0,0 +1,129 @@
+import React from 'react'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import ViewAnnotationModal from './index'
+import type { AnnotationItem, HitHistoryItem } from '../type'
+import { fetchHitHistoryList } from '@/service/annotation'
+
+const mockFormatTime = jest.fn(() => 'formatted-time')
+
+jest.mock('@/hooks/use-timestamp', () => ({
+ __esModule: true,
+ default: () => ({
+ formatTime: mockFormatTime,
+ }),
+}))
+
+jest.mock('@/service/annotation', () => ({
+ fetchHitHistoryList: jest.fn(),
+}))
+
+jest.mock('../edit-annotation-modal/edit-item', () => {
+ const EditItemType = {
+ Query: 'query',
+ Answer: 'answer',
+ }
+ return {
+ __esModule: true,
+ default: ({ type, content, onSave }: { type: string; content: string; onSave: (value: string) => void }) => (
+
+
{content}
+
+
+ ),
+ EditItemType,
+ }
+})
+
+const fetchHitHistoryListMock = fetchHitHistoryList as jest.Mock
+
+const createAnnotationItem = (overrides: Partial = {}): AnnotationItem => ({
+ id: overrides.id ?? 'annotation-id',
+ question: overrides.question ?? 'question',
+ answer: overrides.answer ?? 'answer',
+ created_at: overrides.created_at ?? 1700000000,
+ hit_count: overrides.hit_count ?? 0,
+})
+
+const createHitHistoryItem = (overrides: Partial = {}): HitHistoryItem => ({
+ id: overrides.id ?? 'hit-id',
+ question: overrides.question ?? 'query',
+ match: overrides.match ?? 'match',
+ response: overrides.response ?? 'response',
+ source: overrides.source ?? 'source',
+ score: overrides.score ?? 0.42,
+ created_at: overrides.created_at ?? 1700000000,
+})
+
+const renderComponent = (props?: Partial>) => {
+ const item = createAnnotationItem()
+ const mergedProps: React.ComponentProps = {
+ appId: 'app-id',
+ isShow: true,
+ onHide: jest.fn(),
+ item,
+ onSave: jest.fn().mockResolvedValue(undefined),
+ onRemove: jest.fn().mockResolvedValue(undefined),
+ ...props,
+ }
+ return {
+ ...render(),
+ props: mergedProps,
+ }
+}
+
+describe('ViewAnnotationModal', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ fetchHitHistoryListMock.mockResolvedValue({ data: [], total: 0 })
+ })
+
+ it('should render annotation tab and allow saving updated content', async () => {
+ const { props } = renderComponent()
+
+ await waitFor(() => {
+ expect(fetchHitHistoryListMock).toHaveBeenCalled()
+ })
+
+ fireEvent.click(screen.getByTestId('edit-query'))
+ await waitFor(() => {
+ expect(props.onSave).toHaveBeenCalledWith('query-updated', props.item.answer)
+ })
+
+ fireEvent.click(screen.getByTestId('edit-answer'))
+ await waitFor(() => {
+ expect(props.onSave).toHaveBeenCalledWith(props.item.question, 'answer-updated')
+ })
+
+ fireEvent.click(screen.getByText('appAnnotation.viewModal.hitHistory'))
+ expect(await screen.findByText('appAnnotation.viewModal.noHitHistory')).toBeInTheDocument()
+ expect(mockFormatTime).toHaveBeenCalledWith(props.item.created_at, 'appLog.dateTimeFormat')
+ })
+
+ it('should render hit history entries with pagination badge when data exists', async () => {
+ const hits = [createHitHistoryItem({ question: 'user input' }), createHitHistoryItem({ id: 'hit-2', question: 'second' })]
+ fetchHitHistoryListMock.mockResolvedValue({ data: hits, total: 15 })
+
+ renderComponent()
+
+ fireEvent.click(await screen.findByText('appAnnotation.viewModal.hitHistory'))
+
+ expect(await screen.findByText('user input')).toBeInTheDocument()
+ expect(screen.getByText('15 appAnnotation.viewModal.hits')).toBeInTheDocument()
+ expect(mockFormatTime).toHaveBeenCalledWith(hits[0].created_at, 'appLog.dateTimeFormat')
+ })
+
+ it('should confirm before removing the annotation and hide on success', async () => {
+ const { props } = renderComponent()
+
+ fireEvent.click(screen.getByText('appAnnotation.editModal.removeThisCache'))
+ expect(await screen.findByText('appDebug.feature.annotation.removeConfirm')).toBeInTheDocument()
+
+ const confirmButton = await screen.findByRole('button', { name: 'common.operation.confirm' })
+ fireEvent.click(confirmButton)
+
+ await waitFor(() => {
+ expect(props.onRemove).toHaveBeenCalledTimes(1)
+ expect(props.onHide).toHaveBeenCalledTimes(1)
+ })
+ })
+})