9207d7bb95
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
262 lines
9.5 KiB
TypeScript
262 lines
9.5 KiB
TypeScript
import {computed, ref} from 'vue'
|
|
import {describe, expect, it, vi} from 'vitest'
|
|
import {mount} from '@vue/test-utils'
|
|
import type {DefineComponent} from 'vue'
|
|
import RadioButton from './RadioButton.vue'
|
|
import {radioGroupContextKey, type RadioGroupContext, type RadioValue} from './context'
|
|
|
|
type RadioButtonProps = {
|
|
id?: string
|
|
label?: string
|
|
name?: string
|
|
modelValue?: string | number | boolean | null | undefined
|
|
value?: string | number | boolean | null | undefined
|
|
inputClass?: string
|
|
labelClass?: string
|
|
groupClass?: string
|
|
required?: boolean
|
|
disabled?: boolean
|
|
readonly?: boolean
|
|
hint?: string
|
|
error?: string
|
|
success?: string
|
|
}
|
|
|
|
const RadioButtonForTest = RadioButton as DefineComponent<RadioButtonProps>
|
|
|
|
const mountRadioButton = (props: RadioButtonProps = {}) =>
|
|
mount(RadioButtonForTest, {
|
|
props,
|
|
})
|
|
|
|
describe('MalioRadioButton', () => {
|
|
it('renders the label text', () => {
|
|
const wrapper = mountRadioButton({label: 'Option 1'})
|
|
|
|
expect(wrapper.get('.radio-text').text()).toBe('Option 1')
|
|
})
|
|
|
|
it('applies provided id to input and label', () => {
|
|
const wrapper = mountRadioButton({id: 'radio-id', label: 'Option 1'})
|
|
|
|
expect(wrapper.get('input').attributes('id')).toBe('radio-id')
|
|
expect(wrapper.get('.radio-text').attributes('for')).toBe('radio-id')
|
|
})
|
|
|
|
it('generates an id when missing and reuses it on label', () => {
|
|
const wrapper = mountRadioButton({label: 'Option 1'})
|
|
|
|
const inputId = wrapper.get('input').attributes('id')
|
|
|
|
expect(inputId?.startsWith('malio-radio-')).toBe(true)
|
|
expect(wrapper.get('.radio-text').attributes('for')).toBe(inputId)
|
|
})
|
|
|
|
it('applies the name attribute', () => {
|
|
const wrapper = mountRadioButton({name: 'choice-group'})
|
|
|
|
expect(wrapper.get('input').attributes('name')).toBe('choice-group')
|
|
})
|
|
|
|
it('sets required when true', () => {
|
|
const wrapper = mountRadioButton({required: true})
|
|
|
|
expect(wrapper.get('input').attributes('required')).toBeDefined()
|
|
})
|
|
|
|
it('sets disabled styles when true', () => {
|
|
const wrapper = mountRadioButton({disabled: true, label: 'Option 1'})
|
|
|
|
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
|
|
expect(wrapper.get('.radio-control').classes()).toContain('is-disabled')
|
|
expect(wrapper.get('.radio-text').classes()).toContain('cursor-not-allowed')
|
|
})
|
|
|
|
it('checks the input when modelValue matches value', () => {
|
|
const wrapper = mountRadioButton({modelValue: 'a', value: 'a'})
|
|
|
|
expect((wrapper.get('input').element as HTMLInputElement).checked).toBe(true)
|
|
})
|
|
|
|
it('emits update:modelValue on change', async () => {
|
|
const wrapper = mountRadioButton({modelValue: 'a', value: 'b'})
|
|
|
|
await wrapper.get('input').trigger('change')
|
|
|
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['b'])
|
|
})
|
|
|
|
it('prevents updates when readonly', async () => {
|
|
const wrapper = mountRadioButton({modelValue: 'a', value: 'b', readonly: true})
|
|
const input = wrapper.get('input')
|
|
|
|
;(input.element as HTMLInputElement).checked = true
|
|
await input.trigger('change')
|
|
|
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
|
expect((input.element as HTMLInputElement).checked).toBe(false)
|
|
})
|
|
|
|
it('shows hint message and wires aria-describedby', () => {
|
|
const wrapper = mountRadioButton({hint: 'Helpful hint'})
|
|
const input = wrapper.get('input')
|
|
const message = wrapper.get('.radio-message')
|
|
|
|
expect(message.text()).toBe('Helpful hint')
|
|
expect(input.attributes('aria-describedby')).toBe(message.attributes('id'))
|
|
expect(input.attributes('aria-invalid')).toBe('false')
|
|
})
|
|
|
|
it('shows error state on control, label and helper text', () => {
|
|
const wrapper = mountRadioButton({
|
|
label: 'Option 1',
|
|
error: 'Selection required',
|
|
})
|
|
|
|
expect(wrapper.get('.radio-control').classes()).toContain('is-error')
|
|
expect(wrapper.get('.radio-text').classes()).toContain('text-m-danger')
|
|
expect(wrapper.get('.radio-message').classes()).toContain('text-m-danger')
|
|
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
|
|
})
|
|
|
|
it('shows success state when no error is present', () => {
|
|
const wrapper = mountRadioButton({
|
|
label: 'Option 1',
|
|
success: 'Selection saved',
|
|
})
|
|
|
|
expect(wrapper.get('.radio-control').classes()).toContain('is-success')
|
|
expect(wrapper.get('.radio-text').classes()).toContain('text-m-success')
|
|
expect(wrapper.get('.radio-message').classes()).toContain('text-m-success')
|
|
})
|
|
|
|
it('prioritizes error over success', () => {
|
|
const wrapper = mountRadioButton({
|
|
error: 'Selection required',
|
|
success: 'Selection saved',
|
|
})
|
|
|
|
expect(wrapper.get('.radio-control').classes()).toContain('is-error')
|
|
expect(wrapper.get('.radio-control').classes()).not.toContain('is-success')
|
|
expect(wrapper.get('.radio-message').text()).toBe('Selection required')
|
|
expect(wrapper.get('.radio-message').classes()).toContain('text-m-danger')
|
|
})
|
|
|
|
it('merges custom classes on group, input and label', () => {
|
|
const wrapper = mountRadioButton({
|
|
label: 'Option 1',
|
|
groupClass: 'mt-0 custom-group',
|
|
inputClass: 'border-red-500',
|
|
labelClass: 'font-bold',
|
|
})
|
|
|
|
expect(wrapper.get('.radio-item').classes()).toContain('custom-group')
|
|
expect(wrapper.get('.radio-item').classes()).toContain('mt-0')
|
|
expect(wrapper.get('input').classes()).toContain('border-red-500')
|
|
expect(wrapper.get('.radio-text').classes()).toContain('font-bold')
|
|
})
|
|
|
|
it('uses muted label color and muted border when unchecked', () => {
|
|
const wrapper = mountRadioButton({label: 'Option 1', value: 'a', modelValue: 'b'})
|
|
|
|
expect(wrapper.get('.radio-text').classes()).toContain('text-m-muted')
|
|
expect(wrapper.get('input').classes()).toContain('border-m-muted')
|
|
})
|
|
|
|
it('uses black label color when checked', () => {
|
|
const wrapper = mountRadioButton({label: 'Option 1', value: 'a', modelValue: 'a'})
|
|
|
|
expect(wrapper.get('.radio-text').classes()).toContain('text-black')
|
|
})
|
|
|
|
it('has checked:border-black on input', () => {
|
|
const wrapper = mountRadioButton({label: 'Option 1', value: 'a', modelValue: 'a'})
|
|
|
|
expect(wrapper.get('input').classes()).toContain('checked:border-black')
|
|
})
|
|
|
|
it('affiche l\'astérisque quand required est vrai', () => {
|
|
const wrapper = mountRadioButton({label: 'Champ', required: true})
|
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
|
})
|
|
|
|
it('n\'affiche pas l\'astérisque par défaut', () => {
|
|
const wrapper = mountRadioButton({label: 'Champ'})
|
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
|
})
|
|
|
|
it('updates label color when toggled without v-model (uncontrolled)', async () => {
|
|
const wrapper = mountRadioButton({label: 'Option 1', value: 'a'})
|
|
|
|
expect(wrapper.get('.radio-text').classes()).toContain('text-m-muted')
|
|
|
|
await wrapper.get('input').trigger('change')
|
|
|
|
expect(wrapper.get('.radio-text').classes()).toContain('text-black')
|
|
})
|
|
})
|
|
|
|
const makeGroupCtx = (over: Partial<{
|
|
selected: RadioValue; error: boolean; success: boolean
|
|
disabled: boolean; readonly: boolean; required: boolean
|
|
}> = {}) => {
|
|
const selected = ref<RadioValue>(over.selected ?? null)
|
|
const select = vi.fn((v: RadioValue) => { selected.value = v })
|
|
const ctx: RadioGroupContext = {
|
|
name: computed(() => 'grp'),
|
|
isSelected: (v) => selected.value === v,
|
|
select,
|
|
hasError: computed(() => !!over.error),
|
|
hasSuccess: computed(() => !!over.success),
|
|
disabled: computed(() => !!over.disabled),
|
|
readonly: computed(() => !!over.readonly),
|
|
required: computed(() => !!over.required),
|
|
describedBy: computed(() => 'grp-describedby'),
|
|
}
|
|
return {ctx, select, selected}
|
|
}
|
|
|
|
const mountInGroup = (props: RadioButtonProps, ctx: RadioGroupContext) =>
|
|
mount(RadioButtonForTest, {
|
|
props,
|
|
global: {provide: {[radioGroupContextKey as symbol]: ctx}},
|
|
})
|
|
|
|
describe('MalioRadioButton dans un groupe', () => {
|
|
it('hérite du name du groupe', () => {
|
|
const {ctx} = makeGroupCtx()
|
|
const wrapper = mountInGroup({value: 'a', label: 'A'}, ctx)
|
|
expect(wrapper.get('input').attributes('name')).toBe('grp')
|
|
})
|
|
|
|
it('coché selon isSelected du groupe', () => {
|
|
const {ctx} = makeGroupCtx({selected: 'a'})
|
|
const wrapper = mountInGroup({value: 'a', label: 'A'}, ctx)
|
|
expect((wrapper.get('input').element as HTMLInputElement).checked).toBe(true)
|
|
})
|
|
|
|
it('appelle ctx.select au change au lieu d\'émettre', async () => {
|
|
const {ctx, select} = makeGroupCtx()
|
|
const wrapper = mountInGroup({value: 'b', label: 'B'}, ctx)
|
|
await wrapper.get('input').trigger('change')
|
|
expect(select).toHaveBeenCalledWith('b')
|
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
|
})
|
|
|
|
it('reflète l\'erreur du groupe et ne rend aucun message propre', () => {
|
|
const {ctx} = makeGroupCtx({error: true})
|
|
const wrapper = mountInGroup({value: 'a', label: 'A', hint: 'ignoré'}, ctx)
|
|
expect(wrapper.get('.radio-control').classes()).toContain('is-error')
|
|
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
|
|
expect(wrapper.find('.radio-message').exists()).toBe(false)
|
|
expect(wrapper.get('input').attributes('aria-describedby')).toBe('grp-describedby')
|
|
})
|
|
|
|
it('hérite de disabled/required du groupe', () => {
|
|
const {ctx} = makeGroupCtx({disabled: true, required: true})
|
|
const wrapper = mountInGroup({value: 'a', label: 'A'}, ctx)
|
|
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
|
|
expect(wrapper.get('input').attributes('required')).toBeDefined()
|
|
})
|
|
})
|