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>
290 lines
10 KiB
TypeScript
290 lines
10 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
|
|
* 5. Breadcrumb model mapping:
|
|
* - non-last items carry a `command` that calls router.push with the item's `to`
|
|
* - last item has NO `command` (non-interactive) and NO `route` key (FIX A regression)
|
|
*
|
|
* useBreadcrumb() calls useRoute() internally. We provide a minimal
|
|
* vue-router mock via vi.mock so the composable has a route to call.
|
|
* useRoute is exposed as a vi.fn() so individual tests can override the
|
|
* matched records without re-importing the module.
|
|
*/
|
|
|
|
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'
|
|
import AppTopbar from '@/components-v2/layout/AppTopbar.vue'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock vue-router (useBreadcrumb calls useRoute internally).
|
|
// vi.hoisted() initialises these vi.fn() instances before the vi.mock()
|
|
// factory runs. Vitest hoists vi.hoisted() + vi.mock() above all imports,
|
|
// so the mock is registered before AppTopbar's transitive vue-router
|
|
// import resolves — physical import order here is irrelevant to that.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const { mockRouterPush, mockUseRoute } = vi.hoisted(() => ({
|
|
mockRouterPush: vi.fn(),
|
|
mockUseRoute: vi.fn(() => ({ matched: [] as unknown[] })),
|
|
}))
|
|
|
|
vi.mock('vue-router', () => ({
|
|
useRoute: mockUseRoute,
|
|
useRouter: () => ({
|
|
push: mockRouterPush,
|
|
}),
|
|
}))
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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())
|
|
mockRouterPush.mockReset()
|
|
mockUseRoute.mockReset()
|
|
|
|
// Default: empty matched array (no breadcrumb items)
|
|
mockUseRoute.mockReturnValue({ matched: [] })
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 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()
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// FIX A regression: breadcrumb model mapping uses command+router.push,
|
|
// NOT the `route` key that this PrimeVue Breadcrumb version ignores.
|
|
//
|
|
// BreadcrumbItem.vue renders <a :href="item.url || '#'"> and calls
|
|
// item.command on click — it never reads `route`. This test would have
|
|
// caught the broken mapping that set `route` instead of `command`.
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('breadcrumb model: non-last items have command that calls router.push; last item has no command and no route key', async () => {
|
|
// Provide two matched records so we get a non-last item (with `to`) and
|
|
// a last item (current, no `to`).
|
|
mockUseRoute.mockReturnValue({
|
|
matched: [
|
|
{ meta: { breadcrumb: 'Dashboard' }, name: 'dashboard', path: '/dashboard' },
|
|
{ meta: { breadcrumb: 'Events' }, name: 'events', path: '/events' },
|
|
],
|
|
})
|
|
|
|
const wrapper = mountTopbar()
|
|
|
|
const breadcrumbStub = wrapper.findComponent({ name: 'Breadcrumb' })
|
|
|
|
expect(breadcrumbStub.exists()).toBe(true)
|
|
|
|
const model = breadcrumbStub.props('model') as Array<{
|
|
label: string
|
|
command?: () => void
|
|
route?: unknown
|
|
}>
|
|
|
|
// Should have two items produced by toBreadcrumbItems
|
|
expect(model).toHaveLength(2)
|
|
|
|
const [firstItem, lastItem] = model as [typeof model[0], typeof model[0]]
|
|
|
|
// Non-last item: must carry `command`, must NOT carry `route`
|
|
expect(firstItem.label).toBe('Dashboard')
|
|
expect(typeof firstItem.command).toBe('function')
|
|
expect('route' in firstItem).toBe(false)
|
|
|
|
// Invoking command must call router.push with the item's resolved `to` path
|
|
firstItem.command!()
|
|
expect(mockRouterPush).toHaveBeenCalledOnce()
|
|
expect(mockRouterPush).toHaveBeenCalledWith('/dashboard')
|
|
|
|
// Last/current item: must have NO command (non-interactive), NO `route` key
|
|
expect(lastItem.label).toBe('Events')
|
|
expect(lastItem.command).toBeUndefined()
|
|
expect('route' in lastItem).toBe(false)
|
|
})
|
|
})
|