import { mount } from '@vue/test-utils' import NirField from '../NirField.vue' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { useValidation } from '@/composables/validation/useValidation' describe('NirField.vue', () => { let wrapper: ReturnType keyValidation: ReturnType }>> // Tableau pour suivre tous les wrappers créés pendant les tests let activeWrappers: ReturnType[] = [] // Helper pour attendre que les opérations asynchrones soient terminées async function flushPromises() { return new Promise(resolve => setTimeout(resolve, 0)) } beforeEach(async () => { wrapper = mount(NirField, { props: { modelValue: undefined, required: true, showSuccessMessages: true, outlined: true, }, }) // Ajouter le wrapper à la liste pour le démontage activeWrappers.push(wrapper) // Attendre que le montage initial soit terminé await wrapper.vm.$nextTick() await flushPromises() }) afterEach(async () => { // Attendre que toutes les promesses soient résolues avant de démonter await flushPromises() // Démonter tous les composants pour éviter les fuites mémoire for (const wrapper of activeWrappers) { if (wrapper && typeof wrapper.unmount === 'function') { wrapper.unmount() // Attendre après chaque démontage pour permettre le nettoyage await flushPromises() } } activeWrappers = [] // Réinitialiser tous les mocks vi.resetAllMocks() // Attendre une dernière fois pour s'assurer que tout est nettoyé await flushPromises() }) it('renders correctly', async () => { // Attendre que tous les effets asynchrones soient terminés await wrapper.vm.$nextTick() await flushPromises() expect(wrapper.exists()).toBe(true) expect(wrapper.find('.number-field').exists()).toBe(true) expect(wrapper.find('.key-field').exists()).toBe(true) }) it('displays error message for invalid NIR length', async () => { await wrapper.find('.number-field input').trigger('focus') await wrapper.find('.number-field input').setValue('123') await wrapper.vm.$nextTick() await wrapper.find('.number-field input').trigger('blur') await wrapper.vm.$nextTick() await flushPromises() expect(wrapper.vm.numberValidation.errors.value[0]).toBe('Le numéro de sécurité sociale est invalide.') }) it('validates the NIR field successfully', async () => { await wrapper.find('.number-field input').trigger('focus') await wrapper.find('.number-field input').setValue('2940375120005') await wrapper.vm.$nextTick() await wrapper.find('.number-field input').trigger('blur') await wrapper.vm.$nextTick() await flushPromises() expect(wrapper.vm.numberValidation.successes.value).toContain('Le numéro de sécurité sociale est valide.') }) it('displays error message for invalid key length', async () => { await wrapper.find('.number-field input').setValue('2940375120005') await wrapper.vm.$nextTick() await wrapper.find('.key-field input').trigger('focus') await wrapper.find('.key-field input').setValue('1') await wrapper.vm.$nextTick() await wrapper.find('.key-field input').trigger('blur') await wrapper.vm.$nextTick() await flushPromises() expect(wrapper.vm.keyValidation.errors.value[0]).toBe('La clé de contrôle est invalide.') }) it('validates the key field successfully', async () => { await wrapper.find('.number-field input').setValue('2940375120005') await wrapper.vm.$nextTick() await wrapper.find('.key-field input').trigger('focus') await wrapper.find('.key-field input').setValue('91') await wrapper.vm.$nextTick() await wrapper.find('.key-field input').trigger('blur') await wrapper.vm.$nextTick() await flushPromises() expect(wrapper.vm.keyValidation.successes.value).toContain('La clé de contrôle est valide.') }) it('hides the key field when displayKey is false', async () => { await wrapper.setProps({ displayKey: false }) await wrapper.vm.$nextTick() await flushPromises() expect(wrapper.find('.key-field').exists()).toBe(false) }) it('calls validateOnSubmit and returns true if no errors', async () => { const testWrapper = mount(NirField, { props: { modelValue: undefined, required: false, outlined: true, }, }) // Ajouter le wrapper à la liste pour le démontage activeWrappers.push(testWrapper) const numberField = testWrapper.find('.number-field input') await numberField.setValue('2940375120005') await numberField.trigger('blur') await testWrapper.vm.$nextTick() await flushPromises() const isValid = await testWrapper.vm.validateOnSubmit() expect(isValid).toBe(true) }) it('applies custom key rules when provided', async () => { const customKeyRules = [{ type: 'custom', options: { validate: (value: string) => value === '91', message: 'Custom key validation failed.', successMessage: 'Custom key validation passed.', fieldIdentifier: 'clé', }, }] const customWrapper = mount(NirField, { props: { label: 'NIR Field with custom key validation', modelValue: undefined, customKeyRules, showSuccessMessages: true, outlined: true, }, }) activeWrappers.push(customWrapper) await customWrapper.vm.$nextTick() await flushPromises() const numberField = customWrapper.find('.number-field input') const keyField = customWrapper.find('.key-field input') await numberField.setValue('2940375120005') await keyField.trigger('focus') await keyField.setValue('91') await keyField.trigger('blur') await customWrapper.vm.$nextTick() await flushPromises() expect(customWrapper.vm.keyValidation.successes.value).toContain('Custom key validation passed.') }) it('emits update:modelValue with correct format', async () => { const numberField = wrapper.find('.number-field input') const keyField = wrapper.find('.key-field input') await numberField.setValue('2940375120005') await wrapper.vm.$nextTick() await flushPromises() await keyField.setValue('91') await wrapper.vm.$nextTick() await flushPromises() expect(wrapper.emitted('update:modelValue')?.slice(-1)[0]).toEqual(['294037512000591']) }) it('emits undefined when both fields are empty', async () => { const numberField = wrapper.find('.number-field input') const keyField = wrapper.find('.key-field input') await numberField.setValue('') await keyField.setValue('') await wrapper.vm.$nextTick() await flushPromises() expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([undefined]) }) it('splits modelValue correctly when provided', async () => { await wrapper.setProps({ modelValue: '294037512000591' }) await wrapper.vm.$nextTick() await flushPromises() const numberInput = wrapper.find('.number-field input').element as Element & { value: string } const keyInput = wrapper.find('.key-field input').element as Element & { value: string } expect(numberInput.value.replace(/\s/g, '')).toBe('2940375120005') expect(keyInput.value).toBe('91') }) it('applies numberMask correctly with vMaska directive', async () => { // On teste d'abord la saisie normale de chiffres const numberInput = wrapper.find('.number-field input') await numberInput.setValue('294037512000') await wrapper.vm.$nextTick() await flushPromises() // Vérifier que le masque applique correctement les espaces const inputElement = numberInput.element as Element & { value: string } expect(inputElement.value).toBe('2 94 03 75 120 00') // On ajoute un caractère '5' supplémentaire await numberInput.setValue('2940375120005') await wrapper.vm.$nextTick() await flushPromises() expect(inputElement.value).toBe('2 94 03 75 120 005') // Testons le cas où on utilise le caractère spécial 'A' dans le NIR // Plutôt que de tester la valeur formatée exacte (qui peut changer selon l'implémentation), // testons simplement que la valeur contient 'A' et que la valeur sans espaces est celle attendue // Test plus simple avec juste des chiffres pour vérifier que le masque accepte 13 chiffres await numberInput.setValue('1234567891234') await wrapper.vm.$nextTick() await flushPromises() // Vérifier que la valeur masquée contient bien 13 chiffres (sans les espaces) const maskedValue = inputElement.value.replace(/\s/g, '') expect(maskedValue.length).toBe(13) expect(maskedValue).toBe('1234567891234') }) it('applies keyMask correctly with vMaska directive', async () => { // On teste la saisie de la clé (seulement 2 chiffres autorisés) const keyInput = wrapper.find('.key-field input') await keyInput.setValue('9') await wrapper.vm.$nextTick() await flushPromises() // Vérifier qu'un seul chiffre est accepté const inputElement = keyInput.element as Element & { value: string } expect(inputElement.value).toBe('9') // On ajoute un deuxième chiffre await keyInput.setValue('91') await wrapper.vm.$nextTick() await flushPromises() expect(inputElement.value).toBe('91') // On essaie d'ajouter un troisième chiffre await keyInput.setValue('913') await wrapper.vm.$nextTick() await flushPromises() // Vérifier que le troisième chiffre n'est pas accepté expect(inputElement.value).toBe('91') // On essaie d'ajouter une lettre (non autorisée par le masque) await keyInput.setValue('9A') await wrapper.vm.$nextTick() await flushPromises() // Vérifier que la lettre n'est pas acceptée expect(inputElement.value).toBe('9') }) it('automatically focuses key field when number field is complete', async () => { // Spy sur la méthode focus de l'élément input const focusSpy = vi.spyOn(HTMLElement.prototype, 'focus') // On remplit complètement le champ NIR await wrapper.find('.number-field input').setValue('2940375120005') await wrapper.vm.$nextTick() await flushPromises() // Vérifier que le focus a été appelé au moins une fois // (la méthode focusField est appelée et met le focus sur le champ clé) expect(focusSpy).toHaveBeenCalled() // Restaurer le spy focusSpy.mockRestore() }) describe('Internal update flag protection', () => { it('prevents infinite loops between watch and emitValue', async () => { // Spy sur emit pour compter les appels const emitSpy = vi.spyOn(wrapper.vm, '$emit') // Définir une valeur initiale await wrapper.setProps({ modelValue: '294037512000591' }) await wrapper.vm.$nextTick() await flushPromises() // Réinitialiser le spy pour ne compter que les nouveaux appels emitSpy.mockClear() // Simuler une saisie utilisateur qui devrait déclencher emitValue const numberField = wrapper.find('.number-field input') await numberField.setValue('2940375120006') // Changer le dernier chiffre await wrapper.vm.$nextTick() await flushPromises() // Vérifier qu'il n'y a qu'un seul appel à emit (pas de boucle infinie) const updateModelValueCalls = emitSpy.mock.calls.filter(call => call[0] === 'update:modelValue') expect(updateModelValueCalls.length).toBeLessThanOrEqual(2) // Maximum 2 appels (un pour chaque champ) emitSpy.mockRestore() }) it('allows external modelValue changes to update internal fields', async () => { // Définir une valeur initiale await wrapper.setProps({ modelValue: '294037512000591' }) await wrapper.vm.$nextTick() await flushPromises() // Vérifier que les champs internes sont mis à jour const numberInput = wrapper.find('.number-field input').element as HTMLInputElement const keyInput = wrapper.find('.key-field input').element as HTMLInputElement expect(numberInput.value.replace(/\s/g, '')).toBe('2940375120005') expect(keyInput.value).toBe('91') // Changer la valeur externe await wrapper.setProps({ modelValue: '123456789012345' }) await wrapper.vm.$nextTick() await flushPromises() // Vérifier que les champs internes sont mis à jour correctement expect(numberInput.value.replace(/\s/g, '')).toBe('1234567890123') expect(keyInput.value).toBe('45') }) it('handles null/undefined modelValue without triggering loops', async () => { // Spy sur emit pour compter les appels const emitSpy = vi.spyOn(wrapper.vm, '$emit') // Définir une valeur initiale await wrapper.setProps({ modelValue: '294037512000591' }) await wrapper.vm.$nextTick() await flushPromises() // Réinitialiser le spy emitSpy.mockClear() // Changer vers null await wrapper.setProps({ modelValue: null }) await wrapper.vm.$nextTick() await flushPromises() // Vérifier que les champs sont vidés const numberInput = wrapper.find('.number-field input').element as HTMLInputElement const keyInput = wrapper.find('.key-field input').element as HTMLInputElement expect(numberInput.value).toBe('') expect(keyInput.value).toBe('') // Vérifier qu'il n'y a pas d'appels emit supplémentaires (pas de boucle) const updateModelValueCalls = emitSpy.mock.calls.filter(call => call[0] === 'update:modelValue') expect(updateModelValueCalls.length).toBe(0) // Aucun emit car c'est un changement externe emitSpy.mockRestore() }) }) describe('Configuration UI et UX', () => { it('applies disabled state to both inputs', async () => { await wrapper.setProps({ disabled: true }) await wrapper.vm.$nextTick() await flushPromises() const numberInput = wrapper.find('.number-field input') const keyInput = wrapper.find('.key-field input') expect(numberInput.attributes('disabled')).toBeDefined() expect(keyInput.attributes('disabled')).toBeDefined() }) it('applies readonly state to both inputs', async () => { await wrapper.setProps({ readonly: true }) await wrapper.vm.$nextTick() await flushPromises() const numberInput = wrapper.find('.number-field input') const keyInput = wrapper.find('.key-field input') expect(numberInput.attributes('readonly')).toBeDefined() expect(keyInput.attributes('readonly')).toBeDefined() }) it('respects showSuccessMessages prop', async () => { // Par défaut, showSuccessMessages = true await wrapper.setProps({ showSuccessMessages: true }) // On ne peut pas tester facilement le DOM de VMessages dans ce contexte jsdom avec Vuetify // On vérifie donc que la prop showSuccessMessages est bien transmise aux éléments enfants // internes si applicable, ou que le comportement conditionnel est correctement câblé. // La prop showSuccessMessages est utilisée dans v-show="numberValidation.hasSuccess.value && showSuccessMessages" // Pour tester que Vue applique bien la condition, on vérifie l'état de notre wrapper expect(wrapper.props('showSuccessMessages')).toBe(true) // Avec showSuccessMessages = false await wrapper.setProps({ showSuccessMessages: false }) await wrapper.vm.$nextTick() await flushPromises() expect(wrapper.props('showSuccessMessages')).toBe(false) // On vérifie également que l'attribut est passé aux sous-composants s'ils l'utilisent const textFields = wrapper.findAllComponents({ name: 'SyTextField' }) // SyTextField a showSuccessMessages false en dur dans NirField expect(textFields[0]?.props('showSuccessMessages')).toBe(false) }) it('respects disableErrorHandling prop', async () => { await wrapper.setProps({ disableErrorHandling: true }) const numberField = wrapper.find('.number-field input') await numberField.setValue('123') // Invalid NIR await numberField.trigger('blur') await wrapper.vm.$nextTick() await flushPromises() // La validation a lieu, mais disableErrorHandling est appliqué const numberTextField = wrapper.findComponent({ name: 'SyTextField' }) expect(numberTextField.props('disableErrorHandling')).toBe(true) }) it('renders tooltips correctly when provided', async () => { const nirTooltip = 'Tooltip NIR' const keyTooltip = 'Tooltip Clé' await wrapper.setProps({ nirTooltip, keyTooltip, }) await wrapper.vm.$nextTick() await flushPromises() // Les tooltips sont passés aux SyTextField en tant que props 'appendTooltip' (par défaut) const textFields = wrapper.findAllComponents({ name: 'SyTextField' }) expect(textFields[0]?.props('appendTooltip')).toBe(nirTooltip) expect(textFields[1]?.props('appendTooltip')).toBe(keyTooltip) }) it('renders asterisks correctly when displayAsterisk is true AND required is true', async () => { // L'astérisque n'est affiché que si required = true ET displayAsterisk = true await wrapper.setProps({ required: true, displayAsterisk: true, numberLabel: 'Numéro', keyLabel: 'Clé' }) await wrapper.vm.$nextTick() await flushPromises() // Dans NirField.vue, l'astérisque est ajouté directement à la string 'label' // transmise aux composants enfants const textFields = wrapper.findAllComponents({ name: 'SyTextField' }) expect(textFields[0]?.props('label')).toBe('Numéro *') expect(textFields[1]?.props('label')).toBe('Clé *') // Si on désactive l'astérisque await wrapper.setProps({ displayAsterisk: false }) await wrapper.vm.$nextTick() await flushPromises() expect(textFields[0]?.props('label')).toBe('Numéro') expect(textFields[1]?.props('label')).toBe('Clé') }) it('removes fieldset when withoutFieldset is true', async () => { // Par défaut, le fieldset est présent quand displayKey est true expect(wrapper.find('fieldset').exists()).toBe(true) await wrapper.setProps({ withoutFieldset: true }) await wrapper.vm.$nextTick() await flushPromises() // Le fieldset ne doit plus être présent expect(wrapper.find('fieldset').exists()).toBe(false) // Mais les champs doivent toujours être là expect(wrapper.find('.number-field').exists()).toBe(true) expect(wrapper.find('.key-field').exists()).toBe(true) }) }) describe('Cursor position preservation when displayKey=false', () => { let wrapperWithoutKey: ReturnType> beforeEach(async () => { wrapperWithoutKey = mount(NirField, { props: { modelValue: undefined, displayKey: false, required: false, outlined: true, }, }) activeWrappers.push(wrapperWithoutKey) await wrapperWithoutKey.vm.$nextTick() await flushPromises() }) it('does not add our custom keydown event listener when displayKey is false', async () => { // Spy spécifiquement sur notre fonction handleNumberKeydown const handleNumberKeydownSpy = vi.spyOn(HTMLElement.prototype, 'addEventListener') // Remonter le composant pour déclencher onMounted wrapperWithoutKey.unmount() activeWrappers.pop() const newWrapper = mount(NirField, { props: { displayKey: false, required: false, }, }) activeWrappers.push(newWrapper) await newWrapper.vm.$nextTick() await flushPromises() // Vérifier que notre logique onMounted n'a pas ajouté d'écouteur keydown // (d'autres composants peuvent en ajouter, mais pas notre logique spécifique) const ourKeydownCalls = handleNumberKeydownSpy.mock.calls.filter(call => call[0] === 'keydown' && call[1] && call[1].toString().includes('handleNumberKeydown'), ) expect(ourKeydownCalls).toHaveLength(0) handleNumberKeydownSpy.mockRestore() }) it('does not trigger focus when editing NIR without key field', async () => { // Spy sur la méthode focus const focusSpy = vi.spyOn(HTMLElement.prototype, 'focus') // Saisir un NIR complet const numberInput = wrapperWithoutKey.find('.number-field input') await numberInput.setValue('2940375120005') await wrapperWithoutKey.vm.$nextTick() await flushPromises() // Réinitialiser le spy pour ne compter que les appels suivants focusSpy.mockClear() // Modifier un chiffre au milieu (simuler l'édition) await numberInput.setValue('2940375120006') await wrapperWithoutKey.vm.$nextTick() await flushPromises() // Vérifier qu'aucun focus n'a été déclenché lors de l'édition expect(focusSpy).not.toHaveBeenCalled() focusSpy.mockRestore() }) it('watchers do not execute when displayKey is false', async () => { // Spy sur les méthodes internes pour vérifier qu'elles ne sont pas appelées const focusSpy = vi.spyOn(HTMLElement.prototype, 'focus') // Simuler une modification qui déclencherait normalement les watchers const numberInput = wrapperWithoutKey.find('.number-field input') // Saisir puis effacer pour déclencher les watchers await numberInput.setValue('123') await wrapperWithoutKey.vm.$nextTick() await numberInput.setValue('') await wrapperWithoutKey.vm.$nextTick() await flushPromises() // Vérifier qu'aucun focus automatique n'a été déclenché expect(focusSpy).not.toHaveBeenCalled() focusSpy.mockRestore() }) it('preserves cursor position during editing when displayKey is false', async () => { const numberInput = wrapperWithoutKey.find('.number-field input') const inputElement = numberInput.element as HTMLInputElement // Saisir un NIR complet await numberInput.setValue('2940375120005') await wrapperWithoutKey.vm.$nextTick() await flushPromises() // Simuler le positionnement du curseur au milieu (position 7) inputElement.setSelectionRange(7, 7) // Simuler une modification (suppression d'un caractère) const currentValue = inputElement.value const newValue = currentValue.slice(0, 7) + currentValue.slice(8) await numberInput.setValue(newValue.replace(/\s/g, '')) await wrapperWithoutKey.vm.$nextTick() await flushPromises() // Dans un comportement correct, le curseur ne devrait pas être forcé à la fin // On vérifie que la logique de focus automatique n'interfère pas expect(wrapperWithoutKey.find('.key-field').exists()).toBe(false) }) it('handleKeyInput does not trigger focus when displayKey is false', async () => { const focusSpy = vi.spyOn(HTMLElement.prototype, 'focus') // Déclencher handleKeyInput via une saisie const numberInput = wrapperWithoutKey.find('.number-field input') await numberInput.setValue('123') await numberInput.trigger('input') await wrapperWithoutKey.vm.$nextTick() await flushPromises() // Vérifier qu'aucun focus n'a été déclenché expect(focusSpy).not.toHaveBeenCalled() focusSpy.mockRestore() }) }) })