feat(gui-v2): decompose AppSidebar into SidebarHeader + AppSidebar

Ports crewli-starter's monolithic AppSidebar.vue into two typed production
components: SidebarHeader (the .brand block) and AppSidebar (composing
SidebarHeader + SidebarNav + WorkspaceSwitcher). AppSidebar renders a
permanent <aside> on desktop (lg+) and a PrimeVue Drawer on mobile, both
wired to useShellUiStore for collapse/mobile state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 20:29:18 +02:00
parent d479d35881
commit f0f9cb7e36
4 changed files with 579 additions and 0 deletions

View File

@@ -0,0 +1,195 @@
/**
* 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 useMediaQuery 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'
// ---------------------------------------------------------------------------
// Mock @vueuse/core so we can control `isMobile` per test
// ---------------------------------------------------------------------------
// We expose a reactive ref that individual tests can flip.
const mockIsMobileRef = ref(false)
vi.mock('@vueuse/core', () => ({
useMediaQuery: () => mockIsMobileRef,
}))
// ---------------------------------------------------------------------------
// Import component AFTER mock is set up
// ---------------------------------------------------------------------------
// eslint-disable-next-line import/first -- intentional: mock must be declared first
import SidebarHeader from '@/components-v2/layout/SidebarHeader.vue'
// ---------------------------------------------------------------------------
// Stubs — keep tests fast and focused on logic, not child rendering
// ---------------------------------------------------------------------------
const globalStubs = {
// Icon renders nothing; we just need the collapse button to be present
Icon: { template: '<span class="icon-stub" />' },
}
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')
})
})