Files
crewli/apps/app/src/components-v2/layout/__tests__/AppTopbar.spec.ts
bert.hausmans 7489301195 feat(gui-v2): port AppTopbar + useBreadcrumb to TypeScript
- useBreadcrumb composable: pure toBreadcrumbItems() helper + thin
  useRoute() wrapper; route-driven, no prop coupling
- AppTopbar: hamburger→setMobileOpen, theme/density toggles→shell store,
  PrimeVue Breadcrumb/OverlayBadge/Popover/Avatar/Menu; replaces all
  manual document.mousedown listeners with PrimeVue built-in dismissal;
  notifications stubbed (useNotificationStore is a toast queue, not a
  feed — TODO TECH-WS-GUI-REDESIGN); sign-out→authStore.logout()
- Unit tests: 10 breadcrumb + 6 AppTopbar assertions (16 total, all pass)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 20:51:48 +02:00

225 lines
7.3 KiB
TypeScript

/**
* AppTopbar.spec.ts
*
* Strategy: mount with createPinia(); stub all PrimeVue components so we
* test only the wiring of store actions to user interactions.
*
* Assertions:
* 1. Hamburger click → shell.setMobileOpen(true)
* 2. Theme toggle click → shell.setTheme with flipped value (light→dark, dark→light)
* 3. Density toggle click → shell.setDensity with flipped value
* 4. User-menu Sign out command → authStore.logout called
*
* useBreadcrumb() calls useRoute() internally. We provide a minimal
* vue-router mock via vi.mock so the composable has a route to call.
*/
import { createPinia, setActivePinia } from 'pinia'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useShellUiStore } from '@/stores/useShellUiStore'
import { useAuthStore } from '@/stores/useAuthStore'
// ---------------------------------------------------------------------------
// Mock vue-router (useBreadcrumb calls useRoute internally)
// ---------------------------------------------------------------------------
vi.mock('vue-router', () => ({
useRoute: () => ({
matched: [],
}),
useRouter: () => ({}),
}))
// ---------------------------------------------------------------------------
// Import component AFTER mocks
// ---------------------------------------------------------------------------
// eslint-disable-next-line import/first -- intentional: mocks must precede import
import AppTopbar from '@/components-v2/layout/AppTopbar.vue'
// ---------------------------------------------------------------------------
// Stubs for PrimeVue components
// ---------------------------------------------------------------------------
const globalStubs = {
Breadcrumb: { name: 'Breadcrumb', props: ['model'], template: '<nav class="breadcrumb-stub" />' },
InputText: { name: 'InputText', template: '<input class="input-text-stub" />' },
OverlayBadge: { name: 'OverlayBadge', props: ['value', 'severity'], template: '<div class="overlay-badge-stub"><slot /></div>' },
Popover: {
name: 'Popover',
template: '<div class="popover-stub"><slot /></div>',
methods: { toggle: vi.fn(), hide: vi.fn() },
},
Avatar: {
name: 'Avatar',
props: ['label', 'shape', 'pt'],
template: '<div class="avatar-stub" @click="$emit(\'click\', $event)">{{ label }}</div>',
emits: ['click'],
},
Menu: {
name: 'Menu',
props: ['model', 'popup'],
template: '<div class="menu-stub"><slot name="start" /></div>',
methods: { toggle: vi.fn(), hide: vi.fn() },
},
Icon: { name: 'Icon', props: ['name', 'size'], template: '<span class="icon-stub" :data-name="name" />' },
}
// ---------------------------------------------------------------------------
// Mount helper
// ---------------------------------------------------------------------------
function mountTopbar() {
return mount(AppTopbar, {
global: {
plugins: [createPinia()],
stubs: globalStubs,
},
})
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('AppTopbar', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
// -------------------------------------------------------------------------
// Hamburger → setMobileOpen(true)
// -------------------------------------------------------------------------
it('hamburger button click calls shell.setMobileOpen(true)', async () => {
const wrapper = mountTopbar()
const shell = useShellUiStore()
const spy = vi.spyOn(shell, 'setMobileOpen')
// The hamburger is the first button (aria-label="Open menu")
const hamburger = wrapper.find('button[aria-label="Open menu"]')
expect(hamburger.exists()).toBe(true)
await hamburger.trigger('click')
expect(spy).toHaveBeenCalledWith(true)
})
// -------------------------------------------------------------------------
// Theme toggle → setTheme with flipped value
// -------------------------------------------------------------------------
it('theme toggle flips light → dark', async () => {
const wrapper = mountTopbar()
const shell = useShellUiStore()
shell.theme = 'light'
const spy = vi.spyOn(shell, 'setTheme')
const themeBtn = wrapper.find('button[aria-label="Switch to dark mode"]')
expect(themeBtn.exists()).toBe(true)
await themeBtn.trigger('click')
expect(spy).toHaveBeenCalledWith('dark')
})
it('theme toggle flips dark → light', async () => {
const wrapper = mountTopbar()
const shell = useShellUiStore()
shell.theme = 'dark'
const spy = vi.spyOn(shell, 'setTheme')
// After setting dark we need nextTick so the aria-label computed updates
await wrapper.vm.$nextTick()
const themeBtn = wrapper.find('button[aria-label="Switch to light mode"]')
expect(themeBtn.exists()).toBe(true)
await themeBtn.trigger('click')
expect(spy).toHaveBeenCalledWith('light')
})
// -------------------------------------------------------------------------
// Density toggle → setDensity with flipped value
// -------------------------------------------------------------------------
it('density toggle flips comfortable → compact', async () => {
const wrapper = mountTopbar()
const shell = useShellUiStore()
shell.density = 'comfortable'
const spy = vi.spyOn(shell, 'setDensity')
const densityBtn = wrapper.find('button[aria-label="Switch to compact"]')
expect(densityBtn.exists()).toBe(true)
await densityBtn.trigger('click')
expect(spy).toHaveBeenCalledWith('compact')
})
it('density toggle flips compact → comfortable', async () => {
const wrapper = mountTopbar()
const shell = useShellUiStore()
shell.density = 'compact'
const spy = vi.spyOn(shell, 'setDensity')
await wrapper.vm.$nextTick()
const densityBtn = wrapper.find('button[aria-label="Switch to comfortable"]')
expect(densityBtn.exists()).toBe(true)
await densityBtn.trigger('click')
expect(spy).toHaveBeenCalledWith('comfortable')
})
// -------------------------------------------------------------------------
// Sign out → authStore.logout()
// -------------------------------------------------------------------------
it('Sign out menu item command calls authStore.logout()', async () => {
const wrapper = mountTopbar()
const authStore = useAuthStore()
// Spy on logout — stub the async to resolve immediately
const logoutSpy = vi.spyOn(authStore, 'logout').mockResolvedValue()
// Access the computed userMenuItems exposed on the component's vm
// The menu items are passed as :model to the stubbed Menu component.
// We need to find the Sign out item and call its command directly.
const menuStub = wrapper.findComponent({ name: 'Menu' })
expect(menuStub.exists()).toBe(true)
// The model prop is the computed userMenuItems array
const model = menuStub.props('model') as Array<{ items: Array<{ label: string; command?: () => void }> }>
// Find Sign out in the nested items groups
const signOutItem = model
.flatMap(group => group.items ?? [])
.find(item => item.label === 'Sign out')
expect(signOutItem).toBeDefined()
expect(typeof signOutItem?.command).toBe('function')
signOutItem?.command?.()
expect(logoutSpy).toHaveBeenCalledOnce()
})
})