mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-29 18:44:34 +00:00
The provider-scoped overlay blind-set a fresh slice for every date it could produce one. A settled day's sources age off disk continuously, so that fresh slice is a lower bound, not a correction, and the overlay replaced finalized cache days with whatever the surviving files still explained. It ran over the whole period and over the whole history, so the scoped view could report a fraction of the same day the all-provider view served off the same cache. The overlay now routes through mergeDayEntries with guardPartialSurvival: fresh may FILL a (date, provider) the cache lacks and still wins inside the settle window, but it can no longer SHRINK a settled day the cache already holds. Filling has to keep working, because on a cold cache the scoped parse is the only source a historical day has. Measured on this machine over 2026-08-03..2026-09-01, comparing scoped --provider claude against the claude slice of the unscoped run: before $16649.42 / 81812 calls (short by $291.70 and 2507 calls) after $16941.12 / 84319 calls (exact match, both fields)
502 lines
24 KiB
TypeScript
502 lines
24 KiB
TypeScript
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { mkdir, rm, writeFile } from 'fs/promises'
|
|
import { existsSync } from 'fs'
|
|
import { tmpdir } from 'os'
|
|
import { join } from 'path'
|
|
|
|
import { DAILY_CACHE_VERSION, currentTzKey, type DailyCache, type DailyEntry } from '../src/daily-cache.js'
|
|
import { getDateRange } from '../src/cli-date.js'
|
|
import { loadPricing } from '../src/models.js'
|
|
import {
|
|
buildMenubarPayloadForRange,
|
|
buildDurablePeriod,
|
|
buildPeriodData,
|
|
getDailyCacheConfigHash,
|
|
} from '../src/usage-aggregator.js'
|
|
import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, filterProjectsByDays, clearSessionCache } from '../src/parser.js'
|
|
import { renderOverview } from '../src/overview.js'
|
|
import type { DateRange } from '../src/types.js'
|
|
|
|
// The point of #755: Claude deletes transcripts after ~30 days, so a day that
|
|
// can no longer be re-derived from session files exists ONLY in the durable
|
|
// daily cache. The bug this suite guards: the CLI report / overview / TUI
|
|
// live-parsed the surviving files and dropped those carried days, so their
|
|
// totals fell short of the menubar (which unions the cache). Every surface now
|
|
// routes totals through the ONE shared builder (buildDurablePeriod), so the CLI
|
|
// report totals must equal the menubar payload totals EXACTLY — carried day
|
|
// included — across all-provider, provider-filtered, custom-range, and lifetime
|
|
// queries, and in the plain live regime with no carried days at all.
|
|
|
|
const ROOT = join(tmpdir(), `codeburn-durable-totals-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
|
|
const ENV_KEYS = ['HOME', 'CODEBURN_CACHE_DIR', 'CLAUDE_CONFIG_DIR', 'CLAUDE_CONFIG_DIRS', 'CODEX_HOME', 'USERPROFILE', 'KIMI_CODE_HOME', 'CODEBURN_DESKTOP_SESSIONS_DIR'] as const
|
|
let savedEnv: Record<string, string | undefined>
|
|
|
|
const CARRIED_COST = 100
|
|
|
|
function daysAgoStr(n: number): string {
|
|
const d = new Date(Date.now() - n * 24 * 60 * 60 * 1000)
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
}
|
|
|
|
/** A day the cache holds but no session file can reproduce (sources aged out). */
|
|
function carriedDay(date: string): DailyEntry {
|
|
return {
|
|
date,
|
|
cost: CARRIED_COST,
|
|
savingsUSD: 0,
|
|
calls: 40,
|
|
sessions: 3,
|
|
inputTokens: 5000,
|
|
outputTokens: 2000,
|
|
cacheReadTokens: 0,
|
|
cacheWriteTokens: 0,
|
|
editTurns: 4,
|
|
oneShotTurns: 2,
|
|
models: { 'Opus 4.8': { calls: 40, cost: CARRIED_COST, savingsUSD: 0, inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0 } },
|
|
categories: { coding: { turns: 10, cost: CARRIED_COST, savingsUSD: 0, editTurns: 4, oneShotTurns: 2 } },
|
|
providers: {
|
|
claude: {
|
|
calls: 40, cost: CARRIED_COST, savingsUSD: 0, sessions: 3,
|
|
inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0,
|
|
editTurns: 4, oneShotTurns: 2,
|
|
models: { 'Opus 4.8': { calls: 40, cost: CARRIED_COST, savingsUSD: 0, inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0 } },
|
|
categories: { coding: { turns: 10, cost: CARRIED_COST, savingsUSD: 0, editTurns: 4, oneShotTurns: 2 } },
|
|
projects: { 'proj-x': { cost: CARRIED_COST, calls: 40, savingsUSD: 0, sessions: 3, path: '/Users/gone/proj-x' } },
|
|
},
|
|
},
|
|
projects: { 'proj-x': { cost: CARRIED_COST, calls: 40, savingsUSD: 0, sessions: 3, path: '/Users/gone/proj-x' } },
|
|
carried: true,
|
|
}
|
|
}
|
|
|
|
/** Write a cache whose only historical day is carried (no source files exist). */
|
|
async function seedCarriedCache(): Promise<string> {
|
|
const day = daysAgoStr(10)
|
|
const cache: DailyCache = {
|
|
version: DAILY_CACHE_VERSION,
|
|
savingsConfigHash: getDailyCacheConfigHash(),
|
|
tzKey: currentTzKey(),
|
|
lastComputedDate: daysAgoStr(1),
|
|
days: [carriedDay(day)],
|
|
complete: true,
|
|
}
|
|
await writeFile(join(ROOT, 'cache', `daily-cache.v${DAILY_CACHE_VERSION}.json`), JSON.stringify(cache), 'utf-8')
|
|
return day
|
|
}
|
|
|
|
/** Sources for the settled day still exist but explain only a fraction of what
|
|
* the cache finalized there, which is what source aging looks like in the
|
|
* window before a transcript is deleted outright. */
|
|
async function seedPartialSourcesFor(date: string): Promise<void> {
|
|
const projectDir = join(ROOT, 'home', '.claude', 'projects', 'p')
|
|
await mkdir(projectDir, { recursive: true })
|
|
const [y, m, d] = date.split('-').map(Number)
|
|
const line = JSON.stringify({
|
|
type: 'assistant',
|
|
timestamp: new Date(y!, m! - 1, d!, 12, 0, 0).toISOString(),
|
|
sessionId: 's-partial',
|
|
message: {
|
|
type: 'message', role: 'assistant', model: 'claude-3-5-sonnet-20241022', id: 'm-partial',
|
|
content: [],
|
|
usage: { input_tokens: 1000, output_tokens: 200, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
},
|
|
})
|
|
await writeFile(join(projectDir, 's-partial.jsonl'), line + '\n', 'utf-8')
|
|
}
|
|
|
|
/** Seed a real, priced Claude session dated TODAY (the live, surviving half). */
|
|
async function seedLiveTodaySession(): Promise<void> {
|
|
const projectDir = join(ROOT, 'home', '.claude', 'projects', 'p')
|
|
await mkdir(projectDir, { recursive: true })
|
|
const now = new Date()
|
|
// Timestamps a few minutes OLD, clamped into today: a fixed wall-clock hour
|
|
// (12:00) is in the future whenever the suite runs before noon, and the
|
|
// instant-granular provider-filtered path drops future calls while the
|
|
// day-granular all-provider path keeps them, so the parity assertion failed
|
|
// for every before-noon run (ubuntu CI at 00:17 UTC included). Same fix as
|
|
// project-filter-durable-totals got in 1596220.
|
|
const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
|
|
const minutesAgo = (m: number): string => new Date(Math.max(midnight, now.getTime() - m * 60_000)).toISOString()
|
|
const ts = minutesAgo(40)
|
|
const ts2 = minutesAgo(10)
|
|
const line = (id: string, t: string): string => JSON.stringify({
|
|
type: 'assistant',
|
|
timestamp: t,
|
|
sessionId: 's-today',
|
|
message: {
|
|
type: 'message', role: 'assistant', model: 'claude-3-5-sonnet-20241022', id,
|
|
content: [],
|
|
usage: { input_tokens: 90000, output_tokens: 12000, cache_creation_input_tokens: 0, cache_read_input_tokens: 300000 },
|
|
},
|
|
})
|
|
await writeFile(join(projectDir, 's-today.jsonl'), [line('m1', ts), line('m2', ts2)].join('\n') + '\n', 'utf-8')
|
|
}
|
|
|
|
/** Live-only headline over the surviving files for the range (no cache union). */
|
|
async function liveOnly(range: DateRange): Promise<{ cost: number; calls: number }> {
|
|
clearSessionCache()
|
|
const projects = filterProjectsByName(await parseAllSessions(range, 'all'), [], [])
|
|
const data = buildPeriodData('live', projects)
|
|
return { cost: data.cost, calls: data.calls }
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
await loadPricing()
|
|
})
|
|
|
|
beforeEach(async () => {
|
|
savedEnv = Object.fromEntries(ENV_KEYS.map(k => [k, process.env[k]]))
|
|
await mkdir(join(ROOT, 'home', '.claude'), { recursive: true })
|
|
await mkdir(join(ROOT, 'cache'), { recursive: true })
|
|
await mkdir(join(ROOT, 'no-desktop-sessions'), { recursive: true })
|
|
await mkdir(join(ROOT, 'no-kimi-home'), { recursive: true })
|
|
process.env['HOME'] = join(ROOT, 'home')
|
|
process.env['CODEBURN_CACHE_DIR'] = join(ROOT, 'cache')
|
|
process.env['CLAUDE_CONFIG_DIR'] = join(ROOT, 'home', '.claude')
|
|
delete process.env['CLAUDE_CONFIG_DIRS']
|
|
delete process.env['CODEX_HOME']
|
|
// Keep real provider data on the machine out of every parse: absolute-count
|
|
// assertions are meaningless when the host's own sessions leak in.
|
|
// USERPROFILE matters on Windows, where os.homedir() ignores HOME;
|
|
// KIMI_CODE_HOME / the desktop-sessions override redirect the two env-aware
|
|
// discovery roots. (The codex provider captures its home at import time, so
|
|
// it is redirected separately in vi.hoisted below.)
|
|
process.env['USERPROFILE'] = join(ROOT, 'home')
|
|
process.env['KIMI_CODE_HOME'] = join(ROOT, 'no-kimi-home')
|
|
process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(ROOT, 'no-desktop-sessions')
|
|
clearSessionCache()
|
|
})
|
|
|
|
afterEach(async () => {
|
|
clearSessionCache()
|
|
for (const k of ENV_KEYS) {
|
|
if (savedEnv[k] === undefined) delete process.env[k]
|
|
else process.env[k] = savedEnv[k]
|
|
}
|
|
if (existsSync(ROOT)) await rm(ROOT, { recursive: true, force: true })
|
|
})
|
|
|
|
/** The full report-vs-menubar equality for one resolved range + provider. */
|
|
async function assertParity(range: DateRange, provider: string): Promise<{ menubarCost: number; carried: number }> {
|
|
clearSessionCache()
|
|
const menubar = await buildMenubarPayloadForRange({ range, label: 'p' }, { provider, optimize: false, timeline: false })
|
|
clearSessionCache()
|
|
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider })
|
|
// The report / overview / TUI headline IS durable.data; the menubar payload's
|
|
// current IS the same builder. They must agree bit-for-bit.
|
|
expect(menubar.current.cost).toBe(durable.data.cost)
|
|
expect(menubar.current.calls).toBe(durable.data.calls)
|
|
expect(menubar.current.sessions).toBe(durable.data.sessions)
|
|
expect(menubar.current.inputTokens).toBe(durable.data.inputTokens)
|
|
expect(menubar.current.outputTokens).toBe(durable.data.outputTokens)
|
|
return { menubarCost: menubar.current.cost, carried: durable.carriedCostUSD }
|
|
}
|
|
|
|
describe('CLI totals ↔ menubar parity through the durable daily cache', () => {
|
|
it('counts the carried day equally on both paths for all-provider, custom-range, and lifetime', async () => {
|
|
await seedCarriedCache()
|
|
await seedLiveTodaySession()
|
|
|
|
const custom: DateRange = { start: new Date(Date.now() - 20 * 24 * 60 * 60 * 1000), end: new Date() }
|
|
for (const range of [getDateRange('all').range, getDateRange('lifetime').range, custom]) {
|
|
const { menubarCost, carried } = await assertParity(range, 'all')
|
|
// The carried day is genuinely in the total: it equals the surviving-file
|
|
// parse PLUS the $100 carried day, and strictly exceeds the live-only view.
|
|
const live = await liveOnly(range)
|
|
expect(carried).toBeCloseTo(CARRIED_COST, 6)
|
|
expect(menubarCost).toBeGreaterThan(live.cost)
|
|
expect(menubarCost).toBeCloseTo(live.cost + CARRIED_COST, 6)
|
|
}
|
|
})
|
|
|
|
it('resolves provider filters identically on both paths, slicing the carried day per provider', async () => {
|
|
await seedCarriedCache()
|
|
await seedLiveTodaySession()
|
|
const range = getDateRange('all').range
|
|
|
|
// The corpus is entirely Claude, so the claude slice equals the all total.
|
|
const claude = await assertParity(range, 'claude')
|
|
clearSessionCache()
|
|
const all = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all' })
|
|
expect(claude.menubarCost).toBeCloseTo(all.data.cost, 6)
|
|
expect(claude.carried).toBeCloseTo(CARRIED_COST, 6)
|
|
|
|
// A provider with no data is zero on both paths (no carried leak).
|
|
clearSessionCache()
|
|
const codexMenubar = await buildMenubarPayloadForRange({ range, label: 'p' }, { provider: 'codex', optimize: false, timeline: false })
|
|
clearSessionCache()
|
|
const codexDurable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'codex' })
|
|
expect(codexMenubar.current.cost).toBe(codexDurable.data.cost)
|
|
expect(codexDurable.data.cost).toBe(0)
|
|
expect(codexDurable.carriedCostUSD).toBe(0)
|
|
})
|
|
|
|
it('keeps the scoped view equal to the all-provider slice when a settled day only partially survives', async () => {
|
|
const day = await seedCarriedCache()
|
|
await seedPartialSourcesFor(day)
|
|
await seedLiveTodaySession()
|
|
const range = getDateRange('all').range
|
|
|
|
clearSessionCache()
|
|
const all = await buildMenubarPayloadForRange({ range, label: 'p' }, { provider: 'all', optimize: false, timeline: false })
|
|
clearSessionCache()
|
|
const scoped = await buildMenubarPayloadForRange({ range, label: 'p' }, { provider: 'claude', optimize: false, timeline: false })
|
|
|
|
// The corpus is entirely Claude, so the scoped headline IS the all-provider
|
|
// Claude slice. The scoped path used to overlay the shrunken re-parse over
|
|
// the settled day and report a fraction of the all-provider total.
|
|
expect(all.current.calls).toBeGreaterThan(40)
|
|
expect(scoped.current.calls).toBe(all.current.calls)
|
|
expect(scoped.current.cost).toBeCloseTo(all.current.cost, 6)
|
|
})
|
|
|
|
it('holds the equality in the plain live regime with no carried days', async () => {
|
|
// No cache seeded: the only data is today's surviving session.
|
|
await seedLiveTodaySession()
|
|
const range = getDateRange('all').range
|
|
|
|
const { menubarCost, carried } = await assertParity(range, 'all')
|
|
const live = await liveOnly(range)
|
|
expect(carried).toBe(0)
|
|
expect(menubarCost).toBeGreaterThan(0)
|
|
expect(menubarCost).toBeCloseTo(live.cost, 6)
|
|
})
|
|
})
|
|
|
|
describe('terminal overview carried-day footnote', () => {
|
|
it('appends the preserved-cost footnote exactly when carried > 0', async () => {
|
|
await seedCarriedCache()
|
|
await seedLiveTodaySession()
|
|
const range = getDateRange('all').range
|
|
|
|
clearSessionCache()
|
|
const durable = await buildDurablePeriod({ range, label: 'Last 6 months' }, { provider: 'all' })
|
|
expect(durable.carriedCostUSD).toBeGreaterThan(0)
|
|
const withCarried = renderOverview(durable.liveProjects, {
|
|
label: 'Last 6 months',
|
|
color: false,
|
|
durable: {
|
|
cost: durable.data.cost,
|
|
savingsUSD: durable.data.savingsUSD,
|
|
calls: durable.data.calls,
|
|
sessions: durable.data.sessions,
|
|
inputTokens: durable.data.inputTokens,
|
|
outputTokens: durable.data.outputTokens,
|
|
cacheReadTokens: durable.data.cacheReadTokens,
|
|
cacheWriteTokens: durable.data.cacheWriteTokens,
|
|
days: durable.days,
|
|
carriedCostUSD: durable.carriedCostUSD,
|
|
},
|
|
})
|
|
expect(withCarried).toContain('preserved from expired session logs')
|
|
})
|
|
|
|
it('omits the footnote when nothing was carried', async () => {
|
|
await seedLiveTodaySession()
|
|
const range = getDateRange('all').range
|
|
|
|
clearSessionCache()
|
|
const durable = await buildDurablePeriod({ range, label: 'Last 6 months' }, { provider: 'all' })
|
|
expect(durable.carriedCostUSD).toBe(0)
|
|
const noCarried = renderOverview(durable.liveProjects, {
|
|
label: 'Last 6 months',
|
|
color: false,
|
|
durable: {
|
|
cost: durable.data.cost,
|
|
savingsUSD: durable.data.savingsUSD,
|
|
calls: durable.data.calls,
|
|
sessions: durable.data.sessions,
|
|
inputTokens: durable.data.inputTokens,
|
|
outputTokens: durable.data.outputTokens,
|
|
cacheReadTokens: durable.data.cacheReadTokens,
|
|
cacheWriteTokens: durable.data.cacheWriteTokens,
|
|
days: durable.days,
|
|
carriedCostUSD: durable.carriedCostUSD,
|
|
},
|
|
})
|
|
expect(noCarried).not.toContain('preserved from expired session logs')
|
|
})
|
|
})
|
|
|
|
// Issue #852 review: per-call slicing is only conservation-correct when day
|
|
// bucketing attributes call-derived values to each call's own day. These
|
|
// tests pin the straddling-turn case the review reproduced end-to-end through
|
|
// buildDurablePeriod: a turn starting the previous day at 23:57 with one call
|
|
// before and one after local midnight must keep BOTH calls across a multi-day
|
|
// period (cache ≤ yesterday + live today union), each on its own day.
|
|
//
|
|
// The codex provider captures CODEX_HOME when its module is first imported,
|
|
// so the redirect must happen before module evaluation (vi.hoisted) rather
|
|
// than in beforeEach. The captured dir is per-test-process and empty except
|
|
// for the fixture written below, which also shields the suite from any real
|
|
// ~/.codex on the machine running it.
|
|
const CODEX_ROOT = vi.hoisted(() => {
|
|
const root = `${process.env['TMPDIR'] || '/tmp'}/codeburn-straddle-codex-${process.pid}-${Date.now()}`
|
|
process.env['CODEX_HOME'] = `${root}/codex`
|
|
return root
|
|
})
|
|
|
|
describe('midnight-straddling turn conservation (issue #852)', () => {
|
|
// Day N = 2026-07-27, day N+1 ("today") = 2026-07-28 — LOCAL dates built
|
|
// from constructor args so the case is machine-TZ independent (dateKey /
|
|
// toDateString use the same local getters).
|
|
const NOW = new Date(2026, 6, 28, 12, 0, 0)
|
|
const DAY_N = '2026-07-27'
|
|
const DAY_N1 = '2026-07-28'
|
|
|
|
function fakeNow(): void {
|
|
// Date only: the daily-cache lock and retry helpers must keep real timers.
|
|
vi.useFakeTimers({ toFake: ['Date'] })
|
|
vi.setSystemTime(NOW)
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await rm(CODEX_ROOT, { recursive: true, force: true })
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await rm(CODEX_ROOT, { recursive: true, force: true })
|
|
})
|
|
|
|
// One codex turn (the issue's provider) with a token_count call on each
|
|
// side of local midnight: 23:58 (input 1000/output 200), 00:10 (2000/400).
|
|
async function seedStraddlingCodexTurn(): Promise<void> {
|
|
const sessionDir = join(CODEX_ROOT, 'codex', 'sessions', '2026', '07', '27')
|
|
await mkdir(sessionDir, { recursive: true })
|
|
const line = (obj: unknown): string => JSON.stringify(obj)
|
|
await writeFile(join(sessionDir, 'rollout-straddle.jsonl'), [
|
|
line({ type: 'session_meta', timestamp: new Date(2026, 6, 27, 23, 55, 0).toISOString(), payload: { session_id: 'sess-straddle', model: 'gpt-5.5', cwd: '/tmp/straddle-proj', originator: 'codex_cli_rs' } }),
|
|
line({ type: 'response_item', timestamp: new Date(2026, 6, 27, 23, 57, 0).toISOString(), payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'work through midnight' }] } }),
|
|
line({ type: 'event_msg', timestamp: new Date(2026, 6, 27, 23, 58, 0).toISOString(), payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 1000, output_tokens: 200 }, total_token_usage: { total_tokens: 1200 } } } }),
|
|
line({ type: 'event_msg', timestamp: new Date(2026, 6, 28, 0, 10, 0).toISOString(), payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 2000, output_tokens: 400 }, total_token_usage: { total_tokens: 3600 } } } }),
|
|
].join('\n') + '\n', 'utf-8')
|
|
}
|
|
|
|
// The same straddle through the Claude Code path (scanProjectDirs).
|
|
async function seedStraddlingClaudeTurn(): Promise<void> {
|
|
const projectDir = join(ROOT, 'home', '.claude', 'projects', 'straddle-proj')
|
|
await mkdir(projectDir, { recursive: true })
|
|
const line = (obj: unknown): string => JSON.stringify(obj)
|
|
await writeFile(join(projectDir, 's-straddle.jsonl'), [
|
|
line({ type: 'user', sessionId: 's-straddle', timestamp: new Date(2026, 6, 27, 23, 57, 0).toISOString(), cwd: '/tmp/straddle-proj', message: { role: 'user', content: 'work through midnight' } }),
|
|
line({ type: 'assistant', sessionId: 's-straddle', timestamp: new Date(2026, 6, 27, 23, 58, 0).toISOString(), cwd: '/tmp/straddle-proj', message: { id: 'm1', type: 'message', role: 'assistant', model: 'claude-3-5-sonnet-20241022', content: [], usage: { input_tokens: 1000, output_tokens: 200, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } } }),
|
|
line({ type: 'assistant', sessionId: 's-straddle', timestamp: new Date(2026, 6, 28, 0, 10, 0).toISOString(), cwd: '/tmp/straddle-proj', message: { id: 'm2', type: 'message', role: 'assistant', model: 'claude-3-5-sonnet-20241022', content: [], usage: { input_tokens: 2000, output_tokens: 400, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } } }),
|
|
].join('\n') + '\n', 'utf-8')
|
|
}
|
|
|
|
// The two calls' exact costs from an unfiltered parse (pricing-agnostic truth).
|
|
async function truthCosts(provider: string): Promise<[number, number]> {
|
|
clearSessionCache()
|
|
const projects = await parseAllSessions(undefined, provider)
|
|
const calls = projects.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls)
|
|
expect(calls).toHaveLength(2)
|
|
return [calls[0]!.costUSD, calls[1]!.costUSD]
|
|
}
|
|
|
|
it('keeps day-N + day-N+1 equal to the whole-range totals through buildDurablePeriod', async () => {
|
|
fakeNow()
|
|
try {
|
|
await seedStraddlingCodexTurn()
|
|
const [costN, costN1] = await truthCosts('codex')
|
|
expect(costN + costN1).toBeGreaterThan(0)
|
|
|
|
const range: DateRange = { start: new Date(2026, 6, 27, 0, 0, 0), end: new Date() }
|
|
clearSessionCache()
|
|
const durable = await buildDurablePeriod({ range, label: '2d' }, { provider: 'all' })
|
|
|
|
const dayN = durable.days.find(d => d.date === DAY_N)
|
|
const dayN1 = durable.days.find(d => d.date === DAY_N1)
|
|
// Each side of the turn lands on its own day...
|
|
expect(dayN?.calls).toBe(1)
|
|
expect(dayN1?.calls).toBe(1)
|
|
expect(dayN!.cost).toBeCloseTo(costN, 8)
|
|
expect(dayN1!.cost).toBeCloseTo(costN1, 8)
|
|
// ...and the two sides conserve the whole-range totals (the review's
|
|
// week/month leak returned 1 call and only the pre-midnight cost).
|
|
expect(durable.data.calls).toBe(2)
|
|
expect(dayN!.calls + dayN1!.calls).toBe(durable.data.calls)
|
|
expect(durable.data.cost).toBeCloseTo(costN + costN1, 8)
|
|
expect(dayN!.cost + dayN1!.cost).toBeCloseTo(durable.data.cost, 8)
|
|
expect(durable.data.inputTokens).toBe(3000)
|
|
expect(durable.data.outputTokens).toBe(600)
|
|
expect(dayN!.inputTokens + dayN1!.inputTokens).toBe(durable.data.inputTokens)
|
|
expect(dayN!.outputTokens + dayN1!.outputTokens).toBe(durable.data.outputTokens)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
}, 60_000)
|
|
|
|
it('reconciles By Activity (categories) and daily turns with the headline on the multi-day all-provider view', async () => {
|
|
fakeNow()
|
|
try {
|
|
await seedStraddlingClaudeTurn()
|
|
const [costN, costN1] = await truthCosts('claude')
|
|
|
|
const range: DateRange = { start: new Date(2026, 6, 27, 0, 0, 0), end: new Date() }
|
|
clearSessionCache()
|
|
const durable = await buildDurablePeriod({ range, label: '2d' }, { provider: 'all' })
|
|
|
|
// By Activity must sum to the whole-range headline. Before this fix the
|
|
// today slice came from the unsliced whole-range parse, so the straddling
|
|
// turn's category stayed anchored on its yesterday start and the
|
|
// post-midnight half vanished from By Activity while cost / calls / By
|
|
// Model were already correct; categories summed to only the pre-midnight
|
|
// cost.
|
|
const categoryTotal = durable.data.categories.reduce((s, c) => s + c.cost, 0)
|
|
expect(categoryTotal).toBeCloseTo(costN + costN1, 8)
|
|
expect(categoryTotal).toBeCloseTo(durable.data.cost, 8)
|
|
|
|
// Today's day entry carries its turn's category, not calls>0 with an empty
|
|
// category map (which rendered as `turns: 0` in the JSON daily rows).
|
|
const dayN1 = durable.days.find(d => d.date === DAY_N1)
|
|
expect(dayN1?.calls).toBe(1)
|
|
expect(Object.keys(dayN1?.categories ?? {}).length).toBeGreaterThan(0)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
}, 60_000)
|
|
|
|
it('shows the post-midnight call in the today-only view on the Claude Code path', async () => {
|
|
fakeNow()
|
|
try {
|
|
await seedStraddlingClaudeTurn()
|
|
const [, costN1] = await truthCosts('claude')
|
|
|
|
clearSessionCache()
|
|
const durable = await buildDurablePeriod({ range: getDateRange('today').range, label: 'today' }, { provider: 'all' })
|
|
expect(durable.data.calls).toBe(1)
|
|
expect(durable.data.cost).toBeCloseTo(costN1, 8)
|
|
expect(durable.data.inputTokens).toBe(2000)
|
|
expect(durable.data.outputTokens).toBe(400)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
}, 60_000)
|
|
|
|
it('slices the straddling turn per call in the dashboard/menubar surface filters', async () => {
|
|
fakeNow()
|
|
try {
|
|
await seedStraddlingClaudeTurn()
|
|
clearSessionCache()
|
|
const all = await parseAllSessions(undefined, 'claude')
|
|
const callsOf = (ps: typeof all) => ps.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls)
|
|
const inputOf = (ps: typeof all) => ps.flatMap(p => p.sessions).reduce((s, sess) => s + sess.totalInputTokens, 0)
|
|
|
|
// Dashboard Today/7-Days narrowing over an unfiltered parse.
|
|
const dashToday = filterProjectsByDateRange(all, getDateRange('today').range)
|
|
expect(callsOf(dashToday)).toHaveLength(1)
|
|
expect(inputOf(dashToday)).toBe(2000)
|
|
|
|
// Menubar/history day selection, on each side of midnight.
|
|
const menubarToday = filterProjectsByDays(all, new Set([DAY_N1]))
|
|
expect(callsOf(menubarToday)).toHaveLength(1)
|
|
expect(inputOf(menubarToday)).toBe(2000)
|
|
|
|
const menubarYesterday = filterProjectsByDays(all, new Set([DAY_N]))
|
|
expect(callsOf(menubarYesterday)).toHaveLength(1)
|
|
expect(inputOf(menubarYesterday)).toBe(1000)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
}, 60_000)
|
|
})
|