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()
+ })
+ })
+})