From 232149e63fda9ec4001c60706b5584a2c72a0feb Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 17 Dec 2025 10:19:10 +0800 Subject: [PATCH] chore: add tests for config string and dataset card item (#29743) --- .../config-var/config-string/index.spec.tsx | 121 +++++++++ .../dataset-config/card-item/index.spec.tsx | 242 ++++++++++++++++++ 2 files changed, 363 insertions(+) create mode 100644 web/app/components/app/configuration/config-var/config-string/index.spec.tsx create mode 100644 web/app/components/app/configuration/dataset-config/card-item/index.spec.tsx diff --git a/web/app/components/app/configuration/config-var/config-string/index.spec.tsx b/web/app/components/app/configuration/config-var/config-string/index.spec.tsx new file mode 100644 index 0000000000..e98a8dc53d --- /dev/null +++ b/web/app/components/app/configuration/config-var/config-string/index.spec.tsx @@ -0,0 +1,121 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import ConfigString, { type IConfigStringProps } from './index' + +const renderConfigString = (props?: Partial) => { + const onChange = jest.fn() + const defaultProps: IConfigStringProps = { + value: 5, + maxLength: 10, + modelId: 'model-id', + onChange, + } + + render() + + return { onChange } +} + +describe('ConfigString', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render numeric input with bounds', () => { + renderConfigString({ value: 3, maxLength: 8 }) + + const input = screen.getByRole('spinbutton') + + expect(input).toHaveValue(3) + expect(input).toHaveAttribute('min', '1') + expect(input).toHaveAttribute('max', '8') + }) + + it('should render empty input when value is undefined', () => { + const { onChange } = renderConfigString({ value: undefined }) + + expect(screen.getByRole('spinbutton')).toHaveValue(null) + expect(onChange).not.toHaveBeenCalled() + }) + }) + + describe('Effect behavior', () => { + it('should clamp initial value to maxLength when it exceeds limit', async () => { + const onChange = jest.fn() + render( + , + ) + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith(10) + }) + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it('should clamp when updated prop value exceeds maxLength', async () => { + const onChange = jest.fn() + const { rerender } = render( + , + ) + + rerender( + , + ) + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith(6) + }) + expect(onChange).toHaveBeenCalledTimes(1) + }) + }) + + describe('User interactions', () => { + it('should clamp entered value above maxLength', () => { + const { onChange } = renderConfigString({ maxLength: 7 }) + + fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '12' } }) + + expect(onChange).toHaveBeenCalledWith(7) + }) + + it('should raise value below minimum to one', () => { + const { onChange } = renderConfigString() + + fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '0' } }) + + expect(onChange).toHaveBeenCalledWith(1) + }) + + it('should forward parsed value when within bounds', () => { + const { onChange } = renderConfigString({ maxLength: 9 }) + + fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '7' } }) + + expect(onChange).toHaveBeenCalledWith(7) + }) + + it('should pass through NaN when input is cleared', () => { + const { onChange } = renderConfigString() + + fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '' } }) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange.mock.calls[0][0]).toBeNaN() + }) + }) +}) diff --git a/web/app/components/app/configuration/dataset-config/card-item/index.spec.tsx b/web/app/components/app/configuration/dataset-config/card-item/index.spec.tsx new file mode 100644 index 0000000000..4d92ae4080 --- /dev/null +++ b/web/app/components/app/configuration/dataset-config/card-item/index.spec.tsx @@ -0,0 +1,242 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import Item from './index' +import type React from 'react' +import type { DataSet } from '@/models/datasets' +import { ChunkingMode, DataSourceType, DatasetPermission } from '@/models/datasets' +import type { IndexingType } from '@/app/components/datasets/create/step-two' +import type { RetrievalConfig } from '@/types/app' +import { RETRIEVE_METHOD } from '@/types/app' +import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' + +jest.mock('../settings-modal', () => ({ + __esModule: true, + default: ({ onSave, onCancel, currentDataset }: any) => ( +
+
Mock settings modal
+ + +
+ ), +})) + +jest.mock('@/hooks/use-breakpoints', () => { + const actual = jest.requireActual('@/hooks/use-breakpoints') + return { + __esModule: true, + ...actual, + default: jest.fn(() => actual.MediaType.pc), + } +}) + +const mockedUseBreakpoints = useBreakpoints as jest.MockedFunction + +const baseRetrievalConfig: RetrievalConfig = { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { + reranking_provider_name: 'provider', + reranking_model_name: 'rerank-model', + }, + top_k: 4, + score_threshold_enabled: false, + score_threshold: 0, +} + +const defaultIndexingTechnique: IndexingType = 'high_quality' as IndexingType + +const createDataset = (overrides: Partial = {}): DataSet => { + const { + retrieval_model, + retrieval_model_dict, + icon_info, + ...restOverrides + } = overrides + + const resolvedRetrievalModelDict = { + ...baseRetrievalConfig, + ...retrieval_model_dict, + } + const resolvedRetrievalModel = { + ...baseRetrievalConfig, + ...(retrieval_model ?? retrieval_model_dict), + } + + const defaultIconInfo = { + icon: '๐Ÿ“˜', + icon_type: 'emoji', + icon_background: '#FFEAD5', + icon_url: '', + } + + const resolvedIconInfo = ('icon_info' in overrides) + ? icon_info + : defaultIconInfo + + return { + id: 'dataset-id', + name: 'Dataset Name', + indexing_status: 'completed', + icon_info: resolvedIconInfo as DataSet['icon_info'], + description: 'A test dataset', + permission: DatasetPermission.onlyMe, + data_source_type: DataSourceType.FILE, + indexing_technique: defaultIndexingTechnique, + author_name: 'author', + created_by: 'creator', + updated_by: 'updater', + updated_at: 0, + app_count: 0, + doc_form: ChunkingMode.text, + document_count: 0, + total_document_count: 0, + total_available_documents: 0, + word_count: 0, + provider: 'dify', + embedding_model: 'text-embedding', + embedding_model_provider: 'openai', + embedding_available: true, + retrieval_model_dict: resolvedRetrievalModelDict, + retrieval_model: resolvedRetrievalModel, + tags: [], + external_knowledge_info: { + external_knowledge_id: 'external-id', + external_knowledge_api_id: 'api-id', + external_knowledge_api_name: 'api-name', + external_knowledge_api_endpoint: 'https://endpoint', + }, + external_retrieval_model: { + top_k: 2, + score_threshold: 0.5, + score_threshold_enabled: true, + }, + built_in_field_enabled: true, + doc_metadata: [], + keyword_number: 3, + pipeline_id: 'pipeline-id', + is_published: true, + runtime_mode: 'general', + enable_api: true, + is_multimodal: false, + ...restOverrides, + } +} + +const renderItem = (config: DataSet, props?: Partial>) => { + const onSave = jest.fn() + const onRemove = jest.fn() + + render( + , + ) + + return { onSave, onRemove } +} + +describe('dataset-config/card-item', () => { + beforeEach(() => { + jest.clearAllMocks() + mockedUseBreakpoints.mockReturnValue(MediaType.pc) + }) + + it('should render dataset details with indexing and external badges', () => { + const dataset = createDataset({ + provider: 'external', + retrieval_model_dict: { + ...baseRetrievalConfig, + search_method: RETRIEVE_METHOD.semantic, + }, + }) + + renderItem(dataset) + + const card = screen.getByText(dataset.name).closest('.group') as HTMLElement + const actionButtons = within(card).getAllByRole('button', { hidden: true }) + + expect(screen.getByText(dataset.name)).toBeInTheDocument() + expect(screen.getByText('dataset.indexingTechnique.high_quality ยท dataset.indexingMethod.semantic_search')).toBeInTheDocument() + expect(screen.getByText('dataset.externalTag')).toBeInTheDocument() + expect(actionButtons).toHaveLength(2) + }) + + it('should open settings drawer from edit action and close after saving', async () => { + const user = userEvent.setup() + const dataset = createDataset() + const { onSave } = renderItem(dataset) + + const card = screen.getByText(dataset.name).closest('.group') as HTMLElement + const [editButton] = within(card).getAllByRole('button', { hidden: true }) + await user.click(editButton) + + expect(screen.getByText('Mock settings modal')).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeVisible() + }) + + await user.click(screen.getByText('Save changes')) + + await waitFor(() => { + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Updated dataset' })) + }) + await waitFor(() => { + expect(screen.getByText('Mock settings modal')).not.toBeVisible() + }) + }) + + it('should call onRemove and toggle destructive state on hover', async () => { + const user = userEvent.setup() + const dataset = createDataset() + const { onRemove } = renderItem(dataset) + + const card = screen.getByText(dataset.name).closest('.group') as HTMLElement + const buttons = within(card).getAllByRole('button', { hidden: true }) + const deleteButton = buttons[buttons.length - 1] + + expect(deleteButton.className).not.toContain('action-btn-destructive') + + fireEvent.mouseEnter(deleteButton) + expect(deleteButton.className).toContain('action-btn-destructive') + expect(card.className).toContain('border-state-destructive-border') + + fireEvent.mouseLeave(deleteButton) + expect(deleteButton.className).not.toContain('action-btn-destructive') + + await user.click(deleteButton) + expect(onRemove).toHaveBeenCalledWith(dataset.id) + }) + + it('should use default icon information when icon details are missing', () => { + const dataset = createDataset({ icon_info: undefined }) + + renderItem(dataset) + + const nameElement = screen.getByText(dataset.name) + const iconElement = nameElement.parentElement?.firstElementChild as HTMLElement + + expect(iconElement).toHaveStyle({ background: '#FFF4ED' }) + expect(iconElement.querySelector('em-emoji')).toHaveAttribute('id', '๐Ÿ“™') + }) + + it('should apply mask overlay on mobile when drawer is open', async () => { + mockedUseBreakpoints.mockReturnValue(MediaType.mobile) + const user = userEvent.setup() + const dataset = createDataset() + + renderItem(dataset) + + const card = screen.getByText(dataset.name).closest('.group') as HTMLElement + const [editButton] = within(card).getAllByRole('button', { hidden: true }) + await user.click(editButton) + expect(screen.getByText('Mock settings modal')).toBeInTheDocument() + + const overlay = Array.from(document.querySelectorAll('[class]')) + .find(element => element.className.toString().includes('bg-black/30')) + + expect(overlay).toBeInTheDocument() + }) +})