Files
crewli/apps/app/src/components-v2/layout/__tests__/AppSidebar.spec.ts
bert.hausmans aa4b651870 refactor(gui-v2): imports-first in shell specs, drop eslint-disable
Vitest hoists vi.mock()/vi.hoisted() above all imports, so the
component import can sit with the other imports (import/first satisfied)
without the eslint-disable-next-line directives — the mock factories
only deref their refs at mount time. Honors the no-eslint-disable rule.
28/28 affected specs green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 13:29:58 +02:00

259 lines
8.3 KiB
TypeScript

/**
* AppSidebar.spec.ts — unit tests for AppSidebar composition and mobile wiring.
*
* Strategy: mount with @vue/test-utils stubs for all heavy children (SidebarHeader,
* SidebarNav, WorkspaceSwitcher, Drawer) so we test only:
* 1. Renders the 3 child components (SidebarHeader, SidebarNav, WorkspaceSwitcher).
* 2. Passes `groups` prop to SidebarNav.
* 3. Mobile Drawer v-model:visible wires to shell.mobileOpen (get path).
* 4. Drawer close (v-model:visible = false) calls shell.setMobileOpen(false).
* 5. Drawer is NOT rendered when isMobile=false (desktop); IS rendered when isMobile=true.
* 6. Desktop <aside> applies correct width class based on sidebarCollapsed.
*
* Stubs: Drawer is stubbed with a simple slot passthrough so we can inspect
* whether its `visible` prop is correctly bound to the store.
*
* @vueuse/core is mocked so we can control isMobile per test context.
*/
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 AppSidebar from '@/components-v2/layout/AppSidebar.vue'
import type { V2NavGroup } from '@/types/v2/nav'
// ---------------------------------------------------------------------------
// Mock @vueuse/core so we can control `isMobile` per test.
// AppSidebar uses useBreakpoints(breakpointsTailwind).smaller('lg').
// Vitest hoists vi.mock() above all imports, so the mock is registered
// before AppSidebar'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,
}),
}))
// ---------------------------------------------------------------------------
// A minimal nav group fixture
// ---------------------------------------------------------------------------
const testGroups: V2NavGroup[] = [
{
label: 'Main',
items: [
{ id: 'dashboard', label: 'Dashboard', icon: 'tabler-home', to: { name: 'dashboard' } },
],
},
]
// ---------------------------------------------------------------------------
// Stubs
// ---------------------------------------------------------------------------
/**
* DrawerStub exposes the same `visible` prop as PrimeVue Drawer and renders
* its default slot so child components are mounted. It also emits
* `update:visible` when the close method would fire, which mirrors the
* v-model:visible contract.
*/
const DrawerStub = {
name: 'Drawer',
props: ['visible', 'position', 'pt'],
emits: ['update:visible'],
template: '<div class="drawer-stub" :data-visible="visible"><slot /></div>',
}
const globalStubs = {
Drawer: DrawerStub,
SidebarHeader: { name: 'SidebarHeader', template: '<div class="sidebar-header-stub" />' },
SidebarNav: {
name: 'SidebarNav',
props: ['groups', 'collapsed'],
template: '<div class="sidebar-nav-stub" :data-collapsed="collapsed" :data-groups-count="groups.length" />',
},
WorkspaceSwitcher: {
name: 'WorkspaceSwitcher',
props: ['collapsed'],
template: '<div class="workspace-switcher-stub" />',
},
}
function mountSidebar(groups: V2NavGroup[] = testGroups) {
return mount(AppSidebar, {
props: { groups },
global: {
plugins: [createPinia()],
stubs: globalStubs,
},
})
}
describe('AppSidebar', () => {
beforeEach(() => {
setActivePinia(createPinia())
// Default to desktop (isMobile=false) so most tests exercise the stable path
mockIsMobileRef.value = false
})
// -------------------------------------------------------------------------
// Child composition
// -------------------------------------------------------------------------
it('renders SidebarHeader', () => {
const wrapper = mountSidebar()
expect(wrapper.find('.sidebar-header-stub').exists()).toBe(true)
})
it('renders SidebarNav', () => {
const wrapper = mountSidebar()
expect(wrapper.find('.sidebar-nav-stub').exists()).toBe(true)
})
it('renders WorkspaceSwitcher', () => {
const wrapper = mountSidebar()
expect(wrapper.find('.workspace-switcher-stub').exists()).toBe(true)
})
it('passes groups prop to SidebarNav', () => {
const wrapper = mountSidebar()
const nav = wrapper.find('.sidebar-nav-stub')
// Our stub renders data-groups-count from the groups prop
expect(nav.attributes('data-groups-count')).toBe(String(testGroups.length))
})
// -------------------------------------------------------------------------
// Drawer v-if: mount/unmount based on isMobile
// -------------------------------------------------------------------------
it('desktop (isMobile=false): Drawer is NOT rendered', () => {
mockIsMobileRef.value = false
const wrapper = mountSidebar()
// v-if="isMobile" means the Drawer stub must be absent on desktop
expect(wrapper.find('.drawer-stub').exists()).toBe(false)
})
it('mobile (isMobile=true): Drawer IS rendered', async () => {
mockIsMobileRef.value = true
const wrapper = mountSidebar()
await wrapper.vm.$nextTick()
expect(wrapper.find('.drawer-stub').exists()).toBe(true)
})
// -------------------------------------------------------------------------
// Desktop <aside> width class based on sidebarCollapsed
// -------------------------------------------------------------------------
it('desktop: aside has w-64 class when sidebar is expanded', async () => {
mockIsMobileRef.value = false
const wrapper = mountSidebar()
const shell = useShellUiStore()
shell.sidebarCollapsed = false
await wrapper.vm.$nextTick()
expect(wrapper.find('aside').classes()).toContain('w-64')
expect(wrapper.find('aside').classes()).not.toContain('w-16')
})
it('desktop: aside has w-16 class when sidebar is collapsed', async () => {
mockIsMobileRef.value = false
const wrapper = mountSidebar()
const shell = useShellUiStore()
shell.sidebarCollapsed = true
await wrapper.vm.$nextTick()
expect(wrapper.find('aside').classes()).toContain('w-16')
expect(wrapper.find('aside').classes()).not.toContain('w-64')
})
// -------------------------------------------------------------------------
// Mobile Drawer wiring (only exercised when isMobile=true)
// -------------------------------------------------------------------------
it('mobile: Drawer visible is true when shell.mobileOpen is true', async () => {
mockIsMobileRef.value = true
const wrapper = mountSidebar()
const shell = useShellUiStore()
shell.mobileOpen = true
await wrapper.vm.$nextTick()
// The Drawer stub renders data-visible from its visible prop
const drawer = wrapper.find('.drawer-stub')
expect(drawer.attributes('data-visible')).toBe('true')
})
it('mobile: Drawer visible is false when shell.mobileOpen is false', async () => {
mockIsMobileRef.value = true
const wrapper = mountSidebar()
const shell = useShellUiStore()
shell.mobileOpen = false
await wrapper.vm.$nextTick()
const drawer = wrapper.find('.drawer-stub')
expect(drawer.attributes('data-visible')).toBe('false')
})
it('mobile: emitting update:visible false from Drawer calls shell.setMobileOpen(false)', async () => {
mockIsMobileRef.value = true
const wrapper = mountSidebar()
const shell = useShellUiStore()
shell.mobileOpen = true
const setMobileOpenSpy = vi.spyOn(shell, 'setMobileOpen')
// Simulate the Drawer closing (v-model:visible setter)
await wrapper.findComponent(DrawerStub).vm.$emit('update:visible', false)
expect(setMobileOpenSpy).toHaveBeenCalledWith(false)
})
it('mobile: emitting update:visible true from Drawer calls shell.setMobileOpen(true)', async () => {
mockIsMobileRef.value = true
const wrapper = mountSidebar()
const shell = useShellUiStore()
shell.mobileOpen = false
const setMobileOpenSpy = vi.spyOn(shell, 'setMobileOpen')
await wrapper.findComponent(DrawerStub).vm.$emit('update:visible', true)
expect(setMobileOpenSpy).toHaveBeenCalledWith(true)
})
})