feat: Archipelago demo stack (lightweight)
This commit is contained in:
106
neode-ui/src/stores/__tests__/aiPermissions.test.ts
Normal file
106
neode-ui/src/stores/__tests__/aiPermissions.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useAIPermissionsStore, AI_PERMISSION_CATEGORIES } from '../aiPermissions'
|
||||
|
||||
const STORAGE_KEY = 'archipelago-ai-permissions'
|
||||
|
||||
describe('useAIPermissionsStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('starts with empty permissions when no localStorage', () => {
|
||||
const store = useAIPermissionsStore()
|
||||
expect(store.enabled.size).toBe(0)
|
||||
expect(store.noneEnabled).toBe(true)
|
||||
expect(store.allEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('loads valid categories from localStorage', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(['apps', 'system']))
|
||||
setActivePinia(createPinia())
|
||||
const store = useAIPermissionsStore()
|
||||
expect(store.isEnabled('apps')).toBe(true)
|
||||
expect(store.isEnabled('system')).toBe(true)
|
||||
expect(store.enabled.size).toBe(2)
|
||||
})
|
||||
|
||||
it('filters invalid categories from localStorage', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(['apps', 'invalid-category', 'system']))
|
||||
setActivePinia(createPinia())
|
||||
const store = useAIPermissionsStore()
|
||||
expect(store.enabled.size).toBe(2)
|
||||
expect(store.isEnabled('apps')).toBe(true)
|
||||
expect(store.isEnabled('system')).toBe(true)
|
||||
})
|
||||
|
||||
it('handles corrupt localStorage gracefully', () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'not-valid-json{')
|
||||
setActivePinia(createPinia())
|
||||
const store = useAIPermissionsStore()
|
||||
expect(store.enabled.size).toBe(0)
|
||||
})
|
||||
|
||||
it('toggle adds a category', () => {
|
||||
const store = useAIPermissionsStore()
|
||||
store.toggle('bitcoin')
|
||||
expect(store.isEnabled('bitcoin')).toBe(true)
|
||||
})
|
||||
|
||||
it('toggle removes an enabled category', () => {
|
||||
const store = useAIPermissionsStore()
|
||||
store.toggle('bitcoin')
|
||||
store.toggle('bitcoin')
|
||||
expect(store.isEnabled('bitcoin')).toBe(false)
|
||||
})
|
||||
|
||||
it('toggle persists to localStorage', () => {
|
||||
const store = useAIPermissionsStore()
|
||||
store.toggle('apps')
|
||||
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]')
|
||||
expect(stored).toContain('apps')
|
||||
})
|
||||
|
||||
it('enableAll enables all categories', () => {
|
||||
const store = useAIPermissionsStore()
|
||||
store.enableAll()
|
||||
expect(store.allEnabled).toBe(true)
|
||||
expect(store.enabled.size).toBe(AI_PERMISSION_CATEGORIES.length)
|
||||
for (const cat of AI_PERMISSION_CATEGORIES) {
|
||||
expect(store.isEnabled(cat.id)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('disableAll disables all categories', () => {
|
||||
const store = useAIPermissionsStore()
|
||||
store.enableAll()
|
||||
store.disableAll()
|
||||
expect(store.noneEnabled).toBe(true)
|
||||
expect(store.enabled.size).toBe(0)
|
||||
})
|
||||
|
||||
it('enabledCategories returns array of enabled IDs', () => {
|
||||
const store = useAIPermissionsStore()
|
||||
store.toggle('apps')
|
||||
store.toggle('network')
|
||||
expect(store.enabledCategories).toContain('apps')
|
||||
expect(store.enabledCategories).toContain('network')
|
||||
expect(store.enabledCategories.length).toBe(2)
|
||||
})
|
||||
|
||||
it('AI_PERMISSION_CATEGORIES has 10 categories', () => {
|
||||
expect(AI_PERMISSION_CATEGORIES.length).toBe(10)
|
||||
})
|
||||
|
||||
it('all categories have required fields', () => {
|
||||
for (const cat of AI_PERMISSION_CATEGORIES) {
|
||||
expect(cat.id).toBeTruthy()
|
||||
expect(cat.label).toBeTruthy()
|
||||
expect(cat.description).toBeTruthy()
|
||||
expect(cat.icon).toBeTruthy()
|
||||
expect(cat.group).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
175
neode-ui/src/stores/__tests__/app.test.ts
Normal file
175
neode-ui/src/stores/__tests__/app.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
// Mock the rpc-client module
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
call: vi.fn(),
|
||||
installPackage: vi.fn(),
|
||||
uninstallPackage: vi.fn(),
|
||||
startPackage: vi.fn(),
|
||||
stopPackage: vi.fn(),
|
||||
restartPackage: vi.fn(),
|
||||
updateServer: vi.fn(),
|
||||
restartServer: vi.fn(),
|
||||
shutdownServer: vi.fn(),
|
||||
getMetrics: vi.fn(),
|
||||
getMarketplace: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the websocket module
|
||||
vi.mock('@/api/websocket', () => ({
|
||||
wsClient: {
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
disconnect: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(false),
|
||||
onConnectionStateChange: vi.fn(),
|
||||
},
|
||||
applyDataPatch: vi.fn(),
|
||||
}))
|
||||
|
||||
import { useAppStore } from '../app'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { wsClient } from '@/api/websocket'
|
||||
|
||||
const mockedRpc = vi.mocked(rpcClient)
|
||||
const mockedWs = vi.mocked(wsClient)
|
||||
|
||||
describe('useAppStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
mockedWs.isConnected.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('starts with default unauthenticated state', () => {
|
||||
const store = useAppStore()
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(store.isConnected).toBe(false)
|
||||
expect(store.isLoading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
expect(store.data).toBeNull()
|
||||
})
|
||||
|
||||
it('login succeeds and sets authenticated state', async () => {
|
||||
mockedRpc.login.mockResolvedValue(null)
|
||||
const store = useAppStore()
|
||||
|
||||
await store.login('password123')
|
||||
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
expect(localStorage.getItem('neode-auth')).toBe('true')
|
||||
expect(store.data).not.toBeNull()
|
||||
expect(store.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('login handles TOTP requirement', async () => {
|
||||
mockedRpc.login.mockResolvedValue({ requires_totp: true })
|
||||
const store = useAppStore()
|
||||
|
||||
const result = await store.login('password123')
|
||||
|
||||
expect(result).toEqual({ requires_totp: true })
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
})
|
||||
|
||||
it('login sets error on failure', async () => {
|
||||
mockedRpc.login.mockRejectedValue(new Error('Invalid password'))
|
||||
const store = useAppStore()
|
||||
|
||||
await expect(store.login('wrong')).rejects.toThrow('Invalid password')
|
||||
|
||||
expect(store.error).toBe('Invalid password')
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(store.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('logout clears all state', async () => {
|
||||
mockedRpc.login.mockResolvedValue(null)
|
||||
mockedRpc.logout.mockResolvedValue(undefined)
|
||||
const store = useAppStore()
|
||||
|
||||
// Login first
|
||||
await store.login('password123')
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
|
||||
// Then logout
|
||||
await store.logout()
|
||||
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(store.data).toBeNull()
|
||||
expect(store.isConnected).toBe(false)
|
||||
expect(localStorage.getItem('neode-auth')).toBeNull()
|
||||
expect(mockedWs.disconnect).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logout still clears state even if RPC fails', async () => {
|
||||
mockedRpc.logout.mockRejectedValue(new Error('Network error'))
|
||||
localStorage.setItem('neode-auth', 'true')
|
||||
const store = useAppStore()
|
||||
|
||||
await store.logout()
|
||||
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(localStorage.getItem('neode-auth')).toBeNull()
|
||||
})
|
||||
|
||||
it('checkSession returns true on valid session', async () => {
|
||||
localStorage.setItem('neode-auth', 'true')
|
||||
mockedRpc.call.mockResolvedValue('ping')
|
||||
const store = useAppStore()
|
||||
|
||||
const valid = await store.checkSession()
|
||||
|
||||
expect(valid).toBe(true)
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
expect(store.data).not.toBeNull()
|
||||
})
|
||||
|
||||
it('checkSession returns false when no auth in localStorage', async () => {
|
||||
const store = useAppStore()
|
||||
|
||||
const valid = await store.checkSession()
|
||||
|
||||
expect(valid).toBe(false)
|
||||
})
|
||||
|
||||
it('checkSession returns false and clears state on expired session', async () => {
|
||||
localStorage.setItem('neode-auth', 'true')
|
||||
mockedRpc.call.mockRejectedValue(new Error('401 Unauthorized'))
|
||||
const store = useAppStore()
|
||||
|
||||
const valid = await store.checkSession()
|
||||
|
||||
expect(valid).toBe(false)
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(localStorage.getItem('neode-auth')).toBeNull()
|
||||
expect(mockedWs.disconnect).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('connectWebSocket subscribes and connects', async () => {
|
||||
mockedWs.connect.mockResolvedValue(undefined)
|
||||
// First call: not connected (triggers connect), second call: connected (after connect)
|
||||
mockedWs.isConnected.mockReturnValueOnce(false).mockReturnValue(true)
|
||||
const store = useAppStore()
|
||||
|
||||
await store.connectWebSocket()
|
||||
|
||||
expect(mockedWs.subscribe).toHaveBeenCalledOnce()
|
||||
expect(mockedWs.onConnectionStateChange).toHaveBeenCalledOnce()
|
||||
expect(mockedWs.connect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('needsSessionValidation returns true when auth but not validated', () => {
|
||||
localStorage.setItem('neode-auth', 'true')
|
||||
const store = useAppStore()
|
||||
|
||||
// isAuthenticated is true from localStorage, but sessionValidated is false
|
||||
expect(store.needsSessionValidation()).toBe(true)
|
||||
})
|
||||
})
|
||||
164
neode-ui/src/stores/__tests__/appLauncher.test.ts
Normal file
164
neode-ui/src/stores/__tests__/appLauncher.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
// vi.hoisted runs before vi.mock hoisting
|
||||
const { mockPush, mockWindowOpen } = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockWindowOpen: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock vue-router
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
vi.mock('@/router', () => ({
|
||||
default: { push: mockPush },
|
||||
}))
|
||||
|
||||
vi.stubGlobal('open', mockWindowOpen)
|
||||
|
||||
import { useAppLauncherStore } from '../appLauncher'
|
||||
|
||||
describe('useAppLauncherStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
// Default to HTTP to avoid proxy rewriting
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { origin: 'http://192.168.1.228', protocol: 'http:', hostname: '192.168.1.228' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('starts closed with empty state', () => {
|
||||
const store = useAppLauncherStore()
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.url).toBe('')
|
||||
expect(store.title).toBe('')
|
||||
})
|
||||
|
||||
it('routes known port apps to full-page session', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
// Port 8083 maps to /app/filebrowser/ — should route to session
|
||||
store.open({ url: 'http://192.168.1.228:8083', title: 'FileBrowser' })
|
||||
|
||||
// Legacy overlay should NOT open — routed to session view instead
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'filebrowser' } })
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes BTCPay (port 23000) to full-page session', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:23000', title: 'BTCPay' })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'btcpay' } })
|
||||
})
|
||||
|
||||
it('routes Home Assistant (port 8123) to full-page session', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:8123', title: 'Home Assistant' })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(mockPush).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes Grafana (port 3000) to full-page session', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:3000', title: 'Grafana' })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(mockPush).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens in new tab when openInNewTab flag is set for unknown URL', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
// Use an unresolvable URL so it doesn't route to session
|
||||
store.open({ url: 'http://192.168.1.228:9999', title: 'Unknown', openInNewTab: true })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'http://192.168.1.228:9999',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
})
|
||||
|
||||
it('routes HTTPS same-host apps via session view', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { origin: 'https://192.168.1.228', protocol: 'https:', hostname: '192.168.1.228' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:8083', title: 'FileBrowser' })
|
||||
|
||||
// Known port — routes to full-page session
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'filebrowser' } })
|
||||
})
|
||||
|
||||
it('opens unknown URL in iframe overlay on HTTP', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
// Unresolvable URL — falls through to iframe overlay
|
||||
store.open({ url: 'http://192.168.1.228:9999', title: 'Custom App' })
|
||||
|
||||
expect(store.isOpen).toBe(true)
|
||||
expect(store.url).toBe('http://192.168.1.228:9999')
|
||||
expect(store.title).toBe('Custom App')
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens unknown different-host URL in iframe overlay', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { origin: 'https://192.168.1.228', protocol: 'https:', hostname: '192.168.1.228' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.100:9999', title: 'Remote App' })
|
||||
|
||||
// Different host, unknown port — opens in iframe overlay (no proxy rewrite)
|
||||
expect(store.isOpen).toBe(true)
|
||||
expect(store.url).toBe('http://192.168.1.100:9999')
|
||||
})
|
||||
|
||||
it('close resets state', () => {
|
||||
const store = useAppLauncherStore()
|
||||
// Use unknown URL to trigger iframe overlay
|
||||
store.open({ url: 'http://192.168.1.228:9999', title: 'Custom' })
|
||||
|
||||
store.close()
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.url).toBe('')
|
||||
expect(store.title).toBe('')
|
||||
})
|
||||
|
||||
it('close restores focus to previous element', async () => {
|
||||
vi.useFakeTimers()
|
||||
const store = useAppLauncherStore()
|
||||
const mockButton = { focus: vi.fn() } as unknown as HTMLElement
|
||||
Object.defineProperty(document, 'activeElement', { value: mockButton, configurable: true })
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:9999', title: 'Custom' })
|
||||
store.close()
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.url).toBe('')
|
||||
|
||||
// requestAnimationFrame fires the focus restore callback
|
||||
vi.runAllTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
65
neode-ui/src/stores/__tests__/cli.test.ts
Normal file
65
neode-ui/src/stores/__tests__/cli.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
vi.mock('@/composables/useNavSounds', () => ({
|
||||
playNavSound: vi.fn(),
|
||||
}))
|
||||
|
||||
import { useCLIStore } from '../cli'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const mockedPlayNavSound = vi.mocked(playNavSound)
|
||||
|
||||
describe('useCLIStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('starts closed', () => {
|
||||
const store = useCLIStore()
|
||||
expect(store.isOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('open sets isOpen to true and plays sound', () => {
|
||||
const store = useCLIStore()
|
||||
store.open()
|
||||
expect(store.isOpen).toBe(true)
|
||||
expect(mockedPlayNavSound).toHaveBeenCalledWith('action')
|
||||
})
|
||||
|
||||
it('close sets isOpen to false without sound', () => {
|
||||
const store = useCLIStore()
|
||||
store.open()
|
||||
vi.clearAllMocks()
|
||||
store.close()
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(mockedPlayNavSound).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toggle opens and plays sound when closed', () => {
|
||||
const store = useCLIStore()
|
||||
store.toggle()
|
||||
expect(store.isOpen).toBe(true)
|
||||
expect(mockedPlayNavSound).toHaveBeenCalledWith('action')
|
||||
})
|
||||
|
||||
it('toggle closes without sound when open', () => {
|
||||
const store = useCLIStore()
|
||||
store.open()
|
||||
vi.clearAllMocks()
|
||||
store.toggle()
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(mockedPlayNavSound).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('multiple toggles alternate state', () => {
|
||||
const store = useCLIStore()
|
||||
store.toggle()
|
||||
expect(store.isOpen).toBe(true)
|
||||
store.toggle()
|
||||
expect(store.isOpen).toBe(false)
|
||||
store.toggle()
|
||||
expect(store.isOpen).toBe(true)
|
||||
})
|
||||
})
|
||||
233
neode-ui/src/stores/__tests__/cloud.test.ts
Normal file
233
neode-ui/src/stores/__tests__/cloud.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
vi.mock('@/api/filebrowser-client', () => ({
|
||||
fileBrowserClient: {
|
||||
login: vi.fn(),
|
||||
listDirectory: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
deleteItem: vi.fn(),
|
||||
downloadUrl: vi.fn(),
|
||||
createFolder: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { useCloudStore } from '../cloud'
|
||||
import { fileBrowserClient } from '@/api/filebrowser-client'
|
||||
|
||||
const mockedClient = vi.mocked(fileBrowserClient)
|
||||
|
||||
const mockItems = [
|
||||
{ name: 'photos', path: '/photos', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
|
||||
{ name: 'readme.md', path: '/readme.md', size: 256, modified: '2026-01-02', isDir: false, type: '', extension: 'md' },
|
||||
{ name: 'archive.zip', path: '/archive.zip', size: 4096, modified: '2026-01-03', isDir: false, type: '', extension: 'zip' },
|
||||
]
|
||||
|
||||
describe('useCloudStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('starts with default state', () => {
|
||||
const store = useCloudStore()
|
||||
expect(store.currentPath).toBe('/')
|
||||
expect(store.items).toEqual([])
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
expect(store.authenticated).toBe(false)
|
||||
})
|
||||
|
||||
it('init authenticates with filebrowser', async () => {
|
||||
mockedClient.login.mockResolvedValue(true)
|
||||
const store = useCloudStore()
|
||||
|
||||
const result = await store.init()
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(store.authenticated).toBe(true)
|
||||
expect(mockedClient.login).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('init returns false on auth failure', async () => {
|
||||
mockedClient.login.mockResolvedValue(false)
|
||||
const store = useCloudStore()
|
||||
|
||||
const result = await store.init()
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(store.authenticated).toBe(false)
|
||||
})
|
||||
|
||||
it('init skips login if already authenticated', async () => {
|
||||
mockedClient.login.mockResolvedValue(true)
|
||||
const store = useCloudStore()
|
||||
|
||||
await store.init()
|
||||
await store.init()
|
||||
|
||||
expect(mockedClient.login).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('navigate loads items and updates path', async () => {
|
||||
mockedClient.login.mockResolvedValue(true)
|
||||
mockedClient.listDirectory.mockResolvedValue(mockItems)
|
||||
const store = useCloudStore()
|
||||
store.authenticated = true
|
||||
|
||||
await store.navigate('/photos')
|
||||
|
||||
expect(store.items).toEqual(mockItems)
|
||||
expect(store.currentPath).toBe('/photos')
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
|
||||
it('navigate authenticates if not authenticated', async () => {
|
||||
mockedClient.login.mockResolvedValue(true)
|
||||
mockedClient.listDirectory.mockResolvedValue(mockItems)
|
||||
const store = useCloudStore()
|
||||
|
||||
await store.navigate('/')
|
||||
|
||||
expect(mockedClient.login).toHaveBeenCalled()
|
||||
expect(store.items).toEqual(mockItems)
|
||||
})
|
||||
|
||||
it('navigate sets error on auth failure', async () => {
|
||||
mockedClient.login.mockResolvedValue(false)
|
||||
const store = useCloudStore()
|
||||
|
||||
await store.navigate('/')
|
||||
|
||||
expect(store.error).toBe('Failed to authenticate with File Browser')
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('navigate falls back to creating directory on list failure', async () => {
|
||||
const store = useCloudStore()
|
||||
store.authenticated = true
|
||||
|
||||
// First listDirectory fails, then createFolder succeeds, then retry succeeds
|
||||
mockedClient.listDirectory
|
||||
.mockRejectedValueOnce(new Error('Not found'))
|
||||
.mockResolvedValueOnce([])
|
||||
mockedClient.createFolder.mockResolvedValue(undefined)
|
||||
|
||||
await store.navigate('/new-folder')
|
||||
|
||||
expect(mockedClient.createFolder).toHaveBeenCalledWith('/', 'new-folder')
|
||||
expect(store.currentPath).toBe('/new-folder')
|
||||
})
|
||||
|
||||
it('navigate falls back to root when directory creation also fails', async () => {
|
||||
const store = useCloudStore()
|
||||
store.authenticated = true
|
||||
|
||||
// Call 1: listDirectory('/deep/nested') rejects
|
||||
// Call 2: listDirectory('/') in the fallback catch resolves
|
||||
mockedClient.listDirectory
|
||||
.mockRejectedValueOnce(new Error('Not found'))
|
||||
.mockResolvedValueOnce(mockItems)
|
||||
|
||||
mockedClient.createFolder.mockRejectedValueOnce(new Error('Create failed'))
|
||||
|
||||
await store.navigate('/deep/nested')
|
||||
|
||||
expect(store.currentPath).toBe('/')
|
||||
expect(store.items).toEqual(mockItems)
|
||||
})
|
||||
|
||||
it('navigate sets error when root listing fails', async () => {
|
||||
const store = useCloudStore()
|
||||
store.authenticated = true
|
||||
|
||||
mockedClient.listDirectory.mockRejectedValueOnce(new Error('Server error'))
|
||||
|
||||
await store.navigate('/')
|
||||
|
||||
expect(store.error).toBe('Failed to list root directory')
|
||||
})
|
||||
|
||||
it('breadcrumbs computes from path', () => {
|
||||
const store = useCloudStore()
|
||||
store.currentPath = '/photos/vacation/2026'
|
||||
|
||||
expect(store.breadcrumbs).toEqual([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'photos', path: '/photos' },
|
||||
{ name: 'vacation', path: '/photos/vacation' },
|
||||
{ name: '2026', path: '/photos/vacation/2026' },
|
||||
])
|
||||
})
|
||||
|
||||
it('breadcrumbs at root only shows Home', () => {
|
||||
const store = useCloudStore()
|
||||
expect(store.breadcrumbs).toEqual([{ name: 'Home', path: '/' }])
|
||||
})
|
||||
|
||||
it('sortedItems puts directories first, sorted alphabetically', () => {
|
||||
const store = useCloudStore()
|
||||
store.items = [
|
||||
{ name: 'readme.md', path: '/readme.md', size: 256, modified: '2026-01-01', isDir: false, type: '', extension: 'md' },
|
||||
{ name: 'docs', path: '/docs', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
|
||||
{ name: 'archive.zip', path: '/archive.zip', size: 4096, modified: '2026-01-01', isDir: false, type: '', extension: 'zip' },
|
||||
{ name: 'assets', path: '/assets', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
|
||||
]
|
||||
|
||||
const sorted = store.sortedItems
|
||||
expect(sorted.map((i) => i.name)).toEqual(['assets', 'docs', 'archive.zip', 'readme.md'])
|
||||
})
|
||||
|
||||
it('uploadFile uploads and refreshes', async () => {
|
||||
const store = useCloudStore()
|
||||
store.authenticated = true
|
||||
store.currentPath = '/uploads'
|
||||
mockedClient.upload.mockResolvedValue(undefined)
|
||||
mockedClient.listDirectory.mockResolvedValue([])
|
||||
|
||||
const file = new File(['test'], 'test.txt')
|
||||
await store.uploadFile(file)
|
||||
|
||||
expect(mockedClient.upload).toHaveBeenCalledWith('/uploads', file)
|
||||
expect(mockedClient.listDirectory).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deleteItem deletes and refreshes', async () => {
|
||||
const store = useCloudStore()
|
||||
store.authenticated = true
|
||||
store.currentPath = '/'
|
||||
mockedClient.deleteItem.mockResolvedValue(undefined)
|
||||
mockedClient.listDirectory.mockResolvedValue([])
|
||||
|
||||
await store.deleteItem('/old-file.txt')
|
||||
|
||||
expect(mockedClient.deleteItem).toHaveBeenCalledWith('/old-file.txt')
|
||||
expect(mockedClient.listDirectory).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('downloadUrl delegates to filebrowser client', () => {
|
||||
mockedClient.downloadUrl.mockReturnValue('http://localhost/api/raw/file.txt?auth=token')
|
||||
const store = useCloudStore()
|
||||
|
||||
const url = store.downloadUrl('/file.txt')
|
||||
|
||||
expect(url).toBe('http://localhost/api/raw/file.txt?auth=token')
|
||||
expect(mockedClient.downloadUrl).toHaveBeenCalledWith('/file.txt')
|
||||
})
|
||||
|
||||
it('reset clears all state', () => {
|
||||
const store = useCloudStore()
|
||||
store.currentPath = '/deep/path'
|
||||
store.items = mockItems
|
||||
store.loading = true
|
||||
store.error = 'something'
|
||||
|
||||
store.reset()
|
||||
|
||||
expect(store.currentPath).toBe('/')
|
||||
expect(store.items).toEqual([])
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
})
|
||||
336
neode-ui/src/stores/__tests__/container.test.ts
Normal file
336
neode-ui/src/stores/__tests__/container.test.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
vi.mock('@/api/container-client', () => ({
|
||||
containerClient: {
|
||||
listContainers: vi.fn(),
|
||||
getHealthStatus: vi.fn(),
|
||||
installApp: vi.fn(),
|
||||
startContainer: vi.fn(),
|
||||
stopContainer: vi.fn(),
|
||||
removeContainer: vi.fn(),
|
||||
getContainerLogs: vi.fn(),
|
||||
getContainerStatus: vi.fn(),
|
||||
startBundledApp: vi.fn(),
|
||||
stopBundledApp: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { useContainerStore } from '../container'
|
||||
import { containerClient } from '@/api/container-client'
|
||||
|
||||
const mockedClient = vi.mocked(containerClient)
|
||||
|
||||
const mockContainers = [
|
||||
{ id: '1', name: 'bitcoin-knots', state: 'running' as const, status: 'Up 2 hours', image: 'bitcoinknots:29', created: '2026-01-01', ports: ['8332'], lan_address: 'http://localhost:8332' },
|
||||
{ id: '2', name: 'lnd', state: 'stopped' as const, status: 'Exited (0)', image: 'lnd:v0.18.4', created: '2026-01-01', ports: ['9735'], lan_address: undefined },
|
||||
{ id: '3', name: 'mempool', state: 'running' as const, status: 'Up 1 hour', image: 'mempool:latest', created: '2026-01-01', ports: ['8080'], lan_address: 'http://localhost:8080' },
|
||||
]
|
||||
|
||||
describe('useContainerStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('fetchContainers loads container list', async () => {
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.fetchContainers()
|
||||
|
||||
expect(store.containers).toEqual(mockContainers)
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
|
||||
it('fetchContainers sets error on failure', async () => {
|
||||
mockedClient.listContainers.mockRejectedValue(new Error('Connection refused'))
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.fetchContainers()
|
||||
|
||||
expect(store.error).toBe('Connection refused')
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('runningContainers filters correctly', async () => {
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.fetchContainers()
|
||||
|
||||
expect(store.runningContainers).toHaveLength(2)
|
||||
expect(store.runningContainers.map(c => c.name)).toEqual(['bitcoin-knots', 'mempool'])
|
||||
})
|
||||
|
||||
it('stoppedContainers filters correctly', async () => {
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.fetchContainers()
|
||||
|
||||
expect(store.stoppedContainers).toHaveLength(1)
|
||||
expect(store.stoppedContainers[0]!.name).toBe('lnd')
|
||||
})
|
||||
|
||||
it('startContainer calls client and refreshes', async () => {
|
||||
mockedClient.startContainer.mockResolvedValue(undefined)
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
mockedClient.getHealthStatus.mockResolvedValue({ 'bitcoin-knots': 'healthy' })
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.startContainer('bitcoin-knots')
|
||||
|
||||
expect(mockedClient.startContainer).toHaveBeenCalledWith('bitcoin-knots')
|
||||
expect(mockedClient.listContainers).toHaveBeenCalled()
|
||||
expect(mockedClient.getHealthStatus).toHaveBeenCalled()
|
||||
expect(store.isAppLoading('bitcoin-knots')).toBe(false)
|
||||
})
|
||||
|
||||
it('stopContainer calls client and refreshes', async () => {
|
||||
mockedClient.stopContainer.mockResolvedValue(undefined)
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.stopContainer('lnd')
|
||||
|
||||
expect(mockedClient.stopContainer).toHaveBeenCalledWith('lnd')
|
||||
expect(mockedClient.listContainers).toHaveBeenCalled()
|
||||
expect(store.isAppLoading('lnd')).toBe(false)
|
||||
})
|
||||
|
||||
it('getAppState returns correct states', async () => {
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.fetchContainers()
|
||||
|
||||
expect(store.getAppState('bitcoin-knots')).toBe('running')
|
||||
expect(store.getAppState('lnd')).toBe('stopped')
|
||||
expect(store.getAppState('nonexistent')).toBe('not-installed')
|
||||
})
|
||||
|
||||
it('isAppLoading tracks per-app loading state', async () => {
|
||||
let resolveStart: (() => void) | undefined
|
||||
mockedClient.startContainer.mockImplementation(() => new Promise(r => { resolveStart = r }))
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
mockedClient.getHealthStatus.mockResolvedValue({})
|
||||
const store = useContainerStore()
|
||||
|
||||
const startPromise = store.startContainer('bitcoin-knots')
|
||||
|
||||
expect(store.isAppLoading('bitcoin-knots')).toBe(true)
|
||||
expect(store.isAppLoading('lnd')).toBe(false)
|
||||
|
||||
resolveStart!()
|
||||
await startPromise
|
||||
|
||||
expect(store.isAppLoading('bitcoin-knots')).toBe(false)
|
||||
})
|
||||
|
||||
it('fetchHealthStatus loads health data', async () => {
|
||||
mockedClient.getHealthStatus.mockResolvedValue({ 'bitcoin-knots': 'healthy', 'lnd': 'degraded' })
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.fetchHealthStatus()
|
||||
|
||||
expect(store.getHealthStatus('bitcoin-knots')).toBe('healthy')
|
||||
expect(store.getHealthStatus('lnd')).toBe('degraded')
|
||||
expect(store.getHealthStatus('unknown-app')).toBe('unknown')
|
||||
})
|
||||
|
||||
it('fetchHealthStatus handles errors silently', async () => {
|
||||
mockedClient.getHealthStatus.mockRejectedValue(new Error('fail'))
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.fetchHealthStatus()
|
||||
// Should not throw, healthStatus stays empty
|
||||
expect(store.healthStatus).toEqual({})
|
||||
})
|
||||
|
||||
it('installApp installs and refreshes containers', async () => {
|
||||
mockedClient.installApp.mockResolvedValue('new-app')
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
const store = useContainerStore()
|
||||
|
||||
const result = await store.installApp('/path/to/manifest')
|
||||
|
||||
expect(result).toBe('new-app')
|
||||
expect(mockedClient.installApp).toHaveBeenCalledWith('/path/to/manifest')
|
||||
expect(mockedClient.listContainers).toHaveBeenCalled()
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('installApp sets error and rethrows on failure', async () => {
|
||||
mockedClient.installApp.mockRejectedValue(new Error('Install failed'))
|
||||
const store = useContainerStore()
|
||||
|
||||
await expect(store.installApp('/bad/manifest')).rejects.toThrow('Install failed')
|
||||
expect(store.error).toBe('Install failed')
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('startContainer sets error on failure', async () => {
|
||||
mockedClient.startContainer.mockRejectedValue(new Error('Start failed'))
|
||||
const store = useContainerStore()
|
||||
|
||||
await expect(store.startContainer('bitcoin-knots')).rejects.toThrow('Start failed')
|
||||
expect(store.error).toBe('Start failed')
|
||||
expect(store.isAppLoading('bitcoin-knots')).toBe(false)
|
||||
})
|
||||
|
||||
it('stopContainer sets error on failure', async () => {
|
||||
mockedClient.stopContainer.mockRejectedValue(new Error('Stop failed'))
|
||||
const store = useContainerStore()
|
||||
|
||||
await expect(store.stopContainer('lnd')).rejects.toThrow('Stop failed')
|
||||
expect(store.error).toBe('Stop failed')
|
||||
expect(store.isAppLoading('lnd')).toBe(false)
|
||||
})
|
||||
|
||||
it('removeContainer removes and refreshes', async () => {
|
||||
mockedClient.removeContainer.mockResolvedValue(undefined)
|
||||
mockedClient.listContainers.mockResolvedValue([])
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.removeContainer('old-app')
|
||||
|
||||
expect(mockedClient.removeContainer).toHaveBeenCalledWith('old-app')
|
||||
expect(mockedClient.listContainers).toHaveBeenCalled()
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('removeContainer sets error on failure', async () => {
|
||||
mockedClient.removeContainer.mockRejectedValue(new Error('Remove failed'))
|
||||
const store = useContainerStore()
|
||||
|
||||
await expect(store.removeContainer('old-app')).rejects.toThrow('Remove failed')
|
||||
expect(store.error).toBe('Remove failed')
|
||||
})
|
||||
|
||||
it('getContainerLogs returns logs', async () => {
|
||||
mockedClient.getContainerLogs.mockResolvedValue(['line1', 'line2', 'line3'])
|
||||
const store = useContainerStore()
|
||||
|
||||
const logs = await store.getContainerLogs('bitcoin-knots', 50)
|
||||
|
||||
expect(logs).toEqual(['line1', 'line2', 'line3'])
|
||||
expect(mockedClient.getContainerLogs).toHaveBeenCalledWith('bitcoin-knots', 50)
|
||||
})
|
||||
|
||||
it('getContainerLogs defaults to 100 lines', async () => {
|
||||
mockedClient.getContainerLogs.mockResolvedValue(['log output'])
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.getContainerLogs('bitcoin-knots')
|
||||
|
||||
expect(mockedClient.getContainerLogs).toHaveBeenCalledWith('bitcoin-knots', 100)
|
||||
})
|
||||
|
||||
it('getContainerLogs sets error on failure', async () => {
|
||||
mockedClient.getContainerLogs.mockRejectedValue(new Error('Log error'))
|
||||
const store = useContainerStore()
|
||||
|
||||
await expect(store.getContainerLogs('bitcoin-knots')).rejects.toThrow('Log error')
|
||||
expect(store.error).toBe('Log error')
|
||||
})
|
||||
|
||||
it('getContainerStatus returns status', async () => {
|
||||
const status = { name: 'bitcoin-knots', state: 'running', uptime: '5h' }
|
||||
mockedClient.getContainerStatus.mockResolvedValue(status as never)
|
||||
const store = useContainerStore()
|
||||
|
||||
const result = await store.getContainerStatus('bitcoin-knots')
|
||||
|
||||
expect(result).toEqual(status)
|
||||
})
|
||||
|
||||
it('getContainerStatus sets error on failure', async () => {
|
||||
mockedClient.getContainerStatus.mockRejectedValue(new Error('Status error'))
|
||||
const store = useContainerStore()
|
||||
|
||||
await expect(store.getContainerStatus('bitcoin-knots')).rejects.toThrow('Status error')
|
||||
expect(store.error).toBe('Status error')
|
||||
})
|
||||
|
||||
it('startBundledApp starts and refreshes', async () => {
|
||||
mockedClient.startBundledApp.mockResolvedValue(undefined)
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
mockedClient.getHealthStatus.mockResolvedValue({})
|
||||
const store = useContainerStore()
|
||||
|
||||
const app = { id: 'bitcoin-knots', name: 'Bitcoin Knots', image: 'btc:29', description: '', icon: '', ports: [], volumes: [], category: 'bitcoin' as const }
|
||||
await store.startBundledApp(app)
|
||||
|
||||
expect(mockedClient.startBundledApp).toHaveBeenCalledWith(app)
|
||||
expect(store.isAppLoading('bitcoin-knots')).toBe(false)
|
||||
})
|
||||
|
||||
it('startBundledApp sets error on failure', async () => {
|
||||
mockedClient.startBundledApp.mockRejectedValue(new Error('Start failed'))
|
||||
const store = useContainerStore()
|
||||
|
||||
const app = { id: 'test', name: 'Test', image: 'test:1', description: '', icon: '', ports: [], volumes: [], category: 'other' as const }
|
||||
await expect(store.startBundledApp(app)).rejects.toThrow('Start failed')
|
||||
expect(store.error).toBe('Start failed')
|
||||
expect(store.isAppLoading('test')).toBe(false)
|
||||
})
|
||||
|
||||
it('stopBundledApp stops and refreshes', async () => {
|
||||
mockedClient.stopBundledApp.mockResolvedValue(undefined)
|
||||
mockedClient.listContainers.mockResolvedValue([])
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.stopBundledApp('bitcoin-knots')
|
||||
|
||||
expect(mockedClient.stopBundledApp).toHaveBeenCalledWith('bitcoin-knots')
|
||||
expect(store.isAppLoading('bitcoin-knots')).toBe(false)
|
||||
})
|
||||
|
||||
it('stopBundledApp sets error on failure', async () => {
|
||||
mockedClient.stopBundledApp.mockRejectedValue(new Error('Stop failed'))
|
||||
const store = useContainerStore()
|
||||
|
||||
await expect(store.stopBundledApp('bitcoin-knots')).rejects.toThrow('Stop failed')
|
||||
expect(store.error).toBe('Stop failed')
|
||||
expect(store.isAppLoading('bitcoin-knots')).toBe(false)
|
||||
})
|
||||
|
||||
it('getContainerById finds by name substring', async () => {
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
const store = useContainerStore()
|
||||
await store.fetchContainers()
|
||||
|
||||
expect(store.getContainerById('bitcoin')?.name).toBe('bitcoin-knots')
|
||||
expect(store.getContainerById('nonexistent')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('getContainerForApp matches by exact name', async () => {
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
const store = useContainerStore()
|
||||
await store.fetchContainers()
|
||||
|
||||
expect(store.getContainerForApp('bitcoin-knots')?.name).toBe('bitcoin-knots')
|
||||
expect(store.getContainerForApp('lnd')?.name).toBe('lnd')
|
||||
})
|
||||
|
||||
it('enrichedBundledApps includes lan_address from matching containers', async () => {
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
const store = useContainerStore()
|
||||
await store.fetchContainers()
|
||||
|
||||
const enriched = store.enrichedBundledApps
|
||||
const btc = enriched.find(a => a.id === 'bitcoin-knots')
|
||||
expect(btc?.lan_address).toBe('http://localhost:8332')
|
||||
})
|
||||
|
||||
it('fetchContainers handles non-Error exceptions', async () => {
|
||||
mockedClient.listContainers.mockRejectedValue('string error')
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.fetchContainers()
|
||||
|
||||
expect(store.error).toBe('Failed to fetch containers')
|
||||
})
|
||||
})
|
||||
52
neode-ui/src/stores/__tests__/controller.test.ts
Normal file
52
neode-ui/src/stores/__tests__/controller.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useControllerStore } from '../controller'
|
||||
|
||||
describe('useControllerStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('starts with default state', () => {
|
||||
const store = useControllerStore()
|
||||
expect(store.isActive).toBe(false)
|
||||
expect(store.gamepadCount).toBe(0)
|
||||
})
|
||||
|
||||
it('setActive sets isActive to true', () => {
|
||||
const store = useControllerStore()
|
||||
store.setActive(true)
|
||||
expect(store.isActive).toBe(true)
|
||||
})
|
||||
|
||||
it('setActive sets isActive to false', () => {
|
||||
const store = useControllerStore()
|
||||
store.setActive(true)
|
||||
store.setActive(false)
|
||||
expect(store.isActive).toBe(false)
|
||||
})
|
||||
|
||||
it('setGamepadCount updates count and activates when > 0', () => {
|
||||
const store = useControllerStore()
|
||||
store.setGamepadCount(2)
|
||||
expect(store.gamepadCount).toBe(2)
|
||||
expect(store.isActive).toBe(true)
|
||||
})
|
||||
|
||||
it('setGamepadCount deactivates when count is 0', () => {
|
||||
const store = useControllerStore()
|
||||
store.setGamepadCount(1)
|
||||
expect(store.isActive).toBe(true)
|
||||
store.setGamepadCount(0)
|
||||
expect(store.gamepadCount).toBe(0)
|
||||
expect(store.isActive).toBe(false)
|
||||
})
|
||||
|
||||
it('setActive does not affect gamepadCount', () => {
|
||||
const store = useControllerStore()
|
||||
store.setGamepadCount(3)
|
||||
store.setActive(false)
|
||||
expect(store.isActive).toBe(false)
|
||||
expect(store.gamepadCount).toBe(3)
|
||||
})
|
||||
})
|
||||
227
neode-ui/src/stores/__tests__/goals.test.ts
Normal file
227
neode-ui/src/stores/__tests__/goals.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
// Mock the app store
|
||||
const mockPackages: Record<string, { state: string }> = {}
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
packages: mockPackages,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock the goals data with a controlled set
|
||||
vi.mock('@/data/goals', () => ({
|
||||
GOALS: [
|
||||
{
|
||||
id: 'accept-payments',
|
||||
title: 'Accept Payments',
|
||||
subtitle: 'Receive Bitcoin and Lightning payments',
|
||||
icon: 'payments',
|
||||
category: 'payments',
|
||||
requiredApps: ['bitcoin-knots', 'lnd'],
|
||||
steps: [
|
||||
{ id: 'install-bitcoin', title: 'Install Bitcoin', description: '', appId: 'bitcoin-knots', action: 'install', isAutomatic: true },
|
||||
{ id: 'install-lnd', title: 'Install LND', description: '', appId: 'lnd', action: 'install', isAutomatic: true },
|
||||
{ id: 'open-channel', title: 'Open Channel', description: '', action: 'configure', isAutomatic: false },
|
||||
],
|
||||
estimatedTime: '~30 min',
|
||||
difficulty: 'beginner',
|
||||
},
|
||||
{
|
||||
id: 'create-identity',
|
||||
title: 'Create Identity',
|
||||
subtitle: 'Sovereign digital identity',
|
||||
icon: 'identity',
|
||||
category: 'identity',
|
||||
requiredApps: [],
|
||||
steps: [
|
||||
{ id: 'generate-did', title: 'Generate DID', description: '', action: 'verify', isAutomatic: true },
|
||||
{ id: 'setup-nostr', title: 'Setup Nostr', description: '', action: 'configure', isAutomatic: false },
|
||||
],
|
||||
estimatedTime: '~5 min',
|
||||
difficulty: 'beginner',
|
||||
},
|
||||
{
|
||||
id: 'store-photos',
|
||||
title: 'Store Photos',
|
||||
subtitle: 'Private photo backup',
|
||||
icon: 'photos',
|
||||
category: 'storage',
|
||||
requiredApps: ['immich'],
|
||||
steps: [
|
||||
{ id: 'install-immich', title: 'Install Immich', description: '', appId: 'immich', action: 'install', isAutomatic: true },
|
||||
{ id: 'configure-immich', title: 'Configure', description: '', action: 'configure', isAutomatic: false },
|
||||
],
|
||||
estimatedTime: '~15 min',
|
||||
difficulty: 'beginner',
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
import { useGoalStore } from '../goals'
|
||||
|
||||
describe('useGoalStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
// Clear mock packages
|
||||
Object.keys(mockPackages).forEach((k) => delete mockPackages[k])
|
||||
})
|
||||
|
||||
it('starts with empty progress', () => {
|
||||
const store = useGoalStore()
|
||||
expect(store.progress).toEqual({})
|
||||
})
|
||||
|
||||
it('loads progress from localStorage', () => {
|
||||
const savedProgress = {
|
||||
'accept-payments': {
|
||||
goalId: 'accept-payments',
|
||||
status: 'in-progress',
|
||||
currentStepIndex: 1,
|
||||
completedSteps: ['install-bitcoin'],
|
||||
startedAt: 1000,
|
||||
},
|
||||
}
|
||||
localStorage.setItem('archipelago-goal-progress', JSON.stringify(savedProgress))
|
||||
|
||||
const store = useGoalStore()
|
||||
expect(store.progress['accept-payments']).toBeDefined()
|
||||
expect(store.progress['accept-payments']!.completedSteps).toContain('install-bitcoin')
|
||||
})
|
||||
|
||||
it('handles corrupt localStorage data', () => {
|
||||
localStorage.setItem('archipelago-goal-progress', 'not-valid-json{{{')
|
||||
|
||||
const store = useGoalStore()
|
||||
expect(store.progress).toEqual({})
|
||||
})
|
||||
|
||||
it('startGoal creates progress entry and saves', () => {
|
||||
const store = useGoalStore()
|
||||
|
||||
store.startGoal('accept-payments')
|
||||
|
||||
expect(store.progress['accept-payments']).toBeDefined()
|
||||
expect(store.progress['accept-payments']!.status).toBe('in-progress')
|
||||
expect(store.progress['accept-payments']!.currentStepIndex).toBe(0)
|
||||
expect(store.progress['accept-payments']!.completedSteps).toEqual([])
|
||||
expect(localStorage.getItem('archipelago-goal-progress')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('completeStep adds step to completedSteps', () => {
|
||||
const store = useGoalStore()
|
||||
store.startGoal('accept-payments')
|
||||
|
||||
store.completeStep('accept-payments', 'install-bitcoin')
|
||||
|
||||
expect(store.progress['accept-payments']!.completedSteps).toContain('install-bitcoin')
|
||||
})
|
||||
|
||||
it('completeStep does not duplicate step IDs', () => {
|
||||
const store = useGoalStore()
|
||||
store.startGoal('accept-payments')
|
||||
|
||||
store.completeStep('accept-payments', 'install-bitcoin')
|
||||
store.completeStep('accept-payments', 'install-bitcoin')
|
||||
|
||||
expect(store.progress['accept-payments']!.completedSteps.filter((s) => s === 'install-bitcoin')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('completeStep marks goal completed when all steps done', () => {
|
||||
const store = useGoalStore()
|
||||
store.startGoal('accept-payments')
|
||||
|
||||
store.completeStep('accept-payments', 'install-bitcoin')
|
||||
store.completeStep('accept-payments', 'install-lnd')
|
||||
store.completeStep('accept-payments', 'open-channel')
|
||||
|
||||
expect(store.progress['accept-payments']!.status).toBe('completed')
|
||||
})
|
||||
|
||||
it('completeStep is a no-op when goal not started', () => {
|
||||
const store = useGoalStore()
|
||||
|
||||
store.completeStep('accept-payments', 'install-bitcoin')
|
||||
|
||||
expect(store.progress['accept-payments']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resetGoal removes progress entry', () => {
|
||||
const store = useGoalStore()
|
||||
store.startGoal('accept-payments')
|
||||
expect(store.progress['accept-payments']).toBeDefined()
|
||||
|
||||
store.resetGoal('accept-payments')
|
||||
|
||||
expect(store.progress['accept-payments']).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('getGoalStatus', () => {
|
||||
it('returns not-started for unknown goal', () => {
|
||||
const store = useGoalStore()
|
||||
expect(store.getGoalStatus('nonexistent')).toBe('not-started')
|
||||
})
|
||||
|
||||
it('returns not-started when no apps installed and no progress', () => {
|
||||
const store = useGoalStore()
|
||||
expect(store.getGoalStatus('accept-payments')).toBe('not-started')
|
||||
})
|
||||
|
||||
it('returns completed when all required apps are running', () => {
|
||||
mockPackages['bitcoin-knots'] = { state: 'running' }
|
||||
mockPackages['lnd'] = { state: 'running' }
|
||||
|
||||
const store = useGoalStore()
|
||||
expect(store.getGoalStatus('accept-payments')).toBe('completed')
|
||||
})
|
||||
|
||||
it('returns in-progress when some required apps are installed', () => {
|
||||
mockPackages['bitcoin-knots'] = { state: 'running' }
|
||||
|
||||
const store = useGoalStore()
|
||||
expect(store.getGoalStatus('accept-payments')).toBe('in-progress')
|
||||
})
|
||||
|
||||
it('uses manual progress for goals without required apps', () => {
|
||||
const store = useGoalStore()
|
||||
|
||||
// create-identity has no required apps
|
||||
expect(store.getGoalStatus('create-identity')).toBe('not-started')
|
||||
|
||||
store.startGoal('create-identity')
|
||||
expect(store.getGoalStatus('create-identity')).toBe('in-progress')
|
||||
})
|
||||
|
||||
it('recognizes app aliases (immich-server matches immich)', () => {
|
||||
mockPackages['immich-server'] = { state: 'running' }
|
||||
|
||||
const store = useGoalStore()
|
||||
expect(store.getGoalStatus('store-photos')).toBe('completed')
|
||||
})
|
||||
|
||||
it('auto-syncs install steps from actual package state', () => {
|
||||
mockPackages['bitcoin-knots'] = { state: 'stopped' }
|
||||
|
||||
const store = useGoalStore()
|
||||
store.getGoalStatus('accept-payments')
|
||||
|
||||
// Should have auto-created progress and marked install-bitcoin as completed
|
||||
expect(store.progress['accept-payments']).toBeDefined()
|
||||
expect(store.progress['accept-payments']!.completedSteps).toContain('install-bitcoin')
|
||||
})
|
||||
})
|
||||
|
||||
it('goalStatuses computes status for all goals', () => {
|
||||
mockPackages['bitcoin-knots'] = { state: 'running' }
|
||||
mockPackages['lnd'] = { state: 'running' }
|
||||
|
||||
const store = useGoalStore()
|
||||
const statuses = store.goalStatuses
|
||||
|
||||
expect(statuses['accept-payments']).toBe('completed')
|
||||
expect(statuses['create-identity']).toBe('not-started')
|
||||
expect(statuses['store-photos']).toBe('not-started')
|
||||
})
|
||||
})
|
||||
52
neode-ui/src/stores/__tests__/loginTransition.test.ts
Normal file
52
neode-ui/src/stores/__tests__/loginTransition.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useLoginTransitionStore } from '../loginTransition'
|
||||
|
||||
describe('useLoginTransitionStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('starts with all flags false', () => {
|
||||
const store = useLoginTransitionStore()
|
||||
expect(store.justLoggedIn).toBe(false)
|
||||
expect(store.pendingWelcomeTyping).toBe(false)
|
||||
expect(store.startWelcomeTyping).toBe(false)
|
||||
})
|
||||
|
||||
it('setJustLoggedIn updates justLoggedIn', () => {
|
||||
const store = useLoginTransitionStore()
|
||||
store.setJustLoggedIn(true)
|
||||
expect(store.justLoggedIn).toBe(true)
|
||||
store.setJustLoggedIn(false)
|
||||
expect(store.justLoggedIn).toBe(false)
|
||||
})
|
||||
|
||||
it('setPendingWelcomeTyping updates pendingWelcomeTyping', () => {
|
||||
const store = useLoginTransitionStore()
|
||||
store.setPendingWelcomeTyping(true)
|
||||
expect(store.pendingWelcomeTyping).toBe(true)
|
||||
store.setPendingWelcomeTyping(false)
|
||||
expect(store.pendingWelcomeTyping).toBe(false)
|
||||
})
|
||||
|
||||
it('setStartWelcomeTyping updates startWelcomeTyping', () => {
|
||||
const store = useLoginTransitionStore()
|
||||
store.setStartWelcomeTyping(true)
|
||||
expect(store.startWelcomeTyping).toBe(true)
|
||||
store.setStartWelcomeTyping(false)
|
||||
expect(store.startWelcomeTyping).toBe(false)
|
||||
})
|
||||
|
||||
it('flags are independent of each other', () => {
|
||||
const store = useLoginTransitionStore()
|
||||
store.setJustLoggedIn(true)
|
||||
store.setPendingWelcomeTyping(true)
|
||||
expect(store.startWelcomeTyping).toBe(false)
|
||||
|
||||
store.setStartWelcomeTyping(true)
|
||||
store.setJustLoggedIn(false)
|
||||
expect(store.pendingWelcomeTyping).toBe(true)
|
||||
expect(store.startWelcomeTyping).toBe(true)
|
||||
})
|
||||
})
|
||||
81
neode-ui/src/stores/__tests__/screensaver.test.ts
Normal file
81
neode-ui/src/stores/__tests__/screensaver.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useScreensaverStore } from '../screensaver'
|
||||
|
||||
describe('useScreensaverStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('starts inactive', () => {
|
||||
const store = useScreensaverStore()
|
||||
expect(store.isActive).toBe(false)
|
||||
})
|
||||
|
||||
it('activate sets isActive to true', () => {
|
||||
const store = useScreensaverStore()
|
||||
store.activate()
|
||||
expect(store.isActive).toBe(true)
|
||||
})
|
||||
|
||||
it('deactivate sets isActive to false', () => {
|
||||
const store = useScreensaverStore()
|
||||
store.activate()
|
||||
store.deactivate()
|
||||
expect(store.isActive).toBe(false)
|
||||
})
|
||||
|
||||
it('deactivate starts inactivity timer that activates after 3 minutes', () => {
|
||||
const store = useScreensaverStore()
|
||||
store.deactivate()
|
||||
expect(store.isActive).toBe(false)
|
||||
|
||||
vi.advanceTimersByTime(3 * 60 * 1000)
|
||||
expect(store.isActive).toBe(true)
|
||||
})
|
||||
|
||||
it('resetInactivityTimer restarts the 3-minute countdown', () => {
|
||||
const store = useScreensaverStore()
|
||||
store.deactivate()
|
||||
|
||||
// Advance 2 minutes
|
||||
vi.advanceTimersByTime(2 * 60 * 1000)
|
||||
expect(store.isActive).toBe(false)
|
||||
|
||||
// Reset timer
|
||||
store.resetInactivityTimer()
|
||||
|
||||
// Advance another 2 minutes (would have triggered without reset)
|
||||
vi.advanceTimersByTime(2 * 60 * 1000)
|
||||
expect(store.isActive).toBe(false)
|
||||
|
||||
// Full 3 minutes from reset
|
||||
vi.advanceTimersByTime(1 * 60 * 1000)
|
||||
expect(store.isActive).toBe(true)
|
||||
})
|
||||
|
||||
it('clearInactivityTimer prevents activation', () => {
|
||||
const store = useScreensaverStore()
|
||||
store.deactivate()
|
||||
store.clearInactivityTimer()
|
||||
|
||||
vi.advanceTimersByTime(5 * 60 * 1000)
|
||||
expect(store.isActive).toBe(false)
|
||||
})
|
||||
|
||||
it('activate clears any pending timer', () => {
|
||||
const store = useScreensaverStore()
|
||||
store.deactivate()
|
||||
store.activate()
|
||||
|
||||
// If timer wasn't cleared, deactivating and waiting would trigger twice
|
||||
store.deactivate()
|
||||
vi.advanceTimersByTime(3 * 60 * 1000)
|
||||
expect(store.isActive).toBe(true)
|
||||
})
|
||||
})
|
||||
194
neode-ui/src/stores/__tests__/spotlight.test.ts
Normal file
194
neode-ui/src/stores/__tests__/spotlight.test.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
// Mock the nav sounds module
|
||||
vi.mock('@/composables/useNavSounds', () => ({
|
||||
playNavSound: vi.fn(),
|
||||
}))
|
||||
|
||||
import { useSpotlightStore } from '../spotlight'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const mockedPlayNavSound = vi.mocked(playNavSound)
|
||||
|
||||
describe('useSpotlightStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('starts closed with default state', () => {
|
||||
const store = useSpotlightStore()
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.selectedIndex).toBe(0)
|
||||
expect(store.recentItems).toEqual([])
|
||||
})
|
||||
|
||||
it('open sets isOpen to true and plays sound', () => {
|
||||
const store = useSpotlightStore()
|
||||
|
||||
store.open()
|
||||
|
||||
expect(store.isOpen).toBe(true)
|
||||
expect(store.selectedIndex).toBe(0)
|
||||
expect(mockedPlayNavSound).toHaveBeenCalledWith('action')
|
||||
})
|
||||
|
||||
it('close sets isOpen to false and resets index', () => {
|
||||
const store = useSpotlightStore()
|
||||
store.open()
|
||||
|
||||
store.close()
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.selectedIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('toggle opens when closed', () => {
|
||||
const store = useSpotlightStore()
|
||||
|
||||
store.toggle()
|
||||
|
||||
expect(store.isOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('toggle closes when open', () => {
|
||||
const store = useSpotlightStore()
|
||||
store.open()
|
||||
|
||||
store.toggle()
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('setSelectedIndex updates the selected index', () => {
|
||||
const store = useSpotlightStore()
|
||||
|
||||
store.setSelectedIndex(3)
|
||||
|
||||
expect(store.selectedIndex).toBe(3)
|
||||
})
|
||||
|
||||
describe('recent items', () => {
|
||||
it('addRecentItem adds item with timestamp', () => {
|
||||
const store = useSpotlightStore()
|
||||
|
||||
store.addRecentItem({ id: 'home', label: 'Home', path: '/dashboard', type: 'navigate' })
|
||||
|
||||
expect(store.recentItems).toHaveLength(1)
|
||||
expect(store.recentItems[0]!.id).toBe('home')
|
||||
expect(store.recentItems[0]!.label).toBe('Home')
|
||||
expect(store.recentItems[0]!.timestamp).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('addRecentItem deduplicates by id and type', () => {
|
||||
const store = useSpotlightStore()
|
||||
|
||||
store.addRecentItem({ id: 'home', label: 'Home', path: '/dashboard', type: 'navigate' })
|
||||
store.addRecentItem({ id: 'home', label: 'Home Updated', path: '/dashboard', type: 'navigate' })
|
||||
|
||||
expect(store.recentItems).toHaveLength(1)
|
||||
expect(store.recentItems[0]!.label).toBe('Home Updated')
|
||||
})
|
||||
|
||||
it('addRecentItem keeps different types with same id', () => {
|
||||
const store = useSpotlightStore()
|
||||
|
||||
store.addRecentItem({ id: 'bitcoin', label: 'Bitcoin (navigate)', type: 'navigate' })
|
||||
store.addRecentItem({ id: 'bitcoin', label: 'Bitcoin (action)', type: 'action' })
|
||||
|
||||
expect(store.recentItems).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('addRecentItem caps at 8 items', () => {
|
||||
const store = useSpotlightStore()
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
store.addRecentItem({ id: `item-${i}`, label: `Item ${i}`, type: 'navigate' })
|
||||
}
|
||||
|
||||
expect(store.recentItems).toHaveLength(8)
|
||||
// Most recent should be first
|
||||
expect(store.recentItems[0]!.id).toBe('item-9')
|
||||
})
|
||||
|
||||
it('addRecentItem persists to localStorage', () => {
|
||||
const store = useSpotlightStore()
|
||||
|
||||
store.addRecentItem({ id: 'apps', label: 'Apps', path: '/apps', type: 'navigate' })
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem('archipelago-spotlight-recent')!)
|
||||
expect(stored).toHaveLength(1)
|
||||
expect(stored[0].id).toBe('apps')
|
||||
})
|
||||
|
||||
it('loadRecentItems reads from localStorage', () => {
|
||||
const saved = [
|
||||
{ id: 'home', label: 'Home', path: '/dashboard', type: 'navigate', timestamp: 1000 },
|
||||
{ id: 'apps', label: 'Apps', path: '/apps', type: 'navigate', timestamp: 2000 },
|
||||
]
|
||||
localStorage.setItem('archipelago-spotlight-recent', JSON.stringify(saved))
|
||||
|
||||
const store = useSpotlightStore()
|
||||
store.loadRecentItems()
|
||||
|
||||
expect(store.recentItems).toHaveLength(2)
|
||||
expect(store.recentItems[0]!.id).toBe('home')
|
||||
})
|
||||
|
||||
it('loadRecentItems handles corrupt localStorage', () => {
|
||||
localStorage.setItem('archipelago-spotlight-recent', 'not-json{{{')
|
||||
|
||||
const store = useSpotlightStore()
|
||||
store.loadRecentItems()
|
||||
|
||||
expect(store.recentItems).toEqual([])
|
||||
})
|
||||
|
||||
it('loadRecentItems handles empty localStorage', () => {
|
||||
const store = useSpotlightStore()
|
||||
store.loadRecentItems()
|
||||
|
||||
expect(store.recentItems).toEqual([])
|
||||
})
|
||||
|
||||
it('open calls loadRecentItems', () => {
|
||||
const saved = [
|
||||
{ id: 'test', label: 'Test', type: 'navigate', timestamp: 1000 },
|
||||
]
|
||||
localStorage.setItem('archipelago-spotlight-recent', JSON.stringify(saved))
|
||||
|
||||
const store = useSpotlightStore()
|
||||
store.open()
|
||||
|
||||
expect(store.recentItems).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('help modal', () => {
|
||||
it('showHelpModal opens the modal with content', () => {
|
||||
const store = useSpotlightStore()
|
||||
|
||||
store.showHelpModal({
|
||||
title: 'What is Bitcoin?',
|
||||
content: 'A peer-to-peer electronic cash system.',
|
||||
relatedPath: '/apps/bitcoin',
|
||||
})
|
||||
|
||||
expect(store.helpModal.show).toBe(true)
|
||||
expect(store.helpModal.title).toBe('What is Bitcoin?')
|
||||
expect(store.helpModal.content).toBe('A peer-to-peer electronic cash system.')
|
||||
expect(store.helpModal.relatedPath).toBe('/apps/bitcoin')
|
||||
})
|
||||
|
||||
it('closeHelpModal closes the modal', () => {
|
||||
const store = useSpotlightStore()
|
||||
store.showHelpModal({ title: 'Test', content: 'Content' })
|
||||
|
||||
store.closeHelpModal()
|
||||
|
||||
expect(store.helpModal.show).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
90
neode-ui/src/stores/__tests__/uiMode.test.ts
Normal file
90
neode-ui/src/stores/__tests__/uiMode.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useUIModeStore } from '../uiMode'
|
||||
|
||||
describe('useUIModeStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('defaults to gamer mode when no stored value', () => {
|
||||
const store = useUIModeStore()
|
||||
expect(store.mode).toBe('gamer')
|
||||
expect(store.isGamer).toBe(true)
|
||||
expect(store.isEasy).toBe(false)
|
||||
expect(store.isChat).toBe(false)
|
||||
})
|
||||
|
||||
it('loads stored mode from localStorage', () => {
|
||||
localStorage.setItem('archipelago-ui-mode', 'easy')
|
||||
const store = useUIModeStore()
|
||||
expect(store.mode).toBe('easy')
|
||||
expect(store.isEasy).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores invalid localStorage values', () => {
|
||||
localStorage.setItem('archipelago-ui-mode', 'invalid-mode')
|
||||
const store = useUIModeStore()
|
||||
expect(store.mode).toBe('gamer')
|
||||
})
|
||||
|
||||
it('setMode updates mode and persists', () => {
|
||||
const store = useUIModeStore()
|
||||
store.setMode('easy')
|
||||
expect(store.mode).toBe('easy')
|
||||
expect(store.isEasy).toBe(true)
|
||||
expect(localStorage.getItem('archipelago-ui-mode')).toBe('easy')
|
||||
})
|
||||
|
||||
it('setMode to chat mode', () => {
|
||||
const store = useUIModeStore()
|
||||
store.setMode('chat')
|
||||
expect(store.mode).toBe('chat')
|
||||
expect(store.isChat).toBe(true)
|
||||
expect(store.isGamer).toBe(false)
|
||||
})
|
||||
|
||||
it('cycleMode cycles between easy and gamer', () => {
|
||||
const store = useUIModeStore()
|
||||
// Start at gamer
|
||||
expect(store.mode).toBe('gamer')
|
||||
|
||||
// Cycle to easy
|
||||
const next1 = store.cycleMode()
|
||||
expect(next1).toBe('easy')
|
||||
expect(store.mode).toBe('easy')
|
||||
|
||||
// Cycle back to gamer (wraps after easy since order is [easy, gamer])
|
||||
const next2 = store.cycleMode()
|
||||
expect(next2).toBe('gamer')
|
||||
expect(store.mode).toBe('gamer')
|
||||
})
|
||||
|
||||
it('cycleMode from chat wraps to easy', () => {
|
||||
const store = useUIModeStore()
|
||||
store.setMode('chat')
|
||||
const next = store.cycleMode()
|
||||
// chat is not in the order array, so idx=-1, next = order[0] = easy
|
||||
expect(next).toBe('easy')
|
||||
})
|
||||
|
||||
it('syncFromBackend updates mode from backend', () => {
|
||||
const store = useUIModeStore()
|
||||
store.syncFromBackend('easy')
|
||||
expect(store.mode).toBe('easy')
|
||||
expect(localStorage.getItem('archipelago-ui-mode')).toBe('easy')
|
||||
})
|
||||
|
||||
it('syncFromBackend ignores invalid modes', () => {
|
||||
const store = useUIModeStore()
|
||||
store.syncFromBackend('invalid' as 'gamer')
|
||||
expect(store.mode).toBe('gamer') // unchanged
|
||||
})
|
||||
|
||||
it('syncFromBackend ignores undefined', () => {
|
||||
const store = useUIModeStore()
|
||||
store.syncFromBackend(undefined)
|
||||
expect(store.mode).toBe('gamer') // unchanged
|
||||
})
|
||||
})
|
||||
78
neode-ui/src/stores/__tests__/web5Badge.test.ts
Normal file
78
neode-ui/src/stores/__tests__/web5Badge.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useWeb5BadgeStore } from '../web5Badge'
|
||||
|
||||
// Mock rpcClient
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
describe('useWeb5BadgeStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('starts with zero pending requests', () => {
|
||||
const store = useWeb5BadgeStore()
|
||||
expect(store.pendingRequestCount).toBe(0)
|
||||
})
|
||||
|
||||
it('refresh updates count from API', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({
|
||||
requests: [{ id: '1' }, { id: '2' }, { id: '3' }],
|
||||
})
|
||||
|
||||
const store = useWeb5BadgeStore()
|
||||
await store.refresh()
|
||||
|
||||
expect(store.pendingRequestCount).toBe(3)
|
||||
expect(rpcClient.call).toHaveBeenCalledWith({ method: 'network.list-requests' })
|
||||
})
|
||||
|
||||
it('refresh handles empty requests', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({ requests: [] })
|
||||
|
||||
const store = useWeb5BadgeStore()
|
||||
await store.refresh()
|
||||
|
||||
expect(store.pendingRequestCount).toBe(0)
|
||||
})
|
||||
|
||||
it('refresh handles null requests gracefully', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({ requests: null })
|
||||
|
||||
const store = useWeb5BadgeStore()
|
||||
await store.refresh()
|
||||
|
||||
expect(store.pendingRequestCount).toBe(0)
|
||||
})
|
||||
|
||||
it('refresh handles API error gracefully', async () => {
|
||||
vi.mocked(rpcClient.call).mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const store = useWeb5BadgeStore()
|
||||
store.pendingRequestCount = 5 // pre-existing value
|
||||
await store.refresh()
|
||||
|
||||
// Should not throw, count stays at pre-existing value (error swallowed)
|
||||
expect(store.pendingRequestCount).toBe(5)
|
||||
})
|
||||
|
||||
it('refresh updates count on subsequent calls', async () => {
|
||||
vi.mocked(rpcClient.call)
|
||||
.mockResolvedValueOnce({ requests: [{ id: '1' }] })
|
||||
.mockResolvedValueOnce({ requests: [{ id: '1' }, { id: '2' }] })
|
||||
|
||||
const store = useWeb5BadgeStore()
|
||||
await store.refresh()
|
||||
expect(store.pendingRequestCount).toBe(1)
|
||||
|
||||
await store.refresh()
|
||||
expect(store.pendingRequestCount).toBe(2)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user