267 lines
8.4 KiB
TypeScript
267 lines
8.4 KiB
TypeScript
|
|
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)
|
||
|
|
})
|
||
|
|
})
|