codeburn/tests/overview.test.ts
ozymandiashh a12db6e549
Show real Claude project leaf names; stop stray-.git over-grouping (#540)
The 'By Project' panel collapsed sibling Claude Code projects to a parent
folder in two cases:

1) resolveCanonicalProjectPath walked up to ANY ancestor .git and used it as
   the canonical project. A stray .git in a parent of several projects (a
   dotfiles bare repo, an accidental git init) made every sibling resolve to
   that parent ('Projects'/'home'). Now only a real linked worktree (.git is a
   FILE pointing at <main>/.git/worktrees/<name>) canonicalizes to its main
   repo; an ordinary repo or a stray ancestor .git keeps the session's own cwd
   as the project. Genuine worktree grouping is preserved.

2) When a Claude session has no cwd metadata, the dir slug was unsanitized by
   replacing every '-' with '/', inventing path segments that overview then
   split on, so 'Projects-Content-OS' rendered as 'os'. The lossy slug is now
   kept intact, and overview only basenames ABSOLUTE paths.

Fixes #493
2026-06-21 22:42:19 +02:00

118 lines
4.1 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect } from 'vitest'
import { renderOverview } from '../src/overview.js'
import type { ProjectSummary } from '../src/types.js'
function makeProject(opts: {
project: string
projectPath: string
cost: number
calls: number
model: string
provider: string
tokens: { input: number; output: number; cacheR: number; cacheW: number }
}): ProjectSummary {
const usage = {
inputTokens: opts.tokens.input,
outputTokens: opts.tokens.output,
cacheReadInputTokens: opts.tokens.cacheR,
cacheCreationInputTokens: opts.tokens.cacheW,
}
return {
project: opts.project,
projectPath: opts.projectPath,
totalCostUSD: opts.cost,
totalSavingsUSD: 0,
totalProxiedCostUSD: 0,
totalApiCalls: opts.calls,
sessions: [{
sessionId: 's1',
project: opts.project,
totalInputTokens: opts.tokens.input,
totalOutputTokens: opts.tokens.output,
totalCacheReadTokens: opts.tokens.cacheR,
totalCacheWriteTokens: opts.tokens.cacheW,
apiCalls: opts.calls,
modelBreakdown: { [opts.model]: { calls: opts.calls, costUSD: opts.cost, savingsUSD: 0, tokens: usage } },
categoryBreakdown: { coding: { turns: 1, costUSD: opts.cost, savingsUSD: 0, retries: 0, editTurns: 1, oneShotTurns: 1 } },
toolBreakdown: { Bash: { calls: 5 }, Read: { calls: 2 } },
mcpBreakdown: {},
bashBreakdown: {},
skillBreakdown: {},
subagentBreakdown: {},
turns: [{
userMessage: 'hi',
timestamp: '2026-06-15T10:00:00Z',
sessionId: 's1',
category: 'coding',
retries: 0,
hasEdits: true,
assistantCalls: [{ provider: opts.provider, model: opts.model, costUSD: opts.cost, usage }],
}],
}],
} as unknown as ProjectSummary
}
describe('renderOverview', () => {
it('renders the detailed sections from real aggregation', () => {
const out = renderOverview([makeProject({
project: 'myproject',
projectPath: '/Users/test/myproject',
cost: 12.5,
calls: 3,
model: 'claude-opus-4-8',
provider: 'claude',
tokens: { input: 1000, output: 200, cacheR: 5000, cacheW: 100 },
})], { label: 'June 2026', color: false })
for (const section of ['Totals', 'By tool', 'Top models', 'Highest-value days', 'Top projects', 'Daily', 'By activity', 'Tools']) {
expect(out).toContain(section)
}
expect(out).toContain('Opus 4.8') // model display name
expect(out).toContain('claude') // provider in By tool
expect(out).toContain('myproject') // clean project name from path basename
expect(out).toContain('$12.50')
expect(out).toContain('2026-06-15')
expect(out).toContain('Coding')
expect(out).toContain('Bash')
})
it('uses thousands separators and a B unit, and strips color in no-color mode', () => {
const out = renderOverview([makeProject({
project: 'big',
projectPath: '/Users/test/big',
cost: 1234.56,
calls: 10,
model: 'claude-opus-4-8',
provider: 'claude',
tokens: { input: 1_000_000, output: 1_000_000, cacheR: 2_000_000_000, cacheW: 0 },
})], { label: 'June 2026', color: false })
expect(out).toContain('$1,234.56')
// tokens render as full, comma-grouped numbers (not abbreviated)
expect(out).toContain('2,002,000,000')
// no-color mode must not emit ANSI escape codes
// eslint-disable-next-line no-control-regex
expect(out).not.toMatch(/\[/)
})
it('reports no usage for an empty range', () => {
const out = renderOverview([], { label: 'June 2026', color: false })
expect(out).toContain('No usage found for June 2026')
})
it('does not split a slug-only Claude project path into fake path segments', () => {
const out = renderOverview([makeProject({
project: 'Projects-Content-OS',
projectPath: 'Projects/Content/OS',
cost: 3.25,
calls: 1,
model: 'claude-sonnet-4-5',
provider: 'claude',
tokens: { input: 1000, output: 200, cacheR: 0, cacheW: 0 },
})], { label: 'June 2026', color: false })
expect(out).toContain('Projects-Content-OS')
expect(out).not.toContain(' OS ')
})
})