Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/mobile/src/features/usage/UsageRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -290,8 +290,8 @@ export function UsageRouteScreen() {
) : null}
{merged.approximateEnvironments.length > 0 ? (
<Text className="text-sm text-foreground-muted">
Totals may count shared usage more than once because these environments run older
servers or have directories whose filesystem identity could not be read:{" "}
Totals for these environments are approximate because they run older servers, read
overlapping history folders, or have folders whose identity could not be read:{" "}
{selectedEnvironments
.filter((environment) =>
merged.approximateEnvironments.includes(environment.environmentId),
Expand Down
112 changes: 112 additions & 0 deletions apps/mobile/src/state/usage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { EnvironmentId, UsageDay, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
import { act, useLayoutEffect } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { describe, expect, it, vi } from "vite-plus/test";

import { useUsage, type EnvironmentUsageStatus, type UsageView } from "./usage";

const state = vi.hoisted(() => ({ environments: [] as EnvironmentUsageStatus[] }));
vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.environments }));
vi.mock("./atom-registry", () => ({ appAtomRegistry: {} }));
vi.mock("./presentation", () => ({ environmentPresentations: {} }));
vi.mock("./server", () => ({ serverEnvironment: {} }));

const input = {
sinceDay: UsageDay.make("2026-09-04"),
untilDay: UsageDay.make("2026-09-05"),
timeZone: "UTC",
};
function reading(id: string, partial: boolean): EnvironmentUsageStatus {
const cell = (day: UsageDay, costUsd: number) => ({
day,
provider: "codex" as const,
model: "gpt-6",
totals: {
uncachedInputTokens: 10,
cachedInputTokens: 0,
cacheCreationTokens: 0,
outputTokens: 5,
reasoningTokens: 0,
},
costUsd,
cacheSavingsUsd: 0,
costSource: "modelPriced" as const,
records: 1,
unpricedRecords: 0,
sessions: 1,
});
const buckets = partial
? [cell(input.sinceDay, 4), cell(input.untilDay, 3)]
: [cell(input.sinceDay, 10)];
return {
environmentId: EnvironmentId.make(id),
label: id,
isPending: false,
isConnected: true,
error: null,
summary: {
...input,
contractVersion: USAGE_CONTRACT_VERSION,
readAt: partial ? "2026-09-05T12:00:00Z" : "2026-09-04T12:00:00Z",
buckets,
sources: [
{
fingerprint: {
hostId: "host",
provider: "codex",
resolvedHomePath: "/sessions",
volumeId: "physical-volume",
},
status: partial ? "partial" : "ok",
scannedFiles: 1,
skippedFiles: partial ? 1 : 0,
malformedRecords: 0,
distinctSessions: partial ? 2 : 1,
message: partial ? "One source file could not be read." : null,
buckets,
},
],
pricing: { status: "fresh", source: "test", fetchedAt: null, knownModels: 1 },
scanDurationMs: 1,
},
};
}

Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe("mobile usage scan selection", () => {
it("retains complete cells and new partial cells, then recomputes ownership when the selection changes", async () => {
state.environments = [reading("complete", false), reading("partial", true)];
let latest: UsageView | undefined;
let renderer: ReactTestRenderer | undefined;
function Probe({ selected }: { selected: ReadonlySet<EnvironmentId> | null }) {
const usage = useUsage(input, selected);
useLayoutEffect(() => {
latest = usage;
}, [usage]);
return null;
}
try {
await act(async () => {
renderer = create(<Probe selected={null} />);
});
expect(latest?.merged.costUsd).toBe(13);
expect(latest?.merged.sessions).toBe(2);
expect(latest?.merged.duplicateSources).toEqual(["partial: /sessions"]);
await act(async () =>
renderer?.update(<Probe selected={new Set([EnvironmentId.make("partial")])} />),
);
expect(latest?.merged.costUsd).toBe(7);
expect(latest?.merged.duplicateSources).toEqual([]);
await act(async () =>
renderer?.update(<Probe selected={new Set([EnvironmentId.make("complete")])} />),
);
expect(latest?.merged.costUsd).toBe(10);
state.environments = state.environments.toReversed();
await act(async () => renderer?.update(<Probe selected={null} />));
expect(latest?.merged.costUsd).toBe(13);
expect(latest?.merged.sessions).toBe(2);
} finally {
await act(async () => renderer?.unmount());
}
});
});
4 changes: 2 additions & 2 deletions apps/web/src/components/usage/UsagePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -797,8 +797,8 @@ function UsageCoverageNotice({
) : null}
{approximate.length > 0 ? (
<span>
Totals may include shared usage more than once because these environments run older
servers or have directories whose filesystem identity could not be read:{" "}
Totals for these environments are approximate because they run older servers, read
overlapping history folders, or have folders whose identity could not be read:{" "}
{approximate.map((entry) => entry.label).join(", ")}.
</span>
) : null}
Expand Down
53 changes: 53 additions & 0 deletions apps/web/src/state/usage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,59 @@ afterEach(async () => {
});

describe("usage environment selection", () => {
it("keeps complete and supplemental partial cells when selection changes", async () => {
const complete = environment("complete", 10, "shared");
const partial = environment("partial", 4, "shared");
if (complete.summary === null || partial.summary === null)
throw new Error("Missing fixture summary");
const completeBuckets = complete.summary.buckets.map((cell) => ({
...cell,
model: "shared-model",
}));
const partialBuckets = [
...partial.summary.buckets.map((cell) => ({ ...cell, model: "shared-model" })),
{ ...partial.summary.buckets[0]!, model: "new-model", costUsd: 3 },
];
testState.environments = [
{
...complete,
summary: {
...complete.summary,
buckets: completeBuckets,
sources: complete.summary.sources.map((source) => ({
...source,
buckets: completeBuckets,
})),
},
},
{
...partial,
summary: {
...partial.summary,
readAt: "2026-09-04T13:00:00Z",
buckets: partialBuckets,
sources: partial.summary.sources.map((source) => ({
...source,
status: "partial" as const,
distinctSessions: 2,
buckets: partialBuckets,
})),
},
},
];
await act(() => renderer?.update(<Probe selected={null} />));
expect(latest.merged.costUsd).toBe(13);
expect(latest.merged.sessions).toBe(2);
await select("partial");
expect(latest.merged.costUsd).toBe(7);
expect(latest.merged.duplicateSources).toEqual([]);
await select("complete");
expect(latest.merged.costUsd).toBe(10);
testState.environments = testState.environments.toReversed();
await act(() => renderer?.update(<Probe selected={null} />));
expect(latest.merged.costUsd).toBe(13);
});

it("starts with all environments and adds results as they arrive", async () => {
expect(latest.merged.costUsd).toBe(30);
expect(latest.isPending).toBe(false);
Expand Down
Loading
Loading