diff --git a/static/style.css b/static/style.css index 5260161..8af747a 100644 --- a/static/style.css +++ b/static/style.css @@ -4407,7 +4407,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami .conv-pipe-total .conv-pipe-val { color: var(--accent); font-size: 15px; } /* Turn history */ -.conv-turn-history { display: flex; flex-direction: column; gap: 5px; overflow-y: auto; max-height: 220px; } +.conv-turn-history { display: flex; flex-direction: column; gap: 5px; overflow-y: auto; max-height: 220px; flex-shrink: 0; /* else the flex column panel squeezes it to 0 and hides the rows */ } .conv-history-empty { font-size: 12px; color: var(--subtext); font-style: italic; } .conv-hist-item { display: flex; align-items: center; gap: 6px; font-size: 12px; padding: 4px 6px; border-radius: 5px; background: var(--panel); } .conv-hist-num { font-weight: 700; color: var(--subtext); min-width: 20px; } diff --git a/tests/conversation-metrics.spec.js b/tests/conversation-metrics.spec.js index eaf6c59..ef14987 100644 --- a/tests/conversation-metrics.spec.js +++ b/tests/conversation-metrics.spec.js @@ -4,16 +4,17 @@ const { test, expect } = require('./_guard'); const sse = (/** @type {any[]} */ evts) => evts.map(e => `data: ${JSON.stringify(e)}\n\n`).join(''); -async function boot(page, { usage = true, lang = '' } = {}) { +async function boot(page, { usage = true, lang = '', errorTurns = [], statsAfterDone = false } = {}) { let n = 0; await page.route('**/api/**', route => route.request().method() === 'GET' ? route.fulfill({ json: {} }) : route.fulfill({ json: { ok: true } })); await page.route('**/api/conversation/turn', route => { n++; + if (errorTurns.includes(n)) return route.fulfill({ contentType: 'text/event-stream', body: sse([{ type: 'error', stage: 'llm', message: 'boom' }]) }); const stats = { type: 'stats', stt_ms: 100 + n, llm_ttft_ms: 200 + n * 10, llm_total_ms: 900, tts_ms: 300 + n, total_ms: 1500, usage: usage ? { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } : null }; return route.fulfill({ contentType: 'text/event-stream', body: sse([ { type: 'transcript', text: 'hi', stt_ms: null }, { type: 'token', delta: 'Hello.' }, - { type: 'llm_done', text: 'Hello.', llm_ttft_ms: 1, llm_total_ms: 2 }, stats, { type: 'done' }]) }); + { type: 'llm_done', text: 'Hello.', llm_ttft_ms: 1, llm_total_ms: 2 }, ...(statsAfterDone ? [{ type: 'done' }, stats] : [stats, { type: 'done' }])]) }); }); await page.route('**/static/dist/main.min.js*', r => r.abort()); if (lang) await page.addInitScript(l => { try { localStorage.setItem('app-lang', l); } catch (e) {} }, lang); @@ -27,6 +28,7 @@ async function turn(page, i) { await page.locator('#conv-text-input').fill('hello ' + i); await page.locator('#conv-text-input').press('Enter'); await expect(page.locator('#conv-turn-history .conv-hist-item')).toHaveCount(i); + await expect(page.locator('#conv-turn-history .conv-hist-item').first().locator('.conv-hist-num')).toHaveText('#' + i); await expect(page.locator('#conv-send-btn')).toBeEnabled(); } @@ -75,3 +77,41 @@ test('empty state and German labels', async ({ page }) => { await expect(page.locator('#cm-tok-total')).toHaveText('k. A.'); await expect(page.locator('#conv-stats-panel .conv-stats-head', { hasText: 'Token-Verbrauch' })).toBeVisible(); }); + +test('turn history rows are visible with #n and a formatted time (normal and error turns)', async ({ page }) => { + await boot(page, { errorTurns: [3] }); + for (let i = 1; i <= 3; i++) await turn(page, i); + const rows = page.locator('#conv-turn-history .conv-hist-item'); + for (let i = 0; i < 3; i++) { + const row = rows.nth(i); // newest first + await expect(row).toBeVisible(); // not squeezed to 0 height by the flex panel + await expect(row.locator('.conv-hist-num')).toHaveText('#' + (3 - i)); + await expect(row.locator('.conv-hist-time')).toHaveText(/^\d+(\.\d+)?(ms|s)$/); + } + await expect(rows.first().locator('.conv-hist-err')).toHaveCount(2); // error row: icon + time + await expect(rows.nth(1).locator('.conv-hist-ok')).toHaveCount(2); + await expect(rows.nth(1).locator('.conv-hist-time')).toHaveText('1.50s'); +}); + +test('stats arriving after done still yield a history row with a time', async ({ page }) => { + await boot(page, { statsAfterDone: true }); + await turn(page, 1); + await expect(page.locator('#conv-turn-history .conv-hist-time').first()).toHaveText(/^\d+(\.\d+)?(ms|s)$/); +}); + +test('chart text is legible in the narrow column, single point is drawn', async ({ page }) => { + await boot(page); + await turn(page, 1); + const chart = page.locator('.conv-chart[data-metric="llm"]'); + await expect(chart.locator('circle')).toHaveCount(1); + const dot = await chart.locator('circle').boundingBox(); + const box = await chart.locator('svg').boundingBox(); + expect(dot && box && dot.width >= 6 && dot.x > box.x + 40 && dot.x < box.x + box.width).toBeTruthy(); + await expect(chart.locator('svg')).toContainText('0'); + const sizes = await chart.locator('svg text').evaluateAll(els => els.map(e => { + const r = e.getBoundingClientRect(); return { fs: parseFloat(getComputedStyle(e).fontSize), scale: r.height / (e.getBBox().height || 1) }; + })); + const vb = await chart.locator('svg').evaluate(e => ({ vw: e.viewBox.baseVal.width, cw: e.getBoundingClientRect().width })); + for (const s of sizes) expect(s.fs * (vb.cw / vb.vw)).toBeGreaterThanOrEqual(10); + expect(Math.abs(vb.vw - vb.cw)).toBeLessThan(2); +});