import { mount, flushPromises } from '@vue/test-utils' import { describe, it, expect, vi, afterEach } from 'vitest' import { defineComponent } from 'vue' import { createRouter, createMemoryHistory } from 'vue-router' import HorizontalNavbar from '../HorizontalNavbar.vue' const SyTabsStub = defineComponent({ name: 'SyTabs', props: { items: { type: Array, default: () => [] }, modelValue: { type: Number, default: -1 }, confirmTabChange: { type: Boolean, default: false }, confirmationMessage: { type: String, default: undefined }, }, emits: ['update:modelValue', 'cancel-navigation', 'confirm-tab-change'], template: `
`, }) const stubs = { RouterLink: true, SyTabs: SyTabsStub, } const defaultItems = [ { label: 'Accueil', to: '/' }, { label: 'À propos', to: '/about' }, { label: 'Contact', to: '/contact' }, ] describe('HorizontalNavbar', () => { afterEach(() => { vi.restoreAllMocks() }) describe('rendu', () => { it('rend le composant avec la classe horizontal-menu', () => { const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items: defaultItems }, }) expect(wrapper.find('.horizontal-menu').exists()).toBe(true) }) it('rend autant d\'onglets que d\'items', () => { const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items: defaultItems }, }) expect(wrapper.findAll('.tab-btn')).toHaveLength(3) }) it('rend sans items sans crash', () => { const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items: [] }, }) expect(wrapper.find('.horizontal-menu').exists()).toBe(true) expect(wrapper.findAll('.tab-btn')).toHaveLength(0) }) it('applique la largeur personnalisée via le prop width', () => { const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items: defaultItems, width: '1200px' }, }) expect(wrapper.html()).toBeTruthy() }) }) describe('slots', () => { it('rend le slot navigation-bar-prepend', () => { const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items: defaultItems }, slots: { 'navigation-bar-prepend': 'Logo', 'navigation-bar-append': '', 'default': '', }, }) expect(wrapper.find('.prepend-content').exists()).toBe(true) expect(wrapper.find('.prepend-content').text()).toBe('Logo') }) it('rend le slot navigation-bar-append', () => { const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items: defaultItems }, slots: { 'navigation-bar-prepend': '', 'navigation-bar-append': 'Actions', 'default': '', }, }) expect(wrapper.find('.append-content').exists()).toBe(true) }) }) describe('handleTabChange', () => { it('met à jour activeTab quand un onglet est cliqué', async () => { const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items: defaultItems }, }) await wrapper.findAll('.tab-btn')[1]!.trigger('click') await flushPromises() const vm = wrapper.vm as unknown as { activeTab: number } expect(vm.activeTab).toBe(1) }) it('ne navigue pas via router.push si l\'item est désactivé', async () => { const mockPush = vi.fn() const items = [ { label: 'Accueil', to: '/' }, { label: 'Désactivé', to: '/disabled', disabled: true }, ] const wrapper = mount(HorizontalNavbar, { global: { stubs, mocks: { $router: { push: mockPush, currentRoute: { value: { path: '/' } } } }, }, props: { items }, }) await wrapper.findAll('.tab-btn')[1]!.trigger('click') await flushPromises() expect(mockPush).not.toHaveBeenCalled() }) it('émet cancel-navigation depuis SyTabs', async () => { const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items: defaultItems }, }) wrapper.findComponent(SyTabsStub).vm.$emit('cancel-navigation') await flushPromises() expect(wrapper.emitted('cancel-navigation')).toBeTruthy() }) }) describe('confirmTabChange', () => { it('transmet confirmTabChange=true à SyTabs', () => { const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items: defaultItems, confirmTabChange: true }, }) const tabs = wrapper.findComponent(SyTabsStub) expect(tabs.props('confirmTabChange')).toBe(true) }) it('transmet un confirmationMessage string à SyTabs', () => { const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items: defaultItems, confirmTabChange: true, confirmationMessage: 'Voulez-vous continuer ?', }, }) const tabs = wrapper.findComponent(SyTabsStub) expect(tabs.props('confirmationMessage')).toBe('Voulez-vous continuer ?') }) it('formattedConfirmationMessage est undefined si confirmationMessage est un booléen', () => { const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items: defaultItems, confirmTabChange: true, confirmationMessage: true, }, }) const tabs = wrapper.findComponent(SyTabsStub) expect(tabs.props('confirmationMessage')).toBeUndefined() }) it('émet confirm-tab-change avec le message et le callback', async () => { const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items: defaultItems, confirmTabChange: true }, }) const callback = vi.fn() wrapper.findComponent(SyTabsStub).vm.$emit('confirm-tab-change', 'Message', callback) await flushPromises() expect(wrapper.emitted('confirm-tab-change')).toBeTruthy() const [msg, cb] = wrapper.emitted('confirm-tab-change')![0] as [string, () => void] expect(msg).toBe('Message') expect(cb).toBe(callback) }) }) describe('resetTabSelection', () => { it('retourne activeTab=-1 et activeItemIndex=-1 si items est vide', () => { const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items: [] }, }) const vm = wrapper.vm as unknown as { resetTabSelection: () => { activeTab: number, activeItemIndex: number } } const result = vm.resetTabSelection() expect(result.activeTab).toBe(-1) expect(result.activeItemIndex).toBe(-1) }) it('est exposée et appelable depuis l\'extérieur', () => { const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items: defaultItems }, }) const vm = wrapper.vm as unknown as { resetTabSelection: () => unknown } expect(typeof vm.resetTabSelection).toBe('function') expect(() => vm.resetTabSelection()).not.toThrow() }) }) describe('isActive', () => { it('retourne false pour un item désactivé', () => { const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items: [{ label: 'Test', to: '/', disabled: true }], }, }) const vm = wrapper.vm as unknown as { isActive: (item: { label: string, to: string, disabled?: boolean }, index: number) => boolean } expect(vm.isActive({ label: 'Test', to: '/', disabled: true }, 0)).toBe(false) }) it('retourne false pour un item avec to (string) quand pathname ne correspond pas', () => { const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items: [{ label: 'À propos', to: '/about' }] }, }) const vm = wrapper.vm as unknown as { isActive: (item: { label: string, to: string }, index: number) => boolean } // jsdom pathname est '/', '/about' ne correspond pas expect(vm.isActive({ label: 'À propos', to: '/about' }, 0)).toBe(false) }) it('retourne true pour un item avec to (string) correspondant à window.location.pathname', () => { Object.defineProperty(window, 'location', { value: { ...window.location, pathname: '/about', href: 'http://localhost/about' }, writable: true, }) const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items: [{ label: 'À propos', to: '/about' }] }, }) const vm = wrapper.vm as unknown as { isActive: (item: { label: string, to: string }, index: number) => boolean } expect(vm.isActive({ label: 'À propos', to: '/about' }, 0)).toBe(true) Object.defineProperty(window, 'location', { value: { ...window.location, pathname: '/', href: 'http://localhost/' }, writable: true, }) }) it('retourne true pour un item avec to (string) sous-chemin de pathname', () => { Object.defineProperty(window, 'location', { value: { ...window.location, pathname: '/about/team', href: 'http://localhost/about/team' }, writable: true, }) const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items: [{ label: 'À propos', to: '/about' }] }, }) const vm = wrapper.vm as unknown as { isActive: (item: { label: string, to: string }, index: number) => boolean } expect(vm.isActive({ label: 'À propos', to: '/about' }, 0)).toBe(true) Object.defineProperty(window, 'location', { value: { ...window.location, pathname: '/', href: 'http://localhost/' }, writable: true, }) }) it('retourne true pour un item avec to (objet) correspondant à pathname', () => { Object.defineProperty(window, 'location', { value: { ...window.location, pathname: '/contact', href: 'http://localhost/contact' }, writable: true, }) const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items: [{ label: 'Contact', to: { path: '/contact' } }] }, }) const vm = wrapper.vm as unknown as { isActive: (item: { label: string, to: { path: string } }, index: number) => boolean } expect(vm.isActive({ label: 'Contact', to: { path: '/contact' } }, 0)).toBe(true) Object.defineProperty(window, 'location', { value: { ...window.location, pathname: '/', href: 'http://localhost/' }, writable: true, }) }) it('retourne false pour un item avec to (objet) non correspondant', () => { const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items: [{ label: 'Contact', to: { path: '/contact' } }] }, }) const vm = wrapper.vm as unknown as { isActive: (item: { label: string, to: { path: string } }, index: number) => boolean } // jsdom pathname est '/', '/contact' ne correspond pas expect(vm.isActive({ label: 'Contact', to: { path: '/contact' } }, 0)).toBe(false) }) }) describe('handleTabChange — navigation', () => { it('met à jour activeTab au clic sur un onglet (sans router réel)', async () => { const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items: defaultItems }, }) await wrapper.findAll('.tab-btn')[1]!.trigger('click') await flushPromises() const vm = wrapper.vm as unknown as { activeTab: number } expect(vm.activeTab).toBe(1) }) it('met à jour activeTab mais retourne tôt si confirmTabChange est true', async () => { const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items: defaultItems, confirmTabChange: true }, }) await wrapper.findAll('.tab-btn')[1]!.trigger('click') await flushPromises() const vm = wrapper.vm as unknown as { activeTab: number } expect(vm.activeTab).toBe(1) }) it('navigue via window.location.href si item a un href', async () => { const locationSpy = vi.spyOn(window, 'location', 'get').mockReturnValue({ ...window.location, href: '', } as Location) const hrefSetter = vi.fn() Object.defineProperty(window, 'location', { value: { ...window.location, set href(v: string) { hrefSetter(v) } }, writable: true, }) const items = [{ label: 'Externe', href: 'https://example.com' }] const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items }, }) await wrapper.findAll('.tab-btn')[0]!.trigger('click') await flushPromises() locationSpy.mockRestore() }) it('ne plante pas si l\'item cliqué n\'existe pas dans items', async () => { const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items: defaultItems }, }) const vm = wrapper.vm as unknown as { handleTabChange: (index: number) => Promise } await expect(vm.handleTabChange(999)).resolves.toBeUndefined() }) }) describe('resetTabSelection — avec items et route', () => { it('retourne les valeurs courantes de activeTab et activeItemIndex', () => { const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items: defaultItems }, }) const vm = wrapper.vm as unknown as { resetTabSelection: () => { activeTab: number, activeItemIndex: number } } const result = vm.resetTabSelection() expect(typeof result.activeTab).toBe('number') expect(typeof result.activeItemIndex).toBe('number') }) it('retourne -1 si aucun item ne correspond à la route', () => { const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items: defaultItems }, }) const vm = wrapper.vm as unknown as { resetTabSelection: () => { activeTab: number, activeItemIndex: number } } const result = vm.resetTabSelection() expect(result.activeTab).toBe(-1) }) it('met à jour activeTab après avoir appelé handleTabChange puis resetTabSelection', async () => { const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items: defaultItems }, }) await wrapper.findAll('.tab-btn')[1]!.trigger('click') await flushPromises() const vm = wrapper.vm as unknown as { activeTab: number resetTabSelection: () => { activeTab: number, activeItemIndex: number } } expect(vm.activeTab).toBe(1) }) }) describe('avec vue-router réel', () => { function makeRouter() { return createRouter({ history: createMemoryHistory(), routes: [ { path: '/', component: { template: '
' } }, { path: '/about', component: { template: '
' } }, { path: '/contact', component: { template: '
' } }, { path: '/about/team', component: { template: '
' } }, ], }) } it('isActive retourne true quand la route correspond à un item to (string)', async () => { const router = makeRouter() await router.push('/about') const wrapper = mount(HorizontalNavbar, { global: { stubs, plugins: [router] }, props: { items: defaultItems }, }) await flushPromises() const vm = wrapper.vm as unknown as { isActive: (item: { label: string, to: string }, index: number) => boolean } expect(vm.isActive({ label: 'À propos', to: '/about' }, 1)).toBe(true) }) it('resetTabSelection trouve l\'item actif avec une route correspondante', async () => { const router = makeRouter() await router.push('/about') const wrapper = mount(HorizontalNavbar, { global: { stubs, plugins: [router] }, props: { items: defaultItems }, }) await flushPromises() const vm = wrapper.vm as unknown as { resetTabSelection: () => { activeTab: number, activeItemIndex: number } } const result = vm.resetTabSelection() expect(result.activeTab).toBe(1) expect(result.activeItemIndex).toBe(1) }) it('watcher currentPath met à jour activeTab lors d\'un changement de route', async () => { const router = makeRouter() await router.push('/') const wrapper = mount(HorizontalNavbar, { global: { stubs, plugins: [router] }, props: { items: defaultItems }, }) await flushPromises() await router.push('/about') await flushPromises() const vm = wrapper.vm as unknown as { activeTab: number } expect(vm.activeTab).toBe(1) }) it('watcher currentPath remet activeTab à -1 si aucun item ne correspond', async () => { const router = makeRouter() await router.push('/about') const itemsWithoutContact = [ { label: 'Accueil', to: '/home' }, { label: 'À propos', to: '/about' }, ] const wrapper = mount(HorizontalNavbar, { global: { stubs, plugins: [router] }, props: { items: itemsWithoutContact }, }) await flushPromises() await router.push('/contact') await flushPromises() const vm = wrapper.vm as unknown as { activeTab: number } expect(vm.activeTab).toBe(-1) }) it('handleTabChange appelle router.push avec le to de l\'item', async () => { const router = makeRouter() const pushSpy = vi.spyOn(router, 'push') await router.push('/') const wrapper = mount(HorizontalNavbar, { global: { stubs, plugins: [router] }, props: { items: defaultItems }, }) await flushPromises() await wrapper.findAll('.tab-btn')[1]!.trigger('click') await flushPromises() expect(pushSpy).toHaveBeenCalledWith('/about') }) it('isActive retourne true pour un item avec to (string) sous-chemin', async () => { const router = makeRouter() await router.push('/about/team') const wrapper = mount(HorizontalNavbar, { global: { stubs, plugins: [router] }, props: { items: defaultItems }, }) await flushPromises() const vm = wrapper.vm as unknown as { isActive: (item: { label: string, to: string }, index: number) => boolean } expect(vm.isActive({ label: 'À propos', to: '/about' }, 1)).toBe(true) }) }) describe('tabItems computed', () => { it('convertit les items en TabItem avec les bonnes propriétés', () => { const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items: [ { label: 'Accueil', to: '/', disabled: false }, { label: 'Externe', href: 'https://example.com' }, ], }, }) const vm = wrapper.vm as unknown as { tabItems: Array<{ label: string, value: number, href?: string, to?: string, disabled?: boolean }> } expect(vm.tabItems).toHaveLength(2) expect(vm.tabItems[0]!.label).toBe('Accueil') expect(vm.tabItems[0]!.value).toBe(0) expect(vm.tabItems[1]!.href).toBe('https://example.com') }) it('retourne un tableau vide si items n\'est pas un tableau', () => { const wrapper = mount(HorizontalNavbar, { global: { stubs }, props: { items: [] }, }) const vm = wrapper.vm as unknown as { tabItems: unknown[] } expect(vm.tabItems).toHaveLength(0) }) }) })