mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-21 05:23:19 +00:00
* Add per-day cost for both sides to the compare-periods report * Rebuild the Compare periods screen around a lead sentence, day bars and top movers * Update the Compare periods doc for the new layout * Extract the duplicated chart tooltip into one ChartTip component * Make the Compare periods sentence, tiles, day chart and movers table say what they measure * Cover the compare lead sentence rules and the percent, date and path formats * Update the Compare periods doc for the new day chart * Style the Compare periods Swap button as an outlined pill instead of a solid grey block * Fix the round-2 findings on the Compare periods chart, range label, Swap button and duration * Fix single-day range label doubling and lone day bar width in Compare periods rangeLabel now short-circuits from === to like terseRangeLabel already did. pcmp-bar uses flex 0 1 50% so a lone bar in a slot takes half the slot instead of the full slot, matching paired bars. * Keep a lone day bar the same width as a paired one
126 lines
5 KiB
TypeScript
126 lines
5 KiB
TypeScript
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
|
||
import { tmpdir } from 'os'
|
||
import { join } from 'path'
|
||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||
|
||
import { loadPricing } from '../src/models.js'
|
||
import { parseAllSessions, clearSessionCache } from '../src/parser.js'
|
||
import { buildPeriodDiffReport, diffSessions } from '../src/period-diff.js'
|
||
import type { DateRange } from '../src/types.js'
|
||
|
||
// End-to-end proof that a session with activity on BOTH sides of the A/B
|
||
// boundary is attributed at the parser's real granularity (per call, issue
|
||
// #852), and that the period-diff engine joins the two halves into one row.
|
||
|
||
let base: string
|
||
|
||
beforeEach(async () => {
|
||
base = await mkdtemp(join(tmpdir(), 'codeburn-period-diff-'))
|
||
process.env['CLAUDE_CONFIG_DIR'] = base
|
||
await loadPricing()
|
||
})
|
||
|
||
afterEach(async () => {
|
||
delete process.env['CLAUDE_CONFIG_DIR']
|
||
clearSessionCache()
|
||
await rm(base, { recursive: true, force: true })
|
||
})
|
||
|
||
function line(timestamp: string, messageId: string, inputTokens: number): string {
|
||
return JSON.stringify({
|
||
type: 'assistant',
|
||
sessionId: 'straddle',
|
||
timestamp,
|
||
message: {
|
||
id: messageId,
|
||
type: 'message',
|
||
role: 'assistant',
|
||
model: 'claude-sonnet-4-5',
|
||
content: [{ type: 'text', text: 'ok' }],
|
||
usage: { input_tokens: inputTokens, output_tokens: 100 },
|
||
},
|
||
})
|
||
}
|
||
|
||
// One session, two turns: local Apr 16 10:00 (side A) and Apr 17 10:00 (side B).
|
||
async function writeStraddlingSession(): Promise<void> {
|
||
const projectDir = join(base, 'projects', 'work')
|
||
await mkdir(projectDir, { recursive: true })
|
||
const tsA = new Date(2026, 3, 16, 10, 0, 0).toISOString()
|
||
const tsB = new Date(2026, 3, 17, 10, 0, 0).toISOString()
|
||
await writeFile(
|
||
join(projectDir, 'straddle.jsonl'),
|
||
[line(tsA, 'msg-a', 1000), line(tsB, 'msg-b', 3000)].join('\n') + '\n',
|
||
'utf-8',
|
||
)
|
||
}
|
||
|
||
function dayRange(y: number, m: number, d: number): DateRange {
|
||
return { start: new Date(y, m, d), end: new Date(y, m, d, 23, 59, 59, 999) }
|
||
}
|
||
|
||
describe('period diff over a real parse', () => {
|
||
it('splits a boundary-straddling session by call and reconciles the diff', async () => {
|
||
await writeStraddlingSession()
|
||
const rangeA = dayRange(2026, 3, 16)
|
||
const rangeB = dayRange(2026, 3, 17)
|
||
|
||
const projectsA = await parseAllSessions(rangeA, 'all')
|
||
const projectsB = await parseAllSessions(rangeB, 'all')
|
||
|
||
// Each range holds the SAME session with only ITS calls (post-slice).
|
||
const sessionA = projectsA[0]!.sessions[0]!
|
||
const sessionB = projectsB[0]!.sessions[0]!
|
||
expect(sessionA.sessionId).toBe('straddle')
|
||
expect(sessionB.sessionId).toBe('straddle')
|
||
const costA = sessionA.totalCostUSD
|
||
const costB = sessionB.totalCostUSD
|
||
expect(costA).toBeGreaterThan(0)
|
||
expect(costB).toBeGreaterThan(costA) // Apr 17's call carries more input tokens.
|
||
|
||
const report = buildPeriodDiffReport({
|
||
provider: 'all',
|
||
rangeA: { from: '2026-04-16', to: '2026-04-16' },
|
||
rangeB: { from: '2026-04-17', to: '2026-04-17' },
|
||
projectsA,
|
||
projectsB,
|
||
})
|
||
// Same canonical project on both sides → one contribution row, diff = B − A.
|
||
expect(report.projects).toHaveLength(1)
|
||
expect(report.projects[0]!.costA).toBeCloseTo(costA, 10)
|
||
expect(report.projects[0]!.costB).toBeCloseTo(costB, 10)
|
||
expect(report.totals.diff.cost).toBeCloseTo(costB - costA, 10)
|
||
expect(lensSum(report.projects)).toBeCloseTo(report.totals.diff.cost, 10)
|
||
|
||
// Drill-down: ONE row for the straddling session, cost on both sides.
|
||
const rows = diffSessions(projectsA, projectsB, 'project', projectsA[0]!.project)
|
||
expect(rows).toHaveLength(1)
|
||
expect(rows[0]!.sessionId).toBe('straddle')
|
||
expect(rows[0]!.costA).toBeCloseTo(costA, 10)
|
||
expect(rows[0]!.costB).toBeCloseTo(costB, 10)
|
||
})
|
||
|
||
it('reports cost per local day for both sides, zero-filled across the range', async () => {
|
||
await writeStraddlingSession()
|
||
const projectsA = await parseAllSessions({ start: new Date(2026, 3, 15), end: new Date(2026, 3, 17, 23, 59, 59, 999) }, 'all')
|
||
const projectsB = await parseAllSessions({ start: new Date(2026, 3, 18), end: new Date(2026, 3, 20, 23, 59, 59, 999) }, 'all')
|
||
const report = buildPeriodDiffReport({
|
||
provider: 'all',
|
||
rangeA: { from: '2026-04-15', to: '2026-04-17' },
|
||
rangeB: { from: '2026-04-18', to: '2026-04-20' },
|
||
projectsA,
|
||
projectsB,
|
||
})
|
||
expect(report.daily.A.map(day => day.date)).toEqual(['2026-04-15', '2026-04-16', '2026-04-17'])
|
||
expect(report.daily.B.map(day => day.date)).toEqual(['2026-04-18', '2026-04-19', '2026-04-20'])
|
||
expect(report.daily.A[0]!.cost).toBe(0)
|
||
expect(report.daily.A[1]!.cost).toBeGreaterThan(0)
|
||
expect(report.daily.A[2]!.cost).toBeGreaterThan(report.daily.A[1]!.cost)
|
||
expect(report.daily.A.reduce((sum, day) => sum + day.cost, 0)).toBeCloseTo(report.totals.A.cost, 10)
|
||
expect(report.daily.B.map(day => day.cost)).toEqual([0, 0, 0])
|
||
})
|
||
})
|
||
|
||
function lensSum(rows: Array<{ diff: number }>): number {
|
||
return rows.reduce((s, row) => s + row.diff, 0)
|
||
}
|