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>
293 lines
9.9 KiB
TypeScript
293 lines
9.9 KiB
TypeScript
/**
|
||
* 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)
|
||
})
|
||
})
|