mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-04 22:01:25 +00:00
fix(daily-cache): keep prototype-named project keys; make filter tests date-deterministic
Two issues on top of the --project/--exclude durable-headline fix: - sanitizeProjects dropped any project whose key is an Object.prototype member name (constructor, valueOf, __proto__, ...). A project key is a directory basename, so such a name is legitimate, and dropping it left the day's per-project split summing to less than the day cost — so the sliced, project-filtered headline silently lost that project's spend with no footnote. The keys are written via setOwn (defineProperty), so keeping them is pollution-safe; only the redundant `name in Object.prototype` guard is removed. Regression test added (mutation-checked: fails without the guard removed). - The new project-filter tests seeded a carried day 10 days ago but ranged over the calendar month, so within the first 10 days of a month that day fell out of range and the tests went red. Replaced with a fixed 20-day window that always spans the seeded day.
This commit is contained in:
parent
4bfcc020f6
commit
a769b5008a
2 changed files with 51 additions and 14 deletions
|
|
@ -257,7 +257,13 @@ function sanitizeProjects(raw: unknown): { projects?: DailyEntry['projects'] } {
|
||||||
if (!isRecord(raw)) return {}
|
if (!isRecord(raw)) return {}
|
||||||
const out: NonNullable<DailyEntry['projects']> = {}
|
const out: NonNullable<DailyEntry['projects']> = {}
|
||||||
for (const [name, p] of Object.entries(raw)) {
|
for (const [name, p] of Object.entries(raw)) {
|
||||||
if (name in Object.prototype || !isRecord(p)) continue
|
// A project key is a directory basename, so it can legitimately be a
|
||||||
|
// prototype-member name ("constructor", "valueOf", ...). `setOwn` writes it
|
||||||
|
// as an own property via defineProperty, so keeping it is pollution-safe —
|
||||||
|
// and dropping it would silently subtract that project's cost from a
|
||||||
|
// --project/--exclude total (the day's split would no longer sum to its own
|
||||||
|
// cost, which the filtered headline relies on).
|
||||||
|
if (!isRecord(p)) continue
|
||||||
setOwn(out, name, {
|
setOwn(out, name, {
|
||||||
cost: num(p.cost),
|
cost: num(p.cost),
|
||||||
calls: num(p.calls),
|
calls: num(p.calls),
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { tmpdir } from 'os'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
|
|
||||||
import { DAILY_CACHE_VERSION, currentTzKey, type DailyCache, type DailyEntry } from '../src/daily-cache.js'
|
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 { loadPricing } from '../src/models.js'
|
||||||
import { buildDurablePeriod, buildMenubarPayloadForRange, buildPeriodData, getDailyCacheConfigHash } from '../src/usage-aggregator.js'
|
import { buildDurablePeriod, buildMenubarPayloadForRange, buildPeriodData, getDailyCacheConfigHash } from '../src/usage-aggregator.js'
|
||||||
import { parseAllSessions, filterProjectsByName, clearSessionCache } from '../src/parser.js'
|
import { parseAllSessions, filterProjectsByName, clearSessionCache } from '../src/parser.js'
|
||||||
|
|
@ -148,13 +147,20 @@ afterEach(async () => {
|
||||||
if (existsSync(ROOT)) await rm(ROOT, { recursive: true, force: true })
|
if (existsSync(ROOT)) await rm(ROOT, { recursive: true, force: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
const monthRange = (): DateRange => getDateRange('month').range
|
// A fixed 20-day window back from now: it always spans the carried day (10 days
|
||||||
|
// ago) and today, so the period never depends on where "now" falls in the
|
||||||
|
// calendar. A real `month` range drops the 10-days-ago day whenever today is
|
||||||
|
// within 10 days of the 1st, which made these tests flake early in each month.
|
||||||
|
const coveringRange = (): DateRange => ({
|
||||||
|
start: new Date(Date.now() - 20 * 24 * 60 * 60 * 1000),
|
||||||
|
end: new Date(),
|
||||||
|
})
|
||||||
|
|
||||||
describe('durable headline honours --project / --exclude on carried days', () => {
|
describe('durable headline honours --project / --exclude on carried days', () => {
|
||||||
it('drops an excluded project from the headline so it reconciles with the By Project panel', async () => {
|
it('drops an excluded project from the headline so it reconciles with the By Project panel', async () => {
|
||||||
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
|
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
|
||||||
await seedLiveTodaySession()
|
await seedLiveTodaySession()
|
||||||
const range = monthRange()
|
const range = coveringRange()
|
||||||
|
|
||||||
const live = await liveOnly(range, [], ['drop-me'])
|
const live = await liveOnly(range, [], ['drop-me'])
|
||||||
clearSessionCache()
|
clearSessionCache()
|
||||||
|
|
@ -172,7 +178,7 @@ describe('durable headline honours --project / --exclude on carried days', () =>
|
||||||
it('keeps only the named project when --project is given', async () => {
|
it('keeps only the named project when --project is given', async () => {
|
||||||
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
|
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
|
||||||
await seedLiveTodaySession()
|
await seedLiveTodaySession()
|
||||||
const range = monthRange()
|
const range = coveringRange()
|
||||||
|
|
||||||
const live = await liveOnly(range, ['keep-me'], [])
|
const live = await liveOnly(range, ['keep-me'], [])
|
||||||
clearSessionCache()
|
clearSessionCache()
|
||||||
|
|
@ -185,7 +191,7 @@ describe('durable headline honours --project / --exclude on carried days', () =>
|
||||||
it('matches a filter against the project path as well as its name', async () => {
|
it('matches a filter against the project path as well as its name', async () => {
|
||||||
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
|
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
|
||||||
await seedLiveTodaySession()
|
await seedLiveTodaySession()
|
||||||
const range = monthRange()
|
const range = coveringRange()
|
||||||
|
|
||||||
const live = await liveOnly(range, [], ['/Users/gone/drop-me'])
|
const live = await liveOnly(range, [], ['/Users/gone/drop-me'])
|
||||||
clearSessionCache()
|
clearSessionCache()
|
||||||
|
|
@ -194,10 +200,35 @@ describe('durable headline honours --project / --exclude on carried days', () =>
|
||||||
expect(durable.data.cost).toBeCloseTo(live.cost + KEEP.cost, 6)
|
expect(durable.data.cost).toBeCloseTo(live.cost + KEEP.cost, 6)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps a project whose name is a prototype member (constructor) in the sliced total', async () => {
|
||||||
|
// A project directory can legitimately be named "constructor"/"valueOf"/etc.
|
||||||
|
// The day cache must carry it as an own key: if the load path drops it, the
|
||||||
|
// day's per-project split no longer sums to the day cost, and the sliced
|
||||||
|
// headline silently loses that project's spend.
|
||||||
|
const day = carriedDayWithProjects(daysAgoStr(10))
|
||||||
|
const protoName = 'constructor'
|
||||||
|
Object.defineProperty(day.projects!, protoName, {
|
||||||
|
value: { cost: 25, calls: 5, savingsUSD: 0, sessions: 1, path: '/Users/gone/constructor' },
|
||||||
|
enumerable: true, writable: true, configurable: true,
|
||||||
|
})
|
||||||
|
day.cost += 25
|
||||||
|
day.calls += 5
|
||||||
|
await seedCache(day)
|
||||||
|
await seedLiveTodaySession()
|
||||||
|
const range = coveringRange()
|
||||||
|
|
||||||
|
// Keep everything (exclude a non-matching term): the constructor project's
|
||||||
|
// $25 must be present alongside keep-me and drop-me.
|
||||||
|
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['zzz-nomatch'] })
|
||||||
|
const live = await liveOnly(range, [], ['zzz-nomatch'])
|
||||||
|
expect(durable.data.cost).toBeCloseTo(live.cost + DAY_COST + 25, 6)
|
||||||
|
expect(durable.unattributedCostUSD).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
it('contributes nothing from a carried day whose every project is excluded', async () => {
|
it('contributes nothing from a carried day whose every project is excluded', async () => {
|
||||||
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
|
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
|
||||||
await seedLiveTodaySession()
|
await seedLiveTodaySession()
|
||||||
const range = monthRange()
|
const range = coveringRange()
|
||||||
|
|
||||||
const live = await liveOnly(range, [], ['keep-me', 'drop-me'])
|
const live = await liveOnly(range, [], ['keep-me', 'drop-me'])
|
||||||
clearSessionCache()
|
clearSessionCache()
|
||||||
|
|
@ -210,7 +241,7 @@ describe('durable headline honours --project / --exclude on carried days', () =>
|
||||||
it('applies the project filter underneath a provider filter', async () => {
|
it('applies the project filter underneath a provider filter', async () => {
|
||||||
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
|
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
|
||||||
await seedLiveTodaySession()
|
await seedLiveTodaySession()
|
||||||
const range = monthRange()
|
const range = coveringRange()
|
||||||
|
|
||||||
// Stated as the delta the filter must produce, so the assertion holds
|
// Stated as the delta the filter must produce, so the assertion holds
|
||||||
// whatever the provider-scoped live parse contributes.
|
// whatever the provider-scoped live parse contributes.
|
||||||
|
|
@ -226,7 +257,7 @@ describe('durable headline honours --project / --exclude on carried days', () =>
|
||||||
it('keeps the menubar payload in step with the report under a project filter', async () => {
|
it('keeps the menubar payload in step with the report under a project filter', async () => {
|
||||||
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
|
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
|
||||||
await seedLiveTodaySession()
|
await seedLiveTodaySession()
|
||||||
const range = monthRange()
|
const range = coveringRange()
|
||||||
|
|
||||||
clearSessionCache()
|
clearSessionCache()
|
||||||
const menubar = await buildMenubarPayloadForRange({ range, label: 'p' }, { provider: 'all', exclude: ['drop-me'], optimize: false, timeline: false })
|
const menubar = await buildMenubarPayloadForRange({ range, label: 'p' }, { provider: 'all', exclude: ['drop-me'], optimize: false, timeline: false })
|
||||||
|
|
@ -243,7 +274,7 @@ describe('durable headline honours --project / --exclude on carried days', () =>
|
||||||
it('leaves the unfiltered headline exactly as it was', async () => {
|
it('leaves the unfiltered headline exactly as it was', async () => {
|
||||||
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
|
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
|
||||||
await seedLiveTodaySession()
|
await seedLiveTodaySession()
|
||||||
const range = monthRange()
|
const range = coveringRange()
|
||||||
|
|
||||||
const live = await liveOnly(range, [], [])
|
const live = await liveOnly(range, [], [])
|
||||||
clearSessionCache()
|
clearSessionCache()
|
||||||
|
|
@ -259,7 +290,7 @@ describe('carried days with no per-project split (pre-v15)', () => {
|
||||||
it('sets the unfilterable day aside instead of leaking it into a filtered headline', async () => {
|
it('sets the unfilterable day aside instead of leaking it into a filtered headline', async () => {
|
||||||
await seedCache(carriedDayWithoutProjects(daysAgoStr(10)))
|
await seedCache(carriedDayWithoutProjects(daysAgoStr(10)))
|
||||||
await seedLiveTodaySession()
|
await seedLiveTodaySession()
|
||||||
const range = monthRange()
|
const range = coveringRange()
|
||||||
|
|
||||||
const live = await liveOnly(range, [], ['drop-me'])
|
const live = await liveOnly(range, [], ['drop-me'])
|
||||||
clearSessionCache()
|
clearSessionCache()
|
||||||
|
|
@ -279,7 +310,7 @@ describe('carried days with no per-project split (pre-v15)', () => {
|
||||||
delete day.providers['claude']!.projects
|
delete day.providers['claude']!.projects
|
||||||
await seedCache(day)
|
await seedCache(day)
|
||||||
await seedLiveTodaySession()
|
await seedLiveTodaySession()
|
||||||
const range = monthRange()
|
const range = coveringRange()
|
||||||
|
|
||||||
clearSessionCache()
|
clearSessionCache()
|
||||||
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'claude', exclude: ['drop-me'] })
|
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'claude', exclude: ['drop-me'] })
|
||||||
|
|
@ -291,7 +322,7 @@ describe('carried days with no per-project split (pre-v15)', () => {
|
||||||
it('says so in the overview instead of just showing a short total', async () => {
|
it('says so in the overview instead of just showing a short total', async () => {
|
||||||
await seedCache(carriedDayWithoutProjects(daysAgoStr(10)))
|
await seedCache(carriedDayWithoutProjects(daysAgoStr(10)))
|
||||||
await seedLiveTodaySession()
|
await seedLiveTodaySession()
|
||||||
const range = monthRange()
|
const range = coveringRange()
|
||||||
|
|
||||||
clearSessionCache()
|
clearSessionCache()
|
||||||
const durable = await buildDurablePeriod({ range, label: 'This month' }, { provider: 'all', exclude: ['drop-me'] })
|
const durable = await buildDurablePeriod({ range, label: 'This month' }, { provider: 'all', exclude: ['drop-me'] })
|
||||||
|
|
@ -320,7 +351,7 @@ describe('carried days with no per-project split (pre-v15)', () => {
|
||||||
it('still counts the day in full when no project filter is active', async () => {
|
it('still counts the day in full when no project filter is active', async () => {
|
||||||
await seedCache(carriedDayWithoutProjects(daysAgoStr(10)))
|
await seedCache(carriedDayWithoutProjects(daysAgoStr(10)))
|
||||||
await seedLiveTodaySession()
|
await seedLiveTodaySession()
|
||||||
const range = monthRange()
|
const range = coveringRange()
|
||||||
|
|
||||||
const live = await liveOnly(range, [], [])
|
const live = await liveOnly(range, [], [])
|
||||||
clearSessionCache()
|
clearSessionCache()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue