Files
crewli-old/apps/app/src/components-v2/layout/__tests__/AppTopbar.spec.ts
bert.hausmans ac36dfe9b7 feat(layout): Plan 2.5 P5 — shell parity fixes 1–5 + useBreadcrumb retire
Per RFC-WS-PRIMEVUE-PLAN-2-5 §5.1–§5.5 plus the AD-2.5-W1 option-A
supersession (no sub on dropdown items either, accepted divergence).

Atomic changes:
- AppTopbar: brand block (gradient "C" mark + Crewli wordmark) removed
  per Fix 1; the #start slot now renders <AppBreadcrumb /> per Fix 2.
  Legacy meta-based useBreadcrumb consumption (breadcrumbModel computed,
  vue-router useRouter import, command-based PrimeVue Breadcrumb model)
  is gone; AppBreadcrumb owns the registry-driven path. Dead
  topbar-mark-shadow scoped CSS rule deleted.
- AppBreadcrumb: import updated to the renamed useBreadcrumb.
- AppSidebar: docstring updated to make the Fix 3 vertical order
  (Header → Nav → Switcher, switcher bottom-anchored) explicit. No
  template change needed: SidebarNav's root <nav class="flex-1"> already
  fills available column space, naturally pushing WorkspaceSwitcher to
  the bottom (two flex-1 siblings would split the column 50/50 and
  compress the nav — a separate spacer element is structurally wrong).
- WorkspaceSwitcher: dropdown panel restructured per crewli-starter
  reference. Semantic class markers (.popover-head/.title/.link/.list/
  .opt/.is-current/.ws-logo/.name/.check-mark/.foot) added alongside
  Tailwind utilities so specs assert structure with stable selectors.
  Footer buttons wired to placeholder createWorkspace / inviteUser
  handlers (console.warn + TODO) until the flows ship. Manage link
  stays a non-navigating label (no v2-workspaces-manage route yet).
  No sub line on any dropdown row (AD-2.5-W1 option A).

Atomic legacy useBreadcrumb retirement (planned since P1):
- Legacy route-meta-driven useBreadcrumb + toBreadcrumbItems +
  BreadcrumbRouteRecord types deleted entirely (only AppTopbar
  consumed it, and that consumption is gone after Fix 2).
- useNavBreadcrumb → useBreadcrumb (single SoT for breadcrumb chain).
- NavBreadcrumbItem → BreadcrumbItem.
- AppBreadcrumb.vue import updated to the new name.
- SidebarNav.vue docstring reference scrubbed to the new name.
- useBreadcrumb.spec.ts: 10 legacy toBreadcrumbItems specs removed;
  4 walkNavTree specs retained.

AppTopbar.spec.ts:
- vue-router mock simplified (route.matched no longer relevant).
- AppBreadcrumb stubbed in #start; legacy command-vs-route assertion
  removed; new spec verifies AppBreadcrumb is rendered.

WorkspaceSwitcher.spec.ts: 5 new dropdown specs (header / row count /
current-row checkmark / footer buttons / no-sub on rows).

Suite delta: 557 → 552 (−5 net: −10 legacy toBreadcrumbItems specs,
+5 Fix 5 dropdown specs, −1 obsolete AppTopbar breadcrumb-model spec,
+1 new AppTopbar AppBreadcrumb-presence spec).

vue-tsc clean. Scoped ESLint clean (0 errors). All 3 re-grep checks
returned 0 hits (useNavBreadcrumb/NavBreadcrumbItem, topbar brand
selectors, standalone "sub" identifier in WorkspaceSwitcher — only
documentation comments referencing the no-sub state remain, which
describe absence by design).

Manual smoke skipped (Auto Mode); coverage from the post-edit specs
includes AppBreadcrumb-in-#start, dropdown structure, and trigger
no-sub. Recommend Bert run `pnpm --filter crewli-app dev` and verify
the 6 checks listed in the prompt before merging.

Known divergence from crewli-starter (accepted):
- Dropdown rows are ~16px shorter than crewli-starter (no sub line).
  Tracked as WORKSPACE-DROPDOWN-SUB-CONTENT for a future RFC with
  the required backend scope (organisations.type enum + metrics).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 20:22:33 +02:00

