feat(client): link the stats legend rows to their donut slices on hover
Hovering a legend row or a ring segment now lights both: the shared hover key dims the other slices to the stacked bars' 0.35 idiom and tints the active row. A hover with no painted arc (a zero slice) tints the row but leaves the ring at rest. The legend names bolden, and the token buckets get clearer names and colors: Cached Input / 缓存输入, Uncached Input / 未缓存输入 (violet, no longer blue-next-to-blue with Output), Cache Hit, Cache Write.
This commit is contained in:
1 parent
426f07926b
commit
a658dc4fea
10 files changed
+177
-44
No files matched your search
@@ -26,6 +26,10 @@ export interface DonutProps {
|
||||
centerSub?: ReactNS.ReactNode
|
||||
/** Outer size in px (default 118). */
|
||||
size?: number
|
||||
/** The hovered slice key — the legend row ↔ segment hover link. */
|
||||
hoverKey?: string | null
|
||||
/** Hover relay; absent renders the ring inert. */
|
||||
onHoverKey?: (key: string | null) => void
|
||||
}
|
||||
|
||||
export function makeDonut(kit: ViewKit): (props: DonutProps) => ReactNS.ReactElement {
|
||||
@@ -50,14 +54,23 @@ export function makeDonut(kit: ViewKit): (props: DonutProps) => ReactNS.ReactEle
|
||||
acc += pct
|
||||
}
|
||||
}
|
||||
// The ring dims only for a hover that lands on a painted arc — a legend
|
||||
// row without a segment (a zero slice) leaves the ring at rest.
|
||||
const hovering = props.hoverKey !== null && props.hoverKey !== undefined
|
||||
&& arcs.some(a => a.key === props.hoverKey)
|
||||
return (
|
||||
<div className="lc-donut" style={{ width: size, height: size }}>
|
||||
<div
|
||||
className={'lc-donut' + (hovering ? ' lc-donut-dim' : '')}
|
||||
style={{ width: size, height: size }}
|
||||
onMouseLeave={() => { if (props.onHoverKey !== undefined) props.onHoverKey(null) }}
|
||||
>
|
||||
<svg viewBox="0 0 42 42" width={size} height={size} aria-hidden="true">
|
||||
{arcs.length === 0
|
||||
? <circle className="lc-donut-track" cx="21" cy="21" r="15.9155" fill="none" strokeWidth="4" />
|
||||
: arcs.map(a => (
|
||||
<circle
|
||||
key={a.key}
|
||||
className={'lc-donut-seg' + (props.hoverKey === a.key ? ' lc-donut-seg-on' : '')}
|
||||
cx="21"
|
||||
cy="21"
|
||||
r="15.9155"
|
||||
@@ -66,6 +79,7 @@ export function makeDonut(kit: ViewKit): (props: DonutProps) => ReactNS.ReactEle
|
||||
strokeWidth="4"
|
||||
strokeDasharray={`${a.pct} ${100 - a.pct}`}
|
||||
strokeDashoffset={a.offset}
|
||||
onMouseEnter={() => { if (props.onHoverKey !== undefined) props.onHoverKey(a.key) }}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
* per slice — the primary line pairs the color dot and the name with the
|
||||
* bold share closing a fixed right column, and the secondary line drops the
|
||||
* absolute quantity (plus a qualifier like the call count) under the name in
|
||||
* muted small print. No tracks or bars — the donut IS the proportion chart;
|
||||
* the rows are its legend. Preformatted strings in, dumb markup out.
|
||||
* muted small print. Hovering a row lights it and its donut segment together
|
||||
* (the shared hover key), and vice versa. No tracks or bars — the donut IS
|
||||
* the proportion chart; the rows are its legend. Preformatted strings in,
|
||||
* dumb markup out.
|
||||
*/
|
||||
|
||||
import type * as ReactNS from 'react'
|
||||
@@ -24,13 +26,29 @@ export interface SliceRow {
|
||||
dim?: boolean
|
||||
}
|
||||
|
||||
export function makeSliceList(kit: ViewKit): (props: { rows: SliceRow[] }) => ReactNS.ReactElement {
|
||||
export function makeSliceList(kit: ViewKit): (props: {
|
||||
rows: SliceRow[]
|
||||
/** The hovered slice key — the legend row ↔ segment hover link. */
|
||||
hoverKey?: string | null
|
||||
/** Hover relay; absent renders the rows inert. */
|
||||
onHoverKey?: (key: string | null) => void
|
||||
}) => ReactNS.ReactElement {
|
||||
void kit
|
||||
return function SliceList(props: { rows: SliceRow[] }): ReactNS.ReactElement {
|
||||
return function SliceList(props: {
|
||||
rows: SliceRow[]
|
||||
hoverKey?: string | null
|
||||
onHoverKey?: (key: string | null) => void
|
||||
}): ReactNS.ReactElement {
|
||||
return (
|
||||
<div className="lc-sl">
|
||||
<div className="lc-sl" onMouseLeave={() => { if (props.onHoverKey !== undefined) props.onHoverKey(null) }}>
|
||||
{props.rows.map(r => (
|
||||
<div key={r.key} className={'lc-sl-row' + (r.dim ? ' lc-sl-row-dim' : '')}>
|
||||
<div
|
||||
key={r.key}
|
||||
className={'lc-sl-row'
|
||||
+ (r.dim ? ' lc-sl-row-dim' : '')
|
||||
+ (props.hoverKey !== undefined && props.hoverKey === r.key ? ' lc-sl-row-on' : '')}
|
||||
onMouseEnter={() => { if (props.onHoverKey !== undefined) props.onHoverKey(r.key) }}
|
||||
>
|
||||
<div className="lc-sl-main">
|
||||
<i className="lc-sl-dot" style={{ background: r.color }} />
|
||||
<span className="lc-sl-label" title={r.label}>{r.label}</span>
|
||||
|
||||
@@ -17,14 +17,9 @@ import { React } from '../react'
|
||||
|
||||
import { makeSliceList } from './sliceList'
|
||||
import type { SliceRow } from './sliceList'
|
||||
import type { DonutSegment } from './donut'
|
||||
import type { DonutProps, DonutSegment } from './donut'
|
||||
|
||||
export function makeStatsTiming(kit: ViewKit, Donut: (props: {
|
||||
segments: DonutSegment[]
|
||||
centerTop: ReactNS.ReactNode
|
||||
centerSub?: ReactNS.ReactNode
|
||||
size?: number
|
||||
}) => ReactNS.ReactElement): (props: {
|
||||
export function makeStatsTiming(kit: ViewKit, Donut: (props: DonutProps) => ReactNS.ReactElement): (props: {
|
||||
timing: TimingTotals | null
|
||||
locale: string
|
||||
}) => ReactNS.ReactElement {
|
||||
@@ -32,6 +27,8 @@ export function makeStatsTiming(kit: ViewKit, Donut: (props: {
|
||||
const SliceList = makeSliceList(kit)
|
||||
return function StatsTiming(props: { timing: TimingTotals | null; locale: string }): ReactNS.ReactElement {
|
||||
const lang: 'zh' | 'en' = props.locale === 'zh' ? 'zh' : 'en'
|
||||
// The legend row ↔ donut segment hover link (shared key, set from either side).
|
||||
const [hoverKey, setHoverKey] = React.useState<string | null>(null)
|
||||
const timing = props.timing
|
||||
const wall = timing !== null && Number.isFinite(timing.wallMs) && timing.wallMs > 0 ? timing.wallMs : 0
|
||||
let segments: DonutSegment[] = []
|
||||
@@ -91,8 +88,10 @@ export function makeStatsTiming(kit: ViewKit, Donut: (props: {
|
||||
size={96}
|
||||
centerTop={wall > 0 ? fmtDuration(wall, lang) : '—'}
|
||||
centerSub={t('timing.total')}
|
||||
hoverKey={hoverKey}
|
||||
onHoverKey={setHoverKey}
|
||||
/>
|
||||
<SliceList rows={rows} />
|
||||
<SliceList rows={rows} hoverKey={hoverKey} onHoverKey={setHoverKey} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -18,19 +18,16 @@ import { React } from '../react'
|
||||
|
||||
import { makeSliceList } from './sliceList'
|
||||
import type { SliceRow } from './sliceList'
|
||||
import type { DonutSegment } from './donut'
|
||||
import type { DonutProps } from './donut'
|
||||
|
||||
export function makeStatsTokens(kit: ViewKit, Donut: (props: {
|
||||
segments: DonutSegment[]
|
||||
centerTop: ReactNS.ReactNode
|
||||
centerSub?: ReactNS.ReactNode
|
||||
size?: number
|
||||
}) => ReactNS.ReactElement): (props: {
|
||||
export function makeStatsTokens(kit: ViewKit, Donut: (props: DonutProps) => ReactNS.ReactElement): (props: {
|
||||
usage: TokenUsage | null
|
||||
}) => ReactNS.ReactElement {
|
||||
const { t, fmt, fmtShare } = kit
|
||||
const SliceList = makeSliceList(kit)
|
||||
return function StatsTokens(props: { usage: TokenUsage | null }): ReactNS.ReactElement {
|
||||
// The legend row ↔ donut segment hover link (shared key, set from either side).
|
||||
const [hoverKey, setHoverKey] = React.useState<string | null>(null)
|
||||
const reads = props.usage !== null ? numOf(props.usage.cacheReadTokens) : 0
|
||||
const writes = props.usage !== null ? numOf(props.usage.cacheWriteTokens) : 0
|
||||
const uncached = props.usage !== null ? numOf(props.usage.uncachedInputTokens) : 0
|
||||
@@ -44,7 +41,7 @@ export function makeStatsTokens(kit: ViewKit, Donut: (props: {
|
||||
const buckets: { key: string; color: string; label: string; note?: string; value: number }[] = [
|
||||
{ key: 'read', color: '#22c55e', label: t('tokens.cacheRead'), value: reads },
|
||||
{ key: 'write', color: '#f59e0b', label: t('tokens.cacheWrite'), value: writes },
|
||||
{ key: 'uncached', color: '#6366f1', label: t('tokens.uncached'), value: uncached },
|
||||
{ key: 'uncached', color: '#a855f7', label: t('tokens.uncached'), value: uncached },
|
||||
{ key: 'output', color: '#3b82f6', label: t('tokens.output'), note: t('tokens.outputNote'), value: output },
|
||||
]
|
||||
const shown = billed > 0 ? buckets.filter(b => b.value > 0) : buckets
|
||||
@@ -65,8 +62,10 @@ export function makeStatsTokens(kit: ViewKit, Donut: (props: {
|
||||
size={96}
|
||||
centerTop={hit === null ? '—' : `${hit}%`}
|
||||
centerSub={t('tokens.cacheHit')}
|
||||
hoverKey={hoverKey}
|
||||
onHoverKey={setHoverKey}
|
||||
/>
|
||||
<SliceList rows={rows} />
|
||||
<SliceList rows={rows} hoverKey={hoverKey} onHoverKey={setHoverKey} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+6
-6
@@ -38,9 +38,9 @@ export const DICT_ZH: Record<string, string> = {
|
||||
'tokens.title': 'Token 统计',
|
||||
'tokens.hint': '供应商上报的累计计费用量',
|
||||
'tokens.cacheHit': '缓存命中',
|
||||
'tokens.cacheRead': '缓存读取',
|
||||
'tokens.cacheRead': '缓存输入',
|
||||
'tokens.cacheWrite': '缓存写入',
|
||||
'tokens.uncached': '未命中输入',
|
||||
'tokens.uncached': '未缓存输入',
|
||||
'tokens.output': '输出',
|
||||
'tokens.outputNote': '含思考',
|
||||
'plugin.title': '插件信息',
|
||||
@@ -249,10 +249,10 @@ export const DICT_EN: Record<string, string> = {
|
||||
'timing.empty': 'No timing data yet — durations appear as the conversation runs',
|
||||
'tokens.title': 'Token Stats',
|
||||
'tokens.hint': 'Cumulative provider-reported usage',
|
||||
'tokens.cacheHit': 'Cache hit',
|
||||
'tokens.cacheRead': 'Cache read',
|
||||
'tokens.cacheWrite': 'Cache write',
|
||||
'tokens.uncached': 'Uncached input',
|
||||
'tokens.cacheHit': 'Cache Hit',
|
||||
'tokens.cacheRead': 'Cached Input',
|
||||
'tokens.cacheWrite': 'Cache Write',
|
||||
'tokens.uncached': 'Uncached Input',
|
||||
'tokens.output': 'Output',
|
||||
'tokens.outputNote': 'incl. reasoning',
|
||||
'plugin.title': 'Plugin Info',
|
||||
|
||||
@@ -46,6 +46,12 @@
|
||||
.lc-donut { position: relative; flex: none; }
|
||||
.lc-donut svg { display: block; }
|
||||
.lc-donut-track { stroke: rgba(128,128,128,0.18); }
|
||||
/* The legend row ↔ segment hover link: hovering either side dims the ring's
|
||||
other slices to 0.35 (the stacked bars' idiom) while the active one stays
|
||||
solid; the center figures stay at rest. */
|
||||
.lc-donut-seg { transition: opacity 120ms ease; }
|
||||
.lc-donut-dim .lc-donut-seg { opacity: 0.35; }
|
||||
.lc-donut-dim .lc-donut-seg-on { opacity: 1; }
|
||||
.lc-donut-center { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px; pointer-events: none; text-align: center; padding: 0 7px; box-sizing: border-box; }
|
||||
.lc-donut-center b { line-height: 1.15; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
|
||||
.lc-donut-center span { color: var(--dsw-alias-label-secondary); white-space: nowrap; }
|
||||
@@ -58,11 +64,14 @@
|
||||
a last resort; a zero-figure row dims — the ring already carries the share.
|
||||
*/
|
||||
.lc-sl { display: flex; flex-direction: column; gap: 6px; min-width: 0; overflow: hidden; }
|
||||
.lc-sl-row { display: flex; flex-direction: column; gap: 1px; font-size: 12px; min-width: 0; }
|
||||
/* The pill tint spreads 6px past the text column (negative margins) so the
|
||||
hovered row reads as one lit surface without shifting the columns. */
|
||||
.lc-sl-row { display: flex; flex-direction: column; gap: 1px; font-size: 12px; min-width: 0; border-radius: 6px; padding: 1px 6px; margin: 0 -6px; transition: background-color 120ms ease; }
|
||||
.lc-sl-row-on { background: var(--dsw-alias-interactive-bg-hover); }
|
||||
.lc-sl-row-dim { opacity: 0.55; }
|
||||
.lc-sl-main { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||
.lc-sl-dot { flex: none; width: 8px; height: 8px; border-radius: 2px; }
|
||||
.lc-sl-label { flex: 1 1 auto; min-width: 0; line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.lc-sl-label { flex: 1 1 auto; min-width: 0; line-height: 1.25; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.lc-sl-pct { flex: none; min-width: 34px; text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.lc-sl-sub { margin-left: 14px; color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 1.3; font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.lc-pi-grid { display: grid; grid-template-columns: 1fr; gap: 8px; }
|
||||
|
||||
@@ -4,13 +4,20 @@
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, test } from 'vitest'
|
||||
import { h } from '../../../src/client/react'
|
||||
import { h, React } from '../../../src/client/react'
|
||||
import { makeDonut } from '../../../src/client/components/donut'
|
||||
import { makeKit, mount, query, queryAll, text } from '../helpers/kit'
|
||||
import type { DonutSegment } from '../../../src/client/components/donut'
|
||||
import { hover, makeKit, mount, query, queryAll, text, unhover } from '../helpers/kit'
|
||||
|
||||
const kit = makeKit()
|
||||
const Donut = makeDonut(kit)
|
||||
|
||||
/** A parent that really holds the hover key, so hovering re-renders the ring. */
|
||||
function HoverHarness(props: { segments: DonutSegment[] }) {
|
||||
const [hoverKey, setHoverKey] = React.useState<string | null>(null)
|
||||
return h(Donut, { segments: props.segments, centerTop: 'x', hoverKey, onHoverKey: setHoverKey })
|
||||
}
|
||||
|
||||
describe('Donut', () => {
|
||||
test('segments render one circle each, in order, with accumulating offsets', async () => {
|
||||
const m = await mount(h(Donut, {
|
||||
@@ -70,3 +77,38 @@ describe('Donut', () => {
|
||||
await m.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Donut hover link', () => {
|
||||
const SEGS: DonutSegment[] = [
|
||||
{ key: 'a', color: '#ff0000', value: 30 },
|
||||
{ key: 'b', color: '#00ff00', value: 70 },
|
||||
]
|
||||
|
||||
test('hovering an arc dims the ring and lights it; leaving the ring clears both', async () => {
|
||||
const m = await mount(h(HoverHarness, { segments: SEGS }))
|
||||
const arcs = queryAll(m.container, '.lc-donut-seg')
|
||||
const cls = (el: Element): string => el.getAttribute('class') ?? ''
|
||||
await hover(arcs[0])
|
||||
assert.ok(query(m.container, '.lc-donut').className.includes('lc-donut-dim'))
|
||||
assert.ok(cls(arcs[0]).includes('lc-donut-seg-on'))
|
||||
assert.ok(!cls(arcs[1]).includes('lc-donut-seg-on'))
|
||||
await unhover(query(m.container, '.lc-donut'))
|
||||
assert.ok(!query(m.container, '.lc-donut').className.includes('lc-donut-dim'))
|
||||
assert.ok(!cls(arcs[0]).includes('lc-donut-seg-on'))
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('a hover key with no painted arc leaves the ring at rest', async () => {
|
||||
const m = await mount(h(Donut, { segments: SEGS, centerTop: 'x', hoverKey: 'ghost' }))
|
||||
assert.ok(!query(m.container, '.lc-donut').className.includes('lc-donut-dim'))
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('hover events without a relay stay inert', async () => {
|
||||
const m = await mount(h(Donut, { segments: SEGS, centerTop: 'x' }))
|
||||
await hover(query(m.container, '.lc-donut-seg'))
|
||||
await unhover(query(m.container, '.lc-donut'))
|
||||
assert.ok(!query(m.container, '.lc-donut').className.includes('lc-donut-dim'))
|
||||
await m.unmount()
|
||||
})
|
||||
})
|
||||
@@ -4,13 +4,20 @@
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, test } from 'vitest'
|
||||
import { h } from '../../../src/client/react'
|
||||
import { h, React } from '../../../src/client/react'
|
||||
import { makeSliceList } from '../../../src/client/components/sliceList'
|
||||
import { makeKit, mount, queryAll, text } from '../helpers/kit'
|
||||
import type { SliceRow } from '../../../src/client/components/sliceList'
|
||||
import { hover, makeKit, mount, query, queryAll, text, unhover } from '../helpers/kit'
|
||||
|
||||
const kit = makeKit()
|
||||
const SliceList = makeSliceList(kit)
|
||||
|
||||
/** A parent that really holds the hover key, so hovering re-renders the rows. */
|
||||
function HoverHarness(props: { rows: SliceRow[] }) {
|
||||
const [hoverKey, setHoverKey] = React.useState<string | null>(null)
|
||||
return h(SliceList, { rows: props.rows, hoverKey, onHoverKey: setHoverKey })
|
||||
}
|
||||
|
||||
describe('SliceList', () => {
|
||||
test('rows render the primary line and the secondary count line in order', async () => {
|
||||
const m = await mount(h(SliceList, {
|
||||
@@ -53,4 +60,27 @@ describe('SliceList', () => {
|
||||
assert.ok(queryAll(m.container, '.lc-sl').length === 1)
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('hovering a row lights it; leaving the list clears it', async () => {
|
||||
const rowsIn: SliceRow[] = [
|
||||
{ key: 'a', color: '#ff0000', label: 'Alpha', pct: '84%', count: '204.9k' },
|
||||
{ key: 'b', color: '#00ff00', label: 'Beta', pct: '<1%', count: '3m 20s · 9 runs' },
|
||||
]
|
||||
const m = await mount(h(HoverHarness, { rows: rowsIn }))
|
||||
const rows = queryAll(m.container, '.lc-sl-row')
|
||||
await hover(rows[1])
|
||||
assert.ok(rows[1].className.includes('lc-sl-row-on'))
|
||||
assert.ok(!rows[0].className.includes('lc-sl-row-on'))
|
||||
await unhover(query(m.container, '.lc-sl'))
|
||||
assert.ok(!rows[1].className.includes('lc-sl-row-on'))
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('hover events without a relay stay inert', async () => {
|
||||
const m = await mount(h(SliceList, { rows: [{ key: 'a', color: '#ff0000', label: 'Alpha', pct: '1%', count: '1' }] }))
|
||||
await hover(query(m.container, '.lc-sl-row'))
|
||||
await unhover(query(m.container, '.lc-sl'))
|
||||
assert.equal(queryAll(m.container, '.lc-sl-row-on').length, 0)
|
||||
await m.unmount()
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,7 @@ import { h } from '../../../src/client/react'
|
||||
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 } from '../helpers/kit'
|
||||
import { makeKit, mount, query, queryAll, text, hover } from '../helpers/kit'
|
||||
|
||||
const kit = makeKit()
|
||||
const kitZh = makeKit('zh')
|
||||
@@ -74,6 +74,15 @@ describe('StatsTiming', () => {
|
||||
assert.equal(queryAll(m.container, '.lc-donut circle').length, 2)
|
||||
// The zero overhead row dims whole.
|
||||
assert.deepEqual(rowOf(m.container, 2), { pct: '0%', label: 'Overhead', count: '—', dim: true })
|
||||
// The hover link: the zero-overhead row tints itself but leaves the ring
|
||||
// at rest (no painted arc to light); a painted row dims the ring.
|
||||
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'))
|
||||
await hover(rows[1])
|
||||
assert.ok(query(m.container, '.lc-donut').className.includes('lc-donut-dim'))
|
||||
assert.ok((queryAll(m.container, '.lc-donut-seg')[1]?.getAttribute('class') ?? '').includes('lc-donut-seg-on'))
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { h } from '../../../src/client/react'
|
||||
import { makeDonut } from '../../../src/client/components/donut'
|
||||
import { makeStatsTokens } from '../../../src/client/components/statsTokens'
|
||||
import type { TokenUsage } from '../../../src/shared/types'
|
||||
import { makeKit, mount, query, queryAll } from '../helpers/kit'
|
||||
import { makeKit, mount, query, queryAll, hover, unhover } from '../helpers/kit'
|
||||
|
||||
const kit = makeKit()
|
||||
const StatsTokens = makeStatsTokens(kit, makeDonut(kit))
|
||||
@@ -30,24 +30,37 @@ describe('StatsTokens', () => {
|
||||
// Prompt-side hit = 300 / (100 + 300 + 0) = 75.00% (the chat line's formula);
|
||||
// the rows normalize over the whole billed total (450).
|
||||
assert.equal(query(m.container, '.lc-donut-center b').textContent, '75.00%')
|
||||
assert.equal(query(m.container, '.lc-donut-center span').textContent, 'Cache hit')
|
||||
assert.equal(query(m.container, '.lc-donut-center span').textContent, 'Cache Hit')
|
||||
// Zero buckets stay hidden: the always-zero DeepSeek cache write drops out.
|
||||
assert.equal(queryAll(m.container, '.lc-sl-row').length, 3)
|
||||
assert.deepEqual(rowOf(m.container, 0), { pct: '67%', label: 'Cache read', count: '300' })
|
||||
assert.deepEqual(rowOf(m.container, 1), { pct: '22%', label: 'Uncached input', count: '100' })
|
||||
assert.deepEqual(rowOf(m.container, 0), { pct: '67%', label: 'Cached Input', count: '300' })
|
||||
assert.deepEqual(rowOf(m.container, 1), { pct: '22%', label: 'Uncached Input', count: '100' })
|
||||
assert.deepEqual(rowOf(m.container, 2), { pct: '11%', label: 'Output', count: '50 · incl. reasoning' })
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('hovering a legend row lights its donut segment and the row itself', async () => {
|
||||
const m = await mount(h(StatsTokens, { usage: USAGE }))
|
||||
const rows = queryAll(m.container, '.lc-sl-row')
|
||||
await hover(rows[0])
|
||||
assert.ok(query(m.container, '.lc-donut').className.includes('lc-donut-dim'))
|
||||
assert.ok((queryAll(m.container, '.lc-donut-seg')[0]?.getAttribute('class') ?? '').includes('lc-donut-seg-on'))
|
||||
assert.ok(rows[0].className.includes('lc-sl-row-on'))
|
||||
await unhover(rows[0])
|
||||
assert.ok(!query(m.container, '.lc-donut').className.includes('lc-donut-dim'))
|
||||
assert.ok(!rows[0].className.includes('lc-sl-row-on'))
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('non-zero cache writes render as a fourth bucket', async () => {
|
||||
const written: TokenUsage = { uncachedInputTokens: 100, outputTokens: 50, cacheReadTokens: 300, cacheWriteTokens: 50 }
|
||||
const m = await mount(h(StatsTokens, { usage: written }))
|
||||
// Hit = 300 / (100 + 300 + 50) = 66.66%; rows normalize over billed 500.
|
||||
assert.equal(query(m.container, '.lc-donut-center b').textContent, '66.66%')
|
||||
assert.equal(queryAll(m.container, '.lc-sl-row').length, 4)
|
||||
assert.deepEqual(rowOf(m.container, 0), { pct: '60%', label: 'Cache read', count: '300' })
|
||||
assert.deepEqual(rowOf(m.container, 1), { pct: '10%', label: 'Cache write', count: '50' })
|
||||
assert.deepEqual(rowOf(m.container, 2), { pct: '20%', label: 'Uncached input', count: '100' })
|
||||
assert.deepEqual(rowOf(m.container, 0), { pct: '60%', label: 'Cached Input', count: '300' })
|
||||
assert.deepEqual(rowOf(m.container, 1), { pct: '10%', label: 'Cache Write', count: '50' })
|
||||
assert.deepEqual(rowOf(m.container, 2), { pct: '20%', label: 'Uncached Input', count: '100' })
|
||||
assert.deepEqual(rowOf(m.container, 3), { pct: '10%', label: 'Output', count: '50 · incl. reasoning' })
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user