From 6e2a3a0d070cbb12cf7ce1c2d2380256b20fe0ac Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Sun, 14 Sep 2025 00:20:56 -0700 Subject: [PATCH] [test] Add component test for `SelectButton` Vue widget (#5530) * add component test for select button * [refactor] improve test structure and typing - addresses @DrJKL review comments - Use proper SimplifiedWidget['options'] type instead of loose object type - Extract helper functions as module-level function declarations for better organization - Remove type assertion violation by using proper union type for null/undefined values - Format code with prettier to maintain consistency * [refactor] use safer optional chaining in test assertions - addresses @DrJKL's safety preference Replace emitted\![0] with emitted?.[0] for safer array access in test expectations. This follows the same pattern as applied to the textarea widget tests for consistency. --- .../components/WidgetSelectButton.test.ts | 433 ++++++++++++++++++ 1 file changed, 433 insertions(+) create mode 100644 src/renderer/extensions/vueNodes/widgets/components/WidgetSelectButton.test.ts diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectButton.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectButton.test.ts new file mode 100644 index 000000000..5e5494a09 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectButton.test.ts @@ -0,0 +1,433 @@ +import { mount } from '@vue/test-utils' +import PrimeVue from 'primevue/config' +import { describe, expect, it, vi } from 'vitest' + +import type { SimplifiedWidget } from '@/types/simplifiedWidget' + +import WidgetSelectButton from './WidgetSelectButton.vue' + +function createMockWidget( + value: string = 'option1', + options: SimplifiedWidget['options'] = {}, + callback?: (value: string) => void +): SimplifiedWidget { + return { + name: 'test_selectbutton', + type: 'string', + value, + options, + callback + } +} + +function mountComponent( + widget: SimplifiedWidget, + modelValue: string, + readonly = false +) { + return mount(WidgetSelectButton, { + global: { + plugins: [PrimeVue] + }, + props: { + widget, + modelValue, + readonly + } + }) +} + +async function clickSelectButton( + wrapper: ReturnType, + optionText: string +) { + const buttons = wrapper.findAll('button') + const targetButton = buttons.find((button) => + button.text().includes(optionText) + ) + + if (!targetButton) { + throw new Error(`Button with text "${optionText}" not found`) + } + + await targetButton.trigger('click') + return targetButton +} + +describe('WidgetSelectButton Button Selection', () => { + describe('Basic Rendering', () => { + it('renders FormSelectButton component', () => { + const widget = createMockWidget('option1', { + values: ['option1', 'option2', 'option3'] + }) + const wrapper = mountComponent(widget, 'option1') + + const formSelectButton = wrapper.findComponent({ + name: 'FormSelectButton' + }) + expect(formSelectButton.exists()).toBe(true) + }) + + it('renders buttons for each option', () => { + const options = ['first', 'second', 'third'] + const widget = createMockWidget('first', { values: options }) + const wrapper = mountComponent(widget, 'first') + + 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('handles empty options array', () => { + const widget = createMockWidget('', { values: [] }) + const wrapper = mountComponent(widget, '') + + const buttons = wrapper.findAll('button') + expect(buttons).toHaveLength(0) + }) + + it('handles missing values option', () => { + const widget = createMockWidget('') + const wrapper = mountComponent(widget, '') + + const buttons = wrapper.findAll('button') + expect(buttons).toHaveLength(0) + }) + }) + + describe('Selection State', () => { + it('highlights selected option', () => { + const options = ['apple', 'banana', 'cherry'] + const widget = createMockWidget('banana', { values: options }) + const wrapper = mountComponent(widget, 'banana') + + const buttons = wrapper.findAll('button') + const selectedButton = buttons[1] // 'banana' + const unselectedButton = buttons[0] // 'apple' + + expect(selectedButton.classes()).toContain('bg-white') + expect(selectedButton.classes()).toContain('text-neutral-900') + expect(unselectedButton.classes()).not.toContain('bg-white') + expect(unselectedButton.classes()).not.toContain('text-neutral-900') + }) + + it('handles no selection gracefully', () => { + const options = ['option1', 'option2'] + const widget = createMockWidget('nonexistent', { values: options }) + const wrapper = mountComponent(widget, 'nonexistent') + + const buttons = wrapper.findAll('button') + buttons.forEach((button) => { + expect(button.classes()).not.toContain('bg-white') + expect(button.classes()).not.toContain('text-neutral-900') + }) + }) + + it('updates selection when modelValue changes', async () => { + const options = ['first', 'second', 'third'] + const widget = createMockWidget('first', { values: options }) + const wrapper = mountComponent(widget, 'first') + + // Initially 'first' is selected + let buttons = wrapper.findAll('button') + expect(buttons[0].classes()).toContain('bg-white') + + // Update to 'second' + await wrapper.setProps({ modelValue: 'second' }) + buttons = wrapper.findAll('button') + expect(buttons[0].classes()).not.toContain('bg-white') + expect(buttons[1].classes()).toContain('bg-white') + }) + }) + + describe('User Interactions', () => { + it('emits update:modelValue when button is clicked', async () => { + const options = ['first', 'second', 'third'] + const widget = createMockWidget('first', { values: options }) + const wrapper = mountComponent(widget, 'first') + + await clickSelectButton(wrapper, 'second') + + const emitted = wrapper.emitted('update:modelValue') + expect(emitted).toBeDefined() + expect(emitted?.[0]).toEqual(['second']) + }) + + it('handles callback execution when provided', async () => { + const mockCallback = vi.fn() + const options = ['option1', 'option2'] + const widget = createMockWidget( + 'option1', + { values: options }, + mockCallback + ) + const wrapper = mountComponent(widget, 'option1') + + await clickSelectButton(wrapper, 'option2') + + expect(mockCallback).toHaveBeenCalledWith('option2') + }) + + it('handles missing callback gracefully', async () => { + const options = ['option1', 'option2'] + const widget = createMockWidget('option1', { values: options }, undefined) + const wrapper = mountComponent(widget, 'option1') + + await clickSelectButton(wrapper, 'option2') + + // Should still emit Vue event + const emitted = wrapper.emitted('update:modelValue') + expect(emitted).toBeDefined() + expect(emitted?.[0]).toEqual(['option2']) + }) + + it('allows clicking same option again', async () => { + const options = ['option1', 'option2'] + const widget = createMockWidget('option1', { values: options }) + const wrapper = mountComponent(widget, 'option1') + + await clickSelectButton(wrapper, 'option1') + + const emitted = wrapper.emitted('update:modelValue') + expect(emitted).toBeDefined() + expect(emitted?.[0]).toEqual(['option1']) + }) + }) + + describe('Readonly Mode', () => { + it('disables all buttons when readonly', () => { + const options = ['option1', 'option2', 'option3'] + const widget = createMockWidget('option1', { values: options }) + const wrapper = mountComponent(widget, 'option1', true) + + const formSelectButton = wrapper.findComponent({ + name: 'FormSelectButton' + }) + expect(formSelectButton.props('disabled')).toBe(true) + + const buttons = wrapper.findAll('button') + buttons.forEach((button) => { + expect(button.element.disabled).toBe(true) + expect(button.classes()).toContain('cursor-not-allowed') + expect(button.classes()).toContain('opacity-50') + }) + }) + + it('does not emit changes in readonly mode', async () => { + const options = ['option1', 'option2'] + const widget = createMockWidget('option1', { values: options }) + const wrapper = mountComponent(widget, 'option1', true) + + await clickSelectButton(wrapper, 'option2') + + const emitted = wrapper.emitted('update:modelValue') + expect(emitted).toBeUndefined() + }) + + it('does not change visual state in readonly mode', () => { + const options = ['option1', 'option2'] + const widget = createMockWidget('option1', { values: options }) + const wrapper = mountComponent(widget, 'option1', true) + + const buttons = wrapper.findAll('button') + buttons.forEach((button) => { + expect(button.classes()).not.toContain('hover:bg-zinc-200/50') + }) + }) + }) + + describe('Option Types', () => { + it('handles string options', () => { + const options = ['apple', 'banana', 'cherry'] + const widget = createMockWidget('banana', { values: options }) + const wrapper = mountComponent(widget, 'banana') + + const buttons = wrapper.findAll('button') + expect(buttons[0].text()).toBe('apple') + expect(buttons[1].text()).toBe('banana') + expect(buttons[2].text()).toBe('cherry') + }) + + it('handles number options', () => { + const options = [1, 2, 3] + const widget = createMockWidget('2', { values: options }) + const wrapper = mountComponent(widget, '2') + + const buttons = wrapper.findAll('button') + expect(buttons[0].text()).toBe('1') + expect(buttons[1].text()).toBe('2') + expect(buttons[2].text()).toBe('3') + + // The selected button should be the one with '2' + expect(buttons[1].classes()).toContain('bg-white') + }) + + it('handles object options with label and value', () => { + const options = [ + { label: 'First Option', value: 'first' }, + { label: 'Second Option', value: 'second' }, + { label: 'Third Option', value: 'third' } + ] + const widget = createMockWidget('second', { values: options }) + const wrapper = mountComponent(widget, 'second') + + const buttons = wrapper.findAll('button') + expect(buttons[0].text()).toBe('First Option') + expect(buttons[1].text()).toBe('Second Option') + expect(buttons[2].text()).toBe('Third Option') + + // 'second' should be selected + expect(buttons[1].classes()).toContain('bg-white') + }) + + it('emits correct values for object options', async () => { + const options = [ + { label: 'First', value: 'first_val' }, + { label: 'Second', value: 'second_val' } + ] + const widget = createMockWidget('first_val', { values: options }) + const wrapper = mountComponent(widget, 'first_val') + + await clickSelectButton(wrapper, 'Second') + + const emitted = wrapper.emitted('update:modelValue') + expect(emitted).toBeDefined() + expect(emitted?.[0]).toEqual(['second_val']) + }) + }) + + describe('Edge Cases', () => { + it('handles options with special characters', () => { + const options = ['@#$%^&*()', '{}[]|\\:";\'<>?,./'] + const widget = createMockWidget(options[0], { values: options }) + const wrapper = mountComponent(widget, options[0]) + + const buttons = wrapper.findAll('button') + expect(buttons[0].text()).toBe('@#$%^&*()') + expect(buttons[1].text()).toBe('{}[]|\\:";\'<>?,./') + }) + + it('handles empty string options', () => { + const options = ['', 'not empty', ' ', 'normal'] + const widget = createMockWidget('', { values: options }) + const wrapper = mountComponent(widget, '') + + const buttons = wrapper.findAll('button') + expect(buttons).toHaveLength(4) + expect(buttons[0].classes()).toContain('bg-white') // Empty string is selected + }) + + it('handles null/undefined in options', () => { + const options: (string | null | undefined)[] = [ + 'valid', + null, + undefined, + 'another' + ] + const widget = createMockWidget('valid', { values: options }) + const wrapper = mountComponent(widget, 'valid') + + const buttons = wrapper.findAll('button') + expect(buttons).toHaveLength(4) + expect(buttons[0].classes()).toContain('bg-white') + }) + + it('handles very long option text', () => { + const longText = + 'This is a very long option text that might cause layout issues if not handled properly' + const options = ['short', longText, 'normal'] + const widget = createMockWidget('short', { values: options }) + const wrapper = mountComponent(widget, 'short') + + const buttons = wrapper.findAll('button') + expect(buttons[1].text()).toBe(longText) + }) + + it('handles large number of options', () => { + const options = Array.from({ length: 20 }, (_, i) => `option${i + 1}`) + const widget = createMockWidget('option5', { values: options }) + const wrapper = mountComponent(widget, 'option5') + + const buttons = wrapper.findAll('button') + expect(buttons).toHaveLength(20) + expect(buttons[4].classes()).toContain('bg-white') // option5 is at index 4 + }) + + it('handles duplicate options', () => { + const options = ['duplicate', 'unique', 'duplicate', 'unique'] + const widget = createMockWidget('duplicate', { values: options }) + const wrapper = mountComponent(widget, 'duplicate') + + const buttons = wrapper.findAll('button') + expect(buttons).toHaveLength(4) + // Both 'duplicate' buttons should be highlighted (due to value matching) + expect(buttons[0].classes()).toContain('bg-white') + expect(buttons[2].classes()).toContain('bg-white') + }) + }) + + describe('Styling and Layout', () => { + it('applies proper button styling', () => { + const options = ['option1', 'option2'] + const widget = createMockWidget('option1', { values: options }) + const wrapper = mountComponent(widget, 'option1') + + const buttons = wrapper.findAll('button') + buttons.forEach((button) => { + expect(button.classes()).toContain('flex-1') + expect(button.classes()).toContain('h-6') + expect(button.classes()).toContain('px-5') + expect(button.classes()).toContain('rounded') + expect(button.classes()).toContain('text-center') + expect(button.classes()).toContain('text-xs') + }) + }) + + it('applies container styling', () => { + const options = ['option1', 'option2'] + const widget = createMockWidget('option1', { values: options }) + const wrapper = mountComponent(widget, 'option1') + + const container = wrapper.find('div').element + expect(container.className).toContain('p-1') + expect(container.className).toContain('inline-flex') + expect(container.className).toContain('justify-center') + expect(container.className).toContain('items-center') + expect(container.className).toContain('gap-1') + }) + + it('applies hover effects for non-selected options', () => { + const options = ['option1', 'option2'] + const widget = createMockWidget('option1', { values: options }) + const wrapper = mountComponent(widget, 'option1', false) + + const buttons = wrapper.findAll('button') + const unselectedButton = buttons[1] // 'option2' + + expect(unselectedButton.classes()).toContain('hover:bg-zinc-200/50') + expect(unselectedButton.classes()).toContain('cursor-pointer') + }) + }) + + describe('Integration with Layout', () => { + it('renders within WidgetLayoutField', () => { + const widget = createMockWidget('test', { values: ['test'] }) + const wrapper = mountComponent(widget, 'test') + + const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' }) + expect(layoutField.exists()).toBe(true) + expect(layoutField.props('widget')).toEqual(widget) + }) + + it('passes widget name to layout field', () => { + const widget = createMockWidget('test', { values: ['test'] }) + widget.name = 'custom_select_button' + const wrapper = mountComponent(widget, 'test') + + const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' }) + expect(layoutField.props('widget').name).toBe('custom_select_button') + }) + }) +})