Files
malio-layer-ui/app/components/malio/input/InputEmail.test.ts
T
tristan cda0f994ca feat(ui) : prop reserveMessageSpace (défaut true) sur la famille input
Ajoute une prop booléenne reserveMessageSpace (défaut true) aux 10 composants
de la famille input. Par défaut, comportement inchangé (ligne message toujours
rendue avec min-h-[1rem]). À false, la ligne ne prend aucun espace en l'absence
de message, et s'affiche sans min-h quand un message est présent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:43:31 +02:00

319 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import InputEmail from './InputEmail.vue'
type InputEmailProps = {
id?: string
label?: string
name?: string
autocomplete?: string
modelValue?: string | null
inputClass?: string
labelClass?: string
groupClass?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
iconName?: string
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
lowercase?: boolean
reserveMessageSpace?: boolean
}
const InputEmailForTest = InputEmail as DefineComponent<InputEmailProps>
const mountComponent = (props: InputEmailProps = {}) =>
mount(InputEmailForTest, {
props,
global: {
stubs: {
IconifyIcon: {
template: '<span data-test="icon" v-bind="$attrs" />',
},
},
},
})
describe('MalioInputEmail', () => {
it('renders the initial input value', () => {
const wrapper = mountComponent({modelValue: 'user@example.com'})
expect(wrapper.get('input').element.value).toBe('user@example.com')
})
it('renders the label text', () => {
const wrapper = mountComponent({label: 'Adresse email'})
expect(wrapper.get('label').text()).toBe('Adresse email')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mountComponent({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 = mountComponent({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('has type email', () => {
const wrapper = mountComponent()
expect(wrapper.get('input').attributes('type')).toBe('email')
})
it('has inputmode email', () => {
const wrapper = mountComponent()
expect(wrapper.get('input').attributes('inputmode')).toBe('email')
})
it('renders the default email icon', () => {
const wrapper = mountComponent()
const iconComponent = wrapper.findComponent(IconifyIcon)
expect(iconComponent.props('icon')).toBe('mdi:email-outline')
})
it('allows overriding the icon', () => {
const wrapper = mountComponent({iconName: 'mdi:at'})
const iconComponent = wrapper.findComponent(IconifyIcon)
expect(iconComponent.props('icon')).toBe('mdi:at')
})
it('does not render icon when iconName is empty', () => {
const wrapper = mountComponent({iconName: ''})
expect(wrapper.find('[data-test="icon"]').exists()).toBe(false)
})
it('places icon on the right by default', () => {
const wrapper = mountComponent()
expect(wrapper.get('[data-test="icon"]').classes()).toContain('right-[10px]')
})
it('places icon on the left when iconPosition is left', () => {
const wrapper = mountComponent({iconPosition: 'left'})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('left-[10px]')
})
it('emits update:modelValue on input change', async () => {
const wrapper = mountComponent({modelValue: ''})
await wrapper.get('input').setValue('new@example.com')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['new@example.com'])
})
it('sets disabled styles when true', () => {
const wrapper = mountComponent({disabled: true})
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
expect(wrapper.get('input').classes()).toContain('cursor-not-allowed')
})
it('sets readonly when true', () => {
const wrapper = mountComponent({readonly: true})
expect(wrapper.get('input').attributes('readonly')).toBeDefined()
})
it('shows error message and styles', () => {
const wrapper = mountComponent({error: 'Email invalide'})
expect(wrapper.get('p.text-m-danger').text()).toBe('Email invalide')
expect(wrapper.get('input').classes()).toContain('border-m-danger')
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
})
it('shows error style on icon', () => {
const wrapper = mountComponent({error: 'Error'})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-danger')
})
it('shows success message and styles', () => {
const wrapper = mountComponent({success: 'Email valide'})
expect(wrapper.get('p.text-m-success').text()).toBe('Email valide')
expect(wrapper.get('input').classes()).toContain('border-m-success')
})
it('shows success style on icon', () => {
const wrapper = mountComponent({success: 'Success'})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-success')
})
it('shows default icon color when empty and unfocused', () => {
const wrapper = mountComponent()
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
})
it('shows primary icon color on focus', async () => {
const wrapper = mountComponent()
await wrapper.get('input').trigger('focus')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary')
})
it('shows black icon color when filled and unfocused', () => {
const wrapper = mountComponent({modelValue: 'user@example.com'})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('keeps primary icon color when filled and focused', async () => {
const wrapper = mountComponent({modelValue: 'user@example.com'})
await wrapper.get('input').trigger('focus')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary')
})
it('keeps default icon color when disabled, even if filled', () => {
const wrapper = mountComponent({modelValue: 'user@example.com', disabled: true})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
})
it('error overrides focus color on icon', async () => {
const wrapper = mountComponent({error: 'Email invalide'})
await wrapper.get('input').trigger('focus')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-danger')
})
it('shows hint message', () => {
const wrapper = mountComponent({hint: 'ex: prenom.nom@malio.fr'})
expect(wrapper.get('p.text-m-muted').text()).toBe('ex: prenom.nom@malio.fr')
})
it('links label to input via for/id', () => {
const wrapper = mountComponent({id: 'email-field', label: 'Email'})
expect(wrapper.get('input').attributes('id')).toBe('email-field')
expect(wrapper.get('label').attributes('for')).toBe('email-field')
})
it('generates an id when missing and reuses it on label', () => {
const wrapper = mountComponent({label: 'Email'})
const inputId = wrapper.get('input').attributes('id')
expect(inputId?.startsWith('malio-input-email-')).toBe(true)
expect(wrapper.get('label').attributes('for')).toBe(inputId)
})
it('aria-invalid is false when no error', () => {
const wrapper = mountComponent()
expect(wrapper.get('input').attributes('aria-invalid')).toBe('false')
})
it('uses autocomplete off by default', () => {
const wrapper = mountComponent()
expect(wrapper.get('input').attributes('autocomplete')).toBe('off')
})
it('allows overriding autocomplete', () => {
const wrapper = mountComponent({autocomplete: 'email'})
expect(wrapper.get('input').attributes('autocomplete')).toBe('email')
})
it('supprime tous les espaces saisis', async () => {
const wrapper = mountComponent()
await wrapper.get('input').setValue(' a b @ c.com ')
const emits = wrapper.emitted('update:modelValue')!
expect(emits[emits.length - 1]).toEqual(['ab@c.com'])
expect(wrapper.get('input').element.value).toBe('ab@c.com')
})
it('conserve la casse par défaut', async () => {
const wrapper = mountComponent()
await wrapper.get('input').setValue('User@Example.COM')
const emits = wrapper.emitted('update:modelValue')!
expect(emits[emits.length - 1]).toEqual(['User@Example.COM'])
})
it('met en minuscules quand lowercase est vrai', async () => {
const wrapper = mountComponent({lowercase: true})
await wrapper.get('input').setValue('User@Example.COM')
const emits = wrapper.emitted('update:modelValue')!
expect(emits[emits.length - 1]).toEqual(['user@example.com'])
})
it('émet la valeur sanitisée en mode contrôlé', async () => {
const wrapper = mountComponent({modelValue: ''})
await wrapper.get('input').setValue(' a b @ c.com ')
expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual(['ab@c.com'])
})
it('resynchronise le DOM en mode contrôlé même quand la valeur sanitisée égale déjà modelValue', async () => {
// L'utilisateur ajoute un espace en fin alors que la valeur nettoyée vaut déjà modelValue.
// Le parent ne « changera » pas modelValue → Vue ne re-patche pas le DOM ; l'écriture
// manuelle target.value = sanitized est donc indispensable pour retirer l'espace affiché.
const wrapper = mountComponent({modelValue: 'ab@c.com'})
const input = wrapper.get('input')
await input.setValue('ab@c.com ')
expect(input.element.value).toBe('ab@c.com')
})
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
const field = wrapper.get('input')
expect(field.classes()).toContain('border-black')
expect(field.classes()).not.toContain('border-m-muted')
expect(field.classes()).not.toContain('focus:border-m-primary')
expect(field.classes()).not.toContain('grow-height')
})
it('readonly vide : label gris, pas de bleu', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
expect(wrapper.get('label').classes()).not.toContain('peer-focus:text-m-primary')
expect(wrapper.get('label').classes()).toContain('text-m-muted')
})
it('readonly rempli : label noir et icône noire', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true, modelValue: 'user@example.com'})
expect(wrapper.get('label').classes()).toContain('text-black')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountComponent({label: 'Champ'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).toContain('min-h-[1rem]')
})
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
const wrapper = mountComponent({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountComponent({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).not.toContain('min-h-[1rem]')
})
})