| 1 | import { |
| 2 | EnvironmentId, |
| 3 | UsageDay, |
| 4 | USAGE_CONTRACT_VERSION, |
| 5 | type UsageProviderKind, |
| 6 | } from "@t3tools/contracts"; |
| 7 | import { act, useLayoutEffect } from "react"; |
| 8 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 9 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 10 | |
| 11 | import { useUsage, type EnvironmentUsageStatus, type UsageView } from "./usage"; |
| 12 | |
| 13 | const testState = vi.hoisted(() => ({ environments: [] as EnvironmentUsageStatus[] })); |
| 14 | vi.mock("@effect/atom-react", async (importOriginal) => ({ |
| 15 | ...(await importOriginal<typeof import("@effect/atom-react")>()), |
| 16 | useAtomValue: () => testState.environments, |
| 17 | })); |
| 18 | |
| 19 | const input = { |
| 20 | sinceDay: UsageDay.make("2026-09-04"), |
| 21 | untilDay: UsageDay.make("2026-09-04"), |
| 22 | timeZone: "UTC", |
| 23 | }; |
| 24 | |
| 25 | function environment( |
| 26 | id: string, |
| 27 | cost: number | null, |
| 28 | hostId = id, |
| 29 | provider: UsageProviderKind = "codex", |
| 30 | ): EnvironmentUsageStatus { |
| 31 | return { |
| 32 | environmentId: EnvironmentId.make(id), |
| 33 | label: id, |
| 34 | isPending: cost === null, |
| 35 | canReadDiagnostics: true, |
| 36 | isConnected: true, |
| 37 | error: null, |
| 38 | needsCursorKeychainAccess: false, |
| 39 | summary: |
| 40 | cost === null |
| 41 | ? null |
| 42 | : { |
| 43 | ...input, |
| 44 | contractVersion: USAGE_CONTRACT_VERSION, |
| 45 | readAt: "2026-09-04T12:00:00Z", |
| 46 | buckets: [ |
| 47 | { |
| 48 | day: input.sinceDay, |
| 49 | provider, |
| 50 | model: id, |
| 51 | totals: { |
| 52 | uncachedInputTokens: 100, |
| 53 | cachedInputTokens: 0, |
| 54 | cacheCreationTokens: 0, |
| 55 | outputTokens: 50, |
| 56 | reasoningTokens: 0, |
| 57 | }, |
| 58 | costUsd: cost, |
| 59 | cacheSavingsUsd: 0, |
| 60 | costSource: "modelPriced", |
| 61 | records: 1, |
| 62 | unpricedRecords: 0, |
| 63 | sessions: 1, |
| 64 | }, |
| 65 | ], |
| 66 | sources: [ |
| 67 | { |
| 68 | fingerprint: { |
| 69 | hostId, |
| 70 | provider, |
| 71 | resolvedHomePath: "/sessions", |
| 72 | volumeId: hostId, |
| 73 | }, |
| 74 | status: "ok", |
| 75 | scannedFiles: 1, |
| 76 | skippedFiles: 0, |
| 77 | malformedRecords: 0, |
| 78 | distinctSessions: 1, |
| 79 | message: null, |
| 80 | }, |
| 81 | ], |
| 82 | pricing: { status: "fresh", source: "test", fetchedAt: null, knownModels: 1 }, |
| 83 | scanDurationMs: 1, |
| 84 | }, |
| 85 | }; |
| 86 | } |
| 87 | |
| 88 | let renderer: ReactTestRenderer | undefined; |
| 89 | let latest: UsageView; |
| 90 | |
| 91 | function Probe({ |
| 92 | selected, |
| 93 | hidden, |
| 94 | window = input, |
| 95 | }: { |
| 96 | selected: ReadonlySet<EnvironmentId> | null; |
| 97 | hidden?: ReadonlySet<UsageProviderKind>; |
| 98 | window?: typeof input; |
| 99 | }) { |
| 100 | const usage = useUsage(window, selected, hidden); |
| 101 | useLayoutEffect(() => { |
| 102 | latest = usage; |
| 103 | }, [usage]); |
| 104 | return null; |
| 105 | } |
| 106 | |
| 107 | async function select(...ids: string[]) { |
| 108 | await act(() => { |
| 109 | renderer?.update(<Probe selected={new Set(ids.map((id) => EnvironmentId.make(id)))} />); |
| 110 | }); |
| 111 | } |
| 112 | |
| 113 | beforeEach(async () => { |
| 114 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); |
| 115 | testState.environments = [environment("a", 10), environment("b", 20), environment("slow", null)]; |
| 116 | await act(() => { |
| 117 | renderer = create(<Probe selected={null} />); |
| 118 | }); |
| 119 | }); |
| 120 | |
| 121 | afterEach(async () => { |
| 122 | await act(() => renderer?.unmount()); |
| 123 | vi.unstubAllGlobals(); |
| 124 | }); |
| 125 | |
| 126 | describe("usage environment selection", () => { |
| 127 | it("starts with all environments and adds results as they arrive", async () => { |
| 128 | expect(latest.merged.costUsd).toBe(30); |
| 129 | expect(latest.isPending).toBe(false); |
| 130 | expect(latest.isPartial).toBe(true); |
| 131 | |
| 132 | testState.environments = [...testState.environments.slice(0, 2), environment("slow", 40)]; |
| 133 | await act(() => renderer?.update(<Probe selected={null} />)); |
| 134 | expect(latest.merged.costUsd).toBe(70); |
| 135 | expect(latest.isPartial).toBe(false); |
| 136 | }); |
| 137 | |
| 138 | it("excludes unselected usage and pending environments, then restores all", async () => { |
| 139 | await select("b"); |
| 140 | expect(latest.merged.costUsd).toBe(20); |
| 141 | expect(latest.merged.models.map((model) => model.model)).toEqual(["b"]); |
| 142 | expect(latest.isPending).toBe(false); |
| 143 | expect(latest.isPartial).toBe(false); |
| 144 | expect(latest.environments).toHaveLength(3); |
| 145 | |
| 146 | await act(() => renderer?.update(<Probe selected={null} />)); |
| 147 | expect(latest.merged.costUsd).toBe(30); |
| 148 | expect(latest.isPartial).toBe(true); |
| 149 | }); |
| 150 | |
| 151 | it("distinguishes a pending selection from an empty or failed selection", async () => { |
| 152 | await select("slow"); |
| 153 | expect(latest.isPending).toBe(true); |
| 154 | expect(latest.merged.costUsd).toBe(0); |
| 155 | |
| 156 | await select(); |
| 157 | expect(latest.selectedEnvironments).toHaveLength(0); |
| 158 | expect(latest.isPending).toBe(false); |
| 159 | expect(latest.isPartial).toBe(false); |
| 160 | |
| 161 | testState.environments = [{ ...environment("slow", null), isPending: false, error: "Offline" }]; |
| 162 | await select("slow"); |
| 163 | expect(latest.isPending).toBe(false); |
| 164 | expect(latest.isPartial).toBe(false); |
| 165 | }); |
| 166 | |
| 167 | it("deduplicates within the selection so an excluded owner cannot hide usage", async () => { |
| 168 | testState.environments = [environment("a", 10, "shared"), environment("b", 20, "shared")]; |
| 169 | await act(() => renderer?.update(<Probe selected={null} />)); |
| 170 | expect(latest.merged.costUsd).toBe(10); |
| 171 | |
| 172 | await select("b"); |
| 173 | expect(latest.merged.costUsd).toBe(20); |
| 174 | expect(latest.merged.duplicateSources).toEqual([]); |
| 175 | }); |
| 176 | |
| 177 | it("keeps selected cached results visible during a refresh", async () => { |
| 178 | testState.environments = [ |
| 179 | { ...environment("a", 10), isPending: true }, |
| 180 | environment("slow", null), |
| 181 | ]; |
| 182 | await select("a"); |
| 183 | expect(latest.merged.costUsd).toBe(10); |
| 184 | expect(latest.isPending).toBe(false); |
| 185 | expect(latest.isPartial).toBe(false); |
| 186 | }); |
| 187 | |
| 188 | it("shows the last answered usage until the next window answers, for the same selection", async () => { |
| 189 | const selected = new Set([EnvironmentId.make("a")]); |
| 190 | await act(() => renderer?.update(<Probe selected={selected} />)); |
| 191 | expect(latest.shown?.merged.costUsd).toBe(10); |
| 192 | |
| 193 | // A new window that nothing has answered yet. |
| 194 | const nextWindow = { ...input, sinceDay: UsageDay.make("2026-08-28") }; |
| 195 | testState.environments = [environment("a", null)]; |
| 196 | await act(() => renderer?.update(<Probe selected={selected} window={nextWindow} />)); |
| 197 | expect(latest.isPending).toBe(true); |
| 198 | expect(latest.shown?.window).toBe(input); |
| 199 | expect(latest.shown?.merged.costUsd).toBe(10); |
| 200 | |
| 201 | // A window that fails everywhere keeps it too. |
| 202 | testState.environments = [{ ...environment("a", null), isPending: false, error: "Offline" }]; |
| 203 | await act(() => renderer?.update(<Probe selected={selected} window={nextWindow} />)); |
| 204 | expect(latest.isPending).toBe(false); |
| 205 | expect(latest.shown?.window).toBe(input); |
| 206 | expect(latest.shown?.merged.costUsd).toBe(10); |
| 207 | |
| 208 | // Once the new window answers, it replaces the kept one. |
| 209 | testState.environments = [environment("a", 30)]; |
| 210 | await act(() => renderer?.update(<Probe selected={selected} window={nextWindow} />)); |
| 211 | expect(latest.shown?.window).toBe(nextWindow); |
| 212 | expect(latest.shown?.merged.costUsd).toBe(30); |
| 213 | |
| 214 | // A different provider filter does not reuse usage merged with the old one. |
| 215 | testState.environments = [environment("a", null)]; |
| 216 | await act(() => |
| 217 | renderer?.update(<Probe selected={selected} hidden={new Set(["claude"])} window={input} />), |
| 218 | ); |
| 219 | expect(latest.shown).toBeNull(); |
| 220 | |
| 221 | // Another selection has nothing of its own to show. |
| 222 | testState.environments = [environment("a", null)]; |
| 223 | await select("a"); |
| 224 | expect(latest.shown).toBeNull(); |
| 225 | }); |
| 226 | }); |
| 227 | |
| 228 | describe("usage provider filter", () => { |
| 229 | it("drops hidden providers from totals and sessions, then restores them", async () => { |
| 230 | testState.environments = [environment("a", 10), environment("c", 5, "c", "claude")]; |
| 231 | await act(() => renderer?.update(<Probe selected={null} hidden={new Set(["codex"])} />)); |
| 232 | expect(latest.merged.costUsd).toBe(5); |
| 233 | expect(latest.merged.sessions).toBe(1); |
| 234 | expect(latest.merged.providers.map((entry) => entry.provider)).toEqual(["claude"]); |
| 235 | |
| 236 | await act(() => renderer?.update(<Probe selected={null} />)); |
| 237 | expect(latest.merged.costUsd).toBe(15); |
| 238 | expect(latest.merged.sessions).toBe(2); |
| 239 | }); |
| 240 | }); |