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): 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( '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( '[role="region"][aria-label="2 recommended tracks"]', ) const summary = root.querySelector('.intent') const warning = root.querySelector('[data-warning-code="limited_pool"]') const action = root.querySelector('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('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([ { 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('[aria-label="Listening suggestions"]') const buttons = root.querySelectorAll('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 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('[role="alert"]') expect(alert?.dataset.errorCode).toBe('first_stage_failed') expect(alert?.textContent).toContain('Planning failed.') }) })