/** * SidebarHeader.spec.ts — unit tests for the non-trivial logic in SidebarHeader. * * Strategy: mount with @vue/test-utils + createPinia. Child components (Icon) * and @vueuse/core's useBreakpoints are stubbed so we test only: * 1. Collapse-button desktop path → shell.toggleSidebar() called. * 2. Collapse-button mobile path → shell.setMobileOpen(false) called. * 3. Collapsed state hides wordmark + pill; keep collapse button. * * The unit project (src/ __tests__ glob) runs in happy-dom with globals: true * so `describe/it/expect/vi` are available without explicit imports. */ import { createPinia, setActivePinia } from 'pinia' import { mount } from '@vue/test-utils' import { ref } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useShellUiStore } from '@/stores/useShellUiStore' import SidebarHeader from '@/components-v2/layout/SidebarHeader.vue' // --------------------------------------------------------------------------- // Mock @vueuse/core so we can control `isMobile` per test. // The component uses useBreakpoints(breakpointsTailwind).smaller('lg'). // Vitest hoists vi.mock() above all imports, so the mock is registered // before SidebarHeader's transitive @vueuse/core import resolves; the // factory only dereferences mockIsMobileRef inside .smaller(), invoked // at component-mount time (well after this const initialises). // --------------------------------------------------------------------------- const mockIsMobileRef = ref(false) vi.mock('@vueuse/core', () => ({ breakpointsTailwind: {}, useBreakpoints: () => ({ smaller: (_: string) => mockIsMobileRef, }), })) // --------------------------------------------------------------------------- // Stubs — keep tests fast and focused on logic, not child rendering // --------------------------------------------------------------------------- const globalStubs = { // Icon stub exposes `name` via data-icon so tests can assert which glyph // renders (e.g. the mobile close X vs the desktop collapse chevron). Icon: { props: ['name', 'size'], template: '' }, } function mountHeader() { return mount(SidebarHeader, { global: { plugins: [createPinia()], stubs: globalStubs, }, }) } describe('SidebarHeader', () => { beforeEach(() => { setActivePinia(createPinia()) // Reset mobile mock to desktop default before each test mockIsMobileRef.value = false }) // ------------------------------------------------------------------------- // Collapse button — desktop path // ------------------------------------------------------------------------- it('desktop: collapse button calls toggleSidebar', async () => { const wrapper = mountHeader() const shell = useShellUiStore() const toggleSpy = vi.spyOn(shell, 'toggleSidebar') mockIsMobileRef.value = false const btn = wrapper.find('button[aria-label]') await btn.trigger('click') expect(toggleSpy).toHaveBeenCalledOnce() }) it('desktop: collapse button does NOT call setMobileOpen', async () => { const wrapper = mountHeader() const shell = useShellUiStore() const setMobileOpenSpy = vi.spyOn(shell, 'setMobileOpen') mockIsMobileRef.value = false const btn = wrapper.find('button[aria-label]') await btn.trigger('click') expect(setMobileOpenSpy).not.toHaveBeenCalled() }) // ------------------------------------------------------------------------- // Collapse button — mobile path // ------------------------------------------------------------------------- it('mobile: collapse button calls setMobileOpen(false)', async () => { mockIsMobileRef.value = true const wrapper = mountHeader() const shell = useShellUiStore() shell.mobileOpen = true // simulate open drawer const setMobileOpenSpy = vi.spyOn(shell, 'setMobileOpen') const btn = wrapper.find('button[aria-label]') await btn.trigger('click') expect(setMobileOpenSpy).toHaveBeenCalledWith(false) }) it('mobile: collapse button does NOT call toggleSidebar', async () => { mockIsMobileRef.value = true const wrapper = mountHeader() const shell = useShellUiStore() const toggleSpy = vi.spyOn(shell, 'toggleSidebar') const btn = wrapper.find('button[aria-label]') await btn.trigger('click') expect(toggleSpy).not.toHaveBeenCalled() }) // ------------------------------------------------------------------------- // Collapsed state hides wordmark + pill // ------------------------------------------------------------------------- it('expanded: wordmark and pill are visible', () => { const wrapper = mountHeader() const shell = useShellUiStore() shell.sidebarCollapsed = false // The wordmark text appears directly in a expect(wrapper.text()).toContain('Crewli') expect(wrapper.text()).toContain('Beta') }) it('collapsed: wordmark and pill are hidden', async () => { const wrapper = mountHeader() const shell = useShellUiStore() shell.sidebarCollapsed = true // Give Vue a tick to re-render after the store mutation await wrapper.vm.$nextTick() expect(wrapper.text()).not.toContain('Crewli') expect(wrapper.text()).not.toContain('Beta') }) it('collapsed: collapse button is still rendered', async () => { const wrapper = mountHeader() const shell = useShellUiStore() shell.sidebarCollapsed = true await wrapper.vm.$nextTick() const btn = wrapper.find('button[aria-label]') expect(btn.exists()).toBe(true) }) it('collapsed: collapse button has aria-label "Expand sidebar"', async () => { const wrapper = mountHeader() const shell = useShellUiStore() shell.sidebarCollapsed = true await wrapper.vm.$nextTick() const btn = wrapper.find('button[aria-label]') expect(btn.attributes('aria-label')).toBe('Expand sidebar') }) it('expanded: collapse button has aria-label "Collapse sidebar"', () => { const wrapper = mountHeader() const shell = useShellUiStore() shell.sidebarCollapsed = false const btn = wrapper.find('button[aria-label]') expect(btn.attributes('aria-label')).toBe('Collapse sidebar') }) // ------------------------------------------------------------------------- // MOBILE-SHELL-PARITY — mobile close control (defect 2) + always-expanded // header (defect 1) // ------------------------------------------------------------------------- it('mobile: brand-row control is an explicit close (aria-label "Sluit menu", X icon)', () => { mockIsMobileRef.value = true const wrapper = mountHeader() const btn = wrapper.find('button[aria-label]') expect(btn.attributes('aria-label')).toBe('Sluit menu') expect(btn.find('.icon-stub').attributes('data-icon')).toBe('tabler-x') }) it('desktop: brand-row control stays the collapse chevron (aria-label "Collapse sidebar", chevron icon)', () => { mockIsMobileRef.value = false const wrapper = mountHeader() const btn = wrapper.find('button[aria-label]') expect(btn.attributes('aria-label')).toBe('Collapse sidebar') expect(btn.find('.icon-stub').attributes('data-icon')).toBe('tabler-chevron-left') }) it('mobile: header renders EXPANDED even when sidebarCollapsed is true (logo parity)', async () => { mockIsMobileRef.value = true const wrapper = mountHeader() const shell = useShellUiStore() shell.sidebarCollapsed = true await wrapper.vm.$nextTick() // Expanded brand row: wordmark present, exactly one control (the close X), // and NO collapsed-state expand-chevron row. expect(wrapper.find('.brand-name').exists()).toBe(true) expect(wrapper.text()).toContain('Crewli') const buttons = wrapper.findAll('button[aria-label]') expect(buttons).toHaveLength(1) expect(buttons[0].attributes('aria-label')).toBe('Sluit menu') }) // P6-styling-fix — the brand row is ALWAYS left-aligned with constant // px-4 padding (no justify-content switch on collapse). The logo's // horizontal position is therefore identical in expanded vs collapsed // states: 16px from the rail's left edge, which IS the visual centre // when the rail narrows to w-16 (64px = 32px logo + 2 × 16px padding). // This is what eliminates the previous slide-from-x=112 jump. it('collapsed: hides the Crewli wordmark, brand row keeps constant px-4 alignment', async () => { const wrapper = mountHeader() const shell = useShellUiStore() shell.sidebarCollapsed = true await wrapper.vm.$nextTick() expect(wrapper.find('.brand-name').exists()).toBe(false) expect(wrapper.text()).not.toContain('Crewli') // No justify-center / justify-between toggling — those classes // caused the prior collapse-time slide. The brand row should NOT // carry either when collapsed. const brandRow = wrapper.find('.brand-mark').element.parentElement expect(brandRow?.classList.contains('justify-center')).toBe(false) expect(brandRow?.classList.contains('justify-between')).toBe(false) expect(brandRow?.classList.contains('px-4')).toBe(true) }) it('collapsed: expand chevron is rendered in a row below the brand row (no overlap)', async () => { const wrapper = mountHeader() const shell = useShellUiStore() shell.sidebarCollapsed = true await wrapper.vm.$nextTick() // Two buttons would mean both expand AND collapse chevrons render; // we want exactly one — the expand chevron, in its own row. const buttons = wrapper.findAll('button[aria-label]') expect(buttons).toHaveLength(1) expect(buttons[0].attributes('aria-label')).toBe('Expand sidebar') // The expand button is NOT a child of the brand row — it lives in a // sibling row below, so the brand row's logo cannot overlap it. const expandBtn = buttons[0].element const brandMark = wrapper.find('.brand-mark').element expect(expandBtn.parentElement).not.toBe(brandMark.parentElement) }) })