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