archy/neode-ui/src/composables/__tests__/useControllerNav.test.ts

267 lines
8.4 KiB
TypeScript
Raw Normal View History

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// Mock vue-router
const mockRoute = { path: '/dashboard' }
const mockRouter = { push: vi.fn().mockResolvedValue(undefined) }
vi.mock('vue-router', () => ({
useRoute: () => mockRoute,
useRouter: () => mockRouter,
}))
// Mock stores
vi.mock('@/stores/controller', () => ({
useControllerStore: () => ({
setActive: vi.fn(),
setGamepadCount: vi.fn(),
isActive: false,
gamepadCount: 0,
}),
}))
vi.mock('@/stores/spotlight', () => ({
useSpotlightStore: () => ({
isOpen: false,
close: vi.fn(),
}),
}))
vi.mock('@/stores/cli', () => ({
useCLIStore: () => ({
isOpen: false,
close: vi.fn(),
}),
}))
vi.mock('@/stores/appLauncher', () => ({
useAppLauncherStore: () => ({
isOpen: false,
close: vi.fn(),
}),
}))
// Mock useNavSounds
vi.mock('@/composables/useNavSounds', () => ({
playNavSound: vi.fn(),
}))
// Note: The composable uses onMounted/onBeforeUnmount, so full integration tests
// would require a mounted component with Pinia and Router. We test helper logic directly.
describe('useControllerNav - helper functions', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
mockRoute.path = '/dashboard'
// Mock navigator.getGamepads
Object.defineProperty(navigator, 'getGamepads', {
value: vi.fn().mockReturnValue([null, null, null, null]),
configurable: true,
writable: true,
})
})
afterEach(() => {
vi.useRealTimers()
})
// Test the module exports via dynamic import to validate structure
it('exports useControllerNav as a function', async () => {
const mod = await import('../useControllerNav')
expect(typeof mod.useControllerNav).toBe('function')
})
})
describe('useControllerNav - nav key classification', () => {
it('classifies arrow keys and Enter/Escape as nav keys', () => {
const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape']
expect(navKeys.includes('ArrowUp')).toBe(true)
expect(navKeys.includes('ArrowDown')).toBe(true)
expect(navKeys.includes('ArrowLeft')).toBe(true)
expect(navKeys.includes('ArrowRight')).toBe(true)
expect(navKeys.includes('Enter')).toBe(true)
expect(navKeys.includes('Escape')).toBe(true)
})
it('does not classify regular keys as nav keys', () => {
const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape']
expect(navKeys.includes('a')).toBe(false)
expect(navKeys.includes('Space')).toBe(false)
expect(navKeys.includes('Tab')).toBe(false)
})
it('recognizes detail page patterns', () => {
const pattern = /\/apps\/[^/]+$|\/marketplace\/[^/]+$|\/cloud\/[^/]+$/
expect(pattern.test('/apps/bitcoin')).toBe(true)
expect(pattern.test('/marketplace/electrs')).toBe(true)
expect(pattern.test('/cloud/photos')).toBe(true)
expect(pattern.test('/dashboard')).toBe(false)
expect(pattern.test('/apps')).toBe(false)
})
it('recognizes page type patterns', () => {
expect(/^\/dashboard(\/)?$/.test('/dashboard')).toBe(true)
expect(/^\/dashboard(\/)?$/.test('/dashboard/')).toBe(true)
expect(/^\/dashboard\/(apps|marketplace)(\/|$)/.test('/dashboard/apps')).toBe(true)
expect(/^\/dashboard\/(apps|marketplace)(\/|$)/.test('/dashboard/marketplace')).toBe(true)
expect(/^\/dashboard\/cloud(\/|$)/.test('/dashboard/cloud')).toBe(true)
expect(/^\/dashboard\/server(\/|$)/.test('/dashboard/server')).toBe(true)
expect(/^\/dashboard\/web5(\/|$)/.test('/dashboard/web5')).toBe(true)
expect(/^\/dashboard\/settings(\/|$)/.test('/dashboard/settings')).toBe(true)
})
})
describe('useControllerNav - spatial navigation helpers', () => {
// Test the internal helper functions indirectly via the FOCUSABLE_SELECTOR concept
it('identifies focusable elements', () => {
const container = document.createElement('div')
const button = document.createElement('button')
button.textContent = 'Click'
const link = document.createElement('a')
link.href = '/test'
link.textContent = 'Link'
const disabledBtn = document.createElement('button')
disabledBtn.disabled = true
disabledBtn.textContent = 'Disabled'
const input = document.createElement('input')
container.appendChild(button)
container.appendChild(link)
container.appendChild(disabledBtn)
container.appendChild(input)
document.body.appendChild(container)
const focusable = container.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
// Should find button, link, and input but NOT disabled button
expect(focusable.length).toBe(3)
document.body.removeChild(container)
})
it('respects data-controller-ignore attribute', () => {
const container = document.createElement('div')
const button = document.createElement('button')
button.textContent = 'Visible'
const ignoredBtn = document.createElement('button')
ignoredBtn.textContent = 'Ignored'
ignoredBtn.setAttribute('data-controller-ignore', '')
container.appendChild(button)
container.appendChild(ignoredBtn)
document.body.appendChild(container)
const focusable = Array.from(
container.querySelectorAll<HTMLElement>('button:not([disabled])')
).filter(el => !el.hasAttribute('data-controller-ignore'))
expect(focusable.length).toBe(1)
expect(focusable[0]?.textContent).toBe('Visible')
document.body.removeChild(container)
})
it('identifies sidebar and main zones', () => {
const sidebar = document.createElement('div')
sidebar.setAttribute('data-controller-zone', 'sidebar')
const main = document.createElement('div')
main.setAttribute('data-controller-zone', 'main')
const sideBtn = document.createElement('button')
sideBtn.textContent = 'Nav'
sidebar.appendChild(sideBtn)
const mainBtn = document.createElement('button')
mainBtn.textContent = 'Content'
main.appendChild(mainBtn)
document.body.appendChild(sidebar)
document.body.appendChild(main)
// isInZone check
expect(sideBtn.closest('[data-controller-zone="sidebar"]')).toBeTruthy()
expect(mainBtn.closest('[data-controller-zone="main"]')).toBeTruthy()
expect(sideBtn.closest('[data-controller-zone="main"]')).toBeNull()
document.body.removeChild(sidebar)
document.body.removeChild(main)
})
it('identifies container elements', () => {
const container = document.createElement('div')
container.setAttribute('data-controller-container', '')
container.tabIndex = 0
const innerBtn = document.createElement('button')
innerBtn.textContent = 'Inner'
container.appendChild(innerBtn)
document.body.appendChild(container)
// isInsideContainer check
expect(innerBtn.closest('[data-controller-container]')).toBe(container)
expect(container.closest('[data-controller-container]')).toBe(container)
document.body.removeChild(container)
})
it('finds inner focusable elements within containers', () => {
const container = document.createElement('div')
container.setAttribute('data-controller-container', '')
container.tabIndex = 0
const btn1 = document.createElement('button')
btn1.textContent = 'Action 1'
const btn2 = document.createElement('button')
btn2.textContent = 'Action 2'
container.appendChild(btn1)
container.appendChild(btn2)
document.body.appendChild(container)
const inner = Array.from(
container.querySelectorAll<HTMLElement>('button:not([disabled])')
).filter(el => el !== container)
expect(inner.length).toBe(2)
document.body.removeChild(container)
})
})
describe('useControllerNav - gamepad detection', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('counts connected gamepads', () => {
const gamepads = [
{ connected: true } as Gamepad,
null,
{ connected: true } as Gamepad,
null,
]
const count = gamepads.filter((g) => g?.connected).length
expect(count).toBe(2)
})
it('handles null gamepad list', () => {
// Simulate navigator.getGamepads returning null (some browsers)
function getCount(gp: (Gamepad | null)[] | null): number {
return gp ? gp.filter((g) => g?.connected).length : 0
}
expect(getCount(null)).toBe(0)
})
it('handles empty gamepad list', () => {
const gamepads: (Gamepad | null)[] = [null, null, null, null]
const count = Array.from(gamepads).filter((g) => g?.connected).length
expect(count).toBe(0)
})
})