130 lines
5.7 KiB
TypeScript
130 lines
5.7 KiB
TypeScript
// StatsContext (src/client/components/statsContext.tsx) rendered with real
|
|
// React: the six-cell grid — session shape with the whole-session human-input
|
|
// tally, the chat-line cache-hit cell, and the priced cost cell with its rate
|
|
// tooltip — in both locales. The context-event tallies live on the events
|
|
// card's kind filters (contextView.spec.ts); `countsOfRecords` still derives
|
|
// every count the split generation's wire head carries, pinned here.
|
|
|
|
import { createElement as h } from 'react'
|
|
import assert from 'node:assert/strict'
|
|
import { describe, test } from 'vitest'
|
|
import { countsOfRecords, makeStatsContext } from '../../../src/client/components/statsContext'
|
|
import type { ContextEventRecord, RequestRecord, SessionCostUsage, TokenUsage } from '../../../src/shared/types'
|
|
import { makeKit, mount, queryAll, text } from '../helpers/kit'
|
|
|
|
const kit = makeKit()
|
|
const kitZh = makeKit('zh')
|
|
const StatsContext = makeStatsContext(kit)
|
|
const StatsContextZh = makeStatsContext(kitZh)
|
|
|
|
const COST: SessionCostUsage = { flash: { peak: { uncached: 1_000_000, cacheRead: 0, cacheWrite: 0, output: 0 } } }
|
|
// Prompt-side billed input 300 (100 uncached + 200 read) → hit 66.6% truncated.
|
|
const USAGE: TokenUsage = { uncachedInputTokens: 100, outputTokens: 50, cacheReadTokens: 200, cacheWriteTokens: 0 }
|
|
|
|
function req(turn?: number): RequestRecord {
|
|
return {
|
|
time: 0, seq: 0, system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 0,
|
|
...(turn !== undefined ? { turn } : {}),
|
|
}
|
|
}
|
|
|
|
function ev(kind: ContextEventRecord['kind']): ContextEventRecord {
|
|
return { seq: 0, time: 0, kind }
|
|
}
|
|
|
|
function cells(container: HTMLElement): { labels: string[]; values: string[] } {
|
|
const grid = queryAll(container, '.lc-stat')
|
|
return {
|
|
labels: grid.map(el => el.querySelector('.lc-stat-label')?.textContent ?? ''),
|
|
values: grid.map(el => el.querySelector('.lc-stat-value')?.textContent ?? ''),
|
|
}
|
|
}
|
|
|
|
describe('countsOfRecords (the inline generation derivation)', () => {
|
|
test('tallies distinct turns, records, and the three priced event kinds', () => {
|
|
// Two steps in turn 1, one in turn 2, one without a turn (folds as turn 0).
|
|
const counts = countsOfRecords(
|
|
[req(1), req(1), req(2), req()],
|
|
[ev('inject'), ev('inject'), ev('inject'), ev('compaction'), ev('compaction'), ev('prune'), ev('model'), ev('mode')],
|
|
)
|
|
// model/mode events do not appear (only the three priced kinds do).
|
|
assert.deepEqual(counts, { turns: 3, steps: 4, injects: 3, compactions: 2, prunes: 1 })
|
|
})
|
|
|
|
test('empty collections tally zero', () => {
|
|
assert.deepEqual(countsOfRecords([], []), { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 })
|
|
})
|
|
})
|
|
|
|
describe('StatsContext', () => {
|
|
test('folds the six-cell grid: shape stats, the cache-hit cell, and cost', async () => {
|
|
const m = await mount(h(StatsContext, {
|
|
counts: { turns: 3, steps: 4, injects: 3, compactions: 2, prunes: 1 },
|
|
humanInputs: 7,
|
|
toolCalls: 3,
|
|
usage: USAGE,
|
|
cost: COST,
|
|
locale: 'en',
|
|
}))
|
|
assert.ok(text(m.container).includes('Context Stats'))
|
|
const { labels, values } = cells(m.container)
|
|
assert.equal(labels.length, 6)
|
|
assert.deepEqual(labels, ['Turns', 'Steps', 'Human Inputs?', 'Tool Calls', 'Cache Hit', 'Cost?'])
|
|
assert.deepEqual(values, ['3', '4', '7', '3', '66.6%', '$0.30'])
|
|
await m.unmount()
|
|
})
|
|
|
|
test('absent counters, usage, and cost degrade to zeros and the dash', async () => {
|
|
const m = await mount(h(StatsContext, {
|
|
counts: { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 },
|
|
usage: null,
|
|
locale: 'en',
|
|
}))
|
|
assert.deepEqual(cells(m.container).values, ['0', '0', '0', '0', '—', '—'])
|
|
await m.unmount()
|
|
// A usage report with nothing billed prompt-side dashes the hit too.
|
|
const zero: TokenUsage = { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
|
|
const m2 = await mount(h(StatsContext, {
|
|
counts: { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 },
|
|
usage: zero,
|
|
locale: 'en',
|
|
}))
|
|
assert.deepEqual(cells(m2.container).values, ['0', '0', '0', '0', '—', '—'])
|
|
await m2.unmount()
|
|
})
|
|
|
|
test('the human-inputs and cost cells are tipped; the cost bubble lists both families with peak/off rates', async () => {
|
|
const m = await mount(h(StatsContext, {
|
|
counts: { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 },
|
|
usage: null,
|
|
cost: COST,
|
|
locale: 'en',
|
|
}))
|
|
assert.equal(queryAll(m.container, '.lc-stat-tip').length, 2)
|
|
assert.equal(queryAll(m.container, '.lc-stat-q').length, 2)
|
|
const tips = queryAll(m.container, '.lc-stat-tip').map(el => text(el))
|
|
assert.ok(tips[0].includes('question answerings'), 'the human-inputs tip explains its tally')
|
|
const costTip = tips[1]
|
|
assert.ok(costTip.includes('Per-1M-token rates'))
|
|
assert.ok(costTip.includes('deepseek-flash / deepseek-v4-flash'))
|
|
assert.ok(costTip.includes('deepseek-v4-pro'))
|
|
assert.ok(costTip.includes('miss $0.3/$0.15'))
|
|
assert.ok(costTip.includes('output $1.2/$0.6'))
|
|
await m.unmount()
|
|
})
|
|
|
|
test('the zh locale localizes labels and prices the cost in CNY', async () => {
|
|
const m = await mount(h(StatsContextZh, {
|
|
counts: { turns: 1, steps: 1, injects: 0, compactions: 1, prunes: 0 },
|
|
usage: USAGE,
|
|
cost: COST,
|
|
locale: 'zh',
|
|
}))
|
|
assert.ok(text(m.container).includes('上下文统计'))
|
|
const { labels, values } = cells(m.container)
|
|
assert.deepEqual(labels, ['轮次', '步数', '用户输入?', '工具调用', '缓存命中', '预估费用?'])
|
|
assert.deepEqual(values, ['1', '1', '0', '0', '66.6%', '¥2.00'])
|
|
await m.unmount()
|
|
})
|
|
})
|