fix(frontend): refine streamed presentation and resolve stream edge cases
This commit is contained in:
parent
a3af8f45cc
commit
2cc33a721c
18 changed files with 450 additions and 46 deletions
|
|
@ -128,8 +128,23 @@ describe('streamRecommendations', () => {
|
|||
expect(wasCancelled).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a terminal error as the only event', async () => {
|
||||
const error = { type: 'error', code: 'first_stage_failed', message: 'Planning failed.' }
|
||||
stubResponse(streamResponse([encoder.encode(`${JSON.stringify(error)}\n`)]))
|
||||
const events: StreamEvent[] = []
|
||||
|
||||
await streamRecommendations(
|
||||
{ schema_version: 1, query: 'focus', history: [], prior_recommendations: [] },
|
||||
new AbortController().signal,
|
||||
(event) => events.push(event),
|
||||
)
|
||||
|
||||
expect(events).toEqual([error])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an event before metadata', [{ type: 'warning', code: 'early', message: 'No metadata.' }]],
|
||||
['done before metadata', [{ type: 'done', track_count: 0, total_ms: 4 }]],
|
||||
[
|
||||
'duplicate metadata',
|
||||
[
|
||||
|
|
|
|||
186
frontend/tests/resultPresentation.test.ts
Normal file
186
frontend/tests/resultPresentation.test.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
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('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('assigns a deliberate stagger order to arriving cards', () => {
|
||||
const root = mountComponent(AssistantMessage, { turn: doneTurn(), canSave: true })
|
||||
const cards = root.querySelectorAll<HTMLElement>('article')
|
||||
|
||||
expect(cards[0]?.style.getPropertyValue('--card-order')).toBe('0')
|
||||
expect(cards[1]?.style.getPropertyValue('--card-order')).toBe('1')
|
||||
})
|
||||
|
||||
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 focusable row', () => {
|
||||
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?.tabIndex).toBe(0)
|
||||
expect(buttons).toHaveLength(suggestions.length)
|
||||
expect([...buttons].map((button) => button.textContent?.trim())).toEqual(suggestions)
|
||||
|
||||
buttons[0]?.click()
|
||||
expect(onPick).toHaveBeenCalledWith('Focus')
|
||||
})
|
||||
|
||||
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.')
|
||||
})
|
||||
})
|
||||
|
|
@ -13,14 +13,16 @@ vi.mock('../src/lib/recommendationStream', async (importOriginal) => {
|
|||
const streamMock = vi.mocked(streamRecommendations)
|
||||
const unmountCallbacks: Array<() => void> = []
|
||||
|
||||
function mountChat() {
|
||||
function mountChat(
|
||||
createPlaylist: Parameters<typeof useChatStream>[0] = vi.fn(async () => ({ url: null })),
|
||||
) {
|
||||
let chat: ReturnType<typeof useChatStream> | undefined
|
||||
const root = document.createElement('div')
|
||||
document.body.append(root)
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
chat = useChatStream(vi.fn(async () => ({ url: null })))
|
||||
chat = useChatStream(createPlaylist)
|
||||
return () => h('div')
|
||||
},
|
||||
}),
|
||||
|
|
@ -106,6 +108,22 @@ describe('useChatStream', () => {
|
|||
expect(turn?.transportFailure).toBeNull()
|
||||
})
|
||||
|
||||
it('stores a metadata-free terminal error as a normal stream error', async () => {
|
||||
streamMock.mockImplementation(async (_request, _signal, onEvent) => {
|
||||
onEvent({ type: 'error', code: 'first_stage_failed', message: 'Planning failed.' })
|
||||
})
|
||||
const chat = mountChat()
|
||||
|
||||
await chat.send('error')
|
||||
|
||||
expect(assistantTurns(chat)[0]).toMatchObject({
|
||||
status: 'error',
|
||||
requestId: null,
|
||||
error: { code: 'first_stage_failed', message: 'Planning failed.' },
|
||||
transportFailure: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('stores an incomplete stream as a transport failure', async () => {
|
||||
streamMock.mockRejectedValue(
|
||||
new StreamTransportError('unexpected_eof', 'The stream ended early.'),
|
||||
|
|
@ -239,4 +257,26 @@ describe('useChatStream', () => {
|
|||
|
||||
expect(receivedQuery).toHaveLength(1000)
|
||||
})
|
||||
|
||||
it('uses the bare query as the capped playlist name', async () => {
|
||||
const createPlaylist = vi.fn(async () => ({ url: null }))
|
||||
streamMock.mockImplementation(async (_request, _signal, onEvent) => {
|
||||
onEvent(metadata('playlist'))
|
||||
onEvent(track(1))
|
||||
onEvent({ type: 'done', track_count: 1, total_ms: 5 })
|
||||
})
|
||||
const chat = mountChat(createPlaylist)
|
||||
const query = `Late-night instrumental focus ${'x'.repeat(100)}`
|
||||
|
||||
await chat.send(query)
|
||||
const turn = assistantTurns(chat)[0]
|
||||
if (!turn) throw new Error('Assistant turn was not created.')
|
||||
await chat.savePlaylist(turn.id)
|
||||
|
||||
expect(createPlaylist).toHaveBeenCalledWith({
|
||||
schema_version: 1,
|
||||
name: query.slice(0, 100),
|
||||
track_uris: ['spotify:track:1'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue