Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit b67e1527a2
2068 changed files with 472303 additions and 0 deletions
@@ -0,0 +1,166 @@
import { describe, it, expect } from 'vitest'
import {
extractAllFilms,
extractAllSongs,
extractAllBooks,
extractAllTVSeries,
extractAllPlaces,
extractAllImages,
extractMagazineSections,
stripContentTags,
} from '../contentExtraction'
// ─── Edge cases ──────────────────────────────────────────────────
describe('contentExtraction edge cases', () => {
it('handles empty string', () => {
expect(extractAllFilms('')).toEqual([])
expect(extractAllSongs('', '')).toEqual([])
expect(extractAllBooks('', '')).toEqual([])
expect(extractAllTVSeries('', '')).toEqual([])
expect(extractAllPlaces('', '')).toEqual([])
expect(extractAllImages('', '')).toEqual([])
expect(extractMagazineSections('')).toEqual([])
})
it('handles text with no tags', () => {
const text = 'Just a regular response about movies and music.'
expect(extractAllFilms(text)).toEqual([])
expect(extractAllSongs(text, '')).toEqual([])
})
it('handles interleaved tags of different types', () => {
const text = `
Here are some recommendations:
[[film_ext:Inception|2010|Christopher Nolan]]
Then a great song:
[[song_ext:Bohemian Rhapsody|Queen|1975]]
And a book:
[[book_ext:Dune|Frank Herbert|1965]]
`
const films = extractAllFilms(text)
const songs = extractAllSongs(text, '')
const books = extractAllBooks(text, '')
expect(films).toHaveLength(1)
expect(films[0].title).toBe('Inception')
expect(songs).toHaveLength(1)
expect(songs[0].title).toBe('Bohemian Rhapsody')
expect(books).toHaveLength(1)
expect(books[0].title).toBe('Dune')
})
it('handles malformed tags (missing fields)', () => {
// film_ext requires title|year|director — missing any means no match
expect(extractAllFilms('[[film_ext:Inception]]')).toHaveLength(0)
expect(extractAllFilms('[[film_ext:Inception|2010]]')).toHaveLength(0)
// All 3 fields present — matches
const films = extractAllFilms('[[film_ext:Inception|2010|Christopher Nolan]]')
expect(films).toHaveLength(1)
expect(films[0].title).toBe('Inception')
})
it('handles unicode content in tags', () => {
const text = '[[film_ext:千と千尋の神隠し|2001|宮崎駿]]'
const films = extractAllFilms(text)
expect(films).toHaveLength(1)
expect(films[0].title).toBe('千と千尋の神隠し')
})
it('handles tags with extra whitespace in pipe-separated values', () => {
// Regex captures include leading/trailing spaces in groups
// but the tag format requires no spaces around brackets
const text = '[[film_ext:Inception|2010|Christopher Nolan]]'
const films = extractAllFilms(text)
expect(films).toHaveLength(1)
expect(films[0].title).toBe('Inception')
expect(films[0].director).toBe('Christopher Nolan')
})
it('handles duplicate tags (same title)', () => {
const text = `
[[film_ext:Inception|2010|Christopher Nolan]]
[[film_ext:Inception|2010|Christopher Nolan]]
`
const films = extractAllFilms(text)
// Should deduplicate
expect(films).toHaveLength(1)
})
it('handles place_ext with all fields', () => {
const text = '[[place_ext:Sushi Nakazawa|40.7258|-74.0030|Japanese|$$$$|New York]]'
const places = extractAllPlaces(text, '')
expect(places).toHaveLength(1)
expect(places[0].name).toBe('Sushi Nakazawa')
})
it('handles tv_ext tags', () => {
const text = '[[tv_ext:Breaking Bad|2008|Vince Gilligan]]'
const series = extractAllTVSeries(text, '')
expect(series).toHaveLength(1)
expect(series[0].title).toBe('Breaking Bad')
})
it('stripContentTags removes all tag types', () => {
const text = 'Watch [[film_ext:Inception|2010|Nolan]] and listen to [[song_ext:Song|Artist|2020]]'
const stripped = stripContentTags(text)
expect(stripped).not.toContain('[[')
expect(stripped).not.toContain(']]')
expect(stripped).toContain('Watch')
expect(stripped).toContain('and listen to')
})
it('handles magazine sections with bold markers', () => {
const text = `
- **Bitcoin rallies**: Price surges 5% as ETF inflows hit record.
- **Lightning Network growth**: Capacity doubles in Q1 2026.
- **Mining difficulty**: New all-time high reached.
`
const sections = extractMagazineSections(text)
expect(sections.length).toBeGreaterThanOrEqual(1)
// First section is a summary grouping the items
const titles = sections.map(s => s.title)
expect(titles.length).toBeGreaterThan(0)
})
})
describe('description pairing (operator-reported: cards carried the WRONG description)', () => {
// Verbatim shape of the Bitcoin-films answer that produced mis-paired cards.
// The model's prose was correct; the parser paired each title with the
// PREVIOUS line because `(?:^|\n)`-anchored patterns report m.index at the
// newline, shifting the line window back by one line.
const TRANSCRIPT = [
'Here are some films about Bitcoin:',
'',
'Documentaries:',
'',
'- The Rise and Rise of Bitcoin (2014) – Early documentary following Bitcoin\'s emergence and community.',
'- Banking on Bitcoin (2016) – Explores Bitcoin\'s origins and its potential to disrupt finance.',
'- Cryptopia (2020) – Examines both the promise and pitfalls of crypto.',
].join('\n')
it('does not caption a title with the previous item\'s description', () => {
const series = extractAllTVSeries(TRANSCRIPT, 'recommend me some bitcoin series')
const banking = series.find(s => /Banking on Bitcoin/i.test(s.title))
if (banking?.synopsis) {
expect(banking.synopsis).not.toMatch(/Early documentary/i)
expect(banking.synopsis).toMatch(/Explores Bitcoin/i)
}
})
it('does not bleed a section header into the first card of a group', () => {
const series = extractAllTVSeries(TRANSCRIPT, 'recommend me some bitcoin series')
for (const s of series) {
expect(s.synopsis ?? '').not.toMatch(/Documentaries:/i)
}
})
it('keeps each item paired with its OWN description', () => {
const series = extractAllTVSeries(TRANSCRIPT, 'recommend me some bitcoin series')
const rise = series.find(s => /Rise and Rise/i.test(s.title))
if (rise?.synopsis) {
expect(rise.synopsis).toMatch(/Early documentary/i)
expect(rise.synopsis).not.toMatch(/Explores Bitcoin/i)
}
})
})
@@ -0,0 +1,34 @@
// Regression: operator phrasing must not be mistaken for content queries.
//
// Reported on-device 2026-08-06 — the content panel showed "Podcast
// recommendations" for a query that had nothing to do with podcasts. Both
// classifiers (this one and ChatPage's loader label) matched a bare `show`,
// which is how an operator phrases most requests to a node assistant:
// "show me my files", "show the logs", "show installed apps".
import { describe, it, expect } from 'vitest'
import { preferredFirstTab } from '../contentFiltering'
describe('preferredFirstTab — operator phrasing is not a content query', () => {
it('does not classify "show me ..." as a podcast query', () => {
expect(preferredFirstTab('show me my files')).not.toBe('podcast')
expect(preferredFirstTab('show the filebrowser logs')).not.toBe('podcast')
expect(preferredFirstTab('show installed apps')).not.toBe('podcast')
})
it('still classifies genuine podcast queries', () => {
expect(preferredFirstTab('recommend me a podcast')).toBe('podcast')
expect(preferredFirstTab('any good episode about bitcoin?')).toBe('podcast')
// NB: a bare "listen" belongs to the song rule, which is checked
// first — only the explicit "listen to a podcast" phrasing lands here.
expect(preferredFirstTab('listen to a podcast about bitcoin')).toBe('podcast')
})
it('keeps tv-show detection, which needs the two-word form', () => {
expect(preferredFirstTab('best tv show to binge')).toBe('tvshow')
})
it('still classifies films and music', () => {
expect(preferredFirstTab('recommend me 10 scifi films')).toBe('film')
expect(preferredFirstTab('good album for working')).toBe('song')
})
})
@@ -0,0 +1,18 @@
import { createApp, defineComponent } from 'vue'
/**
* Helper to test composables that use lifecycle hooks (onMounted, etc.)
* Creates a temporary Vue app, mounts it, runs the composable, and returns the result.
*/
export function withSetup<T>(composable: () => T): [T, ReturnType<typeof createApp>] {
let result!: T
const app = createApp(defineComponent({
setup() {
result = composable()
return () => null
},
}))
const div = document.createElement('div')
app.mount(div)
return [result, app]
}
@@ -0,0 +1,73 @@
/**
* 13-11: requestArchyLibrary (sibling of 13-06's requestArchyContent) and
* the init-time auto-trigger that fires both over the real bridge — the
* GAP-FOUND fix. 13-06 built requestArchyContent/content:request/
* content:push and unit-tested all of it, but nothing in the live UI ever
* called it (see 13-06-SUMMARY.md's Known Limitations); this pins that the
* fetch now fires from a real init-time event, not merely from a direct
* unit-test call.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
type PostedMessage = { type: string; kind?: string; scope?: string; [key: string]: unknown }
describe('useArchy: requestArchyLibrary + init-time auto-trigger (13-11)', () => {
let originalParent: Window
beforeEach(() => {
vi.resetModules()
originalParent = window.parent
Object.defineProperty(window, 'parent', {
value: { postMessage: vi.fn() },
writable: true,
configurable: true,
})
;(window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__ = true
})
afterEach(() => {
Object.defineProperty(window, 'parent', {
value: originalParent,
writable: true,
configurable: true,
})
delete (window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__
vi.restoreAllMocks()
})
it('requestArchyLibrary sends a content:request with kind "library"', async () => {
const { useArchy } = await import('@/composables/useArchy')
const archy = useArchy()
archy.init()
archy.requestArchyLibrary('own').catch(() => {})
expect(window.parent.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'content:request', kind: 'library', scope: 'own' }),
window.location.origin,
)
archy.destroy()
})
it('init() fires both requestArchyContent and requestArchyLibrary as a live init-time event — not merely callable (GAP-FOUND)', async () => {
const { useArchy } = await import('@/composables/useArchy')
const archy = useArchy()
archy.init()
const postMessage = window.parent.postMessage as unknown as ReturnType<typeof vi.fn>
const contentRequests = postMessage.mock.calls
.map(([msg]) => msg as PostedMessage)
.filter((msg) => msg.type === 'content:request')
expect(contentRequests.some((msg) => msg.kind === 'all')).toBe(true)
expect(contentRequests.some((msg) => msg.kind === 'library')).toBe(true)
archy.destroy()
})
it('exposes requestArchyLibrary on the composable\'s returned API', async () => {
const { useArchy } = await import('@/composables/useArchy')
const archy = useArchy()
expect(typeof archy.requestArchyLibrary).toBe('function')
})
})
@@ -0,0 +1,92 @@
// Regression pin for the stale-banner bug (operator-reported 2026-08-04):
// detail views are REUSED, not remounted, when a different item is selected in
// the content window. The composable's stage/apiUrl/primaryIndex used to
// survive that change, so the context-surface banner kept showing the previous
// item's artwork forever. These tests pin the identity-keyed reset and the
// stale-fetch generation guard.
import { describe, it, expect, vi } from 'vitest'
import { ref, nextTick } from 'vue'
import { useBannerFallback } from '../useBannerFallback'
function deferred<T>() {
let resolve!: (v: T) => void
const promise = new Promise<T>((r) => { resolve = r })
return { promise, resolve }
}
describe('useBannerFallback', () => {
it('resets to the new item\'s primary URL after the previous item resolved via API', async () => {
const title = ref('Item A')
const primaries = ref<(string | null)[]>([])
const apiFetch = vi.fn().mockResolvedValue({ posterUrl: 'https://api/a.jpg', backdropUrl: null })
const { bannerSrc } = useBannerFallback({
primaryUrls: () => primaries.value,
apiFetch,
title: () => title.value,
})
// Item A has no primaries — resolves through the API path.
await nextTick()
await vi.waitFor(() => expect(bannerSrc.value).toBe('https://api/a.jpg'))
// Select item B, which has its own primary URL. Before the fix, stage was
// stuck at 'api' and bannerSrc kept returning A's artwork.
title.value = 'Item B'
primaries.value = ['https://cdn/b-poster.jpg']
await nextTick()
await nextTick()
expect(bannerSrc.value).toBe('https://cdn/b-poster.jpg')
})
it('discards a stale API resolution that lands after the item changed', async () => {
const title = ref('Item A')
const primaries = ref<(string | null)[]>([])
const slow = deferred<{ posterUrl: string | null; backdropUrl: string | null }>()
const apiFetch = vi.fn().mockReturnValue(slow.promise)
const { bannerSrc } = useBannerFallback({
primaryUrls: () => primaries.value,
apiFetch,
title: () => title.value,
})
await nextTick()
// Item changes while A's fetch is still in flight.
title.value = 'Item B'
primaries.value = ['https://cdn/b-poster.jpg']
await nextTick()
await nextTick()
expect(bannerSrc.value).toBe('https://cdn/b-poster.jpg')
// A's fetch finally resolves — it must NOT stamp A's artwork over B.
slow.resolve({ posterUrl: 'https://api/a-late.jpg', backdropUrl: null })
await slow.promise
await nextTick()
expect(bannerSrc.value).toBe('https://cdn/b-poster.jpg')
})
it('re-runs the no-primary API kickoff for the new item after a reset', async () => {
const title = ref('Item A')
const primaries = ref<(string | null)[]>(['https://cdn/a.jpg'])
const apiFetch = vi.fn().mockResolvedValue({ posterUrl: 'https://api/b.jpg', backdropUrl: null })
const { bannerSrc } = useBannerFallback({
primaryUrls: () => primaries.value,
apiFetch,
title: () => title.value,
})
expect(bannerSrc.value).toBe('https://cdn/a.jpg')
expect(apiFetch).not.toHaveBeenCalled()
// New item with NO primaries — the kickoff must fire again post-reset.
title.value = 'Item B'
primaries.value = []
await nextTick()
await nextTick()
await vi.waitFor(() => expect(bannerSrc.value).toBe('https://api/b.jpg'))
expect(apiFetch).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,194 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useContentPanel } from '../useContentPanel'
import type { Film, Song, ImageItem, Place } from '@aiui/core/types/content'
describe('useContentPanel', () => {
let panel: ReturnType<typeof useContentPanel>
beforeEach(() => {
panel = useContentPanel()
panel.closePanel()
})
it('starts with panel closed', () => {
expect(panel.panelOpen.value).toBe(false)
expect(panel.availableTabs.value).toHaveLength(0)
})
it('setActiveTab only sets valid tabs', () => {
panel.availableTabs.value = ['film', 'song', 'prompt']
panel.setActiveTab('film')
expect(panel.activeTab.value).toBe('film')
panel.setActiveTab('song')
expect(panel.activeTab.value).toBe('song')
// Invalid tab should not change
panel.setActiveTab('podcast')
expect(panel.activeTab.value).toBe('song')
})
it('openFilmDetail sets selected film and clears others', () => {
const film = { id: 'f1', title: 'Test Film', year: 2025, genres: [], director: 'Dir' } as unknown as Film
panel.openFilmDetail(film)
expect(panel.selectedFilm.value).toEqual(film)
expect(panel.selectedSong.value).toBeNull()
expect(panel.selectedBook.value).toBeNull()
})
it('closeFilmDetail clears selection', () => {
const film = { id: 'f1', title: 'Test Film', year: 2025, genres: [], director: 'Dir' } as unknown as Film
panel.openFilmDetail(film)
panel.closeFilmDetail()
expect(panel.selectedFilm.value).toBeNull()
})
it('openSongDetail clears film selection', () => {
const film = { id: 'f1', title: 'Test Film', year: 2025, genres: [], director: 'Dir' } as unknown as Film
const song = { id: 's1', title: 'Test Song', artist: 'Artist', album: 'Album', genres: [] } as Song
panel.openFilmDetail(film)
panel.openSongDetail(song)
expect(panel.selectedSong.value).toEqual(song)
expect(panel.selectedFilm.value).toBeNull()
})
it('closePanel resets all state', () => {
const film = { id: 'f1', title: 'Test Film', year: 2025, genres: [], director: 'Dir' } as unknown as Film
panel.openFilmDetail(film)
panel.panelOpen.value = true
panel.availableTabs.value = ['film', 'prompt']
panel.closePanel()
expect(panel.panelOpen.value).toBe(false)
expect(panel.selectedFilm.value).toBeNull()
expect(panel.availableTabs.value).toHaveLength(0)
})
it('enterDesignSystemMode sets up design system tab', () => {
panel.enterDesignSystemMode()
expect(panel.panelOpen.value).toBe(true)
expect(panel.activeTab.value).toBe('design-system')
expect(panel.availableTabs.value).toEqual(['design-system'])
expect(panel.panelTitle.value).toBe('Design System')
})
it('openImageDetail and closeImageDetail work', () => {
const image = { id: 'i1', title: 'Test Image', url: 'https://example.com/img.jpg' } as ImageItem
panel.openImageDetail(image)
expect(panel.selectedImage.value).toEqual(image)
panel.closeImageDetail()
expect(panel.selectedImage.value).toBeNull()
})
// 13-11 (GAP-FOUND 2026-08-03): setArchyContent must make real,
// non-empty node content actually visible — populating panelFilms/
// panelSongs/panelPodcasts alone left the tab bar and panelOpen
// untouched, so real data sat in memory and never rendered.
describe('setArchyContent makes non-empty content actually appear', () => {
it('opens the panel and adds a song tab when songs are non-empty', () => {
const song = { id: 's1', title: 'Song', artist: 'Artist', album: 'Album', genres: [] } as Song
panel.setArchyContent({ songs: [song] })
expect(panel.panelOpen.value).toBe(true)
expect(panel.availableTabs.value).toContain('song')
expect(panel.availableTabs.value).toContain('prompt')
expect(panel.activeTab.value).toBe('song')
expect(panel.panelTitle.value).toBe('Song')
})
it('adds a film tab and a song tab together when both are non-empty', () => {
const film = { id: 'f1', title: 'Film', year: 2025, genres: [], director: 'Dir' } as unknown as Film
const song = { id: 's1', title: 'Song', artist: 'Artist', album: 'Album', genres: [] } as Song
panel.setArchyContent({ films: [film], songs: [song] })
expect(panel.availableTabs.value).toEqual(['film', 'song', 'prompt'])
expect(panel.activeTab.value).toBe('film')
})
it('does not force the panel open when the bundle is entirely empty', () => {
panel.closePanel()
panel.setArchyContent({ films: [], songs: [], podcasts: [] })
expect(panel.panelOpen.value).toBe(false)
expect(panel.availableTabs.value).toHaveLength(0)
// An empty delivery does NOT latch: nothing was supplied, so there is
// no node truth to protect and extraction must stay writable. The
// honest empty state is carried by the 'Nothing found' heading, not
// by the latch.
expect(panel.archyContentActive.value).toBe(false)
})
// Regression (live on archi-dev-box 2026-08-07): "show me paid for peer
// files" — the turn's surface carried 3 purchased images, the reply text
// was a plain markdown list matching no extraction pattern, and
// updatePanelFromText then set panelOpen from the regex-only tab list,
// CLOSING the panel setArchyContent had just opened. The probe showed
// surfaces=1 arriving; the user saw only prose.
it('keeps the panel open on node content when the reply text infers no tabs', () => {
const img = { id: 'i1', title: 'signal-test.jpeg', url: '/content/abc', mimeType: 'image/jpeg' } as unknown as ImageItem
panel.setArchyContent({ images: [img] })
expect(panel.panelOpen.value).toBe(true)
expect(panel.activeTab.value).toBe('image')
// The turn's prose answer arrives after the surfaces and fires the
// text path: a bullet list with no tags, no links, no patterns.
panel.updatePanelFromText(
"You've purchased 4 items from peer nodes:\n\n- signal-test.jpeg (170 KB) — 100 sats\n- Photos/got it!.jpg (253 KB) — 100 sats\n\nThese are mostly photos.",
'show me paid for peer files',
)
expect(panel.panelOpen.value).toBe(true)
expect(panel.availableTabs.value[0]).toBe('image')
expect(panel.panelImages.value).toHaveLength(1)
})
// Regression (operator report 2026-08-07): "recommend me 10 scifi films"
// — the node has no films, the model's reply carried [[film_ext:…]] tags,
// and the global archy latch (set at mount by the node's own images)
// meant extraction could never write panelFilms: ten recommendations
// rendered as bare prose beside an empty panel.
it('renders extracted recommendation previews in a bucket the node left empty', () => {
const img = { id: 'i1', title: 'photo.jpg', url: '/content/x', mimeType: 'image/jpeg' } as unknown as ImageItem
panel.setArchyContent({ images: [img], films: [], songs: [], podcasts: [] })
// The turn: the films scope came back empty, the reply carries tags.
panel.beginArchyContentLoad()
panel.setArchyContent({ films: [], songs: [], podcasts: [], images: [] })
panel.updatePanelFromText(
'Here are two sci-fi films:\n\n[[film_ext:Blade Runner 2049|2017|Denis Villeneuve]] — A stunning neo-noir sequel.\n\n[[film_ext:Arrival|2016|Denis Villeneuve]] — Thoughtful first contact.',
'recommend me sci-fi films',
)
expect(panel.panelFilms.value.map((f) => f.title)).toEqual(['Blade Runner 2049', 'Arrival'])
expect(panel.panelOpen.value).toBe(true)
expect(panel.availableTabs.value[0]).toBe('film')
})
it('never lets extracted tags overwrite a bucket the node actually filled', () => {
const song = { id: 's1', title: 'Real Node Song', artist: 'A', album: 'B', genres: [] } as Song
panel.setArchyContent({ songs: [song] })
panel.updatePanelFromText(
'You might also like [[song_ext:Fake Song|Nobody|2020]]',
'what music do I have',
)
expect(panel.panelSongs.value).toHaveLength(1)
expect(panel.panelSongs.value[0].title).toBe('Real Node Song')
})
it("keeps 'Nothing found' when the node and the reply both yield nothing", () => {
panel.closePanel()
panel.beginArchyContentLoad()
panel.setArchyContent({ films: [], songs: [], podcasts: [], images: [] })
expect(panel.panelTitle.value).toBe('Nothing found')
panel.updatePanelFromText('I could not find anything matching that on this node.', 'show me films')
expect(panel.panelTitle.value).toBe('Nothing found')
expect(panel.panelOpen.value).toBe(false)
})
})
it('openPlaceDetail and closePlaceDetail work', () => {
const place = { id: 'p1', name: 'Test Place', address: '123 St', lat: 0, lng: 0 } as Place
panel.openPlaceDetail(place)
expect(panel.selectedPlace.value).toEqual(place)
panel.closePlaceDetail()
expect(panel.selectedPlace.value).toBeNull()
})
})
@@ -0,0 +1,156 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
// Mock Plyr before importing usePlayer
vi.mock('plyr', () => {
const MockPlyr = vi.fn().mockImplementation(() => ({
on: vi.fn(),
play: vi.fn().mockResolvedValue(undefined),
pause: vi.fn(),
destroy: vi.fn(),
currentTime: 0,
duration: 120,
}))
return { default: MockPlyr }
})
vi.mock('plyr/dist/plyr.css', () => ({}))
import { usePlayer } from '../usePlayer'
function makeSong(id: string, title = `Song ${id}`, artist = `Artist ${id}`) {
return {
id,
title,
artist,
album: 'Test Album',
genres: [],
}
}
describe('usePlayer', () => {
beforeEach(() => {
const player = usePlayer()
player.clearQueue()
vi.restoreAllMocks()
// Reset fetch mock
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
json: () => Promise.resolve({ source: 'test', type: 'stream', url: 'https://example.com/audio.mp3' }),
}))
})
it('returns expected API shape', () => { const player = usePlayer()
expect(player.currentSong).toBeDefined()
expect(player.isPlaying).toBeDefined()
expect(player.isLoading).toBeDefined()
expect(player.queue).toBeDefined()
expect(player.play).toBeTypeOf('function')
expect(player.pause).toBeTypeOf('function')
expect(player.toggle).toBeTypeOf('function')
expect(player.seek).toBeTypeOf('function')
expect(player.addToQueue).toBeTypeOf('function')
expect(player.playNext).toBeTypeOf('function')
expect(player.playPrevious).toBeTypeOf('function')
expect(player.clearQueue).toBeTypeOf('function')
})
it('plays a node-source song directly without touching Wavlake', async () => {
const fetchSpy = vi.fn()
vi.stubGlobal('fetch', fetchSpy)
const player = usePlayer()
const song = {
...makeSong('n1', 'Node Track', 'Node Artist'),
sources: [{ type: 'funkwhale', name: 'This node', url: '/content/abc-123' }],
}
await player.play(song as never)
expect(fetchSpy).not.toHaveBeenCalled()
expect(player.isLoading.value).toBe(false)
expect(player.error.value).toBeNull()
expect(player.currentSong.value?.id).toBe('n1')
expect(player.playableSource.value?.url).toBe('/content/abc-123')
expect(player.playableSource.value?.source).toBe('node')
})
it('starts with no track', () => {
const player = usePlayer()
expect(player.hasTrack.value).toBe(false)
expect(player.currentSong.value).toBeNull()
expect(player.isPlaying.value).toBe(false)
})
it('addToQueue adds songs', () => {
const player = usePlayer()
const song1 = makeSong('1')
const song2 = makeSong('2')
player.addToQueue(song1)
player.addToQueue(song2)
expect(player.queue.value).toHaveLength(2)
expect(player.queue.value[0].id).toBe('1')
expect(player.queue.value[1].id).toBe('2')
})
it('addToQueue deduplicates by id', () => {
const player = usePlayer()
const song = makeSong('1')
player.addToQueue(song)
player.addToQueue(song)
expect(player.queue.value).toHaveLength(1)
})
it('removeFromQueue removes by index', () => {
const player = usePlayer()
player.addToQueue(makeSong('1'))
player.addToQueue(makeSong('2'))
player.addToQueue(makeSong('3'))
player.removeFromQueue(1)
expect(player.queue.value).toHaveLength(2)
expect(player.queue.value[0].id).toBe('1')
expect(player.queue.value[1].id).toBe('3')
})
it('clearQueue resets all state', () => {
const player = usePlayer()
player.addToQueue(makeSong('1'))
player.addToQueue(makeSong('2'))
player.clearQueue()
expect(player.queue.value).toHaveLength(0)
expect(player.currentIndex.value).toBe(-1)
expect(player.currentSong.value).toBeNull()
})
it('hasNext and hasPrevious compute correctly', () => {
const player = usePlayer()
player.addToQueue(makeSong('1'))
player.addToQueue(makeSong('2'))
player.addToQueue(makeSong('3'))
// At start, no previous
expect(player.hasPrevious.value).toBe(false)
// Move to middle
player.currentIndex.value = 1
expect(player.hasNext.value).toBe(true)
expect(player.hasPrevious.value).toBe(true)
// At end
player.currentIndex.value = 2
expect(player.hasNext.value).toBe(false)
expect(player.hasPrevious.value).toBe(true)
})
it('progress computes percentage from currentTime/duration', () => {
const player = usePlayer()
player.duration.value = 100
player.currentTime.value = 50
expect(player.progress.value).toBe(50)
player.currentTime.value = 0
expect(player.progress.value).toBe(0)
})
it('progress returns 0 when duration is 0', () => {
const player = usePlayer()
player.duration.value = 0
player.currentTime.value = 10
expect(player.progress.value).toBe(0)
})
})
@@ -0,0 +1,93 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { withSetup } from './testUtils'
import { useVisualViewport } from '../useVisualViewport'
// Helper to create a mock VisualViewport
function createMockViewport(height = 800) {
const listeners: Record<string, Set<EventListener>> = {}
return {
height,
width: 390,
offsetLeft: 0,
offsetTop: 0,
pageLeft: 0,
pageTop: 0,
scale: 1,
addEventListener: vi.fn((event: string, handler: EventListener) => {
if (!listeners[event]) listeners[event] = new Set()
listeners[event].add(handler)
}),
removeEventListener: vi.fn((event: string, handler: EventListener) => {
listeners[event]?.delete(handler)
}),
dispatchEvent: vi.fn(),
onresize: null,
onscroll: null,
_listeners: listeners,
_setHeight(h: number) {
this.height = h
// Fire resize listeners
listeners['resize']?.forEach(fn => fn(new Event('resize')))
},
}
}
describe('useVisualViewport', () => {
let mockVV: ReturnType<typeof createMockViewport>
beforeEach(() => {
vi.useFakeTimers()
mockVV = createMockViewport(800)
Object.defineProperty(window, 'visualViewport', { value: mockVV, configurable: true, writable: true })
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true, writable: true })
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it('returns expected API shape', () => {
const [result] = withSetup(() => useVisualViewport())
expect(result.keyboardHeight).toBeDefined()
expect(result.isKeyboardOpen).toBeDefined()
expect(result.viewportHeight).toBeDefined()
})
it('initializes viewportHeight from visualViewport', () => {
const [result] = withSetup(() => useVisualViewport())
expect(result.viewportHeight.value).toBe(800)
})
it('detects keyboard open when height shrinks significantly', () => {
const [result] = withSetup(() => useVisualViewport())
// Simulate keyboard opening (shrinks to 500px from 800px = 300px keyboard)
mockVV._setHeight(500)
vi.advanceTimersByTime(60) // past debounce
expect(result.isKeyboardOpen.value).toBe(true)
expect(result.keyboardHeight.value).toBe(300)
expect(result.viewportHeight.value).toBe(500)
})
it('does not trigger keyboard for small viewport changes', () => {
const [result] = withSetup(() => useVisualViewport())
// Small change (50px) — not keyboard
mockVV._setHeight(750)
vi.advanceTimersByTime(60)
expect(result.isKeyboardOpen.value).toBe(false)
expect(result.keyboardHeight.value).toBe(50)
})
it('debounces rapid viewport changes', () => {
const [result] = withSetup(() => useVisualViewport())
// Rapid changes — only last should apply
mockVV._setHeight(600)
vi.advanceTimersByTime(10)
mockVV._setHeight(550)
vi.advanceTimersByTime(10)
mockVV._setHeight(500)
vi.advanceTimersByTime(60)
expect(result.viewportHeight.value).toBe(500)
expect(result.keyboardHeight.value).toBe(300)
})
})