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(), })) // ─── Module Export Tests ──────────────────────────────────────── describe('useControllerNav - module', () => { beforeEach(() => { vi.clearAllMocks() vi.useFakeTimers() Object.defineProperty(navigator, 'getGamepads', { value: vi.fn().mockReturnValue([null, null, null, null]), configurable: true, writable: true, }) }) afterEach(() => { vi.useRealTimers() }) it('exports useControllerNav as a function', async () => { const mod = await import('../useControllerNav') expect(typeof mod.useControllerNav).toBe('function') }) }) // ─── Nav Key Classification ───────────────────────────────────── describe('useControllerNav - nav keys', () => { const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape'] it('classifies all arrow keys, Enter, and Escape as nav keys', () => { for (const key of navKeys) { expect(navKeys.includes(key)).toBe(true) } }) it('rejects non-nav keys', () => { for (const key of ['a', 'Space', 'Tab', 'Shift', 'F1', 'Delete']) { expect(navKeys.includes(key)).toBe(false) } }) }) // ─── Route Pattern Tests ──────────────────────────────────────── describe('useControllerNav - route patterns', () => { it('recognizes detail page patterns for Escape-back behavior', () => { 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 all page type patterns for right-arrow targets', () => { 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) }) }) // ─── Focusable Element Detection ──────────────────────────────── describe('useControllerNav - focusable elements', () => { afterEach(() => { document.body.innerHTML = '' }) it('finds buttons, links, and inputs as focusable', () => { document.body.innerHTML = `
Link
` const focusable = document.querySelectorAll( 'a[href], button:not([disabled]), input:not([disabled])' ) expect(focusable.length).toBe(3) }) it('finds elements with tabindex as focusable', () => { document.body.innerHTML = `
Container
Hidden
Not focusable
` const focusable = document.querySelectorAll('[tabindex]:not([tabindex="-1"])') expect(focusable.length).toBe(1) }) it('finds data-controller-container as focusable', () => { document.body.innerHTML = `
Card 1
Card 2
Regular div
` const containers = document.querySelectorAll('[data-controller-container]') expect(containers.length).toBe(2) }) it('excludes data-controller-ignore elements', () => { document.body.innerHTML = `
` const all = Array.from(document.querySelectorAll('button:not([disabled])')).filter( el => !el.hasAttribute('data-controller-ignore') && !el.closest('[data-controller-ignore]') ) expect(all.length).toBe(1) expect(all[0]?.textContent).toBe('Visible') }) }) // ─── Zone Detection ───────────────────────────────────────────── describe('useControllerNav - zones', () => { afterEach(() => { document.body.innerHTML = '' }) it('sidebar elements belong to sidebar zone', () => { document.body.innerHTML = `
Home
` const link = document.querySelector('a')! const btn = document.querySelector('button')! expect(link.closest('[data-controller-zone="sidebar"]')).toBeTruthy() expect(btn.closest('[data-controller-zone="main"]')).toBeTruthy() expect(link.closest('[data-controller-zone="main"]')).toBeNull() }) it('main elements belong to main zone', () => { document.body.innerHTML = `
Nav
` const inner = document.querySelector('button')! expect(inner.closest('[data-controller-zone="main"]')).toBeTruthy() }) }) // ─── Container Drill-in/Drill-out ─────────────────────────────── describe('useControllerNav - container behavior', () => { afterEach(() => { document.body.innerHTML = '' }) it('container elements are identified via data-controller-container', () => { document.body.innerHTML = `
` const container = document.querySelector('[data-controller-container]') expect(container).toBeTruthy() expect(container?.getAttribute('tabindex')).toBe('0') }) it('inner buttons are found within containers', () => { document.body.innerHTML = `
` const container = document.querySelector('[data-controller-container]')! const inner = Array.from(container.querySelectorAll('button:not([disabled])')).filter( el => el !== container ) expect(inner.length).toBe(2) }) it('isInsideContainer detects when element is nested in a container', () => { document.body.innerHTML = `
` const inner = document.getElementById('inner')! const outer = document.getElementById('outer')! const innerContainer = inner.closest('[data-controller-container]') expect(innerContainer).toBeTruthy() expect(innerContainer !== inner).toBe(true) expect(outer.closest('[data-controller-container]')).toBeNull() }) it('data-controller-launch marks a card for Enter=launch behavior', () => { document.body.innerHTML = `
` const container = document.querySelector('[data-controller-container]')! expect(container.hasAttribute('data-controller-launch')).toBe(true) const btn = container.querySelector('[data-controller-launch-btn]') expect(btn).toBeTruthy() }) it('data-controller-install marks a card for Enter=install behavior', () => { document.body.innerHTML = `
` const container = document.querySelector('[data-controller-container]')! expect(container.hasAttribute('data-controller-install')).toBe(true) const btn = container.querySelector('[data-controller-install-btn]') expect(btn).toBeTruthy() }) }) // ─── Spatial Navigation (findNearestInDirection) ──────────────── describe('useControllerNav - spatial navigation logic', () => { afterEach(() => { document.body.innerHTML = '' }) it('direction filtering works correctly', () => { // Simulate the direction check logic from findNearestInDirection const fromRect = { left: 200, right: 350, top: 0, bottom: 150, width: 150, height: 150 } const threshold = 50 // Element to the left const leftRect = { left: 0, right: 150, top: 0, bottom: 150 } expect(leftRect.right <= fromRect.left + threshold).toBe(true) // is to the left // Element to the right const rightRect = { left: 400, right: 550, top: 0, bottom: 150 } expect(rightRect.left >= fromRect.right - threshold).toBe(true) // is to the right // Element below const belowRect = { left: 200, right: 350, top: 200, bottom: 350 } expect(belowRect.top >= fromRect.bottom - threshold).toBe(true) // is below // Element above (from below position) expect(fromRect.bottom <= belowRect.top + threshold).toBe(true) // fromRect is above belowRect }) it('overlap scoring prefers aligned elements', () => { // Two elements to the right: one aligned, one offset const fromRect = { left: 0, right: 150, top: 50, bottom: 200, width: 150, height: 150 } // Aligned (same row, full overlap on Y axis) const alignedRect = { left: 200, right: 350, top: 50, bottom: 200, width: 150, height: 150 } const alignedOverlap = Math.max(0, Math.min(fromRect.bottom, alignedRect.bottom) - Math.max(fromRect.top, alignedRect.top)) // Offset (partially overlapping on Y axis) const offsetRect = { left: 200, right: 350, top: 160, bottom: 310, width: 150, height: 150 } const offsetOverlap = Math.max(0, Math.min(fromRect.bottom, offsetRect.bottom) - Math.max(fromRect.top, offsetRect.top)) expect(alignedOverlap).toBeGreaterThan(offsetOverlap) // aligned element wins expect(alignedOverlap).toBe(150) // full overlap expect(offsetOverlap).toBe(40) // partial overlap }) }) // ─── Gamepad Detection ────────────────────────────────────────── describe('useControllerNav - gamepad', () => { it('counts connected gamepads', () => { const gamepads = [ { connected: true } as Gamepad, null, { connected: true } as Gamepad, null, ] expect(gamepads.filter(g => g?.connected).length).toBe(2) }) it('handles null gamepad list', () => { const getCount = (gp: (Gamepad | null)[] | null): number => gp ? gp.filter(g => g?.connected).length : 0 expect(getCount(null)).toBe(0) }) it('handles all-null gamepad list', () => { const gamepads: (Gamepad | null)[] = [null, null, null, null] expect(Array.from(gamepads).filter(g => g?.connected).length).toBe(0) }) }) // ─── Sidebar Navigation ───────────────────────────────────────── describe('useControllerNav - sidebar behavior', () => { afterEach(() => { document.body.innerHTML = '' }) it('sidebar has linear up/down navigation with wrap', () => { document.body.innerHTML = `
Home Apps Cloud
` const items = document.querySelectorAll('[data-controller-zone="sidebar"] a, [data-controller-zone="sidebar"] button') expect(items.length).toBe(4) // Wrap: last→first const lastIdx = items.length - 1 const nextIdx = lastIdx >= items.length - 1 ? 0 : lastIdx + 1 expect(nextIdx).toBe(0) // wraps to Home // Wrap: first→last const firstIdx = 0 const prevIdx = firstIdx <= 0 ? items.length - 1 : firstIdx - 1 expect(prevIdx).toBe(3) // wraps to Logout }) it('left arrow from main goes to active sidebar tab', () => { document.body.innerHTML = `
Home Apps
` const activeTab = document.querySelector('.nav-tab-active') expect(activeTab).toBeTruthy() expect(activeTab?.textContent).toBe('Apps') }) }) // ─── Auto-focus Behavior ───────────────────────────────────────── describe('useControllerNav - auto-focus', () => { afterEach(() => { document.body.innerHTML = '' }) it('first container in main zone is the auto-focus target', () => { document.body.innerHTML = `
Card 1
Card 2
` const mainZone = document.querySelector('[data-controller-zone="main"]') const firstContainer = mainZone?.querySelector('[data-controller-container]') expect(firstContainer?.id).toBe('first') }) it('does not auto-focus when input is active', () => { document.body.innerHTML = `
Card
` const input = document.getElementById('search') as HTMLInputElement input.focus() // Auto-focus should skip when input is active expect(document.activeElement?.tagName).toBe('INPUT') }) }) // ─── Tab Roving Behavior ───────────────────────────────────────── describe('useControllerNav - tab roving', () => { afterEach(() => { document.body.innerHTML = '' }) it('role="tab" elements are found as siblings within tablist', () => { document.body.innerHTML = `
` const tabs = document.querySelectorAll('[role="tab"]') expect(tabs.length).toBe(2) const tablist = tabs[0]?.closest('[role="tablist"]') expect(tablist).toBeTruthy() }) it('tab roving cycles right: first → second → first', () => { const tabs = ['tab1', 'tab2'] // Right from index 0 expect((0 + 1) % tabs.length).toBe(1) // Right from index 1 (wraps) expect((1 + 1) % tabs.length).toBe(0) }) it('tab roving cycles left: second → first → second', () => { const tabs = ['tab1', 'tab2'] // Left from index 1 expect((1 - 1 + tabs.length) % tabs.length).toBe(0) // Left from index 0 (wraps) expect((0 - 1 + tabs.length) % tabs.length).toBe(1) }) it('tab roving falls back to parent when no role="tablist" wrapper', () => { document.body.innerHTML = `
` const tab = document.getElementById('tab1')! // No role="tablist" — falls back to parentElement const tablist = tab.closest('[role="tablist"]') ?? tab.parentElement expect(tablist).toBeTruthy() const tabs = tablist!.querySelectorAll('[role="tab"]:not([disabled])') expect(tabs.length).toBe(2) }) }) // ─── Scroll Behavior ────────────────────────────────────────────── describe('useControllerNav - scroll helpers', () => { it('focused elements have scrollIntoView method', () => { document.body.innerHTML = `
Card 1
` const card = document.querySelector('[data-controller-container]') as HTMLElement // jsdom provides scrollIntoView as a no-op expect(card).toBeTruthy() expect(card.focus).toBeDefined() }) }) // ─── Container Grid Navigation ──────────────────────────────────── describe('useControllerNav - grid navigation patterns', () => { afterEach(() => { document.body.innerHTML = '' }) it('marketplace 3-column grid has correct spatial relationships', () => { // Simulate a 3-column grid (like marketplace) document.body.innerHTML = `
App 1
App 2
App 3
App 4
App 5
App 6
` const containers = document.querySelectorAll('[data-controller-container]') expect(containers.length).toBe(6) // Row 1: c1, c2, c3; Row 2: c4, c5, c6 expect(containers[0]?.id).toBe('c1') expect(containers[3]?.id).toBe('c4') }) it('home 2-column grid has correct container count', () => { document.body.innerHTML = `
My Apps
Wallet
System
` const containers = document.querySelectorAll('[data-controller-container]') expect(containers.length).toBe(3) }) })