Archipelago — open-source initial import
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user