dify/web/app/components/tools/labels/selector.spec.tsx

320 lines
10 KiB
TypeScript

import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import LabelSelector from './selector'
// Mock useTags hook with controlled test data
const mockTags = [
{ name: 'agent', label: 'Agent' },
{ name: 'rag', label: 'RAG' },
{ name: 'search', label: 'Search' },
{ name: 'image', label: 'Image' },
]
vi.mock('@/app/components/plugins/hooks', () => ({
useTags: () => ({
tags: mockTags,
tagsMap: mockTags.reduce((acc, tag) => ({ ...acc, [tag.name]: tag }), {}),
getTagLabel: (name: string) => mockTags.find(t => t.name === name)?.label ?? name,
}),
}))
// Mock useDebounceFn to store the function and allow manual triggering
let debouncedFn: (() => void) | null = null
vi.mock('ahooks', () => ({
useDebounceFn: (fn: () => void) => {
debouncedFn = fn
return {
run: () => {
// Schedule to run after React state updates
setTimeout(() => debouncedFn?.(), 0)
},
cancel: vi.fn(),
}
},
}))
describe('LabelSelector', () => {
const mockOnChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
debouncedFn = null
})
afterEach(() => {
vi.useRealTimers()
})
// Rendering Tests
describe('Rendering', () => {
it('should render without crashing', () => {
render(<LabelSelector value={[]} onChange={mockOnChange} />)
expect(screen.getByText('tools.createTool.toolInput.labelPlaceholder')).toBeInTheDocument()
})
it('should display placeholder when no labels selected', () => {
render(<LabelSelector value={[]} onChange={mockOnChange} />)
expect(screen.getByText('tools.createTool.toolInput.labelPlaceholder')).toBeInTheDocument()
})
it('should display selected labels as comma-separated list', () => {
render(<LabelSelector value={['agent', 'rag']} onChange={mockOnChange} />)
expect(screen.getByText('Agent, RAG')).toBeInTheDocument()
})
it('should display single selected label', () => {
render(<LabelSelector value={['agent']} onChange={mockOnChange} />)
expect(screen.getByText('Agent')).toBeInTheDocument()
})
})
// Dropdown Tests
describe('Dropdown', () => {
it('should open dropdown when trigger is clicked', async () => {
render(<LabelSelector value={[]} onChange={mockOnChange} />)
const trigger = screen.getByText('tools.createTool.toolInput.labelPlaceholder')
await act(async () => {
fireEvent.click(trigger)
vi.advanceTimersByTime(10)
})
// Checkboxes should be visible
mockTags.forEach((tag) => {
expect(screen.getByText(tag.label)).toBeInTheDocument()
})
})
it('should close dropdown when trigger is clicked again', async () => {
render(<LabelSelector value={[]} onChange={mockOnChange} />)
const trigger = screen.getByText('tools.createTool.toolInput.labelPlaceholder')
// Open
await act(async () => {
fireEvent.click(trigger)
vi.advanceTimersByTime(10)
})
expect(screen.getByText('Agent')).toBeInTheDocument()
// Close
await act(async () => {
fireEvent.click(trigger)
vi.advanceTimersByTime(10)
})
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
})
// Selection Tests
describe('Selection', () => {
it('should call onChange with selected label when clicking a label', async () => {
render(<LabelSelector value={[]} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('tools.createTool.toolInput.labelPlaceholder'))
vi.advanceTimersByTime(10)
})
expect(screen.getByText('Agent')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByTitle('Agent'))
vi.advanceTimersByTime(10)
})
expect(mockOnChange).toHaveBeenCalledWith(['agent'])
})
it('should remove label from selection when clicking already selected label', async () => {
render(<LabelSelector value={['agent']} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('Agent'))
vi.advanceTimersByTime(10)
})
// Find the label item in the dropdown list and click it
// Use getAllByTitle and select the one in the dropdown (with text-sm class)
const agentElements = screen.getAllByTitle('Agent')
const dropdownItem = agentElements.find(el =>
el.classList.contains('text-sm'),
)
await act(async () => {
if (dropdownItem)
fireEvent.click(dropdownItem)
vi.advanceTimersByTime(10)
})
expect(mockOnChange).toHaveBeenCalledWith([])
})
it('should add label to existing selection', async () => {
render(<LabelSelector value={['agent']} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('Agent'))
vi.advanceTimersByTime(10)
})
expect(screen.getByTitle('RAG')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByTitle('RAG'))
vi.advanceTimersByTime(10)
})
expect(mockOnChange).toHaveBeenCalledWith(['agent', 'rag'])
})
it('should show checkboxes in dropdown', async () => {
render(<LabelSelector value={['agent']} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('Agent'))
vi.advanceTimersByTime(10)
})
// Checkboxes should be visible in the dropdown
const checkboxes = document.querySelectorAll('[data-testid^="checkbox"]')
expect(checkboxes.length).toBeGreaterThan(0)
})
})
// Search Tests
describe('Search', () => {
it('should filter labels based on search input by name', async () => {
render(<LabelSelector value={[]} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('tools.createTool.toolInput.labelPlaceholder'))
vi.advanceTimersByTime(10)
})
expect(screen.getByRole('textbox')).toBeInTheDocument()
await act(async () => {
const searchInput = screen.getByRole('textbox')
// Filter by 'rag' which only matches 'rag' name
fireEvent.change(searchInput, { target: { value: 'rag' } })
vi.advanceTimersByTime(10)
})
// Only RAG should be visible (rag contains 'rag')
expect(screen.getByTitle('RAG')).toBeInTheDocument()
// Agent should not be in the dropdown list (agent doesn't contain 'rag')
expect(screen.queryByTitle('Agent')).not.toBeInTheDocument()
})
it('should show empty state when no labels match search', async () => {
render(<LabelSelector value={[]} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('tools.createTool.toolInput.labelPlaceholder'))
vi.advanceTimersByTime(10)
})
expect(screen.getByRole('textbox')).toBeInTheDocument()
await act(async () => {
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: 'nonexistent' } })
vi.advanceTimersByTime(10)
})
expect(screen.getByText('common.tag.noTag')).toBeInTheDocument()
})
it('should show all labels when search is cleared', async () => {
render(<LabelSelector value={[]} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('tools.createTool.toolInput.labelPlaceholder'))
vi.advanceTimersByTime(10)
})
expect(screen.getByRole('textbox')).toBeInTheDocument()
await act(async () => {
const searchInput = screen.getByRole('textbox')
// First filter to show only RAG
fireEvent.change(searchInput, { target: { value: 'rag' } })
vi.advanceTimersByTime(10)
})
expect(screen.getByTitle('RAG')).toBeInTheDocument()
expect(screen.queryByTitle('Agent')).not.toBeInTheDocument()
await act(async () => {
// Clear the input
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: '' } })
vi.advanceTimersByTime(10)
})
// All labels should be visible again
expect(screen.getByTitle('Agent')).toBeInTheDocument()
expect(screen.getByTitle('RAG')).toBeInTheDocument()
})
})
// Edge Cases
describe('Edge Cases', () => {
it('should handle empty label list', () => {
render(<LabelSelector value={[]} onChange={mockOnChange} />)
expect(screen.getByText('tools.createTool.toolInput.labelPlaceholder')).toBeInTheDocument()
})
it('should handle value with non-existent label', () => {
render(<LabelSelector value={['nonexistent']} onChange={mockOnChange} />)
// Should still render without crashing, undefined label will be filtered
expect(document.querySelector('.text-text-secondary')).toBeInTheDocument()
})
it('should handle multiple labels display', () => {
render(<LabelSelector value={['agent', 'rag', 'search']} onChange={mockOnChange} />)
expect(screen.getByText('Agent, RAG, Search')).toBeInTheDocument()
})
})
// Props Tests
describe('Props', () => {
it('should receive value as array of strings', () => {
render(<LabelSelector value={['agent', 'rag']} onChange={mockOnChange} />)
expect(screen.getByText('Agent, RAG')).toBeInTheDocument()
})
it('should call onChange with updated array', async () => {
render(<LabelSelector value={[]} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('tools.createTool.toolInput.labelPlaceholder'))
vi.advanceTimersByTime(10)
})
expect(screen.getByText('Agent')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByTitle('Agent'))
vi.advanceTimersByTime(10)
})
expect(mockOnChange).toHaveBeenCalledTimes(1)
expect(mockOnChange).toHaveBeenCalledWith(['agent'])
})
})
})