import { describe, it, expect } from 'vitest' import { mount, flushPromises } from '@vue/test-utils' import { ref } from 'vue' import SelectBtnField from '../SelectBtnField.vue' import SyForm from '@/components/Customs/SyForm/SyForm.vue' describe('SelectBtnField', () => { it('renders correctly', () => { const wrapper = mount(SelectBtnField) expect(wrapper.html()).toMatchSnapshot() }) it('renders correctly with props', () => { const wrapper = mount(SelectBtnField, { props: { label: 'Test', helpText: 'Test', items: [ { text: 'Test', value: 'test', }, { text: 'Test 2', value: '', }, { text: 'Test 3', value: 'test3', }, ], }, }) expect(wrapper.html()).toMatchSnapshot() }) it('render correctly in multiple mode', () => { const wrapper = mount(SelectBtnField, { props: { label: 'Test', helpText: 'Test', items: [ { text: 'Test', value: 'test', }, { text: 'Test 2', value: '', }, { text: 'Test 3', value: 'test3', }, ], multiple: true, }, }) expect(wrapper.html()).toMatchSnapshot() }) it('emits an update event when the value change in single mode', async () => { const wrapper = mount(SelectBtnField, { props: { label: 'Test', helpText: 'Test', items: [ { text: 'Test', value: 'test', }, ], }, }) await wrapper.find('[role="option"]').trigger('click') expect(wrapper.emitted()).toHaveProperty('update:modelValue') await wrapper.find('[role="option"]').trigger('click') expect(wrapper.emitted('update:modelValue')).toEqual([['test'], [null]]) }) it(`emits an array of values when the value changes in multiple mode`, async () => { const wrapper = mount(SelectBtnField, { props: { label: 'Test', helpText: 'Test', items: [ { text: 'Test', value: 'test', }, { text: 'Test 2', value: 'test2', }, { text: 'Test 3', value: 'test3', }, ], multiple: true, }, }) await wrapper.find('li:nth-child(2)[role="option"]').trigger('click') await wrapper.find('li:nth-child(3)[role="option"]').trigger('click') await wrapper.find('li:nth-child(2)[role="option"]').trigger('click') expect(wrapper.emitted('update:modelValue')).toEqual([ [['test2']], [['test2', 'test3']], [['test3']], ]) }) it('handles multiple mode safely when modelValue is null', async () => { const wrapper = mount(SelectBtnField, { props: { label: 'Test', helpText: 'Test', items: [ { text: 'Test', value: 'test', }, { text: 'Test 2', value: 'test2', }, { text: 'Test 3', value: 'test3', }, ], multiple: true, modelValue: null, }, }) await wrapper.find('[role="option"]').trigger('click') expect(wrapper.emitted('update:modelValue')).toEqual([ [['test']], ]) }) it(`display correctly with an error`, () => { const wrapper = mount(SelectBtnField, { props: { label: 'Test', helpText: 'Test', items: [ { text: 'Test', value: 'test', }, { text: 'Test 2', value: 'test2', }, { text: 'Test 3', value: 'test3', }, ], errorMessages: ['Test'], }, }) expect(wrapper.html()).toMatchSnapshot() }) it(`clear the others values when defined to unique`, async () => { const wrapper = mount(SelectBtnField, { props: { label: 'Test', items: [ { text: 'Test 1', value: 'test1', }, { text: 'Test 2', value: 'test2', }, { text: 'Other', value: 'other', unique: true, }, ], multiple: true, }, }) await wrapper.find('li:nth-child(1)[role="option"]').trigger('click') await wrapper.find('li:nth-child(2)[role="option"]').trigger('click') await wrapper.find('li:nth-child(3)[role="option"]').trigger('click') await wrapper.find('li:nth-child(2)[role="option"]').trigger('click') expect(wrapper.emitted('update:modelValue')).toEqual([ [['test1']], [['test1', 'test2']], [['other']], [['test2']], ]) }) it(`display correctly in dark mode with an error`, () => { const DarkMode = { template: `
`, components: { SelectBtnField, }, data() { return { props: { label: 'Test', helpText: 'Test', items: [ { text: 'Test 1', value: 'test1', }, { text: 'Test 2', value: 'test2', }, ], errorMessages: ['Test'], multiple: true, inline: true, }, } }, } const wrapper = mount(DarkMode) wrapper.find('li:nth-child(1)[role="option"]').trigger('click') wrapper.find('li:nth-child(2)[role="option"]').trigger('click') expect(wrapper.html()).toMatchSnapshot() }) it(`display correctly with in dark mode with a help text`, () => { const DarkMode = { template: `
`, components: { SelectBtnField, }, data() { return { props: { label: 'Test', helpText: 'Test', items: [ { text: 'Test 1', value: 'test1', }, { text: 'Test 2', value: 'test2', }, ], multiple: true, inline: true, }, } }, } const wrapper = mount(DarkMode) expect(wrapper.html()).toMatchSnapshot() }) it('do not allow to select an item when the readonly prop is defined', async () => { const wrapper = mount(SelectBtnField, { props: { label: 'Test', helpText: 'Test', items: [ { text: 'Test 1', value: 'test1', }, { text: 'Test 2', value: 'test2', }, ], readonly: true, }, }) await wrapper.find('[role="option"]').trigger('click') expect(wrapper.emitted()).not.toHaveProperty('update:modelValue') }) it('filters out items with null or undefined value', () => { const wrapper = mount(SelectBtnField, { props: { items: [ { text: 'Valide', value: 'valide' }, { text: 'Null', value: null as unknown as string }, { text: 'Undefined', value: undefined as unknown as string }, ], }, }) const options = wrapper.findAll('[role="option"]') expect(options).toHaveLength(1) expect(options[0]!.text()).toContain('Valide') }) it('syncs internalValue when modelValue prop changes externally', async () => { const wrapper = mount(SelectBtnField, { props: { items: [ { text: 'A', value: 'a' }, { text: 'B', value: 'b' }, ], modelValue: 'a', }, }) let selectedItem = wrapper.find('[aria-selected="true"]') expect(selectedItem.text()).toContain('A') await wrapper.setProps({ modelValue: 'b' }) selectedItem = wrapper.find('[aria-selected="true"]') expect(selectedItem.text()).toContain('B') }) it('syncs internalValue array when modelValue changes in multiple mode', async () => { const wrapper = mount(SelectBtnField, { props: { items: [ { text: 'A', value: 'a' }, { text: 'B', value: 'b' }, ], multiple: true, modelValue: ['a'], }, }) expect(wrapper.find('li:nth-child(1)').attributes('aria-checked')).toBe('true') expect(wrapper.find('li:nth-child(2)').attributes('aria-checked')).toBe('false') await wrapper.setProps({ modelValue: ['a', 'b'] }) expect(wrapper.find('li:nth-child(2)').attributes('aria-checked')).toBe('true') }) it('sets correct ARIA attributes on the listbox', () => { const wrapper = mount(SelectBtnField, { props: { label: 'Choix', multiple: true, inline: true, errorMessages: ['Erreur'], }, }) const listbox = wrapper.find('[role="listbox"]') expect(listbox.attributes('aria-label')).toBe('Choix') expect(listbox.attributes('aria-multiselectable')).toBe('true') expect(listbox.attributes('aria-orientation')).toBe('horizontal') expect(listbox.attributes('aria-invalid')).toBe('true') }) it('sets aria-readonly on the listbox when readonly', () => { const wrapper = mount(SelectBtnField, { props: { readonly: true }, }) expect(wrapper.find('[role="listbox"]').attributes('aria-readonly')).toBe('true') }) it('navigates to next item with ArrowRight key', async () => { const wrapper = mount(SelectBtnField, { props: { items: [ { text: 'A', value: 'a' }, { text: 'B', value: 'b' }, { text: 'C', value: 'c' }, ], }, }) await wrapper.find('[role="listbox"]').trigger('keydown', { key: 'ArrowRight' }) await wrapper.find('[role="listbox"]').trigger('keydown', { key: 'ArrowRight' }) const emitted = wrapper.emitted('update:modelValue') expect(emitted).toBeTruthy() expect(emitted!.length).toBeGreaterThanOrEqual(1) }) it('wraps around to first item when navigating past last with ArrowDown', async () => { const wrapper = mount(SelectBtnField, { props: { items: [ { text: 'A', value: 'a' }, { text: 'B', value: 'b' }, ], modelValue: 'b', }, }) await wrapper.find('[role="listbox"]').trigger('keydown', { key: 'ArrowDown' }) const emitted = wrapper.emitted('update:modelValue') expect(emitted).toBeTruthy() expect(emitted![emitted!.length - 1]![0]).toBe('a') }) it('wraps around to last item when navigating before first with ArrowUp', async () => { const wrapper = mount(SelectBtnField, { props: { items: [ { text: 'A', value: 'a' }, { text: 'B', value: 'b' }, ], modelValue: 'a', }, }) await wrapper.find('[role="listbox"]').trigger('keydown', { key: 'ArrowUp' }) const emitted = wrapper.emitted('update:modelValue') expect(emitted).toBeTruthy() expect(emitted![emitted!.length - 1]![0]).toBe('b') }) it('selects item with Space key', async () => { const wrapper = mount(SelectBtnField, { props: { items: [{ text: 'Test', value: 'test' }], }, }) await wrapper.find('[role="option"]').trigger('keydown', { key: ' ' }) expect(wrapper.emitted('update:modelValue')).toEqual([['test']]) }) it('selecting unique item when others are selected replaces all', async () => { const wrapper = mount(SelectBtnField, { props: { multiple: true, items: [ { text: 'A', value: 'a' }, { text: 'B', value: 'b' }, { text: 'Unique', value: 'unique', unique: true }, ], modelValue: ['a', 'b'], }, }) await wrapper.find('li:nth-child(3)[role="option"]').trigger('click') const emitted = wrapper.emitted('update:modelValue') expect(emitted![emitted!.length - 1]![0]).toEqual(['unique']) }) it('re-selecting a unique item in multiple mode deselects it', async () => { const wrapper = mount(SelectBtnField, { props: { multiple: true, items: [ { text: 'Unique', value: 'unique', unique: true }, ], modelValue: ['unique'], }, }) await wrapper.find('[role="option"]').trigger('click') const emitted = wrapper.emitted('update:modelValue') expect(emitted![emitted!.length - 1]![0]).toEqual([]) }) it('displays helpText when no validation message', () => { const wrapper = mount(SelectBtnField, { props: { helpText: 'Aide contextuelle', items: [{ text: 'A', value: 'a' }], }, }) expect(wrapper.text()).toContain('Aide contextuelle') }) it('displays errorMessages instead of helpText when both are provided', () => { const wrapper = mount(SelectBtnField, { props: { helpText: 'Aide contextuelle', errorMessages: ['Champ invalide'], items: [{ text: 'A', value: 'a' }], }, }) expect(wrapper.text()).toContain('Champ invalide') expect(wrapper.text()).not.toContain('Aide contextuelle') }) describe('validation', () => { it('exposes validateOnSubmit and returns false for an empty required field', async () => { const wrapper = mount(SelectBtnField, { props: { label: 'Moyen de contact', required: true, items: [{ text: 'Email', value: 'email' }], }, }) const vm = wrapper.vm as unknown as { validateOnSubmit: () => Promise, errors: string[] } const result = await vm.validateOnSubmit() expect(result).toBe(false) expect(vm.errors).toContain('Le champ Moyen de contact est requis.') wrapper.unmount() }) it('validateOnSubmit returns true when a required field is filled', async () => { const wrapper = mount(SelectBtnField, { props: { label: 'Moyen de contact', required: true, modelValue: 'email', items: [{ text: 'Email', value: 'email' }], }, }) const vm = wrapper.vm as unknown as { validateOnSubmit: () => Promise } expect(await vm.validateOnSubmit()).toBe(true) wrapper.unmount() }) it('displays an error from a customRule', async () => { const wrapper = mount(SelectBtnField, { props: { label: 'Moyen de contact', modelValue: 'sms', items: [ { text: 'Email', value: 'email' }, { text: 'SMS', value: 'sms' }, ], customRules: [{ type: 'custom', options: { validate: (v: unknown) => v !== 'sms', message: 'Le SMS n’est pas disponible.', }, }], }, }) const vm = wrapper.vm as unknown as { validateOnSubmit: () => Promise, hasError: boolean, errors: string[] } await vm.validateOnSubmit() await flushPromises() expect(vm.hasError).toBe(true) expect(vm.errors).toContain('Le SMS n’est pas disponible.') expect(wrapper.find('[role="listbox"]').attributes('aria-invalid')).toBe('true') wrapper.unmount() }) it('displays a warning and a success message from custom rules', async () => { const warningWrapper = mount(SelectBtnField, { props: { label: 'Moyen de contact', modelValue: 'courrier', items: [{ text: 'Courrier', value: 'courrier' }], customWarningRules: [{ type: 'custom', options: { validate: (v: unknown) => v !== 'courrier', warningMessage: 'Délais allongés.', }, }], }, }) const warningVm = warningWrapper.vm as unknown as { validateOnSubmit: () => Promise, hasWarning: boolean, warnings: string[] } await warningVm.validateOnSubmit() await flushPromises() expect(warningVm.hasWarning).toBe(true) expect(warningVm.warnings).toContain('Délais allongés.') warningWrapper.unmount() const successWrapper = mount(SelectBtnField, { props: { label: 'Moyen de contact', modelValue: 'email', showSuccessMessages: true, items: [{ text: 'Email', value: 'email' }], customSuccessRules: [{ type: 'custom', options: { validate: (v: unknown) => v === 'email', successMessage: 'Choix optimal.', }, }], }, }) const successVm = successWrapper.vm as unknown as { validateOnSubmit: () => Promise, hasSuccess: boolean, successes: string[] } await successVm.validateOnSubmit() await flushPromises() expect(successVm.hasSuccess).toBe(true) expect(successVm.successes).toContain('Choix optimal.') successWrapper.unmount() }) it('sets aria-required when required', () => { const wrapper = mount(SelectBtnField, { props: { label: 'Choix', required: true }, }) expect(wrapper.find('[role="listbox"]').attributes('aria-required')).toBe('true') wrapper.unmount() }) it('does not display errors when disableErrorHandling is true', async () => { const wrapper = mount(SelectBtnField, { props: { label: 'Moyen de contact', required: true, disableErrorHandling: true, items: [{ text: 'Email', value: 'email' }], }, }) const vm = wrapper.vm as unknown as { validateOnSubmit: () => Promise, errors: string[] } await vm.validateOnSubmit() await flushPromises() expect(vm.errors).toHaveLength(0) expect(wrapper.find('[role="listbox"]').attributes('aria-invalid')).toBe('false') wrapper.unmount() }) it('registers with a parent SyForm and is validated on submit', async () => { const wrapper = mount({ components: { SyForm, SelectBtnField }, template: ` `, setup() { return { value: ref(null), items: [{ text: 'Email', value: 'email' }], } }, }) const form = wrapper.getComponent(SyForm) const isValid = await (form.vm as unknown as { validate: () => Promise }).validate() await flushPromises() expect(isValid).toBe(false) expect(wrapper.text()).toContain('Le champ Moyen de contact est requis.') wrapper.unmount() }) it('validates on selection change when isValidateOnBlur is false', async () => { const wrapper = mount({ components: { SelectBtnField }, template: ` `, setup() { return { value: ref(null), items: [ { text: 'Email', value: 'email' }, { text: 'SMS', value: 'sms' }, ], customRules: [{ type: 'custom', options: { validate: (v: unknown) => v !== 'sms', message: 'Le SMS n’est pas disponible.', }, }], } }, }) const vm = wrapper.findComponent(SelectBtnField).vm as unknown as { hasError: boolean, errors: string[] } // Sélection d'une valeur invalide -> validation immédiate await wrapper.find('li:nth-child(2)[role="option"]').trigger('click') await flushPromises() expect(vm.hasError).toBe(true) expect(vm.errors).toContain('Le SMS n’est pas disponible.') // Re-clic pour désélectionner -> l'erreur disparaît await wrapper.find('li:nth-child(2)[role="option"]').trigger('click') await flushPromises() expect(vm.hasError).toBe(false) wrapper.unmount() }) it('supports Vuetify native validation (useVuetifyValidation)', async () => { const wrapper = mount(SelectBtnField, { props: { label: 'Moyen de contact', useVuetifyValidation: true, rules: [(v: unknown) => !!v || 'Le moyen de contact est requis.'], items: [{ text: 'Email', value: 'email' }], }, }) const vm = wrapper.vm as unknown as { validateOnSubmit: () => Promise, errors: string[] } expect(await vm.validateOnSubmit()).toBe(false) await flushPromises() expect(vm.errors).toContain('Le moyen de contact est requis.') wrapper.unmount() }) it('clears the validation state via clearValidation', async () => { const wrapper = mount(SelectBtnField, { props: { label: 'Moyen de contact', required: true, items: [{ text: 'Email', value: 'email' }], }, }) const vm = wrapper.vm as unknown as { validateOnSubmit: () => Promise clearValidation: () => void errors: string[] } await vm.validateOnSubmit() await flushPromises() expect(vm.errors.length).toBeGreaterThan(0) vm.clearValidation() await flushPromises() expect(vm.errors).toHaveLength(0) wrapper.unmount() }) it('renders external error, warning and success messages', async () => { const errorWrapper = mount(SelectBtnField, { props: { label: 'Moyen de contact', errorMessages: ['Erreur externe'], items: [{ text: 'Email', value: 'email' }], }, }) expect(errorWrapper.text()).toContain('Erreur externe') expect(errorWrapper.find('.select-btn-field__message--error').exists()).toBe(true) errorWrapper.unmount() const warningWrapper = mount(SelectBtnField, { props: { label: 'Moyen de contact', warningMessages: ['Avertissement externe'], items: [{ text: 'Email', value: 'email' }], }, }) expect(warningWrapper.text()).toContain('Avertissement externe') expect(warningWrapper.find('.select-btn-field__message--warning').exists()).toBe(true) warningWrapper.unmount() const successWrapper = mount(SelectBtnField, { props: { label: 'Moyen de contact', successMessages: ['Succès externe'], showSuccessMessages: true, items: [{ text: 'Email', value: 'email' }], }, }) expect(successWrapper.text()).toContain('Succès externe') expect(successWrapper.find('.select-btn-field__message--success').exists()).toBe(true) successWrapper.unmount() }) it('applies the matching state class on the listbox container', async () => { const wrapper = mount(SelectBtnField, { props: { label: 'Moyen de contact', errorMessages: ['Erreur'], items: [{ text: 'Email', value: 'email' }], }, }) expect(wrapper.find('.select-btn-field__options--error').exists()).toBe(true) wrapper.unmount() }) it('does not render the messages area when hideDetails is true', () => { const wrapper = mount(SelectBtnField, { props: { label: 'Moyen de contact', hideDetails: true, errorMessages: ['Erreur masquée'], items: [{ text: 'Email', value: 'email' }], }, }) expect(wrapper.find('.select-btn-field__messages').exists()).toBe(false) expect(wrapper.text()).not.toContain('Erreur masquée') wrapper.unmount() }) it('does not display success message text when showSuccessMessages is false', async () => { const wrapper = mount(SelectBtnField, { props: { label: 'Moyen de contact', modelValue: 'email', items: [{ text: 'Email', value: 'email' }], customSuccessRules: [{ type: 'custom', options: { validate: (v: unknown) => v === 'email', successMessage: 'Choix optimal.', }, }], }, }) const vm = wrapper.vm as unknown as { validateOnSubmit: () => Promise, hasSuccess: boolean } await vm.validateOnSubmit() await flushPromises() // L'état de succès est actif mais le message texte reste masqué expect(vm.hasSuccess).toBe(true) expect(wrapper.text()).not.toContain('Choix optimal.') wrapper.unmount() }) it('does not enter success state when filled without an explicit success source', async () => { const wrapper = mount({ components: { SelectBtnField }, template: ``, setup() { return { value: ref(null), items: [{ text: 'Email', value: 'email' }], } }, }) // Sélection d'un item (champ rempli) sans aucune règle/source de succès await wrapper.find('[role="option"]').trigger('click') await flushPromises() const field = wrapper.findComponent(SelectBtnField) const vm = field.vm as unknown as { hasSuccess: boolean, successes: string[] } expect(vm.hasSuccess).toBe(false) expect(vm.successes).toHaveLength(0) // Pas de coloration verte de l'item sélectionné expect(wrapper.find('.select-btn-field__options--success').exists()).toBe(false) wrapper.unmount() }) it('does not allow selection when disabled', async () => { const wrapper = mount(SelectBtnField, { props: { label: 'Moyen de contact', disabled: true, items: [{ text: 'Email', value: 'email' }], }, }) await wrapper.find('[role="option"]').trigger('click') expect(wrapper.emitted()).not.toHaveProperty('update:modelValue') expect(wrapper.find('[role="listbox"]').attributes('aria-disabled')).toBe('true') wrapper.unmount() }) }) })