codeburn/tests/usage-aggregator.test.ts
ozymandiashh 75f537e974 Merge origin/main into cache-read and quota pace
Main took render revision 6 for `sessionCountBasis` and changed the
provider-slice session fold from a sum to a per-day max. Both had to
survive the merge, so the resolutions are unions rather than sides:

- `addProviderSlice` keeps main's `Math.max` for `sessions` (per-day
  ticks are not distinct identities) and adds the cache-read sum beside
  it, with a comment saying why one is additive and the other is not.
- `ProviderSliceTotal`, `ProviderCost`, `providerDetails` and the Swift
  `ProviderDetail` carry both `sessionCountBasis` and `cacheReadTokens`.
- The snapshot revision stays 7, which is still the next free number
  above main's 6. Its doc comment said v6 was PR1265's per-model counts;
  it is main's `sessionCountBasis`, so the comment now says that.

The status-menubar suite hard-codes render revisions. Main's assertion
that a rejected v5 snapshot is rewritten as `:render-6:` auto-merged
cleanly while this branch moved the current revision to 7, which would
have failed silently on the next bump too. Those assertions now derive
the tag from `STATUS_SNAPSHOT_RENDER_VERSION`, so the current and
previous revisions follow the constant; the deliberately historical
`:render-5:` and `:render-2:` plants stay literal.
2026-09-12 20:27:33 +03:00

133 lines
7.1 KiB
TypeScript

