discovery-by-llm/frontend/tests/resultPresentation.test.ts
Justin Visser dbc5c2d93f
Some checks are pending
ci / backend (push) Waiting to run
ci / frontend (push) Waiting to run
fix: stabilize the live recommendation path
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 10:47:30 +02:00

257 lines
8.4 KiB
TypeScript

import { createApp, defineComponent, h, nextTick, ref } from 'vue'
import type { Component } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import AppHeader from '../src/components/AppHeader.vue'
import AssistantMessage from '../src/components/AssistantMessage.vue'
import MessageList from '../src/components/MessageList.vue'
import SuggestionChips from '../src/components/SuggestionChips.vue'
import type { AssistantTurn, ChatTurn } from '../src/lib/models'
import type { TrackEvent } from '../src/lib/types'
const unmountCallbacks: Array<() => void> = []
function track(rank: number): TrackEvent {
return {
type: 'track',
rank,
track: {
id: `track-${rank}`,
uri: `spotify:track:${rank}`,
title: `Track ${rank}`,
artists: ['Artist'],
album_name: 'Album',
album_art_url: null,
external_url: null,
},
justification: 'It fits.',
}
}
function doneTurn(): AssistantTurn {
return {
id: 'assistant-1',
role: 'assistant',
query: 'Focused listening',
status: 'done',
requestId: 'request-1',
intentSummary: 'Calm music for focused work.',
candidateCount: 20,
tracks: [track(1), track(2)],
warnings: [{ type: 'warning', code: 'limited_pool', message: 'The pool was limited.' }],
error: null,
transportFailure: null,
completion: { type: 'done', track_count: 2, total_ms: 12 },
playlist: { status: 'idle', name: null, url: null, message: null },
}
}
function mountComponent(component: Component, props: Record<string, unknown>): HTMLElement {
const root = document.createElement('div')
document.body.append(root)
const app = createApp(
defineComponent({
render: () => h(component, props),
}),
)
app.mount(root)
unmountCallbacks.push(() => {
app.unmount()
root.remove()
})
return root
}
afterEach(() => {
while (unmountCallbacks.length) unmountCallbacks.pop()?.()
vi.restoreAllMocks()
})
describe('result presentation', () => {
it('uses the app title as a fresh-chat control', () => {
const onReset = vi.fn()
const root = mountComponent(AppHeader, {
auth: { status: 'anonymous', user: null, message: null },
mode: 'demo',
healthFailed: false,
onReset,
})
const button = root.querySelector<HTMLButtonElement>(
'button[aria-label="discovery-by-llm: start a new chat"]',
)
expect(button?.type).toBe('button')
expect(button?.textContent).toContain('discovery-by-llm')
button?.click()
expect(onReset).toHaveBeenCalledOnce()
})
it('renders demo as a header button and hides logout for a seeded user', () => {
const demoUrl = 'https://demo.example.com'
const root = mountComponent(AppHeader, {
auth: {
status: 'authenticated',
user: { display_name: 'Seed Listener', can_logout: false },
message: null,
},
mode: 'live',
healthFailed: false,
demoUrl,
})
const demoLink = root.querySelector<HTMLAnchorElement>('a.demo-link')
expect(demoLink?.classList.contains('button')).toBe(true)
expect(demoLink?.href).toBe(`${demoUrl}/`)
expect(demoLink?.textContent).toContain('demo')
expect(root.textContent).toContain('Seed Listener')
expect(root.textContent).not.toContain('Log out')
})
it('keeps logout available for an interactive local user', () => {
const onLogout = vi.fn()
const root = mountComponent(AppHeader, {
auth: {
status: 'authenticated',
user: { display_name: 'Local Listener', can_logout: true },
message: null,
},
mode: 'live',
healthFailed: false,
demoUrl: null,
onLogout,
})
const logout = [...root.querySelectorAll<HTMLButtonElement>('button')].find((button) =>
button.textContent?.includes('Log out'),
)
logout?.click()
expect(onLogout).toHaveBeenCalledOnce()
})
it('keeps completion context outside the focusable track region', () => {
const root = mountComponent(AssistantMessage, { turn: doneTurn(), canSave: true })
const region = root.querySelector<HTMLElement>(
'[role="region"][aria-label="2 recommended tracks"]',
)
const summary = root.querySelector<HTMLElement>('.intent')
const warning = root.querySelector<HTMLElement>('[data-warning-code="limited_pool"]')
const action = root.querySelector<HTMLButtonElement>('button')
expect(region?.tabIndex).toBe(0)
expect(region?.querySelectorAll('article')).toHaveLength(2)
expect(summary?.textContent).toContain('Calm music')
expect(warning?.textContent).toContain('pool was limited')
expect(action?.textContent).toContain('Save as playlist')
expect(region?.contains(summary ?? null)).toBe(false)
expect(region?.contains(warning ?? null)).toBe(false)
expect(region?.contains(action ?? null)).toBe(false)
})
it('renders arriving cards without an artificial entrance delay', () => {
const root = mountComponent(AssistantMessage, { turn: doneTurn(), canSave: true })
const cards = root.querySelectorAll<HTMLElement>('article')
expect(cards.length).toBeGreaterThan(1)
expect(cards[0]?.style.getPropertyValue('--card-order')).toBe('')
expect(cards[1]?.style.getPropertyValue('--card-order')).toBe('')
})
it('anchors a completed response at the top of the conversation viewport', async () => {
const scrollIntoView = vi
.spyOn(Element.prototype, 'scrollIntoView')
.mockImplementation(() => undefined)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
callback(0)
return 1
})
const complete = doneTurn()
const turns = ref<ChatTurn[]>([
{ id: 'user-1', role: 'user', text: 'Focused listening' },
{ ...complete, status: 'streaming', completion: null },
])
const root = document.createElement('div')
document.body.append(root)
const app = createApp(
defineComponent({
render: () =>
h(MessageList, {
turns: turns.value,
suggestions: [],
canSave: true,
}),
}),
)
app.mount(root)
unmountCallbacks.push(() => {
app.unmount()
root.remove()
})
turns.value = [turns.value[0] as ChatTurn, complete]
await nextTick()
await nextTick()
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'start' })
})
it('keeps suggestions in a labeled region', () => {
const onPick = vi.fn()
const suggestions = ['Focus', 'Energy', 'Surprise']
const root = mountComponent(SuggestionChips, { suggestions, onPick })
const region = root.querySelector<HTMLElement>('[aria-label="Listening suggestions"]')
const buttons = root.querySelectorAll<HTMLButtonElement>('button')
expect(region).not.toBeNull()
expect(buttons).toHaveLength(suggestions.length)
expect([...buttons].map((button) => button.textContent?.trim())).toEqual(suggestions)
buttons[0]?.click()
expect(onPick).toHaveBeenCalledWith('Focus')
})
it('renders a quota error without retry and keeps the demo escape hatch', () => {
const turn: AssistantTurn = {
...doneTurn(),
status: 'error',
tracks: [],
warnings: [],
error: {
type: 'error',
code: 'quota_exhausted',
message: "Spotify's Development Mode quota is temporarily exhausted.",
},
completion: null,
}
const demoUrl = 'https://demo.example.com'
const root = mountComponent(AssistantMessage, {
turn,
canSave: false,
demoUrl,
})
const alert = root.querySelector<HTMLElement>('[role="alert"]')
const demoLink = alert?.querySelector<HTMLAnchorElement>('a')
expect(alert?.dataset.errorCode).toBe('quota_exhausted')
expect(alert?.querySelector('button')).toBeNull()
expect(demoLink?.href).toBe(`${demoUrl}/`)
expect(demoLink?.textContent).toContain('Open the demo instance')
})
it('renders a first-stage stream error with its code and message', () => {
const turn: AssistantTurn = {
...doneTurn(),
status: 'error',
requestId: null,
intentSummary: '',
tracks: [],
warnings: [],
error: { type: 'error', code: 'first_stage_failed', message: 'Planning failed.' },
transportFailure: null,
completion: null,
}
const root = mountComponent(AssistantMessage, { turn, canSave: false })
const alert = root.querySelector<HTMLElement>('[role="alert"]')
expect(alert?.dataset.errorCode).toBe('first_stage_failed')
expect(alert?.textContent).toContain('Planning failed.')
})
})