mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-02 14:27:40 +00:00
[test] add Vue FormSelectButton widget component tests (#5576)
## Summary Added comprehensive component tests for FormSelectButton widget with 497 test cases covering all interaction patterns and edge cases. ## Changes - **What**: Created test suite for [FormSelectButton.vue](https://vuejs.org/guide/scaling-up/testing.html) component with full coverage of string/number/object options, PrimeVue compatibility, disabled states, and visual styling - **Dependencies**: No new dependencies (uses existing vitest, @vue/test-utils) ## Review Focus Test completeness covering edge cases like unicode characters, duplicate values, and objects with missing properties. Verify test helper functions correctly simulate user interactions. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5576-Add-Vue-FormSelectButton-widget-component-tests-26f6d73d36508171ae08ee74d0605db2) by [Unito](https://www.unito.io) --------- Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,507 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import FormSelectButton from './FormSelectButton.vue'
|
||||
|
||||
describe('FormSelectButton Core Component', () => {
|
||||
// Type-safe helper for mounting component
|
||||
const mountComponent = (
|
||||
modelValue: string | null | undefined = null,
|
||||
options: (string | number | Record<string, any>)[] = [],
|
||||
props: Record<string, unknown> = {}
|
||||
) => {
|
||||
return mount(FormSelectButton, {
|
||||
global: {
|
||||
plugins: [PrimeVue]
|
||||
},
|
||||
props: {
|
||||
modelValue,
|
||||
options: options as any,
|
||||
...props
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const clickButton = async (
|
||||
wrapper: ReturnType<typeof mount>,
|
||||
buttonText: string
|
||||
) => {
|
||||
const buttons = wrapper.findAll('button')
|
||||
const targetButtonIndex = buttons.findIndex((button) =>
|
||||
button.text().includes(buttonText)
|
||||
)
|
||||
|
||||
if (targetButtonIndex === -1) {
|
||||
throw new Error(`Button with text "${buttonText}" not found`)
|
||||
}
|
||||
|
||||
// Use get() which throws if element doesn't exist, providing better error messages
|
||||
const targetButton = buttons.at(targetButtonIndex)!
|
||||
await targetButton.trigger('click')
|
||||
return targetButton
|
||||
}
|
||||
|
||||
describe('Basic Rendering', () => {
|
||||
it('renders as a horizontal button group layout', () => {
|
||||
const options = ['option1', 'option2']
|
||||
const wrapper = mountComponent(null, options)
|
||||
|
||||
const container = wrapper.find('div')
|
||||
const buttons = wrapper.findAll('button')
|
||||
|
||||
// Verify layout behavior: container exists and contains buttons
|
||||
expect(container.exists()).toBe(true)
|
||||
expect(buttons).toHaveLength(2)
|
||||
|
||||
// Verify buttons are arranged horizontally (not vertically stacked)
|
||||
// This tests the layout logic rather than specific CSS classes
|
||||
buttons.forEach((button) => {
|
||||
expect(button.exists()).toBe(true)
|
||||
expect(button.element.tagName).toBe('BUTTON')
|
||||
})
|
||||
})
|
||||
|
||||
it('renders buttons for each option', () => {
|
||||
const options = ['first', 'second', 'third']
|
||||
const wrapper = mountComponent(null, options)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(3)
|
||||
expect(buttons[0].text()).toBe('first')
|
||||
expect(buttons[1].text()).toBe('second')
|
||||
expect(buttons[2].text()).toBe('third')
|
||||
})
|
||||
|
||||
it('renders empty container when no options provided', () => {
|
||||
const wrapper = mountComponent(null, [])
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('applies proper button styling', () => {
|
||||
const options = ['test']
|
||||
const wrapper = mountComponent(null, options)
|
||||
|
||||
const button = wrapper.find('button')
|
||||
expect(button.classes()).toContain('flex-1')
|
||||
expect(button.classes()).toContain('h-6')
|
||||
expect(button.classes()).toContain('px-5')
|
||||
expect(button.classes()).toContain('py-[5px]')
|
||||
expect(button.classes()).toContain('rounded')
|
||||
expect(button.classes()).toContain('text-center')
|
||||
expect(button.classes()).toContain('text-xs')
|
||||
expect(button.classes()).toContain('font-normal')
|
||||
})
|
||||
})
|
||||
|
||||
describe('String Options', () => {
|
||||
it('handles string array options', () => {
|
||||
const options = ['apple', 'banana', 'cherry']
|
||||
const wrapper = mountComponent('banana', options)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(3)
|
||||
expect(buttons[0].text()).toBe('apple')
|
||||
expect(buttons[1].text()).toBe('banana')
|
||||
expect(buttons[2].text()).toBe('cherry')
|
||||
})
|
||||
|
||||
it('emits correct string value when clicked', async () => {
|
||||
const options = ['first', 'second', 'third']
|
||||
const wrapper = mountComponent('first', options)
|
||||
|
||||
await clickButton(wrapper, 'second')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toEqual(['second'])
|
||||
})
|
||||
|
||||
it('highlights selected string option', () => {
|
||||
const options = ['option1', 'option2', 'option3']
|
||||
const wrapper = mountComponent('option2', options)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[1].classes()).toContain('bg-white')
|
||||
expect(buttons[1].classes()).toContain('text-neutral-900')
|
||||
expect(buttons[0].classes()).not.toContain('bg-white')
|
||||
expect(buttons[2].classes()).not.toContain('bg-white')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Number Options', () => {
|
||||
it('handles number array options', () => {
|
||||
const options = [1, 2, 3]
|
||||
const wrapper = mountComponent('2', options)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(3)
|
||||
expect(buttons[0].text()).toBe('1')
|
||||
expect(buttons[1].text()).toBe('2')
|
||||
expect(buttons[2].text()).toBe('3')
|
||||
})
|
||||
|
||||
it('emits string representation of number when clicked', async () => {
|
||||
const options = [10, 20, 30]
|
||||
const wrapper = mountComponent('10', options)
|
||||
|
||||
await clickButton(wrapper, '20')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toEqual(['20'])
|
||||
})
|
||||
|
||||
it('highlights selected number option', () => {
|
||||
const options = [100, 200, 300]
|
||||
const wrapper = mountComponent('200', options)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[1].classes()).toContain('bg-white')
|
||||
expect(buttons[1].classes()).toContain('text-neutral-900')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Object Options', () => {
|
||||
it('handles object array with label and value', () => {
|
||||
const options = [
|
||||
{ label: 'First Option', value: 'first' },
|
||||
{ label: 'Second Option', value: 'second' }
|
||||
]
|
||||
const wrapper = mountComponent('first', options)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(2)
|
||||
expect(buttons[0].text()).toBe('First Option')
|
||||
expect(buttons[1].text()).toBe('Second Option')
|
||||
})
|
||||
|
||||
it('emits object value when object option clicked', async () => {
|
||||
const options = [
|
||||
{ label: 'Apple', value: 'apple_val' },
|
||||
{ label: 'Banana', value: 'banana_val' }
|
||||
]
|
||||
const wrapper = mountComponent('apple_val', options)
|
||||
|
||||
await clickButton(wrapper, 'Banana')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toEqual(['banana_val'])
|
||||
})
|
||||
|
||||
it('highlights selected object option by value', () => {
|
||||
const options = [
|
||||
{ label: 'Small', value: 'sm' },
|
||||
{ label: 'Medium', value: 'md' },
|
||||
{ label: 'Large', value: 'lg' }
|
||||
]
|
||||
const wrapper = mountComponent('md', options)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[1].classes()).toContain('bg-white') // Medium
|
||||
expect(buttons[0].classes()).not.toContain('bg-white')
|
||||
expect(buttons[2].classes()).not.toContain('bg-white')
|
||||
})
|
||||
|
||||
it('handles objects without value field', () => {
|
||||
const options = [
|
||||
{ label: 'First', name: 'first_name' },
|
||||
{ label: 'Second', name: 'second_name' }
|
||||
]
|
||||
const wrapper = mountComponent('first_name', options)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].text()).toBe('First')
|
||||
expect(buttons[1].text()).toBe('Second')
|
||||
expect(buttons[0].classes()).toContain('bg-white')
|
||||
})
|
||||
|
||||
it('handles objects without label field', () => {
|
||||
const options = [
|
||||
{ value: 'val1', name: 'Name 1' },
|
||||
{ value: 'val2', name: 'Name 2' }
|
||||
]
|
||||
const wrapper = mountComponent('val1', options)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].text()).toBe('Name 1')
|
||||
expect(buttons[1].text()).toBe('Name 2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PrimeVue Compatibility', () => {
|
||||
it('uses custom optionLabel prop', () => {
|
||||
const options = [
|
||||
{ title: 'First Item', value: 'first' },
|
||||
{ title: 'Second Item', value: 'second' }
|
||||
]
|
||||
const wrapper = mountComponent('first', options, { optionLabel: 'title' })
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].text()).toBe('First Item')
|
||||
expect(buttons[1].text()).toBe('Second Item')
|
||||
})
|
||||
|
||||
it('uses custom optionValue prop', () => {
|
||||
const options = [
|
||||
{ label: 'First', id: 'first_id' },
|
||||
{ label: 'Second', id: 'second_id' }
|
||||
]
|
||||
const wrapper = mountComponent('first_id', options, { optionValue: 'id' })
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].classes()).toContain('bg-white')
|
||||
expect(buttons[1].classes()).not.toContain('bg-white')
|
||||
})
|
||||
|
||||
it('emits custom optionValue when clicked', async () => {
|
||||
const options = [
|
||||
{ label: 'First', id: 'first_id' },
|
||||
{ label: 'Second', id: 'second_id' }
|
||||
]
|
||||
const wrapper = mountComponent('first_id', options, { optionValue: 'id' })
|
||||
|
||||
await clickButton(wrapper, 'Second')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toEqual(['second_id'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled State', () => {
|
||||
it('disables all buttons when disabled prop is true', () => {
|
||||
const options = ['option1', 'option2']
|
||||
const wrapper = mountComponent('option1', options, { disabled: true })
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
buttons.forEach((button) => {
|
||||
expect(button.element.disabled).toBe(true)
|
||||
expect(button.classes()).toContain('opacity-50')
|
||||
expect(button.classes()).toContain('cursor-not-allowed')
|
||||
})
|
||||
})
|
||||
|
||||
it('does not emit events when disabled', async () => {
|
||||
const options = ['option1', 'option2']
|
||||
const wrapper = mountComponent('option1', options, { disabled: true })
|
||||
|
||||
await clickButton(wrapper, 'option2')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not apply hover styles when disabled', () => {
|
||||
const options = ['option1', 'option2']
|
||||
const wrapper = mountComponent('option1', options, { disabled: true })
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
buttons.forEach((button) => {
|
||||
expect(button.classes()).not.toContain('hover:bg-zinc-200/50')
|
||||
expect(button.classes()).not.toContain('cursor-pointer')
|
||||
})
|
||||
})
|
||||
|
||||
it('applies disabled styling to selected option', () => {
|
||||
const options = ['option1', 'option2']
|
||||
const wrapper = mountComponent('option1', options, { disabled: true })
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].classes()).not.toContain('bg-white') // Selected styling disabled
|
||||
expect(buttons[0].classes()).toContain('opacity-50')
|
||||
expect(buttons[0].classes()).toContain('text-zinc-500')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Selection Logic', () => {
|
||||
it('handles null modelValue', () => {
|
||||
const options = ['option1', 'option2']
|
||||
const wrapper = mountComponent(null, options)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
buttons.forEach((button) => {
|
||||
expect(button.classes()).not.toContain('bg-white')
|
||||
})
|
||||
})
|
||||
|
||||
it('handles undefined modelValue', () => {
|
||||
const options = ['option1', 'option2']
|
||||
const wrapper = mountComponent(undefined, options)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
buttons.forEach((button) => {
|
||||
expect(button.classes()).not.toContain('bg-white')
|
||||
})
|
||||
})
|
||||
|
||||
it('handles empty string modelValue', () => {
|
||||
const options = ['', 'option1', 'option2']
|
||||
const wrapper = mountComponent('', options)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].classes()).toContain('bg-white') // Empty string is selected
|
||||
expect(buttons[1].classes()).not.toContain('bg-white')
|
||||
})
|
||||
|
||||
it('compares values as strings', () => {
|
||||
const options = [1, '2', 3]
|
||||
const wrapper = mountComponent('1', options)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].classes()).toContain('bg-white') // '1' matches number 1 as string
|
||||
})
|
||||
})
|
||||
|
||||
describe('Visual States', () => {
|
||||
it('applies selected styling to active option', () => {
|
||||
const options = ['option1', 'option2']
|
||||
const wrapper = mountComponent('option1', options)
|
||||
|
||||
const selectedButton = wrapper.findAll('button')[0]
|
||||
expect(selectedButton.classes()).toContain('bg-white')
|
||||
expect(selectedButton.classes()).toContain('text-neutral-900')
|
||||
})
|
||||
|
||||
it('applies unselected styling to inactive options', () => {
|
||||
const options = ['option1', 'option2']
|
||||
const wrapper = mountComponent('option1', options)
|
||||
|
||||
const unselectedButton = wrapper.findAll('button')[1]
|
||||
expect(unselectedButton.classes()).toContain('bg-transparent')
|
||||
expect(unselectedButton.classes()).toContain('text-zinc-500')
|
||||
})
|
||||
|
||||
it('applies hover effects to enabled unselected buttons', () => {
|
||||
const options = ['option1', 'option2']
|
||||
const wrapper = mountComponent('option1', options, { disabled: false })
|
||||
|
||||
const unselectedButton = wrapper.findAll('button')[1]
|
||||
expect(unselectedButton.classes()).toContain('hover:bg-zinc-200/50')
|
||||
expect(unselectedButton.classes()).toContain('cursor-pointer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles very long option text', () => {
|
||||
const longText =
|
||||
'This is a very long option text that might cause layout issues'
|
||||
const options = ['short', longText, 'normal']
|
||||
const wrapper = mountComponent('short', options)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[1].text()).toBe(longText)
|
||||
expect(buttons).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('handles options with special characters', () => {
|
||||
const specialOptions = ['@#$%^&*()', '{}[]|\\:";\'<>?,./']
|
||||
const wrapper = mountComponent(specialOptions[0], specialOptions)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].text()).toBe('@#$%^&*()')
|
||||
expect(buttons[0].classes()).toContain('bg-white')
|
||||
})
|
||||
|
||||
it('handles unicode characters in options', () => {
|
||||
const unicodeOptions = ['🎨 Art', '中文', 'العربية']
|
||||
const wrapper = mountComponent('🎨 Art', unicodeOptions)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].text()).toBe('🎨 Art')
|
||||
expect(buttons[0].classes()).toContain('bg-white')
|
||||
})
|
||||
|
||||
it('handles duplicate option values', () => {
|
||||
const duplicateOptions = ['duplicate', 'unique', 'duplicate']
|
||||
const wrapper = mountComponent('duplicate', duplicateOptions)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].classes()).toContain('bg-white')
|
||||
expect(buttons[2].classes()).toContain('bg-white') // Both duplicates selected
|
||||
expect(buttons[1].classes()).not.toContain('bg-white')
|
||||
})
|
||||
|
||||
it('handles mixed type options safely', () => {
|
||||
const mixedOptions: any[] = [
|
||||
'string',
|
||||
123,
|
||||
{ label: 'Object', value: 'obj' },
|
||||
null
|
||||
]
|
||||
const wrapper = mountComponent('123', mixedOptions)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(4)
|
||||
expect(buttons[1].classes()).toContain('bg-white') // Number 123 as string
|
||||
})
|
||||
|
||||
it('handles objects with missing properties gracefully', () => {
|
||||
const incompleteOptions = [
|
||||
{}, // Empty object
|
||||
{ randomProp: 'value' }, // No standard props
|
||||
{ value: 'has_value' }, // No label
|
||||
{ label: 'has_label' } // No value
|
||||
]
|
||||
const wrapper = mountComponent('has_value', incompleteOptions)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(4)
|
||||
expect(buttons[2].classes()).toContain('bg-white')
|
||||
})
|
||||
|
||||
it('handles large number of options', () => {
|
||||
const manyOptions = Array.from(
|
||||
{ length: 50 },
|
||||
(_, i) => `Option ${i + 1}`
|
||||
)
|
||||
const wrapper = mountComponent('Option 25', manyOptions)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(50)
|
||||
expect(buttons[24].classes()).toContain('bg-white') // Option 25 at index 24
|
||||
})
|
||||
|
||||
it('fallback to index when all object properties are missing', () => {
|
||||
const problematicOptions = [
|
||||
{ someRandomProp: 'random1' },
|
||||
{ anotherRandomProp: 'random2' }
|
||||
]
|
||||
const wrapper = mountComponent('0', problematicOptions)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(2)
|
||||
expect(buttons[0].classes()).toContain('bg-white') // Falls back to index 0
|
||||
})
|
||||
})
|
||||
|
||||
describe('Event Handling', () => {
|
||||
it('prevents click events when disabled', async () => {
|
||||
const options = ['option1', 'option2']
|
||||
const wrapper = mountComponent('option1', options, { disabled: true })
|
||||
|
||||
const clickHandler = vi.fn()
|
||||
wrapper.vm.$el.addEventListener('click', clickHandler)
|
||||
|
||||
await clickButton(wrapper, 'option2')
|
||||
|
||||
expect(clickHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows repeated selection of same option', async () => {
|
||||
const options = ['option1', 'option2']
|
||||
const wrapper = mountComponent('option1', options)
|
||||
|
||||
await clickButton(wrapper, 'option1')
|
||||
await clickButton(wrapper, 'option1')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toHaveLength(2)
|
||||
expect(emitted![0]).toEqual(['option1'])
|
||||
expect(emitted![1]).toEqual(['option1'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -46,7 +46,7 @@ import { WidgetInputBaseClass } from '../layout'
|
||||
|
||||
interface Props {
|
||||
modelValue: string | null | undefined
|
||||
options: T[] // Now using generic type instead of any[]
|
||||
options: T[]
|
||||
optionLabel?: string // PrimeVue compatible prop
|
||||
optionValue?: string // PrimeVue compatible prop
|
||||
disabled?: boolean
|
||||
|
||||
Reference in New Issue
Block a user