daili-usage-keeper / web /src /components /usage /CostTrendChart.test.ts
pjpjq's picture
fix: build usage keeper from source
b034029 verified
import { describe, expect, it } from 'vitest';
import { buildOverviewCostTrendSeries, shouldShowCostPricingHint } from './CostTrendChart';
import type { UsageOverviewResponse } from '@/lib/types';
const formatTestLocalDayKey = (date: Date): string => {
const pad = (value: number) => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
};
const usageWithBackendCost: UsageOverviewResponse = {
usage: {
total_requests: 9,
success_count: 8,
failure_count: 1,
total_tokens: 900,
requests_by_day: {},
requests_by_hour: {},
tokens_by_day: {},
tokens_by_hour: {},
apis: {},
},
summary: {
request_count: 9,
token_count: 900,
window_minutes: 1440,
rpm: 0.5,
tpm: 10,
total_cost: 1.23,
cost_available: true,
cached_tokens: 0,
reasoning_tokens: 0,
},
series: {
requests: {
'2026-04-22': 3,
},
tokens: {
'2026-04-22': 300,
},
rpm: {
'2026-04-22': 3 / 1440,
},
tpm: {
'2026-04-22': 300 / 1440,
},
cost: {
'2026-04-22': 0.46,
},
input_tokens: {},
output_tokens: {},
cached_tokens: {},
reasoning_tokens: {},
},
hourly_series: {
requests: {
'2026-04-22T09:00:00Z': 1,
'2026-04-22T10:00:00Z': 2,
},
tokens: {
'2026-04-22T09:00:00Z': 100,
'2026-04-22T10:00:00Z': 200,
},
rpm: {
'2026-04-22T09:00:00Z': 1 / 60,
'2026-04-22T10:00:00Z': 2 / 60,
},
tpm: {
'2026-04-22T09:00:00Z': 100 / 60,
'2026-04-22T10:00:00Z': 200 / 60,
},
cost: {
'2026-04-22T09:00:00Z': 0.12,
'2026-04-22T10:00:00Z': 0.34,
},
input_tokens: {},
output_tokens: {},
cached_tokens: {},
reasoning_tokens: {},
},
daily_series: {
requests: {
'2026-04-22': 3,
},
tokens: {
'2026-04-22': 300,
},
rpm: {
'2026-04-22': 3 / 1440,
},
tpm: {
'2026-04-22': 300 / 1440,
},
cost: {
'2026-04-22': 0.46,
},
input_tokens: {},
output_tokens: {},
cached_tokens: {},
reasoning_tokens: {},
},
};
const usageWithMixedCostBuckets: UsageOverviewResponse = {
...usageWithBackendCost,
summary: {
...usageWithBackendCost.summary!,
window_minutes: 180,
},
series: {
...usageWithBackendCost.series!,
cost: {
'2026-04-22T09:00:00Z': 0.12,
'2026-04-22T10:00:00Z': 0.34,
'2026-04-22': 0.46,
},
},
};
const usageWithLongRangeHourlySeries: UsageOverviewResponse = {
...usageWithBackendCost,
summary: {
...usageWithBackendCost.summary!,
window_minutes: 7 * 24 * 60,
},
hourly_series: {
...(usageWithBackendCost.hourly_series ?? usageWithBackendCost.series!),
cost: Object.fromEntries(
Array.from({ length: 48 }, (_, index) => {
const hour = String(index % 24).padStart(2, '0');
const day = index < 24 ? '22' : '23';
return [`2026-04-${day}T${hour}:00:00Z`, index + 1];
}),
),
},
};
const usageWithLongRangeDailySeries: UsageOverviewResponse = {
...usageWithBackendCost,
summary: {
...usageWithBackendCost.summary!,
window_minutes: 7 * 24 * 60,
},
series: {
...usageWithBackendCost.series!,
cost: {
'2026-04-17': 1,
'2026-04-18': 2,
'2026-04-19': 3,
'2026-04-20': 4,
'2026-04-21': 5,
'2026-04-22': 6,
'2026-04-23': 7,
},
},
};
describe('buildOverviewCostTrendSeries', () => {
it('uses a 24-hour window and token-breakdown-style raw hour labels for backend cost series', () => {
const result = buildOverviewCostTrendSeries({
usage: usageWithBackendCost,
period: 'hour',
hourWindowHours: 24,
endMs: Date.parse('2026-04-22T10:59:59Z'),
});
expect(result.costAvailable).toBe(true);
expect(result.labels).toHaveLength(24);
expect(result.labels[0]).toMatch(/^\d{2}:\d{2}$/);
expect(result.labels[23]).toMatch(/^\d{2}:\d{2}$/);
expect(result.data.slice(-2)).toEqual([0.12, 0.34]);
expect(result.hasData).toBe(true);
});
it('keeps short-range hour view aligned to backend partial-hour buckets', () => {
const result = buildOverviewCostTrendSeries({
usage: {
...usageWithBackendCost,
hourly_series: {
...usageWithBackendCost.hourly_series!,
cost: {
'2026-04-24T02:00:00Z': 1.47,
'2026-04-24T03:00:00Z': 0,
'2026-04-24T04:00:00Z': 0,
'2026-04-24T05:00:00Z': 0,
'2026-04-24T06:00:00Z': 0,
},
},
},
period: 'hour',
hourWindowHours: 4,
endMs: Date.parse('2026-04-24T06:16:00Z'),
});
expect(result.labels).toHaveLength(5);
expect(result.data).toEqual([1.47, 0, 0, 0, 0]);
expect(result.hasData).toBe(true);
});
it('aggregates hourly cost into daily buckets when day view is selected for a short range', () => {
const result = buildOverviewCostTrendSeries({
usage: usageWithMixedCostBuckets,
period: 'day',
hourWindowHours: 3,
endMs: Date.parse('2026-04-22T10:59:59Z'),
});
expect(result.costAvailable).toBe(true);
expect(result.labels).toEqual(['2026-04-22']);
expect(result.data).toEqual([0.46]);
expect(result.hasData).toBe(true);
});
it('aggregates hourly cost fallback into local day buckets', () => {
const result = buildOverviewCostTrendSeries({
usage: {
...usageWithBackendCost,
daily_series: undefined,
series: {
...usageWithBackendCost.series!,
cost: {
'2026-04-22T16:30:00Z': 0.5,
},
},
},
period: 'day',
});
expect(result.labels).toEqual([formatTestLocalDayKey(new Date('2026-04-22T16:30:00Z'))]);
expect(result.data).toEqual([0.5]);
});
it('limits hour view to the latest 24 hourly buckets for long ranges', () => {
const result = buildOverviewCostTrendSeries({
usage: usageWithLongRangeHourlySeries,
period: 'hour',
hourWindowHours: 24,
endMs: Date.parse('2026-04-23T23:59:59Z'),
});
expect(result.costAvailable).toBe(true);
expect(result.labels).toHaveLength(24);
expect(result.labels[0]).toMatch(/^\d{2}:\d{2}$/);
expect(result.labels[23]).toMatch(/^\d{2}:\d{2}$/);
expect(result.data[0]).toBe(25);
expect(result.data[23]).toBe(48);
expect(result.hasData).toBe(true);
});
it('still returns cost data when cost availability is partial but backend series is populated', () => {
const result = buildOverviewCostTrendSeries({
usage: {
...usageWithLongRangeDailySeries,
summary: {
...usageWithLongRangeDailySeries.summary!,
cost_available: false,
},
hourly_series: {
...(usageWithLongRangeDailySeries.hourly_series ?? usageWithLongRangeDailySeries.series!),
cost: {
'2026-04-23T22:00:00Z': 1,
'2026-04-23T23:00:00Z': 2,
},
},
},
period: 'hour',
hourWindowHours: 24,
endMs: Date.parse('2026-04-23T23:59:59Z'),
});
expect(result.costAvailable).toBe(false);
expect(result.labels).toHaveLength(24);
expect(result.data.slice(-2)).toEqual([1, 2]);
expect(result.hasData).toBe(true);
});
it('does not show pricing setup hint when cost data is already present', () => {
expect(shouldShowCostPricingHint({ costAvailable: false, hasData: true })).toBe(false);
expect(shouldShowCostPricingHint({ costAvailable: false, hasData: false })).toBe(true);
});
});