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
|
|
@ -273,3 +273,48 @@ Waarom:
|
|||
- Machine-geconverteerde UI-code krijgt dezelfde behandeling als de
|
||||
backend: een review-pass met concrete findings en een test-suite die de
|
||||
contractkritische randen vastzet, voordat het richting main gaat.
|
||||
|
||||
## Dag 3 - avond
|
||||
### Live eval run
|
||||
|
||||
Wat ik deed:
|
||||
|
||||
- Alle 8 scenario's in 1 run live gedraaid, plus de baseline arm (bare
|
||||
search) voor de comparison table.
|
||||
- Het Spotify-dagquotum was eerder al een keer vol; daarom vooraf
|
||||
maatregelen genomen: scenario's sequentieel, geen retries, de server-boot
|
||||
zelf als quota-check, en live meegekeken op 429's (niet gehit).
|
||||
- Uitkomst: 4 scenario's pass, 3 vallen alleen op een te strenge
|
||||
synonym-check in de eval zelf (gefixt), en discover-new faalt echt:
|
||||
call 1 verzint bij familiarity=new titels die niet bestaan, dus er
|
||||
overleeft niets de grounding. Fix volgt in de intent prompt.
|
||||
- De refinement turn deed live 0 Spotify calls: pool reuse werkt.
|
||||
|
||||
Waarom:
|
||||
|
||||
- De comparison table in de README wil ik op echte metingen baseren, en het
|
||||
quotum maakt herhalen duur.
|
||||
|
||||
Wat ik heb laten vallen of uitgesteld:
|
||||
|
||||
- discover-new opnieuw opnemen wacht op de prompt fix.
|
||||
|
||||
### Frontend: presentatie
|
||||
|
||||
Wat ik deed:
|
||||
|
||||
- Cards komen rustig gestaggerd binnen (met reduced-motion pad), results
|
||||
en chips scrollen in eigen panelen zodat de summary zichtbaar blijft,
|
||||
klik op het logo start een nieuwe chat, fonts self-hosted.
|
||||
- Een error die voor de metadata binnenkomt is nu een echte foutmelding
|
||||
in de chat, geen transport failure. Playlist-namen hadden een dubbele
|
||||
prefix; de frontend stuurt nu de kale query. 10 nieuwe tests.
|
||||
|
||||
Waarom:
|
||||
|
||||
- Dit is wat de reviewer als eerste ziet; rustige presentatie en eerlijke
|
||||
foutmeldingen gaan voor extra features.
|
||||
|
||||
Wat ik heb laten vallen of uitgesteld:
|
||||
|
||||
- Verdere styling; de tijd gaat naar eval, demo mode en de README.
|
||||
|
|
|
|||
27
frontend/package-lock.json
generated
27
frontend/package-lock.json
generated
|
|
@ -8,6 +8,9 @@
|
|||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@fontsource/bricolage-grotesque": "^5.3.0",
|
||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||
"@fontsource/public-sans": "^5.3.0",
|
||||
"vue": "^3.5.40"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -156,6 +159,30 @@
|
|||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/bricolage-grotesque": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/bricolage-grotesque/-/bricolage-grotesque-5.3.0.tgz",
|
||||
"integrity": "sha512-MdOVb/5in11IfN/IQJOExMuu6AroCIQbirl0yX7NUvKk+h0HYIKfGaVAXRS/gCPil4aFxWhjZnOw28tUwv7tFw==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/jetbrains-mono": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz",
|
||||
"integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/public-sans": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/public-sans/-/public-sans-5.3.0.tgz",
|
||||
"integrity": "sha512-kjODI0S3zdv0mBYCIQ8TbBayaiqszpc2UbhJiO3bjIqVVXzcWfHSt2o3WBCLOY3juaGaQoy4MoWCcgmfI5hCuA==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@
|
|||
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/bricolage-grotesque": "^5.3.0",
|
||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||
"@fontsource/public-sans": "^5.3.0",
|
||||
"vue": "^3.5.40"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const props = defineProps<{
|
|||
mode: AppMode | null
|
||||
healthFailed: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ logout: []; toggleDev: [opener: HTMLElement] }>()
|
||||
const emit = defineEmits<{ logout: []; reset: []; toggleDev: [opener: HTMLElement] }>()
|
||||
|
||||
function openDevPanel(event: MouseEvent): void {
|
||||
if (event.currentTarget instanceof HTMLElement) {
|
||||
|
|
@ -29,11 +29,18 @@ const modeLabel = computed(() => {
|
|||
<header class="header">
|
||||
<div class="inner">
|
||||
<div class="brand">
|
||||
<span class="wordmark"
|
||||
>{{ messages.appHeaderBrandPrefix
|
||||
}}<span class="accent">{{ messages.appHeaderBrandAccent }}</span
|
||||
>{{ messages.appHeaderBrandSuffix }}</span
|
||||
<button
|
||||
class="brand-control"
|
||||
type="button"
|
||||
:aria-label="messages.appHeaderBrandResetLabel"
|
||||
@click="$emit('reset')"
|
||||
>
|
||||
<span class="wordmark"
|
||||
>{{ messages.appHeaderBrandPrefix
|
||||
}}<span class="accent">{{ messages.appHeaderBrandAccent }}</span
|
||||
>{{ messages.appHeaderBrandSuffix }}</span
|
||||
>
|
||||
</button>
|
||||
<span class="kicker">{{ messages.appHeaderKicker }}</span>
|
||||
</div>
|
||||
<div class="controls">
|
||||
|
|
@ -78,6 +85,20 @@ const modeLabel = computed(() => {
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-control {
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
|
||||
.brand-control:focus-visible {
|
||||
outline: 2px solid var(--c-accent);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
font-family: var(--f-display);
|
||||
font-size: var(--t-brand);
|
||||
|
|
|
|||
|
|
@ -42,8 +42,6 @@ const isEmptyResult = computed(
|
|||
<div class="column">
|
||||
<p v-if="turn.intentSummary" class="intent">{{ turn.intentSummary }}</p>
|
||||
|
||||
<ResultSet v-if="turn.tracks.length" :tracks="turn.tracks" />
|
||||
|
||||
<ThinkingIndicator v-if="turn.status === 'streaming'" :label="thinkingLabel" />
|
||||
|
||||
<StreamWarning
|
||||
|
|
@ -85,6 +83,8 @@ const isEmptyResult = computed(
|
|||
:url="turn.playlist.url"
|
||||
:track-count="turn.tracks.length"
|
||||
/>
|
||||
|
||||
<ResultSet v-if="turn.tracks.length" :tracks="turn.tracks" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ onUnmounted(() => window.removeEventListener('keydown', onShortcut))
|
|||
:mode="mode"
|
||||
:health-failed="health.status === 'failed'"
|
||||
@logout="logout"
|
||||
@reset="reset"
|
||||
@toggle-dev="openDevPanel"
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -69,7 +69,13 @@ async function scrollToLatest(): Promise<void> {
|
|||
if (!pinned.value) return
|
||||
await nextTick()
|
||||
requestAnimationFrame(() => {
|
||||
if (list.value) list.value.scrollTop = list.value.scrollHeight
|
||||
const element = list.value
|
||||
if (!element) return
|
||||
if (latestAssistant.value?.status === 'done') {
|
||||
element.querySelector<HTMLElement>('.turn:last-child')?.scrollIntoView({ block: 'start' })
|
||||
return
|
||||
}
|
||||
element.scrollTop = element.scrollHeight
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,28 @@
|
|||
<script setup lang="ts">
|
||||
import { messages } from '../lib/messages'
|
||||
import { computed } from 'vue'
|
||||
import { formatMessage } from '../lib/messages'
|
||||
import type { TrackEvent } from '../lib/types'
|
||||
import TrackCard from './TrackCard.vue'
|
||||
|
||||
defineProps<{ tracks: TrackEvent[] }>()
|
||||
const props = defineProps<{ tracks: TrackEvent[] }>()
|
||||
const regionLabel = computed(() =>
|
||||
formatMessage(
|
||||
props.tracks.length === 1
|
||||
? 'resultSetRecommendedTrackLabel'
|
||||
: 'resultSetRecommendedTracksLabel',
|
||||
{ count: props.tracks.length },
|
||||
),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="set" :aria-label="messages.resultSetRecommendedTracksLabel">
|
||||
<TrackCard v-for="event in tracks" :key="event.track.id" :event="event" />
|
||||
<div class="set" role="region" :aria-label="regionLabel" tabindex="0">
|
||||
<TrackCard
|
||||
v-for="(event, index) in tracks"
|
||||
:key="event.track.id"
|
||||
:event="event"
|
||||
:style="`--card-order: ${index}`"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -17,5 +31,18 @@ defineProps<{ tracks: TrackEvent[] }>()
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
max-height: min(42dvh, 480px);
|
||||
padding: var(--s-2) var(--s-3) var(--s-2) 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior-y: contain;
|
||||
border-block: 1px solid var(--c-line);
|
||||
scrollbar-color: var(--c-line-strong) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.set {
|
||||
max-height: min(34dvh, 360px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,60 @@
|
|||
<script setup lang="ts">
|
||||
import { messages } from '../lib/messages'
|
||||
|
||||
defineProps<{ suggestions: readonly string[] }>()
|
||||
defineEmits<{ pick: [suggestion: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chips">
|
||||
<button
|
||||
v-for="suggestion in suggestions"
|
||||
:key="suggestion"
|
||||
class="chip"
|
||||
type="button"
|
||||
@click="$emit('pick', suggestion)"
|
||||
>
|
||||
{{ suggestion }}
|
||||
</button>
|
||||
<div class="frame">
|
||||
<div class="chips" role="region" :aria-label="messages.suggestionChipsRegionLabel" tabindex="0">
|
||||
<button
|
||||
v-for="suggestion in suggestions"
|
||||
:key="suggestion"
|
||||
class="chip"
|
||||
type="button"
|
||||
@click="$emit('pick', suggestion)"
|
||||
>
|
||||
{{ suggestion }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.frame {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: var(--c-surface-raised);
|
||||
border: 1px solid var(--c-line);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
|
||||
.frame::after {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: 1px;
|
||||
bottom: var(--s-3);
|
||||
width: var(--s-10);
|
||||
pointer-events: none;
|
||||
content: '';
|
||||
background: linear-gradient(to right, transparent, var(--c-surface-raised));
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-3) var(--s-10) var(--s-3) var(--s-3);
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
scrollbar-color: var(--c-line-strong) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.chips:focus-visible {
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
|
|
@ -31,7 +64,7 @@ defineEmits<{ pick: [suggestion: string] }>()
|
|||
font-size: var(--t-small);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
background: var(--c-surface-raised);
|
||||
background: var(--c-surface);
|
||||
border: 1px solid var(--c-line);
|
||||
border-radius: var(--r-pill);
|
||||
transition: all var(--dur-fast) ease;
|
||||
|
|
@ -42,18 +75,4 @@ defineEmits<{ pick: [suggestion: string] }>()
|
|||
border-color: var(--c-accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.chips {
|
||||
flex-wrap: nowrap;
|
||||
padding: 0 var(--gutter);
|
||||
margin: 0 calc(-1 * var(--gutter));
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.chips::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ const artworkAlt = computed(() =>
|
|||
border-color var(--dur-fast) ease,
|
||||
background var(--dur-fast) ease;
|
||||
animation: card-in var(--dur-card) var(--ease) both;
|
||||
animation-delay: calc(var(--card-order, 0) * var(--dur-card-stagger));
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
PRIOR_RECOMMENDATIONS_MAX_TRACKS,
|
||||
QUERY_MAX_LENGTH,
|
||||
} from '../lib/constants'
|
||||
import { formatMessage, messages } from '../lib/messages'
|
||||
import { messages } from '../lib/messages'
|
||||
import type { AssistantTurn, ChatTurn, EventLogEntry, TransportFailure } from '../lib/models'
|
||||
import { EMPTY_PLAYLIST_STATE } from '../lib/models'
|
||||
import {
|
||||
|
|
@ -101,9 +101,7 @@ function reduceEvent(turn: AssistantTurn, event: StreamEvent): AssistantTurn {
|
|||
}
|
||||
|
||||
function playlistName(query: string): string {
|
||||
return formatMessage('useChatStreamPlaylistName', { query })
|
||||
.slice(0, PLAYLIST_NAME_MAX_LENGTH)
|
||||
.trim()
|
||||
return query.slice(0, PLAYLIST_NAME_MAX_LENGTH).trim()
|
||||
}
|
||||
|
||||
function createTurnId(): string {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
export const messages = {
|
||||
appHeaderBrandPrefix: 'discovery',
|
||||
appHeaderBrandResetLabel: 'discovery-by-llm: start a new chat',
|
||||
appHeaderBrandAccent: '-by-',
|
||||
appHeaderBrandSuffix: 'llm',
|
||||
appHeaderKicker: 'proof of concept',
|
||||
|
|
@ -90,10 +91,13 @@ export const messages = {
|
|||
resultActionsConnectSpotify: 'Connect Spotify to save',
|
||||
resultActionsSummary: '{count} verified tracks ready for a private playlist',
|
||||
|
||||
resultSetRecommendedTracksLabel: 'Recommended tracks',
|
||||
resultSetRecommendedTrackLabel: '{count} recommended track',
|
||||
resultSetRecommendedTracksLabel: '{count} recommended tracks',
|
||||
|
||||
streamErrorRetry: 'Edit and retry',
|
||||
|
||||
suggestionChipsRegionLabel: 'Listening suggestions',
|
||||
|
||||
trackCardArtworkAlt: '{album} album artwork',
|
||||
trackCardOpenSpotify: 'Open in Spotify',
|
||||
|
||||
|
|
@ -110,7 +114,6 @@ export const messages = {
|
|||
useApiPlaylistCreationFailed: 'Playlist creation returned {status}.',
|
||||
|
||||
useChatStreamTrackCountMismatch: 'The final track count did not match the streamed results.',
|
||||
useChatStreamPlaylistName: '[discovery-by-llm] {query}',
|
||||
useChatStreamRequestReplaced: 'This request was replaced by a newer request.',
|
||||
useChatStreamUnexpectedFailure: 'The recommendation request failed unexpectedly.',
|
||||
useChatStreamTransportEvent: 'transport',
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ function parseLine(line: string): StreamEvent {
|
|||
function advancePhase(phase: StreamPhase, event: StreamEvent): StreamPhase {
|
||||
if (phase === 'metadata') {
|
||||
if (event.type === 'metadata') return 'events'
|
||||
if (event.type === 'error') return 'terminal'
|
||||
throw new StreamTransportError('protocol', messages.recommendationStreamMissingMetadata)
|
||||
}
|
||||
if (phase === 'events') {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { createApp } from 'vue'
|
||||
import '@fontsource/bricolage-grotesque/600.css'
|
||||
import '@fontsource/jetbrains-mono/400.css'
|
||||
import '@fontsource/public-sans/400.css'
|
||||
import '@fontsource/public-sans/500.css'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
|
|
|
|||
|
|
@ -74,6 +74,11 @@
|
|||
|
||||
--shadow-drawer: -24px 0 60px rgba(0, 0, 0, 0.45);
|
||||
--ease: cubic-bezier(0.2, 0.8, 0.3, 1);
|
||||
--dur-fast: 0.16s;
|
||||
--dur-card: 0.4s;
|
||||
--dur-card-stagger: 0.08s;
|
||||
--dur-reduced-motion: 0.001s;
|
||||
|
||||
color-scheme: dark;
|
||||
color: var(--c-text);
|
||||
background: var(--c-bg);
|
||||
|
|
@ -151,7 +156,9 @@ a:hover {
|
|||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
animation-delay: var(--dur-reduced-motion) !important;
|
||||
animation-duration: var(--dur-reduced-motion) !important;
|
||||
transition-delay: var(--dur-reduced-motion) !important;
|
||||
transition-duration: var(--dur-reduced-motion) !important;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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