codeburn/tests/cli-json-daily.test.ts
Resham Joshi 5800179da9
Add multi-day calendar selection with custom popover UI (#393)
* Add multi-day calendar selection with custom popover UI

Replace native DatePicker with a custom-themed CalendarPopover supporting
non-contiguous multi-day selection. Add --days CLI flag for comma-separated
date filtering that post-filters from the bounding range scan.

CLI: parseDaysFlag() parses and validates dates, filterProjectsByDays()
filters turns to only selected days. Verified that sum of individual --day
calls matches --days output across cost, calls, activities, models, and
providers.

Swift: DataClient passes --days for multi-day, with single-day fallback.
AppStore tracks selectedDays as Set<String> with PayloadCacheKey support.
CalendarPopover uses pending state committed only on Done tap.

* Clear + Done in calendar resets to current period

When user clears all selected days and taps Done, switch back to the
active period instead of silently keeping the old day selection.
2026-05-24 09:49:59 -07:00

237 lines
7.8 KiB
TypeScript

import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
function runCli(args: string[], home: string) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
cwd: process.cwd(),
env: {
...process.env,
CLAUDE_CONFIG_DIR: join(home, '.claude'),
HOME: home,
TZ: 'UTC',
},
encoding: 'utf-8',
})
}
function userLine(sessionId: string, timestamp: string): string {
return JSON.stringify({
type: 'user',
sessionId,
timestamp,
message: { role: 'user', content: 'do the thing' },
})
}
function assistantEditLine(sessionId: string, timestamp: string, messageId: string): string {
// Includes a tool_use of `Edit` so the parser flags this turn as hasEdits=true.
// Single edit-turn with no retry (one assistant message in the turn) → counts
// as one oneShotTurn.
return JSON.stringify({
type: 'assistant',
sessionId,
timestamp,
message: {
id: messageId,
type: 'message',
role: 'assistant',
model: 'claude-sonnet-4-5',
content: [
{ type: 'text', text: 'editing' },
{ type: 'tool_use', id: 'tu-1', name: 'Edit', input: { file_path: '/tmp/x', old_string: 'a', new_string: 'b' } },
],
usage: { input_tokens: 1000, output_tokens: 100 },
},
})
}
function assistantNoEditLine(sessionId: string, timestamp: string, messageId: string): string {
// No edit tool — this turn does not count toward editTurns/oneShotTurns,
// but does count toward `turns` and `calls`.
return JSON.stringify({
type: 'assistant',
sessionId,
timestamp,
message: {
id: messageId,
type: 'message',
role: 'assistant',
model: 'claude-sonnet-4-5',
content: [{ type: 'text', text: 'just chatting' }],
usage: { input_tokens: 200, output_tokens: 30 },
},
})
}
describe('codeburn report --format json daily[] one-shot fields (issue #279)', () => {
it('exposes per-day turns / editTurns / oneShotTurns / oneShotRate', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-json-daily-'))
try {
const projectDir = join(home, '.claude', 'projects', 'app')
await mkdir(projectDir, { recursive: true })
// Day 1 (2026-04-10): one edit-turn (one-shot), one chat-turn
// Day 2 (2026-04-11): one edit-turn (one-shot)
await writeFile(
join(projectDir, 'session.jsonl'),
[
userLine('s1', '2026-04-10T09:00:00Z'),
assistantEditLine('s1', '2026-04-10T09:01:00Z', 'm-d1-edit'),
userLine('s1', '2026-04-10T10:00:00Z'),
assistantNoEditLine('s1', '2026-04-10T10:01:00Z', 'm-d1-chat'),
userLine('s1', '2026-04-11T09:00:00Z'),
assistantEditLine('s1', '2026-04-11T09:01:00Z', 'm-d2-edit'),
].join('\n'),
)
const result = runCli([
'--format', 'json',
'--from', '2026-04-10',
'--to', '2026-04-11',
'--provider', 'claude',
], home)
expect(result.status).toBe(0)
const report = JSON.parse(result.stdout) as {
daily: Array<{
date: string
cost: number
calls: number
turns: number
editTurns: number
oneShotTurns: number
oneShotRate: number | null
}>
}
expect(report.daily).toHaveLength(2)
const day1 = report.daily.find(d => d.date === '2026-04-10')
expect(day1).toBeDefined()
expect(day1!.turns).toBe(2)
expect(day1!.editTurns).toBe(1)
expect(day1!.oneShotTurns).toBe(1)
expect(day1!.oneShotRate).toBe(100)
const day2 = report.daily.find(d => d.date === '2026-04-11')
expect(day2).toBeDefined()
expect(day2!.turns).toBe(1)
expect(day2!.editTurns).toBe(1)
expect(day2!.oneShotTurns).toBe(1)
expect(day2!.oneShotRate).toBe(100)
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('reports null oneShotRate when the day has no edit turns', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-json-daily-'))
try {
const projectDir = join(home, '.claude', 'projects', 'app')
await mkdir(projectDir, { recursive: true })
await writeFile(
join(projectDir, 'chat-only.jsonl'),
[
userLine('s2', '2026-04-10T09:00:00Z'),
assistantNoEditLine('s2', '2026-04-10T09:01:00Z', 'm-chat-1'),
userLine('s2', '2026-04-10T09:30:00Z'),
assistantNoEditLine('s2', '2026-04-10T09:31:00Z', 'm-chat-2'),
].join('\n'),
)
const result = runCli([
'--format', 'json',
'--from', '2026-04-10',
'--to', '2026-04-10',
'--provider', 'claude',
], home)
expect(result.status).toBe(0)
const report = JSON.parse(result.stdout) as {
daily: Array<{ date: string; turns: number; editTurns: number; oneShotTurns: number; oneShotRate: number | null }>
}
const day = report.daily.find(d => d.date === '2026-04-10')!
expect(day.turns).toBe(2)
expect(day.editTurns).toBe(0)
expect(day.oneShotTurns).toBe(0)
// null, not 0 — the rate is undefined when no edits happened, and a
// chat-only day would otherwise read as 0% one-shot which is misleading.
expect(day.oneShotRate).toBeNull()
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('filters a single review day with --day', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-json-day-'))
try {
const projectDir = join(home, '.claude', 'projects', 'app')
await mkdir(projectDir, { recursive: true })
// runCli pins TZ=UTC, so these exact boundaries exercise the inclusive
// start/end of the selected calendar day without local-offset drift.
await writeFile(
join(projectDir, 'day-selector.jsonl'),
[
userLine('day-before', '2026-04-09T23:59:00Z'),
assistantNoEditLine('day-before', '2026-04-09T23:59:30Z', 'm-before'),
userLine('day-selected', '2026-04-10T00:00:00Z'),
assistantEditLine('day-selected', '2026-04-10T23:59:59Z', 'm-selected'),
userLine('day-after', '2026-04-11T00:00:00Z'),
assistantNoEditLine('day-after', '2026-04-11T00:00:30Z', 'm-after'),
].join('\n'),
)
const result = runCli([
'--format', 'json',
'--day', '2026-04-10',
'--provider', 'claude',
], home)
expect(result.status).toBe(0)
const report = JSON.parse(result.stdout) as {
period: string
periodKey: string
daily: Array<{ date: string; calls: number; editTurns: number }>
projects: Array<{ sessions: number; calls: number }>
}
expect(report.period).toBe('Day (2026-04-10)')
expect(report.periodKey).toBe('day')
expect(report.daily.map(d => d.date)).toEqual(['2026-04-10'])
expect(report.daily[0]?.calls).toBe(1)
expect(report.daily[0]?.editTurns).toBe(1)
expect(report.projects[0]?.sessions).toBe(1)
expect(report.projects[0]?.calls).toBe(1)
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('rejects --day combined with --from/--to', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-json-day-'))
try {
const result = runCli([
'--format', 'json',
'--day', '2026-04-10',
'--from', '2026-04-10',
'--provider', 'claude',
], home)
expect(result.status).toBe(1)
expect(result.stderr).toContain('--day cannot be combined with --from or --to')
} finally {
await rm(home, { recursive: true, force: true })
}
})
})