| 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | |
| 3 | import { buildPeriodColumns, chartScale, niceScale } from "./UsageProviderChart"; |
| 4 | import { providersWithUsage } from "./usageProviders"; |
| 5 | |
| 6 | describe("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 | |
| 27 | describe("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 | |
| 65 | describe("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 | |
| 129 | describe("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 | |
| 140 | describe("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 | }); |