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 = `
`
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 = `
Visible
Ignored
Also ignored
`
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 = `
Action
`
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 = `
`
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 = `
Stop
Launch
`
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 = `
Stop
Launch
`
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 = `
Action
Outside
`
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 = `
Launch
`
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 = `
Install
`
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 = `
`
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 = `
Action
`
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 = `
`
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 = `
`
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 = `
Dashboard
Setup
`
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 = `
Dashboard
Setup
`
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 = `
`
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 = `
`
const containers = document.querySelectorAll('[data-controller-container]')
expect(containers.length).toBe(3)
})
})