Files
dsh-context/tests/client/components/statsTiming.spec.ts
T

192 lines
11 KiB
TypeScript

// StatsTiming (src/client/components/statsTiming.tsx) rendered with real
// React: the active-time donut (TTFT + generation vs tools vs overhead) and
// the pct-led slice rows with call counts and true durations — plus the empty
// and hostile-timing degrades.
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { makeDonut } from '../../../src/client/components/donut'
import { makeStatsTiming } from '../../../src/client/components/statsTiming'
import type { TimingTotals } from '../../../src/shared/types'
import { makeKit, mount, query, queryAll, text, hover } from '../helpers/kit'
const kit = makeKit()
const kitZh = makeKit('zh')
const StatsTiming = makeStatsTiming(kit, makeDonut(kit))
const StatsTimingZh = makeStatsTiming(kitZh, makeDonut(kitZh))
const TIMING: TimingTotals = {
wallMs: 600_000, ttftMs: 100_000, genMs: 140_000, calls: 10, toolsMs: 300_000, toolCalls: 25,
tools: { bash: { calls: 15, ms: 200_000 }, read: { calls: 8, ms: 80_000 } },
}
function rowOf(container: HTMLElement, i: number): { pct: string; label: string; count: string; dim: boolean } {
const row = queryAll(container, '.lc-sl-row')[i]
return {
pct: row.querySelector('.lc-sl-pct')?.textContent ?? '',
label: row.querySelector('.lc-sl-label')?.textContent ?? '',
count: row.querySelector('.lc-sl-sub')?.textContent ?? '',
dim: row.className.includes('lc-sl-row-dim'),
}
}
describe('StatsTiming', () => {
test('absent and all-zero timings render the empty state', async () => {
for (const timing of [null, { wallMs: 0, ttftMs: 0, genMs: 0, calls: 0, toolsMs: 0, toolCalls: 0, tools: {} }] as const) {
const m = await mount(h(StatsTiming, { timing }))
assert.ok(text(m.container).includes('No timing data yet'))
assert.equal(queryAll(m.container, '.lc-sl-row').length, 0)
assert.equal(queryAll(m.container, '.lc-donut').length, 0)
await m.unmount()
}
})
test('the donut center shows the wall total; rows lead with shares', async () => {
const m = await mount(h(StatsTiming, { timing: TIMING }))
assert.ok(text(m.container).includes('Timing Stats'))
assert.equal(query(m.container, '.lc-donut-center b').textContent, '10m0s')
assert.equal(query(m.container, '.lc-donut-center span').textContent, 'Active Time')
assert.deepEqual(rowOf(m.container, 0), { pct: '16.7%', label: 'TTFT', count: '1m40s · 10 calls', dim: false })
assert.deepEqual(rowOf(m.container, 1), { pct: '23.3%', label: 'LLM Gen', count: '2m20s', dim: false })
// The tools row keeps the true 5m sum even though the ring clamps it.
assert.deepEqual(rowOf(m.container, 2), { pct: '50.0%', label: 'Tool runs', count: '5m0s · 25 runs', dim: false })
assert.deepEqual(rowOf(m.container, 3), { pct: '10.0%', label: 'Overhead', count: '1m0s', dim: false })
// Four ring segments painted (ttft + gen + tools + overhead); four rows.
assert.equal(queryAll(m.container, '.lc-donut circle').length, 4)
assert.equal(queryAll(m.container, '.lc-sl-row').length, 4)
await m.unmount()
})
test('labels localize while durations stay locale-free', async () => {
const m = await mount(h(StatsTimingZh, { timing: TIMING }))
assert.equal(query(m.container, '.lc-donut-center b').textContent, '10m0s')
assert.deepEqual(rowOf(m.container, 0), { pct: '16.7%', label: '模型等待', count: '1m40s · 10次', dim: false })
assert.deepEqual(rowOf(m.container, 1), { pct: '23.3%', label: '模型生成', count: '2m20s', dim: false })
await m.unmount()
})
test('parallel tool overlap: the count keeps the true sum, the share caps at 100%', async () => {
// 9 parallel 22s calls inside a 100s wall: tools sum 200s > wall.
const timing: TimingTotals = { wallMs: 100_000, ttftMs: 25_000, genMs: 35_000, calls: 2, toolsMs: 200_000, toolCalls: 9, tools: { bash: { calls: 9, ms: 200_000 } } }
const m = await mount(h(StatsTiming, { timing }))
assert.deepEqual(rowOf(m.container, 2), { pct: '100.0%', label: 'Tool runs', count: '3m20s · 9 runs', dim: false })
// The ring clamps tools into the 40s post-model window; the zero overhead
// slice paints no arc AND its legend row is omitted: 3 circles, 3 rows.
assert.equal(queryAll(m.container, '.lc-donut circle').length, 3)
assert.equal(queryAll(m.container, '.lc-sl-row').length, 3)
// The hover link: a painted row dims the ring and lights its own arc.
const rows = queryAll(m.container, '.lc-sl-row')
await hover(rows[2])
assert.ok(rows[2].className.includes('lc-sl-row-on'))
assert.ok(query(m.container, '.lc-donut').className.includes('lc-donut-dim'))
assert.ok((queryAll(m.container, '.lc-donut-seg')[2]?.getAttribute('class') ?? '').includes('lc-donut-seg-on'))
await m.unmount()
})
test('a hostile no-wall timing keeps only the non-zero slices', async () => {
const timing: TimingTotals = { wallMs: 0, ttftMs: 5_000, genMs: 0, calls: 3, toolsMs: 0, toolCalls: 1, tools: { bash: { calls: 1, ms: 0 } } }
const m = await mount(h(StatsTiming, { timing }))
assert.equal(query(m.container, '.lc-donut-center b').textContent, '—')
// Only TTFT carries time; the zero slices are omitted whole (their dash
// has nothing to qualify, and their borrowed call count would mislead).
assert.equal(queryAll(m.container, '.lc-sl-row').length, 1)
assert.deepEqual(rowOf(m.container, 0), { pct: '—', label: 'TTFT', count: '5.0s · 3 calls', dim: false })
await m.unmount()
})
test('a slice that never happened is omitted, not rendered as a zero row', async () => {
// The screenshot case: no reasoning output, no tool calls — the rows that
// would borrow the session's call count ("0.0% · 3 calls") are dropped.
const timing: TimingTotals = {
wallMs: 532, ttftMs: 480, genMs: 52, reasoningMs: 0, textMs: 52, toolArgMs: 0,
calls: 1, toolsMs: 0, toolCalls: 0, tools: {},
}
const m = await mount(h(StatsTiming, { timing }))
const labels = queryAll(m.container, '.lc-sl-label').map(n => n.textContent)
assert.deepEqual(labels, ['TTFT', 'Answer'], 'zero buckets and zero tools are gone')
assert.equal(queryAll(m.container, '.lc-sl-row').length, queryAll(m.container, '.lc-donut-seg').length)
await m.unmount()
})
test('every slice zero with a live call count still renders the empty state', async () => {
// calls > 0 opens the card, but nothing was priced: no rows survive.
const timing: TimingTotals = { wallMs: 0, ttftMs: 0, genMs: 0, calls: 2, toolsMs: 0, toolCalls: 0, tools: {} }
const m = await mount(h(StatsTiming, { timing }))
assert.ok(text(m.container).includes('No timing data yet'))
assert.equal(queryAll(m.container, '.lc-sl-row').length, 0)
await m.unmount()
})
})
describe('StatsTiming — the generation split', () => {
// genMs 140s splits into thinking 90s, answer 30s, tool args 20s.
const SPLIT: TimingTotals = {
wallMs: 600_000, ttftMs: 100_000, genMs: 140_000, reasoningMs: 90_000, textMs: 30_000, toolArgMs: 20_000,
calls: 10, toolsMs: 300_000, toolCalls: 25, tools: { bash: { calls: 15, ms: 200_000 } },
}
test('the model slice expands into thinking / answer / tool args', async () => {
const m = await mount(h(StatsTiming, { timing: SPLIT }))
assert.equal(queryAll(m.container, '.lc-sl-row').length, 6)
assert.deepEqual(rowOf(m.container, 0), { pct: '16.7%', label: 'TTFT', count: '1m40s · 10 calls', dim: false })
assert.deepEqual(rowOf(m.container, 1), { pct: '15.0%', label: 'Thinking', count: '1m30s', dim: false })
assert.deepEqual(rowOf(m.container, 2), { pct: '5.0%', label: 'Answer', count: '30.0s', dim: false })
assert.deepEqual(rowOf(m.container, 3), { pct: '3.3%', label: 'Tool args', count: '20.0s', dim: false })
assert.deepEqual(rowOf(m.container, 4), { pct: '50.0%', label: 'Tool runs', count: '5m0s · 25 runs', dim: false })
assert.deepEqual(rowOf(m.container, 5), { pct: '10.0%', label: 'Overhead', count: '1m0s', dim: false })
// Six ring segments: the three decode buckets replace the single gen arc.
assert.equal(queryAll(m.container, '.lc-donut circle').length, 6)
await m.unmount()
})
test('the decode slices carry NO call count — they count blocks, not calls', async () => {
// One model call can emit several tool-call blocks and often emits no
// reasoning at all, so a borrowed per-call count would be a false tally.
const timing: TimingTotals = { ...SPLIT, calls: 236, toolCalls: 302 }
const m = await mount(h(StatsTiming, { timing }))
const sub = (i: number): string => queryAll(m.container, '.lc-sl-sub')[i]?.textContent ?? ''
assert.equal(sub(0), '1m40s · 236 calls', 'TTFT keeps its per-call count')
for (const i of [1, 2, 3]) {
assert.ok(!sub(i).includes('236 calls'), `decode row ${i} must not borrow the call count: ${sub(i)}`)
assert.ok(!sub(i).includes('302'), `decode row ${i} must not borrow the tool count: ${sub(i)}`)
}
assert.equal(sub(4), '5m0s · 302 runs', 'tools keep their own per-run count')
await m.unmount()
})
test('the split labels localize', async () => {
const m = await mount(h(StatsTimingZh, { timing: SPLIT }))
assert.equal(rowOf(m.container, 1).label, '模型思考')
assert.equal(rowOf(m.container, 2).label, '模型输出')
assert.equal(rowOf(m.container, 3).label, '工具参数')
await m.unmount()
})
test('an absent split keeps the single LLM Gen row', async () => {
const m = await mount(h(StatsTiming, { timing: TIMING }))
assert.equal(queryAll(m.container, '.lc-sl-row').length, 4)
assert.equal(rowOf(m.container, 1).label, 'LLM Gen')
await m.unmount()
})
test('all-zero buckets keep the un-split shape instead of three dead rows', async () => {
const m = await mount(h(StatsTiming, { timing: { ...TIMING, reasoningMs: 0, textMs: 0, toolArgMs: 0 } }))
assert.equal(queryAll(m.container, '.lc-sl-row').length, 4)
assert.equal(rowOf(m.container, 1).label, 'LLM Gen')
await m.unmount()
})
test('a hostile bucket total exceeding the generation window clamps the ring, rows stay true', async () => {
// The buckets claim 500s inside a 140s generation window: the ring caps
// them to the window while every row keeps its own figure.
const timing: TimingTotals = { ...SPLIT, reasoningMs: 400_000, textMs: 100_000, toolArgMs: 0 }
const m = await mount(h(StatsTiming, { timing }))
assert.deepEqual(rowOf(m.container, 1), { pct: '66.7%', label: 'Thinking', count: '6m40s', dim: false })
assert.deepEqual(rowOf(m.container, 2), { pct: '16.7%', label: 'Answer', count: '1m40s', dim: false })
// The zero tool-args bucket is omitted whole, so Tools follows Answer.
assert.equal(rowOf(m.container, 3).label, 'Tool runs')
await m.unmount()
})
})