Files
dify/web/app/components/datasets/documents/create-from-pipeline/data-source-options/index.spec.tsx
2025-12-19 17:49:51 +08:00

1663 lines
51 KiB
TypeScript

import { act, fireEvent, render, screen } from '@testing-library/react'
import { renderHook } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import React from 'react'
import DataSourceOptions from './index'
import OptionCard from './option-card'
import DatasourceIcon from './datasource-icon'
import { useDatasourceIcon } from './hooks'
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import { BlockEnum, type Node } from '@/app/components/workflow/types'
import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types'
// ==========================================
// Mock External Dependencies
// ==========================================
// Mock useDatasourceOptions hook from parent hooks
const mockUseDatasourceOptions = jest.fn()
jest.mock('../hooks', () => ({
useDatasourceOptions: (nodes: Node<DataSourceNodeType>[]) => mockUseDatasourceOptions(nodes),
}))
// Mock useDataSourceList API hook
const mockUseDataSourceList = jest.fn()
jest.mock('@/service/use-pipeline', () => ({
useDataSourceList: (enabled: boolean) => mockUseDataSourceList(enabled),
}))
// Mock transformDataSourceToTool utility
const mockTransformDataSourceToTool = jest.fn()
jest.mock('@/app/components/workflow/block-selector/utils', () => ({
transformDataSourceToTool: (item: unknown) => mockTransformDataSourceToTool(item),
}))
// Mock basePath
jest.mock('@/utils/var', () => ({
basePath: '/mock-base-path',
}))
// ==========================================
// Test Data Builders
// ==========================================
const createMockDataSourceNodeData = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => ({
title: 'Test Data Source',
desc: 'Test description',
type: BlockEnum.DataSource,
plugin_id: 'test-plugin-id',
provider_type: 'local_file',
provider_name: 'Test Provider',
datasource_name: 'test-datasource',
datasource_label: 'Test Datasource Label',
datasource_parameters: {},
datasource_configurations: {},
...overrides,
})
const createMockPipelineNode = (overrides?: Partial<Node<DataSourceNodeType>>): Node<DataSourceNodeType> => {
const nodeData = createMockDataSourceNodeData(overrides?.data)
return {
id: `node-${Math.random().toString(36).slice(2, 9)}`,
type: 'custom',
position: { x: 0, y: 0 },
data: nodeData,
...overrides,
}
}
const createMockPipelineNodes = (count = 3): Node<DataSourceNodeType>[] => {
return Array.from({ length: count }, (_, i) =>
createMockPipelineNode({
id: `node-${i + 1}`,
data: createMockDataSourceNodeData({
title: `Data Source ${i + 1}`,
plugin_id: `plugin-${i + 1}`,
datasource_name: `datasource-${i + 1}`,
}),
}),
)
}
const createMockDatasourceOption = (
node: Node<DataSourceNodeType>,
) => ({
label: node.data.title,
value: node.id,
data: node.data,
})
const createMockDataSourceListItem = (overrides?: Record<string, unknown>) => ({
declaration: {
identity: {
icon: '/icons/test-icon.png',
name: 'test-datasource',
label: { en_US: 'Test Datasource' },
},
provider: 'test-provider',
},
plugin_id: 'test-plugin-id',
...overrides,
})
// ==========================================
// Test Utilities
// ==========================================
const createQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const renderWithProviders = (
ui: React.ReactElement,
queryClient?: QueryClient,
) => {
const client = queryClient || createQueryClient()
return render(
<QueryClientProvider client={client}>
{ui}
</QueryClientProvider>,
)
}
const createHookWrapper = () => {
const queryClient = createQueryClient()
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
// ==========================================
// DatasourceIcon Tests
// ==========================================
describe('DatasourceIcon', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<DatasourceIcon iconUrl="https://example.com/icon.png" />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render icon with background image', () => {
// Arrange
const iconUrl = 'https://example.com/icon.png'
// Act
const { container } = render(<DatasourceIcon iconUrl={iconUrl} />)
// Assert
const iconDiv = container.querySelector('[style*="background-image"]')
expect(iconDiv).toHaveStyle({ backgroundImage: `url(${iconUrl})` })
})
it('should render with default size (sm)', () => {
// Arrange & Act
const { container } = render(<DatasourceIcon iconUrl="https://example.com/icon.png" />)
// Assert - Default size is 'sm' which maps to 'w-5 h-5'
expect(container.firstChild).toHaveClass('w-5')
expect(container.firstChild).toHaveClass('h-5')
})
})
describe('Props', () => {
describe('size', () => {
it('should render with xs size', () => {
// Arrange & Act
const { container } = render(
<DatasourceIcon iconUrl="https://example.com/icon.png" size="xs" />,
)
// Assert
expect(container.firstChild).toHaveClass('w-4')
expect(container.firstChild).toHaveClass('h-4')
expect(container.firstChild).toHaveClass('rounded-[5px]')
})
it('should render with sm size', () => {
// Arrange & Act
const { container } = render(
<DatasourceIcon iconUrl="https://example.com/icon.png" size="sm" />,
)
// Assert
expect(container.firstChild).toHaveClass('w-5')
expect(container.firstChild).toHaveClass('h-5')
expect(container.firstChild).toHaveClass('rounded-md')
})
it('should render with md size', () => {
// Arrange & Act
const { container } = render(
<DatasourceIcon iconUrl="https://example.com/icon.png" size="md" />,
)
// Assert
expect(container.firstChild).toHaveClass('w-6')
expect(container.firstChild).toHaveClass('h-6')
expect(container.firstChild).toHaveClass('rounded-lg')
})
})
describe('className', () => {
it('should apply custom className', () => {
// Arrange & Act
const { container } = render(
<DatasourceIcon iconUrl="https://example.com/icon.png" className="custom-class" />,
)
// Assert
expect(container.firstChild).toHaveClass('custom-class')
})
it('should merge custom className with default classes', () => {
// Arrange & Act
const { container } = render(
<DatasourceIcon iconUrl="https://example.com/icon.png" className="custom-class" size="sm" />,
)
// Assert
expect(container.firstChild).toHaveClass('custom-class')
expect(container.firstChild).toHaveClass('w-5')
expect(container.firstChild).toHaveClass('h-5')
})
})
describe('iconUrl', () => {
it('should handle empty iconUrl', () => {
// Arrange & Act
const { container } = render(<DatasourceIcon iconUrl="" />)
// Assert
const iconDiv = container.querySelector('[style*="background-image"]')
expect(iconDiv).toHaveStyle({ backgroundImage: 'url()' })
})
it('should handle special characters in iconUrl', () => {
// Arrange
const iconUrl = 'https://example.com/icon.png?param=value&other=123'
// Act
const { container } = render(<DatasourceIcon iconUrl={iconUrl} />)
// Assert
const iconDiv = container.querySelector('[style*="background-image"]')
expect(iconDiv).toHaveStyle({ backgroundImage: `url(${iconUrl})` })
})
it('should handle data URL as iconUrl', () => {
// Arrange
const dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
// Act
const { container } = render(<DatasourceIcon iconUrl={dataUrl} />)
// Assert
const iconDiv = container.querySelector('[style*="background-image"]')
expect(iconDiv).toBeInTheDocument()
})
})
})
describe('Styling', () => {
it('should have flex container classes', () => {
// Arrange & Act
const { container } = render(<DatasourceIcon iconUrl="https://example.com/icon.png" />)
// Assert
expect(container.firstChild).toHaveClass('flex')
expect(container.firstChild).toHaveClass('items-center')
expect(container.firstChild).toHaveClass('justify-center')
})
it('should have shadow-xs class from size map', () => {
// Arrange & Act
const { container } = render(<DatasourceIcon iconUrl="https://example.com/icon.png" />)
// Assert - Default size 'sm' has shadow-xs
expect(container.firstChild).toHaveClass('shadow-xs')
})
it('should have inner div with bg-cover class', () => {
// Arrange & Act
const { container } = render(<DatasourceIcon iconUrl="https://example.com/icon.png" />)
// Assert
const innerDiv = container.querySelector('.bg-cover')
expect(innerDiv).toBeInTheDocument()
expect(innerDiv).toHaveClass('bg-center')
expect(innerDiv).toHaveClass('rounded-md')
})
})
})
// ==========================================
// useDatasourceIcon Hook Tests
// ==========================================
describe('useDatasourceIcon', () => {
beforeEach(() => {
jest.clearAllMocks()
mockUseDataSourceList.mockReturnValue({
data: [],
isSuccess: false,
})
mockTransformDataSourceToTool.mockImplementation(item => ({
plugin_id: item.plugin_id,
icon: item.declaration?.identity?.icon,
}))
})
describe('Loading State', () => {
it('should return undefined when data is not loaded', () => {
// Arrange
mockUseDataSourceList.mockReturnValue({
data: undefined,
isSuccess: false,
})
const nodeData = createMockDataSourceNodeData()
// Act
const { result } = renderHook(() => useDatasourceIcon(nodeData), {
wrapper: createHookWrapper(),
})
// Assert
expect(result.current).toBeUndefined()
})
it('should call useDataSourceList with true', () => {
// Arrange
const nodeData = createMockDataSourceNodeData()
// Act
renderHook(() => useDatasourceIcon(nodeData), {
wrapper: createHookWrapper(),
})
// Assert
expect(mockUseDataSourceList).toHaveBeenCalledWith(true)
})
})
describe('Success State', () => {
it('should return icon when data is loaded and plugin matches', () => {
// Arrange
const mockDataSourceList = [
createMockDataSourceListItem({
plugin_id: 'test-plugin-id',
declaration: {
identity: {
icon: '/icons/test-icon.png',
name: 'test',
label: { en_US: 'Test' },
},
},
}),
]
mockUseDataSourceList.mockReturnValue({
data: mockDataSourceList,
isSuccess: true,
})
mockTransformDataSourceToTool.mockImplementation(item => ({
plugin_id: item.plugin_id,
icon: item.declaration?.identity?.icon,
}))
const nodeData = createMockDataSourceNodeData({ plugin_id: 'test-plugin-id' })
// Act
const { result } = renderHook(() => useDatasourceIcon(nodeData), {
wrapper: createHookWrapper(),
})
// Assert - Icon should have basePath prepended
expect(result.current).toBe('/mock-base-path/icons/test-icon.png')
})
it('should return undefined when plugin does not match', () => {
// Arrange
const mockDataSourceList = [
createMockDataSourceListItem({
plugin_id: 'other-plugin-id',
}),
]
mockUseDataSourceList.mockReturnValue({
data: mockDataSourceList,
isSuccess: true,
})
const nodeData = createMockDataSourceNodeData({ plugin_id: 'test-plugin-id' })
// Act
const { result } = renderHook(() => useDatasourceIcon(nodeData), {
wrapper: createHookWrapper(),
})
// Assert
expect(result.current).toBeUndefined()
})
it('should prepend basePath to icon when icon does not include basePath', () => {
// Arrange
const mockDataSourceList = [
createMockDataSourceListItem({
plugin_id: 'test-plugin-id',
declaration: {
identity: {
icon: '/icons/test-icon.png',
name: 'test',
label: { en_US: 'Test' },
},
},
}),
]
mockUseDataSourceList.mockReturnValue({
data: mockDataSourceList,
isSuccess: true,
})
mockTransformDataSourceToTool.mockImplementation(item => ({
plugin_id: item.plugin_id,
icon: item.declaration?.identity?.icon,
}))
const nodeData = createMockDataSourceNodeData({ plugin_id: 'test-plugin-id' })
// Act
const { result } = renderHook(() => useDatasourceIcon(nodeData), {
wrapper: createHookWrapper(),
})
// Assert - Icon should have basePath prepended
expect(result.current).toBe('/mock-base-path/icons/test-icon.png')
})
it('should not prepend basePath when icon already includes basePath', () => {
// Arrange
const mockDataSourceList = [
createMockDataSourceListItem({
plugin_id: 'test-plugin-id',
declaration: {
identity: {
icon: '/mock-base-path/icons/test-icon.png',
name: 'test',
label: { en_US: 'Test' },
},
},
}),
]
mockUseDataSourceList.mockReturnValue({
data: mockDataSourceList,
isSuccess: true,
})
mockTransformDataSourceToTool.mockImplementation(item => ({
plugin_id: item.plugin_id,
icon: item.declaration?.identity?.icon,
}))
const nodeData = createMockDataSourceNodeData({ plugin_id: 'test-plugin-id' })
// Act
const { result } = renderHook(() => useDatasourceIcon(nodeData), {
wrapper: createHookWrapper(),
})
// Assert - Icon should not be modified
expect(result.current).toBe('/mock-base-path/icons/test-icon.png')
})
})
describe('Edge Cases', () => {
it('should handle empty dataSourceList', () => {
// Arrange
mockUseDataSourceList.mockReturnValue({
data: [],
isSuccess: true,
})
const nodeData = createMockDataSourceNodeData()
// Act
const { result } = renderHook(() => useDatasourceIcon(nodeData), {
wrapper: createHookWrapper(),
})
// Assert
expect(result.current).toBeUndefined()
})
it('should handle null dataSourceList', () => {
// Arrange
mockUseDataSourceList.mockReturnValue({
data: null,
isSuccess: true,
})
const nodeData = createMockDataSourceNodeData()
// Act
const { result } = renderHook(() => useDatasourceIcon(nodeData), {
wrapper: createHookWrapper(),
})
// Assert
expect(result.current).toBeUndefined()
})
it('should handle icon as non-string type', () => {
// Arrange
const mockDataSourceList = [
createMockDataSourceListItem({
plugin_id: 'test-plugin-id',
declaration: {
identity: {
icon: { url: '/icons/test-icon.png' }, // Object instead of string
name: 'test',
label: { en_US: 'Test' },
},
},
}),
]
mockUseDataSourceList.mockReturnValue({
data: mockDataSourceList,
isSuccess: true,
})
mockTransformDataSourceToTool.mockImplementation(item => ({
plugin_id: item.plugin_id,
icon: item.declaration?.identity?.icon,
}))
const nodeData = createMockDataSourceNodeData({ plugin_id: 'test-plugin-id' })
// Act
const { result } = renderHook(() => useDatasourceIcon(nodeData), {
wrapper: createHookWrapper(),
})
// Assert - Should return the icon object as-is since it's not a string
expect(result.current).toEqual({ url: '/icons/test-icon.png' })
})
it('should memoize result based on plugin_id', () => {
// Arrange
const mockDataSourceList = [
createMockDataSourceListItem({
plugin_id: 'test-plugin-id',
}),
]
mockUseDataSourceList.mockReturnValue({
data: mockDataSourceList,
isSuccess: true,
})
const nodeData = createMockDataSourceNodeData({ plugin_id: 'test-plugin-id' })
// Act
const { result, rerender } = renderHook(() => useDatasourceIcon(nodeData), {
wrapper: createHookWrapper(),
})
const firstResult = result.current
// Rerender with same props
rerender()
// Assert - Should return the same memoized result
expect(result.current).toBe(firstResult)
})
})
})
// ==========================================
// OptionCard Tests
// ==========================================
describe('OptionCard', () => {
const defaultProps = {
label: 'Test Option',
selected: false,
nodeData: createMockDataSourceNodeData(),
}
beforeEach(() => {
jest.clearAllMocks()
// Setup default mock for useDatasourceIcon
mockUseDataSourceList.mockReturnValue({
data: [],
isSuccess: true,
})
})
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
renderWithProviders(<OptionCard {...defaultProps} />)
// Assert
expect(screen.getByText('Test Option')).toBeInTheDocument()
})
it('should render label text', () => {
// Arrange & Act
renderWithProviders(<OptionCard {...defaultProps} label="Custom Label" />)
// Assert
expect(screen.getByText('Custom Label')).toBeInTheDocument()
})
it('should render DatasourceIcon component', () => {
// Arrange & Act
const { container } = renderWithProviders(<OptionCard {...defaultProps} />)
// Assert - DatasourceIcon container should exist
const iconContainer = container.querySelector('.size-8')
expect(iconContainer).toBeInTheDocument()
})
it('should set title attribute for label truncation', () => {
// Arrange
const longLabel = 'This is a very long label that might be truncated'
// Act
renderWithProviders(<OptionCard {...defaultProps} label={longLabel} />)
// Assert
const labelElement = screen.getByText(longLabel)
expect(labelElement).toHaveAttribute('title', longLabel)
})
})
describe('Props', () => {
describe('selected', () => {
it('should apply selected styles when selected is true', () => {
// Arrange & Act
const { container } = renderWithProviders(
<OptionCard {...defaultProps} selected={true} />,
)
// Assert
const card = container.firstChild
expect(card).toHaveClass('border-components-option-card-option-selected-border')
expect(card).toHaveClass('bg-components-option-card-option-selected-bg')
})
it('should apply unselected styles when selected is false', () => {
// Arrange & Act
const { container } = renderWithProviders(
<OptionCard {...defaultProps} selected={false} />,
)
// Assert
const card = container.firstChild
expect(card).toHaveClass('border-components-option-card-option-border')
expect(card).toHaveClass('bg-components-option-card-option-bg')
})
it('should apply text-text-primary to label when selected', () => {
// Arrange & Act
renderWithProviders(<OptionCard {...defaultProps} selected={true} />)
// Assert
const label = screen.getByText('Test Option')
expect(label).toHaveClass('text-text-primary')
})
it('should apply text-text-secondary to label when not selected', () => {
// Arrange & Act
renderWithProviders(<OptionCard {...defaultProps} selected={false} />)
// Assert
const label = screen.getByText('Test Option')
expect(label).toHaveClass('text-text-secondary')
})
})
describe('onClick', () => {
it('should call onClick when card is clicked', () => {
// Arrange
const mockOnClick = jest.fn()
renderWithProviders(
<OptionCard {...defaultProps} onClick={mockOnClick} />,
)
// Act - Click on the label text's parent card
const labelElement = screen.getByText('Test Option')
const card = labelElement.closest('[class*="cursor-pointer"]')
expect(card).toBeInTheDocument()
fireEvent.click(card!)
// Assert
expect(mockOnClick).toHaveBeenCalledTimes(1)
})
it('should not crash when onClick is not provided', () => {
// Arrange & Act
renderWithProviders(
<OptionCard {...defaultProps} onClick={undefined} />,
)
// Act - Click on the label text's parent card should not throw
const labelElement = screen.getByText('Test Option')
const card = labelElement.closest('[class*="cursor-pointer"]')
expect(card).toBeInTheDocument()
fireEvent.click(card!)
// Assert - Component should still be rendered
expect(screen.getByText('Test Option')).toBeInTheDocument()
})
})
describe('nodeData', () => {
it('should pass nodeData to useDatasourceIcon hook', () => {
// Arrange
const customNodeData = createMockDataSourceNodeData({ plugin_id: 'custom-plugin' })
// Act
renderWithProviders(<OptionCard {...defaultProps} nodeData={customNodeData} />)
// Assert - Hook should be called (via useDataSourceList mock)
expect(mockUseDataSourceList).toHaveBeenCalled()
})
})
})
describe('Styling', () => {
it('should have cursor-pointer class', () => {
// Arrange & Act
const { container } = renderWithProviders(<OptionCard {...defaultProps} />)
// Assert
expect(container.firstChild).toHaveClass('cursor-pointer')
})
it('should have flex layout classes', () => {
// Arrange & Act
const { container } = renderWithProviders(<OptionCard {...defaultProps} />)
// Assert
expect(container.firstChild).toHaveClass('flex')
expect(container.firstChild).toHaveClass('items-center')
expect(container.firstChild).toHaveClass('gap-2')
})
it('should have rounded-xl border', () => {
// Arrange & Act
const { container } = renderWithProviders(<OptionCard {...defaultProps} />)
// Assert
expect(container.firstChild).toHaveClass('rounded-xl')
expect(container.firstChild).toHaveClass('border')
})
it('should have padding p-3', () => {
// Arrange & Act
const { container } = renderWithProviders(<OptionCard {...defaultProps} />)
// Assert
expect(container.firstChild).toHaveClass('p-3')
})
it('should have line-clamp-2 for label truncation', () => {
// Arrange & Act
renderWithProviders(<OptionCard {...defaultProps} />)
// Assert
const label = screen.getByText('Test Option')
expect(label).toHaveClass('line-clamp-2')
})
})
describe('Memoization', () => {
it('should be wrapped with React.memo', () => {
// Assert - OptionCard should be a memoized component
expect(OptionCard).toBeDefined()
// React.memo wraps the component, so we check it renders correctly
const { container } = renderWithProviders(<OptionCard {...defaultProps} />)
expect(container.firstChild).toBeInTheDocument()
})
})
})
// ==========================================
// DataSourceOptions Tests
// ==========================================
describe('DataSourceOptions', () => {
const defaultNodes = createMockPipelineNodes(3)
const defaultOptions = defaultNodes.map(createMockDatasourceOption)
const defaultProps = {
pipelineNodes: defaultNodes,
datasourceNodeId: '',
onSelect: jest.fn(),
}
beforeEach(() => {
jest.clearAllMocks()
mockUseDatasourceOptions.mockReturnValue(defaultOptions)
mockUseDataSourceList.mockReturnValue({
data: [],
isSuccess: true,
})
})
// ==========================================
// Rendering Tests
// ==========================================
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
renderWithProviders(<DataSourceOptions {...defaultProps} />)
// Assert
expect(screen.getByText('Data Source 1')).toBeInTheDocument()
expect(screen.getByText('Data Source 2')).toBeInTheDocument()
expect(screen.getByText('Data Source 3')).toBeInTheDocument()
})
it('should render correct number of option cards', () => {
// Arrange & Act
renderWithProviders(<DataSourceOptions {...defaultProps} />)
// Assert
expect(screen.getByText('Data Source 1')).toBeInTheDocument()
expect(screen.getByText('Data Source 2')).toBeInTheDocument()
expect(screen.getByText('Data Source 3')).toBeInTheDocument()
})
it('should render with grid layout', () => {
// Arrange & Act
const { container } = renderWithProviders(<DataSourceOptions {...defaultProps} />)
// Assert
const gridContainer = container.firstChild
expect(gridContainer).toHaveClass('grid')
expect(gridContainer).toHaveClass('w-full')
expect(gridContainer).toHaveClass('grid-cols-4')
expect(gridContainer).toHaveClass('gap-1')
})
it('should render no option cards when options is empty', () => {
// Arrange
mockUseDatasourceOptions.mockReturnValue([])
// Act
const { container } = renderWithProviders(<DataSourceOptions {...defaultProps} />)
// Assert
expect(screen.queryByText('Data Source')).not.toBeInTheDocument()
// Grid container should still exist
expect(container.firstChild).toHaveClass('grid')
})
it('should render single option card when only one option exists', () => {
// Arrange
const singleOption = [createMockDatasourceOption(defaultNodes[0])]
mockUseDatasourceOptions.mockReturnValue(singleOption)
// Act
renderWithProviders(<DataSourceOptions {...defaultProps} />)
// Assert
expect(screen.getByText('Data Source 1')).toBeInTheDocument()
expect(screen.queryByText('Data Source 2')).not.toBeInTheDocument()
})
})
// ==========================================
// Props Tests
// ==========================================
describe('Props', () => {
describe('pipelineNodes', () => {
it('should pass pipelineNodes to useDatasourceOptions hook', () => {
// Arrange
const customNodes = createMockPipelineNodes(2)
mockUseDatasourceOptions.mockReturnValue(customNodes.map(createMockDatasourceOption))
// Act
renderWithProviders(
<DataSourceOptions {...defaultProps} pipelineNodes={customNodes} />,
)
// Assert
expect(mockUseDatasourceOptions).toHaveBeenCalledWith(customNodes)
})
it('should handle empty pipelineNodes array', () => {
// Arrange
mockUseDatasourceOptions.mockReturnValue([])
// Act
renderWithProviders(
<DataSourceOptions {...defaultProps} pipelineNodes={[]} />,
)
// Assert
expect(mockUseDatasourceOptions).toHaveBeenCalledWith([])
})
})
describe('datasourceNodeId', () => {
it('should mark corresponding option as selected', () => {
// Arrange & Act
const { container } = renderWithProviders(
<DataSourceOptions
{...defaultProps}
datasourceNodeId="node-2"
/>,
)
// Assert - Check for selected styling on second card
const cards = container.querySelectorAll('.rounded-xl.border')
expect(cards[1]).toHaveClass('border-components-option-card-option-selected-border')
})
it('should show no selection when datasourceNodeId is empty', () => {
// Arrange & Act
const { container } = renderWithProviders(
<DataSourceOptions
{...defaultProps}
datasourceNodeId=""
/>,
)
// Assert - No card should have selected styling
const selectedCards = container.querySelectorAll('.border-components-option-card-option-selected-border')
expect(selectedCards).toHaveLength(0)
})
it('should show no selection when datasourceNodeId does not match any option', () => {
// Arrange & Act
const { container } = renderWithProviders(
<DataSourceOptions
{...defaultProps}
datasourceNodeId="non-existent-node"
/>,
)
// Assert
const selectedCards = container.querySelectorAll('.border-components-option-card-option-selected-border')
expect(selectedCards).toHaveLength(0)
})
it('should update selection when datasourceNodeId changes', () => {
// Arrange
const { container, rerender } = renderWithProviders(
<DataSourceOptions
{...defaultProps}
datasourceNodeId="node-1"
/>,
)
// Assert initial selection
let cards = container.querySelectorAll('.rounded-xl.border')
expect(cards[0]).toHaveClass('border-components-option-card-option-selected-border')
// Act - Change selection
rerender(
<QueryClientProvider client={createQueryClient()}>
<DataSourceOptions
{...defaultProps}
datasourceNodeId="node-2"
/>
</QueryClientProvider>,
)
// Assert new selection
cards = container.querySelectorAll('.rounded-xl.border')
expect(cards[0]).not.toHaveClass('border-components-option-card-option-selected-border')
expect(cards[1]).toHaveClass('border-components-option-card-option-selected-border')
})
})
describe('onSelect', () => {
it('should receive onSelect callback', () => {
// Arrange
const mockOnSelect = jest.fn()
// Act
renderWithProviders(
<DataSourceOptions
{...defaultProps}
onSelect={mockOnSelect}
/>,
)
// Assert - Component renders without error
expect(screen.getByText('Data Source 1')).toBeInTheDocument()
})
})
})
// ==========================================
// Side Effects and Cleanup Tests
// ==========================================
describe('Side Effects and Cleanup', () => {
describe('useEffect - Auto-select first option', () => {
it('should auto-select first option when options exist and no datasourceNodeId', () => {
// Arrange
const mockOnSelect = jest.fn()
// Act
renderWithProviders(
<DataSourceOptions
{...defaultProps}
datasourceNodeId=""
onSelect={mockOnSelect}
/>,
)
// Assert - Should auto-select first option on mount
expect(mockOnSelect).toHaveBeenCalledTimes(1)
expect(mockOnSelect).toHaveBeenCalledWith({
nodeId: 'node-1',
nodeData: defaultOptions[0].data,
} satisfies Datasource)
})
it('should NOT auto-select when datasourceNodeId is provided', () => {
// Arrange
const mockOnSelect = jest.fn()
// Act
renderWithProviders(
<DataSourceOptions
{...defaultProps}
datasourceNodeId="node-2"
onSelect={mockOnSelect}
/>,
)
// Assert - Should not auto-select because datasourceNodeId is provided
expect(mockOnSelect).not.toHaveBeenCalled()
})
it('should NOT auto-select when options array is empty', () => {
// Arrange
mockUseDatasourceOptions.mockReturnValue([])
const mockOnSelect = jest.fn()
// Act
renderWithProviders(
<DataSourceOptions
{...defaultProps}
pipelineNodes={[]}
datasourceNodeId=""
onSelect={mockOnSelect}
/>,
)
// Assert
expect(mockOnSelect).not.toHaveBeenCalled()
})
it('should only run useEffect once on initial mount', () => {
// Arrange
const mockOnSelect = jest.fn()
const { rerender } = renderWithProviders(
<DataSourceOptions
{...defaultProps}
datasourceNodeId=""
onSelect={mockOnSelect}
/>,
)
// Assert - Called once on mount
expect(mockOnSelect).toHaveBeenCalledTimes(1)
// Act - Rerender with same props
rerender(
<QueryClientProvider client={createQueryClient()}>
<DataSourceOptions
{...defaultProps}
datasourceNodeId=""
onSelect={mockOnSelect}
/>
</QueryClientProvider>,
)
// Assert - Still called only once (useEffect has empty dependency array)
expect(mockOnSelect).toHaveBeenCalledTimes(1)
})
})
})
// ==========================================
// Callback Stability and Memoization Tests
// ==========================================
describe('Callback Stability and Memoization', () => {
it('should maintain callback reference stability across renders with same props', () => {
// Arrange
const mockOnSelect = jest.fn()
const { rerender } = renderWithProviders(
<DataSourceOptions
{...defaultProps}
onSelect={mockOnSelect}
/>,
)
// Get initial click handlers
expect(screen.getByText('Data Source 1')).toBeInTheDocument()
// Trigger clicks to test handlers work
fireEvent.click(screen.getByText('Data Source 1'))
expect(mockOnSelect).toHaveBeenCalledTimes(2) // 1 auto-select + 1 click
// Act - Rerender with same onSelect reference
rerender(
<QueryClientProvider client={createQueryClient()}>
<DataSourceOptions
{...defaultProps}
onSelect={mockOnSelect}
/>
</QueryClientProvider>,
)
// Assert - Component still works after rerender
fireEvent.click(screen.getByText('Data Source 2'))
expect(mockOnSelect).toHaveBeenCalledTimes(3)
})
it('should update callback when onSelect changes', () => {
// Arrange
const mockOnSelect1 = jest.fn()
const mockOnSelect2 = jest.fn()
const { rerender } = renderWithProviders(
<DataSourceOptions
{...defaultProps}
datasourceNodeId="node-1"
onSelect={mockOnSelect1}
/>,
)
// Act - Click with first callback
fireEvent.click(screen.getByText('Data Source 2'))
expect(mockOnSelect1).toHaveBeenCalledTimes(1)
// Act - Change callback
rerender(
<QueryClientProvider client={createQueryClient()}>
<DataSourceOptions
{...defaultProps}
datasourceNodeId="node-1"
onSelect={mockOnSelect2}
/>
</QueryClientProvider>,
)
// Act - Click with new callback
fireEvent.click(screen.getByText('Data Source 3'))
// Assert - New callback should be called
expect(mockOnSelect2).toHaveBeenCalledTimes(1)
expect(mockOnSelect2).toHaveBeenCalledWith({
nodeId: 'node-3',
nodeData: defaultOptions[2].data,
})
})
it('should update callback when options change', () => {
// Arrange
const mockOnSelect = jest.fn()
const { rerender } = renderWithProviders(
<DataSourceOptions
{...defaultProps}
datasourceNodeId="node-1"
onSelect={mockOnSelect}
/>,
)
// Act - Click first option
fireEvent.click(screen.getByText('Data Source 1'))
expect(mockOnSelect).toHaveBeenCalledWith({
nodeId: 'node-1',
nodeData: defaultOptions[0].data,
})
// Act - Change options
const newNodes = createMockPipelineNodes(2)
const newOptions = newNodes.map(node => createMockDatasourceOption(node))
mockUseDatasourceOptions.mockReturnValue(newOptions)
rerender(
<QueryClientProvider client={createQueryClient()}>
<DataSourceOptions
pipelineNodes={newNodes}
datasourceNodeId="node-1"
onSelect={mockOnSelect}
/>
</QueryClientProvider>,
)
// Act - Click updated first option
fireEvent.click(screen.getByText('Data Source 1'))
// Assert - Callback receives new option data
expect(mockOnSelect).toHaveBeenLastCalledWith({
nodeId: newOptions[0].value,
nodeData: newOptions[0].data,
})
})
})
// ==========================================
// User Interactions and Event Handlers Tests
// ==========================================
describe('User Interactions and Event Handlers', () => {
describe('Option Selection', () => {
it('should call onSelect with correct datasource when clicking an option', () => {
// Arrange
const mockOnSelect = jest.fn()
renderWithProviders(
<DataSourceOptions
{...defaultProps}
datasourceNodeId="node-1"
onSelect={mockOnSelect}
/>,
)
// Act - Click second option
fireEvent.click(screen.getByText('Data Source 2'))
// Assert
expect(mockOnSelect).toHaveBeenCalledTimes(1)
expect(mockOnSelect).toHaveBeenCalledWith({
nodeId: 'node-2',
nodeData: defaultOptions[1].data,
} satisfies Datasource)
})
it('should allow selecting already selected option', () => {
// Arrange
const mockOnSelect = jest.fn()
renderWithProviders(
<DataSourceOptions
{...defaultProps}
datasourceNodeId="node-1"
onSelect={mockOnSelect}
/>,
)
// Act - Click already selected option
fireEvent.click(screen.getByText('Data Source 1'))
// Assert
expect(mockOnSelect).toHaveBeenCalledTimes(1)
expect(mockOnSelect).toHaveBeenCalledWith({
nodeId: 'node-1',
nodeData: defaultOptions[0].data,
})
})
it('should allow multiple sequential selections', () => {
// Arrange
const mockOnSelect = jest.fn()
renderWithProviders(
<DataSourceOptions
{...defaultProps}
datasourceNodeId="node-1"
onSelect={mockOnSelect}
/>,
)
// Act - Click options sequentially
fireEvent.click(screen.getByText('Data Source 1'))
fireEvent.click(screen.getByText('Data Source 2'))
fireEvent.click(screen.getByText('Data Source 3'))
// Assert
expect(mockOnSelect).toHaveBeenCalledTimes(3)
expect(mockOnSelect).toHaveBeenNthCalledWith(1, {
nodeId: 'node-1',
nodeData: defaultOptions[0].data,
})
expect(mockOnSelect).toHaveBeenNthCalledWith(2, {
nodeId: 'node-2',
nodeData: defaultOptions[1].data,
})
expect(mockOnSelect).toHaveBeenNthCalledWith(3, {
nodeId: 'node-3',
nodeData: defaultOptions[2].data,
})
})
})
describe('handelSelect Internal Logic', () => {
it('should handle rapid successive clicks', async () => {
// Arrange
const mockOnSelect = jest.fn()
renderWithProviders(
<DataSourceOptions
{...defaultProps}
datasourceNodeId="node-1"
onSelect={mockOnSelect}
/>,
)
// Act - Rapid clicks
await act(async () => {
fireEvent.click(screen.getByText('Data Source 1'))
fireEvent.click(screen.getByText('Data Source 2'))
fireEvent.click(screen.getByText('Data Source 3'))
fireEvent.click(screen.getByText('Data Source 1'))
fireEvent.click(screen.getByText('Data Source 2'))
})
// Assert - All clicks should be registered
expect(mockOnSelect).toHaveBeenCalledTimes(5)
})
})
})
// ==========================================
// Edge Cases and Error Handling Tests
// ==========================================
describe('Edge Cases and Error Handling', () => {
describe('Empty States', () => {
it('should handle empty options array gracefully', () => {
// Arrange
mockUseDatasourceOptions.mockReturnValue([])
// Act
const { container } = renderWithProviders(
<DataSourceOptions
{...defaultProps}
pipelineNodes={[]}
/>,
)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should not crash when datasourceNodeId is undefined', () => {
// Arrange & Act
renderWithProviders(
<DataSourceOptions
pipelineNodes={defaultNodes}
datasourceNodeId={undefined as unknown as string}
onSelect={jest.fn()}
/>,
)
// Assert
expect(screen.getByText('Data Source 1')).toBeInTheDocument()
})
})
describe('Null/Undefined Values', () => {
it('should handle option with missing data properties', () => {
// Arrange
const optionWithMinimalData = [{
label: 'Minimal Option',
value: 'minimal-1',
data: {
title: 'Minimal',
desc: '',
type: BlockEnum.DataSource,
plugin_id: '',
provider_type: '',
provider_name: '',
datasource_name: '',
datasource_label: '',
datasource_parameters: {},
datasource_configurations: {},
} as DataSourceNodeType,
}]
mockUseDatasourceOptions.mockReturnValue(optionWithMinimalData)
// Act
renderWithProviders(<DataSourceOptions {...defaultProps} />)
// Assert
expect(screen.getByText('Minimal Option')).toBeInTheDocument()
})
})
describe('Large Data Sets', () => {
it('should handle large number of options', () => {
// Arrange
const manyNodes = createMockPipelineNodes(50)
const manyOptions = manyNodes.map(createMockDatasourceOption)
mockUseDatasourceOptions.mockReturnValue(manyOptions)
// Act
renderWithProviders(
<DataSourceOptions
{...defaultProps}
pipelineNodes={manyNodes}
/>,
)
// Assert
expect(screen.getByText('Data Source 1')).toBeInTheDocument()
expect(screen.getByText('Data Source 50')).toBeInTheDocument()
})
})
describe('Special Characters in Data', () => {
it('should handle special characters in option labels', () => {
// Arrange
const specialNode = createMockPipelineNode({
id: 'special-node',
data: createMockDataSourceNodeData({
title: 'Data Source <script>alert("xss")</script>',
}),
})
const specialOptions = [createMockDatasourceOption(specialNode)]
mockUseDatasourceOptions.mockReturnValue(specialOptions)
// Act
renderWithProviders(
<DataSourceOptions
{...defaultProps}
pipelineNodes={[specialNode]}
/>,
)
// Assert - Special characters should be escaped/rendered safely
expect(screen.getByText('Data Source <script>alert("xss")</script>')).toBeInTheDocument()
})
it('should handle unicode characters in option labels', () => {
// Arrange
const unicodeNode = createMockPipelineNode({
id: 'unicode-node',
data: createMockDataSourceNodeData({
title: '数据源 📁 Source émoji',
}),
})
const unicodeOptions = [createMockDatasourceOption(unicodeNode)]
mockUseDatasourceOptions.mockReturnValue(unicodeOptions)
// Act
renderWithProviders(
<DataSourceOptions
{...defaultProps}
pipelineNodes={[unicodeNode]}
/>,
)
// Assert
expect(screen.getByText('数据源 📁 Source émoji')).toBeInTheDocument()
})
it('should handle empty string as option value', () => {
// Arrange
const emptyValueOption = [{
label: 'Empty Value Option',
value: '',
data: createMockDataSourceNodeData(),
}]
mockUseDatasourceOptions.mockReturnValue(emptyValueOption)
// Act
renderWithProviders(<DataSourceOptions {...defaultProps} />)
// Assert
expect(screen.getByText('Empty Value Option')).toBeInTheDocument()
})
})
describe('Boundary Conditions', () => {
it('should handle single option selection correctly', () => {
// Arrange
const singleOption = [createMockDatasourceOption(defaultNodes[0])]
mockUseDatasourceOptions.mockReturnValue(singleOption)
const mockOnSelect = jest.fn()
// Act
renderWithProviders(
<DataSourceOptions
{...defaultProps}
datasourceNodeId="node-1"
onSelect={mockOnSelect}
/>,
)
// Assert - Click should still work
fireEvent.click(screen.getByText('Data Source 1'))
expect(mockOnSelect).toHaveBeenCalledTimes(1)
})
it('should handle options with same labels but different values', () => {
// Arrange
const duplicateLabelOptions = [
{
label: 'Duplicate Label',
value: 'node-a',
data: createMockDataSourceNodeData({ plugin_id: 'plugin-a' }),
},
{
label: 'Duplicate Label',
value: 'node-b',
data: createMockDataSourceNodeData({ plugin_id: 'plugin-b' }),
},
]
mockUseDatasourceOptions.mockReturnValue(duplicateLabelOptions)
const mockOnSelect = jest.fn()
// Act
renderWithProviders(
<DataSourceOptions
{...defaultProps}
datasourceNodeId="node-a"
onSelect={mockOnSelect}
/>,
)
// Assert - Both should render
const labels = screen.getAllByText('Duplicate Label')
expect(labels).toHaveLength(2)
// Click second one
fireEvent.click(labels[1])
expect(mockOnSelect).toHaveBeenCalledWith({
nodeId: 'node-b',
nodeData: expect.objectContaining({ plugin_id: 'plugin-b' }),
})
})
})
describe('Component Unmounting', () => {
it('should handle unmounting without errors', () => {
// Arrange
const mockOnSelect = jest.fn()
const { unmount } = renderWithProviders(
<DataSourceOptions
{...defaultProps}
onSelect={mockOnSelect}
/>,
)
// Act
unmount()
// Assert - No errors thrown, component cleanly unmounted
expect(screen.queryByText('Data Source 1')).not.toBeInTheDocument()
})
it('should handle unmounting during rapid interactions', async () => {
// Arrange
const mockOnSelect = jest.fn()
const { unmount } = renderWithProviders(
<DataSourceOptions
{...defaultProps}
datasourceNodeId="node-1"
onSelect={mockOnSelect}
/>,
)
// Act - Start interactions then unmount
fireEvent.click(screen.getByText('Data Source 1'))
// Unmount during/after interaction
unmount()
// Assert - Should not throw
expect(screen.queryByText('Data Source 1')).not.toBeInTheDocument()
})
})
})
// ==========================================
// Integration Tests
// ==========================================
describe('Integration', () => {
it('should render OptionCard with correct props', () => {
// Arrange & Act
const { container } = renderWithProviders(<DataSourceOptions {...defaultProps} />)
// Assert - Verify real OptionCard components are rendered
const cards = container.querySelectorAll('.rounded-xl.border')
expect(cards).toHaveLength(3)
})
it('should correctly pass selected state to OptionCard', () => {
// Arrange & Act
const { container } = renderWithProviders(
<DataSourceOptions
{...defaultProps}
datasourceNodeId="node-2"
/>,
)
// Assert
const cards = container.querySelectorAll('.rounded-xl.border')
expect(cards[0]).not.toHaveClass('border-components-option-card-option-selected-border')
expect(cards[1]).toHaveClass('border-components-option-card-option-selected-border')
expect(cards[2]).not.toHaveClass('border-components-option-card-option-selected-border')
})
it('should use option.value as key for React rendering', () => {
// This test verifies that React doesn't throw duplicate key warnings
// Arrange
const uniqueValueOptions = createMockPipelineNodes(5).map(createMockDatasourceOption)
mockUseDatasourceOptions.mockReturnValue(uniqueValueOptions)
// Act - Should render without console warnings about duplicate keys
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn())
renderWithProviders(<DataSourceOptions {...defaultProps} />)
// Assert
expect(consoleSpy).not.toHaveBeenCalledWith(
expect.stringContaining('key'),
)
consoleSpy.mockRestore()
})
})
// ==========================================
// All Prop Variations Tests
// ==========================================
describe('All Prop Variations', () => {
it.each([
{ datasourceNodeId: '', description: 'empty string' },
{ datasourceNodeId: 'node-1', description: 'first node' },
{ datasourceNodeId: 'node-2', description: 'middle node' },
{ datasourceNodeId: 'node-3', description: 'last node' },
{ datasourceNodeId: 'non-existent', description: 'non-existent node' },
])('should handle datasourceNodeId as $description', ({ datasourceNodeId }) => {
// Arrange & Act
renderWithProviders(
<DataSourceOptions
{...defaultProps}
datasourceNodeId={datasourceNodeId}
/>,
)
// Assert
expect(screen.getByText('Data Source 1')).toBeInTheDocument()
})
it.each([
{ count: 0, description: 'zero options' },
{ count: 1, description: 'single option' },
{ count: 3, description: 'few options' },
{ count: 10, description: 'many options' },
])('should render correctly with $description', ({ count }) => {
// Arrange
const nodes = createMockPipelineNodes(count)
const options = nodes.map(createMockDatasourceOption)
mockUseDatasourceOptions.mockReturnValue(options)
// Act
renderWithProviders(
<DataSourceOptions
pipelineNodes={nodes}
datasourceNodeId=""
onSelect={jest.fn()}
/>,
)
// Assert
if (count > 0)
expect(screen.getByText('Data Source 1')).toBeInTheDocument()
else
expect(screen.queryByText('Data Source 1')).not.toBeInTheDocument()
})
})
})