Files
bookorbit/client/src/components/__tests__/AppHeader.spec.ts
T
neon 7a203a03d7 fix(client): add missing icon tooltips
Make icon-only controls discoverable to mouse and keyboard users with localized tooltips and accessible names.

Fixes #588
2026-09-12 09:24:32 +00:00

352 lines
13 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick, ref, type Ref } from 'vue'
import type { GlobalSearchResult } from '@/features/book/composables/useGlobalSearch'
import AppHeader from '../AppHeader.vue'
const mocks = vi.hoisted(() => ({
routerPush: vi.fn<(to: unknown) => void>(),
results: null as unknown as Ref<GlobalSearchResult[]>,
total: null as unknown as Ref<number>,
loading: null as unknown as Ref<boolean>,
loadingMore: null as unknown as Ref<boolean>,
settled: null as unknown as Ref<boolean>,
hasMore: null as unknown as Ref<boolean>,
user: null as unknown as Ref<{ provisioningMethod: string; settings?: { achievementPreferences?: { enabled?: boolean } } }>,
loadMore: vi.fn<() => Promise<void>>(),
clearGlobalSearch: vi.fn<() => void>(),
}))
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-router')>()
return {
...actual,
useRouter: () => ({ push: mocks.routerPush }),
useRoute: () => ({ name: 'dashboard' }),
}
})
vi.mock('@/features/book/composables/useGlobalSearch', () => ({
useGlobalSearch: () => ({
results: mocks.results,
total: mocks.total,
loading: mocks.loading,
loadingMore: mocks.loadingMore,
settled: mocks.settled,
hasMore: mocks.hasMore,
loadMore: mocks.loadMore,
clear: mocks.clearGlobalSearch,
}),
}))
vi.mock('@/features/auth/composables/useAuth', async () => {
return {
useAuth: () => ({
user: mocks.user,
logout: vi.fn<() => void>(),
}),
}
})
vi.mock('@/composables/useChangePasswordDialog', () => ({
useChangePasswordDialog: () => ({ open: vi.fn<() => void>() }),
}))
vi.mock('@/features/auth/composables/usePermissions', async () => {
const { ref: vueRef } = await import('vue')
return {
usePermissions: () => ({
hasPermission: () => false,
isDemoRestrictedAccount: vueRef(false),
}),
}
})
vi.mock('@/features/library/composables/useLibraryUploadEvents', () => ({
useLibraryUploadEvents: () => ({ onLibraryUploadCompleted: () => vi.fn<() => void>() }),
}))
vi.mock('@/features/notifications/composables/useNotifications', () => ({
useNotifications: () => ({ subscribe: vi.fn<() => void>() }),
}))
vi.mock('@/features/whats-new/composables/useWhatsNew', async () => {
const { ref: vueRef } = await import('vue')
return {
useWhatsNew: () => ({ hasUnseen: vueRef(false) }),
}
})
vi.mock('@/stores/theme', () => ({
useThemeStore: () => ({ radius: 'rounded' }),
}))
vi.mock('@/stores/locale', () => ({
useLocaleStore: () => ({ locale: 'en', setLocale: vi.fn<(...args: unknown[]) => Promise<void>>().mockResolvedValue(undefined) }),
matchSupportedLocale: () => null,
}))
vi.mock('@/components/ui/sidebar', () => ({
SidebarTrigger: { name: 'SidebarTrigger', template: '<button type="button"><slot /></button>' },
}))
vi.mock('@/components/ui/separator', () => ({
Separator: { name: 'Separator', template: '<div><slot /></div>' },
}))
vi.mock('@/components/ui/popover', () => ({
Popover: { name: 'Popover', template: '<div><slot /></div>' },
PopoverContent: { name: 'PopoverContent', template: '<div><slot /></div>' },
PopoverTrigger: { name: 'PopoverTrigger', template: '<div><slot /></div>' },
}))
vi.mock('@/components/ui/button', () => ({
Button: { name: 'Button', template: '<button type="button"><slot /></button>' },
}))
vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenu: { name: 'DropdownMenu', template: '<div><slot /></div>' },
DropdownMenuContent: { name: 'DropdownMenuContent', template: '<div><slot /></div>' },
DropdownMenuItem: { name: 'DropdownMenuItem', template: '<button type="button"><slot /></button>' },
DropdownMenuLabel: { name: 'DropdownMenuLabel', template: '<div><slot /></div>' },
DropdownMenuSeparator: { name: 'DropdownMenuSeparator', template: '<div />' },
DropdownMenuTrigger: { name: 'DropdownMenuTrigger', template: '<div><slot /></div>' },
DropdownMenuSub: { name: 'DropdownMenuSub', template: '<div><slot /></div>' },
DropdownMenuSubContent: { name: 'DropdownMenuSubContent', template: '<div><slot /></div>' },
DropdownMenuSubTrigger: { name: 'DropdownMenuSubTrigger', template: '<button type="button"><slot /></button>' },
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: { name: 'Tooltip', template: '<div><slot /></div>' },
TooltipContent: { name: 'TooltipContent', template: '<div><slot /></div>' },
TooltipTrigger: { name: 'TooltipTrigger', template: '<div><slot /></div>' },
}))
vi.mock('@/features/book/components/BookCoverImage.vue', () => ({
default: { name: 'BookCoverImage', template: '<img alt="" />' },
}))
vi.mock('@/components/AccentPicker.vue', () => ({ default: { name: 'AccentPicker', template: '<div />' } }))
vi.mock('@/components/RadiusPicker.vue', () => ({ default: { name: 'RadiusPicker', template: '<div />' } }))
vi.mock('@/components/BackgroundPicker.vue', () => ({ default: { name: 'BackgroundPicker', template: '<div />' } }))
vi.mock('@/components/ThemePicker.vue', () => ({ default: { name: 'ThemePicker', template: '<div />' } }))
vi.mock('@/features/library/components/BookUploadModal.vue', () => ({ default: { name: 'BookUploadModal', template: '<div />' } }))
vi.mock('@/features/notifications/components/NotificationSheet.vue', () => ({ default: { name: 'NotificationSheet', template: '<div />' } }))
vi.mock('@/components/UserAvatar.vue', () => ({ default: { name: 'UserAvatar', template: '<div />' } }))
function makeResult(id: number, title = 'Prey'): GlobalSearchResult {
return {
id,
status: 'present',
coverAspectRatio: '2/3',
title,
authors: ['Author'],
seriesId: null,
seriesName: null,
seriesIndex: null,
files: [{ id: id * 10, format: 'epub', role: 'primary', sizeBytes: null }],
publishedDate: null,
publishedYear: null,
language: null,
genres: [],
tags: [],
rating: null,
readingProgress: null,
readStatus: null,
addedAt: '2026-01-01T00:00:00.000Z',
updatedAt: null,
metadataScore: null,
hasCover: false,
hasMetadataLocks: false,
lockedFields: [],
subtitle: null,
publisher: null,
pageCount: null,
isbn13: null,
narrators: [],
customMetadata: [],
}
}
function mountHeader() {
return mount(AppHeader, {
global: {
stubs: {
Transition: false,
},
},
})
}
describe('AppHeader global search', () => {
beforeEach(() => {
mocks.routerPush.mockReset()
mocks.loadMore.mockReset()
mocks.loadMore.mockResolvedValue(undefined)
mocks.clearGlobalSearch.mockReset()
mocks.results = ref([makeResult(101), makeResult(102, 'Predator')])
mocks.total = ref(42)
mocks.loading = ref(false)
mocks.loadingMore = ref(false)
mocks.settled = ref(true)
mocks.hasMore = ref(true)
mocks.user = ref({ provisioningMethod: 'password', settings: {} })
})
it('does not leave the dropdown for a separate search route on Enter when no result is selected', async () => {
const wrapper = mountHeader()
const input = wrapper.get('input[placeholder="Search all books..."]')
await input.trigger('focus')
await input.setValue(' Prey ')
await input.trigger('keydown', { key: 'Enter' })
expect(mocks.routerPush).not.toHaveBeenCalled()
})
it('keeps Enter-to-open behavior when a result is selected', async () => {
const wrapper = mountHeader()
const input = wrapper.get('input[placeholder="Search all books..."]')
await input.trigger('focus')
await input.setValue('Prey')
await input.trigger('keydown', { key: 'ArrowDown' })
await input.trigger('keydown', { key: 'Enter' })
expect(mocks.routerPush).toHaveBeenCalledWith({ name: 'book-detail', params: { bookId: 101 } })
})
it('loads more results from the dropdown footer', async () => {
const wrapper = mountHeader()
const input = wrapper.get('input[placeholder="Search all books..."]')
await input.trigger('focus')
await input.setValue('Prey')
const loadMore = wrapper.findAll('button').find((button) => button.text() === 'Load more (2/42)')
expect(loadMore).toBeDefined()
if (!loadMore) throw new Error('Expected load more search results button')
await loadMore.trigger('click')
expect(mocks.loadMore).toHaveBeenCalledOnce()
})
it('loads more results when the dropdown scroll reaches the end', async () => {
const wrapper = mountHeader()
const input = wrapper.get('input[placeholder="Search all books..."]')
await input.trigger('focus')
await input.setValue('Prey')
const dropdown = wrapper.get('[data-testid="global-search-dropdown"]')
Object.defineProperty(dropdown.element, 'scrollHeight', { configurable: true, value: 1000 })
Object.defineProperty(dropdown.element, 'clientHeight', { configurable: true, value: 240 })
Object.defineProperty(dropdown.element, 'scrollTop', { configurable: true, value: 680 })
await dropdown.trigger('scroll')
expect(mocks.loadMore).toHaveBeenCalledOnce()
})
it('renders only the visible virtual slice of loaded dropdown results', async () => {
mocks.results = ref(Array.from({ length: 60 }, (_, index) => makeResult(index + 1, `Prey ${index + 1}`)))
mocks.total = ref(60)
mocks.hasMore = ref(false)
const wrapper = mountHeader()
const input = wrapper.get('input[placeholder="Search all books..."]')
await input.trigger('focus')
await input.setValue('Prey')
const renderedResults = wrapper.findAll('button').filter((button) => button.text().startsWith('Prey '))
expect(renderedResults.length).toBeGreaterThan(0)
expect(renderedResults.length).toBeLessThan(60)
expect(wrapper.text()).toContain('Prey 1')
expect(wrapper.text()).not.toContain('Prey 60')
const dropdown = wrapper.get('[data-testid="global-search-dropdown"]')
Object.defineProperty(dropdown.element, 'scrollTop', { configurable: true, value: 4704 })
await dropdown.trigger('scroll')
await nextTick()
expect(wrapper.text()).not.toContain('Prey 1')
expect(wrapper.text()).toContain('Prey 60')
})
it('does not render the destinations the sidebar now owns', () => {
const wrapper = mountHeader()
const text = wrapper.text()
for (const destination of ['Book Dock', 'Annotations']) {
expect(text).not.toContain(destination)
}
})
it('keeps Statistics and Achievements in the header', () => {
const wrapper = mountHeader()
expect(wrapper.text()).toContain('Statistics')
expect(wrapper.text()).toContain('Achievements')
})
it('hides achievement entry points when achievements are disabled', () => {
mocks.user = ref({ provisioningMethod: 'password', settings: { achievementPreferences: { enabled: false } } })
const wrapper = mountHeader()
expect(wrapper.text()).not.toContain('Achievements')
})
it('navigates to statistics from the mobile overflow menu', async () => {
const wrapper = mountHeader()
const statisticsItem = wrapper.findAllComponents({ name: 'DropdownMenuItem' }).find((item) => item.text().trim() === 'Statistics')
expect(statisticsItem).toBeDefined()
if (!statisticsItem) throw new Error('Expected statistics overflow menu item')
await statisticsItem.trigger('click')
expect(mocks.routerPush).toHaveBeenCalledWith({ name: 'statistics', query: { tab: 'library' } })
})
it('places the desktop language control immediately before settings', () => {
const wrapper = mountHeader()
const buttons = wrapper.findAll('button')
const languageIndex = buttons.findIndex((button) => button.attributes('data-testid') === 'language-control')
const settingsIndex = buttons.findIndex((button) => button.attributes('data-tour') === 'settings-nav')
expect(languageIndex).toBeGreaterThanOrEqual(0)
expect(settingsIndex).toBeGreaterThanOrEqual(0)
if (languageIndex < 0 || settingsIndex < 0) throw new Error('Expected desktop language and settings controls')
const desktopGroup = buttons[settingsIndex]?.element.closest('[class~="md:flex"]')
if (!desktopGroup) throw new Error('Expected desktop control group')
const controls = Array.from(desktopGroup.children)
const languageControlIndex = controls.findIndex((control) => control.contains(buttons[languageIndex]?.element ?? null))
const settingsControlIndex = controls.findIndex((control) => control.contains(buttons[settingsIndex]?.element ?? null))
expect(settingsControlIndex).toBe(languageControlIndex + 1)
})
it('labels the icon-only desktop destinations and preference controls', () => {
const wrapper = mountHeader()
expect(wrapper.get('[data-tour="statistics-btn"]').attributes('aria-label')).toBe('Statistics')
expect(wrapper.get('[data-tour="documentation-link"]').attributes('aria-label')).toBe('Help')
expect(wrapper.get('[data-tour="appearance-picker"]').attributes('aria-label')).toBe('Appearance')
expect(wrapper.get('[data-tour="settings-nav"]').attributes('aria-label')).toBe('Settings')
})
it('opens settings from the mobile overflow menu', async () => {
const wrapper = mountHeader()
const settingsItem = wrapper.findAllComponents({ name: 'DropdownMenuItem' }).find((item) => item.text().trim() === 'Settings')
expect(settingsItem).toBeDefined()
if (!settingsItem) throw new Error('Expected settings overflow menu item')
await settingsItem.trigger('click')
expect(mocks.routerPush).toHaveBeenCalledWith({ name: 'settings-libraries' })
})
})