Files
crewli/apps/app/src/components-v2/layout/__tests__/SidebarHeader.spec.ts
bert.hausmans 31e79f79c3 test: mobile drawer parity contract (close control, expanded header, height)
MOBILE-SHELL-PARITY. Adds 7 Vitest assertions for the three defects:
- AppSidebar: header pt is '!hidden' (default close-X suppressed), content pt
  is a full-height flex column (flex-col/h-full/min-h-0), showCloseIcon is not
  forced false, and WorkspaceSwitcher renders inside the drawer.
- SidebarHeader: the Icon stub now exposes data-icon; mobile brand-row control
  is an explicit close (aria-label 'Sluit menu', tabler-x), desktop stays the
  collapse chevron, and the header renders expanded on mobile even when
  sidebarCollapsed is true (logo parity).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 03:48:43 +02:00

293 lines
9.9 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.
/**
* 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: '<span class="icon-stub" :data-icon="name" />' },
}
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 <span>
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)
})
})