1import { describe, expect, it } from "vite-plus/test";
2
3import { buildPeriodColumns, chartScale, niceScale } from "./UsageProviderChart";
4import { providersWithUsage } from "./usageProviders";
5
6describe("chartScale", () => {
7 const column = (codex: number) => ({
8 total: codex,
9 bands: [{ provider: "codex" as const, value: codex }],
10 });
11
12 it("holds unlabeled placeholder gridlines while loading providers have nothing to show", () => {
13 const scale = chartScale([column(0)], new Set(["codex" as const]));
14
15 expect(scale.labeled).toBe(false);
16 expect(scale.ticks.length).toBeGreaterThan(1);
17 });
18
19 it("scales to what is on screen, loading or not", () => {
20 expect(chartScale([column(40)], new Set(["codex" as const]))).toMatchObject({
21 max: 40,
22 labeled: true,
23 });
24 });
25});
26
27describe("niceScale", () => {
28 it("never puts the peak above the top of the scale", () => {
29 // Regression: an earlier version stopped at the last step below the peak,
30 // so the tallest day was drawn past the plot and clipped.
31 for (const peak of [1122.71, 999, 1, 0.04, 1_400_000_000, 37.5, 5000, 100.001]) {
32 const { max } = niceScale(peak, 4);
33 expect(max, `peak ${peak}`).toBeGreaterThanOrEqual(peak);
34 }
35 });
36
37 it("starts at zero and ends at the maximum", () => {
38 const { max, ticks } = niceScale(1122.71, 4);
39
40 expect(ticks[0]).toBe(0);
41 expect(ticks[ticks.length - 1]).toBeCloseTo(max, 6);
42 });
43
44 it("uses evenly spaced 1/2/5 steps", () => {
45 const { ticks } = niceScale(1122.71, 4);
46 const steps = ticks.slice(1).map((tick, index) => tick - (ticks[index] ?? 0));
47
48 for (const step of steps) expect(step).toBeCloseTo(steps[0] ?? 0, 6);
49 const [first = 0] = steps;
50 const normalized = first / 10 ** Math.floor(Math.log10(first));
51 expect([1, 2, 5, 10]).toContain(Math.round(normalized));
52 });
53
54 it("keeps the tick count near the requested resolution", () => {
55 const { ticks } = niceScale(1122.71, 4);
56 expect(ticks.length).toBeGreaterThanOrEqual(3);
57 expect(ticks.length).toBeLessThanOrEqual(7);
58 });
59
60 it("degrades to a single zero tick with no data", () => {
61 expect(niceScale(0, 4)).toEqual({ max: 0, ticks: [0] });
62 });
63});
64
65describe("buildPeriodColumns", () => {
66 const days = ["2026-08-01", "2026-08-02", "2026-08-03"];
67 const byDay = new Map([
68 [
69 "2026-08-01",
70 {
71 day: "2026-08-01",
72 costUsd: 35,
73 totalTokens: 350,
74 byProvider: new Map([
75 ["codex" as const, { costUsd: 10, totalTokens: 100 }],
76 ["claude" as const, { costUsd: 20, totalTokens: 200 }],
77 ["pi" as const, { costUsd: 5, totalTokens: 50 }],
78 ]),
79 },
80 ],
81 // 2026-08-02 is deliberately absent: a day with no activity.
82 [
83 "2026-08-03",
84 {
85 day: "2026-08-03",
86 costUsd: 5,
87 totalTokens: 50,
88 byProvider: new Map([["claude" as const, { costUsd: 5, totalTokens: 50 }]]),
89 },
90 ],
91 ]);
92
93 it("plots each day on its own", () => {
94 expect(buildPeriodColumns(days, byDay, "cost").map((column) => column.total)).toEqual([
95 35, 0, 5,
96 ]);
97 });
98
99 it("reads the requested metric", () => {
100 expect(buildPeriodColumns(days, byDay, "tokens").map((column) => column.total)).toEqual([
101 350, 0, 50,
102 ]);
103 });
104
105 it("keeps band values absolute rather than cumulative", () => {
106 // Regression: the bands were once stack offsets, which drew Claude Code
107 // permanently above Codex regardless of which provider spent more.
108 const [first] = buildPeriodColumns(days, byDay, "cost");
109
110 expect(first?.bands).toEqual([
111 { provider: "codex", value: 10 },
112 { provider: "claude", value: 20 },
113 { provider: "grok", value: 0 },
114 { provider: "cursor", value: 0 },
115 { provider: "opencode", value: 0 },
116 { provider: "antigravity", value: 0 },
117 { provider: "pi", value: 5 },
118 ]);
119 });
120
121 it("reports the total as the sum of its bands", () => {
122 for (const column of buildPeriodColumns(days, byDay, "cost")) {
123 const sum = column.bands.reduce((running, band) => running + band.value, 0);
124 expect(column.total).toBeCloseTo(sum, 9);
125 }
126 });
127});
128
129describe("providersWithUsage", () => {
130 it("omits providers with no cost or tokens", () => {
131 expect(
132 providersWithUsage([
133 { provider: "codex", costUsd: 0, totalTokens: 0 },
134 { provider: "claude", costUsd: 0, totalTokens: 200 },
135 ]),
136 ).toEqual(["claude"]);
137 });
138});
139
140describe("hourly chart columns", () => {
141 it("zero-fills inactive hours and preserves hourly provider values", () => {
142 const byHour = new Map([
143 [
144 "2026-08-11T09:37:00.000Z",
145 {
146 day: "2026-08-11",
147 hourStart: "2026-08-11T09:37:00.000Z",
148 costUsd: 4,
149 totalTokens: 40,
150 byProvider: new Map([["codex" as const, { costUsd: 4, totalTokens: 40 }]]),
151 },
152 ],
153 ]);
154
155 expect(
156 buildPeriodColumns(
157 ["2026-08-11T08:37:00.000Z", "2026-08-11T09:37:00.000Z", "2026-08-11T10:37:00.000Z"],
158 byHour,
159 "cost",
160 ).map((column) => column.total),
161 ).toEqual([0, 4, 0]);
162 });
163});