From 46e0548731be6e60c11f79e3cc1cff309e733848 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Thu, 18 Dec 2025 16:58:55 +0800 Subject: [PATCH] chore: enhance Jest setup and add new tests for dataset creation components (#29825) Co-authored-by: CodingOnStar --- .../index.spec.tsx | 777 ++++++++++ .../components/datasets/create/index.spec.tsx | 1282 +++++++++++++++++ .../step-two/language-select/index.spec.tsx | 596 ++++++++ .../step-two/preview-item/index.spec.tsx | 803 +++++++++++ web/jest.setup.ts | 15 + web/package.json | 1 + web/pnpm-lock.yaml | 22 + 7 files changed, 3496 insertions(+) create mode 100644 web/app/components/datasets/create/empty-dataset-creation-modal/index.spec.tsx create mode 100644 web/app/components/datasets/create/index.spec.tsx create mode 100644 web/app/components/datasets/create/step-two/language-select/index.spec.tsx create mode 100644 web/app/components/datasets/create/step-two/preview-item/index.spec.tsx diff --git a/web/app/components/datasets/create/empty-dataset-creation-modal/index.spec.tsx b/web/app/components/datasets/create/empty-dataset-creation-modal/index.spec.tsx new file mode 100644 index 0000000000..4023948555 --- /dev/null +++ b/web/app/components/datasets/create/empty-dataset-creation-modal/index.spec.tsx @@ -0,0 +1,777 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import React from 'react' +import EmptyDatasetCreationModal from './index' +import { createEmptyDataset } from '@/service/datasets' +import { useInvalidDatasetList } from '@/service/knowledge/use-dataset' + +// Mock Next.js router +const mockPush = jest.fn() +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + }), +})) + +// Mock createEmptyDataset API +jest.mock('@/service/datasets', () => ({ + createEmptyDataset: jest.fn(), +})) + +// Mock useInvalidDatasetList hook +jest.mock('@/service/knowledge/use-dataset', () => ({ + useInvalidDatasetList: jest.fn(), +})) + +// Mock ToastContext - need to mock both createContext and useContext from use-context-selector +const mockNotify = jest.fn() +jest.mock('use-context-selector', () => ({ + createContext: jest.fn(() => ({ + Provider: ({ children }: { children: React.ReactNode }) => children, + })), + useContext: jest.fn(() => ({ notify: mockNotify })), +})) + +// Type cast mocked functions +const mockCreateEmptyDataset = createEmptyDataset as jest.MockedFunction +const mockInvalidDatasetList = jest.fn() +const mockUseInvalidDatasetList = useInvalidDatasetList as jest.MockedFunction + +// Test data builder for props +const createDefaultProps = (overrides?: Partial<{ show: boolean; onHide: () => void }>) => ({ + show: true, + onHide: jest.fn(), + ...overrides, +}) + +describe('EmptyDatasetCreationModal', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseInvalidDatasetList.mockReturnValue(mockInvalidDatasetList) + mockCreateEmptyDataset.mockResolvedValue({ + id: 'dataset-123', + name: 'Test Dataset', + } as ReturnType extends Promise ? T : never) + }) + + // ========================================== + // Rendering Tests - Verify component renders correctly + // ========================================== + describe('Rendering', () => { + it('should render without crashing when show is true', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert - Check modal title is rendered + expect(screen.getByText('datasetCreation.stepOne.modal.title')).toBeInTheDocument() + }) + + it('should render modal with correct elements', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByText('datasetCreation.stepOne.modal.title')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepOne.modal.tip')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepOne.modal.input')).toBeInTheDocument() + expect(screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepOne.modal.confirmButton')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepOne.modal.cancelButton')).toBeInTheDocument() + }) + + it('should render input with empty value initially', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert + const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement + expect(input.value).toBe('') + }) + + it('should not render modal content when show is false', () => { + // Arrange + const props = createDefaultProps({ show: false }) + + // Act + render() + + // Assert - Modal should not be visible (check for absence of title) + expect(screen.queryByText('datasetCreation.stepOne.modal.title')).not.toBeInTheDocument() + }) + }) + + // ========================================== + // Props Testing - Verify all prop variations work correctly + // ========================================== + describe('Props', () => { + describe('show prop', () => { + it('should show modal when show is true', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('datasetCreation.stepOne.modal.title')).toBeInTheDocument() + }) + + it('should hide modal when show is false', () => { + // Arrange & Act + render() + + // Assert + expect(screen.queryByText('datasetCreation.stepOne.modal.title')).not.toBeInTheDocument() + }) + + it('should toggle visibility when show prop changes', () => { + // Arrange + const onHide = jest.fn() + const { rerender } = render() + + // Act & Assert - Initially hidden + expect(screen.queryByText('datasetCreation.stepOne.modal.title')).not.toBeInTheDocument() + + // Act & Assert - Show modal + rerender() + expect(screen.getByText('datasetCreation.stepOne.modal.title')).toBeInTheDocument() + }) + }) + + describe('onHide prop', () => { + it('should call onHide when cancel button is clicked', () => { + // Arrange + const mockOnHide = jest.fn() + render() + + // Act + const cancelButton = screen.getByText('datasetCreation.stepOne.modal.cancelButton') + fireEvent.click(cancelButton) + + // Assert + expect(mockOnHide).toHaveBeenCalledTimes(1) + }) + + it('should call onHide when close icon is clicked', async () => { + // Arrange + const mockOnHide = jest.fn() + render() + + // Act - Wait for modal to be rendered, then find the close span + // The close span is located in the modalHeader div, next to the title + const titleElement = await screen.findByText('datasetCreation.stepOne.modal.title') + const headerDiv = titleElement.parentElement + const closeButton = headerDiv?.querySelector('span') + + expect(closeButton).toBeInTheDocument() + fireEvent.click(closeButton!) + + // Assert + expect(mockOnHide).toHaveBeenCalledTimes(1) + }) + }) + }) + + // ========================================== + // State Management - Test input state updates + // ========================================== + describe('State Management', () => { + it('should update input value when user types', () => { + // Arrange + const props = createDefaultProps() + render() + const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement + + // Act + fireEvent.change(input, { target: { value: 'My Dataset' } }) + + // Assert + expect(input.value).toBe('My Dataset') + }) + + it('should persist input value when modal is hidden and shown again via rerender', () => { + // Arrange + const onHide = jest.fn() + const { rerender } = render() + const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement + + // Act - Type in input + fireEvent.change(input, { target: { value: 'Test Dataset' } }) + expect(input.value).toBe('Test Dataset') + + // Hide and show modal via rerender (component is not unmounted, state persists) + rerender() + rerender() + + // Assert - Input value persists because component state is preserved during rerender + const newInput = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement + expect(newInput.value).toBe('Test Dataset') + }) + + it('should handle consecutive input changes', () => { + // Arrange + const props = createDefaultProps() + render() + const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement + + // Act & Assert + fireEvent.change(input, { target: { value: 'A' } }) + expect(input.value).toBe('A') + + fireEvent.change(input, { target: { value: 'AB' } }) + expect(input.value).toBe('AB') + + fireEvent.change(input, { target: { value: 'ABC' } }) + expect(input.value).toBe('ABC') + }) + }) + + // ========================================== + // User Interactions - Test event handlers + // ========================================== + describe('User Interactions', () => { + it('should submit form when confirm button is clicked with valid input', async () => { + // Arrange + const mockOnHide = jest.fn() + render() + const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') + const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') + + // Act + fireEvent.change(input, { target: { value: 'Valid Dataset Name' } }) + fireEvent.click(confirmButton) + + // Assert + await waitFor(() => { + expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: 'Valid Dataset Name' }) + }) + }) + + it('should show error notification when input is empty', async () => { + // Arrange + const props = createDefaultProps() + render() + const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') + + // Act - Click confirm without entering a name + fireEvent.click(confirmButton) + + // Assert + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'datasetCreation.stepOne.modal.nameNotEmpty', + }) + }) + expect(mockCreateEmptyDataset).not.toHaveBeenCalled() + }) + + it('should show error notification when input exceeds 40 characters', async () => { + // Arrange + const props = createDefaultProps() + render() + const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') + const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') + + // Act - Enter a name longer than 40 characters + const longName = 'A'.repeat(41) + fireEvent.change(input, { target: { value: longName } }) + fireEvent.click(confirmButton) + + // Assert + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'datasetCreation.stepOne.modal.nameLengthInvalid', + }) + }) + expect(mockCreateEmptyDataset).not.toHaveBeenCalled() + }) + + it('should allow exactly 40 characters', async () => { + // Arrange + const mockOnHide = jest.fn() + render() + const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') + const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') + + // Act - Enter exactly 40 characters + const exactLengthName = 'A'.repeat(40) + fireEvent.change(input, { target: { value: exactLengthName } }) + fireEvent.click(confirmButton) + + // Assert + await waitFor(() => { + expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: exactLengthName }) + }) + }) + + it('should close modal on cancel button click', () => { + // Arrange + const mockOnHide = jest.fn() + render() + const cancelButton = screen.getByText('datasetCreation.stepOne.modal.cancelButton') + + // Act + fireEvent.click(cancelButton) + + // Assert + expect(mockOnHide).toHaveBeenCalledTimes(1) + }) + }) + + // ========================================== + // API Calls - Test API interactions + // ========================================== + describe('API Calls', () => { + it('should call createEmptyDataset with correct parameters', async () => { + // Arrange + const mockOnHide = jest.fn() + render() + const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') + const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') + + // Act + fireEvent.change(input, { target: { value: 'New Dataset' } }) + fireEvent.click(confirmButton) + + // Assert + await waitFor(() => { + expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: 'New Dataset' }) + }) + }) + + it('should call invalidDatasetList after successful creation', async () => { + // Arrange + const mockOnHide = jest.fn() + render() + const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') + const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') + + // Act + fireEvent.change(input, { target: { value: 'Test Dataset' } }) + fireEvent.click(confirmButton) + + // Assert + await waitFor(() => { + expect(mockInvalidDatasetList).toHaveBeenCalled() + }) + }) + + it('should call onHide after successful creation', async () => { + // Arrange + const mockOnHide = jest.fn() + render() + const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') + const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') + + // Act + fireEvent.change(input, { target: { value: 'Test Dataset' } }) + fireEvent.click(confirmButton) + + // Assert + await waitFor(() => { + expect(mockOnHide).toHaveBeenCalled() + }) + }) + + it('should show error notification on API failure', async () => { + // Arrange + mockCreateEmptyDataset.mockRejectedValue(new Error('API Error')) + const mockOnHide = jest.fn() + render() + const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') + const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') + + // Act + fireEvent.change(input, { target: { value: 'Test Dataset' } }) + fireEvent.click(confirmButton) + + // Assert + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'datasetCreation.stepOne.modal.failed', + }) + }) + }) + + it('should not call onHide on API failure', async () => { + // Arrange + mockCreateEmptyDataset.mockRejectedValue(new Error('API Error')) + const mockOnHide = jest.fn() + render() + const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') + const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') + + // Act + fireEvent.change(input, { target: { value: 'Test Dataset' } }) + fireEvent.click(confirmButton) + + // Assert - Wait for API call to complete + await waitFor(() => { + expect(mockCreateEmptyDataset).toHaveBeenCalled() + }) + // onHide should not be called on failure + expect(mockOnHide).not.toHaveBeenCalled() + }) + + it('should not invalidate dataset list on API failure', async () => { + // Arrange + mockCreateEmptyDataset.mockRejectedValue(new Error('API Error')) + const props = createDefaultProps() + render() + const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') + const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') + + // Act + fireEvent.change(input, { target: { value: 'Test Dataset' } }) + fireEvent.click(confirmButton) + + // Assert + await waitFor(() => { + expect(mockNotify).toHaveBeenCalled() + }) + expect(mockInvalidDatasetList).not.toHaveBeenCalled() + }) + }) + + // ========================================== + // Router Navigation - Test Next.js router + // ========================================== + describe('Router Navigation', () => { + it('should navigate to dataset documents page after successful creation', async () => { + // Arrange + mockCreateEmptyDataset.mockResolvedValue({ + id: 'test-dataset-456', + name: 'Test', + } as ReturnType extends Promise ? T : never) + const mockOnHide = jest.fn() + render() + const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') + const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') + + // Act + fireEvent.change(input, { target: { value: 'Test' } }) + fireEvent.click(confirmButton) + + // Assert + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/datasets/test-dataset-456/documents') + }) + }) + + it('should not navigate on validation error', async () => { + // Arrange + const props = createDefaultProps() + render() + const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') + + // Act - Click confirm with empty input + fireEvent.click(confirmButton) + + // Assert + await waitFor(() => { + expect(mockNotify).toHaveBeenCalled() + }) + expect(mockPush).not.toHaveBeenCalled() + }) + + it('should not navigate on API error', async () => { + // Arrange + mockCreateEmptyDataset.mockRejectedValue(new Error('API Error')) + const props = createDefaultProps() + render() + const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') + const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') + + // Act + fireEvent.change(input, { target: { value: 'Test' } }) + fireEvent.click(confirmButton) + + // Assert + await waitFor(() => { + expect(mockNotify).toHaveBeenCalled() + }) + expect(mockPush).not.toHaveBeenCalled() + }) + }) + + // ========================================== + // Edge Cases - Test boundary conditions and error handling + // ========================================== + describe('Edge Cases', () => { + it('should handle whitespace-only input as valid (component behavior)', async () => { + // Arrange + const mockOnHide = jest.fn() + render() + const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') + const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') + + // Act - Enter whitespace only + fireEvent.change(input, { target: { value: ' ' } }) + fireEvent.click(confirmButton) + + // Assert - Current implementation treats whitespace as valid input + await waitFor(() => { + expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: ' ' }) + }) + }) + + it('should handle special characters in input', async () => { + // Arrange + const mockOnHide = jest.fn() + render() + const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') + const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') + + // Act + fireEvent.change(input, { target: { value: 'Test @#$% Dataset!' } }) + fireEvent.click(confirmButton) + + // Assert + await waitFor(() => { + expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: 'Test @#$% Dataset!' }) + }) + }) + + it('should handle Unicode characters in input', async () => { + // Arrange + const mockOnHide = jest.fn() + render() + const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') + const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') + + // Act + fireEvent.change(input, { target: { value: '数据集测试 🚀' } }) + fireEvent.click(confirmButton) + + // Assert + await waitFor(() => { + expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: '数据集测试 🚀' }) + }) + }) + + it('should handle input at exactly 40 character boundary', async () => { + // Arrange + const mockOnHide = jest.fn() + render() + const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') + const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') + + // Act - Test boundary: 40 characters is valid + const name40Chars = 'A'.repeat(40) + fireEvent.change(input, { target: { value: name40Chars } }) + fireEvent.click(confirmButton) + + // Assert + await waitFor(() => { + expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: name40Chars }) + }) + }) + + it('should reject input at 41 character boundary', async () => { + // Arrange + const props = createDefaultProps() + render() + const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') + const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') + + // Act - Test boundary: 41 characters is invalid + const name41Chars = 'A'.repeat(41) + fireEvent.change(input, { target: { value: name41Chars } }) + fireEvent.click(confirmButton) + + // Assert + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'datasetCreation.stepOne.modal.nameLengthInvalid', + }) + }) + expect(mockCreateEmptyDataset).not.toHaveBeenCalled() + }) + + it('should handle rapid consecutive submits', async () => { + // Arrange + const mockOnHide = jest.fn() + render() + const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') + const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') + + // Act - Rapid clicks + fireEvent.change(input, { target: { value: 'Test' } }) + fireEvent.click(confirmButton) + fireEvent.click(confirmButton) + fireEvent.click(confirmButton) + + // Assert - API will be called multiple times (no debounce in current implementation) + await waitFor(() => { + expect(mockCreateEmptyDataset).toHaveBeenCalled() + }) + }) + + it('should handle input with leading/trailing spaces', async () => { + // Arrange + const mockOnHide = jest.fn() + render() + const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') + const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') + + // Act + fireEvent.change(input, { target: { value: ' Dataset Name ' } }) + fireEvent.click(confirmButton) + + // Assert - Current implementation does not trim spaces + await waitFor(() => { + expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: ' Dataset Name ' }) + }) + }) + + it('should handle newline characters in input (browser strips newlines)', async () => { + // Arrange + const mockOnHide = jest.fn() + render() + const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') + const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') + + // Act + fireEvent.change(input, { target: { value: 'Line1\nLine2' } }) + fireEvent.click(confirmButton) + + // Assert - HTML input elements strip newline characters (expected browser behavior) + await waitFor(() => { + expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: 'Line1Line2' }) + }) + }) + }) + + // ========================================== + // Validation Tests - Test input validation + // ========================================== + describe('Validation', () => { + it('should not submit when input is empty string', async () => { + // Arrange + const props = createDefaultProps() + render() + const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') + + // Act + fireEvent.click(confirmButton) + + // Assert + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'datasetCreation.stepOne.modal.nameNotEmpty', + }) + }) + }) + + it('should validate length before calling API', async () => { + // Arrange + const props = createDefaultProps() + render() + const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') + const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') + + // Act + fireEvent.change(input, { target: { value: 'A'.repeat(50) } }) + fireEvent.click(confirmButton) + + // Assert - Should show error before API call + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'datasetCreation.stepOne.modal.nameLengthInvalid', + }) + }) + expect(mockCreateEmptyDataset).not.toHaveBeenCalled() + }) + + it('should validate empty string before length check', async () => { + // Arrange + const props = createDefaultProps() + render() + const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') + + // Act - Don't enter anything + fireEvent.click(confirmButton) + + // Assert - Should show empty error, not length error + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'datasetCreation.stepOne.modal.nameNotEmpty', + }) + }) + }) + }) + + // ========================================== + // Integration Tests - Test complete flows + // ========================================== + describe('Integration', () => { + it('should complete full successful creation flow', async () => { + // Arrange + const mockOnHide = jest.fn() + mockCreateEmptyDataset.mockResolvedValue({ + id: 'new-id-789', + name: 'Complete Flow Test', + } as ReturnType extends Promise ? T : never) + render() + const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') + const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') + + // Act + fireEvent.change(input, { target: { value: 'Complete Flow Test' } }) + fireEvent.click(confirmButton) + + // Assert - Verify complete flow + await waitFor(() => { + // 1. API called + expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: 'Complete Flow Test' }) + // 2. Dataset list invalidated + expect(mockInvalidDatasetList).toHaveBeenCalled() + // 3. Modal closed + expect(mockOnHide).toHaveBeenCalled() + // 4. Navigation happened + expect(mockPush).toHaveBeenCalledWith('/datasets/new-id-789/documents') + }) + }) + + it('should handle error flow correctly', async () => { + // Arrange + const mockOnHide = jest.fn() + mockCreateEmptyDataset.mockRejectedValue(new Error('Server Error')) + render() + const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') + const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') + + // Act + fireEvent.change(input, { target: { value: 'Error Test' } }) + fireEvent.click(confirmButton) + + // Assert - Verify error handling + await waitFor(() => { + // 1. API was called + expect(mockCreateEmptyDataset).toHaveBeenCalled() + // 2. Error notification shown + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'datasetCreation.stepOne.modal.failed', + }) + }) + + // 3. These should NOT happen on error + expect(mockInvalidDatasetList).not.toHaveBeenCalled() + expect(mockOnHide).not.toHaveBeenCalled() + expect(mockPush).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/datasets/create/index.spec.tsx b/web/app/components/datasets/create/index.spec.tsx new file mode 100644 index 0000000000..b0bac1a1cb --- /dev/null +++ b/web/app/components/datasets/create/index.spec.tsx @@ -0,0 +1,1282 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import React from 'react' +import DatasetUpdateForm from './index' +import { ChunkingMode, DataSourceType, DatasetPermission } from '@/models/datasets' +import type { DataSet } from '@/models/datasets' +import { DataSourceProvider } from '@/models/common' +import type { DataSourceAuth } from '@/app/components/header/account-setting/data-source-page-new/types' +import { RETRIEVE_METHOD } from '@/types/app' + +// IndexingType values from step-two (defined here since we mock step-two) +// Using type assertion to match the expected IndexingType enum from step-two +const IndexingTypeValues = { + QUALIFIED: 'high_quality' as const, + ECONOMICAL: 'economy' as const, +} + +// ========================================== +// Mock External Dependencies +// ========================================== + +// Mock react-i18next (handled by __mocks__/react-i18next.ts but we override for custom messages) +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +// Mock next/link +jest.mock('next/link', () => { + return function MockLink({ children, href }: { children: React.ReactNode; href: string }) { + return {children} + } +}) + +// Mock modal context +const mockSetShowAccountSettingModal = jest.fn() +jest.mock('@/context/modal-context', () => ({ + useModalContextSelector: (selector: (state: any) => any) => { + const state = { + setShowAccountSettingModal: mockSetShowAccountSettingModal, + } + return selector(state) + }, +})) + +// Mock dataset detail context +let mockDatasetDetail: DataSet | undefined +jest.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: (selector: (state: any) => any) => { + const state = { + dataset: mockDatasetDetail, + } + return selector(state) + }, +})) + +// Mock useDefaultModel hook +let mockEmbeddingsDefaultModel: { model: string; provider: string } | undefined +jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useDefaultModel: () => ({ + data: mockEmbeddingsDefaultModel, + mutate: jest.fn(), + isLoading: false, + }), +})) + +// Mock useGetDefaultDataSourceListAuth hook +let mockDataSourceList: { result: DataSourceAuth[] } | undefined +let mockIsLoadingDataSourceList = false +let mockFetchingError = false +jest.mock('@/service/use-datasource', () => ({ + useGetDefaultDataSourceListAuth: () => ({ + data: mockDataSourceList, + isLoading: mockIsLoadingDataSourceList, + isError: mockFetchingError, + }), +})) + +// ========================================== +// Mock Child Components +// ========================================== + +// Track props passed to child components +let stepOneProps: Record = {} +let stepTwoProps: Record = {} +let stepThreeProps: Record = {} +// _topBarProps is assigned but not directly used in assertions - values checked via data-testid +let _topBarProps: Record = {} + +jest.mock('./step-one', () => ({ + __esModule: true, + default: (props: Record) => { + stepOneProps = props + return ( +
+ {props.dataSourceType} + {props.files?.length || 0} + {props.notionPages?.length || 0} + {props.websitePages?.length || 0} + + + + + + + + + + + +
+ ) + }, +})) + +jest.mock('./step-two', () => ({ + __esModule: true, + default: (props: Record) => { + stepTwoProps = props + return ( +
+ {String(props.isAPIKeySet)} + {props.dataSourceType} + {props.files?.length || 0} + + + + + + +
+ ) + }, +})) + +jest.mock('./step-three', () => ({ + __esModule: true, + default: (props: Record) => { + stepThreeProps = props + return ( +
+ {props.datasetId || 'none'} + {props.datasetName || 'none'} + {props.indexingType || 'none'} + {props.retrievalMethod || 'none'} +
+ ) + }, +})) + +jest.mock('./top-bar', () => ({ + TopBar: (props: Record) => { + _topBarProps = props + return ( +
+ {props.activeIndex} + {props.datasetId || 'none'} +
+ ) + }, +})) + +// ========================================== +// Test Data Builders +// ========================================== + +const createMockDataset = (overrides?: Partial): DataSet => ({ + id: 'dataset-123', + name: 'Test Dataset', + indexing_status: 'completed', + icon_info: { icon: '', icon_background: '', icon_type: 'emoji' as const }, + description: 'Test description', + permission: DatasetPermission.onlyMe, + data_source_type: DataSourceType.FILE, + indexing_technique: IndexingTypeValues.QUALIFIED as any, + created_by: 'user-1', + updated_by: 'user-1', + updated_at: Date.now(), + app_count: 0, + doc_form: ChunkingMode.text, + document_count: 0, + total_document_count: 0, + word_count: 0, + provider: 'openai', + embedding_model: 'text-embedding-ada-002', + embedding_model_provider: 'openai', + embedding_available: true, + retrieval_model_dict: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_mode: undefined, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + weights: undefined, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0, + }, + retrieval_model: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_mode: undefined, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + weights: undefined, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0, + }, + tags: [], + external_knowledge_info: { + external_knowledge_id: '', + external_knowledge_api_id: '', + external_knowledge_api_name: '', + external_knowledge_api_endpoint: '', + }, + external_retrieval_model: { + top_k: 3, + score_threshold: 0.5, + score_threshold_enabled: false, + }, + built_in_field_enabled: false, + runtime_mode: 'general' as const, + enable_api: false, + is_multimodal: false, + ...overrides, +}) + +const createMockDataSourceAuth = (overrides?: Partial): DataSourceAuth => ({ + credential_id: 'cred-1', + provider: 'notion', + plugin_id: 'plugin-1', + ...overrides, +} as DataSourceAuth) + +// ========================================== +// Test Suite +// ========================================== + +describe('DatasetUpdateForm', () => { + beforeEach(() => { + jest.clearAllMocks() + // Reset mock state + mockDatasetDetail = undefined + mockEmbeddingsDefaultModel = { model: 'text-embedding-ada-002', provider: 'openai' } + mockDataSourceList = { result: [createMockDataSourceAuth()] } + mockIsLoadingDataSourceList = false + mockFetchingError = false + // Reset captured props + stepOneProps = {} + stepTwoProps = {} + stepThreeProps = {} + _topBarProps = {} + }) + + // ========================================== + // Rendering Tests - Verify component renders correctly in different states + // ========================================== + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByTestId('top-bar')).toBeInTheDocument() + expect(screen.getByTestId('step-one')).toBeInTheDocument() + }) + + it('should render TopBar with correct active index for step 1', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByTestId('top-bar-active-index')).toHaveTextContent('0') + }) + + it('should render StepOne by default', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByTestId('step-one')).toBeInTheDocument() + expect(screen.queryByTestId('step-two')).not.toBeInTheDocument() + expect(screen.queryByTestId('step-three')).not.toBeInTheDocument() + }) + + it('should show loading state when data source list is loading', () => { + // Arrange + mockIsLoadingDataSourceList = true + + // Act + render() + + // Assert - Loading component should be rendered (not the steps) + expect(screen.queryByTestId('step-one')).not.toBeInTheDocument() + }) + + it('should show error state when fetching fails', () => { + // Arrange + mockFetchingError = true + + // Act + render() + + // Assert + expect(screen.getByText('datasetCreation.error.unavailable')).toBeInTheDocument() + }) + }) + + // ========================================== + // Props Testing - Verify datasetId prop behavior + // ========================================== + describe('Props', () => { + describe('datasetId prop', () => { + it('should pass datasetId to TopBar', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByTestId('top-bar-dataset-id')).toHaveTextContent('dataset-abc') + }) + + it('should pass datasetId to StepOne', () => { + // Arrange & Act + render() + + // Assert + expect(stepOneProps.datasetId).toBe('dataset-abc') + }) + + it('should render without datasetId', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByTestId('top-bar-dataset-id')).toHaveTextContent('none') + expect(stepOneProps.datasetId).toBeUndefined() + }) + }) + }) + + // ========================================== + // State Management - Test state initialization and transitions + // ========================================== + describe('State Management', () => { + describe('dataSourceType state', () => { + it('should initialize with FILE data source type', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByTestId('step-one-data-source-type')).toHaveTextContent(DataSourceType.FILE) + }) + + it('should update dataSourceType when changeType is called', () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('step-one-change-type')) + + // Assert + expect(screen.getByTestId('step-one-data-source-type')).toHaveTextContent(DataSourceType.NOTION) + }) + }) + + describe('step state', () => { + it('should initialize at step 1', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByTestId('step-one')).toBeInTheDocument() + expect(screen.getByTestId('top-bar-active-index')).toHaveTextContent('0') + }) + + it('should transition to step 2 when nextStep is called', () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('step-one-next')) + + // Assert + expect(screen.queryByTestId('step-one')).not.toBeInTheDocument() + expect(screen.getByTestId('step-two')).toBeInTheDocument() + expect(screen.getByTestId('top-bar-active-index')).toHaveTextContent('1') + }) + + it('should transition to step 3 from step 2', () => { + // Arrange + render() + + // First go to step 2 + fireEvent.click(screen.getByTestId('step-one-next')) + + // Act - go to step 3 + fireEvent.click(screen.getByTestId('step-two-next')) + + // Assert + expect(screen.queryByTestId('step-two')).not.toBeInTheDocument() + expect(screen.getByTestId('step-three')).toBeInTheDocument() + expect(screen.getByTestId('top-bar-active-index')).toHaveTextContent('2') + }) + + it('should go back to step 1 from step 2', () => { + // Arrange + render() + fireEvent.click(screen.getByTestId('step-one-next')) + + // Act + fireEvent.click(screen.getByTestId('step-two-prev')) + + // Assert + expect(screen.getByTestId('step-one')).toBeInTheDocument() + expect(screen.queryByTestId('step-two')).not.toBeInTheDocument() + }) + }) + + describe('fileList state', () => { + it('should initialize with empty file list', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByTestId('step-one-files-count')).toHaveTextContent('0') + }) + + it('should update file list when updateFileList is called', () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('step-one-update-files')) + + // Assert + expect(screen.getByTestId('step-one-files-count')).toHaveTextContent('1') + }) + }) + + describe('notionPages state', () => { + it('should initialize with empty notion pages', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByTestId('step-one-notion-pages-count')).toHaveTextContent('0') + }) + + it('should update notion pages when updateNotionPages is called', () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('step-one-update-notion-pages')) + + // Assert + expect(screen.getByTestId('step-one-notion-pages-count')).toHaveTextContent('1') + }) + }) + + describe('websitePages state', () => { + it('should initialize with empty website pages', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByTestId('step-one-website-pages-count')).toHaveTextContent('0') + }) + + it('should update website pages when setWebsitePages is called', () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('step-one-update-website-pages')) + + // Assert + expect(screen.getByTestId('step-one-website-pages-count')).toHaveTextContent('1') + }) + }) + }) + + // ========================================== + // Callback Stability - Test memoization of callbacks + // ========================================== + describe('Callback Stability and Memoization', () => { + it('should provide stable updateNotionPages callback reference', () => { + // Arrange + const { rerender } = render() + const initialCallback = stepOneProps.updateNotionPages + + // Act - trigger a rerender + rerender() + + // Assert - callback reference should be the same due to useCallback + expect(stepOneProps.updateNotionPages).toBe(initialCallback) + }) + + it('should provide stable updateNotionCredentialId callback reference', () => { + // Arrange + const { rerender } = render() + const initialCallback = stepOneProps.updateNotionCredentialId + + // Act + rerender() + + // Assert + expect(stepOneProps.updateNotionCredentialId).toBe(initialCallback) + }) + + it('should provide stable updateFileList callback reference', () => { + // Arrange + const { rerender } = render() + const initialCallback = stepOneProps.updateFileList + + // Act + rerender() + + // Assert + expect(stepOneProps.updateFileList).toBe(initialCallback) + }) + + it('should provide stable updateFile callback reference', () => { + // Arrange + const { rerender } = render() + const initialCallback = stepOneProps.updateFile + + // Act + rerender() + + // Assert + expect(stepOneProps.updateFile).toBe(initialCallback) + }) + + it('should provide stable updateIndexingTypeCache callback reference', () => { + // Arrange + const { rerender } = render() + fireEvent.click(screen.getByTestId('step-one-next')) + const initialCallback = stepTwoProps.updateIndexingTypeCache + + // Act - trigger a rerender without changing step + rerender() + + // Assert - callbacks with same dependencies should be stable + expect(stepTwoProps.updateIndexingTypeCache).toBe(initialCallback) + }) + }) + + // ========================================== + // User Interactions - Test event handlers + // ========================================== + describe('User Interactions', () => { + it('should open account settings when onSetting is called from StepOne', () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('step-one-setting')) + + // Assert + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'data-source' }) + }) + + it('should open provider settings when onSetting is called from StepTwo', () => { + // Arrange + render() + fireEvent.click(screen.getByTestId('step-one-next')) + + // Act + fireEvent.click(screen.getByTestId('step-two-setting')) + + // Assert + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'provider' }) + }) + + it('should update crawl options when onCrawlOptionsChange is called', () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('step-one-update-crawl-options')) + + // Assert + expect(stepOneProps.crawlOptions.limit).toBe(20) + }) + + it('should update crawl provider when onWebsiteCrawlProviderChange is called', () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('step-one-update-crawl-provider')) + + // Assert - Need to verify state through StepTwo props + fireEvent.click(screen.getByTestId('step-one-next')) + expect(stepTwoProps.websiteCrawlProvider).toBe(DataSourceProvider.fireCrawl) + }) + + it('should update job id when onWebsiteCrawlJobIdChange is called', () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('step-one-update-job-id')) + + // Assert - Verify through StepTwo props + fireEvent.click(screen.getByTestId('step-one-next')) + expect(stepTwoProps.websiteCrawlJobId).toBe('job-123') + }) + + it('should update file progress correctly using immer produce', () => { + // Arrange + render() + fireEvent.click(screen.getByTestId('step-one-update-files')) + + // Act + fireEvent.click(screen.getByTestId('step-one-update-file-progress')) + + // Assert - Progress should be updated + expect(stepOneProps.files[0].progress).toBe(50) + }) + + it('should update notion credential id', () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('step-one-update-notion-credential')) + + // Assert + expect(stepOneProps.notionCredentialId).toBe('credential-123') + }) + }) + + // ========================================== + // Step Two Specific Tests + // ========================================== + describe('StepTwo Rendering and Props', () => { + it('should pass isAPIKeySet as true when embeddingsDefaultModel exists', () => { + // Arrange + mockEmbeddingsDefaultModel = { model: 'model-1', provider: 'openai' } + render() + + // Act + fireEvent.click(screen.getByTestId('step-one-next')) + + // Assert + expect(screen.getByTestId('step-two-is-api-key-set')).toHaveTextContent('true') + }) + + it('should pass isAPIKeySet as false when embeddingsDefaultModel is undefined', () => { + // Arrange + mockEmbeddingsDefaultModel = undefined + render() + + // Act + fireEvent.click(screen.getByTestId('step-one-next')) + + // Assert + expect(screen.getByTestId('step-two-is-api-key-set')).toHaveTextContent('false') + }) + + it('should pass correct dataSourceType to StepTwo', () => { + // Arrange + render() + fireEvent.click(screen.getByTestId('step-one-change-type')) + + // Act + fireEvent.click(screen.getByTestId('step-one-next')) + + // Assert + expect(screen.getByTestId('step-two-data-source-type')).toHaveTextContent(DataSourceType.NOTION) + }) + + it('should pass files mapped to file property to StepTwo', () => { + // Arrange + render() + fireEvent.click(screen.getByTestId('step-one-update-files')) + + // Act + fireEvent.click(screen.getByTestId('step-one-next')) + + // Assert + expect(screen.getByTestId('step-two-files-count')).toHaveTextContent('1') + }) + + it('should update indexing type cache from StepTwo', () => { + // Arrange + render() + fireEvent.click(screen.getByTestId('step-one-next')) + + // Act + fireEvent.click(screen.getByTestId('step-two-update-indexing-cache')) + + // Assert - Go to step 3 and verify + fireEvent.click(screen.getByTestId('step-two-next')) + expect(screen.getByTestId('step-three-indexing-type')).toHaveTextContent('high_quality') + }) + + it('should update retrieval method cache from StepTwo', () => { + // Arrange + render() + fireEvent.click(screen.getByTestId('step-one-next')) + + // Act + fireEvent.click(screen.getByTestId('step-two-update-retrieval-cache')) + + // Assert - Go to step 3 and verify + fireEvent.click(screen.getByTestId('step-two-next')) + expect(screen.getByTestId('step-three-retrieval-method')).toHaveTextContent('semantic_search') + }) + + it('should update result cache from StepTwo', () => { + // Arrange + render() + fireEvent.click(screen.getByTestId('step-one-next')) + + // Act + fireEvent.click(screen.getByTestId('step-two-update-result-cache')) + + // Assert - Go to step 3 and verify creationCache is passed + fireEvent.click(screen.getByTestId('step-two-next')) + expect(stepThreeProps.creationCache).toBeDefined() + expect(stepThreeProps.creationCache?.batch).toBe('batch-1') + }) + }) + + // ========================================== + // Step Two with datasetId and datasetDetail + // ========================================== + describe('StepTwo with existing dataset', () => { + it('should not render StepTwo when datasetId exists but datasetDetail is undefined', () => { + // Arrange + mockDatasetDetail = undefined + render() + + // Act + fireEvent.click(screen.getByTestId('step-one-next')) + + // Assert - StepTwo should not render due to condition + expect(screen.queryByTestId('step-two')).not.toBeInTheDocument() + }) + + it('should render StepTwo when datasetId exists and datasetDetail is defined', () => { + // Arrange + mockDatasetDetail = createMockDataset() + render() + + // Act + fireEvent.click(screen.getByTestId('step-one-next')) + + // Assert + expect(screen.getByTestId('step-two')).toBeInTheDocument() + }) + + it('should pass indexingType from datasetDetail to StepTwo', () => { + // Arrange + mockDatasetDetail = createMockDataset({ indexing_technique: IndexingTypeValues.ECONOMICAL as any }) + render() + + // Act + fireEvent.click(screen.getByTestId('step-one-next')) + + // Assert + expect(stepTwoProps.indexingType).toBe('economy') + }) + }) + + // ========================================== + // Step Three Tests + // ========================================== + describe('StepThree Rendering and Props', () => { + it('should pass datasetId to StepThree', () => { + // Arrange - Need datasetDetail for StepTwo to render when datasetId exists + mockDatasetDetail = createMockDataset() + render() + + // Act - Navigate to step 3 + fireEvent.click(screen.getByTestId('step-one-next')) + fireEvent.click(screen.getByTestId('step-two-next')) + + // Assert + expect(screen.getByTestId('step-three-dataset-id')).toHaveTextContent('dataset-456') + }) + + it('should pass datasetName from datasetDetail to StepThree', () => { + // Arrange + mockDatasetDetail = createMockDataset({ name: 'My Special Dataset' }) + render() + + // Act + fireEvent.click(screen.getByTestId('step-one-next')) + fireEvent.click(screen.getByTestId('step-two-next')) + + // Assert + expect(screen.getByTestId('step-three-dataset-name')).toHaveTextContent('My Special Dataset') + }) + + it('should use cached indexing type when datasetDetail indexing_technique is not available', () => { + // Arrange + render() + + // Navigate to step 2 and set cache + fireEvent.click(screen.getByTestId('step-one-next')) + fireEvent.click(screen.getByTestId('step-two-update-indexing-cache')) + + // Act - Navigate to step 3 + fireEvent.click(screen.getByTestId('step-two-next')) + + // Assert + expect(screen.getByTestId('step-three-indexing-type')).toHaveTextContent('high_quality') + }) + + it('should use datasetDetail indexing_technique over cached value', () => { + // Arrange + mockDatasetDetail = createMockDataset({ indexing_technique: IndexingTypeValues.ECONOMICAL as any }) + render() + + // Navigate to step 2 and set different cache + fireEvent.click(screen.getByTestId('step-one-next')) + fireEvent.click(screen.getByTestId('step-two-update-indexing-cache')) + + // Act - Navigate to step 3 + fireEvent.click(screen.getByTestId('step-two-next')) + + // Assert - Should use datasetDetail value, not cache + expect(screen.getByTestId('step-three-indexing-type')).toHaveTextContent('economy') + }) + + it('should use retrieval method from datasetDetail when available', () => { + // Arrange + mockDatasetDetail = createMockDataset() + mockDatasetDetail.retrieval_model_dict = { + ...mockDatasetDetail.retrieval_model_dict, + search_method: RETRIEVE_METHOD.fullText, + } + render() + + // Act + fireEvent.click(screen.getByTestId('step-one-next')) + fireEvent.click(screen.getByTestId('step-two-next')) + + // Assert + expect(screen.getByTestId('step-three-retrieval-method')).toHaveTextContent('full_text_search') + }) + }) + + // ========================================== + // StepOne Props Tests + // ========================================== + describe('StepOne Props', () => { + it('should pass authedDataSourceList from hook response', () => { + // Arrange + const mockAuth = createMockDataSourceAuth({ provider: 'google-drive' }) + mockDataSourceList = { result: [mockAuth] } + + // Act + render() + + // Assert + expect(stepOneProps.authedDataSourceList).toEqual([mockAuth]) + }) + + it('should pass empty array when dataSourceList is undefined', () => { + // Arrange + mockDataSourceList = undefined + + // Act + render() + + // Assert + expect(stepOneProps.authedDataSourceList).toEqual([]) + }) + + it('should pass dataSourceTypeDisable as true when datasetDetail has data_source_type', () => { + // Arrange + mockDatasetDetail = createMockDataset({ data_source_type: DataSourceType.FILE }) + + // Act + render() + + // Assert + expect(stepOneProps.dataSourceTypeDisable).toBe(true) + }) + + it('should pass dataSourceTypeDisable as false when datasetDetail is undefined', () => { + // Arrange + mockDatasetDetail = undefined + + // Act + render() + + // Assert + expect(stepOneProps.dataSourceTypeDisable).toBe(false) + }) + + it('should pass default crawl options', () => { + // Arrange & Act + render() + + // Assert + expect(stepOneProps.crawlOptions).toEqual({ + crawl_sub_pages: true, + only_main_content: true, + includes: '', + excludes: '', + limit: 10, + max_depth: '', + use_sitemap: true, + }) + }) + }) + + // ========================================== + // Edge Cases - Test boundary conditions and error handling + // ========================================== + describe('Edge Cases', () => { + it('should handle empty data source list', () => { + // Arrange + mockDataSourceList = { result: [] } + + // Act + render() + + // Assert + expect(stepOneProps.authedDataSourceList).toEqual([]) + }) + + it('should handle undefined datasetDetail retrieval_model_dict', () => { + // Arrange + mockDatasetDetail = createMockDataset() + // @ts-expect-error - Testing undefined case + mockDatasetDetail.retrieval_model_dict = undefined + render() + + // Act + fireEvent.click(screen.getByTestId('step-one-next')) + fireEvent.click(screen.getByTestId('step-two-update-retrieval-cache')) + fireEvent.click(screen.getByTestId('step-two-next')) + + // Assert - Should use cached value + expect(screen.getByTestId('step-three-retrieval-method')).toHaveTextContent('semantic_search') + }) + + it('should handle step state correctly after multiple navigations', () => { + // Arrange + render() + + // Act - Navigate forward and back multiple times + fireEvent.click(screen.getByTestId('step-one-next')) // to step 2 + fireEvent.click(screen.getByTestId('step-two-prev')) // back to step 1 + fireEvent.click(screen.getByTestId('step-one-next')) // to step 2 + fireEvent.click(screen.getByTestId('step-two-next')) // to step 3 + + // Assert + expect(screen.getByTestId('step-three')).toBeInTheDocument() + expect(screen.getByTestId('top-bar-active-index')).toHaveTextContent('2') + }) + + it('should handle result cache being undefined', () => { + // Arrange + render() + + // Act - Navigate to step 3 without setting result cache + fireEvent.click(screen.getByTestId('step-one-next')) + fireEvent.click(screen.getByTestId('step-two-next')) + + // Assert + expect(stepThreeProps.creationCache).toBeUndefined() + }) + + it('should pass result cache to step three', async () => { + // Arrange + render() + fireEvent.click(screen.getByTestId('step-one-next')) + + // Set result cache value + fireEvent.click(screen.getByTestId('step-two-update-result-cache')) + + // Navigate to step 3 + fireEvent.click(screen.getByTestId('step-two-next')) + + // Assert - Result cache is correctly passed to step three + expect(stepThreeProps.creationCache).toBeDefined() + expect(stepThreeProps.creationCache?.batch).toBe('batch-1') + }) + + it('should preserve state when navigating between steps', () => { + // Arrange + render() + + // Set up various states + fireEvent.click(screen.getByTestId('step-one-change-type')) + fireEvent.click(screen.getByTestId('step-one-update-files')) + fireEvent.click(screen.getByTestId('step-one-update-notion-pages')) + + // Navigate to step 2 and back + fireEvent.click(screen.getByTestId('step-one-next')) + fireEvent.click(screen.getByTestId('step-two-prev')) + + // Assert - All state should be preserved + expect(screen.getByTestId('step-one-data-source-type')).toHaveTextContent(DataSourceType.NOTION) + expect(screen.getByTestId('step-one-files-count')).toHaveTextContent('1') + expect(screen.getByTestId('step-one-notion-pages-count')).toHaveTextContent('1') + }) + }) + + // ========================================== + // Integration Tests - Test complete flows + // ========================================== + describe('Integration', () => { + it('should complete full flow from step 1 to step 3 with all state updates', () => { + // Arrange + render() + + // Step 1: Set up data + fireEvent.click(screen.getByTestId('step-one-update-files')) + fireEvent.click(screen.getByTestId('step-one-next')) + + // Step 2: Set caches + fireEvent.click(screen.getByTestId('step-two-update-indexing-cache')) + fireEvent.click(screen.getByTestId('step-two-update-retrieval-cache')) + fireEvent.click(screen.getByTestId('step-two-update-result-cache')) + fireEvent.click(screen.getByTestId('step-two-next')) + + // Assert - All data flows through to Step 3 + expect(screen.getByTestId('step-three-indexing-type')).toHaveTextContent('high_quality') + expect(screen.getByTestId('step-three-retrieval-method')).toHaveTextContent('semantic_search') + expect(stepThreeProps.creationCache?.batch).toBe('batch-1') + }) + + it('should handle complete website crawl workflow', () => { + // Arrange + render() + + // Set website data source through button click + fireEvent.click(screen.getByTestId('step-one-update-website-pages')) + fireEvent.click(screen.getByTestId('step-one-update-crawl-options')) + fireEvent.click(screen.getByTestId('step-one-update-crawl-provider')) + fireEvent.click(screen.getByTestId('step-one-update-job-id')) + + // Navigate to step 2 + fireEvent.click(screen.getByTestId('step-one-next')) + + // Assert - All website data passed to StepTwo + expect(stepTwoProps.websitePages.length).toBe(1) + expect(stepTwoProps.websiteCrawlProvider).toBe(DataSourceProvider.fireCrawl) + expect(stepTwoProps.websiteCrawlJobId).toBe('job-123') + expect(stepTwoProps.crawlOptions.limit).toBe(20) + }) + + it('should handle complete notion workflow', () => { + // Arrange + render() + + // Set notion data source + fireEvent.click(screen.getByTestId('step-one-change-type')) + fireEvent.click(screen.getByTestId('step-one-update-notion-pages')) + fireEvent.click(screen.getByTestId('step-one-update-notion-credential')) + + // Navigate to step 2 + fireEvent.click(screen.getByTestId('step-one-next')) + + // Assert + expect(stepTwoProps.notionPages.length).toBe(1) + expect(stepTwoProps.notionCredentialId).toBe('credential-123') + }) + + it('should handle edit mode with existing dataset', () => { + // Arrange + mockDatasetDetail = createMockDataset({ + name: 'Existing Dataset', + indexing_technique: IndexingTypeValues.QUALIFIED as any, + data_source_type: DataSourceType.NOTION, + }) + render() + + // Assert - Step 1 should have disabled data source type + expect(stepOneProps.dataSourceTypeDisable).toBe(true) + + // Navigate through + fireEvent.click(screen.getByTestId('step-one-next')) + + // Assert - Step 2 should receive dataset info + expect(stepTwoProps.indexingType).toBe('high_quality') + expect(stepTwoProps.datasetId).toBe('dataset-123') + + // Navigate to Step 3 + fireEvent.click(screen.getByTestId('step-two-next')) + + // Assert - Step 3 should show dataset details + expect(screen.getByTestId('step-three-dataset-name')).toHaveTextContent('Existing Dataset') + expect(screen.getByTestId('step-three-indexing-type')).toHaveTextContent('high_quality') + }) + }) + + // ========================================== + // Default Crawl Options Tests + // ========================================== + describe('Default Crawl Options', () => { + it('should have correct default crawl options structure', () => { + // Arrange & Act + render() + + // Assert + const crawlOptions = stepOneProps.crawlOptions + expect(crawlOptions).toMatchObject({ + crawl_sub_pages: true, + only_main_content: true, + includes: '', + excludes: '', + limit: 10, + max_depth: '', + use_sitemap: true, + }) + }) + + it('should preserve crawl options when navigating steps', () => { + // Arrange + render() + + // Update crawl options + fireEvent.click(screen.getByTestId('step-one-update-crawl-options')) + + // Navigate to step 2 and back + fireEvent.click(screen.getByTestId('step-one-next')) + fireEvent.click(screen.getByTestId('step-two-prev')) + + // Assert + expect(stepOneProps.crawlOptions.limit).toBe(20) + }) + }) + + // ========================================== + // Error State Tests + // ========================================== + describe('Error States', () => { + it('should display error message when fetching data source list fails', () => { + // Arrange + mockFetchingError = true + + // Act + render() + + // Assert + const errorElement = screen.getByText('datasetCreation.error.unavailable') + expect(errorElement).toBeInTheDocument() + }) + + it('should not render steps when in error state', () => { + // Arrange + mockFetchingError = true + + // Act + render() + + // Assert + expect(screen.queryByTestId('step-one')).not.toBeInTheDocument() + expect(screen.queryByTestId('step-two')).not.toBeInTheDocument() + expect(screen.queryByTestId('step-three')).not.toBeInTheDocument() + }) + + it('should render error page with 500 code when in error state', () => { + // Arrange + mockFetchingError = true + + // Act + render() + + // Assert - Error state renders AppUnavailable, not the normal layout + expect(screen.getByText('500')).toBeInTheDocument() + expect(screen.queryByTestId('top-bar')).not.toBeInTheDocument() + }) + }) + + // ========================================== + // Loading State Tests + // ========================================== + describe('Loading States', () => { + it('should not render steps while loading', () => { + // Arrange + mockIsLoadingDataSourceList = true + + // Act + render() + + // Assert + expect(screen.queryByTestId('step-one')).not.toBeInTheDocument() + }) + + it('should render TopBar while loading', () => { + // Arrange + mockIsLoadingDataSourceList = true + + // Act + render() + + // Assert + expect(screen.getByTestId('top-bar')).toBeInTheDocument() + }) + + it('should render StepOne after loading completes', async () => { + // Arrange + mockIsLoadingDataSourceList = true + const { rerender } = render() + + // Assert - Initially not rendered + expect(screen.queryByTestId('step-one')).not.toBeInTheDocument() + + // Act - Loading completes + mockIsLoadingDataSourceList = false + rerender() + + // Assert - Now rendered + await waitFor(() => { + expect(screen.getByTestId('step-one')).toBeInTheDocument() + }) + }) + }) +}) diff --git a/web/app/components/datasets/create/step-two/language-select/index.spec.tsx b/web/app/components/datasets/create/step-two/language-select/index.spec.tsx new file mode 100644 index 0000000000..ad9611668d --- /dev/null +++ b/web/app/components/datasets/create/step-two/language-select/index.spec.tsx @@ -0,0 +1,596 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import React from 'react' +import LanguageSelect from './index' +import type { ILanguageSelectProps } from './index' +import { languages } from '@/i18n-config/language' + +// Get supported languages for test assertions +const supportedLanguages = languages.filter(lang => lang.supported) + +// Test data builder for props +const createDefaultProps = (overrides?: Partial): ILanguageSelectProps => ({ + currentLanguage: 'English', + onSelect: jest.fn(), + disabled: false, + ...overrides, +}) + +describe('LanguageSelect', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + // ========================================== + // Rendering Tests - Verify component renders correctly + // ========================================== + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByText('English')).toBeInTheDocument() + }) + + it('should render current language text', () => { + // Arrange + const props = createDefaultProps({ currentLanguage: 'Chinese Simplified' }) + + // Act + render() + + // Assert + expect(screen.getByText('Chinese Simplified')).toBeInTheDocument() + }) + + it('should render dropdown arrow icon', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render() + + // Assert - RiArrowDownSLine renders as SVG + const svgIcon = container.querySelector('svg') + expect(svgIcon).toBeInTheDocument() + }) + + it('should render all supported languages in dropdown when opened', () => { + // Arrange + const props = createDefaultProps() + render() + + // Act - Click button to open dropdown + const button = screen.getByRole('button') + fireEvent.click(button) + + // Assert - All supported languages should be visible + // Use getAllByText because current language appears both in button and dropdown + supportedLanguages.forEach((lang) => { + expect(screen.getAllByText(lang.prompt_name).length).toBeGreaterThanOrEqual(1) + }) + }) + + it('should render check icon for selected language', () => { + // Arrange + const selectedLanguage = 'Japanese' + const props = createDefaultProps({ currentLanguage: selectedLanguage }) + render() + + // Act + const button = screen.getByRole('button') + fireEvent.click(button) + + // Assert - The selected language option should have a check icon + const languageOptions = screen.getAllByText(selectedLanguage) + // One in the button, one in the dropdown list + expect(languageOptions.length).toBeGreaterThanOrEqual(1) + }) + }) + + // ========================================== + // Props Testing - Verify all prop variations work correctly + // ========================================== + describe('Props', () => { + describe('currentLanguage prop', () => { + it('should display English when currentLanguage is English', () => { + const props = createDefaultProps({ currentLanguage: 'English' }) + render() + expect(screen.getByText('English')).toBeInTheDocument() + }) + + it('should display Chinese Simplified when currentLanguage is Chinese Simplified', () => { + const props = createDefaultProps({ currentLanguage: 'Chinese Simplified' }) + render() + expect(screen.getByText('Chinese Simplified')).toBeInTheDocument() + }) + + it('should display Japanese when currentLanguage is Japanese', () => { + const props = createDefaultProps({ currentLanguage: 'Japanese' }) + render() + expect(screen.getByText('Japanese')).toBeInTheDocument() + }) + + it.each(supportedLanguages.map(l => l.prompt_name))( + 'should display %s as current language', + (language) => { + const props = createDefaultProps({ currentLanguage: language }) + render() + expect(screen.getByText(language)).toBeInTheDocument() + }, + ) + }) + + describe('disabled prop', () => { + it('should have disabled button when disabled is true', () => { + // Arrange + const props = createDefaultProps({ disabled: true }) + + // Act + render() + + // Assert + const button = screen.getByRole('button') + expect(button).toBeDisabled() + }) + + it('should have enabled button when disabled is false', () => { + // Arrange + const props = createDefaultProps({ disabled: false }) + + // Act + render() + + // Assert + const button = screen.getByRole('button') + expect(button).not.toBeDisabled() + }) + + it('should have enabled button when disabled is undefined', () => { + // Arrange + const props = createDefaultProps() + delete (props as Partial).disabled + + // Act + render() + + // Assert + const button = screen.getByRole('button') + expect(button).not.toBeDisabled() + }) + + it('should apply disabled styling when disabled is true', () => { + // Arrange + const props = createDefaultProps({ disabled: true }) + + // Act + const { container } = render() + + // Assert - Check for disabled class on text elements + const disabledTextElement = container.querySelector('.text-components-button-tertiary-text-disabled') + expect(disabledTextElement).toBeInTheDocument() + }) + + it('should apply cursor-not-allowed styling when disabled', () => { + // Arrange + const props = createDefaultProps({ disabled: true }) + + // Act + const { container } = render() + + // Assert + const elementWithCursor = container.querySelector('.cursor-not-allowed') + expect(elementWithCursor).toBeInTheDocument() + }) + }) + + describe('onSelect prop', () => { + it('should be callable as a function', () => { + const mockOnSelect = jest.fn() + const props = createDefaultProps({ onSelect: mockOnSelect }) + render() + + // Open dropdown and click a language + const button = screen.getByRole('button') + fireEvent.click(button) + + const germanOption = screen.getByText('German') + fireEvent.click(germanOption) + + expect(mockOnSelect).toHaveBeenCalledWith('German') + }) + }) + }) + + // ========================================== + // User Interactions - Test event handlers + // ========================================== + describe('User Interactions', () => { + it('should open dropdown when button is clicked', () => { + // Arrange + const props = createDefaultProps() + render() + + // Act + const button = screen.getByRole('button') + fireEvent.click(button) + + // Assert - Check if dropdown content is visible + expect(screen.getAllByText('English').length).toBeGreaterThanOrEqual(1) + }) + + it('should call onSelect when a language option is clicked', () => { + // Arrange + const mockOnSelect = jest.fn() + const props = createDefaultProps({ onSelect: mockOnSelect }) + render() + + // Act + const button = screen.getByRole('button') + fireEvent.click(button) + const frenchOption = screen.getByText('French') + fireEvent.click(frenchOption) + + // Assert + expect(mockOnSelect).toHaveBeenCalledTimes(1) + expect(mockOnSelect).toHaveBeenCalledWith('French') + }) + + it('should call onSelect with correct language when selecting different languages', () => { + // Arrange + const mockOnSelect = jest.fn() + const props = createDefaultProps({ onSelect: mockOnSelect }) + render() + + // Act & Assert - Test multiple language selections + const testLanguages = ['Korean', 'Spanish', 'Italian'] + + testLanguages.forEach((lang) => { + mockOnSelect.mockClear() + const button = screen.getByRole('button') + fireEvent.click(button) + const languageOption = screen.getByText(lang) + fireEvent.click(languageOption) + expect(mockOnSelect).toHaveBeenCalledWith(lang) + }) + }) + + it('should not open dropdown when disabled', () => { + // Arrange + const props = createDefaultProps({ disabled: true }) + render() + + // Act + const button = screen.getByRole('button') + fireEvent.click(button) + + // Assert - Dropdown should not open, only one instance of the current language should exist + const englishElements = screen.getAllByText('English') + expect(englishElements.length).toBe(1) // Only the button text, not dropdown + }) + + it('should not call onSelect when component is disabled', () => { + // Arrange + const mockOnSelect = jest.fn() + const props = createDefaultProps({ onSelect: mockOnSelect, disabled: true }) + render() + + // Act + const button = screen.getByRole('button') + fireEvent.click(button) + + // Assert + expect(mockOnSelect).not.toHaveBeenCalled() + }) + + it('should handle rapid consecutive clicks', () => { + // Arrange + const mockOnSelect = jest.fn() + const props = createDefaultProps({ onSelect: mockOnSelect }) + render() + + // Act - Rapid clicks + const button = screen.getByRole('button') + fireEvent.click(button) + fireEvent.click(button) + fireEvent.click(button) + + // Assert - Component should not crash + expect(button).toBeInTheDocument() + }) + }) + + // ========================================== + // Component Memoization - Test React.memo behavior + // ========================================== + describe('Memoization', () => { + it('should be wrapped with React.memo', () => { + // Assert - Check component has memo wrapper + expect(LanguageSelect.$$typeof).toBe(Symbol.for('react.memo')) + }) + + it('should not re-render when props remain the same', () => { + // Arrange + const mockOnSelect = jest.fn() + const props = createDefaultProps({ onSelect: mockOnSelect }) + const renderSpy = jest.fn() + + // Create a wrapper component to track renders + const TrackedLanguageSelect: React.FC = (trackedProps) => { + renderSpy() + return + } + const MemoizedTracked = React.memo(TrackedLanguageSelect) + + // Act + const { rerender } = render() + rerender() + + // Assert - Should only render once due to same props + expect(renderSpy).toHaveBeenCalledTimes(1) + }) + + it('should re-render when currentLanguage changes', () => { + // Arrange + const props = createDefaultProps({ currentLanguage: 'English' }) + + // Act + const { rerender } = render() + expect(screen.getByText('English')).toBeInTheDocument() + + rerender() + + // Assert + expect(screen.getByText('French')).toBeInTheDocument() + }) + + it('should re-render when disabled changes', () => { + // Arrange + const props = createDefaultProps({ disabled: false }) + + // Act + const { rerender } = render() + expect(screen.getByRole('button')).not.toBeDisabled() + + rerender() + + // Assert + expect(screen.getByRole('button')).toBeDisabled() + }) + }) + + // ========================================== + // Edge Cases - Test boundary conditions and error handling + // ========================================== + describe('Edge Cases', () => { + it('should handle empty string as currentLanguage', () => { + // Arrange + const props = createDefaultProps({ currentLanguage: '' }) + + // Act + render() + + // Assert - Component should still render + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + }) + + it('should handle non-existent language as currentLanguage', () => { + // Arrange + const props = createDefaultProps({ currentLanguage: 'NonExistentLanguage' }) + + // Act + render() + + // Assert - Should display the value even if not in list + expect(screen.getByText('NonExistentLanguage')).toBeInTheDocument() + }) + + it('should handle special characters in language names', () => { + // Arrange - Turkish has special character in prompt_name + const props = createDefaultProps({ currentLanguage: 'Türkçe' }) + + // Act + render() + + // Assert + expect(screen.getByText('Türkçe')).toBeInTheDocument() + }) + + it('should handle very long language names', () => { + // Arrange + const longLanguageName = 'A'.repeat(100) + const props = createDefaultProps({ currentLanguage: longLanguageName }) + + // Act + render() + + // Assert - Should not crash and should display the text + expect(screen.getByText(longLanguageName)).toBeInTheDocument() + }) + + it('should render correct number of language options', () => { + // Arrange + const props = createDefaultProps() + render() + + // Act + const button = screen.getByRole('button') + fireEvent.click(button) + + // Assert - Should show all supported languages + const expectedCount = supportedLanguages.length + // Each language appears in the dropdown (use getAllByText because current language appears twice) + supportedLanguages.forEach((lang) => { + expect(screen.getAllByText(lang.prompt_name).length).toBeGreaterThanOrEqual(1) + }) + expect(supportedLanguages.length).toBe(expectedCount) + }) + + it('should only show supported languages in dropdown', () => { + // Arrange + const props = createDefaultProps() + render() + + // Act + const button = screen.getByRole('button') + fireEvent.click(button) + + // Assert - All displayed languages should be supported + const allLanguages = languages + const unsupportedLanguages = allLanguages.filter(lang => !lang.supported) + + unsupportedLanguages.forEach((lang) => { + expect(screen.queryByText(lang.prompt_name)).not.toBeInTheDocument() + }) + }) + + it('should handle undefined onSelect gracefully when clicking', () => { + // Arrange - This tests TypeScript boundary, but runtime should not crash + const props = createDefaultProps() + + // Act + render() + const button = screen.getByRole('button') + fireEvent.click(button) + const option = screen.getByText('German') + + // Assert - Should not throw + expect(() => fireEvent.click(option)).not.toThrow() + }) + + it('should maintain selection state visually with check icon', () => { + // Arrange + const props = createDefaultProps({ currentLanguage: 'Russian' }) + const { container } = render() + + // Act + const button = screen.getByRole('button') + fireEvent.click(button) + + // Assert - Find the check icon (RiCheckLine) in the dropdown + // The selected option should have a check icon next to it + const checkIcons = container.querySelectorAll('svg.text-text-accent') + expect(checkIcons.length).toBeGreaterThanOrEqual(1) + }) + }) + + // ========================================== + // Accessibility - Basic accessibility checks + // ========================================== + describe('Accessibility', () => { + it('should have accessible button element', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + }) + + it('should have clickable language options', () => { + // Arrange + const props = createDefaultProps() + render() + + // Act + const button = screen.getByRole('button') + fireEvent.click(button) + + // Assert - Options should be clickable (have cursor-pointer class) + const options = screen.getAllByText(/English|French|German|Japanese/i) + expect(options.length).toBeGreaterThan(0) + }) + }) + + // ========================================== + // Integration with Popover - Test Popover behavior + // ========================================== + describe('Popover Integration', () => { + it('should use manualClose prop on Popover', () => { + // Arrange + const mockOnSelect = jest.fn() + const props = createDefaultProps({ onSelect: mockOnSelect }) + + // Act + render() + const button = screen.getByRole('button') + fireEvent.click(button) + + // Assert - Popover should be open + expect(screen.getAllByText('English').length).toBeGreaterThanOrEqual(1) + }) + + it('should have correct popup z-index class', () => { + // Arrange + const props = createDefaultProps() + const { container } = render() + + // Act + const button = screen.getByRole('button') + fireEvent.click(button) + + // Assert - Check for z-20 class (popupClassName='z-20') + // This is applied to the Popover + expect(container.querySelector('.z-20')).toBeTruthy() + }) + }) + + // ========================================== + // Styling Tests - Verify correct CSS classes applied + // ========================================== + describe('Styling', () => { + it('should apply tertiary button styling', () => { + // Arrange + const props = createDefaultProps() + const { container } = render() + + // Assert - Check for tertiary button classes (uses ! prefix for important) + expect(container.querySelector('.\\!bg-components-button-tertiary-bg')).toBeInTheDocument() + }) + + it('should apply hover styling class to options', () => { + // Arrange + const props = createDefaultProps() + const { container } = render() + + // Act + const button = screen.getByRole('button') + fireEvent.click(button) + + // Assert - Options should have hover class + const optionWithHover = container.querySelector('.hover\\:bg-state-base-hover') + expect(optionWithHover).toBeInTheDocument() + }) + + it('should apply correct text styling to language options', () => { + // Arrange + const props = createDefaultProps() + const { container } = render() + + // Act + const button = screen.getByRole('button') + fireEvent.click(button) + + // Assert - Check for system-sm-medium class on options + const styledOption = container.querySelector('.system-sm-medium') + expect(styledOption).toBeInTheDocument() + }) + + it('should apply disabled styling to icon when disabled', () => { + // Arrange + const props = createDefaultProps({ disabled: true }) + const { container } = render() + + // Assert - Check for disabled text color on icon + const disabledIcon = container.querySelector('.text-components-button-tertiary-text-disabled') + expect(disabledIcon).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/create/step-two/preview-item/index.spec.tsx b/web/app/components/datasets/create/step-two/preview-item/index.spec.tsx new file mode 100644 index 0000000000..432d070ea9 --- /dev/null +++ b/web/app/components/datasets/create/step-two/preview-item/index.spec.tsx @@ -0,0 +1,803 @@ +import { render, screen } from '@testing-library/react' +import React from 'react' +import PreviewItem, { PreviewType } from './index' +import type { IPreviewItemProps } from './index' + +// Test data builder for props +const createDefaultProps = (overrides?: Partial): IPreviewItemProps => ({ + type: PreviewType.TEXT, + index: 1, + content: 'Test content', + ...overrides, +}) + +const createQAProps = (overrides?: Partial): IPreviewItemProps => ({ + type: PreviewType.QA, + index: 1, + qa: { + question: 'Test question', + answer: 'Test answer', + }, + ...overrides, +}) + +describe('PreviewItem', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + // ========================================== + // Rendering Tests - Verify component renders correctly + // ========================================== + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByText('Test content')).toBeInTheDocument() + }) + + it('should render with TEXT type', () => { + // Arrange + const props = createDefaultProps({ content: 'Sample text content' }) + + // Act + render() + + // Assert + expect(screen.getByText('Sample text content')).toBeInTheDocument() + }) + + it('should render with QA type', () => { + // Arrange + const props = createQAProps() + + // Act + render() + + // Assert + expect(screen.getByText('Q')).toBeInTheDocument() + expect(screen.getByText('A')).toBeInTheDocument() + expect(screen.getByText('Test question')).toBeInTheDocument() + expect(screen.getByText('Test answer')).toBeInTheDocument() + }) + + it('should render sharp icon (#) with formatted index', () => { + // Arrange + const props = createDefaultProps({ index: 5 }) + + // Act + const { container } = render() + + // Assert - Index should be padded to 3 digits + expect(screen.getByText('005')).toBeInTheDocument() + // Sharp icon SVG should exist + const svgElements = container.querySelectorAll('svg') + expect(svgElements.length).toBeGreaterThanOrEqual(1) + }) + + it('should render character count for TEXT type', () => { + // Arrange + const content = 'Hello World' // 11 characters + const props = createDefaultProps({ content }) + + // Act + render() + + // Assert - Shows character count with translation key + expect(screen.getByText(/11/)).toBeInTheDocument() + expect(screen.getByText(/datasetCreation.stepTwo.characters/)).toBeInTheDocument() + }) + + it('should render character count for QA type', () => { + // Arrange + const props = createQAProps({ + qa: { + question: 'Hello', // 5 characters + answer: 'World', // 5 characters - total 10 + }, + }) + + // Act + render() + + // Assert - Shows combined character count + expect(screen.getByText(/10/)).toBeInTheDocument() + }) + + it('should render text icon SVG', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render() + + // Assert - Should have SVG icons + const svgElements = container.querySelectorAll('svg') + expect(svgElements.length).toBe(2) // Sharp icon and text icon + }) + }) + + // ========================================== + // Props Testing - Verify all prop variations work correctly + // ========================================== + describe('Props', () => { + describe('type prop', () => { + it('should render TEXT content when type is TEXT', () => { + // Arrange + const props = createDefaultProps({ type: PreviewType.TEXT, content: 'Text mode content' }) + + // Act + render() + + // Assert + expect(screen.getByText('Text mode content')).toBeInTheDocument() + expect(screen.queryByText('Q')).not.toBeInTheDocument() + expect(screen.queryByText('A')).not.toBeInTheDocument() + }) + + it('should render QA content when type is QA', () => { + // Arrange + const props = createQAProps({ + type: PreviewType.QA, + qa: { question: 'My question', answer: 'My answer' }, + }) + + // Act + render() + + // Assert + expect(screen.getByText('Q')).toBeInTheDocument() + expect(screen.getByText('A')).toBeInTheDocument() + expect(screen.getByText('My question')).toBeInTheDocument() + expect(screen.getByText('My answer')).toBeInTheDocument() + }) + + it('should use TEXT as default type when type is "text"', () => { + // Arrange + const props = createDefaultProps({ type: 'text' as PreviewType, content: 'Default type content' }) + + // Act + render() + + // Assert + expect(screen.getByText('Default type content')).toBeInTheDocument() + }) + + it('should use QA type when type is "QA"', () => { + // Arrange + const props = createQAProps({ type: 'QA' as PreviewType }) + + // Act + render() + + // Assert + expect(screen.getByText('Q')).toBeInTheDocument() + expect(screen.getByText('A')).toBeInTheDocument() + }) + }) + + describe('index prop', () => { + it.each([ + [1, '001'], + [5, '005'], + [10, '010'], + [99, '099'], + [100, '100'], + [999, '999'], + [1000, '1000'], + ])('should format index %i as %s', (index, expected) => { + // Arrange + const props = createDefaultProps({ index }) + + // Act + render() + + // Assert + expect(screen.getByText(expected)).toBeInTheDocument() + }) + + it('should handle index 0', () => { + // Arrange + const props = createDefaultProps({ index: 0 }) + + // Act + render() + + // Assert + expect(screen.getByText('000')).toBeInTheDocument() + }) + + it('should handle large index numbers', () => { + // Arrange + const props = createDefaultProps({ index: 12345 }) + + // Act + render() + + // Assert + expect(screen.getByText('12345')).toBeInTheDocument() + }) + }) + + describe('content prop', () => { + it('should render content when provided', () => { + // Arrange + const props = createDefaultProps({ content: 'Custom content here' }) + + // Act + render() + + // Assert + expect(screen.getByText('Custom content here')).toBeInTheDocument() + }) + + it('should handle multiline content', () => { + // Arrange + const multilineContent = 'Line 1\nLine 2\nLine 3' + const props = createDefaultProps({ content: multilineContent }) + + // Act + const { container } = render() + + // Assert - Check content is rendered (multiline text is in pre-line div) + const contentDiv = container.querySelector('[style*="white-space: pre-line"]') + expect(contentDiv?.textContent).toContain('Line 1') + expect(contentDiv?.textContent).toContain('Line 2') + expect(contentDiv?.textContent).toContain('Line 3') + }) + + it('should preserve whitespace with pre-line style', () => { + // Arrange + const props = createDefaultProps({ content: 'Text with spaces' }) + + // Act + const { container } = render() + + // Assert - Check for whiteSpace: pre-line style + const contentDiv = container.querySelector('[style*="white-space: pre-line"]') + expect(contentDiv).toBeInTheDocument() + }) + }) + + describe('qa prop', () => { + it('should render question and answer when qa is provided', () => { + // Arrange + const props = createQAProps({ + qa: { + question: 'What is testing?', + answer: 'Testing is verification.', + }, + }) + + // Act + render() + + // Assert + expect(screen.getByText('What is testing?')).toBeInTheDocument() + expect(screen.getByText('Testing is verification.')).toBeInTheDocument() + }) + + it('should render Q and A labels', () => { + // Arrange + const props = createQAProps() + + // Act + render() + + // Assert + expect(screen.getByText('Q')).toBeInTheDocument() + expect(screen.getByText('A')).toBeInTheDocument() + }) + + it('should handle multiline question', () => { + // Arrange + const props = createQAProps({ + qa: { + question: 'Question line 1\nQuestion line 2', + answer: 'Answer', + }, + }) + + // Act + const { container } = render() + + // Assert - Check content is in pre-line div + const preLineDivs = container.querySelectorAll('[style*="white-space: pre-line"]') + const questionDiv = Array.from(preLineDivs).find(div => div.textContent?.includes('Question line 1')) + expect(questionDiv).toBeTruthy() + expect(questionDiv?.textContent).toContain('Question line 2') + }) + + it('should handle multiline answer', () => { + // Arrange + const props = createQAProps({ + qa: { + question: 'Question', + answer: 'Answer line 1\nAnswer line 2', + }, + }) + + // Act + const { container } = render() + + // Assert - Check content is in pre-line div + const preLineDivs = container.querySelectorAll('[style*="white-space: pre-line"]') + const answerDiv = Array.from(preLineDivs).find(div => div.textContent?.includes('Answer line 1')) + expect(answerDiv).toBeTruthy() + expect(answerDiv?.textContent).toContain('Answer line 2') + }) + }) + }) + + // ========================================== + // Component Memoization - Test React.memo behavior + // ========================================== + describe('Memoization', () => { + it('should be wrapped with React.memo', () => { + // Assert - Check component has memo wrapper + expect(PreviewItem.$$typeof).toBe(Symbol.for('react.memo')) + }) + + it('should not re-render when props remain the same', () => { + // Arrange + const props = createDefaultProps() + const renderSpy = jest.fn() + + // Create a wrapper component to track renders + const TrackedPreviewItem: React.FC = (trackedProps) => { + renderSpy() + return + } + const MemoizedTracked = React.memo(TrackedPreviewItem) + + // Act + const { rerender } = render() + rerender() + + // Assert - Should only render once due to same props + expect(renderSpy).toHaveBeenCalledTimes(1) + }) + + it('should re-render when content changes', () => { + // Arrange + const props = createDefaultProps({ content: 'Initial content' }) + + // Act + const { rerender } = render() + expect(screen.getByText('Initial content')).toBeInTheDocument() + + rerender() + + // Assert + expect(screen.getByText('Updated content')).toBeInTheDocument() + }) + + it('should re-render when index changes', () => { + // Arrange + const props = createDefaultProps({ index: 1 }) + + // Act + const { rerender } = render() + expect(screen.getByText('001')).toBeInTheDocument() + + rerender() + + // Assert + expect(screen.getByText('099')).toBeInTheDocument() + }) + + it('should re-render when type changes', () => { + // Arrange + const props = createDefaultProps({ type: PreviewType.TEXT, content: 'Text content' }) + + // Act + const { rerender } = render() + expect(screen.getByText('Text content')).toBeInTheDocument() + expect(screen.queryByText('Q')).not.toBeInTheDocument() + + rerender() + + // Assert + expect(screen.getByText('Q')).toBeInTheDocument() + expect(screen.getByText('A')).toBeInTheDocument() + }) + + it('should re-render when qa prop changes', () => { + // Arrange + const props = createQAProps({ + qa: { question: 'Original question', answer: 'Original answer' }, + }) + + // Act + const { rerender } = render() + expect(screen.getByText('Original question')).toBeInTheDocument() + + rerender() + + // Assert + expect(screen.getByText('New question')).toBeInTheDocument() + expect(screen.getByText('New answer')).toBeInTheDocument() + }) + }) + + // ========================================== + // Edge Cases - Test boundary conditions and error handling + // ========================================== + describe('Edge Cases', () => { + describe('Empty/Undefined values', () => { + it('should handle undefined content gracefully', () => { + // Arrange + const props = createDefaultProps({ content: undefined }) + + // Act + render() + + // Assert - Should show 0 characters (use more specific text match) + expect(screen.getByText(/^0 datasetCreation/)).toBeInTheDocument() + }) + + it('should handle empty string content', () => { + // Arrange + const props = createDefaultProps({ content: '' }) + + // Act + render() + + // Assert - Should show 0 characters (use more specific text match) + expect(screen.getByText(/^0 datasetCreation/)).toBeInTheDocument() + }) + + it('should handle undefined qa gracefully', () => { + // Arrange + const props: IPreviewItemProps = { + type: PreviewType.QA, + index: 1, + qa: undefined, + } + + // Act + render() + + // Assert - Should render Q and A labels but with empty content + expect(screen.getByText('Q')).toBeInTheDocument() + expect(screen.getByText('A')).toBeInTheDocument() + // Character count should be 0 (use more specific text match) + expect(screen.getByText(/^0 datasetCreation/)).toBeInTheDocument() + }) + + it('should handle undefined question in qa', () => { + // Arrange + const props: IPreviewItemProps = { + type: PreviewType.QA, + index: 1, + qa: { + question: undefined as unknown as string, + answer: 'Only answer', + }, + } + + // Act + render() + + // Assert + expect(screen.getByText('Only answer')).toBeInTheDocument() + }) + + it('should handle undefined answer in qa', () => { + // Arrange + const props: IPreviewItemProps = { + type: PreviewType.QA, + index: 1, + qa: { + question: 'Only question', + answer: undefined as unknown as string, + }, + } + + // Act + render() + + // Assert + expect(screen.getByText('Only question')).toBeInTheDocument() + }) + + it('should handle empty question and answer strings', () => { + // Arrange + const props = createQAProps({ + qa: { question: '', answer: '' }, + }) + + // Act + render() + + // Assert - Should show 0 characters (use more specific text match) + expect(screen.getByText(/^0 datasetCreation/)).toBeInTheDocument() + expect(screen.getByText('Q')).toBeInTheDocument() + expect(screen.getByText('A')).toBeInTheDocument() + }) + }) + + describe('Character count calculation', () => { + it('should calculate correct character count for TEXT type', () => { + // Arrange - 'Test' has 4 characters + const props = createDefaultProps({ content: 'Test' }) + + // Act + render() + + // Assert + expect(screen.getByText(/4/)).toBeInTheDocument() + }) + + it('should calculate correct character count for QA type (question + answer)', () => { + // Arrange - 'ABC' (3) + 'DEFGH' (5) = 8 characters + const props = createQAProps({ + qa: { question: 'ABC', answer: 'DEFGH' }, + }) + + // Act + render() + + // Assert + expect(screen.getByText(/8/)).toBeInTheDocument() + }) + + it('should count special characters correctly', () => { + // Arrange - Content with special characters + const props = createDefaultProps({ content: '你好世界' }) // 4 Chinese characters + + // Act + render() + + // Assert + expect(screen.getByText(/4/)).toBeInTheDocument() + }) + + it('should count newlines in character count', () => { + // Arrange - 'a\nb' has 3 characters + const props = createDefaultProps({ content: 'a\nb' }) + + // Act + render() + + // Assert + expect(screen.getByText(/3/)).toBeInTheDocument() + }) + + it('should count spaces in character count', () => { + // Arrange - 'a b' has 3 characters + const props = createDefaultProps({ content: 'a b' }) + + // Act + render() + + // Assert + expect(screen.getByText(/3/)).toBeInTheDocument() + }) + }) + + describe('Boundary conditions', () => { + it('should handle very long content', () => { + // Arrange + const longContent = 'A'.repeat(10000) + const props = createDefaultProps({ content: longContent }) + + // Act + render() + + // Assert - Should show correct character count + expect(screen.getByText(/10000/)).toBeInTheDocument() + }) + + it('should handle very long index', () => { + // Arrange + const props = createDefaultProps({ index: 999999999 }) + + // Act + render() + + // Assert + expect(screen.getByText('999999999')).toBeInTheDocument() + }) + + it('should handle negative index', () => { + // Arrange + const props = createDefaultProps({ index: -1 }) + + // Act + render() + + // Assert - padStart pads from the start, so -1 becomes 0-1 + expect(screen.getByText('0-1')).toBeInTheDocument() + }) + + it('should handle content with only whitespace', () => { + // Arrange + const props = createDefaultProps({ content: ' ' }) // 3 spaces + + // Act + render() + + // Assert + expect(screen.getByText(/3/)).toBeInTheDocument() + }) + + it('should handle content with HTML-like characters', () => { + // Arrange + const props = createDefaultProps({ content: '
Test
' }) + + // Act + render() + + // Assert - Should render as text, not HTML + expect(screen.getByText('
Test
')).toBeInTheDocument() + }) + + it('should handle content with emojis', () => { + // Arrange - Emojis can have complex character lengths + const props = createDefaultProps({ content: '😀👍' }) + + // Act + render() + + // Assert - Emoji length depends on JS string length + expect(screen.getByText('😀👍')).toBeInTheDocument() + }) + }) + + describe('Type edge cases', () => { + it('should ignore qa prop when type is TEXT', () => { + // Arrange - Both content and qa provided, but type is TEXT + const props: IPreviewItemProps = { + type: PreviewType.TEXT, + index: 1, + content: 'Text content', + qa: { question: 'Should not show', answer: 'Also should not show' }, + } + + // Act + render() + + // Assert + expect(screen.getByText('Text content')).toBeInTheDocument() + expect(screen.queryByText('Should not show')).not.toBeInTheDocument() + expect(screen.queryByText('Also should not show')).not.toBeInTheDocument() + }) + + it('should use content length for TEXT type even when qa is provided', () => { + // Arrange + const props: IPreviewItemProps = { + type: PreviewType.TEXT, + index: 1, + content: 'Hi', // 2 characters + qa: { question: 'Question', answer: 'Answer' }, // Would be 14 characters if used + } + + // Act + render() + + // Assert - Should show 2, not 14 + expect(screen.getByText(/2/)).toBeInTheDocument() + }) + + it('should ignore content prop when type is QA', () => { + // Arrange + const props: IPreviewItemProps = { + type: PreviewType.QA, + index: 1, + content: 'Should not display', + qa: { question: 'Q text', answer: 'A text' }, + } + + // Act + render() + + // Assert + expect(screen.queryByText('Should not display')).not.toBeInTheDocument() + expect(screen.getByText('Q text')).toBeInTheDocument() + expect(screen.getByText('A text')).toBeInTheDocument() + }) + }) + }) + + // ========================================== + // PreviewType Enum - Test exported enum values + // ========================================== + describe('PreviewType Enum', () => { + it('should have TEXT value as "text"', () => { + expect(PreviewType.TEXT).toBe('text') + }) + + it('should have QA value as "QA"', () => { + expect(PreviewType.QA).toBe('QA') + }) + }) + + // ========================================== + // Styling Tests - Verify correct CSS classes applied + // ========================================== + describe('Styling', () => { + it('should have rounded container with gray background', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render() + + // Assert + const rootDiv = container.firstChild as HTMLElement + expect(rootDiv).toHaveClass('rounded-xl', 'bg-gray-50', 'p-4') + }) + + it('should have proper header styling', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render() + + // Assert - Check header div styling + const headerDiv = container.querySelector('.flex.h-5.items-center.justify-between') + expect(headerDiv).toBeInTheDocument() + }) + + it('should have index badge styling', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render() + + // Assert + const indexBadge = container.querySelector('.border.border-gray-200') + expect(indexBadge).toBeInTheDocument() + expect(indexBadge).toHaveClass('rounded-md', 'italic', 'font-medium') + }) + + it('should have content area with line-clamp', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render() + + // Assert + const contentArea = container.querySelector('.line-clamp-6') + expect(contentArea).toBeInTheDocument() + expect(contentArea).toHaveClass('max-h-[120px]', 'overflow-hidden') + }) + + it('should have Q/A labels with gray color', () => { + // Arrange + const props = createQAProps() + + // Act + const { container } = render() + + // Assert + const labels = container.querySelectorAll('.text-gray-400') + expect(labels.length).toBeGreaterThanOrEqual(2) // Q and A labels + }) + }) + + // ========================================== + // i18n Translation - Test translation integration + // ========================================== + describe('i18n Translation', () => { + it('should use translation key for characters label', () => { + // Arrange + const props = createDefaultProps({ content: 'Test' }) + + // Act + render() + + // Assert - The mock returns the key as-is + expect(screen.getByText(/datasetCreation.stepTwo.characters/)).toBeInTheDocument() + }) + }) +}) diff --git a/web/jest.setup.ts b/web/jest.setup.ts index 02062b4604..9c3b0bf3bd 100644 --- a/web/jest.setup.ts +++ b/web/jest.setup.ts @@ -1,5 +1,20 @@ import '@testing-library/jest-dom' import { cleanup } from '@testing-library/react' +import { mockAnimationsApi } from 'jsdom-testing-mocks' + +// Mock Web Animations API for Headless UI +mockAnimationsApi() + +// Suppress act() warnings from @headlessui/react internal Transition component +// These warnings are caused by Headless UI's internal async state updates, not our code +const originalConsoleError = console.error +console.error = (...args: unknown[]) => { + // Check all arguments for the Headless UI TransitionRootFn act warning + const fullMessage = args.map(arg => (typeof arg === 'string' ? arg : '')).join(' ') + if (fullMessage.includes('TransitionRootFn') && fullMessage.includes('not wrapped in act')) + return + originalConsoleError.apply(console, args) +} // Fix for @headlessui/react compatibility with happy-dom // headlessui tries to override focus properties which may be read-only in happy-dom diff --git a/web/package.json b/web/package.json index 961288b495..d54e6effb2 100644 --- a/web/package.json +++ b/web/package.json @@ -201,6 +201,7 @@ "globals": "^15.15.0", "husky": "^9.1.7", "jest": "^29.7.0", + "jsdom-testing-mocks": "^1.16.0", "knip": "^5.66.1", "lint-staged": "^15.5.2", "lodash": "^4.17.21", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index ac671d8b98..8523215a07 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -515,6 +515,9 @@ importers: jest: specifier: ^29.7.0 version: 29.7.0(@types/node@18.15.0)(ts-node@10.9.2(@types/node@18.15.0)(typescript@5.9.3)) + jsdom-testing-mocks: + specifier: ^1.16.0 + version: 1.16.0 knip: specifier: ^5.66.1 version: 5.72.0(@types/node@18.15.0)(typescript@5.9.3) @@ -4190,6 +4193,9 @@ packages: resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} engines: {node: '>=12.0.0'} + bezier-easing@2.1.0: + resolution: {integrity: sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==} + big.js@5.2.2: resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==} @@ -4660,6 +4666,9 @@ packages: webpack: optional: true + css-mediaquery@0.1.2: + resolution: {integrity: sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==} + css-select@4.3.0: resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==} @@ -6317,6 +6326,10 @@ packages: resolution: {integrity: sha512-F9GQ+F1ZU6qvSrZV8fNFpjDNf614YzR2eF6S0+XbDjAcUI28FSoXnYZFjQmb1kFx3rrJb5PnxUH3/Yti6fcM+g==} engines: {node: '>=12.0.0'} + jsdom-testing-mocks@1.16.0: + resolution: {integrity: sha512-wLrulXiLpjmcUYOYGEvz4XARkrmdVpyxzdBl9IAMbQ+ib2/UhUTRCn49McdNfXLff2ysGBUms49ZKX0LR1Q0gg==} + engines: {node: '>=14'} + jsesc@3.0.2: resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} engines: {node: '>=6'} @@ -13070,6 +13083,8 @@ snapshots: dependencies: open: 8.4.2 + bezier-easing@2.1.0: {} + big.js@5.2.2: {} binary-extensions@2.3.0: {} @@ -13577,6 +13592,8 @@ snapshots: optionalDependencies: webpack: 5.103.0(esbuild@0.25.0)(uglify-js@3.19.3) + css-mediaquery@0.1.2: {} + css-select@4.3.0: dependencies: boolbase: 1.0.0 @@ -15682,6 +15699,11 @@ snapshots: jsdoc-type-pratt-parser@5.4.0: {} + jsdom-testing-mocks@1.16.0: + dependencies: + bezier-easing: 2.1.0 + css-mediaquery: 0.1.2 + jsesc@3.0.2: {} jsesc@3.1.0: {}