303 lines
11 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. AppBreadcrumb is rendered in the #start slot (Plan 2.5 P5 Fix 2).
*
* AppBreadcrumb (and its useBreadcrumb composable) is stubbed so this spec
* never reaches the real route-driven walkNavTree path — breadcrumb-derivation
* coverage lives in useBreadcrumb.spec.ts.
*/
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 — AppTopbar no longer uses useRoute/useRouter directly, but
// PrimeVue's stubbed components and AppBreadcrumb (stubbed below) may import
// from vue-router transitively. A minimal mock keeps the test environment lean.
// ---------------------------------------------------------------------------
vi.mock('vue-router', () => ({
useRoute: () => ({ name: 'v2-dashboard' }),
useRouter: () => ({ push: vi.fn() }),
RouterLink: { name: 'RouterLink', props: ['to'], template: '<a><slot /></a>' },
}))
// ---------------------------------------------------------------------------
// Stubs for PrimeVue components
// ---------------------------------------------------------------------------
/**
* MenubarStub — mirrors the PrimeVue Menubar slot contract used by AppTopbar
* (RFC AD-3). Renders #start and #end named slots so [data-tb="breadcrumb"]
* and [data-tb="user"] are inspectable inside the stub.
*/
const MenubarStub = {
name: 'Menubar',
props: ['model', 'pt'],
template: '<div class="menubar-stub"><slot name="start" /><slot name="end" /></div>',
}
const globalStubs = {
Menubar: MenubarStub,
AppBreadcrumb: { name: 'AppBreadcrumb', template: '<nav class="app-breadcrumb-stub" data-testid="app-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()
})
// -------------------------------------------------------------------------
// Plan 2.5 P5 Fix 2: AppTopbar #start renders <AppBreadcrumb /> (not the
// legacy PrimeVue Breadcrumb fed by a route-meta mapping). Breadcrumb
// derivation coverage lives in useBreadcrumb.spec.ts.
// -------------------------------------------------------------------------
it('renders <AppBreadcrumb /> inside the Menubar #start slot', () => {
const wrapper = mountTopbar()
const bar = wrapper.findComponent(MenubarStub)
expect(bar.find('[data-testid="app-breadcrumb-stub"]').exists()).toBe(true)
})
// -------------------------------------------------------------------------
// RFC AD-3: AppTopbar wraps PrimeVue Menubar as chrome root
// -------------------------------------------------------------------------
it('renders the top bar inside a PrimeVue Menubar (RFC AD-3)', () => {
const w = mountTopbar()
expect(w.findComponent(MenubarStub).exists()).toBe(true)
})
it('breadcrumb lives in Menubar #start, user cluster in #end; search wrapper exists and mobile-ws-btn is NOT its descendant', async () => {
// Mount first so the component's pinia plugin is active, then get the
// store instance from that same pinia context.
const w = mountTopbar()
const bar = w.findComponent(MenubarStub)
expect(bar.find('[data-tb="breadcrumb"]').exists()).toBe(true)
expect(bar.find('[data-tb="user"]').exists()).toBe(true)
// AD-3 regression lock: data-tb="search" must exist as a direct #end sibling
expect(bar.find('[data-tb="search"]').exists()).toBe(true)
// Seed authStore with an organisation so authStore.currentOrganisation resolves
// (it is a computed derived from organisations[0]). setUser() is the public API
// that populates the organisations array.
// Must call useAuthStore() AFTER mountTopbar() so we use the same pinia instance.
const authStore = useAuthStore()
authStore.setUser({
id: 'u1',
first_name: 'Test',
last_name: 'User',
full_name: 'Test User',
date_of_birth: null,
email: 'test@example.com',
phone: null,
timezone: 'UTC',
locale: 'en',
avatar: null,
organisations: [{ id: 'org-1', name: 'Test Org', slug: 'test-org', role: 'org_admin' }],
app_roles: [],
permissions: [],
})
await w.vm.$nextTick()
const wsBtnSelector = 'button[aria-label^="Workspace:"]'
// The mobile workspace button must exist in the bar (currentOrganisation is set).
expect(bar.find(wsBtnSelector).exists()).toBe(true)
// The mobile workspace button must NOT be a descendant of data-tb="search".
// In the corrected structure it is a free sibling in the #end flex row.
expect(bar.find('[data-tb="search"]').find(wsBtnSelector).exists()).toBe(false)
})
})