/** * Tests for useControllerNav — validates against GAMEPAD-NAV-MAP.md * * Tests the navigation logic (element queries, spatial nav, zone detection) * without mounting the composable (which needs Vue lifecycle). */ import { describe, it, expect, vi, afterEach } from 'vitest' // ─── Mocks ───────────────────────────────────────────────────── vi.mock('vue-router', () => ({ useRoute: () => ({ path: '/dashboard' }), useRouter: () => ({ push: vi.fn().mockResolvedValue(undefined) }), })) vi.mock('@/stores/controller', () => ({ useControllerStore: () => ({ setActive: vi.fn(), setGamepadCount: vi.fn() }) })) 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() }) })) vi.mock('@/composables/useNavSounds', () => ({ playNavSound: vi.fn() })) // ─── Helpers ─────────────────────────────────────────────────── const FOCUSABLE_SELECTOR = [ 'a[href]', 'button:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])', '[data-controller-focus]', '[data-controller-container]', ].join(', ') function queryFocusable(root: HTMLElement | Document = document): HTMLElement[] { return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter( el => !el.hasAttribute('data-controller-ignore') && !el.closest('[data-controller-ignore]') ) } function queryContainers(): HTMLElement[] { const zone = document.querySelector('[data-controller-zone="main"]') if (!zone) return [] return Array.from(zone.querySelectorAll('[data-controller-container]')) } function queryNavBarItems(): HTMLElement[] { const zone = document.querySelector('[data-controller-zone="main"]') if (!zone) return [] return queryFocusable(zone as HTMLElement).filter(el => !el.hasAttribute('data-controller-container') && !el.closest('[data-controller-container]') ) } function querySidebar(): HTMLElement[] { const zone = document.querySelector('[data-controller-zone="sidebar"]') return zone ? queryFocusable(zone as HTMLElement) : [] } // ─── Module Export ────────────────────────────────────────────── describe('module', () => { it('exports useControllerNav', async () => { const mod = await import('../useControllerNav') expect(typeof mod.useControllerNav).toBe('function') }) }) // ─── SIDEBAR: Up/Down wrap, Right→container, Left→nothing ────── describe('sidebar navigation (NAV-MAP: Sidebar)', () => { afterEach(() => { document.body.innerHTML = '' }) it('finds all sidebar nav items', () => { document.body.innerHTML = `
Home Apps Cloud
Card
` expect(querySidebar().length).toBe(5) }) it('wraps down: Logout → Home', () => { const items = ['Home', 'Apps', 'Cloud', 'Logout'] const lastIdx = items.length - 1 expect((lastIdx + 1) % items.length).toBe(0) // wraps to Home }) it('wraps up: Home → Logout', () => { const items = ['Home', 'Apps', 'Cloud', 'Logout'] expect((0 - 1 + items.length) % items.length).toBe(items.length - 1) // wraps to Logout }) it('right from sidebar targets first container, not nav bar items', () => { document.body.innerHTML = `
Home
Card
` const containers = queryContainers() expect(containers[0]?.id).toBe('card1') }) it('left from sidebar does nothing (no target exists)', () => { document.body.innerHTML = `
Home
` const sidebar = querySidebar() const el = sidebar[0]! // Nothing to the left of sidebar expect(el.closest('[data-controller-zone="sidebar"]')).toBeTruthy() }) }) // ─── HOME: 2-col grid + nav bar ──────────────────────────────── describe('HOME grid (NAV-MAP: HOME /dashboard)', () => { afterEach(() => { document.body.innerHTML = '' }) it('has Dashboard and Setup nav bar items', () => { document.body.innerHTML = `
My Apps
Cloud
` const navItems = queryNavBarItems() expect(navItems.length).toBe(2) expect(navItems[0]?.id).toBe('dashTab') expect(navItems[1]?.id).toBe('setupTab') }) it('containers exclude nav bar items', () => { document.body.innerHTML = `
My Apps
Cloud
Network
Wallet
System
` const containers = queryContainers() expect(containers.length).toBe(5) expect(containers.map(c => c.id)).toEqual(['myApps', 'cloud', 'network', 'wallet', 'system']) // Nav bar items are separate const navItems = queryNavBarItems() expect(navItems.length).toBe(2) }) it('inner controls are not in the container grid', () => { document.body.innerHTML = `
Go
` // Only 1 container in grid expect(queryContainers().length).toBe(1) // Nav bar is empty (all focusables are inside the container) expect(queryNavBarItems().length).toBe(0) }) }) // ─── APPS: 3-col grid + nav bar with tabs/filters/search ─────── describe('APPS grid (NAV-MAP: APPS /dashboard/apps)', () => { afterEach(() => { document.body.innerHTML = '' }) it('nav bar has tabs, filters, and search', () => { document.body.innerHTML = `
App Store
App1
App2
App3
` const navItems = queryNavBarItems() // 3 tabs + 2 filters + 1 search = 6 nav bar items expect(navItems.length).toBe(6) expect(navItems.map(el => el.id)).toEqual(['myAppsTab', 'storeTab', 'servicesTab', 'allFilter', 'btcFilter', 'search']) // 3 containers expect(queryContainers().length).toBe(3) }) it('app cards with launch attribute are containers', () => { document.body.innerHTML = `
` const containers = queryContainers() expect(containers.length).toBe(1) expect(containers[0]?.hasAttribute('data-controller-launch')).toBe(true) const launchBtn = containers[0]?.querySelector('[data-controller-launch-btn]') expect(launchBtn).toBeTruthy() }) }) // ─── CLOUD: 3-col, no nav bar ────────────────────────────────── describe('CLOUD grid (NAV-MAP: CLOUD /dashboard/cloud)', () => { afterEach(() => { document.body.innerHTML = '' }) it('has section cards as containers, no nav bar', () => { document.body.innerHTML = `
Photos
Music
Documents
Files
` expect(queryContainers().length).toBe(4) expect(queryNavBarItems().length).toBe(0) }) }) // ─── NETWORK: 2-col ──────────────────────────────────────────── describe('NETWORK grid (NAV-MAP: NETWORK /dashboard/server)', () => { afterEach(() => { document.body.innerHTML = '' }) it('has Local Network and Web3 containers', () => { document.body.innerHTML = `
Local Network
Web3
` const containers = queryContainers() expect(containers.length).toBe(2) expect(containers[0]?.id).toBe('localNet') expect(containers[1]?.id).toBe('web3') }) }) // ─── SETTINGS: vertical stack ────────────────────────────────── describe('SETTINGS grid (NAV-MAP: SETTINGS /dashboard/settings)', () => { afterEach(() => { document.body.innerHTML = '' }) it('has stacked section containers', () => { document.body.innerHTML = `
Account Info
Change Password
Two-Factor
System Info
Danger Zone
` const containers = queryContainers() expect(containers.length).toBe(5) // No nav bar expect(queryNavBarItems().length).toBe(0) }) }) // ─── ENTER behavior ──────────────────────────────────────────── describe('enter key behavior (NAV-MAP: Rules 5)', () => { afterEach(() => { document.body.innerHTML = '' }) it('container with primary link: Enter should navigate', () => { document.body.innerHTML = `
Go
` const container = document.querySelector('[data-controller-container]')! const link = container.querySelector('a[href]') expect(link).toBeTruthy() expect(link?.getAttribute('href')).toBe('/dashboard/apps') }) it('container without link: Enter drills into inner [Y] controls', () => { document.body.innerHTML = `
` const container = document.querySelector('[data-controller-container]')! expect(container.querySelector('a[href]')).toBeNull() const inner = Array.from(container.querySelectorAll('button')) expect(inner.length).toBe(2) }) it('install container: Enter clicks install button', () => { document.body.innerHTML = `
` const container = document.querySelector('[data-controller-container]')! expect(container.hasAttribute('data-controller-install')).toBe(true) expect(container.querySelector('[data-controller-install-btn]')).toBeTruthy() }) it('launch container: Enter clicks launch button', () => { document.body.innerHTML = `
` const container = document.querySelector('[data-controller-container]')! expect(container.hasAttribute('data-controller-launch')).toBe(true) expect(container.querySelector('[data-controller-launch-btn]')).toBeTruthy() }) }) // ─── INSIDE CONTAINER [Y] ────────────────────────────────────── describe('inside container navigation (NAV-MAP: Rules 6)', () => { afterEach(() => { document.body.innerHTML = '' }) it('inner controls are isolated from other containers', () => { document.body.innerHTML = `
` const card1 = document.getElementById('card1')! const inner = queryFocusable(card1).filter(el => el !== card1 && !el.hasAttribute('data-controller-container')) expect(inner.length).toBe(2) expect(inner.map(el => el.id)).toEqual(['stop', 'restart']) // "other" is NOT in card1's inner controls expect(inner.find(el => el.id === 'other')).toBeUndefined() }) it('escape from inner control returns to container', () => { document.body.innerHTML = `
` const inner = document.getElementById('inner')! const container = inner.closest('[data-controller-container]') expect(container).toBeTruthy() expect(container?.id).toBe('card') expect(container?.getAttribute('tabindex')).toBe('0') }) it('isInsideContainer is true for nested, false for container itself', () => { document.body.innerHTML = `
` const inside = document.getElementById('inside')! const outside = document.getElementById('outside')! const card = document.getElementById('card')! // inside: has container ancestor that isn't itself const insideContainer = inside.closest('[data-controller-container]') expect(insideContainer && insideContainer !== inside).toBe(true) // card: IS the container expect(card.hasAttribute('data-controller-container')).toBe(true) // outside: no container ancestor expect(outside.closest('[data-controller-container]')).toBeNull() }) }) // ─── TEXT INPUT handling ─────────────────────────────────────── describe('text input handling (NAV-MAP: text inputs)', () => { afterEach(() => { document.body.innerHTML = '' }) it('up/down exits input, left/right stays', () => { const exitKeys = ['ArrowUp', 'ArrowDown'] const stayKeys = ['ArrowLeft', 'ArrowRight'] exitKeys.forEach(k => expect(['ArrowUp', 'ArrowDown'].includes(k)).toBe(true)) stayKeys.forEach(k => expect(['ArrowUp', 'ArrowDown'].includes(k)).toBe(false)) }) it('enter on password clicks next button (submit)', () => { document.body.innerHTML = ` ` const all = queryFocusable() const passIdx = all.findIndex(el => el.id === 'pass') const next = all[passIdx + 1] expect(next?.tagName).toBe('BUTTON') expect(next?.id).toBe('login') }) }) // ─── FOCUS MEMORY ────────────────────────────────────────────── describe('focus memory (NAV-MAP: zone transitions)', () => { afterEach(() => { document.body.innerHTML = '' }) it('remembers and recalls elements', () => { document.body.innerHTML = `` const memory = new Map() const btn = document.getElementById('btn')! memory.set('main', btn) expect(memory.get('main')).toBe(btn) expect(document.contains(btn)).toBe(true) }) it('detects stale (removed) elements', () => { document.body.innerHTML = `` const memory = new Map() const btn = document.getElementById('btn')! memory.set('main', btn) btn.remove() expect(document.contains(memory.get('main')!)).toBe(false) }) it('clears on route change', () => { const memory = new Map() document.body.innerHTML = `` memory.set('main', document.getElementById('btn')!) memory.delete('main') expect(memory.get('main')).toBeUndefined() }) }) // ─── SPATIAL NAVIGATION ──────────────────────────────────────── describe('spatial navigation', () => { it('overlap scoring: aligned > offset', () => { const from = { top: 50, bottom: 200, left: 0, right: 150 } const aligned = { top: 50, bottom: 200, left: 200, right: 350 } const offset = { top: 160, bottom: 310, left: 200, right: 350 } const alignedOv = Math.max(0, Math.min(from.bottom, aligned.bottom) - Math.max(from.top, aligned.top)) const offsetOv = Math.max(0, Math.min(from.bottom, offset.bottom) - Math.max(from.top, offset.top)) expect(alignedOv).toBe(150) expect(offsetOv).toBe(40) expect(alignedOv).toBeGreaterThan(offsetOv) }) it('tiebreaker: up/down prefers leftmost', () => { // Two elements below, same distance, same overlap const a = { left: 0 } const b = { left: 200 } // Sort: leftmost wins expect(a.left - b.left).toBeLessThan(0) // a is leftmost }) it('no wrap in 2D grid (NAV-MAP: Rules 2)', () => { // At rightmost column, pressing right should find nothing const from = { left: 400, right: 600, top: 0, bottom: 200 } const threshold = 50 // No element to the right const candidate = { left: 0, right: 150 } // far left expect(candidate.left >= from.right - threshold).toBe(false) // NOT to the right }) }) // ─── GAMEPAD DETECTION ───────────────────────────────────────── describe('gamepad detection', () => { it('counts connected gamepads', () => { const gp = [{ connected: true }, null, { connected: true }, null] as (Gamepad | null)[] expect(gp.filter(g => g?.connected).length).toBe(2) }) it('handles null list', () => { const count = (gp: (Gamepad | null)[] | null) => gp ? gp.filter(g => g?.connected).length : 0 expect(count(null)).toBe(0) }) }) // ─── DATA-CONTROLLER-IGNORE ──────────────────────────────────── describe('data-controller-ignore', () => { afterEach(() => { document.body.innerHTML = '' }) it('excluded elements are filtered out', () => { document.body.innerHTML = `
` const all = queryFocusable() expect(all.length).toBe(1) expect(all[0]?.id).toBe('real') }) }) // ─── NAV BAR [N] DETECTION ───────────────────────────────────── describe('nav bar detection', () => { afterEach(() => { document.body.innerHTML = '' }) it('nav bar items are in main zone but not inside containers', () => { document.body.innerHTML = `
` const navItems = queryNavBarItems() expect(navItems.length).toBe(2) expect(navItems[0]?.id).toBe('tab1') expect(navItems[1]?.id).toBe('tab2') // Inner button is NOT a nav bar item expect(navItems.find(el => el.id === 'inner')).toBeUndefined() }) it('pages without nav bar return empty', () => { document.body.innerHTML = `
Card
` expect(queryNavBarItems().length).toBe(0) }) }) // ─── DISCOVER: featured + grid ───────────────────────────────── describe('DISCOVER grid (NAV-MAP: DISCOVER /dashboard/discover)', () => { afterEach(() => { document.body.innerHTML = '' }) it('has nav bar + featured + app grid', () => { document.body.innerHTML = `
My Apps App Store
Featured 1
Featured 2
App 1
App 2
` expect(queryNavBarItems().length).toBe(2) expect(queryContainers().length).toBe(4) }) }) // ─── MESH / FLEET / SETTINGS containers exist ────────────────── describe('pages have containers (NAV-MAP: all pages)', () => { afterEach(() => { document.body.innerHTML = '' }) it('mesh has panel containers', () => { document.body.innerHTML = `
Device Status
Chat Panel
Peers
` expect(queryContainers().length).toBe(3) }) it('fleet has stat + node containers', () => { document.body.innerHTML = `
Nodes
Online
Offline
Health
Node 1
` expect(queryContainers().length).toBe(5) }) }) // ─── FULL FLOW: sidebar → container → inner → back ───────────── describe('full navigation flow (NAV-MAP: Rules 1-8)', () => { afterEach(() => { document.body.innerHTML = '' }) it('complete roundtrip: sidebar → container → inner → escape → sidebar', () => { document.body.innerHTML = `
Go
` // Step 1: Sidebar exists, has active tab const sidebar = querySidebar() expect(sidebar.length).toBe(2) const activeTab = document.querySelector('.nav-tab-active') as HTMLElement expect(activeTab?.id).toBe('sideHome') // Step 2: Right from sidebar → first container const containers = queryContainers() expect(containers[0]?.id).toBe('card1') // Step 3: Enter on card1 (no primary link) → drill into inner controls const card1 = document.getElementById('card1')! const inner = queryFocusable(card1).filter(el => el !== card1 && !el.hasAttribute('data-controller-container')) expect(inner.length).toBe(2) expect(inner[0]?.id).toBe('inner1') // Step 4: Escape from inner → back to card1 const innerEl = document.getElementById('inner1')! const parentContainer = innerEl.closest('[data-controller-container]') expect(parentContainer?.id).toBe('card1') // Step 5: Escape from card1 → sidebar active tab expect(activeTab?.id).toBe('sideHome') // Step 6: card2 has primary link → Enter navigates const card2 = document.getElementById('card2')! const primaryLink = card2.querySelector('a[href]') expect(primaryLink?.getAttribute('href')).toBe('/dashboard/cloud') }) it('no dead ends: every container can reach sidebar', () => { document.body.innerHTML = `
C1
C2
` // Every container is in main zone const containers = queryContainers() containers.forEach(c => { expect(c.closest('[data-controller-zone="main"]')).toBeTruthy() }) // Sidebar has at least one item expect(querySidebar().length).toBeGreaterThan(0) // Active tab exists for Left → sidebar expect(document.querySelector('.nav-tab-active')).toBeTruthy() }) })