Files
crewli-old/apps/app/src/components-v2/layout/__tests__/AppTopbar.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

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)
})
})