From 5bb1346da857bd955198875fbca6afd591056648 Mon Sep 17 00:00:00 2001 From: Joel Date: Wed, 17 Dec 2025 13:36:40 +0800 Subject: [PATCH 1/2] chore: tests form add annotation (#29770) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../edit-item/index.spec.tsx | 53 ++++++ .../add-annotation-modal/index.spec.tsx | 155 ++++++++++++++++++ .../annotation/add-annotation-modal/index.tsx | 2 +- 3 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 web/app/components/app/annotation/add-annotation-modal/edit-item/index.spec.tsx create mode 100644 web/app/components/app/annotation/add-annotation-modal/index.spec.tsx diff --git a/web/app/components/app/annotation/add-annotation-modal/edit-item/index.spec.tsx b/web/app/components/app/annotation/add-annotation-modal/edit-item/index.spec.tsx new file mode 100644 index 0000000000..356f813afc --- /dev/null +++ b/web/app/components/app/annotation/add-annotation-modal/edit-item/index.spec.tsx @@ -0,0 +1,53 @@ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import EditItem, { EditItemType } from './index' + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +describe('AddAnnotationModal/EditItem', () => { + test('should render query inputs with user avatar and placeholder strings', () => { + render( + , + ) + + expect(screen.getByText('appAnnotation.addModal.queryName')).toBeInTheDocument() + expect(screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder')).toBeInTheDocument() + expect(screen.getByText('Why?')).toBeInTheDocument() + }) + + test('should render answer name and placeholder text', () => { + render( + , + ) + + expect(screen.getByText('appAnnotation.addModal.answerName')).toBeInTheDocument() + expect(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder')).toBeInTheDocument() + expect(screen.getByDisplayValue('Existing answer')).toBeInTheDocument() + }) + + test('should propagate changes when answer content updates', () => { + const handleChange = jest.fn() + render( + , + ) + + fireEvent.change(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder'), { target: { value: 'Because' } }) + expect(handleChange).toHaveBeenCalledWith('Because') + }) +}) diff --git a/web/app/components/app/annotation/add-annotation-modal/index.spec.tsx b/web/app/components/app/annotation/add-annotation-modal/index.spec.tsx new file mode 100644 index 0000000000..3103e3c96d --- /dev/null +++ b/web/app/components/app/annotation/add-annotation-modal/index.spec.tsx @@ -0,0 +1,155 @@ +import React from 'react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import AddAnnotationModal from './index' +import { useProviderContext } from '@/context/provider-context' + +jest.mock('@/context/provider-context', () => ({ + useProviderContext: jest.fn(), +})) + +const mockToastNotify = jest.fn() +jest.mock('@/app/components/base/toast', () => ({ + __esModule: true, + default: { + notify: jest.fn(args => mockToastNotify(args)), + }, +})) + +jest.mock('@/app/components/billing/annotation-full', () => () =>
) + +const mockUseProviderContext = useProviderContext as jest.Mock + +const getProviderContext = ({ usage = 0, total = 10, enableBilling = false } = {}) => ({ + plan: { + usage: { annotatedResponse: usage }, + total: { annotatedResponse: total }, + }, + enableBilling, +}) + +describe('AddAnnotationModal', () => { + const baseProps = { + isShow: true, + onHide: jest.fn(), + onAdd: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + mockUseProviderContext.mockReturnValue(getProviderContext()) + }) + + const typeQuestion = (value: string) => { + fireEvent.change(screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder'), { + target: { value }, + }) + } + + const typeAnswer = (value: string) => { + fireEvent.change(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder'), { + target: { value }, + }) + } + + test('should render modal title when drawer is visible', () => { + render() + + expect(screen.getByText('appAnnotation.addModal.title')).toBeInTheDocument() + }) + + test('should capture query input text when typing', () => { + render() + typeQuestion('Sample question') + expect(screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder')).toHaveValue('Sample question') + }) + + test('should capture answer input text when typing', () => { + render() + typeAnswer('Sample answer') + expect(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder')).toHaveValue('Sample answer') + }) + + test('should show annotation full notice and disable submit when quota exceeded', () => { + mockUseProviderContext.mockReturnValue(getProviderContext({ usage: 10, total: 10, enableBilling: true })) + render() + + expect(screen.getByTestId('annotation-full')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.add' })).toBeDisabled() + }) + + test('should call onAdd with form values when create next enabled', async () => { + const onAdd = jest.fn().mockResolvedValue(undefined) + render() + + typeQuestion('Question value') + typeAnswer('Answer value') + fireEvent.click(screen.getByTestId('checkbox-create-next-checkbox')) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' })) + }) + + expect(onAdd).toHaveBeenCalledWith({ question: 'Question value', answer: 'Answer value' }) + }) + + test('should reset fields after saving when create next enabled', async () => { + const onAdd = jest.fn().mockResolvedValue(undefined) + render() + + typeQuestion('Question value') + typeAnswer('Answer value') + const createNextToggle = screen.getByText('appAnnotation.addModal.createNext').previousElementSibling as HTMLElement + fireEvent.click(createNextToggle) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' })) + }) + + await waitFor(() => { + expect(screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder')).toHaveValue('') + expect(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder')).toHaveValue('') + }) + }) + + test('should show toast when validation fails for missing question', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' })) + expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + message: 'appAnnotation.errorMessage.queryRequired', + })) + }) + + test('should show toast when validation fails for missing answer', () => { + render() + typeQuestion('Filled question') + fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' })) + + expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + message: 'appAnnotation.errorMessage.answerRequired', + })) + }) + + test('should close modal when save completes and create next unchecked', async () => { + const onAdd = jest.fn().mockResolvedValue(undefined) + render() + + typeQuestion('Q') + typeAnswer('A') + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' })) + }) + + expect(baseProps.onHide).toHaveBeenCalled() + }) + + test('should allow cancel button to close the drawer', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + expect(baseProps.onHide).toHaveBeenCalled() + }) +}) diff --git a/web/app/components/app/annotation/add-annotation-modal/index.tsx b/web/app/components/app/annotation/add-annotation-modal/index.tsx index 274a57adf1..0ae4439531 100644 --- a/web/app/components/app/annotation/add-annotation-modal/index.tsx +++ b/web/app/components/app/annotation/add-annotation-modal/index.tsx @@ -101,7 +101,7 @@ const AddAnnotationModal: FC = ({
- setIsCreateNext(!isCreateNext)} /> + setIsCreateNext(!isCreateNext)} />
{t('appAnnotation.addModal.createNext')}
From 94a5fd3617b0a61865605018f4dcb1399386a76c Mon Sep 17 00:00:00 2001 From: Joel Date: Wed, 17 Dec 2025 13:36:50 +0800 Subject: [PATCH 2/2] chore: tests for webapp run batch (#29767) --- .../run-batch/csv-download/index.spec.tsx | 49 +++++++++++ .../run-batch/csv-reader/index.spec.tsx | 70 +++++++++++++++ .../text-generation/run-batch/index.spec.tsx | 88 +++++++++++++++++++ 3 files changed, 207 insertions(+) create mode 100644 web/app/components/share/text-generation/run-batch/csv-download/index.spec.tsx create mode 100644 web/app/components/share/text-generation/run-batch/csv-reader/index.spec.tsx create mode 100644 web/app/components/share/text-generation/run-batch/index.spec.tsx diff --git a/web/app/components/share/text-generation/run-batch/csv-download/index.spec.tsx b/web/app/components/share/text-generation/run-batch/csv-download/index.spec.tsx new file mode 100644 index 0000000000..45c8d75b55 --- /dev/null +++ b/web/app/components/share/text-generation/run-batch/csv-download/index.spec.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import CSVDownload from './index' + +const mockType = { Link: 'mock-link' } +let capturedProps: Record | undefined + +jest.mock('react-papaparse', () => ({ + useCSVDownloader: () => { + const CSVDownloader = ({ children, ...props }: React.PropsWithChildren>) => { + capturedProps = props + return
{children}
+ } + return { + CSVDownloader, + Type: mockType, + } + }, +})) + +describe('CSVDownload', () => { + const vars = [{ name: 'prompt' }, { name: 'context' }] + + beforeEach(() => { + capturedProps = undefined + jest.clearAllMocks() + }) + + test('should render table headers and sample row for each variable', () => { + render() + + expect(screen.getByText('share.generation.csvStructureTitle')).toBeInTheDocument() + expect(screen.getAllByRole('row')[0].children).toHaveLength(2) + expect(screen.getByText('prompt share.generation.field')).toBeInTheDocument() + expect(screen.getByText('context share.generation.field')).toBeInTheDocument() + }) + + test('should configure CSV downloader with template data', () => { + render() + + expect(capturedProps?.filename).toBe('template') + expect(capturedProps?.type).toBe(mockType.Link) + expect(capturedProps?.bom).toBe(true) + expect(capturedProps?.data).toEqual([ + { prompt: '', context: '' }, + ]) + expect(screen.getByText('share.generation.downloadTemplate')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/share/text-generation/run-batch/csv-reader/index.spec.tsx b/web/app/components/share/text-generation/run-batch/csv-reader/index.spec.tsx new file mode 100644 index 0000000000..3b854c07a8 --- /dev/null +++ b/web/app/components/share/text-generation/run-batch/csv-reader/index.spec.tsx @@ -0,0 +1,70 @@ +import React from 'react' +import { act, render, screen, waitFor } from '@testing-library/react' +import CSVReader from './index' + +let mockAcceptedFile: { name: string } | null = null +let capturedHandlers: Record void> = {} + +jest.mock('react-papaparse', () => ({ + useCSVReader: () => ({ + CSVReader: ({ children, ...handlers }: any) => { + capturedHandlers = handlers + return ( +
+ {children({ + getRootProps: () => ({ 'data-testid': 'drop-zone' }), + acceptedFile: mockAcceptedFile, + })} +
+ ) + }, + }), +})) + +describe('CSVReader', () => { + beforeEach(() => { + mockAcceptedFile = null + capturedHandlers = {} + jest.clearAllMocks() + }) + + test('should display upload instructions when no file selected', async () => { + const onParsed = jest.fn() + render() + + expect(screen.getByText('share.generation.csvUploadTitle')).toBeInTheDocument() + expect(screen.getByText('share.generation.browse')).toBeInTheDocument() + + await act(async () => { + capturedHandlers.onUploadAccepted?.({ data: [['row1']] }) + }) + expect(onParsed).toHaveBeenCalledWith([['row1']]) + }) + + test('should show accepted file name without extension', () => { + mockAcceptedFile = { name: 'batch.csv' } + render() + + expect(screen.getByText('batch')).toBeInTheDocument() + expect(screen.getByText('.csv')).toBeInTheDocument() + }) + + test('should toggle hover styling on drag events', async () => { + render() + const dragEvent = { preventDefault: jest.fn() } as unknown as DragEvent + + await act(async () => { + capturedHandlers.onDragOver?.(dragEvent) + }) + await waitFor(() => { + expect(screen.getByTestId('drop-zone')).toHaveClass('border-components-dropzone-border-accent') + }) + + await act(async () => { + capturedHandlers.onDragLeave?.(dragEvent) + }) + await waitFor(() => { + expect(screen.getByTestId('drop-zone')).not.toHaveClass('border-components-dropzone-border-accent') + }) + }) +}) diff --git a/web/app/components/share/text-generation/run-batch/index.spec.tsx b/web/app/components/share/text-generation/run-batch/index.spec.tsx new file mode 100644 index 0000000000..26e337c418 --- /dev/null +++ b/web/app/components/share/text-generation/run-batch/index.spec.tsx @@ -0,0 +1,88 @@ +import React from 'react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import RunBatch from './index' +import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' + +jest.mock('@/hooks/use-breakpoints', () => { + const actual = jest.requireActual('@/hooks/use-breakpoints') + return { + __esModule: true, + default: jest.fn(), + MediaType: actual.MediaType, + } +}) + +let latestOnParsed: ((data: string[][]) => void) | undefined +let receivedCSVDownloadProps: Record | undefined + +jest.mock('./csv-reader', () => (props: { onParsed: (data: string[][]) => void }) => { + latestOnParsed = props.onParsed + return
+}) + +jest.mock('./csv-download', () => (props: { vars: { name: string }[] }) => { + receivedCSVDownloadProps = props + return
+}) + +const mockUseBreakpoints = useBreakpoints as jest.Mock + +describe('RunBatch', () => { + const vars = [{ name: 'prompt' }] + + beforeEach(() => { + mockUseBreakpoints.mockReturnValue(MediaType.pc) + latestOnParsed = undefined + receivedCSVDownloadProps = undefined + jest.clearAllMocks() + }) + + test('should enable run button after CSV parsed and send data', async () => { + const onSend = jest.fn() + render( + , + ) + + expect(receivedCSVDownloadProps?.vars).toEqual(vars) + await act(async () => { + latestOnParsed?.([['row1']]) + }) + + const runButton = screen.getByRole('button', { name: 'share.generation.run' }) + await waitFor(() => { + expect(runButton).not.toBeDisabled() + }) + + fireEvent.click(runButton) + expect(onSend).toHaveBeenCalledWith([['row1']]) + }) + + test('should keep button disabled and show spinner when results still running on mobile', async () => { + mockUseBreakpoints.mockReturnValue(MediaType.mobile) + const onSend = jest.fn() + const { container } = render( + , + ) + + await act(async () => { + latestOnParsed?.([['row']]) + }) + + const runButton = screen.getByRole('button', { name: 'share.generation.run' }) + await waitFor(() => { + expect(runButton).toBeDisabled() + }) + expect(runButton).toHaveClass('grow') + const icon = container.querySelector('svg') + expect(icon).toHaveClass('animate-spin') + expect(onSend).not.toHaveBeenCalled() + }) +})