import { describe, it, expect } from 'vitest' import { mount } from '@vue/test-utils' import { nextTick } from 'vue' import SyBtnMenu from '../SyBtnMenu.vue' describe('SyBtnMenu', () => { it('renders the component with default props', () => { const wrapper = mount(SyBtnMenu, { props: { primaryInfo: 'John Doe', }, attachTo: document.body, }) expect(wrapper.exists()).toBe(true) expect(wrapper.text()).toContain('John Doe') wrapper.unmount() }) it('shows secondaryInfo if provided', () => { const wrapper = mount(SyBtnMenu, { props: { primaryInfo: 'John Doe', secondaryInfo: 'Additional Info', }, attachTo: document.body, }) expect(wrapper.text()).toContain('Additional Info') wrapper.unmount() }) it('emits "update:modelValue" when an item is selected', async () => { const wrapper = mount(SyBtnMenu, { props: { modelValue: null, menuItems: [ { text: 'Option 1', value: 'option1' }, { text: 'Option 2', value: 'option2' }, ], primaryInfo: 'John Doe', }, attachTo: document.body, }) const button = wrapper.find('.sy-user-menu-btn') await button.trigger('click') expect(wrapper.vm.isOpen).toBe(true) const listItem = wrapper.findAllComponents({ name: 'VListItem' }).at(0) expect(listItem).toBeTruthy() await listItem?.trigger('click') expect(wrapper.emitted('update:modelValue')).toBeTruthy() expect(wrapper.emitted('update:modelValue')![0]).toEqual([ { text: 'Option 1', value: 'option1' }, ]) wrapper.unmount() }) it('toggles the menu open and closed', async () => { const wrapper = mount(SyBtnMenu, { props: { primaryInfo: 'John Doe', }, attachTo: document.body, }) const button = wrapper.find('.sy-user-menu-btn') expect(wrapper.vm.isOpen).toBe(false) await button.trigger('click') expect(wrapper.vm.isOpen).toBe(true) await button.trigger('click') expect(wrapper.vm.isOpen).toBe(false) wrapper.unmount() }) it('formats menu items correctly', () => { const wrapper = mount(SyBtnMenu, { props: { primaryInfo: 'John Doe', menuItems: ['Option 1', 'Option 2'], }, attachTo: document.body, }) const formattedItems = wrapper.vm.formattedItems expect(formattedItems).toEqual([ { text: 'Option 1', value: 'Option 1' }, { text: 'Option 2', value: 'Option 2' }, ]) wrapper.unmount() }) it('updates selectedItem when modelValue changes', async () => { const wrapper = mount(SyBtnMenu, { props: { modelValue: 'initial-value', primaryInfo: 'John Doe', }, attachTo: document.body, }) expect(wrapper.vm.selectedItem).toBe('initial-value') await wrapper.setProps({ modelValue: 'new-value' }) expect(wrapper.vm.selectedItem).toBe('new-value') wrapper.unmount() }) it('renders the primaryInfo in a span when isMobileVersion and hideIcon are true', async () => { const wrapper = mount(SyBtnMenu, { props: { primaryInfo: 'John Doe', isMobileView: true, hideIcon: true, }, attachTo: document.body, }) const span = wrapper.find('span.font-weight-bold.text-caption') expect(span.text()).toBe('John Doe') wrapper.unmount() }) it('does not render the span if isMobileVersion is false', () => { const wrapper = mount(SyBtnMenu, { props: { primaryInfo: 'John Doe', isMobileView: false, hideIcon: true, }, attachTo: document.body, }) const span = wrapper.find('span.font-weight-bold.text-sm-caption') expect(span.exists()).toBe(false) wrapper.unmount() }) it('does not render the span if hideIcon is false', () => { const wrapper = mount(SyBtnMenu, { props: { primaryInfo: 'John Doe', isMobileView: true, hideIcon: false, }, attachTo: document.body, }) const span = wrapper.find('span.font-weight-bold.text-sm-caption') expect(span.exists()).toBe(false) wrapper.unmount() }) it('renders a prepend icon when menu item provides icon', async () => { const wrapper = mount(SyBtnMenu, { props: { primaryInfo: 'John Doe', menuItems: [ { text: 'Administration', value: 'admin', icon: 'mdi-account' }, ], }, attachTo: document.body, }) await wrapper.find('.sy-user-menu-btn').trigger('click') await nextTick() const prependIcon = document.body.querySelector('.v-list-item__prepend .v-icon') as HTMLElement | null expect(prependIcon).not.toBeNull() expect(prependIcon?.classList.contains('mdi-account')).toBe(true) wrapper.unmount() }) it('does not render a prepend icon when menu item has no icon', async () => { const wrapper = mount(SyBtnMenu, { props: { primaryInfo: 'John Doe', menuItems: [ { text: 'Administration', value: 'admin' }, ], }, attachTo: document.body, }) await wrapper.find('.sy-user-menu-btn').trigger('click') const prependIcon = wrapper.find('.v-list-item__prepend .v-icon') expect(prependIcon.exists()).toBe(false) wrapper.unmount() }) // showIdentityInList : en mode icône seule, l'identité (primaryInfo/secondaryInfo) // est masquée dans l'activateur ; elle doit alors réapparaître en tête du menu. // Le menu (VMenu) est téléporté hors de l'arbre DOM du wrapper : on interroge // document.body (cf. tests existants sur .v-list-item__prepend plus haut). describe('identity in list', () => { it('shows primaryInfo and secondaryInfo in the menu when iconOnly and showIdentityInList are true', async () => { const wrapper = mount(SyBtnMenu, { props: { primaryInfo: 'Jean Dupont', secondaryInfo: 'Administrateur', iconOnly: true, showIdentityInList: true, }, attachTo: document.body, }) await wrapper.find('.sy-user-menu-btn').trigger('click') const identity = document.body.querySelector('.sy-user-menu-identity') expect(identity).not.toBeNull() expect(identity?.textContent).toContain('Jean Dupont') expect(identity?.textContent).toContain('Administrateur') wrapper.unmount() }) it('keeps the icon-only activator circular', () => { const wrapper = mount(SyBtnMenu, { props: { primaryInfo: 'Jean Dupont', iconOnly: true }, attachTo: document.body, }) expect(wrapper.get('.sy-user-menu-btn').classes()).toContain('rounded-circle') wrapper.unmount() }) it('does not force the circle on the full-width activator', () => { const wrapper = mount(SyBtnMenu, { props: { primaryInfo: 'Jean Dupont' }, attachTo: document.body, }) expect(wrapper.get('.sy-user-menu-btn').classes()).not.toContain('rounded-circle') wrapper.unmount() }) // L'état « menu ouvert » de l'activateur est stylé via `[aria-expanded="true"]` : si VMenu // cessait de poser cet attribut, le fond disparaîtrait sans que rien n'échoue. it('marks the activator as expanded while the menu is open', async () => { const wrapper = mount(SyBtnMenu, { props: { primaryInfo: 'Jean Dupont', menuItems: ['Option 1'] }, attachTo: document.body, }) const activator = wrapper.get('.sy-user-menu-btn') expect(activator.attributes('aria-expanded')).toBe('false') await activator.trigger('click') expect(activator.attributes('aria-expanded')).toBe('true') wrapper.unmount() }) it('exposes aria-controls only once the menu it references exists', async () => { const wrapper = mount(SyBtnMenu, { props: { primaryInfo: 'Jean Dupont', menuItems: ['Option 1'] }, attachTo: document.body, }) const activator = wrapper.get('.sy-user-menu-btn') expect(activator.attributes('aria-controls')).toBeUndefined() expect(activator.attributes('aria-owns')).toBeUndefined() await activator.trigger('click') const controls = activator.attributes('aria-controls') expect(controls).toBeTruthy() expect(document.getElementById(controls!)).not.toBeNull() wrapper.unmount() }) it('does not show the identity block when showIdentityInList is false (default)', async () => { const wrapper = mount(SyBtnMenu, { props: { primaryInfo: 'Jean Dupont', secondaryInfo: 'Administrateur', iconOnly: true, hideLogoutBtn: true, }, attachTo: document.body, }) await wrapper.find('.sy-user-menu-btn').trigger('click') expect(document.body.querySelector('.sy-user-menu-identity')).toBeNull() wrapper.unmount() }) it('does not show the identity block when iconOnly is false, even if showIdentityInList is true', async () => { const wrapper = mount(SyBtnMenu, { props: { primaryInfo: 'Jean Dupont', showIdentityInList: true, }, attachTo: document.body, }) await wrapper.find('.sy-user-menu-btn').trigger('click') expect(document.body.querySelector('.sy-user-menu-identity')).toBeNull() wrapper.unmount() }) it('enables the menu (not disabled) when only the identity block would be shown', async () => { const wrapper = mount(SyBtnMenu, { props: { primaryInfo: 'Jean Dupont', iconOnly: true, showIdentityInList: true, hideLogoutBtn: true, }, attachTo: document.body, }) await wrapper.find('.sy-user-menu-btn').trigger('click') expect(document.body.querySelector('.sy-user-menu-identity')).not.toBeNull() wrapper.unmount() }) // Contrat ARIA de l'encart. Il est visible : il doit rester restituable — masquer un contenu // affiché contrevient au critère RGAA 10.8, et c'est pour ça que l'identité a été retirée du // nom accessible de l'activateur. `role="presentation"` retire le `
  • ` de l'arbre sans // masquer son contenu, ce qui garde le `role="menu"` conforme (seuls `menuitem`, `group` et // `separator` y sont admis) tout en laissant l'identité lisible. it('keeps the identity block restituable inside the menu', async () => { const wrapper = mount(SyBtnMenu, { props: { primaryInfo: 'Jean Dupont', secondaryInfo: 'Administrateur', iconOnly: true, showIdentityInList: true, }, attachTo: document.body, }) await wrapper.find('.sy-user-menu-btn').trigger('click') const identity = document.body.querySelector('.sy-user-menu-identity')! expect(identity.getAttribute('aria-hidden')).toBeNull() expect(identity.getAttribute('role')).toBe('presentation') // Dans la liste (maquette), mais pas exposé comme une option du menu. expect(identity.closest('[role="menu"]')).not.toBeNull() expect(identity.matches('[role="menuitem"]')).toBe(false) wrapper.unmount() }) // Le slot remplit l'encart, il ne le remplace pas : le composant garde la main sur le `
  • ` // et son `role="presentation"`, sans quoi un contenu personnalisé casserait la structure du // `role="menu"`. it('lets the header-list-item slot fill the identity block', async () => { const wrapper = mount(SyBtnMenu, { props: { primaryInfo: 'Jean Dupont', iconOnly: true, showIdentityInList: true, }, slots: { 'header-list-item': 'Custom', }, attachTo: document.body, }) await wrapper.find('.sy-user-menu-btn').trigger('click') const identity = document.body.querySelector('.sy-user-menu-identity')! expect(identity.querySelector('.custom-identity')).not.toBeNull() expect(identity.textContent).not.toContain('Jean Dupont') expect(identity.getAttribute('role')).toBe('presentation') wrapper.unmount() }) it('does not show the identity block when showIdentityInList is false (default)', async () => { const wrapper = mount(SyBtnMenu, { props: { primaryInfo: 'Jean Dupont', secondaryInfo: 'Administrateur', iconOnly: true, hideLogoutBtn: true, }, attachTo: document.body, }) await wrapper.find('.sy-user-menu-btn').trigger('click') expect(document.body.querySelector('.sy-user-menu-identity')).toBeNull() wrapper.unmount() }) it('does not show the identity block when iconOnly is false, even if showIdentityInList is true', async () => { const wrapper = mount(SyBtnMenu, { props: { primaryInfo: 'Jean Dupont', showIdentityInList: true, }, attachTo: document.body, }) await wrapper.find('.sy-user-menu-btn').trigger('click') expect(document.body.querySelector('.sy-user-menu-identity')).toBeNull() wrapper.unmount() }) it('enables the menu (not disabled) when only the identity block would be shown', async () => { const wrapper = mount(SyBtnMenu, { props: { primaryInfo: 'Jean Dupont', iconOnly: true, showIdentityInList: true, hideLogoutBtn: true, }, attachTo: document.body, }) await wrapper.find('.sy-user-menu-btn').trigger('click') expect(document.body.querySelector('.sy-user-menu-identity')).not.toBeNull() wrapper.unmount() }) // Contrat ARIA de l'encart. Il est visible : il doit rester restituable — masquer un contenu // affiché contrevient au critère RGAA 10.8, et c'est pour ça que l'identité a été retirée du // nom accessible de l'activateur. `role="presentation"` retire le `
  • ` de l'arbre sans // masquer son contenu, ce qui garde le `role="menu"` conforme (seuls `menuitem`, `group` et // `separator` y sont admis) tout en laissant l'identité lisible. it('keeps the identity block restituable inside the menu', async () => { const wrapper = mount(SyBtnMenu, { props: { primaryInfo: 'Jean Dupont', secondaryInfo: 'Administrateur', iconOnly: true, showIdentityInList: true, }, attachTo: document.body, }) await wrapper.find('.sy-user-menu-btn').trigger('click') const identity = document.body.querySelector('.sy-user-menu-identity')! expect(identity.getAttribute('aria-hidden')).toBeNull() expect(identity.getAttribute('role')).toBe('presentation') // Dans la liste (maquette), mais pas exposé comme une option du menu. expect(identity.closest('[role="menu"]')).not.toBeNull() expect(identity.matches('[role="menuitem"]')).toBe(false) wrapper.unmount() }) }) // Le ring de focus est géré globalement (_btns.scss) sur le bouton natif ; // jsdom ne calcule pas :focus-visible, on vérifie donc les prérequis structurels. describe('focus', () => { it('renders a native