import { describe, expect, it, beforeAll } from 'vitest'
import { addProviderSlice, buildMenubarPayloadForRange, mergeDayModelsByDisplayName, providerSliceHasUsage, type ProviderSliceTotal } from '../src/usage-aggregator.js'
import { getDateRange } from '../src/cli-date.js'
import { loadPricing } from '../src/models.js'
import type { ModelDayStats } from '../src/daily-cache.js'
function modelStats(overrides: Partial<ModelDayStats>): ModelDayStats {
return { calls: 0, cost: 0, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, ...overrides }
}
describe('buildMenubarPayloadForRange', () => {
beforeAll(async () => {
await loadPricing()
})
it('treats token-only provider slices as usage', () => {
expect(providerSliceHasUsage({
calls: 0,
cost: 0,
savingsUSD: 0,
inputTokens: 1,
})).toBe(true)
expect(providerSliceHasUsage({ calls: 0, cost: 0, savingsUSD: 0 })).toBe(false)
})
it('returns a valid payload and skips optimize findings when optimize:false', async () => {
const payload = await buildMenubarPayloadForRange(getDateRange('today'), { provider: 'all', optimize: false })
expect(typeof payload.current.label).toBe('string')
expect(payload.current.cost).toBeGreaterThanOrEqual(0)
expect(Array.isArray(payload.current.topProjects)).toBe(true)
expect(Array.isArray(payload.current.topModels)).toBe(true)
expect(Array.isArray(payload.history.daily)).toBe(true)
expect(payload.history.timeline?.bucketMinutes).toBe(15)
expect(Array.isArray(payload.history.timeline?.points)).toBe(true)
expect(payload.current.retryTax.totalUSD).toBeGreaterThanOrEqual(0)
// Codex credits are always present in the payload (display gates them); 0 with no data.
expect(typeof payload.current.codexCredits).toBe('number')
expect(payload.current.codexCredits).toBeGreaterThanOrEqual(0)
// optimize:false => scanAndDetect skipped => empty optimize block regardless of data
expect(payload.optimize).toEqual({ findingCount: 0, savingsUSD: 0, topFindings: [] })
expect(payload.stale).toBeUndefined()
})
})
// The fold both cache-backed provider branches use (claude-config-scoped and
// all-providers). The dock's glance reads these per-provider figures, so a sum
// that leaked across providers would print another provider's spend.
describe('addProviderSlice', () => {
it('sums cost, calls and tokens; session ticks stay a per-day max bound', () => {
const totals: Record<string, ProviderSliceTotal> = {}
addProviderSlice(totals, 'claude', { cost: 10, calls: 4, savingsUSD: 0, inputTokens: 100, outputTokens: 20, sessions: 2 })
addProviderSlice(totals, 'claude', { cost: 2.5, calls: 1, savingsUSD: 0, inputTokens: 50, outputTokens: 5, sessions: 1 })
addProviderSlice(totals, 'codex', { cost: 1, calls: 3, savingsUSD: 0, inputTokens: 7, outputTokens: 3, sessions: 1 })
expect(totals.claude).toEqual({ cost: 12.5, calls: 5, hasUsage: true, inputTokens: 150, outputTokens: 25, sessions: 2, cacheReadIncomplete: true })
expect(totals.codex).toEqual({ cost: 1, calls: 3, hasUsage: true, inputTokens: 7, outputTokens: 3, sessions: 1, cacheReadIncomplete: true })
})
it('leaves tokens absent (not zero) when no day carried a breakdown', () => {
const totals: Record<string, ProviderSliceTotal> = {}
// A day finalized before per-provider tokens were cached.
addProviderSlice(totals, 'claude', { cost: 3, calls: 2, savingsUSD: 0 })
expect(totals.claude).toEqual({ cost: 3, calls: 2, hasUsage: true, cacheReadIncomplete: true })
expect(totals.claude!.inputTokens).toBeUndefined()
expect(totals.claude!.outputTokens).toBeUndefined()
expect(totals.claude!.sessions).toBeUndefined()
// One day that does report them makes the total reportable again, counting
// only what was actually reported.
addProviderSlice(totals, 'claude', { cost: 1, calls: 1, savingsUSD: 0, inputTokens: 9, outputTokens: 4 })
expect(totals.claude).toEqual({ cost: 4, calls: 3, hasUsage: true, inputTokens: 9, outputTokens: 4, cacheReadIncomplete: true })
})
it('keeps a token-only day visible as usage', () => {
const totals: Record<string, ProviderSliceTotal> = {}
addProviderSlice(totals, 'hermes', { cost: 0, calls: 0, savingsUSD: 0, inputTokens: 12, outputTokens: 0 })
expect(totals.hermes).toEqual({ cost: 0, calls: 0, hasUsage: true, inputTokens: 12, outputTokens: 0, cacheReadIncomplete: true })
})
it('sums cache read per provider, keeping zero and absence distinct', () => {
const totals: Record<string, ProviderSliceTotal> = {}
addProviderSlice(totals, 'claude', { cost: 1, calls: 1, savingsUSD: 0, cacheReadTokens: 100 })
addProviderSlice(totals, 'claude', { cost: 1, calls: 1, savingsUSD: 0, cacheReadTokens: 50 })
addProviderSlice(totals, 'codex', { cost: 1, calls: 1, savingsUSD: 0, cacheReadTokens: 0 })
addProviderSlice(totals, 'grok', { cost: 1, calls: 1, savingsUSD: 0 })
expect(totals.claude!.cacheReadTokens).toBe(150)
expect(totals.claude!.cacheReadIncomplete).toBeUndefined()
expect(totals.codex!.cacheReadTokens).toBe(0) // a reported zero, not unknown
expect(totals.grok!.cacheReadTokens).toBeUndefined() // absent, not zero
})
it('marks the total incomplete when an active day lacks cache read', () => {
const totals: Record<string, ProviderSliceTotal> = {}
addProviderSlice(totals, 'claude', { cost: 1, calls: 1, savingsUSD: 0, cacheReadTokens: 100 })
addProviderSlice(totals, 'claude', { cost: 1, calls: 1, savingsUSD: 0 })
expect(totals.claude!.cacheReadIncomplete).toBe(true)
})
it('does not mark incomplete when only idle days lack cache read', () => {
const totals: Record<string, ProviderSliceTotal> = {}
addProviderSlice(totals, 'claude', { cost: 1, calls: 1, savingsUSD: 0, cacheReadTokens: 100 })
addProviderSlice(totals, 'claude', { cost: 0, calls: 0, savingsUSD: 0 })
expect(totals.claude!.cacheReadIncomplete).toBeUndefined()
expect(totals.claude!.cacheReadTokens).toBe(100)
})
})
describe('mergeDayModelsByDisplayName', () => {
it('collapses two raw MiniMax routes into one display-name row and keeps both raw ids (#1239)', () => {
const merged = mergeDayModelsByDisplayName({
'minimax/MiniMax-M3': modelStats({ calls: 355, cost: 6.99, inputTokens: 500, outputTokens: 100 }),
'MiniMaxAI/MiniMax-M3': modelStats({ calls: 60, cost: 0.37, inputTokens: 200, outputTokens: 50 }),
})
expect(merged).toHaveLength(1)
expect(merged[0]!.name).toBe('MiniMax M3')
expect(merged[0]!.rawModels).toEqual(['minimax/MiniMax-M3', 'MiniMaxAI/MiniMax-M3'])
expect(merged[0]!.calls).toBe(415)
expect(merged[0]!.cost).toBeCloseTo(7.36, 6)
expect(merged[0]!.inputTokens).toBe(700)
expect(merged[0]!.outputTokens).toBe(150)
})
it('keeps distinct models on separate rows with a single-entry rawModels', () => {
const merged = mergeDayModelsByDisplayName({
'claude-sonnet-4-6': modelStats({ calls: 1, cost: 1 }),
'gpt-5': modelStats({ calls: 2, cost: 2 }),
})
expect(merged.map(m => m.name).sort()).toEqual(['GPT-5', 'Sonnet 4.6'])
for (const row of merged) expect(row.rawModels).toHaveLength(1)
})
})