authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-09-22 17:45:50-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-22 17:45:50-07:00
logb954af60c402ff0698f9f2d9a214695e817f07fe
tree4d8e1b21663453a01a8cab77a36e164b0d9ba4ba
parent68607c5a99147710b92228e0642f6c0a642e32a6
signature Signed by PGP key B5690EEEBB952194

test(web): cover usage model ordering without static markup (#13104)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

5 files changed, 63 insertions(+), 234 deletions(-)

apps/web/src/components/usage/UsagePage.test.tsx deleted-231
...@@ -1,231 +0,0 @@
1import { EnvironmentId, UsageDay, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
2import { mergeUsage } from "@t3tools/shared/usageMerge";
3import { renderToStaticMarkup } from "react-dom/server";
4import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
5
6const testState = vi.hoisted(() => ({
7 useUsage: vi.fn(),
8 metric: "cost" as "cost" | "tokens" | "limits",
9 breakdown: "time" as "model" | "time",
10}));
11
12vi.mock("react", async (importOriginal) => {
13 const actual = await importOriginal<typeof import("react")>();
14 return {
15 ...actual,
16 useState: vi.fn((initial: unknown) => [
17 initial === readUsagePagePreferences
18 ? { metric: testState.metric, windowDays: 30 }
19 : typeof initial === "function"
20 ? {
21 days: 1,
22 window: {
23 sinceDay: "2026-08-10",
24 untilDay: "2026-08-11",
25 timeZone: "UTC",
26 resolution: "hour",
27 sinceTime: "2026-08-10T12:37:00.000Z",
28 untilTime: "2026-08-11T12:37:00.000Z",
29 },
30 }
31 : initial === "cost"
32 ? testState.metric
33 : initial === "model"
34 ? testState.breakdown
35 : initial,
36 vi.fn(),
37 ]),
38 };
39});
40
41vi.mock("../../env", () => ({ isElectron: false }));
42vi.mock("../../state/usage", () => ({ useUsage: testState.useUsage }));
43vi.mock("../ui/button", () => ({ Button: "button", InlineButton: "button" }));
44vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" }));
45vi.mock("../ui/select", () => ({
46 Select: "div",
47 SelectItem: "div",
48 SelectPopup: "div",
49 SelectTrigger: "div",
50 SelectValue: "div",
51}));
52vi.mock("../ui/sidebar", () => ({ SidebarInset: "div" }));
53vi.mock("../ui/toggle-group", () => ({ Toggle: "button", ToggleGroup: "div" }));
54vi.mock("../WorkspaceBreadcrumb", () => ({
55 WorkspaceBreadcrumb: "div",
56 WorkspaceBreadcrumbItem: "div",
57 WorkspaceBreadcrumbSeparator: "span",
58}));
59vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" }));
60vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" }));
61vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" }));
62vi.mock("./UsagePriceOverrides", () => ({ UsagePriceOverrides: () => null }));
63vi.mock("./usageProviders", async (importOriginal) => {
64 const actual = await importOriginal<typeof import("./usageProviders")>();
65 return {
66 ...actual,
67 PROVIDER_PRESENTATION: {
68 codex: { color: "white", label: "Codex", mark: "span" },
69 claude: { color: "orange", label: "Claude Code", mark: "span" },
70 },
71 };
72});
73
74import { UsagePage } from "./UsagePage";
75import { readUsagePagePreferences } from "./usagePagePreferences";
76
77const providerTotals = (codex: number, claude: number) =>
78 new Map([
79 ["codex", { costUsd: codex, totalTokens: codex * 1_000 }],
80 ["claude", { costUsd: claude, totalTokens: claude * 1_000 }],
81 ] as const);
82
83const modelTotals = Object.freeze([
84 {
85 model: "expensive-model",
86 provider: "claude" as const,
87 costUsd: 10,
88 totalTokens: 100,
89 records: 1,
90 unpricedRecords: 0,
91 costShare: 10 / 16,
92 },
93 {
94 model: "token-heavy-model",
95 provider: "codex" as const,
96 costUsd: 5,
97 totalTokens: 1_000,
98 records: 1,
99 unpricedRecords: 0,
100 costShare: 5 / 16,
101 },
102 {
103 model: "token-heavy-cheaper-model",
104 provider: "codex" as const,
105 costUsd: 1,
106 totalTokens: 1_000,
107 records: 1,
108 unpricedRecords: 0,
109 costShare: 1 / 16,
110 },
111 {
112 model: "unpriced-model",
113 provider: "codex" as const,
114 costUsd: 0,
115 totalTokens: 500,
116 records: 2,
117 unpricedRecords: 2,
118 costShare: 0,
119 },
120]);
121
122const environments = [
123 {
124 environmentId: EnvironmentId.make("test-environment"),
125 label: "Test environment",
126 isPending: false,
127 error: null,
128 summary: {
129 contractVersion: USAGE_CONTRACT_VERSION,
130 readAt: "2026-08-11T12:37:00.000Z",
131 sinceDay: UsageDay.make("2026-08-10"),
132 untilDay: UsageDay.make("2026-08-11"),
133 timeZone: "UTC",
134 buckets: [],
135 sources: [],
136 pricing: { status: "fresh", source: "test", fetchedAt: null, knownModels: 1 },
137 scanDurationMs: 1,
138 },
139 },
140];
141
142beforeEach(() => {
143 testState.metric = "cost";
144 testState.breakdown = "time";
145 testState.useUsage.mockReturnValue({
146 merged: {
147 ...mergeUsage([], USAGE_CONTRACT_VERSION),
148 models: modelTotals,
149 hourly: [
150 {
151 day: "2026-08-10",
152 hourStart: "2026-08-10T13:37:00.000Z",
153 costUsd: 13,
154 totalTokens: 13_000,
155 byProvider: providerTotals(7, 6),
156 },
157 {
158 day: "2026-08-11",
159 hourStart: "2026-08-11T11:37:00.000Z",
160 costUsd: 11,
161 totalTokens: 11_000,
162 byProvider: providerTotals(6, 5),
163 },
164 ],
165 },
166 environments,
167 selectedEnvironments: environments,
168 isPending: false,
169 isPartial: false,
170 refresh: vi.fn(),
171 });
172});
173
174describe("UsagePage hourly breakdown", () => {
175 it("keeps recent activity visible first without empty hourly rows", () => {
176 const markup = renderToStaticMarkup(<UsagePage />);
177 const body = markup.match(/<tbody>(.*?)<\/tbody>/)?.[1] ?? "";
178
179 expect(body.match(/<tr/g)).toHaveLength(2);
180 expect(body).toContain("$11.00");
181 expect(body).toContain("$13.00");
182 expect(body.indexOf("$11.00")).toBeLessThan(body.indexOf("$13.00"));
183 });
184
185 it("keeps chronological ordering when the token metric is selected", () => {
186 testState.metric = "tokens";
187
188 const markup = renderToStaticMarkup(<UsagePage />);
189 const body = markup.match(/<tbody>(.*?)<\/tbody>/)?.[1] ?? "";
190
191 expect(body).toMatch(/\$11\.00.*\$13\.00/);
192 });
193});
194
195describe("UsagePage model breakdown", () => {
196 it("sorts models by cost when the cost metric is selected", () => {
197 testState.breakdown = "model";
198
199 const markup = renderToStaticMarkup(<UsagePage />);
200 const body = markup.match(/<tbody>(.*?)<\/tbody>/)?.[1] ?? "";
201
202 expect(body).toMatch(/expensive-model.*token-heavy-model.*token-heavy-cheaper-model/);
203 });
204
205 it("flags a model with no known rates instead of showing it as free", () => {
206 testState.breakdown = "model";
207
208 const markup = renderToStaticMarkup(<UsagePage />);
209 const body = markup.match(/<tbody>(.*?)<\/tbody>/)?.[1] ?? "";
210 const unpricedRow = body.split("<tr").find((row) => row.includes("unpriced-model")) ?? "";
211
212 expect(unpricedRow).toContain("Unpriced");
213 expect(unpricedRow).not.toContain("$0.00");
214 });
215
216 it("sorts models by token usage when the token metric is selected", () => {
217 testState.metric = "tokens";
218 testState.breakdown = "model";
219
220 const markup = renderToStaticMarkup(<UsagePage />);
221 const body = markup.match(/<tbody>(.*?)<\/tbody>/)?.[1] ?? "";
222
223 expect(body).toMatch(/token-heavy-model.*token-heavy-cheaper-model.*expensive-model/);
224 expect(modelTotals.map((model) => model.model)).toEqual([
225 "expensive-model",
226 "token-heavy-model",
227 "token-heavy-cheaper-model",
228 "unpriced-model",
229 ]);
230 });
231});
apps/web/src/components/usage/UsagePage.tsx+2-3
...@@ -63,6 +63,7 @@ import { WorkspacePageHeader } from "../WorkspacePageHeader";...@@ -63,6 +63,7 @@ import { WorkspacePageHeader } from "../WorkspacePageHeader";
63import { UsageLimitsSection } from "./UsageLimits";63import { UsageLimitsSection } from "./UsageLimits";
64import { UsagePriceOverrides } from "./UsagePriceOverrides";64import { UsagePriceOverrides } from "./UsagePriceOverrides";
65import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";65import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";
66import { sortModelsByTokens } from "./usageBreakdown";
66import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders";67import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders";
67import {68import {
68 readUsagePagePreferences,69 readUsagePagePreferences,
...@@ -141,9 +142,7 @@ export function UsagePage() {...@@ -141,9 +142,7 @@ export function UsagePage() {
141 const breakdownModels = useMemo(142 const breakdownModels = useMemo(
142 () =>143 () =>
143 breakdown === "model" && metric === "tokens"144 breakdown === "model" && metric === "tokens"
144 ? merged.models.toSorted(145 ? sortModelsByTokens(merged.models)
145 (left, right) => right.totalTokens - left.totalTokens || right.costUsd - left.costUsd,
146 )
147 : merged.models,146 : merged.models,
148 [breakdown, merged.models, metric],147 [breakdown, merged.models, metric],
149 );148 );
apps/web/src/components/usage/usageBreakdown.test.ts created+31
...@@ -0,0 +1,31 @@
1import type { ModelTotals } from "@t3tools/shared/usageMerge";
2import { describe, expect, it } from "vite-plus/test";
3
4import { sortModelsByTokens } from "./usageBreakdown";
5
6const model = (name: string, totalTokens: number, costUsd: number): ModelTotals => ({
7 model: name,
8 provider: "codex",
9 costUsd,
10 totalTokens,
11 records: 1,
12 unpricedRecords: 0,
13 costShare: 0,
14});
15
16describe("sortModelsByTokens", () => {
17 it("sorts by tokens, breaks ties by cost, and leaves the input alone", () => {
18 const models = [
19 model("lower-cost", 100, 1),
20 model("more-tokens", 200, 2),
21 model("higher-cost", 100, 3),
22 ];
23
24 expect(sortModelsByTokens(models).map((item) => item.model)).toEqual([
25 "more-tokens",
26 "higher-cost",
27 "lower-cost",
28 ]);
29 expect(models.map((item) => item.model)).toEqual(["lower-cost", "more-tokens", "higher-cost"]);
30 });
31});
apps/web/src/components/usage/usageBreakdown.ts created+7
...@@ -0,0 +1,7 @@
1import type { ModelTotals } from "@t3tools/shared/usageMerge";
2
3export function sortModelsByTokens(models: readonly ModelTotals[]) {
4 return models.toSorted(
5 (left, right) => right.totalTokens - left.totalTokens || right.costUsd - left.costUsd,
6 );
7}
packages/shared/src/usageMerge.test.ts+23
...@@ -273,6 +273,29 @@ describe("mergeUsage", () => {...@@ -273,6 +273,29 @@ describe("mergeUsage", () => {
273 ]);273 ]);
274 });274 });
275275
276 it("orders models by cost descending", () => {
277 const merged = mergeUsage(
278 [
279 environment(
280 "env-a",
281 summary(
282 [
283 bucket({ provider: "claude", model: "lower-cost", costUsd: 4 }),
284 bucket({ provider: "codex", model: "higher-cost", costUsd: 9 }),
285 ],
286 [
287 { provider: "claude", hostId: "mac", homePath: "/a/.claude" },
288 { provider: "codex", hostId: "mac", homePath: "/a/.codex" },
289 ],
290 ),
291 ),
292 ],
293 USAGE_CONTRACT_VERSION,
294 );
295
296 expect(merged.models.map((model) => model.model)).toEqual(["higher-cost", "lower-cost"]);
297 });
298
276 it("keeps two machines apart when hostname and home path collide", () => {299 it("keeps two machines apart when hostname and home path collide", () => {
277 // Every Mac resolves /Users/theo/.claude, so a hostname clash used to make300 // Every Mac resolves /Users/theo/.claude, so a hostname clash used to make
278 // one machine's usage vanish. Filesystem identity separates them.301 // one machine's usage vanish. Filesystem identity separates them.