dify/web/app/components/base/select/__tests__/index.spec.tsx
Saumya Talwani f50e44b24a
test: improve coverage for some test files (#32916)
Signed-off-by: edvatar <88481784+toroleapinc@users.noreply.github.com>
Signed-off-by: -LAN- <laipz8200@outlook.com>
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: majiayu000 <1835304752@qq.com>
Co-authored-by: Poojan <poojan@infocusp.com>
Co-authored-by: sahil-infocusp <73810410+sahil-infocusp@users.noreply.github.com>
Co-authored-by: 非法操作 <hjlarry@163.com>
Co-authored-by: Pandaaaa906 <ye.pandaaaa906@gmail.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
Co-authored-by: heyszt <270985384@qq.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Ijas <ijas.ahmd.ap@gmail.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: 木之本澪 <kinomotomiovo@gmail.com>
Co-authored-by: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com>
Co-authored-by: 不做了睡大觉 <64798754+stakeswky@users.noreply.github.com>
Co-authored-by: User <user@example.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: edvatar <88481784+toroleapinc@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
Co-authored-by: Leilei <138381132+Inlei@users.noreply.github.com>
Co-authored-by: HaKu <104669497+haku-ink@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: wangxiaolei <fatelei@gmail.com>
Co-authored-by: Varun Chawla <34209028+veeceey@users.noreply.github.com>
Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: tda <95275462+tda1017@users.noreply.github.com>
Co-authored-by: root <root@DESKTOP-KQLO90N>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Co-authored-by: Niels Kaspers <153818647+nielskaspers@users.noreply.github.com>
Co-authored-by: hj24 <mambahj24@gmail.com>
Co-authored-by: Tyson Cung <45380903+tysoncung@users.noreply.github.com>
Co-authored-by: Stephen Zhou <hi@hyoban.cc>
Co-authored-by: FFXN <31929997+FFXN@users.noreply.github.com>
Co-authored-by: slegarraga <64795732+slegarraga@users.noreply.github.com>
Co-authored-by: 99 <wh2099@pm.me>
Co-authored-by: Br1an <932039080@qq.com>
Co-authored-by: L1nSn0w <l1nsn0w@qq.com>
Co-authored-by: Yunlu Wen <yunlu.wen@dify.ai>
Co-authored-by: akkoaya <151345394+akkoaya@users.noreply.github.com>
Co-authored-by: 盐粒 Yanli <yanli@dify.ai>
Co-authored-by: lif <1835304752@qq.com>
Co-authored-by: weiguang li <codingpunk@gmail.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
Co-authored-by: HanWenbo <124024253+hwb96@users.noreply.github.com>
Co-authored-by: Coding On Star <447357187@qq.com>
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: Stable Genius <stablegenius043@gmail.com>
Co-authored-by: Stable Genius <259448942+stablegenius49@users.noreply.github.com>
Co-authored-by: ふるい <46769295+Echo0ff@users.noreply.github.com>
Co-authored-by: Xiyuan Chen <52963600+GareArc@users.noreply.github.com>
2026-03-06 18:59:16 +08:00

767 lines
21 KiB
TypeScript

import type { Item } from '../index'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Select, { PortalSelect, SimpleSelect } from '../index'
const items: Item[] = [
{ value: 'apple', name: 'Apple' },
{ value: 'banana', name: 'Banana' },
{ value: 'citrus', name: 'Citrus' },
]
describe('Select', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should show the default selected item when defaultValue matches an item', () => {
render(
<Select
items={items}
defaultValue="banana"
allowSearch={false}
onSelect={vi.fn()}
/>,
)
expect(screen.getByTitle('Banana')).toBeInTheDocument()
})
it('should render null selectedItem when defaultValue does not match any item', () => {
render(
<Select
items={items}
defaultValue="missing"
allowSearch={false}
onSelect={vi.fn()}
/>,
)
// No item title should appear for a non-matching default
expect(screen.queryByTitle('Apple')).not.toBeInTheDocument()
expect(screen.queryByTitle('Banana')).not.toBeInTheDocument()
})
it('should render with allowSearch=true (input mode)', () => {
render(
<Select
items={items}
defaultValue="apple"
allowSearch={true}
onSelect={vi.fn()}
/>,
)
expect(screen.getByRole('combobox')).toBeInTheDocument()
})
it('should apply custom bgClassName', () => {
render(
<Select
items={items}
defaultValue="apple"
allowSearch={false}
onSelect={vi.fn()}
bgClassName="bg-custom-color"
/>,
)
expect(screen.getByTitle('Apple')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onSelect when choosing an option from default select', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<Select
items={items}
defaultValue="banana"
allowSearch={false}
onSelect={onSelect}
/>,
)
await user.click(screen.getByTitle('Banana'))
await user.click(screen.getByText('Citrus'))
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({
value: 'citrus',
name: 'Citrus',
}))
})
it('should not open or select when default select is disabled', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<Select
items={items}
defaultValue="banana"
allowSearch={false}
disabled={true}
onSelect={onSelect}
/>,
)
await user.click(screen.getByTitle('Banana'))
expect(screen.queryByText('Citrus')).not.toBeInTheDocument()
expect(onSelect).not.toHaveBeenCalled()
})
it('should filter items when searching with allowSearch=true', async () => {
const user = userEvent.setup()
render(
<Select
items={items}
defaultValue="apple"
allowSearch={true}
onSelect={vi.fn()}
/>,
)
// First, click the chevron button to open the dropdown
const buttons = screen.getAllByRole('button')
await user.click(buttons[0])
// Now type in the search input to filter
const input = screen.getByRole('combobox')
await user.clear(input)
await user.type(input, 'ban')
// Citrus should be filtered away
expect(screen.queryByText('Citrus')).not.toBeInTheDocument()
})
it('should not filter or update query when disabled and allowSearch=true', async () => {
render(
<Select
items={items}
defaultValue="apple"
allowSearch={true}
disabled={true}
onSelect={vi.fn()}
/>,
)
const input = screen.getByRole('combobox') as HTMLInputElement
// we must use fireEvent because userEvent throws on disabled inputs
fireEvent.change(input, { target: { value: 'ban' } })
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
// Since it's disabled, no search dropdown should appear.
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
})
it('should not call onSelect when a disabled Combobox value changes externally', () => {
// In Headless UI, disabled elements do not fire events via React.
// To cover the defensive `if (!disabled)` branches inside the callbacks,
// we temporarily remove the disabled attribute from the DOM to force the event through.
const onSelect = vi.fn()
render(
<Select
items={items}
defaultValue="apple"
allowSearch={false}
disabled={true}
onSelect={onSelect}
/>,
)
const button = screen.getAllByRole('button')[0] as HTMLButtonElement
button.removeAttribute('disabled')
button.removeAttribute('aria-disabled')
fireEvent.click(button)
expect(onSelect).not.toHaveBeenCalled()
})
it('should not open dropdown when clicking ComboboxButton while disabled and allowSearch=false', () => {
// Covers line 128-141 where disabled check prevents open state toggle
render(
<Select
items={items}
defaultValue="apple"
allowSearch={false}
disabled={true}
onSelect={vi.fn()}
/>,
)
// The main trigger button should be disabled
const button = screen.getAllByRole('button')[0] as HTMLButtonElement
button.removeAttribute('disabled')
const chevron = screen.getAllByRole('button')[1] as HTMLButtonElement
chevron.removeAttribute('disabled')
fireEvent.click(button)
fireEvent.click(chevron)
// Dropdown options should not appear because the internal `if (!disabled)` guards it
expect(screen.queryByText('Banana')).not.toBeInTheDocument()
})
it('should handle missing item nicely in renderTrigger', () => {
render(
<SimpleSelect
items={items}
defaultValue="non-existent"
onSelect={vi.fn()}
renderTrigger={(selected) => {
return (
<span>
{/* eslint-disable-next-line style/jsx-one-expression-per-line */}
Custom: {selected?.name ?? 'Fallback'}
</span>
)
}}
/>,
)
expect(screen.getByText('Custom: Fallback')).toBeInTheDocument()
})
it('should render with custom renderOption', async () => {
const user = userEvent.setup()
render(
<Select
items={items}
defaultValue="apple"
allowSearch={false}
onSelect={vi.fn()}
renderOption={({ item, selected }) => (
<span data-testid={`custom-opt-${item.value}`}>
{item.name}
{selected ? ' ✓' : ''}
</span>
)}
/>,
)
await user.click(screen.getByTitle('Apple'))
expect(screen.getByTestId('custom-opt-apple')).toBeInTheDocument()
expect(screen.getByTestId('custom-opt-banana')).toBeInTheDocument()
})
it('should show ChevronUpIcon when open and ChevronDownIcon when closed', async () => {
const user = userEvent.setup()
render(
<Select
items={items}
defaultValue="apple"
allowSearch={false}
onSelect={vi.fn()}
/>,
)
// Initially closed — should have a chevron button
await user.click(screen.getByTitle('Apple'))
// Dropdown is now open
expect(screen.getByText('Banana')).toBeInTheDocument()
})
})
})
// ──────────────────────────────────────────────────────────────
// SimpleSelect (Listbox-based)
// ──────────────────────────────────────────────────────────────
describe('SimpleSelect', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render i18n placeholder when no selection exists', () => {
render(
<SimpleSelect
items={items}
defaultValue="missing"
onSelect={vi.fn()}
/>,
)
expect(screen.getByText(/select/i)).toBeInTheDocument()
})
it('should render custom placeholder when provided', () => {
render(
<SimpleSelect
items={items}
defaultValue="missing"
placeholder="Pick one"
onSelect={vi.fn()}
/>,
)
expect(screen.getByText('Pick one')).toBeInTheDocument()
})
it('should render selected item name when defaultValue matches', () => {
render(
<SimpleSelect
items={items}
defaultValue="banana"
onSelect={vi.fn()}
/>,
)
expect(screen.getByText('Banana')).toBeInTheDocument()
})
it('should render with isLoading=true showing spinner', () => {
render(
<SimpleSelect
items={items}
defaultValue="apple"
onSelect={vi.fn()}
isLoading={true}
/>,
)
// Loader icon should be rendered (RiLoader4Line has aria hidden)
expect(screen.getByText('Apple')).toBeInTheDocument()
})
it('should render group items as non-selectable headers', async () => {
const user = userEvent.setup()
const groupItems: Item[] = [
{ value: 'fruits-group', name: 'Fruits', isGroup: true },
{ value: 'apple', name: 'Apple' },
{ value: 'banana', name: 'Banana' },
]
render(
<SimpleSelect
items={groupItems}
defaultValue="apple"
onSelect={vi.fn()}
/>,
)
await user.click(screen.getByRole('button'))
expect(screen.getByText('Fruits')).toBeInTheDocument()
})
it('should not render ListboxOptions when disabled', () => {
render(
<SimpleSelect
items={items}
defaultValue="apple"
disabled={true}
onSelect={vi.fn()}
/>,
)
expect(screen.getByText('Apple')).toBeInTheDocument()
})
it('should not open SimpleSelect when disabled', async () => {
const user = userEvent.setup()
render(
<SimpleSelect
items={items}
defaultValue="apple"
disabled={true}
onSelect={vi.fn()}
/>,
)
const button = screen.getByRole('button')
await user.click(button)
// Banana should not be visible as it won't open
expect(screen.queryByText('Banana')).not.toBeInTheDocument()
})
it('should not trigger onSelect via onChange when Listbox is disabled', () => {
// Covers line 228 (!disabled check) inside Listbox onChange
const onSelect = vi.fn()
render(
<SimpleSelect
items={items}
defaultValue="apple"
disabled={true}
onSelect={onSelect}
/>,
)
const button = screen.getByRole('button') as HTMLButtonElement
button.removeAttribute('disabled')
button.removeAttribute('aria-disabled')
fireEvent.click(button)
expect(onSelect).not.toHaveBeenCalled()
})
})
describe('User Interactions', () => {
it('should call onSelect and update display when an option is chosen', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<SimpleSelect
items={items}
defaultValue="missing"
onSelect={onSelect}
/>,
)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('Apple'))
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({
value: 'apple',
name: 'Apple',
}))
expect(screen.getByText('Apple')).toBeInTheDocument()
})
it('should pass open state into renderTrigger', async () => {
const user = userEvent.setup()
render(
<SimpleSelect
items={items}
defaultValue="missing"
onSelect={vi.fn()}
renderTrigger={(selected, open) => (
<span>{`${selected?.name ?? 'none'}-${open ? 'open' : 'closed'}`}</span>
)}
/>,
)
expect(screen.getByText('none-closed')).toBeInTheDocument()
await user.click(screen.getByText('none-closed'))
expect(screen.getByText('none-open')).toBeInTheDocument()
})
it('should clear selection when XMark is clicked (notClearable=false)', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<SimpleSelect
items={items}
defaultValue="apple"
onSelect={onSelect}
notClearable={false}
/>,
)
// The clear button (XMarkIcon) should be visible when an item is selected
const clearBtn = screen.getByRole('button').querySelector('[aria-hidden="false"]')
expect(clearBtn).toBeInTheDocument()
await user.click(clearBtn!)
expect(onSelect).toHaveBeenCalledWith({ name: '', value: '' })
})
it('should not show clear button when notClearable is true', () => {
render(
<SimpleSelect
items={items}
defaultValue="apple"
onSelect={vi.fn()}
notClearable={true}
/>,
)
const clearBtn = screen.getByRole('button').querySelector('[aria-hidden="false"]')
expect(clearBtn).not.toBeInTheDocument()
})
it('should hide check marks when hideChecked is true', async () => {
const user = userEvent.setup()
render(
<SimpleSelect
items={items}
defaultValue="apple"
onSelect={vi.fn()}
hideChecked={true}
/>,
)
await user.click(screen.getByRole('button'))
// The selected item should be visible but without a check icon
expect(screen.getAllByText('Apple').length).toBeGreaterThanOrEqual(1)
})
it('should render with custom renderOption in SimpleSelect', async () => {
const user = userEvent.setup()
render(
<SimpleSelect
items={items}
defaultValue="apple"
onSelect={vi.fn()}
renderOption={({ item, selected }) => (
<span data-testid={`simple-opt-${item.value}`}>
{item.name}
{selected ? ' (selected)' : ''}
</span>
)}
/>,
)
await user.click(screen.getByRole('button'))
expect(screen.getByTestId('simple-opt-apple')).toBeInTheDocument()
expect(screen.getByTestId('simple-opt-banana')).toBeInTheDocument()
// Verify the custom render shows selected state
expect(screen.getByTestId('simple-opt-apple')).toHaveTextContent('Apple (selected)')
})
it('should call onOpenChange when the button is clicked', async () => {
const user = userEvent.setup()
const onOpenChange = vi.fn()
render(
<SimpleSelect
items={items}
defaultValue="apple"
onSelect={vi.fn()}
onOpenChange={onOpenChange}
/>,
)
await user.click(screen.getByRole('button'))
expect(onOpenChange).toHaveBeenCalled()
})
it('should handle disabled items that cannot be selected', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
const disabledItems: Item[] = [
{ value: 'apple', name: 'Apple' },
{ value: 'banana', name: 'Banana', disabled: true },
{ value: 'citrus', name: 'Citrus' },
]
render(
<SimpleSelect
items={disabledItems}
defaultValue="apple"
onSelect={onSelect}
/>,
)
await user.click(screen.getByRole('button'))
// Banana should be rendered but not selectable
expect(screen.getByText('Banana')).toBeInTheDocument()
})
})
})
// ──────────────────────────────────────────────────────────────
// PortalSelect
// ──────────────────────────────────────────────────────────────
describe('PortalSelect', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should show placeholder when value is empty', () => {
render(
<PortalSelect
value=""
items={items}
onSelect={vi.fn()}
/>,
)
expect(screen.getByText(/select/i)).toBeInTheDocument()
})
it('should show selected item name when value matches', () => {
render(
<PortalSelect
value="banana"
items={items}
onSelect={vi.fn()}
/>,
)
expect(screen.getByTitle('Banana')).toBeInTheDocument()
})
it('should render with custom placeholder', () => {
render(
<PortalSelect
value=""
items={items}
onSelect={vi.fn()}
placeholder="Choose fruit"
/>,
)
expect(screen.getByText('Choose fruit')).toBeInTheDocument()
})
it('should render with renderTrigger', () => {
render(
<PortalSelect
value="apple"
items={items}
onSelect={vi.fn()}
renderTrigger={item => (
<span data-testid="custom-trigger">{item?.name ?? 'None'}</span>
)}
/>,
)
expect(screen.getByTestId('custom-trigger')).toHaveTextContent('Apple')
})
it('should show INSTALLED badge when installedValue differs from selected value', () => {
render(
<PortalSelect
value="banana"
items={items}
onSelect={vi.fn()}
installedValue="apple"
/>,
)
expect(screen.getByTitle('Banana')).toBeInTheDocument()
})
it('should apply triggerClassNameFn', () => {
const triggerClassNameFn = vi.fn((open: boolean) => open ? 'trigger-open' : 'trigger-closed')
render(
<PortalSelect
value="apple"
items={items}
onSelect={vi.fn()}
triggerClassNameFn={triggerClassNameFn}
/>,
)
expect(triggerClassNameFn).toHaveBeenCalledWith(false)
})
})
describe('User Interactions', () => {
it('should call onSelect when choosing an option from portal dropdown', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<PortalSelect
value=""
items={items}
onSelect={onSelect}
/>,
)
await user.click(screen.getByText(/select/i))
await user.click(screen.getByText('Citrus'))
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({
value: 'citrus',
name: 'Citrus',
}))
})
it('should not open the portal dropdown when readonly is true', async () => {
const user = userEvent.setup()
render(
<PortalSelect
value=""
items={items}
readonly={true}
onSelect={vi.fn()}
/>,
)
await user.click(screen.getByText(/select/i))
expect(screen.queryByTitle('Citrus')).not.toBeInTheDocument()
})
it('should show check mark for selected item when hideChecked is false', async () => {
const user = userEvent.setup()
render(
<PortalSelect
value="banana"
items={items}
onSelect={vi.fn()}
/>,
)
await user.click(screen.getByTitle('Banana'))
// Banana option in the dropdown should be displayed
const allBananas = screen.getAllByText('Banana')
expect(allBananas.length).toBeGreaterThanOrEqual(1)
})
it('should hide check marks when hideChecked is true', async () => {
const user = userEvent.setup()
render(
<PortalSelect
value="banana"
items={items}
onSelect={vi.fn()}
hideChecked={true}
/>,
)
await user.click(screen.getByTitle('Banana'))
expect(screen.getAllByText('Banana').length).toBeGreaterThanOrEqual(1)
})
it('should display INSTALLED badge in dropdown for installed items', async () => {
const user = userEvent.setup()
render(
<PortalSelect
value="banana"
items={items}
onSelect={vi.fn()}
installedValue="apple"
/>,
)
await user.click(screen.getByTitle('Banana'))
// The installed badge should appear in the dropdown
expect(screen.getByText('INSTALLED')).toBeInTheDocument()
})
it('should render item.extra content in dropdown', async () => {
const user = userEvent.setup()
const extraItems: Item[] = [
{ value: 'apple', name: 'Apple', extra: <span data-testid="extra-apple">Extra</span> },
{ value: 'banana', name: 'Banana' },
]
render(
<PortalSelect
value=""
items={extraItems}
onSelect={vi.fn()}
/>,
)
await user.click(screen.getByText(/select/i))
expect(screen.getByTestId('extra-apple')).toBeInTheDocument()
})
})
})