Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,448 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
adaptContentItems,
|
||||
adaptToFilm,
|
||||
adaptToPodcast,
|
||||
adaptLibraryTracks,
|
||||
adaptLibraryAlbums,
|
||||
classifyByMime,
|
||||
sortDeterministic,
|
||||
type ArchyContentItem,
|
||||
type ArchyLibraryTrack,
|
||||
} from '../archyContentAdapter'
|
||||
|
||||
function item(overrides: Partial<ArchyContentItem>): ArchyContentItem {
|
||||
return {
|
||||
id: 'id-1',
|
||||
filename: 'file.bin',
|
||||
mime_type: 'application/octet-stream',
|
||||
size_bytes: 1024,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('classifyByMime', () => {
|
||||
it('classifies a video mime as video', () => {
|
||||
expect(classifyByMime(item({ mime_type: 'video/mp4', filename: 'movie.mp4' }))).toBe('video')
|
||||
})
|
||||
|
||||
it('classifies an audio mime as audio', () => {
|
||||
expect(classifyByMime(item({ mime_type: 'audio/mpeg', filename: 'song.mp3' }))).toBe('audio')
|
||||
})
|
||||
|
||||
it('classifies images as image, and still excludes documents', () => {
|
||||
expect(classifyByMime(item({ mime_type: 'image/jpeg', filename: 'photo.jpg' }))).toBe('image')
|
||||
expect(classifyByMime(item({ mime_type: 'application/pdf', filename: 'doc.pdf' }))).toBe('excluded')
|
||||
})
|
||||
|
||||
it('classifies images by extension when the mime is generic', () => {
|
||||
// A node whose catalog is mostly photos shared with an unidentified
|
||||
// mime must not present as empty.
|
||||
expect(classifyByMime(item({ mime_type: 'application/octet-stream', filename: 'p.webp' }))).toBe('image')
|
||||
expect(classifyByMime(item({ mime_type: 'application/octet-stream', filename: 'p.heic' }))).toBe('image')
|
||||
})
|
||||
|
||||
it('classifies m4a, aac, opus and wma as audio via extension fallback (ShareModal blind spot)', () => {
|
||||
expect(classifyByMime(item({ mime_type: 'application/octet-stream', filename: 'track.m4a' }))).toBe('audio')
|
||||
expect(classifyByMime(item({ mime_type: 'application/octet-stream', filename: 'track.aac' }))).toBe('audio')
|
||||
expect(classifyByMime(item({ mime_type: 'application/octet-stream', filename: 'track.opus' }))).toBe('audio')
|
||||
expect(classifyByMime(item({ mime_type: 'application/octet-stream', filename: 'track.wma' }))).toBe('audio')
|
||||
})
|
||||
|
||||
it('also classifies the correct audio/* mime for those four extensions', () => {
|
||||
expect(classifyByMime(item({ mime_type: 'audio/mp4', filename: 'track.m4a' }))).toBe('audio')
|
||||
expect(classifyByMime(item({ mime_type: 'audio/aac', filename: 'track.aac' }))).toBe('audio')
|
||||
expect(classifyByMime(item({ mime_type: 'audio/opus', filename: 'track.opus' }))).toBe('audio')
|
||||
expect(classifyByMime(item({ mime_type: 'audio/x-ms-wma', filename: 'track.wma' }))).toBe('audio')
|
||||
})
|
||||
})
|
||||
|
||||
describe('adaptContentItems', () => {
|
||||
it('maps a video-mime item to a Film with id carried through and one source entry', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[item({ id: 'film-1', filename: 'The Movie.mp4', mime_type: 'video/mp4', added_at: '2026-01-01T00:00:00Z' })],
|
||||
{ source: 'own' },
|
||||
)
|
||||
expect(bundle.films).toHaveLength(1)
|
||||
expect(bundle.films[0]!.id).toBe('film-1')
|
||||
expect(bundle.films[0]!.title).toBe('The Movie')
|
||||
expect(bundle.films[0]!.sources).toHaveLength(1)
|
||||
expect(bundle.songs).toHaveLength(0)
|
||||
expect(bundle.podcasts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('maps an audio-mime item to a Song', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[item({ id: 'song-1', filename: 'Track.mp3', mime_type: 'audio/mpeg', added_at: '2026-01-01T00:00:00Z' })],
|
||||
{ source: 'own' },
|
||||
)
|
||||
expect(bundle.songs).toHaveLength(1)
|
||||
expect(bundle.songs[0]!.id).toBe('song-1')
|
||||
expect(bundle.films).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('routes images to the images bucket and still excludes documents', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[
|
||||
item({ id: 'img-1', filename: 'photo.jpg', mime_type: 'image/jpeg' }),
|
||||
item({ id: 'doc-1', filename: 'report.pdf', mime_type: 'application/pdf' }),
|
||||
],
|
||||
{ source: 'own' },
|
||||
)
|
||||
expect(bundle.films).toHaveLength(0)
|
||||
expect(bundle.songs).toHaveLength(0)
|
||||
expect(bundle.podcasts).toHaveLength(0)
|
||||
// The photo renders; the PDF has no grid, so it stays out of every bucket.
|
||||
expect(bundle.images).toHaveLength(1)
|
||||
expect(bundle.images[0]!.id).toBe('img-1')
|
||||
expect(bundle.images[0]!.url).toBe('/content/img-1')
|
||||
})
|
||||
|
||||
it('never locks an OWN paid image: the node serves the authenticated owner (owner-bypass), price stays as a badge', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[item({ id: 'p-1', filename: 'photo.jpg', mime_type: 'image/jpeg', access: { paid: { price_sats: 100 } } })],
|
||||
{ source: 'own' },
|
||||
)
|
||||
expect(bundle.images[0]!.locked).toBe(false)
|
||||
expect(bundle.images[0]!.priceSats).toBe(100)
|
||||
expect(bundle.images[0]!.url).toBe('/content/p-1')
|
||||
})
|
||||
|
||||
it('locks a PEER paid image: price carried, no URL to fetch bytes the user has not bought', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[item({ id: 'p-1', filename: 'photo.jpg', mime_type: 'image/jpeg', access: { paid: { price_sats: 100 } } })],
|
||||
{ source: 'peer', peerOnion: 'seller.onion' },
|
||||
)
|
||||
expect(bundle.images[0]!.locked).toBe(true)
|
||||
expect(bundle.images[0]!.priceSats).toBe(100)
|
||||
expect(bundle.images[0]!.url).toBe('')
|
||||
})
|
||||
|
||||
it('maps an access:Paid item with a price and a locked flag, and no playable source URL', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[
|
||||
item({
|
||||
id: 'paid-1',
|
||||
filename: 'premium.mp4',
|
||||
mime_type: 'video/mp4',
|
||||
access: { paid: { price_sats: 5000 } },
|
||||
}),
|
||||
],
|
||||
{ source: 'peer', peerOnion: 'abc123.onion' },
|
||||
)
|
||||
const film = bundle.films[0]!
|
||||
expect(film.locked).toBe(true)
|
||||
expect(film.priceSats).toBe(5000)
|
||||
expect(film.sources[0]!.url).toBe('')
|
||||
})
|
||||
|
||||
it('gives two items with identical filename and size but different id two distinct cards (adjacency)', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[
|
||||
item({ id: 'peer-a', filename: 'same.mp4', mime_type: 'video/mp4', size_bytes: 500, added_at: '2026-01-01T00:00:00Z' }),
|
||||
item({ id: 'peer-b', filename: 'same.mp4', mime_type: 'video/mp4', size_bytes: 500, added_at: '2026-01-01T00:00:00Z' }),
|
||||
],
|
||||
{ source: 'peer', peerOnion: 'peer.onion' },
|
||||
)
|
||||
expect(bundle.films).toHaveLength(2)
|
||||
const ids = bundle.films.map((f) => f.id)
|
||||
expect(new Set(ids).size).toBe(2)
|
||||
expect(ids).toContain('peer-a')
|
||||
expect(ids).toContain('peer-b')
|
||||
})
|
||||
|
||||
it('an item present both in own library and a peer share appears once per source (adjacency, cross-source)', () => {
|
||||
const own = adaptContentItems(
|
||||
[item({ id: 'shared-item', filename: 'clip.mp4', mime_type: 'video/mp4', size_bytes: 100, added_at: '2026-01-01T00:00:00Z' })],
|
||||
{ source: 'own' },
|
||||
)
|
||||
const peer = adaptContentItems(
|
||||
[item({ id: 'shared-item', filename: 'clip.mp4', mime_type: 'video/mp4', size_bytes: 100, added_at: '2026-01-01T00:00:00Z' })],
|
||||
{ source: 'peer', peerOnion: 'peer.onion' },
|
||||
)
|
||||
// Each source's bundle carries its own single card for the id — the
|
||||
// broker (Task 2) is responsible for not silently merging bundles from
|
||||
// different sources into one deduplicated list.
|
||||
expect(own.films).toHaveLength(1)
|
||||
expect(peer.films).toHaveLength(1)
|
||||
expect(own.films[0]!.sources[0]!.type).not.toBe(peer.films[0]!.sources[0]!.type)
|
||||
})
|
||||
|
||||
it('an empty input array produces empty films/songs/podcasts arrays, not undefined or an error', () => {
|
||||
const bundle = adaptContentItems([], { source: 'own' })
|
||||
expect(bundle).toEqual({ films: [], songs: [], podcasts: [], images: [] })
|
||||
})
|
||||
|
||||
it('handles null/undefined input the same as an empty array', () => {
|
||||
expect(adaptContentItems(null, { source: 'own' })).toEqual({ films: [], songs: [], podcasts: [], images: [] })
|
||||
expect(adaptContentItems(undefined, { source: 'own' })).toEqual({ films: [], songs: [], podcasts: [], images: [] })
|
||||
})
|
||||
|
||||
it('maps a null/absent description to an empty string, never the literal "null"', () => {
|
||||
const withNull = adaptContentItems(
|
||||
[item({ id: 'f1', filename: 'a.mp4', mime_type: 'video/mp4', description: null })],
|
||||
{ source: 'own' },
|
||||
)
|
||||
const withAbsent = adaptContentItems(
|
||||
[item({ id: 'f2', filename: 'b.mp4', mime_type: 'video/mp4' })],
|
||||
{ source: 'own' },
|
||||
)
|
||||
expect(withNull.films[0]!.synopsis).toBe('')
|
||||
expect(withAbsent.films[0]!.synopsis).toBe('')
|
||||
})
|
||||
|
||||
it('sorts added_at descending with id ascending as the deterministic tiebreak', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[
|
||||
item({ id: 'z', filename: 'a.mp4', mime_type: 'video/mp4', added_at: '2026-01-01T00:00:00Z' }),
|
||||
item({ id: 'a', filename: 'b.mp4', mime_type: 'video/mp4', added_at: '2026-01-01T00:00:00Z' }),
|
||||
item({ id: 'm', filename: 'c.mp4', mime_type: 'video/mp4', added_at: '2026-02-01T00:00:00Z' }),
|
||||
],
|
||||
{ source: 'own' },
|
||||
)
|
||||
// Newest added_at first (m), then the 2026-01-01 pair tie-broken by id ascending (a, z).
|
||||
expect(bundle.films.map((f) => f.id)).toEqual(['m', 'a', 'z'])
|
||||
})
|
||||
|
||||
it('a null added_at sorts last rather than crashing the comparator', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[
|
||||
item({ id: 'has-date', filename: 'a.mp4', mime_type: 'video/mp4', added_at: '2026-01-01T00:00:00Z' }),
|
||||
item({ id: 'no-date', filename: 'b.mp4', mime_type: 'video/mp4', added_at: null }),
|
||||
],
|
||||
{ source: 'own' },
|
||||
)
|
||||
expect(bundle.films.map((f) => f.id)).toEqual(['has-date', 'no-date'])
|
||||
})
|
||||
|
||||
it('produces identical output order regardless of input array order (repeat-call stability)', () => {
|
||||
const items = [
|
||||
item({ id: 'a', filename: 'a.mp4', mime_type: 'video/mp4', added_at: '2026-01-01T00:00:00Z' }),
|
||||
item({ id: 'b', filename: 'b.mp4', mime_type: 'video/mp4', added_at: '2026-02-01T00:00:00Z' }),
|
||||
item({ id: 'c', filename: 'c.mp4', mime_type: 'video/mp4', added_at: '2026-01-15T00:00:00Z' }),
|
||||
]
|
||||
const first = adaptContentItems(items, { source: 'own' })
|
||||
const second = adaptContentItems([...items].reverse(), { source: 'own' })
|
||||
expect(first.films.map((f) => f.id)).toEqual(second.films.map((f) => f.id))
|
||||
})
|
||||
|
||||
it('never produces a URL carrying a credential as a query parameter', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[
|
||||
item({ id: 'own-1', filename: 'a.mp4', mime_type: 'video/mp4' }),
|
||||
item({ id: 'song-1', filename: 'b.mp3', mime_type: 'audio/mpeg' }),
|
||||
],
|
||||
{ source: 'own' },
|
||||
)
|
||||
const peerBundle = adaptContentItems(
|
||||
[item({ id: 'peer-1', filename: 'c.mp4', mime_type: 'video/mp4' })],
|
||||
{ source: 'peer', peerOnion: 'somepeer.onion' },
|
||||
)
|
||||
const allUrls = [
|
||||
...bundle.films.flatMap((f) => f.sources.map((s) => s.url)),
|
||||
...bundle.songs.flatMap((s) => (s.sources ?? []).map((src) => src.url)),
|
||||
...peerBundle.films.flatMap((f) => f.sources.map((s) => s.url)),
|
||||
]
|
||||
for (const url of allUrls) {
|
||||
expect(url).not.toMatch(/[?&](auth|token)=/)
|
||||
}
|
||||
})
|
||||
|
||||
it('shape-pins every field FilmGrid.vue and SongGrid.vue read', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[
|
||||
item({ id: 'film-shape', filename: 'Shape Test.mp4', mime_type: 'video/mp4', added_at: '2026-01-01T00:00:00Z' }),
|
||||
item({ id: 'song-shape', filename: 'Shape Song.mp3', mime_type: 'audio/mpeg', added_at: '2026-01-01T00:00:00Z' }),
|
||||
],
|
||||
{ source: 'own' },
|
||||
)
|
||||
const film = bundle.films[0]!
|
||||
// FilmGrid.vue reads: id, title, year, director, cast (search/aria-label),
|
||||
// rating, sources[].type (badges), coverSrc()/fallbackSrc() consume
|
||||
// posterUrl/backdropUrl/title/year, genres (topGenres filter).
|
||||
expect(typeof film.id).toBe('string')
|
||||
expect(typeof film.title).toBe('string')
|
||||
expect(typeof film.year).toBe('number')
|
||||
expect(typeof film.director).toBe('string')
|
||||
expect(Array.isArray(film.cast)).toBe(true)
|
||||
expect(typeof film.rating).toBe('number')
|
||||
expect(Array.isArray(film.genres)).toBe(true)
|
||||
expect(Array.isArray(film.sources)).toBe(true)
|
||||
expect(film.sources.length).toBeGreaterThan(0)
|
||||
expect(typeof film.sources[0]!.type).toBe('string')
|
||||
|
||||
const song = bundle.songs[0]!
|
||||
// SongGrid.vue reads: id, title, artist (search/aria-label), album
|
||||
// (search), genres (topGenres), coverUrl, sources[].type (badges).
|
||||
expect(typeof song.id).toBe('string')
|
||||
expect(typeof song.title).toBe('string')
|
||||
expect(typeof song.artist).toBe('string')
|
||||
expect(Array.isArray(song.sources)).toBe(true)
|
||||
expect((song.sources ?? []).length).toBeGreaterThan(0)
|
||||
expect(typeof song.sources![0]!.type).toBe('string')
|
||||
})
|
||||
|
||||
it('pins the three source-badge literal values for own/peer/indeehub films', () => {
|
||||
const own = adaptToFilm(item({ id: 'x', filename: 'x.mp4', mime_type: 'video/mp4' }), { source: 'own' })
|
||||
const peer = adaptToFilm(item({ id: 'y', filename: 'y.mp4', mime_type: 'video/mp4' }), { source: 'peer', peerOnion: 'p.onion' })
|
||||
const indeehub = adaptToFilm(item({ id: 'z', filename: 'z.mp4', mime_type: 'video/mp4' }), { source: 'indeehub' })
|
||||
expect(own.sources[0]!.type).toBe('nextcloud')
|
||||
expect(peer.sources[0]!.type).toBe('plex')
|
||||
expect(indeehub.sources[0]!.type).toBe('indeehub')
|
||||
})
|
||||
})
|
||||
|
||||
describe('adaptToPodcast', () => {
|
||||
it('maps a ContentItem to a Podcast shape (exported for completeness; not reachable via adaptContentItems today)', () => {
|
||||
const podcast = adaptToPodcast(
|
||||
item({ id: 'pod-1', filename: 'Episode One.mp3', mime_type: 'audio/mpeg', description: 'A description' }),
|
||||
{ source: 'own' },
|
||||
)
|
||||
expect(podcast.id).toBe('pod-1')
|
||||
expect(podcast.title).toBe('Episode One')
|
||||
expect(podcast.description).toBe('A description')
|
||||
expect(Array.isArray(podcast.sources)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortDeterministic', () => {
|
||||
it('is a pure function that does not mutate its input', () => {
|
||||
const items = [
|
||||
item({ id: 'b', filename: 'b.mp4', added_at: '2026-01-01T00:00:00Z' }),
|
||||
item({ id: 'a', filename: 'a.mp4', added_at: '2026-02-01T00:00:00Z' }),
|
||||
]
|
||||
const copy = [...items]
|
||||
sortDeterministic(items)
|
||||
expect(items).toEqual(copy)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Library mapping (13-11) ────────────────────────────────────────────
|
||||
|
||||
function track(overrides: Partial<ArchyLibraryTrack>): ArchyLibraryTrack {
|
||||
return {
|
||||
id: { source: 'OwnLibrary', path: '/var/lib/archipelago/filebrowser/Music/Artist/Album/01 Song.flac' },
|
||||
title: 'Song',
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
album_artist: 'Artist',
|
||||
track_number: 1,
|
||||
disc_number: 1,
|
||||
year: 2024,
|
||||
duration_secs: 210,
|
||||
has_tags: true,
|
||||
content_hash: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('adaptLibraryTracks', () => {
|
||||
it('maps a music.list-tracks record to a Song with title/artist/album/duration carried through from tags', () => {
|
||||
const [song] = adaptLibraryTracks([
|
||||
track({ title: 'Night Drive', artist: 'The Synths', album: 'Neon', duration_secs: 187 }),
|
||||
])
|
||||
expect(song!.title).toBe('Night Drive')
|
||||
expect(song!.artist).toBe('The Synths')
|
||||
expect(song!.album).toBe('Neon')
|
||||
expect(song!.duration).toBe(187)
|
||||
})
|
||||
|
||||
it('falls back to album_artist when artist is absent, and to an empty string when both are absent — never null/undefined', () => {
|
||||
const [withAlbumArtist] = adaptLibraryTracks([track({ artist: null, album_artist: 'Various' })])
|
||||
expect(withAlbumArtist!.artist).toBe('Various')
|
||||
|
||||
const [withNeither] = adaptLibraryTracks([track({ artist: null, album_artist: null })])
|
||||
expect(withNeither!.artist).toBe('')
|
||||
expect(withNeither!.artist).not.toBe('null')
|
||||
expect(withNeither!.artist).not.toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves the index\'s own deterministic order — calling the adapter twice on the same input yields the same order', () => {
|
||||
const tracks = [
|
||||
track({ id: { source: 'OwnLibrary', path: '/a' }, title: 'A' }),
|
||||
track({ id: { source: 'OwnLibrary', path: '/b' }, title: 'B' }),
|
||||
track({ id: { source: 'OwnLibrary', path: '/c' }, title: 'C' }),
|
||||
]
|
||||
const first = adaptLibraryTracks(tracks).map((s) => s.title)
|
||||
const second = adaptLibraryTracks(tracks).map((s) => s.title)
|
||||
expect(first).toEqual(['A', 'B', 'C'])
|
||||
expect(second).toEqual(first)
|
||||
})
|
||||
|
||||
it('a track with no cover art maps with an absent coverUrl, not a broken-image URL', () => {
|
||||
const [song] = adaptLibraryTracks([track({})])
|
||||
expect(song!.coverUrl).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a peer-sourced track carries a source entry distinguishing it from an own-library track (same three pinned literals)', () => {
|
||||
const [own] = adaptLibraryTracks([track({ id: { source: 'OwnLibrary', path: '/x' } })])
|
||||
const [peer] = adaptLibraryTracks([
|
||||
track({ id: { source: { Peer: { onion: 'abc123.onion' } }, path: '/var/lib/archipelago/purchased-content/abc123.onion/content-1' } }),
|
||||
])
|
||||
expect(own!.sources![0]!.type).toBe('funkwhale')
|
||||
expect(peer!.sources![0]!.type).toBe('plex')
|
||||
expect(own!.sources![0]!.type).not.toBe(peer!.sources![0]!.type)
|
||||
})
|
||||
|
||||
it('never produces a playback URL carrying a credential as a query parameter', () => {
|
||||
const songs = adaptLibraryTracks([
|
||||
track({ id: { source: 'OwnLibrary', path: '/var/lib/archipelago/filebrowser/Music/a.flac' } }),
|
||||
track({
|
||||
id: { source: { Peer: { onion: 'peer.onion' } }, path: '/var/lib/archipelago/purchased-content/peer.onion/content-9' },
|
||||
}),
|
||||
])
|
||||
for (const song of songs) {
|
||||
for (const source of song.sources ?? []) {
|
||||
expect(source.url).not.toMatch(/[?&](auth|token)=/)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('an own-library track resolves through the existing FileBrowser raw-file route with no query string', () => {
|
||||
const [song] = adaptLibraryTracks([
|
||||
track({ id: { source: 'OwnLibrary', path: '/var/lib/archipelago/filebrowser/Music/Artist/Song.flac' } }),
|
||||
])
|
||||
expect(song!.sources![0]!.url).toBe('/app/filebrowser/api/raw/Music/Artist/Song.flac')
|
||||
})
|
||||
|
||||
it('a peer track resolves through the existing peer Range-streaming proxy', () => {
|
||||
const [song] = adaptLibraryTracks([
|
||||
track({
|
||||
id: { source: { Peer: { onion: 'xyz.onion' } }, path: '/var/lib/archipelago/purchased-content/xyz.onion/content-42' },
|
||||
}),
|
||||
])
|
||||
expect(song!.sources![0]!.url).toBe('/api/peer-content/xyz.onion/content-42')
|
||||
})
|
||||
|
||||
it('an empty library produces an empty songs array, not undefined', () => {
|
||||
expect(adaptLibraryTracks([])).toEqual([])
|
||||
expect(adaptLibraryTracks(null)).toEqual([])
|
||||
expect(adaptLibraryTracks(undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('adaptLibraryAlbums', () => {
|
||||
it('groups tracks by (album_artist, album), preserving first-seen order', () => {
|
||||
const tracks = [
|
||||
track({ id: { source: 'OwnLibrary', path: '/1' }, title: 'T1', album: 'Beta', album_artist: 'X' }),
|
||||
track({ id: { source: 'OwnLibrary', path: '/2' }, title: 'T2', album: 'Alpha', album_artist: 'Y' }),
|
||||
track({ id: { source: 'OwnLibrary', path: '/3' }, title: 'T3', album: 'Beta', album_artist: 'X' }),
|
||||
]
|
||||
const albums = adaptLibraryAlbums(tracks)
|
||||
expect(albums.map((a) => a.album)).toEqual(['Beta', 'Alpha'])
|
||||
expect(albums[0]!.tracks.map((t) => t.title)).toEqual(['T1', 'T3'])
|
||||
expect(albums[1]!.tracks.map((t) => t.title)).toEqual(['T2'])
|
||||
})
|
||||
|
||||
it('a track with no album tag forms no album bucket', () => {
|
||||
const albums = adaptLibraryAlbums([track({ album: null })])
|
||||
expect(albums).toEqual([])
|
||||
})
|
||||
|
||||
it('is stable across repeat calls on the same input', () => {
|
||||
const tracks = [track({ id: { source: 'OwnLibrary', path: '/1' }, album: 'A' })]
|
||||
const first = adaptLibraryAlbums(tracks)
|
||||
const second = adaptLibraryAlbums(tracks)
|
||||
expect(first).toEqual(second)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { useAudioPlayer } from '../useAudioPlayer'
|
||||
|
||||
// Mock HTMLAudioElement
|
||||
let lastMockAudio: MockAudio | undefined
|
||||
|
||||
class MockAudio {
|
||||
src = ''
|
||||
currentTime = 0
|
||||
duration = 120
|
||||
paused = true
|
||||
private listeners: Record<string, Array<() => void>> = {}
|
||||
|
||||
constructor() {
|
||||
lastMockAudio = this
|
||||
}
|
||||
|
||||
addEventListener(event: string, handler: () => void) {
|
||||
if (!this.listeners[event]) this.listeners[event] = []
|
||||
this.listeners[event].push(handler)
|
||||
}
|
||||
|
||||
removeEventListener() {
|
||||
// no-op for tests
|
||||
}
|
||||
|
||||
shouldRejectPlay = false
|
||||
|
||||
play() {
|
||||
if (this.shouldRejectPlay) {
|
||||
return Promise.reject(new DOMException('no supported source', 'NotSupportedError'))
|
||||
}
|
||||
this.paused = false
|
||||
this.emit('play')
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
pause() {
|
||||
this.paused = true
|
||||
this.emit('pause')
|
||||
}
|
||||
|
||||
private emit(event: string) {
|
||||
const handlers = this.listeners[event] || []
|
||||
handlers.forEach(h => h())
|
||||
}
|
||||
|
||||
// Helper to simulate events in tests
|
||||
simulateEvent(event: string) {
|
||||
this.emit(event)
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('Audio', MockAudio)
|
||||
|
||||
describe('useAudioPlayer', () => {
|
||||
beforeEach(() => {
|
||||
// Reset singleton state by stopping any active playback
|
||||
const player = useAudioPlayer()
|
||||
player.stop()
|
||||
if (lastMockAudio) lastMockAudio.shouldRejectPlay = false
|
||||
})
|
||||
|
||||
it('returns all expected properties', () => {
|
||||
const player = useAudioPlayer()
|
||||
expect(player.play).toBeTypeOf('function')
|
||||
expect(player.pause).toBeTypeOf('function')
|
||||
expect(player.seek).toBeTypeOf('function')
|
||||
expect(player.stop).toBeTypeOf('function')
|
||||
expect(player.playing).toBeDefined()
|
||||
expect(player.currentName).toBeDefined()
|
||||
expect(player.currentTime).toBeDefined()
|
||||
expect(player.duration).toBeDefined()
|
||||
expect(player.progress).toBeDefined()
|
||||
expect(player.currentSrc).toBeDefined()
|
||||
expect(player.error).toBeDefined()
|
||||
})
|
||||
|
||||
it('starts in stopped state', () => {
|
||||
const player = useAudioPlayer()
|
||||
expect(player.playing.value).toBe(false)
|
||||
expect(player.currentSrc.value).toBeNull()
|
||||
expect(player.currentName.value).toBe('')
|
||||
})
|
||||
|
||||
it('play sets playing state and current source', () => {
|
||||
const player = useAudioPlayer()
|
||||
player.play('/audio/test.mp3', 'Test Track')
|
||||
expect(player.playing.value).toBe(true)
|
||||
expect(player.currentSrc.value).toBe('/audio/test.mp3')
|
||||
expect(player.currentName.value).toBe('Test Track')
|
||||
})
|
||||
|
||||
it('play toggles pause when same source is playing', () => {
|
||||
const player = useAudioPlayer()
|
||||
player.play('/audio/test.mp3', 'Test')
|
||||
expect(player.playing.value).toBe(true)
|
||||
// Play same source again — should pause
|
||||
player.play('/audio/test.mp3', 'Test')
|
||||
expect(player.playing.value).toBe(false)
|
||||
})
|
||||
|
||||
it('play switches to new source', () => {
|
||||
const player = useAudioPlayer()
|
||||
player.play('/audio/first.mp3', 'First')
|
||||
player.play('/audio/second.mp3', 'Second')
|
||||
expect(player.currentSrc.value).toBe('/audio/second.mp3')
|
||||
expect(player.currentName.value).toBe('Second')
|
||||
})
|
||||
|
||||
it('pause pauses playback', () => {
|
||||
const player = useAudioPlayer()
|
||||
player.play('/audio/test.mp3', 'Test')
|
||||
player.pause()
|
||||
expect(player.playing.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stop resets all state', () => {
|
||||
const player = useAudioPlayer()
|
||||
player.play('/audio/test.mp3', 'Test')
|
||||
player.stop()
|
||||
expect(player.playing.value).toBe(false)
|
||||
expect(player.currentSrc.value).toBeNull()
|
||||
expect(player.currentName.value).toBe('')
|
||||
})
|
||||
|
||||
it('progress computes correctly', () => {
|
||||
const player = useAudioPlayer()
|
||||
expect(player.progress.value).toBe(0) // duration is 0
|
||||
|
||||
player.currentTime.value = 30
|
||||
player.duration.value = 120
|
||||
expect(player.progress.value).toBe(25) // 30/120 * 100
|
||||
})
|
||||
|
||||
it('progress is 0 when duration is 0', () => {
|
||||
const player = useAudioPlayer()
|
||||
player.duration.value = 0
|
||||
player.currentTime.value = 10
|
||||
expect(player.progress.value).toBe(0)
|
||||
})
|
||||
|
||||
it('play() rejection is caught, not left as an unhandled promise rejection', async () => {
|
||||
// Regression: play() rejects independently of the 'error' event (e.g. a
|
||||
// peer-content 404 with no decodable source) — this used to be an
|
||||
// unhandled rejection in the browser console even though the 'error'
|
||||
// listener already set a friendly message (2026-07-01).
|
||||
const player = useAudioPlayer()
|
||||
// Initialize the singleton Audio element first (a no-op play call).
|
||||
player.play('/audio/warmup.mp3', 'Warmup')
|
||||
player.stop()
|
||||
|
||||
lastMockAudio!.shouldRejectPlay = true
|
||||
// Calling play() must not throw synchronously nor leave a rejected
|
||||
// promise unhandled — if useAudioPlayer's play() didn't .catch() the
|
||||
// rejection, `loading` would never flip back to false, since nothing
|
||||
// else resets it on this path (that's the real regression signal).
|
||||
expect(() => player.play('/audio/broken.mp3', 'Broken')).not.toThrow()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(player.loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('shared state across multiple useAudioPlayer calls', () => {
|
||||
const p1 = useAudioPlayer()
|
||||
const p2 = useAudioPlayer()
|
||||
p1.play('/audio/shared.mp3', 'Shared')
|
||||
expect(p2.currentSrc.value).toBe('/audio/shared.mp3')
|
||||
expect(p2.playing.value).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,205 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { KeepAlive, defineComponent, h, ref } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useCachedResource } from '../useCachedResource'
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('useCachedResource', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('does not refetch on reactivation within the TTL, and refetches exactly once after the TTL lapses', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2030, 0, 1, 0, 0, 0))
|
||||
const fetcher = vi.fn().mockResolvedValue('v1')
|
||||
|
||||
const Consumer = defineComponent({
|
||||
setup() {
|
||||
const resource = useCachedResource<string>({
|
||||
key: 'test.reactivation-key',
|
||||
fetcher,
|
||||
ttlMs: 1000,
|
||||
persist: false,
|
||||
})
|
||||
return () => h('div', resource.data.value ?? '')
|
||||
},
|
||||
})
|
||||
const Other = defineComponent({ render: () => h('div', 'other') })
|
||||
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
const show = ref(true)
|
||||
return { show }
|
||||
},
|
||||
render() {
|
||||
return h(KeepAlive, null, () =>
|
||||
this.show ? h(Consumer, { key: 'consumer' }) : h(Other, { key: 'other' }),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const wrapper = mount(Host)
|
||||
await flushPromises()
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Deactivate then reactivate inside the TTL — no additional fetch.
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = false
|
||||
await wrapper.vm.$nextTick()
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = true
|
||||
await wrapper.vm.$nextTick()
|
||||
await flushPromises()
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Deactivate, advance past the TTL, reactivate — exactly one more fetch.
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = false
|
||||
await wrapper.vm.$nextTick()
|
||||
vi.setSystemTime(new Date(2030, 0, 1, 0, 0, 2)) // +2s, past the 1s TTL
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = true
|
||||
await wrapper.vm.$nextTick()
|
||||
await flushPromises()
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('mounts and fetches without throwing outside any KeepAlive boundary', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue('bare')
|
||||
const Consumer = defineComponent({
|
||||
setup() {
|
||||
const resource = useCachedResource<string>({ key: 'test.bare-key', fetcher, persist: false })
|
||||
return () => h('div', resource.data.value ?? '')
|
||||
},
|
||||
})
|
||||
|
||||
const wrapper = mount(Consumer)
|
||||
await flushPromises()
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toBe('bare')
|
||||
})
|
||||
|
||||
it('keeps last-known data and sets error on a rejected refresh, moving loadState ready -> refreshing (not loading)', async () => {
|
||||
const first = deferred<string>()
|
||||
const fetcher = vi.fn().mockReturnValueOnce(first.promise)
|
||||
let resource: ReturnType<typeof useCachedResource<string>> | null = null
|
||||
|
||||
const Consumer = defineComponent({
|
||||
setup() {
|
||||
resource = useCachedResource<string>({ key: 'test.error-key', fetcher, ttlMs: 1000, persist: false })
|
||||
return () => h('div', resource!.data.value ?? '')
|
||||
},
|
||||
})
|
||||
|
||||
mount(Consumer)
|
||||
await Promise.resolve()
|
||||
first.resolve('v1')
|
||||
await flushPromises()
|
||||
|
||||
expect(resource!.data.value).toBe('v1')
|
||||
expect(resource!.loadState.value).toBe('ready')
|
||||
|
||||
const second = deferred<string>()
|
||||
fetcher.mockReturnValueOnce(second.promise)
|
||||
const refreshCall = resource!.refresh()
|
||||
await Promise.resolve()
|
||||
// Sticky-ready: a refresh on already-'ready' data moves to 'refreshing',
|
||||
// never back to 'loading' — content stays on screen while it runs.
|
||||
expect(resource!.loadState.value).toBe('refreshing')
|
||||
|
||||
second.reject(new Error('offline'))
|
||||
await refreshCall
|
||||
await flushPromises()
|
||||
|
||||
expect(resource!.data.value).toBe('v1') // keep-last-known-value
|
||||
expect(resource!.error.value).toBe('offline')
|
||||
})
|
||||
|
||||
// 02-04: found while auditing Cloud.vue/Server.vue's lazy (`immediate:
|
||||
// false`) resources ahead of adding their routes to KEEP_ALIVE_PATHS.
|
||||
// Without this guard, onActivated's refreshIfStale() would treat a
|
||||
// never-fetched entry as stale and eagerly fire the "fetch on first use"
|
||||
// resource the moment the tab is first activated, even though the caller
|
||||
// never explicitly requested it (e.g. a tab-gated Paid Files fetch that
|
||||
// should wait until that sub-tab is opened).
|
||||
it('does not eagerly fetch an immediate:false resource on activation before it has been explicitly requested, but does revalidate it once it has', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2030, 0, 1, 0, 0, 0))
|
||||
const fetcher = vi.fn().mockResolvedValue('lazy-v1')
|
||||
let resource: ReturnType<typeof useCachedResource<string>> | null = null
|
||||
|
||||
const Consumer = defineComponent({
|
||||
setup() {
|
||||
resource = useCachedResource<string>({
|
||||
key: 'test.lazy-key',
|
||||
fetcher,
|
||||
ttlMs: 1000,
|
||||
persist: false,
|
||||
immediate: false,
|
||||
})
|
||||
return () => h('div', resource!.data.value ?? '')
|
||||
},
|
||||
})
|
||||
const Other = defineComponent({ render: () => h('div', 'other') })
|
||||
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
const show = ref(true)
|
||||
return { show }
|
||||
},
|
||||
render() {
|
||||
return h(KeepAlive, null, () =>
|
||||
this.show ? h(Consumer, { key: 'consumer' }) : h(Other, { key: 'other' }),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const wrapper = mount(Host)
|
||||
await flushPromises()
|
||||
expect(fetcher).not.toHaveBeenCalled() // immediate: false — not fetched on mount
|
||||
|
||||
// Deactivate then reactivate — still never explicitly requested, so
|
||||
// activation must not be the thing that fetches it.
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = false
|
||||
await wrapper.vm.$nextTick()
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = true
|
||||
await wrapper.vm.$nextTick()
|
||||
await flushPromises()
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
|
||||
// The caller explicitly requests it now (e.g. the user opened the tab).
|
||||
await resource!.refresh()
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Deactivate within the TTL, reactivate — no additional fetch.
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = false
|
||||
await wrapper.vm.$nextTick()
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = true
|
||||
await wrapper.vm.$nextTick()
|
||||
await flushPromises()
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Deactivate, advance past the TTL, reactivate — now it revalidates,
|
||||
// because it has been fetched before.
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = false
|
||||
await wrapper.vm.$nextTick()
|
||||
vi.setSystemTime(new Date(2030, 0, 1, 0, 0, 2)) // +2s, past the 1s TTL
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = true
|
||||
await wrapper.vm.$nextTick()
|
||||
await flushPromises()
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { useContainersScanTimeout } from '../useContainersScanTimeout'
|
||||
|
||||
describe('useContainersScanTimeout', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('reflects the real scanned flag when it arrives before the timeout', async () => {
|
||||
const scanned = ref(false)
|
||||
const loaded = ref(true)
|
||||
const { effectiveContainersScanned, scanTimedOut } = useContainersScanTimeout(scanned, loaded, 20_000)
|
||||
|
||||
expect(effectiveContainersScanned.value).toBe(false)
|
||||
scanned.value = true
|
||||
await nextTick()
|
||||
expect(effectiveContainersScanned.value).toBe(true)
|
||||
expect(scanTimedOut.value).toBe(false)
|
||||
})
|
||||
|
||||
it('does not start the timeout until initial data has loaded', async () => {
|
||||
const scanned = ref(false)
|
||||
const loaded = ref(false)
|
||||
const { effectiveContainersScanned } = useContainersScanTimeout(scanned, loaded, 20_000)
|
||||
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(effectiveContainersScanned.value).toBe(false)
|
||||
|
||||
loaded.value = true
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(20_000)
|
||||
expect(effectiveContainersScanned.value).toBe(true)
|
||||
})
|
||||
|
||||
it('falls through after the timeout even if the flag never arrives', async () => {
|
||||
const scanned = ref(false)
|
||||
const loaded = ref(true)
|
||||
const { effectiveContainersScanned, scanTimedOut } = useContainersScanTimeout(scanned, loaded, 20_000)
|
||||
|
||||
vi.advanceTimersByTime(19_999)
|
||||
expect(effectiveContainersScanned.value).toBe(false)
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(effectiveContainersScanned.value).toBe(true)
|
||||
expect(scanTimedOut.value).toBe(true)
|
||||
})
|
||||
|
||||
it('cancels the escape hatch when the real flag arrives', async () => {
|
||||
const scanned = ref(false)
|
||||
const loaded = ref(true)
|
||||
const { scanTimedOut } = useContainersScanTimeout(scanned, loaded, 20_000)
|
||||
|
||||
vi.advanceTimersByTime(10_000)
|
||||
scanned.value = true
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(scanTimedOut.value).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,671 @@
|
||||
/**
|
||||
* Tests for useControllerNav — validates against GAMEPAD-NAV-MAP.md
|
||||
*
|
||||
* Tests the navigation logic (element queries, spatial nav, zone detection)
|
||||
* without mounting the composable (which needs Vue lifecycle).
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
|
||||
// ─── Mocks ─────────────────────────────────────────────────────
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ path: '/dashboard' }),
|
||||
useRouter: () => ({ push: vi.fn().mockResolvedValue(undefined) }),
|
||||
}))
|
||||
vi.mock('@/stores/controller', () => ({ useControllerStore: () => ({ setActive: vi.fn(), setGamepadCount: vi.fn() }) }))
|
||||
vi.mock('@/stores/spotlight', () => ({ useSpotlightStore: () => ({ isOpen: false, close: vi.fn() }) }))
|
||||
vi.mock('@/stores/cli', () => ({ useCLIStore: () => ({ isOpen: false, close: vi.fn() }) }))
|
||||
vi.mock('@/stores/appLauncher', () => ({ useAppLauncherStore: () => ({ isOpen: false, close: vi.fn() }) }))
|
||||
vi.mock('@/composables/useNavSounds', () => ({ playNavSound: vi.fn() }))
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'a[href]', 'button:not([disabled])', 'input:not([disabled])',
|
||||
'select:not([disabled])', 'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])', '[data-controller-focus]',
|
||||
'[data-controller-container]',
|
||||
].join(', ')
|
||||
|
||||
function queryFocusable(root: HTMLElement | Document = document): HTMLElement[] {
|
||||
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
||||
el => !el.hasAttribute('data-controller-ignore') && !el.closest('[data-controller-ignore]')
|
||||
)
|
||||
}
|
||||
|
||||
function queryContainers(): HTMLElement[] {
|
||||
const zone = document.querySelector('[data-controller-zone="main"]')
|
||||
if (!zone) return []
|
||||
return Array.from(zone.querySelectorAll<HTMLElement>('[data-controller-container]'))
|
||||
}
|
||||
|
||||
function queryNavBarItems(): HTMLElement[] {
|
||||
const zone = document.querySelector('[data-controller-zone="main"]')
|
||||
if (!zone) return []
|
||||
return queryFocusable(zone as HTMLElement).filter(el =>
|
||||
!el.hasAttribute('data-controller-container') &&
|
||||
!el.closest('[data-controller-container]')
|
||||
)
|
||||
}
|
||||
|
||||
function querySidebar(): HTMLElement[] {
|
||||
const zone = document.querySelector('[data-controller-zone="sidebar"]')
|
||||
return zone ? queryFocusable(zone as HTMLElement) : []
|
||||
}
|
||||
|
||||
// ─── Module Export ──────────────────────────────────────────────
|
||||
|
||||
describe('module', () => {
|
||||
it('exports useControllerNav', async () => {
|
||||
const mod = await import('../useControllerNav')
|
||||
expect(typeof mod.useControllerNav).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── SIDEBAR: Up/Down wrap, Right→container, Left→nothing ──────
|
||||
|
||||
describe('sidebar navigation (NAV-MAP: Sidebar)', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('finds all sidebar nav items', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="sidebar">
|
||||
<a href="/dashboard">Home</a>
|
||||
<a href="/dashboard/apps">Apps</a>
|
||||
<a href="/dashboard/cloud">Cloud</a>
|
||||
<button>AIUI</button>
|
||||
<button>Logout</button>
|
||||
</div>
|
||||
<div data-controller-zone="main">
|
||||
<div data-controller-container tabindex="0">Card</div>
|
||||
</div>
|
||||
`
|
||||
expect(querySidebar().length).toBe(5)
|
||||
})
|
||||
|
||||
it('wraps down: Logout → Home', () => {
|
||||
const items = ['Home', 'Apps', 'Cloud', 'Logout']
|
||||
const lastIdx = items.length - 1
|
||||
expect((lastIdx + 1) % items.length).toBe(0) // wraps to Home
|
||||
})
|
||||
|
||||
it('wraps up: Home → Logout', () => {
|
||||
const items = ['Home', 'Apps', 'Cloud', 'Logout']
|
||||
expect((0 - 1 + items.length) % items.length).toBe(items.length - 1) // wraps to Logout
|
||||
})
|
||||
|
||||
it('right from sidebar targets first container, not nav bar items', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="sidebar"><a href="/">Home</a></div>
|
||||
<div data-controller-zone="main">
|
||||
<button class="mode-switcher-btn" id="tab">Tab</button>
|
||||
<div data-controller-container tabindex="0" id="card1">Card</div>
|
||||
</div>
|
||||
`
|
||||
const containers = queryContainers()
|
||||
expect(containers[0]?.id).toBe('card1')
|
||||
})
|
||||
|
||||
it('left from sidebar does nothing (no target exists)', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="sidebar"><a href="/">Home</a></div>
|
||||
`
|
||||
const sidebar = querySidebar()
|
||||
const el = sidebar[0]!
|
||||
// Nothing to the left of sidebar
|
||||
expect(el.closest('[data-controller-zone="sidebar"]')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── HOME: 2-col grid + nav bar ────────────────────────────────
|
||||
|
||||
describe('HOME grid (NAV-MAP: HOME /dashboard)', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('has Dashboard and Setup nav bar items', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="main">
|
||||
<div role="tablist">
|
||||
<button role="tab" class="mode-switcher-btn" id="dashTab">Dashboard</button>
|
||||
<button role="tab" class="mode-switcher-btn" id="setupTab">Setup</button>
|
||||
</div>
|
||||
<div data-controller-container tabindex="0" id="myApps">My Apps</div>
|
||||
<div data-controller-container tabindex="0" id="cloud">Cloud</div>
|
||||
</div>
|
||||
`
|
||||
const navItems = queryNavBarItems()
|
||||
expect(navItems.length).toBe(2)
|
||||
expect(navItems[0]?.id).toBe('dashTab')
|
||||
expect(navItems[1]?.id).toBe('setupTab')
|
||||
})
|
||||
|
||||
it('containers exclude nav bar items', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="main">
|
||||
<button class="mode-switcher-btn">Dashboard</button>
|
||||
<button class="mode-switcher-btn">Setup</button>
|
||||
<div data-controller-container tabindex="0" id="myApps">My Apps</div>
|
||||
<div data-controller-container tabindex="0" id="cloud">Cloud</div>
|
||||
<div data-controller-container tabindex="0" id="network">Network</div>
|
||||
<div data-controller-container tabindex="0" id="wallet">Wallet</div>
|
||||
<div data-controller-container tabindex="0" id="system">System</div>
|
||||
</div>
|
||||
`
|
||||
const containers = queryContainers()
|
||||
expect(containers.length).toBe(5)
|
||||
expect(containers.map(c => c.id)).toEqual(['myApps', 'cloud', 'network', 'wallet', 'system'])
|
||||
// Nav bar items are separate
|
||||
const navItems = queryNavBarItems()
|
||||
expect(navItems.length).toBe(2)
|
||||
})
|
||||
|
||||
it('inner controls are not in the container grid', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="main">
|
||||
<div data-controller-container tabindex="0" id="myApps">
|
||||
<a href="/dashboard/apps">Go</a>
|
||||
<button id="browseStore">Browse Store</button>
|
||||
<button id="manageApps">Manage Apps</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
// Only 1 container in grid
|
||||
expect(queryContainers().length).toBe(1)
|
||||
// Nav bar is empty (all focusables are inside the container)
|
||||
expect(queryNavBarItems().length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── APPS: 3-col grid + nav bar with tabs/filters/search ───────
|
||||
|
||||
describe('APPS grid (NAV-MAP: APPS /dashboard/apps)', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('nav bar has tabs, filters, and search', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="main">
|
||||
<div class="mode-switcher">
|
||||
<button class="mode-switcher-btn" id="myAppsTab">My Apps</button>
|
||||
<a href="/dashboard/discover" class="mode-switcher-btn" id="storeTab">App Store</a>
|
||||
<button class="mode-switcher-btn" id="servicesTab">Services</button>
|
||||
</div>
|
||||
<div class="mode-switcher">
|
||||
<button class="mode-switcher-btn" id="allFilter">All</button>
|
||||
<button class="mode-switcher-btn" id="btcFilter">Bitcoin</button>
|
||||
</div>
|
||||
<input type="text" id="search" />
|
||||
<div data-controller-container tabindex="0" id="app1">App1</div>
|
||||
<div data-controller-container tabindex="0" id="app2">App2</div>
|
||||
<div data-controller-container tabindex="0" id="app3">App3</div>
|
||||
</div>
|
||||
`
|
||||
const navItems = queryNavBarItems()
|
||||
// 3 tabs + 2 filters + 1 search = 6 nav bar items
|
||||
expect(navItems.length).toBe(6)
|
||||
expect(navItems.map(el => el.id)).toEqual(['myAppsTab', 'storeTab', 'servicesTab', 'allFilter', 'btcFilter', 'search'])
|
||||
|
||||
// 3 containers
|
||||
expect(queryContainers().length).toBe(3)
|
||||
})
|
||||
|
||||
it('app cards with launch attribute are containers', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="main">
|
||||
<div data-controller-container data-controller-launch tabindex="0" id="app1">
|
||||
<button data-controller-launch-btn>Launch</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
const containers = queryContainers()
|
||||
expect(containers.length).toBe(1)
|
||||
expect(containers[0]?.hasAttribute('data-controller-launch')).toBe(true)
|
||||
const launchBtn = containers[0]?.querySelector('[data-controller-launch-btn]')
|
||||
expect(launchBtn).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── CLOUD: 3-col, no nav bar ──────────────────────────────────
|
||||
|
||||
describe('CLOUD grid (NAV-MAP: CLOUD /dashboard/cloud)', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('has section cards as containers, no nav bar', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="main">
|
||||
<div data-controller-container tabindex="0" id="photos">Photos</div>
|
||||
<div data-controller-container tabindex="0" id="music">Music</div>
|
||||
<div data-controller-container tabindex="0" id="docs">Documents</div>
|
||||
<div data-controller-container tabindex="0" id="files">Files</div>
|
||||
</div>
|
||||
`
|
||||
expect(queryContainers().length).toBe(4)
|
||||
expect(queryNavBarItems().length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── NETWORK: 2-col ────────────────────────────────────────────
|
||||
|
||||
describe('NETWORK grid (NAV-MAP: NETWORK /dashboard/server)', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('has Local Network and Web3 containers', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="main">
|
||||
<div data-controller-container tabindex="0" id="localNet">Local Network</div>
|
||||
<div data-controller-container tabindex="0" id="web3">Web3</div>
|
||||
</div>
|
||||
`
|
||||
const containers = queryContainers()
|
||||
expect(containers.length).toBe(2)
|
||||
expect(containers[0]?.id).toBe('localNet')
|
||||
expect(containers[1]?.id).toBe('web3')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── SETTINGS: vertical stack ──────────────────────────────────
|
||||
|
||||
describe('SETTINGS grid (NAV-MAP: SETTINGS /dashboard/settings)', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('has stacked section containers', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="main">
|
||||
<div data-controller-container tabindex="0" id="account">Account Info</div>
|
||||
<div data-controller-container tabindex="0" id="password">Change Password</div>
|
||||
<div data-controller-container tabindex="0" id="twofa">Two-Factor</div>
|
||||
<div data-controller-container tabindex="0" id="system">System Info</div>
|
||||
<div data-controller-container tabindex="0" id="danger">Danger Zone</div>
|
||||
</div>
|
||||
`
|
||||
const containers = queryContainers()
|
||||
expect(containers.length).toBe(5)
|
||||
// No nav bar
|
||||
expect(queryNavBarItems().length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── ENTER behavior ────────────────────────────────────────────
|
||||
|
||||
describe('enter key behavior (NAV-MAP: Rules 5)', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('container with primary link: Enter should navigate', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-container tabindex="0">
|
||||
<a href="/dashboard/apps" id="link">Go</a>
|
||||
<button>Browse</button>
|
||||
</div>
|
||||
`
|
||||
const container = document.querySelector('[data-controller-container]')!
|
||||
const link = container.querySelector('a[href]')
|
||||
expect(link).toBeTruthy()
|
||||
expect(link?.getAttribute('href')).toBe('/dashboard/apps')
|
||||
})
|
||||
|
||||
it('container without link: Enter drills into inner [Y] controls', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-container tabindex="0">
|
||||
<button id="btn1">Open Shop</button>
|
||||
<button id="btn2">Accept Payments</button>
|
||||
</div>
|
||||
`
|
||||
const container = document.querySelector('[data-controller-container]')!
|
||||
expect(container.querySelector('a[href]')).toBeNull()
|
||||
const inner = Array.from(container.querySelectorAll('button'))
|
||||
expect(inner.length).toBe(2)
|
||||
})
|
||||
|
||||
it('install container: Enter clicks install button', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-container data-controller-install tabindex="0">
|
||||
<button data-controller-install-btn>Install</button>
|
||||
</div>
|
||||
`
|
||||
const container = document.querySelector('[data-controller-container]')!
|
||||
expect(container.hasAttribute('data-controller-install')).toBe(true)
|
||||
expect(container.querySelector('[data-controller-install-btn]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('launch container: Enter clicks launch button', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-container data-controller-launch tabindex="0">
|
||||
<button data-controller-launch-btn>Launch</button>
|
||||
</div>
|
||||
`
|
||||
const container = document.querySelector('[data-controller-container]')!
|
||||
expect(container.hasAttribute('data-controller-launch')).toBe(true)
|
||||
expect(container.querySelector('[data-controller-launch-btn]')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── INSIDE CONTAINER [Y] ──────────────────────────────────────
|
||||
|
||||
describe('inside container navigation (NAV-MAP: Rules 6)', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('inner controls are isolated from other containers', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-container tabindex="0" id="card1">
|
||||
<button id="stop">Stop</button>
|
||||
<button id="restart">Restart</button>
|
||||
</div>
|
||||
<div data-controller-container tabindex="0" id="card2">
|
||||
<button id="other">Other</button>
|
||||
</div>
|
||||
`
|
||||
const card1 = document.getElementById('card1')!
|
||||
const inner = queryFocusable(card1).filter(el => el !== card1 && !el.hasAttribute('data-controller-container'))
|
||||
expect(inner.length).toBe(2)
|
||||
expect(inner.map(el => el.id)).toEqual(['stop', 'restart'])
|
||||
// "other" is NOT in card1's inner controls
|
||||
expect(inner.find(el => el.id === 'other')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('escape from inner control returns to container', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-container tabindex="0" id="card">
|
||||
<button id="inner">Action</button>
|
||||
</div>
|
||||
`
|
||||
const inner = document.getElementById('inner')!
|
||||
const container = inner.closest('[data-controller-container]')
|
||||
expect(container).toBeTruthy()
|
||||
expect(container?.id).toBe('card')
|
||||
expect(container?.getAttribute('tabindex')).toBe('0')
|
||||
})
|
||||
|
||||
it('isInsideContainer is true for nested, false for container itself', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-container tabindex="0" id="card">
|
||||
<button id="inside">In</button>
|
||||
</div>
|
||||
<button id="outside">Out</button>
|
||||
`
|
||||
const inside = document.getElementById('inside')!
|
||||
const outside = document.getElementById('outside')!
|
||||
const card = document.getElementById('card')!
|
||||
|
||||
// inside: has container ancestor that isn't itself
|
||||
const insideContainer = inside.closest('[data-controller-container]')
|
||||
expect(insideContainer && insideContainer !== inside).toBe(true)
|
||||
// card: IS the container
|
||||
expect(card.hasAttribute('data-controller-container')).toBe(true)
|
||||
// outside: no container ancestor
|
||||
expect(outside.closest('[data-controller-container]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── TEXT INPUT handling ───────────────────────────────────────
|
||||
|
||||
describe('text input handling (NAV-MAP: text inputs)', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('up/down exits input, left/right stays', () => {
|
||||
const exitKeys = ['ArrowUp', 'ArrowDown']
|
||||
const stayKeys = ['ArrowLeft', 'ArrowRight']
|
||||
exitKeys.forEach(k => expect(['ArrowUp', 'ArrowDown'].includes(k)).toBe(true))
|
||||
stayKeys.forEach(k => expect(['ArrowUp', 'ArrowDown'].includes(k)).toBe(false))
|
||||
})
|
||||
|
||||
it('enter on password clicks next button (submit)', () => {
|
||||
document.body.innerHTML = `
|
||||
<input id="pass" type="password" />
|
||||
<button id="login">Login</button>
|
||||
`
|
||||
const all = queryFocusable()
|
||||
const passIdx = all.findIndex(el => el.id === 'pass')
|
||||
const next = all[passIdx + 1]
|
||||
expect(next?.tagName).toBe('BUTTON')
|
||||
expect(next?.id).toBe('login')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── FOCUS MEMORY ──────────────────────────────────────────────
|
||||
|
||||
describe('focus memory (NAV-MAP: zone transitions)', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('remembers and recalls elements', () => {
|
||||
document.body.innerHTML = `<button id="btn">Test</button>`
|
||||
const memory = new Map<string, HTMLElement>()
|
||||
const btn = document.getElementById('btn')!
|
||||
memory.set('main', btn)
|
||||
expect(memory.get('main')).toBe(btn)
|
||||
expect(document.contains(btn)).toBe(true)
|
||||
})
|
||||
|
||||
it('detects stale (removed) elements', () => {
|
||||
document.body.innerHTML = `<button id="btn">Test</button>`
|
||||
const memory = new Map<string, HTMLElement>()
|
||||
const btn = document.getElementById('btn')!
|
||||
memory.set('main', btn)
|
||||
btn.remove()
|
||||
expect(document.contains(memory.get('main')!)).toBe(false)
|
||||
})
|
||||
|
||||
it('clears on route change', () => {
|
||||
const memory = new Map<string, HTMLElement>()
|
||||
document.body.innerHTML = `<button id="btn">Test</button>`
|
||||
memory.set('main', document.getElementById('btn')!)
|
||||
memory.delete('main')
|
||||
expect(memory.get('main')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── SPATIAL NAVIGATION ────────────────────────────────────────
|
||||
|
||||
describe('spatial navigation', () => {
|
||||
it('overlap scoring: aligned > offset', () => {
|
||||
const from = { top: 50, bottom: 200, left: 0, right: 150 }
|
||||
const aligned = { top: 50, bottom: 200, left: 200, right: 350 }
|
||||
const offset = { top: 160, bottom: 310, left: 200, right: 350 }
|
||||
const alignedOv = Math.max(0, Math.min(from.bottom, aligned.bottom) - Math.max(from.top, aligned.top))
|
||||
const offsetOv = Math.max(0, Math.min(from.bottom, offset.bottom) - Math.max(from.top, offset.top))
|
||||
expect(alignedOv).toBe(150)
|
||||
expect(offsetOv).toBe(40)
|
||||
expect(alignedOv).toBeGreaterThan(offsetOv)
|
||||
})
|
||||
|
||||
it('tiebreaker: up/down prefers leftmost', () => {
|
||||
// Two elements below, same distance, same overlap
|
||||
const a = { left: 0 }
|
||||
const b = { left: 200 }
|
||||
// Sort: leftmost wins
|
||||
expect(a.left - b.left).toBeLessThan(0) // a is leftmost
|
||||
})
|
||||
|
||||
it('no wrap in 2D grid (NAV-MAP: Rules 2)', () => {
|
||||
// At rightmost column, pressing right should find nothing
|
||||
const from = { left: 400, right: 600, top: 0, bottom: 200 }
|
||||
const threshold = 50
|
||||
// No element to the right
|
||||
const candidate = { left: 0, right: 150 } // far left
|
||||
expect(candidate.left >= from.right - threshold).toBe(false) // NOT to the right
|
||||
})
|
||||
})
|
||||
|
||||
// ─── GAMEPAD DETECTION ─────────────────────────────────────────
|
||||
|
||||
describe('gamepad detection', () => {
|
||||
it('counts connected gamepads', () => {
|
||||
const gp = [{ connected: true }, null, { connected: true }, null] as (Gamepad | null)[]
|
||||
expect(gp.filter(g => g?.connected).length).toBe(2)
|
||||
})
|
||||
it('handles null list', () => {
|
||||
const count = (gp: (Gamepad | null)[] | null) => gp ? gp.filter(g => g?.connected).length : 0
|
||||
expect(count(null)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── DATA-CONTROLLER-IGNORE ────────────────────────────────────
|
||||
|
||||
describe('data-controller-ignore', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('excluded elements are filtered out', () => {
|
||||
document.body.innerHTML = `
|
||||
<button data-controller-ignore>Skip</button>
|
||||
<div data-controller-ignore><button>Nested ignored</button></div>
|
||||
<button id="real">Real</button>
|
||||
`
|
||||
const all = queryFocusable()
|
||||
expect(all.length).toBe(1)
|
||||
expect(all[0]?.id).toBe('real')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── NAV BAR [N] DETECTION ─────────────────────────────────────
|
||||
|
||||
describe('nav bar detection', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('nav bar items are in main zone but not inside containers', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="main">
|
||||
<button class="mode-switcher-btn" id="tab1">Dashboard</button>
|
||||
<button class="mode-switcher-btn" id="tab2">Setup</button>
|
||||
<div data-controller-container tabindex="0" id="card">
|
||||
<button id="inner">Inner</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
const navItems = queryNavBarItems()
|
||||
expect(navItems.length).toBe(2)
|
||||
expect(navItems[0]?.id).toBe('tab1')
|
||||
expect(navItems[1]?.id).toBe('tab2')
|
||||
// Inner button is NOT a nav bar item
|
||||
expect(navItems.find(el => el.id === 'inner')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('pages without nav bar return empty', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="main">
|
||||
<div data-controller-container tabindex="0">Card</div>
|
||||
</div>
|
||||
`
|
||||
expect(queryNavBarItems().length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── DISCOVER: featured + grid ─────────────────────────────────
|
||||
|
||||
describe('DISCOVER grid (NAV-MAP: DISCOVER /dashboard/discover)', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('has nav bar + featured + app grid', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="main">
|
||||
<a href="/dashboard/apps" class="mode-switcher-btn" id="myApps">My Apps</a>
|
||||
<a href="/dashboard/discover" class="mode-switcher-btn" id="appStore">App Store</a>
|
||||
<div data-controller-container data-controller-install tabindex="0" id="feat1">Featured 1</div>
|
||||
<div data-controller-container data-controller-install tabindex="0" id="feat2">Featured 2</div>
|
||||
<div data-controller-container data-controller-install tabindex="0" id="app1">App 1</div>
|
||||
<div data-controller-container data-controller-install tabindex="0" id="app2">App 2</div>
|
||||
</div>
|
||||
`
|
||||
expect(queryNavBarItems().length).toBe(2)
|
||||
expect(queryContainers().length).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── MESH / FLEET / SETTINGS containers exist ──────────────────
|
||||
|
||||
describe('pages have containers (NAV-MAP: all pages)', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('mesh has panel containers', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="main">
|
||||
<div data-controller-container tabindex="0" id="device">Device Status</div>
|
||||
<div data-controller-container tabindex="0" id="chat">Chat Panel</div>
|
||||
<div data-controller-container tabindex="0" id="peers">Peers</div>
|
||||
</div>
|
||||
`
|
||||
expect(queryContainers().length).toBe(3)
|
||||
})
|
||||
|
||||
it('fleet has stat + node containers', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="main">
|
||||
<div data-controller-container tabindex="0">Nodes</div>
|
||||
<div data-controller-container tabindex="0">Online</div>
|
||||
<div data-controller-container tabindex="0">Offline</div>
|
||||
<div data-controller-container tabindex="0">Health</div>
|
||||
<div data-controller-container tabindex="0">Node 1</div>
|
||||
</div>
|
||||
`
|
||||
expect(queryContainers().length).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── FULL FLOW: sidebar → container → inner → back ─────────────
|
||||
|
||||
describe('full navigation flow (NAV-MAP: Rules 1-8)', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('complete roundtrip: sidebar → container → inner → escape → sidebar', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="sidebar">
|
||||
<a href="/dashboard" class="nav-tab-active" id="sideHome">Home</a>
|
||||
<a href="/dashboard/apps" id="sideApps">Apps</a>
|
||||
</div>
|
||||
<div data-controller-zone="main">
|
||||
<div data-controller-container tabindex="0" id="card1">
|
||||
<button id="inner1">Browse</button>
|
||||
<button id="inner2">Manage</button>
|
||||
</div>
|
||||
<div data-controller-container tabindex="0" id="card2">
|
||||
<a href="/dashboard/cloud">Go</a>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
// Step 1: Sidebar exists, has active tab
|
||||
const sidebar = querySidebar()
|
||||
expect(sidebar.length).toBe(2)
|
||||
const activeTab = document.querySelector('.nav-tab-active') as HTMLElement
|
||||
expect(activeTab?.id).toBe('sideHome')
|
||||
|
||||
// Step 2: Right from sidebar → first container
|
||||
const containers = queryContainers()
|
||||
expect(containers[0]?.id).toBe('card1')
|
||||
|
||||
// Step 3: Enter on card1 (no primary link) → drill into inner controls
|
||||
const card1 = document.getElementById('card1')!
|
||||
const inner = queryFocusable(card1).filter(el => el !== card1 && !el.hasAttribute('data-controller-container'))
|
||||
expect(inner.length).toBe(2)
|
||||
expect(inner[0]?.id).toBe('inner1')
|
||||
|
||||
// Step 4: Escape from inner → back to card1
|
||||
const innerEl = document.getElementById('inner1')!
|
||||
const parentContainer = innerEl.closest('[data-controller-container]')
|
||||
expect(parentContainer?.id).toBe('card1')
|
||||
|
||||
// Step 5: Escape from card1 → sidebar active tab
|
||||
expect(activeTab?.id).toBe('sideHome')
|
||||
|
||||
// Step 6: card2 has primary link → Enter navigates
|
||||
const card2 = document.getElementById('card2')!
|
||||
const primaryLink = card2.querySelector('a[href]')
|
||||
expect(primaryLink?.getAttribute('href')).toBe('/dashboard/cloud')
|
||||
})
|
||||
|
||||
it('no dead ends: every container can reach sidebar', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="sidebar">
|
||||
<a href="/" class="nav-tab-active">Home</a>
|
||||
</div>
|
||||
<div data-controller-zone="main">
|
||||
<div data-controller-container tabindex="0" id="c1">C1</div>
|
||||
<div data-controller-container tabindex="0" id="c2">C2</div>
|
||||
</div>
|
||||
`
|
||||
// Every container is in main zone
|
||||
const containers = queryContainers()
|
||||
containers.forEach(c => {
|
||||
expect(c.closest('[data-controller-zone="main"]')).toBeTruthy()
|
||||
})
|
||||
// Sidebar has at least one item
|
||||
expect(querySidebar().length).toBeGreaterThan(0)
|
||||
// Active tab exists for Left → sidebar
|
||||
expect(document.querySelector('.nav-tab-active')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,202 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { getFileCategory, useFileType, formatSize, formatDate } from '../useFileType'
|
||||
|
||||
describe('getFileCategory', () => {
|
||||
it('returns folder for directories', () => {
|
||||
expect(getFileCategory('', true)).toBe('folder')
|
||||
expect(getFileCategory('jpg', true)).toBe('folder')
|
||||
})
|
||||
|
||||
it('identifies image extensions', () => {
|
||||
expect(getFileCategory('jpg', false)).toBe('image')
|
||||
expect(getFileCategory('jpeg', false)).toBe('image')
|
||||
expect(getFileCategory('png', false)).toBe('image')
|
||||
expect(getFileCategory('gif', false)).toBe('image')
|
||||
expect(getFileCategory('webp', false)).toBe('image')
|
||||
expect(getFileCategory('svg', false)).toBe('image')
|
||||
expect(getFileCategory('bmp', false)).toBe('image')
|
||||
expect(getFileCategory('ico', false)).toBe('image')
|
||||
})
|
||||
|
||||
it('identifies audio extensions', () => {
|
||||
expect(getFileCategory('mp3', false)).toBe('audio')
|
||||
expect(getFileCategory('flac', false)).toBe('audio')
|
||||
expect(getFileCategory('wav', false)).toBe('audio')
|
||||
expect(getFileCategory('ogg', false)).toBe('audio')
|
||||
expect(getFileCategory('aac', false)).toBe('audio')
|
||||
expect(getFileCategory('m4a', false)).toBe('audio')
|
||||
})
|
||||
|
||||
it('identifies video extensions', () => {
|
||||
expect(getFileCategory('mp4', false)).toBe('video')
|
||||
expect(getFileCategory('mkv', false)).toBe('video')
|
||||
expect(getFileCategory('avi', false)).toBe('video')
|
||||
expect(getFileCategory('mov', false)).toBe('video')
|
||||
expect(getFileCategory('webm', false)).toBe('video')
|
||||
})
|
||||
|
||||
it('identifies document extensions', () => {
|
||||
expect(getFileCategory('pdf', false)).toBe('document')
|
||||
expect(getFileCategory('doc', false)).toBe('document')
|
||||
expect(getFileCategory('docx', false)).toBe('document')
|
||||
expect(getFileCategory('txt', false)).toBe('document')
|
||||
expect(getFileCategory('md', false)).toBe('document')
|
||||
})
|
||||
|
||||
it('identifies spreadsheet extensions', () => {
|
||||
expect(getFileCategory('xls', false)).toBe('spreadsheet')
|
||||
expect(getFileCategory('xlsx', false)).toBe('spreadsheet')
|
||||
expect(getFileCategory('csv', false)).toBe('spreadsheet')
|
||||
expect(getFileCategory('ods', false)).toBe('spreadsheet')
|
||||
})
|
||||
|
||||
it('identifies archive extensions', () => {
|
||||
expect(getFileCategory('zip', false)).toBe('archive')
|
||||
expect(getFileCategory('tar', false)).toBe('archive')
|
||||
expect(getFileCategory('gz', false)).toBe('archive')
|
||||
expect(getFileCategory('rar', false)).toBe('archive')
|
||||
expect(getFileCategory('7z', false)).toBe('archive')
|
||||
})
|
||||
|
||||
it('returns file for unknown extensions', () => {
|
||||
expect(getFileCategory('xyz', false)).toBe('file')
|
||||
expect(getFileCategory('', false)).toBe('file')
|
||||
expect(getFileCategory('bin', false)).toBe('file')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useFileType', () => {
|
||||
it('returns correct category and computed values for an image', () => {
|
||||
const ext = ref('jpg')
|
||||
const isDir = ref(false)
|
||||
const result = useFileType(ext, isDir)
|
||||
|
||||
expect(result.category.value).toBe('image')
|
||||
expect(result.isImage.value).toBe(true)
|
||||
expect(result.isAudio.value).toBe(false)
|
||||
expect(result.isVideo.value).toBe(false)
|
||||
expect(result.iconColor.value).toBe('text-blue-400')
|
||||
expect(result.badgeLabel.value).toBe('Image')
|
||||
})
|
||||
|
||||
it('returns correct values for audio', () => {
|
||||
const ext = ref('mp3')
|
||||
const isDir = ref(false)
|
||||
const result = useFileType(ext, isDir)
|
||||
|
||||
expect(result.category.value).toBe('audio')
|
||||
expect(result.isAudio.value).toBe(true)
|
||||
expect(result.isImage.value).toBe(false)
|
||||
expect(result.iconColor.value).toBe('text-orange-400')
|
||||
expect(result.badgeLabel.value).toBe('Audio')
|
||||
})
|
||||
|
||||
it('returns correct values for video', () => {
|
||||
const ext = ref('mp4')
|
||||
const isDir = ref(false)
|
||||
const result = useFileType(ext, isDir)
|
||||
|
||||
expect(result.category.value).toBe('video')
|
||||
expect(result.isVideo.value).toBe(true)
|
||||
expect(result.iconColor.value).toBe('text-purple-400')
|
||||
})
|
||||
|
||||
it('returns folder when isDir is true', () => {
|
||||
const ext = ref('jpg')
|
||||
const isDir = ref(true)
|
||||
const result = useFileType(ext, isDir)
|
||||
|
||||
expect(result.category.value).toBe('folder')
|
||||
expect(result.isImage.value).toBe(false)
|
||||
expect(result.iconColor.value).toBe('text-amber-400')
|
||||
expect(result.badgeLabel.value).toBe('Folder')
|
||||
})
|
||||
|
||||
it('reacts to ref changes', () => {
|
||||
const ext = ref('jpg')
|
||||
const isDir = ref(false)
|
||||
const result = useFileType(ext, isDir)
|
||||
|
||||
expect(result.category.value).toBe('image')
|
||||
|
||||
ext.value = 'mp3'
|
||||
expect(result.category.value).toBe('audio')
|
||||
expect(result.isAudio.value).toBe(true)
|
||||
expect(result.isImage.value).toBe(false)
|
||||
})
|
||||
|
||||
it('provides icon paths for each category', () => {
|
||||
const ext = ref('pdf')
|
||||
const isDir = ref(false)
|
||||
const result = useFileType(ext, isDir)
|
||||
|
||||
expect(result.iconPaths.value).toBeDefined()
|
||||
expect(result.iconPaths.value.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('provides badge class for each category', () => {
|
||||
const ext = ref('zip')
|
||||
const isDir = ref(false)
|
||||
const result = useFileType(ext, isDir)
|
||||
|
||||
expect(result.badgeClass.value).toContain('bg-yellow')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatSize', () => {
|
||||
it('formats 0 bytes', () => {
|
||||
expect(formatSize(0)).toBe('0 B')
|
||||
})
|
||||
|
||||
it('formats bytes', () => {
|
||||
expect(formatSize(500)).toBe('500 B')
|
||||
})
|
||||
|
||||
it('formats kilobytes', () => {
|
||||
expect(formatSize(1024)).toBe('1.0 KB')
|
||||
expect(formatSize(1536)).toBe('1.5 KB')
|
||||
})
|
||||
|
||||
it('formats megabytes', () => {
|
||||
expect(formatSize(1048576)).toBe('1.0 MB')
|
||||
})
|
||||
|
||||
it('formats gigabytes', () => {
|
||||
expect(formatSize(1073741824)).toBe('1.0 GB')
|
||||
})
|
||||
|
||||
it('formats terabytes', () => {
|
||||
expect(formatSize(1099511627776)).toBe('1.0 TB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('returns "Just now" for very recent dates', () => {
|
||||
const now = new Date().toISOString()
|
||||
expect(formatDate(now)).toBe('Just now')
|
||||
})
|
||||
|
||||
it('returns minutes ago for recent dates', () => {
|
||||
const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString()
|
||||
expect(formatDate(fiveMinAgo)).toBe('5m ago')
|
||||
})
|
||||
|
||||
it('returns hours ago for dates within 24h', () => {
|
||||
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString()
|
||||
expect(formatDate(threeHoursAgo)).toBe('3h ago')
|
||||
})
|
||||
|
||||
it('returns days ago for dates within a week', () => {
|
||||
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString()
|
||||
expect(formatDate(twoDaysAgo)).toBe('2d ago')
|
||||
})
|
||||
|
||||
it('returns formatted date for older dates', () => {
|
||||
const oldDate = new Date('2025-01-15').toISOString()
|
||||
const result = formatDate(oldDate)
|
||||
// Should be a locale date string, not a relative time
|
||||
expect(result).toMatch(/\d/)
|
||||
expect(result).not.toContain('ago')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useLightningRequired } from '../useLightningRequired'
|
||||
|
||||
// The gate reads install state off the app store's package list. Stub the
|
||||
// store rather than the RPC layer so the test pins the decision, not the
|
||||
// transport.
|
||||
const packages = vi.hoisted(() => ({ value: {} as Record<string, unknown> }))
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
get packages() {
|
||||
return packages.value
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('useLightningRequired', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
packages.value = {}
|
||||
// Module-scope `show` is shared by design (one global modal), so reset it
|
||||
// between cases or the first opener leaks into the next test.
|
||||
useLightningRequired().close()
|
||||
})
|
||||
|
||||
it('lets the action through when a Lightning node is running', () => {
|
||||
packages.value = { lnd: { state: 'running' }, 'bitcoin-knots': { state: 'running' } }
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
expect(lightning.lightningStatus()).toBe('running')
|
||||
expect(lightning.requireLightningNode()).toBe(true)
|
||||
expect(lightning.show.value).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks when the node is present but NOT running, and says so', () => {
|
||||
// The bug this closes: `id in packages` is not "usable". A node with an
|
||||
// lnd entry in a non-running state produced a raw connection-refused
|
||||
// error ("Operation failed. Check server logs for details.").
|
||||
packages.value = { lnd: { state: 'stopped' } }
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
expect(lightning.lightningStatus()).toBe('stopped')
|
||||
expect(lightning.requireLightningNode()).toBe(false)
|
||||
expect(lightning.show.value).toBe(true)
|
||||
expect(lightning.status.value).toBe('stopped')
|
||||
})
|
||||
|
||||
it('blocks and raises the install modal when no Lightning node is installed', () => {
|
||||
packages.value = { 'bitcoin-knots': { state: 'running' }, immich: { state: 'running' } }
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
expect(lightning.lightningStatus()).toBe('absent')
|
||||
expect(lightning.hasLightningNode()).toBe(false)
|
||||
// Returns false so the caller bails WITHOUT surfacing an error string —
|
||||
// that was the whole defect: a missing prerequisite rendered as a failure.
|
||||
expect(lightning.requireLightningNode()).toBe(false)
|
||||
expect(lightning.show.value).toBe(true)
|
||||
expect(lightning.status.value).toBe('absent')
|
||||
})
|
||||
|
||||
it('shares one modal state across call sites', () => {
|
||||
packages.value = {}
|
||||
const a = useLightningRequired()
|
||||
const b = useLightningRequired()
|
||||
|
||||
a.requireLightningNode()
|
||||
expect(b.show.value).toBe(true)
|
||||
b.close()
|
||||
expect(a.show.value).toBe(false)
|
||||
})
|
||||
|
||||
it('treats an empty package list as absent', () => {
|
||||
packages.value = {}
|
||||
expect(useLightningRequired().lightningStatus()).toBe('absent')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock Audio globally
|
||||
class MockAudio {
|
||||
src = ''
|
||||
volume = 1
|
||||
loop = false
|
||||
currentTime = 0
|
||||
play = vi.fn().mockResolvedValue(undefined)
|
||||
pause = vi.fn()
|
||||
addEventListener = vi.fn()
|
||||
}
|
||||
|
||||
vi.stubGlobal('Audio', MockAudio)
|
||||
|
||||
// Mock fetch for playLoopStart
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
|
||||
}))
|
||||
|
||||
// Mock AudioContext
|
||||
const mockBufferSource = {
|
||||
buffer: null as AudioBuffer | null,
|
||||
connect: vi.fn(),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
}
|
||||
|
||||
const mockMediaElementSource = {
|
||||
connect: vi.fn(),
|
||||
}
|
||||
|
||||
const mockGainNode = {
|
||||
gain: {
|
||||
value: 1,
|
||||
setValueAtTime: vi.fn(),
|
||||
linearRampToValueAtTime: vi.fn(),
|
||||
exponentialRampToValueAtTime: vi.fn(),
|
||||
},
|
||||
connect: vi.fn(),
|
||||
}
|
||||
|
||||
const mockAudioContext = {
|
||||
state: 'running' as AudioContextState,
|
||||
currentTime: 0,
|
||||
destination: {},
|
||||
resume: vi.fn().mockResolvedValue(undefined),
|
||||
createOscillator: vi.fn().mockReturnValue({
|
||||
type: 'sine',
|
||||
frequency: { value: 440, setValueAtTime: vi.fn() },
|
||||
connect: vi.fn(),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
}),
|
||||
createGain: vi.fn().mockReturnValue({ ...mockGainNode, gain: { ...mockGainNode.gain } }),
|
||||
createBufferSource: vi.fn().mockReturnValue({ ...mockBufferSource }),
|
||||
createMediaElementSource: vi.fn().mockReturnValue({ ...mockMediaElementSource }),
|
||||
decodeAudioData: vi.fn().mockResolvedValue({} as AudioBuffer),
|
||||
}
|
||||
|
||||
vi.stubGlobal('AudioContext', vi.fn().mockImplementation(() => ({ ...mockAudioContext })))
|
||||
|
||||
import {
|
||||
playPop,
|
||||
playLoginSuccessWhoosh,
|
||||
playTypingSound,
|
||||
playIntroTyping,
|
||||
stopIntroTyping,
|
||||
playWelcomeNoderunnerSpeech,
|
||||
playTypingTick,
|
||||
resumeAudioContext,
|
||||
startSynthwave,
|
||||
stopSynthwave,
|
||||
playLoopStart,
|
||||
playKeyboardTypingSound,
|
||||
playDashboardLoadOomph,
|
||||
} from '../useLoginSounds'
|
||||
|
||||
describe('useLoginSounds', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('playPop', () => {
|
||||
it('creates Audio with pop.mp3 and plays it', () => {
|
||||
playPop()
|
||||
// Audio constructor was called (via MockAudio)
|
||||
expect(MockAudio.prototype.constructor).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not throw', () => {
|
||||
expect(() => playPop()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('playLoginSuccessWhoosh', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => playLoginSuccessWhoosh()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('playTypingSound', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => playTypingSound()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('playIntroTyping', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => playIntroTyping()).not.toThrow()
|
||||
})
|
||||
|
||||
it('creates a looping audio element', () => {
|
||||
playIntroTyping()
|
||||
// Does not throw, creates audio
|
||||
})
|
||||
})
|
||||
|
||||
describe('stopIntroTyping', () => {
|
||||
it('does not throw when no audio playing', () => {
|
||||
expect(() => stopIntroTyping()).not.toThrow()
|
||||
})
|
||||
|
||||
it('stops audio that was started', () => {
|
||||
playIntroTyping()
|
||||
expect(() => stopIntroTyping()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('playWelcomeNoderunnerSpeech', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => playWelcomeNoderunnerSpeech()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('playTypingTick', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => playTypingTick()).not.toThrow()
|
||||
})
|
||||
|
||||
it('can be called multiple times (pool rotation)', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(() => playTypingTick()).not.toThrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('resumeAudioContext', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => resumeAudioContext()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('startSynthwave', () => {
|
||||
it('does not throw when no audio context', () => {
|
||||
// Without calling resumeAudioContext first, context might be null
|
||||
expect(() => startSynthwave()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('stopSynthwave', () => {
|
||||
it('does not throw when nothing is playing', () => {
|
||||
expect(() => stopSynthwave()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('playLoopStart', () => {
|
||||
it('does not throw when no audio context', () => {
|
||||
expect(() => playLoopStart()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('playKeyboardTypingSound', () => {
|
||||
it('does not throw when no audio context', () => {
|
||||
expect(() => playKeyboardTypingSound()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('playDashboardLoadOomph', () => {
|
||||
it('does not throw when no audio context', () => {
|
||||
expect(() => playDashboardLoadOomph()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('audio context lifecycle', () => {
|
||||
it('resumeAudioContext then startSynthwave does not throw', () => {
|
||||
resumeAudioContext()
|
||||
expect(() => startSynthwave()).not.toThrow()
|
||||
})
|
||||
|
||||
it('resumeAudioContext then stopSynthwave does not throw', () => {
|
||||
resumeAudioContext()
|
||||
expect(() => stopSynthwave()).not.toThrow()
|
||||
})
|
||||
|
||||
it('resumeAudioContext then playKeyboardTypingSound does not throw', () => {
|
||||
resumeAudioContext()
|
||||
expect(() => playKeyboardTypingSound()).not.toThrow()
|
||||
})
|
||||
|
||||
it('resumeAudioContext then playDashboardLoadOomph does not throw', () => {
|
||||
resumeAudioContext()
|
||||
expect(() => playDashboardLoadOomph()).not.toThrow()
|
||||
})
|
||||
|
||||
it('resumeAudioContext then playLoopStart does not throw', () => {
|
||||
resumeAudioContext()
|
||||
expect(() => playLoopStart()).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useMarketplaceApp } from '../useMarketplaceApp'
|
||||
|
||||
describe('useMarketplaceApp', () => {
|
||||
beforeEach(() => {
|
||||
const { clearCurrentApp } = useMarketplaceApp()
|
||||
clearCurrentApp()
|
||||
})
|
||||
|
||||
it('getCurrentApp returns null initially', () => {
|
||||
const { getCurrentApp } = useMarketplaceApp()
|
||||
expect(getCurrentApp()).toBeNull()
|
||||
})
|
||||
|
||||
it('setCurrentApp stores a full app', () => {
|
||||
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
|
||||
setCurrentApp({
|
||||
id: 'bitcoin',
|
||||
title: 'Bitcoin Core',
|
||||
version: '25.0',
|
||||
icon: '/icons/btc.png',
|
||||
category: 'Finance',
|
||||
description: 'Bitcoin node',
|
||||
author: 'Satoshi',
|
||||
source: 'github',
|
||||
manifestUrl: 'https://example.com/manifest',
|
||||
url: 'https://example.com',
|
||||
repoUrl: 'https://github.com/bitcoin/bitcoin',
|
||||
s9pkUrl: '',
|
||||
dockerImage: 'bitcoin:25.0',
|
||||
})
|
||||
|
||||
const app = getCurrentApp()
|
||||
expect(app).not.toBeNull()
|
||||
expect(app!.id).toBe('bitcoin')
|
||||
expect(app!.title).toBe('Bitcoin Core')
|
||||
expect(app!.version).toBe('25.0')
|
||||
})
|
||||
|
||||
it('setCurrentApp with partial app fills defaults', () => {
|
||||
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
|
||||
setCurrentApp({ id: 'lnd' })
|
||||
|
||||
const app = getCurrentApp()
|
||||
expect(app).not.toBeNull()
|
||||
expect(app!.id).toBe('lnd')
|
||||
expect(app!.title).toBe('')
|
||||
expect(app!.version).toBe('')
|
||||
expect(app!.icon).toBe('')
|
||||
expect(app!.dockerImage).toBe('')
|
||||
})
|
||||
|
||||
it('manifestUrl falls back to s9pkUrl then url', () => {
|
||||
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
|
||||
setCurrentApp({ id: 'test', s9pkUrl: 'https://s9pk.example.com/app.s9pk' })
|
||||
|
||||
const app = getCurrentApp()
|
||||
expect(app!.manifestUrl).toBe('https://s9pk.example.com/app.s9pk')
|
||||
expect(app!.url).toBe('https://s9pk.example.com/app.s9pk')
|
||||
})
|
||||
|
||||
it('url falls back to s9pkUrl then manifestUrl', () => {
|
||||
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
|
||||
setCurrentApp({ id: 'test', manifestUrl: 'https://manifest.example.com' })
|
||||
|
||||
const app = getCurrentApp()
|
||||
expect(app!.url).toBe('https://manifest.example.com')
|
||||
})
|
||||
|
||||
it('clearCurrentApp sets app to null', () => {
|
||||
const { setCurrentApp, clearCurrentApp, getCurrentApp } = useMarketplaceApp()
|
||||
setCurrentApp({ id: 'bitcoin' })
|
||||
expect(getCurrentApp()).not.toBeNull()
|
||||
clearCurrentApp()
|
||||
expect(getCurrentApp()).toBeNull()
|
||||
})
|
||||
|
||||
it('shared state across multiple useMarketplaceApp calls', () => {
|
||||
const instance1 = useMarketplaceApp()
|
||||
const instance2 = useMarketplaceApp()
|
||||
|
||||
instance1.setCurrentApp({ id: 'mempool', title: 'Mempool' })
|
||||
const app = instance2.getCurrentApp()
|
||||
expect(app!.id).toBe('mempool')
|
||||
expect(app!.title).toBe('Mempool')
|
||||
})
|
||||
|
||||
it('handles description as object', () => {
|
||||
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
|
||||
setCurrentApp({
|
||||
id: 'test',
|
||||
description: { short: 'Short desc', long: 'Long description' },
|
||||
})
|
||||
const app = getCurrentApp()
|
||||
expect(app!.description).toEqual({ short: 'Short desc', long: 'Long description' })
|
||||
})
|
||||
|
||||
it('preserves real screenshot metadata', () => {
|
||||
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
|
||||
setCurrentApp({
|
||||
id: 'test',
|
||||
screenshots: [
|
||||
'/screenshots/test-dashboard.png',
|
||||
{ src: '/screenshots/test-settings.png', alt: 'Settings view' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(getCurrentApp()!.screenshots).toEqual([
|
||||
'/screenshots/test-dashboard.png',
|
||||
{ src: '/screenshots/test-settings.png', alt: 'Settings view' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
getReceivedMessages: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { useMessageToast } from '../useMessageToast'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const mockedRpc = vi.mocked(rpcClient)
|
||||
|
||||
describe('useMessageToast', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
// Reset shared singleton state
|
||||
const toast = useMessageToast()
|
||||
toast.stopPolling()
|
||||
toast.receivedMessages.value = []
|
||||
toast.lastMessageCount.value = 0
|
||||
toast.loadingMessages.value = false
|
||||
toast.toastMessage.value = { show: false, text: '', fromPubkey: '' }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
const toast = useMessageToast()
|
||||
toast.stopPolling()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('starts with empty state', () => {
|
||||
const toast = useMessageToast()
|
||||
expect(toast.receivedMessages.value).toEqual([])
|
||||
expect(toast.lastMessageCount.value).toBe(0)
|
||||
expect(toast.loadingMessages.value).toBe(false)
|
||||
expect(toast.toastMessage.value.show).toBe(false)
|
||||
expect(toast.unreadCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('loadReceivedMessages fetches and stores messages', async () => {
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({
|
||||
messages: [
|
||||
{ from_pubkey: 'abc', message: 'Hello', timestamp: '2026-01-01' },
|
||||
],
|
||||
})
|
||||
const toast = useMessageToast()
|
||||
await toast.loadReceivedMessages()
|
||||
|
||||
expect(toast.receivedMessages.value.length).toBe(1)
|
||||
expect(toast.lastMessageCount.value).toBe(1)
|
||||
expect(toast.loadingMessages.value).toBe(false)
|
||||
})
|
||||
|
||||
it('does not show toast on initial load', async () => {
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({
|
||||
messages: [{ from_pubkey: 'a', message: 'Hi', timestamp: '2026-01-01' }],
|
||||
})
|
||||
const toast = useMessageToast()
|
||||
await toast.loadReceivedMessages()
|
||||
|
||||
expect(toast.toastMessage.value.show).toBe(false)
|
||||
})
|
||||
|
||||
it('shows toast when new messages arrive after initial load', async () => {
|
||||
const toast = useMessageToast()
|
||||
|
||||
// Initial load
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({
|
||||
messages: [{ from_pubkey: 'a', message: 'First', timestamp: '2026-01-01' }],
|
||||
})
|
||||
await toast.loadReceivedMessages()
|
||||
|
||||
// New message arrives
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({
|
||||
messages: [
|
||||
{ from_pubkey: 'a', message: 'First', timestamp: '2026-01-01' },
|
||||
{ from_pubkey: 'b', message: 'Second', timestamp: '2026-01-02' },
|
||||
],
|
||||
})
|
||||
await toast.loadReceivedMessages()
|
||||
|
||||
expect(toast.toastMessage.value.show).toBe(true)
|
||||
expect(toast.toastMessage.value.text).toBe('Second')
|
||||
})
|
||||
|
||||
it('shows count for multiple new messages', async () => {
|
||||
const toast = useMessageToast()
|
||||
|
||||
// Initial load
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({
|
||||
messages: [{ from_pubkey: 'a', message: 'One', timestamp: '2026-01-01' }],
|
||||
})
|
||||
await toast.loadReceivedMessages()
|
||||
|
||||
// Multiple new messages
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({
|
||||
messages: [
|
||||
{ from_pubkey: 'a', message: 'One', timestamp: '2026-01-01' },
|
||||
{ from_pubkey: 'b', message: 'Two', timestamp: '2026-01-02' },
|
||||
{ from_pubkey: 'c', message: 'Three', timestamp: '2026-01-03' },
|
||||
],
|
||||
})
|
||||
await toast.loadReceivedMessages()
|
||||
|
||||
expect(toast.toastMessage.value.show).toBe(true)
|
||||
expect(toast.toastMessage.value.text).toBe('2 new messages')
|
||||
})
|
||||
|
||||
it('unreadCount reflects difference', async () => {
|
||||
const toast = useMessageToast()
|
||||
toast.receivedMessages.value = [
|
||||
{ from_pubkey: 'a', message: 'Hi', timestamp: '2026-01-01' },
|
||||
{ from_pubkey: 'b', message: 'Hey', timestamp: '2026-01-02' },
|
||||
]
|
||||
toast.lastMessageCount.value = 1
|
||||
expect(toast.unreadCount.value).toBe(1)
|
||||
})
|
||||
|
||||
it('unreadCount is never negative', () => {
|
||||
const toast = useMessageToast()
|
||||
toast.receivedMessages.value = []
|
||||
toast.lastMessageCount.value = 5
|
||||
expect(toast.unreadCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('markAsRead syncs lastMessageCount', () => {
|
||||
const toast = useMessageToast()
|
||||
toast.receivedMessages.value = [
|
||||
{ from_pubkey: 'a', message: 'Hi', timestamp: '2026-01-01' },
|
||||
{ from_pubkey: 'b', message: 'Hey', timestamp: '2026-01-02' },
|
||||
]
|
||||
toast.lastMessageCount.value = 0
|
||||
toast.markAsRead()
|
||||
expect(toast.lastMessageCount.value).toBe(2)
|
||||
expect(toast.unreadCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('dismissToastAndOpenMessages clears toast and navigates', () => {
|
||||
const toast = useMessageToast()
|
||||
toast.toastMessage.value = { show: true, text: 'New message', fromPubkey: '' }
|
||||
toast.dismissToastAndOpenMessages()
|
||||
|
||||
expect(toast.toastMessage.value.show).toBe(false)
|
||||
expect(mockPush).toHaveBeenCalledWith('/dashboard/mesh')
|
||||
})
|
||||
|
||||
it('stops polling on 401 error', async () => {
|
||||
const toast = useMessageToast()
|
||||
mockedRpc.getReceivedMessages.mockRejectedValue(new Error('401 Unauthorized'))
|
||||
toast.startPolling()
|
||||
|
||||
// Wait for initial load triggered by startPolling
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
// Polling should have stopped, so advancing time should NOT call again
|
||||
vi.clearAllMocks()
|
||||
await vi.advanceTimersByTimeAsync(60000)
|
||||
expect(mockedRpc.getReceivedMessages).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('startPolling does not create duplicate timers', () => {
|
||||
const toast = useMessageToast()
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({ messages: [] })
|
||||
toast.startPolling()
|
||||
toast.startPolling()
|
||||
toast.startPolling()
|
||||
// Should only have one timer — verify by stopping and checking no more calls
|
||||
toast.stopPolling()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
import { useMobileBackButton } from '../useMobileBackButton'
|
||||
|
||||
// Helper component that uses the composable
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
return useMobileBackButton()
|
||||
},
|
||||
template: '<div>{{ bottomPosition }}</div>',
|
||||
})
|
||||
|
||||
describe('useMobileBackButton', () => {
|
||||
let wrapper: ReturnType<typeof mount>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('returns bottomPosition, bottomClass, and tabBarHeight', () => {
|
||||
wrapper = mount(TestComponent)
|
||||
const vm = wrapper.vm as unknown as {
|
||||
bottomPosition: string
|
||||
bottomClass: string
|
||||
tabBarHeight: number
|
||||
}
|
||||
|
||||
expect(typeof vm.bottomPosition).toBe('string')
|
||||
expect(typeof vm.bottomClass).toBe('string')
|
||||
expect(typeof vm.tabBarHeight).toBe('number')
|
||||
})
|
||||
|
||||
it('defaults tabBarHeight to 72', () => {
|
||||
wrapper = mount(TestComponent)
|
||||
const vm = wrapper.vm as unknown as { tabBarHeight: number }
|
||||
expect(vm.tabBarHeight).toBe(72)
|
||||
})
|
||||
|
||||
it('computes bottomPosition as tabBarHeight + 8', () => {
|
||||
wrapper = mount(TestComponent)
|
||||
const vm = wrapper.vm as unknown as {
|
||||
bottomPosition: string
|
||||
tabBarHeight: number
|
||||
}
|
||||
expect(vm.bottomPosition).toBe('80px') // 72 + 8
|
||||
})
|
||||
|
||||
it('computes bottomClass with Tailwind arbitrary value', () => {
|
||||
wrapper = mount(TestComponent)
|
||||
const vm = wrapper.vm as unknown as { bottomClass: string }
|
||||
expect(vm.bottomClass).toBe('bottom-[80px]')
|
||||
})
|
||||
|
||||
it('reads tabBar element if present', async () => {
|
||||
// Create mock tab bar element
|
||||
const tabBar = document.createElement('div')
|
||||
tabBar.setAttribute('data-mobile-tab-bar', '')
|
||||
Object.defineProperty(tabBar, 'offsetHeight', { value: 56 })
|
||||
document.body.appendChild(tabBar)
|
||||
|
||||
wrapper = mount(TestComponent)
|
||||
await nextTick()
|
||||
|
||||
const vm = wrapper.vm as unknown as { tabBarHeight: number }
|
||||
expect(vm.tabBarHeight).toBe(56)
|
||||
|
||||
document.body.removeChild(tabBar)
|
||||
})
|
||||
|
||||
it('falls back to CSS variable when no tab bar element', async () => {
|
||||
document.documentElement.style.setProperty('--mobile-tab-bar-height', '64')
|
||||
|
||||
wrapper = mount(TestComponent)
|
||||
await nextTick()
|
||||
|
||||
const vm = wrapper.vm as unknown as { tabBarHeight: number }
|
||||
expect(vm.tabBarHeight).toBe(64)
|
||||
|
||||
document.documentElement.style.removeProperty('--mobile-tab-bar-height')
|
||||
})
|
||||
|
||||
it('keeps default when no tab bar or CSS var', async () => {
|
||||
wrapper = mount(TestComponent)
|
||||
await nextTick()
|
||||
|
||||
const vm = wrapper.vm as unknown as { tabBarHeight: number }
|
||||
// Should keep the default of 72
|
||||
expect(vm.tabBarHeight).toBe(72)
|
||||
})
|
||||
|
||||
it('cleans up observers on unmount', () => {
|
||||
wrapper = mount(TestComponent)
|
||||
const removeEventSpy = vi.spyOn(window, 'removeEventListener')
|
||||
wrapper.unmount()
|
||||
expect(removeEventSpy).toHaveBeenCalled()
|
||||
removeEventSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('updates on window resize', async () => {
|
||||
const tabBar = document.createElement('div')
|
||||
tabBar.setAttribute('data-mobile-tab-bar', '')
|
||||
Object.defineProperty(tabBar, 'offsetHeight', {
|
||||
value: 48,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
document.body.appendChild(tabBar)
|
||||
|
||||
wrapper = mount(TestComponent)
|
||||
await nextTick()
|
||||
|
||||
// Trigger resize
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
await nextTick()
|
||||
|
||||
const vm = wrapper.vm as unknown as { tabBarHeight: number }
|
||||
expect(vm.tabBarHeight).toBe(48)
|
||||
|
||||
document.body.removeChild(tabBar)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { useModalKeyboard } from '../useModalKeyboard'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
// We need to test the composable inside a component
|
||||
function createTestComponent(onCloseFn: () => void) {
|
||||
return defineComponent({
|
||||
setup() {
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const isOpen = ref(false)
|
||||
const restoreFocusRef = ref<HTMLElement | null>(null)
|
||||
|
||||
useModalKeyboard(containerRef, isOpen, onCloseFn, {
|
||||
restoreFocusRef,
|
||||
})
|
||||
|
||||
return { containerRef, isOpen, restoreFocusRef }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<button id="trigger">Trigger</button>
|
||||
<div v-if="isOpen" ref="containerRef">
|
||||
<button id="btn1">One</button>
|
||||
<button id="btn2">Two</button>
|
||||
<button id="btn3">Three</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
}
|
||||
|
||||
describe('useModalKeyboard', () => {
|
||||
let closeFn: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
closeFn = vi.fn()
|
||||
})
|
||||
|
||||
it('calls onClose when Escape is pressed and modal is open', async () => {
|
||||
const Comp = createTestComponent(closeFn)
|
||||
const wrapper = mount(Comp, { attachTo: document.body })
|
||||
|
||||
wrapper.vm.isOpen = true
|
||||
await nextTick()
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
|
||||
expect(closeFn).toHaveBeenCalledOnce()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not call onClose when modal is closed', () => {
|
||||
const Comp = createTestComponent(closeFn)
|
||||
const wrapper = mount(Comp, { attachTo: document.body })
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
|
||||
expect(closeFn).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('cleans up listener on unmount', () => {
|
||||
const removeSpy = vi.spyOn(window, 'removeEventListener')
|
||||
const Comp = createTestComponent(closeFn)
|
||||
const wrapper = mount(Comp, { attachTo: document.body })
|
||||
|
||||
wrapper.unmount()
|
||||
|
||||
expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true)
|
||||
removeSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock Audio globally
|
||||
class MockAudio {
|
||||
src = ''
|
||||
volume = 1
|
||||
play = vi.fn().mockResolvedValue(undefined)
|
||||
pause = vi.fn()
|
||||
currentTime = 0
|
||||
addEventListener = vi.fn()
|
||||
}
|
||||
|
||||
vi.stubGlobal('Audio', MockAudio)
|
||||
|
||||
// Mock AudioContext
|
||||
const mockOscillator = {
|
||||
type: 'sine',
|
||||
frequency: { setValueAtTime: vi.fn() },
|
||||
connect: vi.fn(),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
}
|
||||
const mockGain = {
|
||||
gain: {
|
||||
setValueAtTime: vi.fn(),
|
||||
linearRampToValueAtTime: vi.fn(),
|
||||
exponentialRampToValueAtTime: vi.fn(),
|
||||
},
|
||||
connect: vi.fn(),
|
||||
}
|
||||
const mockAudioContext = {
|
||||
createOscillator: vi.fn().mockReturnValue(mockOscillator),
|
||||
createGain: vi.fn().mockReturnValue(mockGain),
|
||||
currentTime: 0,
|
||||
destination: {},
|
||||
}
|
||||
|
||||
vi.stubGlobal('AudioContext', vi.fn().mockImplementation(() => mockAudioContext))
|
||||
|
||||
import { playNavSound } from '../useNavSounds'
|
||||
|
||||
describe('playNavSound', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('is a function', () => {
|
||||
expect(playNavSound).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('plays move sound (default)', () => {
|
||||
playNavSound()
|
||||
// Should try to play a sound
|
||||
})
|
||||
|
||||
it('plays move sound explicitly', () => {
|
||||
playNavSound('move')
|
||||
})
|
||||
|
||||
it('plays select sound', () => {
|
||||
playNavSound('select')
|
||||
})
|
||||
|
||||
it('plays action sound', () => {
|
||||
playNavSound('action')
|
||||
})
|
||||
|
||||
it('plays back sound using AudioContext', () => {
|
||||
playNavSound('back')
|
||||
// Back uses Web Audio API synthesis
|
||||
})
|
||||
|
||||
it('does not throw for any sound type', () => {
|
||||
expect(() => playNavSound('move')).not.toThrow()
|
||||
expect(() => playNavSound('select')).not.toThrow()
|
||||
expect(() => playNavSound('action')).not.toThrow()
|
||||
expect(() => playNavSound('back')).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
isOnboardingComplete: vi.fn(),
|
||||
completeOnboarding: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { isOnboardingComplete, completeOnboarding } from '../useOnboarding'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const mockedRpc = vi.mocked(rpcClient)
|
||||
|
||||
describe('useOnboarding', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('isOnboardingComplete', () => {
|
||||
it('returns true when RPC says complete', async () => {
|
||||
mockedRpc.isOnboardingComplete.mockResolvedValue(true)
|
||||
const result = await isOnboardingComplete()
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when RPC says not complete', async () => {
|
||||
mockedRpc.isOnboardingComplete.mockResolvedValue(false)
|
||||
const result = await isOnboardingComplete()
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to localStorage when RPC fails with non-retryable error', async () => {
|
||||
mockedRpc.isOnboardingComplete.mockRejectedValue(new Error('Unknown error'))
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
const result = await isOnboardingComplete()
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false from localStorage fallback when not set', async () => {
|
||||
mockedRpc.isOnboardingComplete.mockRejectedValue(new Error('Unknown error'))
|
||||
const result = await isOnboardingComplete()
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('retries on 502 errors before falling back', async () => {
|
||||
mockedRpc.isOnboardingComplete
|
||||
.mockRejectedValueOnce(new Error('502 Bad Gateway'))
|
||||
.mockResolvedValueOnce(true)
|
||||
|
||||
const promise = isOnboardingComplete()
|
||||
await vi.advanceTimersByTimeAsync(900)
|
||||
const result = await promise
|
||||
expect(result).toBe(true)
|
||||
expect(mockedRpc.isOnboardingComplete).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retries on 503 errors', async () => {
|
||||
mockedRpc.isOnboardingComplete
|
||||
.mockRejectedValueOnce(new Error('503 Service Unavailable'))
|
||||
.mockResolvedValueOnce(false)
|
||||
|
||||
const promise = isOnboardingComplete()
|
||||
await vi.advanceTimersByTimeAsync(900)
|
||||
const result = await promise
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to localStorage after exhausting retries', async () => {
|
||||
mockedRpc.isOnboardingComplete.mockRejectedValue(new Error('502 Bad Gateway'))
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
|
||||
const promise = isOnboardingComplete()
|
||||
await vi.advanceTimersByTimeAsync(8000)
|
||||
const result = await promise
|
||||
expect(result).toBe(true)
|
||||
}, 10000)
|
||||
})
|
||||
|
||||
describe('completeOnboarding', () => {
|
||||
it('calls RPC and sets localStorage', async () => {
|
||||
mockedRpc.completeOnboarding.mockResolvedValue(true)
|
||||
await completeOnboarding()
|
||||
expect(mockedRpc.completeOnboarding).toHaveBeenCalled()
|
||||
expect(localStorage.getItem('neode_onboarding_complete')).toBe('1')
|
||||
})
|
||||
|
||||
it('sets localStorage even when RPC fails', async () => {
|
||||
mockedRpc.completeOnboarding.mockRejectedValue(new Error('Network error'))
|
||||
const promise = completeOnboarding()
|
||||
await vi.advanceTimersByTimeAsync(10000)
|
||||
await promise
|
||||
expect(localStorage.getItem('neode_onboarding_complete')).toBe('1')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// ── Module boundary stubs (per plan: jsdom has no real blob decoding —
|
||||
// assert on what was requested and what was routed where, not byte content) ──
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const playMock = vi.fn()
|
||||
vi.mock('../useAudioPlayer', () => ({
|
||||
useAudioPlayer: () => ({ play: playMock }),
|
||||
}))
|
||||
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { usePaidItemViewer, paidItemKey, type OwnedItemLike } from '../usePaidItemViewer'
|
||||
|
||||
const mockedRpc = vi.mocked(rpcClient)
|
||||
|
||||
const IMAGE_ITEM: OwnedItemLike = {
|
||||
onion: 'abc123.onion',
|
||||
content_id: 'content-1',
|
||||
filename: 'photos/sunset.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
size_bytes: 2048,
|
||||
}
|
||||
const VIDEO_ITEM: OwnedItemLike = {
|
||||
onion: 'abc123.onion',
|
||||
content_id: 'content-2',
|
||||
filename: 'clips/holiday.mp4',
|
||||
mime_type: 'video/mp4',
|
||||
size_bytes: 4096,
|
||||
}
|
||||
const AUDIO_ITEM: OwnedItemLike = {
|
||||
onion: 'abc123.onion',
|
||||
content_id: 'content-3',
|
||||
filename: 'music/track.mp3',
|
||||
mime_type: 'audio/mpeg',
|
||||
size_bytes: 1024,
|
||||
}
|
||||
const DOC_ITEM: OwnedItemLike = {
|
||||
onion: 'abc123.onion',
|
||||
content_id: 'content-4',
|
||||
filename: 'docs/invoice.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
size_bytes: 512,
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void
|
||||
let reject!: (e: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('usePaidItemViewer — UIFIX-04 (lightbox routing) + UIFIX-06 (loading/error)', () => {
|
||||
let createObjectURLSpy: ReturnType<typeof vi.fn>
|
||||
let revokeObjectURLSpy: ReturnType<typeof vi.fn>
|
||||
let windowOpenSpy: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
createObjectURLSpy = vi.fn(() => 'blob:mock-url')
|
||||
revokeObjectURLSpy = vi.fn()
|
||||
URL.createObjectURL = createObjectURLSpy as unknown as typeof URL.createObjectURL
|
||||
URL.revokeObjectURL = revokeObjectURLSpy as unknown as typeof URL.revokeObjectURL
|
||||
windowOpenSpy = vi.fn()
|
||||
window.open = windowOpenSpy as unknown as typeof window.open
|
||||
// atob is provided by jsdom; stub it to avoid depending on real base64 semantics.
|
||||
vi.stubGlobal('atob', vi.fn(() => 'binarydata'))
|
||||
})
|
||||
|
||||
it('routes an image mime to the lightbox, not window.open', async () => {
|
||||
mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' })
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
await viewer.open(IMAGE_ITEM)
|
||||
|
||||
expect(windowOpenSpy).not.toHaveBeenCalled()
|
||||
expect(viewer.lightboxIndex.value).toBe(0)
|
||||
expect(viewer.lightboxItems.value).toHaveLength(1)
|
||||
expect(viewer.error.value).toBeNull()
|
||||
})
|
||||
|
||||
it('routes a video mime to the lightbox, not window.open', async () => {
|
||||
mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'video/mp4' })
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
await viewer.open(VIDEO_ITEM)
|
||||
|
||||
expect(windowOpenSpy).not.toHaveBeenCalled()
|
||||
expect(viewer.lightboxIndex.value).toBe(0)
|
||||
expect(viewer.lightboxItems.value[0]?.name).toBe('holiday.mp4')
|
||||
})
|
||||
|
||||
it('routes an audio mime to the audio player, never the lightbox', async () => {
|
||||
mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'audio/mpeg' })
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
await viewer.open(AUDIO_ITEM)
|
||||
|
||||
expect(playMock).toHaveBeenCalledWith('blob:mock-url', 'track.mp3')
|
||||
expect(viewer.lightboxIndex.value).toBeNull()
|
||||
expect(windowOpenSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to the browser tab for a mime with no in-app viewer', async () => {
|
||||
mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'application/pdf' })
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
await viewer.open(DOC_ITEM)
|
||||
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith('blob:mock-url', '_blank', 'noopener')
|
||||
expect(viewer.lightboxIndex.value).toBeNull()
|
||||
// Existing revoke timer for the browser-tab path is untouched.
|
||||
vi.advanceTimersByTime(60000)
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||
})
|
||||
|
||||
it('the synthetic lightbox item name carries the real extension', async () => {
|
||||
mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' })
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
await viewer.open(IMAGE_ITEM)
|
||||
|
||||
expect(viewer.lightboxItems.value[0]?.name.endsWith('.jpg')).toBe(true)
|
||||
})
|
||||
|
||||
it('sets opening for the whole duration of the fetch and clears it on success', async () => {
|
||||
const d = deferred<{ data_base64: string; mime_type: string }>()
|
||||
mockedRpc.call.mockReturnValue(d.promise as unknown as ReturnType<typeof rpcClient.call>)
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
const p = viewer.open(IMAGE_ITEM)
|
||||
expect(viewer.opening.value).toBe(paidItemKey(IMAGE_ITEM))
|
||||
|
||||
d.resolve({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' })
|
||||
await p
|
||||
|
||||
expect(viewer.opening.value).toBeNull()
|
||||
})
|
||||
|
||||
it('clears opening and does not derive it from a background-refresh flag — it is driven only by the fetch in flight', async () => {
|
||||
// No cached-resource / refreshing concept is wired into this composable at
|
||||
// all: opening only ever reflects the current open() call's own RPC.
|
||||
const d = deferred<{ data_base64: string; mime_type: string }>()
|
||||
mockedRpc.call.mockReturnValue(d.promise as unknown as ReturnType<typeof rpcClient.call>)
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
expect(viewer.opening.value).toBeNull() // idle before any open()
|
||||
const p = viewer.open(IMAGE_ITEM)
|
||||
expect(viewer.opening.value).toBe(paidItemKey(IMAGE_ITEM))
|
||||
d.resolve({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' })
|
||||
await p
|
||||
expect(viewer.opening.value).toBeNull() // back to idle the instant the fetch settles — no lingering "refreshing" state
|
||||
})
|
||||
|
||||
it('surfaces a rejected/timed-out fetch as an error, clears opening, and does not throw past the caller', async () => {
|
||||
mockedRpc.call.mockRejectedValue(new Error('Request timeout'))
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
await expect(viewer.open(IMAGE_ITEM)).resolves.toBeUndefined()
|
||||
|
||||
expect(viewer.error.value).toBeTruthy()
|
||||
expect(viewer.opening.value).toBeNull()
|
||||
expect(viewer.lightboxIndex.value).toBeNull()
|
||||
})
|
||||
|
||||
it('issues exactly one RPC when open() is called twice in quick succession for the same item', async () => {
|
||||
const d = deferred<{ data_base64: string; mime_type: string }>()
|
||||
mockedRpc.call.mockReturnValue(d.promise as unknown as ReturnType<typeof rpcClient.call>)
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
const p1 = viewer.open(IMAGE_ITEM)
|
||||
const p2 = viewer.open(IMAGE_ITEM)
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledTimes(1)
|
||||
|
||||
d.resolve({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' })
|
||||
await Promise.all([p1, p2])
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect, afterEach, beforeEach } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { usePipSession } from '../usePipSession'
|
||||
import { isPipSupported } from '../../utils/pip'
|
||||
|
||||
// jsdom has no picture-in-picture implementation at all, so these three
|
||||
// document/element members don't exist until stubbed — matching the plan's
|
||||
// note that `pipSupported` (module-level) can't be restubbed after import,
|
||||
// which is exactly why `isPipSupported()` exists as a call-time check.
|
||||
function definePipStub(enabled: boolean) {
|
||||
Object.defineProperty(document, 'pictureInPictureEnabled', {
|
||||
value: enabled,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
definePipStub(true)
|
||||
if (!('pictureInPictureElement' in document)) {
|
||||
Object.defineProperty(document, 'pictureInPictureElement', {
|
||||
value: null,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
if (!HTMLVideoElement.prototype.requestPictureInPicture) {
|
||||
HTMLVideoElement.prototype.requestPictureInPicture = async function () {
|
||||
return null as unknown as PictureInPictureWindow
|
||||
}
|
||||
}
|
||||
if (!document.exitPictureInPicture) {
|
||||
document.exitPictureInPicture = async () => {}
|
||||
}
|
||||
})
|
||||
|
||||
function hostEl(): HTMLElement | null {
|
||||
return document.querySelector('[data-pip-session-host]')
|
||||
}
|
||||
|
||||
describe('usePipSession', () => {
|
||||
afterEach(() => {
|
||||
usePipSession().release()
|
||||
})
|
||||
|
||||
it('adopts a video into a body-level host that survives the owner unmounting', () => {
|
||||
const Owner = defineComponent({
|
||||
setup() {
|
||||
return () => h('div', [h('video')])
|
||||
},
|
||||
})
|
||||
const wrapper = mount(Owner, { attachTo: document.body })
|
||||
const video = wrapper.find('video').element as HTMLVideoElement
|
||||
|
||||
const session = usePipSession()
|
||||
session.adopt(video)
|
||||
|
||||
expect(session.active.value).toBe(true)
|
||||
expect(session.element.value).toBe(video)
|
||||
expect(document.body.contains(video)).toBe(true)
|
||||
|
||||
wrapper.unmount()
|
||||
|
||||
// The owning component is gone; the video must still be connected.
|
||||
expect(document.body.contains(video)).toBe(true)
|
||||
expect(hostEl()?.contains(video)).toBe(true)
|
||||
})
|
||||
|
||||
it('release() removes the adopted element and leaves the host empty', () => {
|
||||
const video = document.createElement('video')
|
||||
const session = usePipSession()
|
||||
session.adopt(video)
|
||||
expect(hostEl()?.contains(video)).toBe(true)
|
||||
|
||||
session.release()
|
||||
|
||||
expect(session.active.value).toBe(false)
|
||||
expect(session.element.value).toBeNull()
|
||||
expect(hostEl()?.contains(video)).toBe(false)
|
||||
expect(hostEl()?.childElementCount).toBe(0)
|
||||
})
|
||||
|
||||
it('creates exactly one host node no matter how many times the composable is called', () => {
|
||||
const video = document.createElement('video')
|
||||
usePipSession().adopt(video)
|
||||
usePipSession()
|
||||
usePipSession()
|
||||
expect(document.querySelectorAll('[data-pip-session-host]').length).toBe(1)
|
||||
})
|
||||
|
||||
it('a leavepictureinpicture event on the adopted element releases the session', () => {
|
||||
const video = document.createElement('video')
|
||||
const session = usePipSession()
|
||||
session.adopt(video)
|
||||
|
||||
video.dispatchEvent(new Event('leavepictureinpicture'))
|
||||
|
||||
expect(session.active.value).toBe(false)
|
||||
expect(session.element.value).toBeNull()
|
||||
})
|
||||
|
||||
it('adopting a second element while one is active releases the first rather than leaking it', () => {
|
||||
const first = document.createElement('video')
|
||||
const second = document.createElement('video')
|
||||
const session = usePipSession()
|
||||
|
||||
session.adopt(first)
|
||||
session.adopt(second)
|
||||
|
||||
expect(session.element.value).toBe(second)
|
||||
expect(hostEl()?.contains(first)).toBe(false)
|
||||
expect(hostEl()?.contains(second)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPipSupported', () => {
|
||||
it('reads document state at call time, not at import time', () => {
|
||||
definePipStub(false)
|
||||
expect(isPipSupported()).toBe(false)
|
||||
|
||||
definePipStub(true)
|
||||
expect(isPipSupported()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
// Regression suite for the recurring "tx link opens tx1138.com instead of
|
||||
// the local Mempool app" bug (reported again on .228, 2026-08-06).
|
||||
//
|
||||
// The root cause was never the explorer preference — it was that
|
||||
// `getAppState` reports `not-installed` for an app whose container list has
|
||||
// not been fetched yet. A click that landed before the list arrived sent
|
||||
// the user to a third-party explorer, telling that operator which
|
||||
// transaction they cared about. These tests pin the fix: the decision waits
|
||||
// for real data, and the local app wins whenever it exists.
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
const openSession = vi.fn()
|
||||
vi.mock('@/stores/appLauncher', () => ({
|
||||
useAppLauncherStore: () => ({ openSession }),
|
||||
}))
|
||||
|
||||
let containerState: string
|
||||
let fetched: boolean
|
||||
const ensureFetched = vi.fn(async () => {
|
||||
// Mirrors the real store: state only becomes knowable after the fetch.
|
||||
fetched = true
|
||||
})
|
||||
vi.mock('@/stores/container', () => ({
|
||||
useContainerStore: () => ({
|
||||
ensureFetched,
|
||||
getAppState: (_id: string) => (fetched ? containerState : 'not-installed'),
|
||||
}),
|
||||
}))
|
||||
|
||||
import { useTxExplorer, DEFAULT_TX_EXPLORER } from '../useTxExplorer'
|
||||
|
||||
const TX = 'a'.repeat(64)
|
||||
|
||||
describe('useTxExplorer.openTx', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
fetched = false
|
||||
containerState = 'running'
|
||||
// Reset module-scope prefs/pending between tests.
|
||||
const { setExplorer, cancelPending } = useTxExplorer()
|
||||
setExplorer(DEFAULT_TX_EXPLORER, false)
|
||||
cancelPending()
|
||||
})
|
||||
|
||||
it('opens the local Mempool app when it is running', async () => {
|
||||
const { openTx, pendingTx } = useTxExplorer()
|
||||
await openTx(TX)
|
||||
expect(openSession).toHaveBeenCalledWith('mempool', { path: `/tx/${TX}` })
|
||||
expect(pendingTx.value).toBeNull()
|
||||
})
|
||||
|
||||
it('waits for the container list rather than assuming not-installed (the race)', async () => {
|
||||
const { openTx, pendingTx } = useTxExplorer()
|
||||
// fetched=false at click time — the old synchronous check read
|
||||
// 'not-installed' here and went external.
|
||||
await openTx(TX)
|
||||
expect(ensureFetched).toHaveBeenCalled()
|
||||
expect(openSession).toHaveBeenCalledWith('mempool', { path: `/tx/${TX}` })
|
||||
expect(pendingTx.value).toBeNull()
|
||||
})
|
||||
|
||||
it('still prefers the local app when it is installed but stopped', async () => {
|
||||
containerState = 'stopped'
|
||||
const { openTx } = useTxExplorer()
|
||||
await openTx(TX)
|
||||
expect(openSession).toHaveBeenCalledWith('mempool', { path: `/tx/${TX}` })
|
||||
})
|
||||
|
||||
it('prefers the local app mid-restart rather than leaking to a third party', async () => {
|
||||
containerState = 'restarting'
|
||||
const { openTx } = useTxExplorer()
|
||||
await openTx(TX)
|
||||
expect(openSession).toHaveBeenCalledWith('mempool', { path: `/tx/${TX}` })
|
||||
})
|
||||
|
||||
it('asks for consent only when Mempool genuinely is not installed', async () => {
|
||||
containerState = 'not-installed'
|
||||
const { openTx, pendingTx } = useTxExplorer()
|
||||
await openTx(TX)
|
||||
expect(openSession).not.toHaveBeenCalled()
|
||||
expect(pendingTx.value).toBe(TX)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent, h, KeepAlive, ref, Teleport } from 'vue'
|
||||
import { useViewActive } from '../useViewActive'
|
||||
|
||||
/**
|
||||
* Teleported chrome must not outlive the screen that raised it.
|
||||
*
|
||||
* Main tabs are KeepAlive'd, so navigating away deactivates a view instead of
|
||||
* unmounting it. Anything Teleported to <body> is outside the view's subtree
|
||||
* and therefore survives that deactivation — Mesh's mobile tab bar and the
|
||||
* shared BackButton stayed pinned above the bottom bar on every other screen.
|
||||
*/
|
||||
const ViewWithTeleportedChrome = defineComponent({
|
||||
name: 'ViewWithTeleportedChrome',
|
||||
setup() {
|
||||
const isViewActive = useViewActive()
|
||||
return () =>
|
||||
h('div', [
|
||||
isViewActive.value
|
||||
? h(Teleport, { to: 'body' }, [h('button', { class: 'leaky-chrome' }, 'Back')])
|
||||
: null,
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const Other = defineComponent({ name: 'Other', setup: () => () => h('div', 'other screen') })
|
||||
|
||||
function chromeCount() {
|
||||
return document.body.querySelectorAll('.leaky-chrome').length
|
||||
}
|
||||
|
||||
describe('useViewActive', () => {
|
||||
it('removes teleported chrome when the view is deactivated, and restores it on return', async () => {
|
||||
const showFirst = ref(true)
|
||||
const host = mount(
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(KeepAlive, null, {
|
||||
default: () => (showFirst.value ? h(ViewWithTeleportedChrome) : h(Other)),
|
||||
}),
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
)
|
||||
|
||||
expect(chromeCount()).toBe(1)
|
||||
|
||||
// Navigate away: KeepAlive DEACTIVATES rather than unmounts.
|
||||
showFirst.value = false
|
||||
await host.vm.$nextTick()
|
||||
expect(chromeCount()).toBe(0)
|
||||
|
||||
// Returning must bring it back — the whole point of KeepAlive is that the
|
||||
// instance survived, so the chrome has to come back with it.
|
||||
showFirst.value = true
|
||||
await host.vm.$nextTick()
|
||||
expect(chromeCount()).toBe(1)
|
||||
|
||||
host.unmount()
|
||||
})
|
||||
|
||||
it('keeps the instance alive across the round trip (performance is not sacrificed)', async () => {
|
||||
const showFirst = ref(true)
|
||||
const seen: number[] = []
|
||||
const Counting = defineComponent({
|
||||
name: 'Counting',
|
||||
setup() {
|
||||
const isViewActive = useViewActive()
|
||||
const uid = Math.random()
|
||||
seen.push(uid)
|
||||
return () => h('div', [isViewActive.value ? h(Teleport, { to: 'body' }, [h('i', { class: 'leaky-chrome' })]) : null])
|
||||
},
|
||||
})
|
||||
|
||||
const host = mount(
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(KeepAlive, null, { default: () => (showFirst.value ? h(Counting) : h(Other)) }),
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
)
|
||||
|
||||
showFirst.value = false
|
||||
await host.vm.$nextTick()
|
||||
showFirst.value = true
|
||||
await host.vm.$nextTick()
|
||||
|
||||
// setup() ran once: the view was cached, not re-created. If this ever
|
||||
// becomes 2, the fix has been "solved" by throwing away the perf work.
|
||||
expect(seen.length).toBe(1)
|
||||
expect(chromeCount()).toBe(1)
|
||||
|
||||
host.unmount()
|
||||
})
|
||||
|
||||
it('defaults to active outside a KeepAlive boundary', () => {
|
||||
// Neither hook fires here. A component used both ways — or mounted bare in
|
||||
// a test — must render normally rather than stay invisible forever.
|
||||
const host = mount(ViewWithTeleportedChrome, { attachTo: document.body })
|
||||
expect(chromeCount()).toBe(1)
|
||||
host.unmount()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user