- 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>
225 lines
7.3 KiB
TypeScript
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()
|
|
})
|
|
})
|