refactor: update dependencies and remove unused code
- Added new dependencies: `adler2`, `crc32fast`, `flate2`, `miniz_oxide`, and `libredox`. - Updated existing dependencies: `tokio-rustls` to version 0.26.4 and `filetime` to version 0.2.27. - Removed the `backup.rs` file as it is no longer needed. - Introduced tests for configuration and credential management. - Enhanced the `identity` module to generate W3C compliant DID documents. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
138
neode-ui/src/composables/__tests__/useAudioPlayer.test.ts
Normal file
138
neode-ui/src/composables/__tests__/useAudioPlayer.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { useAudioPlayer } from '../useAudioPlayer'
|
||||
|
||||
// Mock HTMLAudioElement
|
||||
class MockAudio {
|
||||
src = ''
|
||||
currentTime = 0
|
||||
duration = 120
|
||||
paused = true
|
||||
private listeners: Record<string, Array<() => void>> = {}
|
||||
|
||||
addEventListener(event: string, handler: () => void) {
|
||||
if (!this.listeners[event]) this.listeners[event] = []
|
||||
this.listeners[event].push(handler)
|
||||
}
|
||||
|
||||
removeEventListener() {
|
||||
// no-op for tests
|
||||
}
|
||||
|
||||
play() {
|
||||
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()
|
||||
})
|
||||
|
||||
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('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)
|
||||
})
|
||||
})
|
||||
202
neode-ui/src/composables/__tests__/useFileType.test.ts
Normal file
202
neode-ui/src/composables/__tests__/useFileType.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
97
neode-ui/src/composables/__tests__/useMarketplaceApp.test.ts
Normal file
97
neode-ui/src/composables/__tests__/useMarketplaceApp.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
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' })
|
||||
})
|
||||
})
|
||||
178
neode-ui/src/composables/__tests__/useMessageToast.test.ts
Normal file
178
neode-ui/src/composables/__tests__/useMessageToast.test.ts
Normal file
@@ -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: '' }
|
||||
})
|
||||
|
||||
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' }
|
||||
toast.dismissToastAndOpenMessages()
|
||||
|
||||
expect(toast.toastMessage.value.show).toBe(false)
|
||||
expect(mockPush).toHaveBeenCalledWith({ path: '/dashboard/web5', query: { tab: 'messages' } })
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
131
neode-ui/src/composables/__tests__/useToast.test.ts
Normal file
131
neode-ui/src/composables/__tests__/useToast.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -93,7 +93,7 @@ export function stopSynthwave() {
|
||||
if (introAudio) {
|
||||
if (introGain && audioContext) {
|
||||
const t = audioContext.currentTime
|
||||
introGain.gain.setValueAtTime(1, t)
|
||||
introGain.gain.setValueAtTime(introGain.gain.value, t)
|
||||
introGain.gain.linearRampToValueAtTime(0.001, t + 0.2)
|
||||
}
|
||||
setTimeout(() => {
|
||||
@@ -104,6 +104,23 @@ export function stopSynthwave() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop ALL login audio and close AudioContext. Call on route change to dashboard. */
|
||||
export function stopAllAudio() {
|
||||
// Stop synthwave loop
|
||||
if (introAudio) {
|
||||
introAudio.pause()
|
||||
introAudio = null
|
||||
introGain = null
|
||||
}
|
||||
// Stop intro typing
|
||||
stopIntroTyping()
|
||||
// Close AudioContext to kill any lingering BufferSource nodes (playLoopStart)
|
||||
if (audioContext) {
|
||||
audioContext.close().catch(() => {})
|
||||
audioContext = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Pop sound - plays when intro initiator (tap to start) is pressed */
|
||||
export function playPop() {
|
||||
const audio = new Audio('/assets/audio/pop.mp3')
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface MarketplaceAppInfo {
|
||||
repoUrl: string
|
||||
s9pkUrl: string
|
||||
dockerImage: string
|
||||
/** External web URL for iframe-based web apps (no container needed) */
|
||||
webUrl?: string
|
||||
}
|
||||
|
||||
// Simple in-memory store for the current marketplace app
|
||||
@@ -36,6 +38,7 @@ export function useMarketplaceApp() {
|
||||
repoUrl: app.repoUrl ?? '',
|
||||
s9pkUrl: app.s9pkUrl ?? '',
|
||||
dockerImage: app.dockerImage ?? '',
|
||||
webUrl: (app as Record<string, unknown>).webUrl as string | undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ export function useMessageToast() {
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
console.error('Failed to load messages:', e)
|
||||
if (import.meta.env.DEV) console.error('Failed to load messages:', e)
|
||||
} finally {
|
||||
loadingMessages.value = false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user