Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit 57a39f4b86
1642 changed files with 353148 additions and 0 deletions
@@ -0,0 +1,171 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useAudioPlayer } from '../useAudioPlayer'
// Mock HTMLAudioElement
let lastMockAudio: MockAudio | undefined
class MockAudio {
src = ''
currentTime = 0
duration = 120
paused = true
private listeners: Record<string, Array<() => void>> = {}
constructor() {
lastMockAudio = this
}
addEventListener(event: string, handler: () => void) {
if (!this.listeners[event]) this.listeners[event] = []
this.listeners[event].push(handler)
}
removeEventListener() {
// no-op for tests
}
shouldRejectPlay = false
play() {
if (this.shouldRejectPlay) {
return Promise.reject(new DOMException('no supported source', 'NotSupportedError'))
}
this.paused = false
this.emit('play')
return Promise.resolve()
}
pause() {
this.paused = true
this.emit('pause')
}
private emit(event: string) {
const handlers = this.listeners[event] || []
handlers.forEach(h => h())
}
// Helper to simulate events in tests
simulateEvent(event: string) {
this.emit(event)
}
}
vi.stubGlobal('Audio', MockAudio)
describe('useAudioPlayer', () => {
beforeEach(() => {
// Reset singleton state by stopping any active playback
const player = useAudioPlayer()
player.stop()
if (lastMockAudio) lastMockAudio.shouldRejectPlay = false
})
it('returns all expected properties', () => {
const player = useAudioPlayer()
expect(player.play).toBeTypeOf('function')
expect(player.pause).toBeTypeOf('function')
expect(player.seek).toBeTypeOf('function')
expect(player.stop).toBeTypeOf('function')
expect(player.playing).toBeDefined()
expect(player.currentName).toBeDefined()
expect(player.currentTime).toBeDefined()
expect(player.duration).toBeDefined()
expect(player.progress).toBeDefined()
expect(player.currentSrc).toBeDefined()
expect(player.error).toBeDefined()
})
it('starts in stopped state', () => {
const player = useAudioPlayer()
expect(player.playing.value).toBe(false)
expect(player.currentSrc.value).toBeNull()
expect(player.currentName.value).toBe('')
})
it('play sets playing state and current source', () => {
const player = useAudioPlayer()
player.play('/audio/test.mp3', 'Test Track')
expect(player.playing.value).toBe(true)
expect(player.currentSrc.value).toBe('/audio/test.mp3')
expect(player.currentName.value).toBe('Test Track')
})
it('play toggles pause when same source is playing', () => {
const player = useAudioPlayer()
player.play('/audio/test.mp3', 'Test')
expect(player.playing.value).toBe(true)
// Play same source again — should pause
player.play('/audio/test.mp3', 'Test')
expect(player.playing.value).toBe(false)
})
it('play switches to new source', () => {
const player = useAudioPlayer()
player.play('/audio/first.mp3', 'First')
player.play('/audio/second.mp3', 'Second')
expect(player.currentSrc.value).toBe('/audio/second.mp3')
expect(player.currentName.value).toBe('Second')
})
it('pause pauses playback', () => {
const player = useAudioPlayer()
player.play('/audio/test.mp3', 'Test')
player.pause()
expect(player.playing.value).toBe(false)
})
it('stop resets all state', () => {
const player = useAudioPlayer()
player.play('/audio/test.mp3', 'Test')
player.stop()
expect(player.playing.value).toBe(false)
expect(player.currentSrc.value).toBeNull()
expect(player.currentName.value).toBe('')
})
it('progress computes correctly', () => {
const player = useAudioPlayer()
expect(player.progress.value).toBe(0) // duration is 0
player.currentTime.value = 30
player.duration.value = 120
expect(player.progress.value).toBe(25) // 30/120 * 100
})
it('progress is 0 when duration is 0', () => {
const player = useAudioPlayer()
player.duration.value = 0
player.currentTime.value = 10
expect(player.progress.value).toBe(0)
})
it('play() rejection is caught, not left as an unhandled promise rejection', async () => {
// Regression: play() rejects independently of the 'error' event (e.g. a
// peer-content 404 with no decodable source) — this used to be an
// unhandled rejection in the browser console even though the 'error'
// listener already set a friendly message (2026-07-01).
const player = useAudioPlayer()
// Initialize the singleton Audio element first (a no-op play call).
player.play('/audio/warmup.mp3', 'Warmup')
player.stop()
lastMockAudio!.shouldRejectPlay = true
// Calling play() must not throw synchronously nor leave a rejected
// promise unhandled — if useAudioPlayer's play() didn't .catch() the
// rejection, `loading` would never flip back to false, since nothing
// else resets it on this path (that's the real regression signal).
expect(() => player.play('/audio/broken.mp3', 'Broken')).not.toThrow()
await Promise.resolve()
await Promise.resolve()
expect(player.loading.value).toBe(false)
})
it('shared state across multiple useAudioPlayer calls', () => {
const p1 = useAudioPlayer()
const p2 = useAudioPlayer()
p1.play('/audio/shared.mp3', 'Shared')
expect(p2.currentSrc.value).toBe('/audio/shared.mp3')
expect(p2.playing.value).toBe(true)
})
})
@@ -0,0 +1,63 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { ref, nextTick } from 'vue'
import { useContainersScanTimeout } from '../useContainersScanTimeout'
describe('useContainersScanTimeout', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('reflects the real scanned flag when it arrives before the timeout', async () => {
const scanned = ref(false)
const loaded = ref(true)
const { effectiveContainersScanned, scanTimedOut } = useContainersScanTimeout(scanned, loaded, 20_000)
expect(effectiveContainersScanned.value).toBe(false)
scanned.value = true
await nextTick()
expect(effectiveContainersScanned.value).toBe(true)
expect(scanTimedOut.value).toBe(false)
})
it('does not start the timeout until initial data has loaded', async () => {
const scanned = ref(false)
const loaded = ref(false)
const { effectiveContainersScanned } = useContainersScanTimeout(scanned, loaded, 20_000)
vi.advanceTimersByTime(60_000)
expect(effectiveContainersScanned.value).toBe(false)
loaded.value = true
await nextTick()
vi.advanceTimersByTime(20_000)
expect(effectiveContainersScanned.value).toBe(true)
})
it('falls through after the timeout even if the flag never arrives', async () => {
const scanned = ref(false)
const loaded = ref(true)
const { effectiveContainersScanned, scanTimedOut } = useContainersScanTimeout(scanned, loaded, 20_000)
vi.advanceTimersByTime(19_999)
expect(effectiveContainersScanned.value).toBe(false)
vi.advanceTimersByTime(1)
expect(effectiveContainersScanned.value).toBe(true)
expect(scanTimedOut.value).toBe(true)
})
it('cancels the escape hatch when the real flag arrives', async () => {
const scanned = ref(false)
const loaded = ref(true)
const { scanTimedOut } = useContainersScanTimeout(scanned, loaded, 20_000)
vi.advanceTimersByTime(10_000)
scanned.value = true
await nextTick()
vi.advanceTimersByTime(60_000)
expect(scanTimedOut.value).toBe(false)
})
})
@@ -0,0 +1,671 @@
/**
* 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<HTMLElement>(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<HTMLElement>('[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 = `
<div data-controller-zone="sidebar">
<a href="/dashboard">Home</a>
<a href="/dashboard/apps">Apps</a>
<a href="/dashboard/cloud">Cloud</a>
<button>AIUI</button>
<button>Logout</button>
</div>
<div data-controller-zone="main">
<div data-controller-container tabindex="0">Card</div>
</div>
`
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 = `
<div data-controller-zone="sidebar"><a href="/">Home</a></div>
<div data-controller-zone="main">
<button class="mode-switcher-btn" id="tab">Tab</button>
<div data-controller-container tabindex="0" id="card1">Card</div>
</div>
`
const containers = queryContainers()
expect(containers[0]?.id).toBe('card1')
})
it('left from sidebar does nothing (no target exists)', () => {
document.body.innerHTML = `
<div data-controller-zone="sidebar"><a href="/">Home</a></div>
`
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 = `
<div data-controller-zone="main">
<div role="tablist">
<button role="tab" class="mode-switcher-btn" id="dashTab">Dashboard</button>
<button role="tab" class="mode-switcher-btn" id="setupTab">Setup</button>
</div>
<div data-controller-container tabindex="0" id="myApps">My Apps</div>
<div data-controller-container tabindex="0" id="cloud">Cloud</div>
</div>
`
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 = `
<div data-controller-zone="main">
<button class="mode-switcher-btn">Dashboard</button>
<button class="mode-switcher-btn">Setup</button>
<div data-controller-container tabindex="0" id="myApps">My Apps</div>
<div data-controller-container tabindex="0" id="cloud">Cloud</div>
<div data-controller-container tabindex="0" id="network">Network</div>
<div data-controller-container tabindex="0" id="wallet">Wallet</div>
<div data-controller-container tabindex="0" id="system">System</div>
</div>
`
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 = `
<div data-controller-zone="main">
<div data-controller-container tabindex="0" id="myApps">
<a href="/dashboard/apps">Go</a>
<button id="browseStore">Browse Store</button>
<button id="manageApps">Manage Apps</button>
</div>
</div>
`
// 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 = `
<div data-controller-zone="main">
<div class="mode-switcher">
<button class="mode-switcher-btn" id="myAppsTab">My Apps</button>
<a href="/dashboard/discover" class="mode-switcher-btn" id="storeTab">App Store</a>
<button class="mode-switcher-btn" id="servicesTab">Services</button>
</div>
<div class="mode-switcher">
<button class="mode-switcher-btn" id="allFilter">All</button>
<button class="mode-switcher-btn" id="btcFilter">Bitcoin</button>
</div>
<input type="text" id="search" />
<div data-controller-container tabindex="0" id="app1">App1</div>
<div data-controller-container tabindex="0" id="app2">App2</div>
<div data-controller-container tabindex="0" id="app3">App3</div>
</div>
`
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 = `
<div data-controller-zone="main">
<div data-controller-container data-controller-launch tabindex="0" id="app1">
<button data-controller-launch-btn>Launch</button>
</div>
</div>
`
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 = `
<div data-controller-zone="main">
<div data-controller-container tabindex="0" id="photos">Photos</div>
<div data-controller-container tabindex="0" id="music">Music</div>
<div data-controller-container tabindex="0" id="docs">Documents</div>
<div data-controller-container tabindex="0" id="files">Files</div>
</div>
`
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 = `
<div data-controller-zone="main">
<div data-controller-container tabindex="0" id="localNet">Local Network</div>
<div data-controller-container tabindex="0" id="web3">Web3</div>
</div>
`
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 = `
<div data-controller-zone="main">
<div data-controller-container tabindex="0" id="account">Account Info</div>
<div data-controller-container tabindex="0" id="password">Change Password</div>
<div data-controller-container tabindex="0" id="twofa">Two-Factor</div>
<div data-controller-container tabindex="0" id="system">System Info</div>
<div data-controller-container tabindex="0" id="danger">Danger Zone</div>
</div>
`
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 = `
<div data-controller-container tabindex="0">
<a href="/dashboard/apps" id="link">Go</a>
<button>Browse</button>
</div>
`
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 = `
<div data-controller-container tabindex="0">
<button id="btn1">Open Shop</button>
<button id="btn2">Accept Payments</button>
</div>
`
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 = `
<div data-controller-container data-controller-install tabindex="0">
<button data-controller-install-btn>Install</button>
</div>
`
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 = `
<div data-controller-container data-controller-launch tabindex="0">
<button data-controller-launch-btn>Launch</button>
</div>
`
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 = `
<div data-controller-container tabindex="0" id="card1">
<button id="stop">Stop</button>
<button id="restart">Restart</button>
</div>
<div data-controller-container tabindex="0" id="card2">
<button id="other">Other</button>
</div>
`
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 = `
<div data-controller-container tabindex="0" id="card">
<button id="inner">Action</button>
</div>
`
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 = `
<div data-controller-container tabindex="0" id="card">
<button id="inside">In</button>
</div>
<button id="outside">Out</button>
`
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 = `
<input id="pass" type="password" />
<button id="login">Login</button>
`
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 = `<button id="btn">Test</button>`
const memory = new Map<string, HTMLElement>()
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 = `<button id="btn">Test</button>`
const memory = new Map<string, HTMLElement>()
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<string, HTMLElement>()
document.body.innerHTML = `<button id="btn">Test</button>`
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 = `
<button data-controller-ignore>Skip</button>
<div data-controller-ignore><button>Nested ignored</button></div>
<button id="real">Real</button>
`
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 = `
<div data-controller-zone="main">
<button class="mode-switcher-btn" id="tab1">Dashboard</button>
<button class="mode-switcher-btn" id="tab2">Setup</button>
<div data-controller-container tabindex="0" id="card">
<button id="inner">Inner</button>
</div>
</div>
`
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 = `
<div data-controller-zone="main">
<div data-controller-container tabindex="0">Card</div>
</div>
`
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 = `
<div data-controller-zone="main">
<a href="/dashboard/apps" class="mode-switcher-btn" id="myApps">My Apps</a>
<a href="/dashboard/discover" class="mode-switcher-btn" id="appStore">App Store</a>
<div data-controller-container data-controller-install tabindex="0" id="feat1">Featured 1</div>
<div data-controller-container data-controller-install tabindex="0" id="feat2">Featured 2</div>
<div data-controller-container data-controller-install tabindex="0" id="app1">App 1</div>
<div data-controller-container data-controller-install tabindex="0" id="app2">App 2</div>
</div>
`
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 = `
<div data-controller-zone="main">
<div data-controller-container tabindex="0" id="device">Device Status</div>
<div data-controller-container tabindex="0" id="chat">Chat Panel</div>
<div data-controller-container tabindex="0" id="peers">Peers</div>
</div>
`
expect(queryContainers().length).toBe(3)
})
it('fleet has stat + node containers', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<div data-controller-container tabindex="0">Nodes</div>
<div data-controller-container tabindex="0">Online</div>
<div data-controller-container tabindex="0">Offline</div>
<div data-controller-container tabindex="0">Health</div>
<div data-controller-container tabindex="0">Node 1</div>
</div>
`
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 = `
<div data-controller-zone="sidebar">
<a href="/dashboard" class="nav-tab-active" id="sideHome">Home</a>
<a href="/dashboard/apps" id="sideApps">Apps</a>
</div>
<div data-controller-zone="main">
<div data-controller-container tabindex="0" id="card1">
<button id="inner1">Browse</button>
<button id="inner2">Manage</button>
</div>
<div data-controller-container tabindex="0" id="card2">
<a href="/dashboard/cloud">Go</a>
</div>
</div>
`
// 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 = `
<div data-controller-zone="sidebar">
<a href="/" class="nav-tab-active">Home</a>
</div>
<div data-controller-zone="main">
<div data-controller-container tabindex="0" id="c1">C1</div>
<div data-controller-container tabindex="0" id="c2">C2</div>
</div>
`
// 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()
})
})
@@ -0,0 +1,202 @@
import { describe, it, expect } from 'vitest'
import { ref } from 'vue'
import { getFileCategory, useFileType, formatSize, formatDate } from '../useFileType'
describe('getFileCategory', () => {
it('returns folder for directories', () => {
expect(getFileCategory('', true)).toBe('folder')
expect(getFileCategory('jpg', true)).toBe('folder')
})
it('identifies image extensions', () => {
expect(getFileCategory('jpg', false)).toBe('image')
expect(getFileCategory('jpeg', false)).toBe('image')
expect(getFileCategory('png', false)).toBe('image')
expect(getFileCategory('gif', false)).toBe('image')
expect(getFileCategory('webp', false)).toBe('image')
expect(getFileCategory('svg', false)).toBe('image')
expect(getFileCategory('bmp', false)).toBe('image')
expect(getFileCategory('ico', false)).toBe('image')
})
it('identifies audio extensions', () => {
expect(getFileCategory('mp3', false)).toBe('audio')
expect(getFileCategory('flac', false)).toBe('audio')
expect(getFileCategory('wav', false)).toBe('audio')
expect(getFileCategory('ogg', false)).toBe('audio')
expect(getFileCategory('aac', false)).toBe('audio')
expect(getFileCategory('m4a', false)).toBe('audio')
})
it('identifies video extensions', () => {
expect(getFileCategory('mp4', false)).toBe('video')
expect(getFileCategory('mkv', false)).toBe('video')
expect(getFileCategory('avi', false)).toBe('video')
expect(getFileCategory('mov', false)).toBe('video')
expect(getFileCategory('webm', false)).toBe('video')
})
it('identifies document extensions', () => {
expect(getFileCategory('pdf', false)).toBe('document')
expect(getFileCategory('doc', false)).toBe('document')
expect(getFileCategory('docx', false)).toBe('document')
expect(getFileCategory('txt', false)).toBe('document')
expect(getFileCategory('md', false)).toBe('document')
})
it('identifies spreadsheet extensions', () => {
expect(getFileCategory('xls', false)).toBe('spreadsheet')
expect(getFileCategory('xlsx', false)).toBe('spreadsheet')
expect(getFileCategory('csv', false)).toBe('spreadsheet')
expect(getFileCategory('ods', false)).toBe('spreadsheet')
})
it('identifies archive extensions', () => {
expect(getFileCategory('zip', false)).toBe('archive')
expect(getFileCategory('tar', false)).toBe('archive')
expect(getFileCategory('gz', false)).toBe('archive')
expect(getFileCategory('rar', false)).toBe('archive')
expect(getFileCategory('7z', false)).toBe('archive')
})
it('returns file for unknown extensions', () => {
expect(getFileCategory('xyz', false)).toBe('file')
expect(getFileCategory('', false)).toBe('file')
expect(getFileCategory('bin', false)).toBe('file')
})
})
describe('useFileType', () => {
it('returns correct category and computed values for an image', () => {
const ext = ref('jpg')
const isDir = ref(false)
const result = useFileType(ext, isDir)
expect(result.category.value).toBe('image')
expect(result.isImage.value).toBe(true)
expect(result.isAudio.value).toBe(false)
expect(result.isVideo.value).toBe(false)
expect(result.iconColor.value).toBe('text-blue-400')
expect(result.badgeLabel.value).toBe('Image')
})
it('returns correct values for audio', () => {
const ext = ref('mp3')
const isDir = ref(false)
const result = useFileType(ext, isDir)
expect(result.category.value).toBe('audio')
expect(result.isAudio.value).toBe(true)
expect(result.isImage.value).toBe(false)
expect(result.iconColor.value).toBe('text-orange-400')
expect(result.badgeLabel.value).toBe('Audio')
})
it('returns correct values for video', () => {
const ext = ref('mp4')
const isDir = ref(false)
const result = useFileType(ext, isDir)
expect(result.category.value).toBe('video')
expect(result.isVideo.value).toBe(true)
expect(result.iconColor.value).toBe('text-purple-400')
})
it('returns folder when isDir is true', () => {
const ext = ref('jpg')
const isDir = ref(true)
const result = useFileType(ext, isDir)
expect(result.category.value).toBe('folder')
expect(result.isImage.value).toBe(false)
expect(result.iconColor.value).toBe('text-amber-400')
expect(result.badgeLabel.value).toBe('Folder')
})
it('reacts to ref changes', () => {
const ext = ref('jpg')
const isDir = ref(false)
const result = useFileType(ext, isDir)
expect(result.category.value).toBe('image')
ext.value = 'mp3'
expect(result.category.value).toBe('audio')
expect(result.isAudio.value).toBe(true)
expect(result.isImage.value).toBe(false)
})
it('provides icon paths for each category', () => {
const ext = ref('pdf')
const isDir = ref(false)
const result = useFileType(ext, isDir)
expect(result.iconPaths.value).toBeDefined()
expect(result.iconPaths.value.length).toBeGreaterThan(0)
})
it('provides badge class for each category', () => {
const ext = ref('zip')
const isDir = ref(false)
const result = useFileType(ext, isDir)
expect(result.badgeClass.value).toContain('bg-yellow')
})
})
describe('formatSize', () => {
it('formats 0 bytes', () => {
expect(formatSize(0)).toBe('0 B')
})
it('formats bytes', () => {
expect(formatSize(500)).toBe('500 B')
})
it('formats kilobytes', () => {
expect(formatSize(1024)).toBe('1.0 KB')
expect(formatSize(1536)).toBe('1.5 KB')
})
it('formats megabytes', () => {
expect(formatSize(1048576)).toBe('1.0 MB')
})
it('formats gigabytes', () => {
expect(formatSize(1073741824)).toBe('1.0 GB')
})
it('formats terabytes', () => {
expect(formatSize(1099511627776)).toBe('1.0 TB')
})
})
describe('formatDate', () => {
it('returns "Just now" for very recent dates', () => {
const now = new Date().toISOString()
expect(formatDate(now)).toBe('Just now')
})
it('returns minutes ago for recent dates', () => {
const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString()
expect(formatDate(fiveMinAgo)).toBe('5m ago')
})
it('returns hours ago for dates within 24h', () => {
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString()
expect(formatDate(threeHoursAgo)).toBe('3h ago')
})
it('returns days ago for dates within a week', () => {
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString()
expect(formatDate(twoDaysAgo)).toBe('2d ago')
})
it('returns formatted date for older dates', () => {
const oldDate = new Date('2025-01-15').toISOString()
const result = formatDate(oldDate)
// Should be a locale date string, not a relative time
expect(result).toMatch(/\d/)
expect(result).not.toContain('ago')
})
})
@@ -0,0 +1,211 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock Audio globally
class MockAudio {
src = ''
volume = 1
loop = false
currentTime = 0
play = vi.fn().mockResolvedValue(undefined)
pause = vi.fn()
addEventListener = vi.fn()
}
vi.stubGlobal('Audio', MockAudio)
// Mock fetch for playLoopStart
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
}))
// Mock AudioContext
const mockBufferSource = {
buffer: null as AudioBuffer | null,
connect: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
}
const mockMediaElementSource = {
connect: vi.fn(),
}
const mockGainNode = {
gain: {
value: 1,
setValueAtTime: vi.fn(),
linearRampToValueAtTime: vi.fn(),
exponentialRampToValueAtTime: vi.fn(),
},
connect: vi.fn(),
}
const mockAudioContext = {
state: 'running' as AudioContextState,
currentTime: 0,
destination: {},
resume: vi.fn().mockResolvedValue(undefined),
createOscillator: vi.fn().mockReturnValue({
type: 'sine',
frequency: { value: 440, setValueAtTime: vi.fn() },
connect: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
}),
createGain: vi.fn().mockReturnValue({ ...mockGainNode, gain: { ...mockGainNode.gain } }),
createBufferSource: vi.fn().mockReturnValue({ ...mockBufferSource }),
createMediaElementSource: vi.fn().mockReturnValue({ ...mockMediaElementSource }),
decodeAudioData: vi.fn().mockResolvedValue({} as AudioBuffer),
}
vi.stubGlobal('AudioContext', vi.fn().mockImplementation(() => ({ ...mockAudioContext })))
import {
playPop,
playLoginSuccessWhoosh,
playTypingSound,
playIntroTyping,
stopIntroTyping,
playWelcomeNoderunnerSpeech,
playTypingTick,
resumeAudioContext,
startSynthwave,
stopSynthwave,
playLoopStart,
playKeyboardTypingSound,
playDashboardLoadOomph,
} from '../useLoginSounds'
describe('useLoginSounds', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('playPop', () => {
it('creates Audio with pop.mp3 and plays it', () => {
playPop()
// Audio constructor was called (via MockAudio)
expect(MockAudio.prototype.constructor).toBeDefined()
})
it('does not throw', () => {
expect(() => playPop()).not.toThrow()
})
})
describe('playLoginSuccessWhoosh', () => {
it('does not throw', () => {
expect(() => playLoginSuccessWhoosh()).not.toThrow()
})
})
describe('playTypingSound', () => {
it('does not throw', () => {
expect(() => playTypingSound()).not.toThrow()
})
})
describe('playIntroTyping', () => {
it('does not throw', () => {
expect(() => playIntroTyping()).not.toThrow()
})
it('creates a looping audio element', () => {
playIntroTyping()
// Does not throw, creates audio
})
})
describe('stopIntroTyping', () => {
it('does not throw when no audio playing', () => {
expect(() => stopIntroTyping()).not.toThrow()
})
it('stops audio that was started', () => {
playIntroTyping()
expect(() => stopIntroTyping()).not.toThrow()
})
})
describe('playWelcomeNoderunnerSpeech', () => {
it('does not throw', () => {
expect(() => playWelcomeNoderunnerSpeech()).not.toThrow()
})
})
describe('playTypingTick', () => {
it('does not throw', () => {
expect(() => playTypingTick()).not.toThrow()
})
it('can be called multiple times (pool rotation)', () => {
for (let i = 0; i < 10; i++) {
expect(() => playTypingTick()).not.toThrow()
}
})
})
describe('resumeAudioContext', () => {
it('does not throw', () => {
expect(() => resumeAudioContext()).not.toThrow()
})
})
describe('startSynthwave', () => {
it('does not throw when no audio context', () => {
// Without calling resumeAudioContext first, context might be null
expect(() => startSynthwave()).not.toThrow()
})
})
describe('stopSynthwave', () => {
it('does not throw when nothing is playing', () => {
expect(() => stopSynthwave()).not.toThrow()
})
})
describe('playLoopStart', () => {
it('does not throw when no audio context', () => {
expect(() => playLoopStart()).not.toThrow()
})
})
describe('playKeyboardTypingSound', () => {
it('does not throw when no audio context', () => {
expect(() => playKeyboardTypingSound()).not.toThrow()
})
})
describe('playDashboardLoadOomph', () => {
it('does not throw when no audio context', () => {
expect(() => playDashboardLoadOomph()).not.toThrow()
})
})
describe('audio context lifecycle', () => {
it('resumeAudioContext then startSynthwave does not throw', () => {
resumeAudioContext()
expect(() => startSynthwave()).not.toThrow()
})
it('resumeAudioContext then stopSynthwave does not throw', () => {
resumeAudioContext()
expect(() => stopSynthwave()).not.toThrow()
})
it('resumeAudioContext then playKeyboardTypingSound does not throw', () => {
resumeAudioContext()
expect(() => playKeyboardTypingSound()).not.toThrow()
})
it('resumeAudioContext then playDashboardLoadOomph does not throw', () => {
resumeAudioContext()
expect(() => playDashboardLoadOomph()).not.toThrow()
})
it('resumeAudioContext then playLoopStart does not throw', () => {
resumeAudioContext()
expect(() => playLoopStart()).not.toThrow()
})
})
})
@@ -0,0 +1,113 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useMarketplaceApp } from '../useMarketplaceApp'
describe('useMarketplaceApp', () => {
beforeEach(() => {
const { clearCurrentApp } = useMarketplaceApp()
clearCurrentApp()
})
it('getCurrentApp returns null initially', () => {
const { getCurrentApp } = useMarketplaceApp()
expect(getCurrentApp()).toBeNull()
})
it('setCurrentApp stores a full app', () => {
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
setCurrentApp({
id: 'bitcoin',
title: 'Bitcoin Core',
version: '25.0',
icon: '/icons/btc.png',
category: 'Finance',
description: 'Bitcoin node',
author: 'Satoshi',
source: 'github',
manifestUrl: 'https://example.com/manifest',
url: 'https://example.com',
repoUrl: 'https://github.com/bitcoin/bitcoin',
s9pkUrl: '',
dockerImage: 'bitcoin:25.0',
})
const app = getCurrentApp()
expect(app).not.toBeNull()
expect(app!.id).toBe('bitcoin')
expect(app!.title).toBe('Bitcoin Core')
expect(app!.version).toBe('25.0')
})
it('setCurrentApp with partial app fills defaults', () => {
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
setCurrentApp({ id: 'lnd' })
const app = getCurrentApp()
expect(app).not.toBeNull()
expect(app!.id).toBe('lnd')
expect(app!.title).toBe('')
expect(app!.version).toBe('')
expect(app!.icon).toBe('')
expect(app!.dockerImage).toBe('')
})
it('manifestUrl falls back to s9pkUrl then url', () => {
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
setCurrentApp({ id: 'test', s9pkUrl: 'https://s9pk.example.com/app.s9pk' })
const app = getCurrentApp()
expect(app!.manifestUrl).toBe('https://s9pk.example.com/app.s9pk')
expect(app!.url).toBe('https://s9pk.example.com/app.s9pk')
})
it('url falls back to s9pkUrl then manifestUrl', () => {
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
setCurrentApp({ id: 'test', manifestUrl: 'https://manifest.example.com' })
const app = getCurrentApp()
expect(app!.url).toBe('https://manifest.example.com')
})
it('clearCurrentApp sets app to null', () => {
const { setCurrentApp, clearCurrentApp, getCurrentApp } = useMarketplaceApp()
setCurrentApp({ id: 'bitcoin' })
expect(getCurrentApp()).not.toBeNull()
clearCurrentApp()
expect(getCurrentApp()).toBeNull()
})
it('shared state across multiple useMarketplaceApp calls', () => {
const instance1 = useMarketplaceApp()
const instance2 = useMarketplaceApp()
instance1.setCurrentApp({ id: 'mempool', title: 'Mempool' })
const app = instance2.getCurrentApp()
expect(app!.id).toBe('mempool')
expect(app!.title).toBe('Mempool')
})
it('handles description as object', () => {
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
setCurrentApp({
id: 'test',
description: { short: 'Short desc', long: 'Long description' },
})
const app = getCurrentApp()
expect(app!.description).toEqual({ short: 'Short desc', long: 'Long description' })
})
it('preserves real screenshot metadata', () => {
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
setCurrentApp({
id: 'test',
screenshots: [
'/screenshots/test-dashboard.png',
{ src: '/screenshots/test-settings.png', alt: 'Settings view' },
],
})
expect(getCurrentApp()!.screenshots).toEqual([
'/screenshots/test-dashboard.png',
{ src: '/screenshots/test-settings.png', alt: 'Settings view' },
])
})
})
@@ -0,0 +1,178 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
const mockPush = vi.fn()
vi.mock('vue-router', () => ({
useRouter: () => ({ push: mockPush }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
getReceivedMessages: vi.fn(),
},
}))
import { useMessageToast } from '../useMessageToast'
import { rpcClient } from '@/api/rpc-client'
const mockedRpc = vi.mocked(rpcClient)
describe('useMessageToast', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
// Reset shared singleton state
const toast = useMessageToast()
toast.stopPolling()
toast.receivedMessages.value = []
toast.lastMessageCount.value = 0
toast.loadingMessages.value = false
toast.toastMessage.value = { show: false, text: '', fromPubkey: '' }
})
afterEach(() => {
const toast = useMessageToast()
toast.stopPolling()
vi.useRealTimers()
})
it('starts with empty state', () => {
const toast = useMessageToast()
expect(toast.receivedMessages.value).toEqual([])
expect(toast.lastMessageCount.value).toBe(0)
expect(toast.loadingMessages.value).toBe(false)
expect(toast.toastMessage.value.show).toBe(false)
expect(toast.unreadCount.value).toBe(0)
})
it('loadReceivedMessages fetches and stores messages', async () => {
mockedRpc.getReceivedMessages.mockResolvedValue({
messages: [
{ from_pubkey: 'abc', message: 'Hello', timestamp: '2026-01-01' },
],
})
const toast = useMessageToast()
await toast.loadReceivedMessages()
expect(toast.receivedMessages.value.length).toBe(1)
expect(toast.lastMessageCount.value).toBe(1)
expect(toast.loadingMessages.value).toBe(false)
})
it('does not show toast on initial load', async () => {
mockedRpc.getReceivedMessages.mockResolvedValue({
messages: [{ from_pubkey: 'a', message: 'Hi', timestamp: '2026-01-01' }],
})
const toast = useMessageToast()
await toast.loadReceivedMessages()
expect(toast.toastMessage.value.show).toBe(false)
})
it('shows toast when new messages arrive after initial load', async () => {
const toast = useMessageToast()
// Initial load
mockedRpc.getReceivedMessages.mockResolvedValue({
messages: [{ from_pubkey: 'a', message: 'First', timestamp: '2026-01-01' }],
})
await toast.loadReceivedMessages()
// New message arrives
mockedRpc.getReceivedMessages.mockResolvedValue({
messages: [
{ from_pubkey: 'a', message: 'First', timestamp: '2026-01-01' },
{ from_pubkey: 'b', message: 'Second', timestamp: '2026-01-02' },
],
})
await toast.loadReceivedMessages()
expect(toast.toastMessage.value.show).toBe(true)
expect(toast.toastMessage.value.text).toBe('Second')
})
it('shows count for multiple new messages', async () => {
const toast = useMessageToast()
// Initial load
mockedRpc.getReceivedMessages.mockResolvedValue({
messages: [{ from_pubkey: 'a', message: 'One', timestamp: '2026-01-01' }],
})
await toast.loadReceivedMessages()
// Multiple new messages
mockedRpc.getReceivedMessages.mockResolvedValue({
messages: [
{ from_pubkey: 'a', message: 'One', timestamp: '2026-01-01' },
{ from_pubkey: 'b', message: 'Two', timestamp: '2026-01-02' },
{ from_pubkey: 'c', message: 'Three', timestamp: '2026-01-03' },
],
})
await toast.loadReceivedMessages()
expect(toast.toastMessage.value.show).toBe(true)
expect(toast.toastMessage.value.text).toBe('2 new messages')
})
it('unreadCount reflects difference', async () => {
const toast = useMessageToast()
toast.receivedMessages.value = [
{ from_pubkey: 'a', message: 'Hi', timestamp: '2026-01-01' },
{ from_pubkey: 'b', message: 'Hey', timestamp: '2026-01-02' },
]
toast.lastMessageCount.value = 1
expect(toast.unreadCount.value).toBe(1)
})
it('unreadCount is never negative', () => {
const toast = useMessageToast()
toast.receivedMessages.value = []
toast.lastMessageCount.value = 5
expect(toast.unreadCount.value).toBe(0)
})
it('markAsRead syncs lastMessageCount', () => {
const toast = useMessageToast()
toast.receivedMessages.value = [
{ from_pubkey: 'a', message: 'Hi', timestamp: '2026-01-01' },
{ from_pubkey: 'b', message: 'Hey', timestamp: '2026-01-02' },
]
toast.lastMessageCount.value = 0
toast.markAsRead()
expect(toast.lastMessageCount.value).toBe(2)
expect(toast.unreadCount.value).toBe(0)
})
it('dismissToastAndOpenMessages clears toast and navigates', () => {
const toast = useMessageToast()
toast.toastMessage.value = { show: true, text: 'New message', fromPubkey: '' }
toast.dismissToastAndOpenMessages()
expect(toast.toastMessage.value.show).toBe(false)
expect(mockPush).toHaveBeenCalledWith('/dashboard/mesh')
})
it('stops polling on 401 error', async () => {
const toast = useMessageToast()
mockedRpc.getReceivedMessages.mockRejectedValue(new Error('401 Unauthorized'))
toast.startPolling()
// Wait for initial load triggered by startPolling
await vi.advanceTimersByTimeAsync(0)
// Polling should have stopped, so advancing time should NOT call again
vi.clearAllMocks()
await vi.advanceTimersByTimeAsync(60000)
expect(mockedRpc.getReceivedMessages).not.toHaveBeenCalled()
})
it('startPolling does not create duplicate timers', () => {
const toast = useMessageToast()
mockedRpc.getReceivedMessages.mockResolvedValue({ messages: [] })
toast.startPolling()
toast.startPolling()
toast.startPolling()
// Should only have one timer — verify by stopping and checking no more calls
toast.stopPolling()
})
})
@@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, nextTick } from 'vue'
import { useMobileBackButton } from '../useMobileBackButton'
// Helper component that uses the composable
const TestComponent = defineComponent({
setup() {
return useMobileBackButton()
},
template: '<div>{{ bottomPosition }}</div>',
})
describe('useMobileBackButton', () => {
let wrapper: ReturnType<typeof mount>
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
wrapper?.unmount()
vi.useRealTimers()
})
it('returns bottomPosition, bottomClass, and tabBarHeight', () => {
wrapper = mount(TestComponent)
const vm = wrapper.vm as unknown as {
bottomPosition: string
bottomClass: string
tabBarHeight: number
}
expect(typeof vm.bottomPosition).toBe('string')
expect(typeof vm.bottomClass).toBe('string')
expect(typeof vm.tabBarHeight).toBe('number')
})
it('defaults tabBarHeight to 72', () => {
wrapper = mount(TestComponent)
const vm = wrapper.vm as unknown as { tabBarHeight: number }
expect(vm.tabBarHeight).toBe(72)
})
it('computes bottomPosition as tabBarHeight + 8', () => {
wrapper = mount(TestComponent)
const vm = wrapper.vm as unknown as {
bottomPosition: string
tabBarHeight: number
}
expect(vm.bottomPosition).toBe('80px') // 72 + 8
})
it('computes bottomClass with Tailwind arbitrary value', () => {
wrapper = mount(TestComponent)
const vm = wrapper.vm as unknown as { bottomClass: string }
expect(vm.bottomClass).toBe('bottom-[80px]')
})
it('reads tabBar element if present', async () => {
// Create mock tab bar element
const tabBar = document.createElement('div')
tabBar.setAttribute('data-mobile-tab-bar', '')
Object.defineProperty(tabBar, 'offsetHeight', { value: 56 })
document.body.appendChild(tabBar)
wrapper = mount(TestComponent)
await nextTick()
const vm = wrapper.vm as unknown as { tabBarHeight: number }
expect(vm.tabBarHeight).toBe(56)
document.body.removeChild(tabBar)
})
it('falls back to CSS variable when no tab bar element', async () => {
document.documentElement.style.setProperty('--mobile-tab-bar-height', '64')
wrapper = mount(TestComponent)
await nextTick()
const vm = wrapper.vm as unknown as { tabBarHeight: number }
expect(vm.tabBarHeight).toBe(64)
document.documentElement.style.removeProperty('--mobile-tab-bar-height')
})
it('keeps default when no tab bar or CSS var', async () => {
wrapper = mount(TestComponent)
await nextTick()
const vm = wrapper.vm as unknown as { tabBarHeight: number }
// Should keep the default of 72
expect(vm.tabBarHeight).toBe(72)
})
it('cleans up observers on unmount', () => {
wrapper = mount(TestComponent)
const removeEventSpy = vi.spyOn(window, 'removeEventListener')
wrapper.unmount()
expect(removeEventSpy).toHaveBeenCalled()
removeEventSpy.mockRestore()
})
it('updates on window resize', async () => {
const tabBar = document.createElement('div')
tabBar.setAttribute('data-mobile-tab-bar', '')
Object.defineProperty(tabBar, 'offsetHeight', {
value: 48,
writable: true,
configurable: true,
})
document.body.appendChild(tabBar)
wrapper = mount(TestComponent)
await nextTick()
// Trigger resize
window.dispatchEvent(new Event('resize'))
await nextTick()
const vm = wrapper.vm as unknown as { tabBarHeight: number }
expect(vm.tabBarHeight).toBe(48)
document.body.removeChild(tabBar)
})
})
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref, nextTick } from 'vue'
import { mount } from '@vue/test-utils'
import { useModalKeyboard } from '../useModalKeyboard'
import { defineComponent } from 'vue'
// We need to test the composable inside a component
function createTestComponent(onCloseFn: () => void) {
return defineComponent({
setup() {
const containerRef = ref<HTMLElement | null>(null)
const isOpen = ref(false)
const restoreFocusRef = ref<HTMLElement | null>(null)
useModalKeyboard(containerRef, isOpen, onCloseFn, {
restoreFocusRef,
})
return { containerRef, isOpen, restoreFocusRef }
},
template: `
<div>
<button id="trigger">Trigger</button>
<div v-if="isOpen" ref="containerRef">
<button id="btn1">One</button>
<button id="btn2">Two</button>
<button id="btn3">Three</button>
</div>
</div>
`,
})
}
describe('useModalKeyboard', () => {
let closeFn: ReturnType<typeof vi.fn>
beforeEach(() => {
closeFn = vi.fn()
})
it('calls onClose when Escape is pressed and modal is open', async () => {
const Comp = createTestComponent(closeFn)
const wrapper = mount(Comp, { attachTo: document.body })
wrapper.vm.isOpen = true
await nextTick()
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
expect(closeFn).toHaveBeenCalledOnce()
wrapper.unmount()
})
it('does not call onClose when modal is closed', () => {
const Comp = createTestComponent(closeFn)
const wrapper = mount(Comp, { attachTo: document.body })
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
expect(closeFn).not.toHaveBeenCalled()
wrapper.unmount()
})
it('cleans up listener on unmount', () => {
const removeSpy = vi.spyOn(window, 'removeEventListener')
const Comp = createTestComponent(closeFn)
const wrapper = mount(Comp, { attachTo: document.body })
wrapper.unmount()
expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true)
removeSpy.mockRestore()
})
})
@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock Audio globally
class MockAudio {
src = ''
volume = 1
play = vi.fn().mockResolvedValue(undefined)
pause = vi.fn()
currentTime = 0
addEventListener = vi.fn()
}
vi.stubGlobal('Audio', MockAudio)
// Mock AudioContext
const mockOscillator = {
type: 'sine',
frequency: { setValueAtTime: vi.fn() },
connect: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
}
const mockGain = {
gain: {
setValueAtTime: vi.fn(),
linearRampToValueAtTime: vi.fn(),
exponentialRampToValueAtTime: vi.fn(),
},
connect: vi.fn(),
}
const mockAudioContext = {
createOscillator: vi.fn().mockReturnValue(mockOscillator),
createGain: vi.fn().mockReturnValue(mockGain),
currentTime: 0,
destination: {},
}
vi.stubGlobal('AudioContext', vi.fn().mockImplementation(() => mockAudioContext))
import { playNavSound } from '../useNavSounds'
describe('playNavSound', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('is a function', () => {
expect(playNavSound).toBeTypeOf('function')
})
it('plays move sound (default)', () => {
playNavSound()
// Should try to play a sound
})
it('plays move sound explicitly', () => {
playNavSound('move')
})
it('plays select sound', () => {
playNavSound('select')
})
it('plays action sound', () => {
playNavSound('action')
})
it('plays back sound using AudioContext', () => {
playNavSound('back')
// Back uses Web Audio API synthesis
})
it('does not throw for any sound type', () => {
expect(() => playNavSound('move')).not.toThrow()
expect(() => playNavSound('select')).not.toThrow()
expect(() => playNavSound('action')).not.toThrow()
expect(() => playNavSound('back')).not.toThrow()
})
})
@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
isOnboardingComplete: vi.fn(),
completeOnboarding: vi.fn(),
},
}))
import { isOnboardingComplete, completeOnboarding } from '../useOnboarding'
import { rpcClient } from '@/api/rpc-client'
const mockedRpc = vi.mocked(rpcClient)
describe('useOnboarding', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('isOnboardingComplete', () => {
it('returns true when RPC says complete', async () => {
mockedRpc.isOnboardingComplete.mockResolvedValue(true)
const result = await isOnboardingComplete()
expect(result).toBe(true)
})
it('returns false when RPC says not complete', async () => {
mockedRpc.isOnboardingComplete.mockResolvedValue(false)
const result = await isOnboardingComplete()
expect(result).toBe(false)
})
it('falls back to localStorage when RPC fails with non-retryable error', async () => {
mockedRpc.isOnboardingComplete.mockRejectedValue(new Error('Unknown error'))
localStorage.setItem('neode_onboarding_complete', '1')
const result = await isOnboardingComplete()
expect(result).toBe(true)
})
it('returns false from localStorage fallback when not set', async () => {
mockedRpc.isOnboardingComplete.mockRejectedValue(new Error('Unknown error'))
const result = await isOnboardingComplete()
expect(result).toBe(false)
})
it('retries on 502 errors before falling back', async () => {
mockedRpc.isOnboardingComplete
.mockRejectedValueOnce(new Error('502 Bad Gateway'))
.mockResolvedValueOnce(true)
const promise = isOnboardingComplete()
await vi.advanceTimersByTimeAsync(900)
const result = await promise
expect(result).toBe(true)
expect(mockedRpc.isOnboardingComplete).toHaveBeenCalledTimes(2)
})
it('retries on 503 errors', async () => {
mockedRpc.isOnboardingComplete
.mockRejectedValueOnce(new Error('503 Service Unavailable'))
.mockResolvedValueOnce(false)
const promise = isOnboardingComplete()
await vi.advanceTimersByTimeAsync(900)
const result = await promise
expect(result).toBe(false)
})
it('falls back to localStorage after exhausting retries', async () => {
mockedRpc.isOnboardingComplete.mockRejectedValue(new Error('502 Bad Gateway'))
localStorage.setItem('neode_onboarding_complete', '1')
const promise = isOnboardingComplete()
await vi.advanceTimersByTimeAsync(8000)
const result = await promise
expect(result).toBe(true)
}, 10000)
})
describe('completeOnboarding', () => {
it('calls RPC and sets localStorage', async () => {
mockedRpc.completeOnboarding.mockResolvedValue(true)
await completeOnboarding()
expect(mockedRpc.completeOnboarding).toHaveBeenCalled()
expect(localStorage.getItem('neode_onboarding_complete')).toBe('1')
})
it('sets localStorage even when RPC fails', async () => {
mockedRpc.completeOnboarding.mockRejectedValue(new Error('Network error'))
const promise = completeOnboarding()
await vi.advanceTimersByTimeAsync(10000)
await promise
expect(localStorage.getItem('neode_onboarding_complete')).toBe('1')
})
})
})
@@ -0,0 +1,131 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { useToast } from '../useToast'
describe('useToast', () => {
beforeEach(() => {
vi.useFakeTimers()
// Get a fresh toast instance and clear any leftover state
const { toasts, dismiss } = useToast()
// Dismiss all existing toasts
for (const t of [...toasts.value]) {
dismiss(t.id)
}
vi.advanceTimersByTime(500)
})
afterEach(() => {
vi.useRealTimers()
})
it('creates a success toast', () => {
const { success, toasts } = useToast()
success('Operation complete')
expect(toasts.value.length).toBeGreaterThanOrEqual(1)
const toast = toasts.value[toasts.value.length - 1]!
expect(toast.message).toBe('Operation complete')
expect(toast.variant).toBe('success')
expect(toast.dismissing).toBe(false)
})
it('creates an error toast', () => {
const { error, toasts } = useToast()
error('Something went wrong')
const toast = toasts.value[toasts.value.length - 1]!
expect(toast.message).toBe('Something went wrong')
expect(toast.variant).toBe('error')
})
it('creates an info toast', () => {
const { info, toasts } = useToast()
info('FYI: Node syncing')
const toast = toasts.value[toasts.value.length - 1]!
expect(toast.message).toBe('FYI: Node syncing')
expect(toast.variant).toBe('info')
})
it('auto-dismisses toast after duration', () => {
const { success, toasts } = useToast()
success('Will auto-dismiss')
const toast = toasts.value[toasts.value.length - 1]!
const toastId = toast.id
expect(toasts.value.some((t) => t.id === toastId)).toBe(true)
// After 3000ms, the toast should start dismissing
vi.advanceTimersByTime(3000)
const dismissingToast = toasts.value.find((t) => t.id === toastId)
if (dismissingToast) {
expect(dismissingToast.dismissing).toBe(true)
}
// After another 300ms, the toast should be fully removed
vi.advanceTimersByTime(300)
expect(toasts.value.some((t) => t.id === toastId)).toBe(false)
})
it('dismiss marks toast as dismissing then removes it', () => {
const { info, toasts, dismiss } = useToast()
info('Dismissable')
const toast = toasts.value[toasts.value.length - 1]!
dismiss(toast.id)
// Should be marked as dismissing
const found = toasts.value.find((t) => t.id === toast.id)
if (found) {
expect(found.dismissing).toBe(true)
}
// After 300ms animation delay, should be removed
vi.advanceTimersByTime(300)
expect(toasts.value.some((t) => t.id === toast.id)).toBe(false)
})
it('dismiss is a no-op for nonexistent toast ID', () => {
const { dismiss, toasts } = useToast()
const countBefore = toasts.value.length
dismiss(999999)
expect(toasts.value.length).toBe(countBefore)
})
it('each toast gets a unique ID', () => {
const { info, toasts } = useToast()
info('First')
info('Second')
info('Third')
const ids = toasts.value.slice(-3).map((t) => t.id)
const uniqueIds = new Set(ids)
expect(uniqueIds.size).toBe(3)
})
it('caps visible toasts at 5', () => {
const { info, toasts } = useToast()
for (let i = 0; i < 7; i++) {
info(`Toast ${i}`)
}
expect(toasts.value.length).toBeLessThanOrEqual(5)
})
it('toasts ref is readonly', () => {
const { toasts } = useToast()
// The readonly wrapper prevents direct mutation
expect(typeof toasts.value).toBe('object')
})
})