From 91714ee41382ca348066ca0a97197286ce78b1bd Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 17 Dec 2025 10:21:32 +0800 Subject: [PATCH] chore(web): add some jest tests (#29754) --- .../edit-item/index.spec.tsx | 397 +++++++++++++++++ .../edit-annotation-modal/index.spec.tsx | 408 ++++++++++++++++++ .../dataset-config/context-var/index.spec.tsx | 299 +++++++++++++ .../context-var/var-picker.spec.tsx | 392 +++++++++++++++++ 4 files changed, 1496 insertions(+) create mode 100644 web/app/components/app/annotation/edit-annotation-modal/edit-item/index.spec.tsx create mode 100644 web/app/components/app/annotation/edit-annotation-modal/index.spec.tsx create mode 100644 web/app/components/app/configuration/dataset-config/context-var/index.spec.tsx create mode 100644 web/app/components/app/configuration/dataset-config/context-var/var-picker.spec.tsx diff --git a/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.spec.tsx b/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.spec.tsx new file mode 100644 index 0000000000..1f32e55928 --- /dev/null +++ b/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.spec.tsx @@ -0,0 +1,397 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import EditItem, { EditItemType, EditTitle } from './index' + +describe('EditTitle', () => { + it('should render title content correctly', () => { + // Arrange + const props = { title: 'Test Title' } + + // Act + render() + + // Assert + expect(screen.getByText(/test title/i)).toBeInTheDocument() + // Should contain edit icon (svg element) + expect(document.querySelector('svg')).toBeInTheDocument() + }) + + it('should apply custom className when provided', () => { + // Arrange + const props = { + title: 'Test Title', + className: 'custom-class', + } + + // Act + const { container } = render() + + // Assert + expect(screen.getByText(/test title/i)).toBeInTheDocument() + expect(container.querySelector('.custom-class')).toBeInTheDocument() + }) +}) + +describe('EditItem', () => { + const defaultProps = { + type: EditItemType.Query, + content: 'Test content', + onSave: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + // Rendering tests (REQUIRED) + describe('Rendering', () => { + it('should render content correctly', () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert + expect(screen.getByText(/test content/i)).toBeInTheDocument() + // Should show item name (query or answer) + expect(screen.getByText('appAnnotation.editModal.queryName')).toBeInTheDocument() + }) + + it('should render different item types correctly', () => { + // Arrange + const props = { + ...defaultProps, + type: EditItemType.Answer, + content: 'Answer content', + } + + // Act + render() + + // Assert + expect(screen.getByText(/answer content/i)).toBeInTheDocument() + expect(screen.getByText('appAnnotation.editModal.answerName')).toBeInTheDocument() + }) + + it('should show edit controls when not readonly', () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert + expect(screen.getByText('common.operation.edit')).toBeInTheDocument() + }) + + it('should hide edit controls when readonly', () => { + // Arrange + const props = { + ...defaultProps, + readonly: true, + } + + // Act + render() + + // Assert + expect(screen.queryByText('common.operation.edit')).not.toBeInTheDocument() + }) + }) + + // Props tests (REQUIRED) + describe('Props', () => { + it('should respect readonly prop for edit functionality', () => { + // Arrange + const props = { + ...defaultProps, + readonly: true, + } + + // Act + render() + + // Assert + expect(screen.getByText(/test content/i)).toBeInTheDocument() + expect(screen.queryByText('common.operation.edit')).not.toBeInTheDocument() + }) + + it('should display provided content', () => { + // Arrange + const props = { + ...defaultProps, + content: 'Custom content for testing', + } + + // Act + render() + + // Assert + expect(screen.getByText(/custom content for testing/i)).toBeInTheDocument() + }) + + it('should render appropriate content based on type', () => { + // Arrange + const props = { + ...defaultProps, + type: EditItemType.Query, + content: 'Question content', + } + + // Act + render() + + // Assert + expect(screen.getByText(/question content/i)).toBeInTheDocument() + expect(screen.getByText('appAnnotation.editModal.queryName')).toBeInTheDocument() + }) + }) + + // User Interactions + describe('User Interactions', () => { + it('should activate edit mode when edit button is clicked', async () => { + // Arrange + const props = { ...defaultProps } + const user = userEvent.setup() + + // Act + render() + await user.click(screen.getByText('common.operation.edit')) + + // Assert + expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument() + }) + + it('should save new content when save button is clicked', async () => { + // Arrange + const mockSave = jest.fn().mockResolvedValue(undefined) + const props = { + ...defaultProps, + onSave: mockSave, + } + const user = userEvent.setup() + + // Act + render() + await user.click(screen.getByText('common.operation.edit')) + + // Type new content + const textarea = screen.getByRole('textbox') + await user.clear(textarea) + await user.type(textarea, 'Updated content') + + // Save + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + // Assert + expect(mockSave).toHaveBeenCalledWith('Updated content') + }) + + it('should exit edit mode when cancel button is clicked', async () => { + // Arrange + const props = { ...defaultProps } + const user = userEvent.setup() + + // Act + render() + await user.click(screen.getByText('common.operation.edit')) + await user.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + + // Assert + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + expect(screen.getByText(/test content/i)).toBeInTheDocument() + }) + + it('should show content preview while typing', async () => { + // Arrange + const props = { ...defaultProps } + const user = userEvent.setup() + + // Act + render() + await user.click(screen.getByText('common.operation.edit')) + + const textarea = screen.getByRole('textbox') + await user.type(textarea, 'New content') + + // Assert + expect(screen.getByText(/new content/i)).toBeInTheDocument() + }) + + it('should call onSave with correct content when saving', async () => { + // Arrange + const mockSave = jest.fn().mockResolvedValue(undefined) + const props = { + ...defaultProps, + onSave: mockSave, + } + const user = userEvent.setup() + + // Act + render() + await user.click(screen.getByText('common.operation.edit')) + + const textarea = screen.getByRole('textbox') + await user.clear(textarea) + await user.type(textarea, 'Test save content') + + // Save + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + // Assert + expect(mockSave).toHaveBeenCalledWith('Test save content') + }) + + it('should show delete option when content changes', async () => { + // Arrange + const mockSave = jest.fn().mockResolvedValue(undefined) + const props = { + ...defaultProps, + onSave: mockSave, + } + const user = userEvent.setup() + + // Act + render() + + // Enter edit mode and change content + await user.click(screen.getByText('common.operation.edit')) + const textarea = screen.getByRole('textbox') + await user.clear(textarea) + await user.type(textarea, 'Modified content') + + // Save to trigger content change + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + // Assert + expect(mockSave).toHaveBeenCalledWith('Modified content') + }) + + it('should handle keyboard interactions in edit mode', async () => { + // Arrange + const props = { ...defaultProps } + const user = userEvent.setup() + + // Act + render() + await user.click(screen.getByText('common.operation.edit')) + + const textarea = screen.getByRole('textbox') + + // Test typing + await user.type(textarea, 'Keyboard test') + + // Assert + expect(textarea).toHaveValue('Keyboard test') + expect(screen.getByText(/keyboard test/i)).toBeInTheDocument() + }) + }) + + // State Management + describe('State Management', () => { + it('should reset newContent when content prop changes', async () => { + // Arrange + const { rerender } = render() + + // Act - Enter edit mode and type something + const user = userEvent.setup() + await user.click(screen.getByText('common.operation.edit')) + const textarea = screen.getByRole('textbox') + await user.clear(textarea) + await user.type(textarea, 'New content') + + // Rerender with new content prop + rerender() + + // Assert - Textarea value should be reset due to useEffect + expect(textarea).toHaveValue('') + }) + + it('should preserve edit state across content changes', async () => { + // Arrange + const { rerender } = render() + const user = userEvent.setup() + + // Act - Enter edit mode + await user.click(screen.getByText('common.operation.edit')) + + // Rerender with new content + rerender() + + // Assert - Should still be in edit mode + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + }) + + // Edge Cases (REQUIRED) + describe('Edge Cases', () => { + it('should handle empty content', () => { + // Arrange + const props = { + ...defaultProps, + content: '', + } + + // Act + const { container } = render() + + // Assert - Should render without crashing + // Check that the component renders properly with empty content + expect(container.querySelector('.grow')).toBeInTheDocument() + // Should still show edit button + expect(screen.getByText('common.operation.edit')).toBeInTheDocument() + }) + + it('should handle very long content', () => { + // Arrange + const longContent = 'A'.repeat(1000) + const props = { + ...defaultProps, + content: longContent, + } + + // Act + render() + + // Assert + expect(screen.getByText(longContent)).toBeInTheDocument() + }) + + it('should handle content with special characters', () => { + // Arrange + const specialContent = 'Content with & < > " \' characters' + const props = { + ...defaultProps, + content: specialContent, + } + + // Act + render() + + // Assert + expect(screen.getByText(specialContent)).toBeInTheDocument() + }) + + it('should handle rapid edit/cancel operations', async () => { + // Arrange + const props = { ...defaultProps } + const user = userEvent.setup() + + // Act + render() + + // Rapid edit/cancel operations + await user.click(screen.getByText('common.operation.edit')) + await user.click(screen.getByText('common.operation.cancel')) + await user.click(screen.getByText('common.operation.edit')) + await user.click(screen.getByText('common.operation.cancel')) + + // Assert + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + expect(screen.getByText('Test content')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/app/annotation/edit-annotation-modal/index.spec.tsx b/web/app/components/app/annotation/edit-annotation-modal/index.spec.tsx new file mode 100644 index 0000000000..a2e2527605 --- /dev/null +++ b/web/app/components/app/annotation/edit-annotation-modal/index.spec.tsx @@ -0,0 +1,408 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import Toast, { type IToastProps, type ToastHandle } from '@/app/components/base/toast' +import EditAnnotationModal from './index' + +// Mock only external dependencies +jest.mock('@/service/annotation', () => ({ + addAnnotation: jest.fn(), + editAnnotation: jest.fn(), +})) + +jest.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + plan: { + usage: { annotatedResponse: 5 }, + total: { annotatedResponse: 10 }, + }, + enableBilling: true, + }), +})) + +jest.mock('@/hooks/use-timestamp', () => ({ + __esModule: true, + default: () => ({ + formatTime: () => '2023-12-01 10:30:00', + }), +})) + +// Note: i18n is automatically mocked by Jest via __mocks__/react-i18next.ts + +jest.mock('@/app/components/billing/annotation-full', () => ({ + __esModule: true, + default: () =>
, +})) + +type ToastNotifyProps = Pick +type ToastWithNotify = typeof Toast & { notify: (props: ToastNotifyProps) => ToastHandle } +const toastWithNotify = Toast as unknown as ToastWithNotify +const toastNotifySpy = jest.spyOn(toastWithNotify, 'notify').mockReturnValue({ clear: jest.fn() }) + +const { addAnnotation: mockAddAnnotation, editAnnotation: mockEditAnnotation } = jest.requireMock('@/service/annotation') as { + addAnnotation: jest.Mock + editAnnotation: jest.Mock +} + +describe('EditAnnotationModal', () => { + const defaultProps = { + isShow: true, + onHide: jest.fn(), + appId: 'test-app-id', + query: 'Test query', + answer: 'Test answer', + onEdited: jest.fn(), + onAdded: jest.fn(), + onRemove: jest.fn(), + } + + afterAll(() => { + toastNotifySpy.mockRestore() + }) + + beforeEach(() => { + jest.clearAllMocks() + mockAddAnnotation.mockResolvedValue({ + id: 'test-id', + account: { name: 'Test User' }, + }) + mockEditAnnotation.mockResolvedValue({}) + }) + + // Rendering tests (REQUIRED) + describe('Rendering', () => { + it('should render modal when isShow is true', () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert - Check for modal title as it appears in the mock + expect(screen.getByText('appAnnotation.editModal.title')).toBeInTheDocument() + }) + + it('should not render modal when isShow is false', () => { + // Arrange + const props = { ...defaultProps, isShow: false } + + // Act + render() + + // Assert + expect(screen.queryByText('appAnnotation.editModal.title')).not.toBeInTheDocument() + }) + + it('should display query and answer sections', () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert - Look for query and answer content + expect(screen.getByText('Test query')).toBeInTheDocument() + expect(screen.getByText('Test answer')).toBeInTheDocument() + }) + }) + + // Props tests (REQUIRED) + describe('Props', () => { + it('should handle different query and answer content', () => { + // Arrange + const props = { + ...defaultProps, + query: 'Custom query content', + answer: 'Custom answer content', + } + + // Act + render() + + // Assert - Check content is displayed + expect(screen.getByText('Custom query content')).toBeInTheDocument() + expect(screen.getByText('Custom answer content')).toBeInTheDocument() + }) + + it('should show remove option when annotationId is provided', () => { + // Arrange + const props = { + ...defaultProps, + annotationId: 'test-annotation-id', + } + + // Act + render() + + // Assert - Remove option should be present (using pattern) + expect(screen.getByText('appAnnotation.editModal.removeThisCache')).toBeInTheDocument() + }) + }) + + // User Interactions + describe('User Interactions', () => { + it('should enable editing for query and answer sections', () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert - Edit links should be visible (using text content) + const editLinks = screen.getAllByText(/common\.operation\.edit/i) + expect(editLinks).toHaveLength(2) + }) + + it('should show remove option when annotationId is provided', () => { + // Arrange + const props = { + ...defaultProps, + annotationId: 'test-annotation-id', + } + + // Act + render() + + // Assert + expect(screen.getByText('appAnnotation.editModal.removeThisCache')).toBeInTheDocument() + }) + + it('should save content when edited', async () => { + // Arrange + const mockOnAdded = jest.fn() + const props = { + ...defaultProps, + onAdded: mockOnAdded, + } + const user = userEvent.setup() + + // Mock API response + mockAddAnnotation.mockResolvedValueOnce({ + id: 'test-annotation-id', + account: { name: 'Test User' }, + }) + + // Act + render() + + // Find and click edit link for query + const editLinks = screen.getAllByText(/common\.operation\.edit/i) + await user.click(editLinks[0]) + + // Find textarea and enter new content + const textarea = screen.getByRole('textbox') + await user.clear(textarea) + await user.type(textarea, 'New query content') + + // Click save button + const saveButton = screen.getByRole('button', { name: 'common.operation.save' }) + await user.click(saveButton) + + // Assert + expect(mockAddAnnotation).toHaveBeenCalledWith('test-app-id', { + question: 'New query content', + answer: 'Test answer', + message_id: undefined, + }) + }) + }) + + // API Calls + describe('API Calls', () => { + it('should call addAnnotation when saving new annotation', async () => { + // Arrange + const mockOnAdded = jest.fn() + const props = { + ...defaultProps, + onAdded: mockOnAdded, + } + const user = userEvent.setup() + + // Mock the API response + mockAddAnnotation.mockResolvedValueOnce({ + id: 'test-annotation-id', + account: { name: 'Test User' }, + }) + + // Act + render() + + // Edit query content + const editLinks = screen.getAllByText(/common\.operation\.edit/i) + await user.click(editLinks[0]) + + const textarea = screen.getByRole('textbox') + await user.clear(textarea) + await user.type(textarea, 'Updated query') + + const saveButton = screen.getByRole('button', { name: 'common.operation.save' }) + await user.click(saveButton) + + // Assert + expect(mockAddAnnotation).toHaveBeenCalledWith('test-app-id', { + question: 'Updated query', + answer: 'Test answer', + message_id: undefined, + }) + }) + + it('should call editAnnotation when updating existing annotation', async () => { + // Arrange + const mockOnEdited = jest.fn() + const props = { + ...defaultProps, + annotationId: 'test-annotation-id', + messageId: 'test-message-id', + onEdited: mockOnEdited, + } + const user = userEvent.setup() + + // Act + render() + + // Edit query content + const editLinks = screen.getAllByText(/common\.operation\.edit/i) + await user.click(editLinks[0]) + + const textarea = screen.getByRole('textbox') + await user.clear(textarea) + await user.type(textarea, 'Modified query') + + const saveButton = screen.getByRole('button', { name: 'common.operation.save' }) + await user.click(saveButton) + + // Assert + expect(mockEditAnnotation).toHaveBeenCalledWith( + 'test-app-id', + 'test-annotation-id', + { + message_id: 'test-message-id', + question: 'Modified query', + answer: 'Test answer', + }, + ) + }) + }) + + // State Management + describe('State Management', () => { + it('should initialize with closed confirm modal', () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert - Confirm dialog should not be visible initially + expect(screen.queryByText('appDebug.feature.annotation.removeConfirm')).not.toBeInTheDocument() + }) + + it('should show confirm modal when remove is clicked', async () => { + // Arrange + const props = { + ...defaultProps, + annotationId: 'test-annotation-id', + } + const user = userEvent.setup() + + // Act + render() + await user.click(screen.getByText('appAnnotation.editModal.removeThisCache')) + + // Assert - Confirmation dialog should appear + expect(screen.getByText('appDebug.feature.annotation.removeConfirm')).toBeInTheDocument() + }) + + it('should call onRemove when removal is confirmed', async () => { + // Arrange + const mockOnRemove = jest.fn() + const props = { + ...defaultProps, + annotationId: 'test-annotation-id', + onRemove: mockOnRemove, + } + const user = userEvent.setup() + + // Act + render() + + // Click remove + await user.click(screen.getByText('appAnnotation.editModal.removeThisCache')) + + // Click confirm + const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' }) + await user.click(confirmButton) + + // Assert + expect(mockOnRemove).toHaveBeenCalled() + }) + }) + + // Edge Cases (REQUIRED) + describe('Edge Cases', () => { + it('should handle empty query and answer', () => { + // Arrange + const props = { + ...defaultProps, + query: '', + answer: '', + } + + // Act + render() + + // Assert + expect(screen.getByText('appAnnotation.editModal.title')).toBeInTheDocument() + }) + + it('should handle very long content', () => { + // Arrange + const longQuery = 'Q'.repeat(1000) + const longAnswer = 'A'.repeat(1000) + const props = { + ...defaultProps, + query: longQuery, + answer: longAnswer, + } + + // Act + render() + + // Assert + expect(screen.getByText(longQuery)).toBeInTheDocument() + expect(screen.getByText(longAnswer)).toBeInTheDocument() + }) + + it('should handle special characters in content', () => { + // Arrange + const specialQuery = 'Query with & < > " \' characters' + const specialAnswer = 'Answer with & < > " \' characters' + const props = { + ...defaultProps, + query: specialQuery, + answer: specialAnswer, + } + + // Act + render() + + // Assert + expect(screen.getByText(specialQuery)).toBeInTheDocument() + expect(screen.getByText(specialAnswer)).toBeInTheDocument() + }) + + it('should handle onlyEditResponse prop', () => { + // Arrange + const props = { + ...defaultProps, + onlyEditResponse: true, + } + + // Act + render() + + // Assert - Query should be readonly, answer should be editable + const editLinks = screen.queryAllByText(/common\.operation\.edit/i) + expect(editLinks).toHaveLength(1) // Only answer should have edit button + }) + }) +}) diff --git a/web/app/components/app/configuration/dataset-config/context-var/index.spec.tsx b/web/app/components/app/configuration/dataset-config/context-var/index.spec.tsx new file mode 100644 index 0000000000..69378fbb32 --- /dev/null +++ b/web/app/components/app/configuration/dataset-config/context-var/index.spec.tsx @@ -0,0 +1,299 @@ +import * as React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import ContextVar from './index' +import type { Props } from './var-picker' + +// Mock external dependencies only +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: jest.fn() }), + usePathname: () => '/test', +})) + +type PortalToFollowElemProps = { + children: React.ReactNode + open?: boolean + onOpenChange?: (open: boolean) => void +} +type PortalToFollowElemTriggerProps = React.HTMLAttributes & { children?: React.ReactNode; asChild?: boolean } +type PortalToFollowElemContentProps = React.HTMLAttributes & { children?: React.ReactNode } + +jest.mock('@/app/components/base/portal-to-follow-elem', () => { + const PortalContext = React.createContext({ open: false }) + + const PortalToFollowElem = ({ children, open }: PortalToFollowElemProps) => { + return ( + +
{children}
+
+ ) + } + + const PortalToFollowElemContent = ({ children, ...props }: PortalToFollowElemContentProps) => { + const { open } = React.useContext(PortalContext) + if (!open) return null + return ( +
+ {children} +
+ ) + } + + const PortalToFollowElemTrigger = ({ children, asChild, ...props }: PortalToFollowElemTriggerProps) => { + if (asChild && React.isValidElement(children)) { + return React.cloneElement(children, { + ...props, + 'data-testid': 'portal-trigger', + } as React.HTMLAttributes) + } + return ( +
+ {children} +
+ ) + } + + return { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, + } +}) + +describe('ContextVar', () => { + const mockOptions: Props['options'] = [ + { name: 'Variable 1', value: 'var1', type: 'string' }, + { name: 'Variable 2', value: 'var2', type: 'number' }, + ] + + const defaultProps: Props = { + value: 'var1', + options: mockOptions, + onChange: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + // Rendering tests (REQUIRED) + describe('Rendering', () => { + it('should display query variable selector when options are provided', () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert + expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title')).toBeInTheDocument() + }) + + it('should show selected variable with proper formatting when value is provided', () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert + expect(screen.getByText('var1')).toBeInTheDocument() + expect(screen.getByText('{{')).toBeInTheDocument() + expect(screen.getByText('}}')).toBeInTheDocument() + }) + }) + + // Props tests (REQUIRED) + describe('Props', () => { + it('should display selected variable when value prop is provided', () => { + // Arrange + const props = { ...defaultProps, value: 'var2' } + + // Act + render() + + // Assert - Should display the selected value + expect(screen.getByText('var2')).toBeInTheDocument() + }) + + it('should show placeholder text when no value is selected', () => { + // Arrange + const props = { + ...defaultProps, + value: undefined, + } + + // Act + render() + + // Assert - Should show placeholder instead of variable + expect(screen.queryByText('var1')).not.toBeInTheDocument() + expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument() + }) + + it('should display custom tip message when notSelectedVarTip is provided', () => { + // Arrange + const props = { + ...defaultProps, + value: undefined, + notSelectedVarTip: 'Select a variable', + } + + // Act + render() + + // Assert + expect(screen.getByText('Select a variable')).toBeInTheDocument() + }) + + it('should apply custom className to VarPicker when provided', () => { + // Arrange + const props = { + ...defaultProps, + className: 'custom-class', + } + + // Act + const { container } = render() + + // Assert + expect(container.querySelector('.custom-class')).toBeInTheDocument() + }) + }) + + // User Interactions + describe('User Interactions', () => { + it('should call onChange when user selects a different variable', async () => { + // Arrange + const onChange = jest.fn() + const props = { ...defaultProps, onChange } + const user = userEvent.setup() + + // Act + render() + + const triggers = screen.getAllByTestId('portal-trigger') + const varPickerTrigger = triggers[triggers.length - 1] + + await user.click(varPickerTrigger) + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + + // Select a different option + const options = screen.getAllByText('var2') + expect(options.length).toBeGreaterThan(0) + await user.click(options[0]) + + // Assert + expect(onChange).toHaveBeenCalledWith('var2') + expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + }) + + it('should toggle dropdown when clicking the trigger button', async () => { + // Arrange + const props = { ...defaultProps } + const user = userEvent.setup() + + // Act + render() + + const triggers = screen.getAllByTestId('portal-trigger') + const varPickerTrigger = triggers[triggers.length - 1] + + // Open dropdown + await user.click(varPickerTrigger) + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + + // Close dropdown + await user.click(varPickerTrigger) + expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + }) + }) + + // Edge Cases (REQUIRED) + describe('Edge Cases', () => { + it('should handle undefined value gracefully', () => { + // Arrange + const props = { + ...defaultProps, + value: undefined, + } + + // Act + render() + + // Assert + expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title')).toBeInTheDocument() + expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument() + expect(screen.queryByText('var1')).not.toBeInTheDocument() + }) + + it('should handle empty options array', () => { + // Arrange + const props = { + ...defaultProps, + options: [], + value: undefined, + } + + // Act + render() + + // Assert + expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title')).toBeInTheDocument() + expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument() + }) + + it('should handle null value without crashing', () => { + // Arrange + const props = { + ...defaultProps, + value: undefined, + } + + // Act + render() + + // Assert + expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title')).toBeInTheDocument() + expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument() + }) + + it('should handle options with different data types', () => { + // Arrange + const props = { + ...defaultProps, + options: [ + { name: 'String Var', value: 'strVar', type: 'string' }, + { name: 'Number Var', value: '42', type: 'number' }, + { name: 'Boolean Var', value: 'true', type: 'boolean' }, + ], + value: 'strVar', + } + + // Act + render() + + // Assert + expect(screen.getByText('strVar')).toBeInTheDocument() + expect(screen.getByText('{{')).toBeInTheDocument() + expect(screen.getByText('}}')).toBeInTheDocument() + }) + + it('should render variable names with special characters safely', () => { + // Arrange + const props = { + ...defaultProps, + options: [ + { name: 'Variable with & < > " \' characters', value: 'specialVar', type: 'string' }, + ], + value: 'specialVar', + } + + // Act + render() + + // Assert + expect(screen.getByText('specialVar')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/app/configuration/dataset-config/context-var/var-picker.spec.tsx b/web/app/components/app/configuration/dataset-config/context-var/var-picker.spec.tsx new file mode 100644 index 0000000000..cb46ce9788 --- /dev/null +++ b/web/app/components/app/configuration/dataset-config/context-var/var-picker.spec.tsx @@ -0,0 +1,392 @@ +import * as React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import VarPicker, { type Props } from './var-picker' + +// Mock external dependencies only +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: jest.fn() }), + usePathname: () => '/test', +})) + +type PortalToFollowElemProps = { + children: React.ReactNode + open?: boolean + onOpenChange?: (open: boolean) => void +} +type PortalToFollowElemTriggerProps = React.HTMLAttributes & { children?: React.ReactNode; asChild?: boolean } +type PortalToFollowElemContentProps = React.HTMLAttributes & { children?: React.ReactNode } + +jest.mock('@/app/components/base/portal-to-follow-elem', () => { + const PortalContext = React.createContext({ open: false }) + + const PortalToFollowElem = ({ children, open }: PortalToFollowElemProps) => { + return ( + +
{children}
+
+ ) + } + + const PortalToFollowElemContent = ({ children, ...props }: PortalToFollowElemContentProps) => { + const { open } = React.useContext(PortalContext) + if (!open) return null + return ( +
+ {children} +
+ ) + } + + const PortalToFollowElemTrigger = ({ children, asChild, ...props }: PortalToFollowElemTriggerProps) => { + if (asChild && React.isValidElement(children)) { + return React.cloneElement(children, { + ...props, + 'data-testid': 'portal-trigger', + } as React.HTMLAttributes) + } + return ( +
+ {children} +
+ ) + } + + return { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, + } +}) + +describe('VarPicker', () => { + const mockOptions: Props['options'] = [ + { name: 'Variable 1', value: 'var1', type: 'string' }, + { name: 'Variable 2', value: 'var2', type: 'number' }, + { name: 'Variable 3', value: 'var3', type: 'boolean' }, + ] + + const defaultProps: Props = { + value: 'var1', + options: mockOptions, + onChange: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + // Rendering tests (REQUIRED) + describe('Rendering', () => { + it('should render variable picker with dropdown trigger', () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert + expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + expect(screen.getByText('var1')).toBeInTheDocument() + }) + + it('should display selected variable with type icon when value is provided', () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert + expect(screen.getByText('var1')).toBeInTheDocument() + expect(screen.getByText('{{')).toBeInTheDocument() + expect(screen.getByText('}}')).toBeInTheDocument() + // IconTypeIcon should be rendered (check for svg icon) + expect(document.querySelector('svg')).toBeInTheDocument() + }) + + it('should show placeholder text when no value is selected', () => { + // Arrange + const props = { + ...defaultProps, + value: undefined, + } + + // Act + render() + + // Assert + expect(screen.queryByText('var1')).not.toBeInTheDocument() + expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument() + }) + + it('should display custom tip message when notSelectedVarTip is provided', () => { + // Arrange + const props = { + ...defaultProps, + value: undefined, + notSelectedVarTip: 'Select a variable', + } + + // Act + render() + + // Assert + expect(screen.getByText('Select a variable')).toBeInTheDocument() + }) + + it('should render dropdown indicator icon', () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert - Trigger should be present + expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + }) + }) + + // Props tests (REQUIRED) + describe('Props', () => { + it('should apply custom className to wrapper', () => { + // Arrange + const props = { + ...defaultProps, + className: 'custom-class', + } + + // Act + const { container } = render() + + // Assert + expect(container.querySelector('.custom-class')).toBeInTheDocument() + }) + + it('should apply custom triggerClassName to trigger button', () => { + // Arrange + const props = { + ...defaultProps, + triggerClassName: 'custom-trigger-class', + } + + // Act + render() + + // Assert + expect(screen.getByTestId('portal-trigger')).toHaveClass('custom-trigger-class') + }) + + it('should display selected value with proper formatting', () => { + // Arrange + const props = { + ...defaultProps, + value: 'customVar', + options: [ + { name: 'Custom Variable', value: 'customVar', type: 'string' }, + ], + } + + // Act + render() + + // Assert + expect(screen.getByText('customVar')).toBeInTheDocument() + expect(screen.getByText('{{')).toBeInTheDocument() + expect(screen.getByText('}}')).toBeInTheDocument() + }) + }) + + // User Interactions + describe('User Interactions', () => { + it('should open dropdown when clicking the trigger button', async () => { + // Arrange + const onChange = jest.fn() + const props = { ...defaultProps, onChange } + const user = userEvent.setup() + + // Act + render() + await user.click(screen.getByTestId('portal-trigger')) + + // Assert + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + + it('should call onChange and close dropdown when selecting an option', async () => { + // Arrange + const onChange = jest.fn() + const props = { ...defaultProps, onChange } + const user = userEvent.setup() + + // Act + render() + + // Open dropdown + await user.click(screen.getByTestId('portal-trigger')) + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + + // Select a different option + const options = screen.getAllByText('var2') + expect(options.length).toBeGreaterThan(0) + await user.click(options[0]) + + // Assert + expect(onChange).toHaveBeenCalledWith('var2') + expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + }) + + it('should toggle dropdown when clicking trigger button multiple times', async () => { + // Arrange + const props = { ...defaultProps } + const user = userEvent.setup() + + // Act + render() + + const trigger = screen.getByTestId('portal-trigger') + + // Open dropdown + await user.click(trigger) + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + + // Close dropdown + await user.click(trigger) + expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + }) + }) + + // State Management + describe('State Management', () => { + it('should initialize with closed dropdown', () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert + expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + }) + + it('should toggle dropdown state on trigger click', async () => { + // Arrange + const props = { ...defaultProps } + const user = userEvent.setup() + + // Act + render() + + const trigger = screen.getByTestId('portal-trigger') + expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + + // Open dropdown + await user.click(trigger) + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + + // Close dropdown + await user.click(trigger) + expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + }) + + it('should preserve selected value when dropdown is closed without selection', async () => { + // Arrange + const props = { ...defaultProps } + const user = userEvent.setup() + + // Act + render() + + // Open and close dropdown without selecting anything + const trigger = screen.getByTestId('portal-trigger') + await user.click(trigger) + await user.click(trigger) + + // Assert + expect(screen.getByText('var1')).toBeInTheDocument() // Original value still displayed + }) + }) + + // Edge Cases (REQUIRED) + describe('Edge Cases', () => { + it('should handle undefined value gracefully', () => { + // Arrange + const props = { + ...defaultProps, + value: undefined, + } + + // Act + render() + + // Assert + expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument() + expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + }) + + it('should handle empty options array', () => { + // Arrange + const props = { + ...defaultProps, + options: [], + value: undefined, + } + + // Act + render() + + // Assert + expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument() + }) + + it('should handle null value without crashing', () => { + // Arrange + const props = { + ...defaultProps, + value: undefined, + } + + // Act + render() + + // Assert + expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument() + }) + + it('should handle variable names with special characters safely', () => { + // Arrange + const props = { + ...defaultProps, + options: [ + { name: 'Variable with & < > " \' characters', value: 'specialVar', type: 'string' }, + ], + value: 'specialVar', + } + + // Act + render() + + // Assert + expect(screen.getByText('specialVar')).toBeInTheDocument() + }) + + it('should handle long variable names', () => { + // Arrange + const props = { + ...defaultProps, + options: [ + { name: 'A very long variable name that should be truncated', value: 'longVar', type: 'string' }, + ], + value: 'longVar', + } + + // Act + render() + + // Assert + expect(screen.getByText('longVar')).toBeInTheDocument() + expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + }) + }) +})