chore: add AppTypeSelector tests and improve clear button accessibility (#29791)

This commit is contained in:
yyh
2025-12-18 10:11:33 +08:00
committed by GitHub
parent aae330627d
commit a377352a9e
2 changed files with 160 additions and 6 deletions

View File

@@ -0,0 +1,144 @@
import React from 'react'
import { fireEvent, render, screen, within } from '@testing-library/react'
import AppTypeSelector, { AppTypeIcon, AppTypeLabel } from './index'
import { AppModeEnum } from '@/types/app'
jest.mock('react-i18next')
describe('AppTypeSelector', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// Covers default rendering and the closed dropdown state.
describe('Rendering', () => {
it('should render "all types" trigger when no types selected', () => {
render(<AppTypeSelector value={[]} onChange={jest.fn()} />)
expect(screen.getByText('app.typeSelector.all')).toBeInTheDocument()
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
})
})
// Covers prop-driven trigger variants (empty, single, multiple).
describe('Props', () => {
it('should render selected type label and clear button when a single type is selected', () => {
render(<AppTypeSelector value={[AppModeEnum.CHAT]} onChange={jest.fn()} />)
expect(screen.getByText('app.typeSelector.chatbot')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.clear' })).toBeInTheDocument()
})
it('should render icon-only trigger when multiple types are selected', () => {
render(<AppTypeSelector value={[AppModeEnum.CHAT, AppModeEnum.WORKFLOW]} onChange={jest.fn()} />)
expect(screen.queryByText('app.typeSelector.all')).not.toBeInTheDocument()
expect(screen.queryByText('app.typeSelector.chatbot')).not.toBeInTheDocument()
expect(screen.queryByText('app.typeSelector.workflow')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.clear' })).toBeInTheDocument()
})
})
// Covers opening/closing the dropdown and selection updates.
describe('User interactions', () => {
it('should toggle option list when clicking the trigger', () => {
render(<AppTypeSelector value={[]} onChange={jest.fn()} />)
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('app.typeSelector.all'))
expect(screen.getByRole('tooltip')).toBeInTheDocument()
fireEvent.click(screen.getByText('app.typeSelector.all'))
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
})
it('should call onChange with added type when selecting an unselected item', () => {
const onChange = jest.fn()
render(<AppTypeSelector value={[]} onChange={onChange} />)
fireEvent.click(screen.getByText('app.typeSelector.all'))
fireEvent.click(within(screen.getByRole('tooltip')).getByText('app.typeSelector.workflow'))
expect(onChange).toHaveBeenCalledWith([AppModeEnum.WORKFLOW])
})
it('should call onChange with removed type when selecting an already-selected item', () => {
const onChange = jest.fn()
render(<AppTypeSelector value={[AppModeEnum.WORKFLOW]} onChange={onChange} />)
fireEvent.click(screen.getByText('app.typeSelector.workflow'))
fireEvent.click(within(screen.getByRole('tooltip')).getByText('app.typeSelector.workflow'))
expect(onChange).toHaveBeenCalledWith([])
})
it('should call onChange with appended type when selecting an additional item', () => {
const onChange = jest.fn()
render(<AppTypeSelector value={[AppModeEnum.CHAT]} onChange={onChange} />)
fireEvent.click(screen.getByText('app.typeSelector.chatbot'))
fireEvent.click(within(screen.getByRole('tooltip')).getByText('app.typeSelector.agent'))
expect(onChange).toHaveBeenCalledWith([AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT])
})
it('should clear selection without opening the dropdown when clicking clear button', () => {
const onChange = jest.fn()
render(<AppTypeSelector value={[AppModeEnum.CHAT]} onChange={onChange} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
expect(onChange).toHaveBeenCalledWith([])
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
})
})
})
describe('AppTypeLabel', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// Covers label mapping for each supported app type.
it.each([
[AppModeEnum.CHAT, 'app.typeSelector.chatbot'],
[AppModeEnum.AGENT_CHAT, 'app.typeSelector.agent'],
[AppModeEnum.COMPLETION, 'app.typeSelector.completion'],
[AppModeEnum.ADVANCED_CHAT, 'app.typeSelector.advanced'],
[AppModeEnum.WORKFLOW, 'app.typeSelector.workflow'],
] as const)('should render label %s for type %s', (_type, expectedLabel) => {
render(<AppTypeLabel type={_type} />)
expect(screen.getByText(expectedLabel)).toBeInTheDocument()
})
// Covers fallback behavior for unexpected app mode values.
it('should render empty label for unknown type', () => {
const { container } = render(<AppTypeLabel type={'unknown' as AppModeEnum} />)
expect(container.textContent).toBe('')
})
})
describe('AppTypeIcon', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// Covers icon rendering for each supported app type.
it.each([
[AppModeEnum.CHAT],
[AppModeEnum.AGENT_CHAT],
[AppModeEnum.COMPLETION],
[AppModeEnum.ADVANCED_CHAT],
[AppModeEnum.WORKFLOW],
] as const)('should render icon for type %s', (type) => {
const { container } = render(<AppTypeIcon type={type} />)
expect(container.querySelector('svg')).toBeInTheDocument()
})
// Covers fallback behavior for unexpected app mode values.
it('should render nothing for unknown type', () => {
const { container } = render(<AppTypeIcon type={'unknown' as AppModeEnum} />)
expect(container.firstChild).toBeNull()
})
})

View File

@@ -20,6 +20,7 @@ const allTypes: AppModeEnum[] = [AppModeEnum.WORKFLOW, AppModeEnum.ADVANCED_CHAT
const AppTypeSelector = ({ value, onChange }: AppSelectorProps) => {
const [open, setOpen] = useState(false)
const { t } = useTranslation()
return (
<PortalToFollowElem
@@ -37,12 +38,21 @@ const AppTypeSelector = ({ value, onChange }: AppSelectorProps) => {
'flex cursor-pointer items-center justify-between space-x-1 rounded-md px-2 hover:bg-state-base-hover',
)}>
<AppTypeSelectTrigger values={value} />
{value && value.length > 0 && <div className='h-4 w-4' onClick={(e) => {
e.stopPropagation()
onChange([])
}}>
<RiCloseCircleFill className='h-3.5 w-3.5 cursor-pointer text-text-quaternary hover:text-text-tertiary' />
</div>}
{value && value.length > 0 && (
<button
type="button"
aria-label={t('common.operation.clear')}
className="group h-4 w-4"
onClick={(e) => {
e.stopPropagation()
onChange([])
}}
>
<RiCloseCircleFill
className="h-3.5 w-3.5 text-text-quaternary group-hover:text-text-tertiary"
/>
</button>
)}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1002]'>