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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ the [changelog workflow](AGENTS.md#documentation-and-change-quality).

## [Unreleased]

## [0.8.11] - 2026-10-20

### Changed

- Use account-wide daily token totals from the Codex account service in the main chart, preserve server calendar dates, and show the source, last read time, and unavailable states without substituting local totals. ([PR #41](https://github.com/TheBeems/CodexUsageDock/pull/41))

### Fixed

- Keep the chart available when the account service reports very large token totals, without overflowing the scale calculation. ([PR #41](https://github.com/TheBeems/CodexUsageDock/pull/41))

## [0.8.10] - 2026-10-17

### Changed
Expand Down
171 changes: 171 additions & 0 deletions CodexUsageDock.Tests/AccountTokenChartTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
using System.Globalization;
using System.Text.Json;
using System.Xml.Linq;
using Xunit;

namespace CodexUsageDock.Tests;

public sealed class AccountTokenChartTests : IDisposable
{
private const string Account = "synthetic-chart-account";
private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero);
private static readonly DateOnly TokenDate = new(2026, 9, 8);
private static readonly XNamespace Svg = "http://www.w3.org/2000/svg";
private readonly TestEnvironment _environment = new();

public void Dispose() => _environment.Dispose();

private static CodexUsageSnapshot Quota() => CodexUsageSnapshot.Loading with
{
Secondary = new(20, 10080, Now.AddDays(6)),
UpdatedAt = Now,
Source = UsageDataSource.AppServer,
AccountKey = Account,
DefaultBucketId = "codex",
};

private static AccountUsageSnapshot Tokens(AccountUsageStatus status = AccountUsageStatus.Available) =>
new(Account, Now, [new(TokenDate, 890_123_456)], status);

private static JsonDocument Dashboard(AccountUsageSnapshot accountUsage, CodexUsageSnapshot? quota = null,
bool enabled = true) => JsonDocument.Parse(CodexUsageDockPage.FormatMainDataJson(
quota ?? Quota(), Now, false, [], [new(Now.AddMinutes(-1), 81), new(Now, 80)], TimeSpan.FromMinutes(1),
accountUsage: accountUsage, showAccountActivity: enabled));

private static XDocument ParseChart(string imageUrl) =>
XDocument.Parse(Uri.UnescapeDataString(imageUrl["data:image/svg+xml;utf8,".Length..]));

private static IEnumerable<XElement> TokenBars(XDocument chart) =>
chart.Descendants(Svg + "rect").Where(rect => rect.Attribute("data-series")?.Value == "daily-tokens");

[Fact]
public async Task AccountTotalsReachTheMainChartIndependentlyOfLocalTokensAndQuotaRefresh()
{
var pending = new TaskCompletionSource<AccountUsageSnapshot>(TaskCreationOptions.RunContinuationsAsynchronously);
var quota = Quota();
using var service = _environment.CreateService(_ => Task.FromResult(quota), () => quota,
localTokenUsageReader: (_, _, _, _) => Task.FromResult(new LocalTokenUsageSnapshot(
[new(TokenDate, 12_345)], Now, LocalTokenUsageStatus.Complete)),
clock: () => Now, accountUsageReader: _ => pending.Task);

await service.RefreshAsync();
await service.TokenRefreshTask.WaitAsync(TimeSpan.FromSeconds(10));
Assert.False(service.AccountUsageRefreshTask.IsCompleted);
var before = service.GetPresentation();
using (var loading = Dashboard(before.AccountUsage, before.Usage))
{
Assert.Empty(TokenBars(ParseChart(loading.RootElement.GetProperty("weeklyTrendChartUrl").GetString()!)));
Assert.Contains("unavailable", loading.RootElement.GetProperty("weeklyTrendLegend").GetString(), StringComparison.Ordinal);
}

var updates = 0;
service.Updated += (_, _) => Interlocked.Increment(ref updates);
var accountUsage = Tokens();
pending.SetResult(accountUsage);
await service.AccountUsageRefreshTask.WaitAsync(TimeSpan.FromSeconds(10));

var view = service.GetPresentation();
Assert.Same(accountUsage, view.AccountUsage);
Assert.Equal(12_345, Assert.Single(view.TokenUsage.Days).TotalTokens);
Assert.True(updates > 0);
using var dashboard = Dashboard(view.AccountUsage, view.Usage);
var data = dashboard.RootElement;
var bar = Assert.Single(TokenBars(ParseChart(data.GetProperty("weeklyTrendChartUrl").GetString()!)));
Assert.Equal("890123456", bar.Attribute("data-tokens")?.Value);
Assert.Equal("2026-09-08", bar.Attribute("data-date")?.Value);
Assert.Contains("Codex account service", data.GetProperty("weeklyTrendLegend").GetString(), StringComparison.Ordinal);
Assert.Contains("Last read:", data.GetProperty("weeklyTrendLegend").GetString(), StringComparison.Ordinal);
Assert.DoesNotContain("local tokens", data.GetProperty("weeklyTrendChartAlt").GetString(), StringComparison.OrdinalIgnoreCase);
}

[Fact]
public void ServerDatesAndWholeDayTotalsSurviveTimeZoneAndQuotaBoundaryChanges()
{
var timeZone = TimeZoneInfo.CreateCustomTimeZone("UTC+14 test", TimeSpan.FromHours(14), "UTC+14 test", "UTC+14 test");
var tokens = new AccountUsageSnapshot(Account, Now,
[new(new(2026, 9, 8), 0), new(new(2026, 9, 9), 765_432)], AccountUsageStatus.Partial);
var chart = Assert.IsType<WeeklyUsageTrendChart>(WeeklyUsageTrendChartRenderer.Create(
[new(Now.AddDays(-3), 95), new(Now, 80)], Quota().Secondary!, Now, TimeSpan.FromDays(4),
null, tokens, CultureInfo.InvariantCulture, timeZone));

var svg = ParseChart(chart.ImageUrl);
var bar = Assert.Single(TokenBars(svg));
Assert.Equal("2026-09-09", bar.Attribute("data-date")?.Value);
Assert.Equal("765432", bar.Attribute("data-tokens")?.Value);
Assert.Contains("Mon 7 Sep: not reported", chart.AltText, StringComparison.Ordinal);
Assert.Contains("Tue 8 Sep: 0", chart.AltText, StringComparison.Ordinal);
Assert.Contains("Account token data is partial", chart.AltText, StringComparison.Ordinal);
Assert.Contains("time zone is unspecified", chart.AltText, StringComparison.Ordinal);
Assert.Contains("not clipped to the quota window", chart.AltText, StringComparison.Ordinal);
Assert.Contains(svg.Descendants(Svg + "g"), group =>
group.Attribute("data-axis-label")?.Value == "Account tokens/day (right axis)");
}

[Theory]
[InlineData(false, "unavailable")]
[InlineData(true, "unsupported")]
public void UnavailableAccountDataOmitsTokenBarsWithoutRemovingTheQuotaTrend(bool unsupported, string label)
{
using var dashboard = Dashboard(Tokens(unsupported ? AccountUsageStatus.Unsupported : AccountUsageStatus.Unavailable));
var data = dashboard.RootElement;
var chart = ParseChart(data.GetProperty("weeklyTrendChartUrl").GetString()!);

Assert.Empty(TokenBars(chart));
Assert.NotEmpty(chart.Descendants(Svg + "polyline"));
Assert.Contains(label, data.GetProperty("weeklyTrendLegend").GetString(), StringComparison.Ordinal);
}

[Fact]
public void DisabledAccountActivityDoesNotShowCachedAccountTotals()
{
using var dashboard = Dashboard(Tokens(), enabled: false);
var data = dashboard.RootElement;

Assert.Empty(TokenBars(ParseChart(data.GetProperty("weeklyTrendChartUrl").GetString()!)));
Assert.Contains("disabled in settings", data.GetProperty("weeklyTrendLegend").GetString(), StringComparison.Ordinal);
}

[Theory]
[InlineData(null)]
[InlineData("another-account")]
public void UnknownOrDifferentQuotaAccountCannotDisplayAccountTotals(string? quotaAccount)
{
using var dashboard = Dashboard(Tokens(), Quota() with { AccountKey = quotaAccount });
var data = dashboard.RootElement;

Assert.Empty(TokenBars(ParseChart(data.GetProperty("weeklyTrendChartUrl").GetString()!)));
Assert.Contains("unavailable", data.GetProperty("weeklyTrendLegend").GetString(), StringComparison.Ordinal);
}

[Fact]
public void SummaryWithoutDailyBucketsDoesNotInventZeroDays()
{
using var dashboard = Dashboard(new(Account, Now, [], AccountUsageStatus.Partial, LifetimeTokens: 123_456));
var data = dashboard.RootElement;

Assert.Empty(TokenBars(ParseChart(data.GetProperty("weeklyTrendChartUrl").GetString()!)));
Assert.Contains("not reported", data.GetProperty("weeklyTrendChartAlt").GetString(), StringComparison.Ordinal);
Assert.Contains("partial account data", data.GetProperty("weeklyTrendLegend").GetString(), StringComparison.Ordinal);
}

[Theory]
[InlineData(5_000_000_000_000_000_001)]
[InlineData(long.MaxValue)]
public void LargeAccountTotalsDoNotOverflowTheChartScale(long tokens)
{
using var payload = JsonDocument.Parse(JsonSerializer.Serialize(new
{
dailyUsageBuckets = new[] { new { startDate = "2026-09-08", tokens } },
}));
var accountUsage = AccountUsageParser.Parse(payload.RootElement, Account, Now);
using var dashboard = Dashboard(accountUsage);
var data = dashboard.RootElement;
var imageUrl = data.GetProperty("weeklyTrendChartUrl").GetString()!;
var bar = Assert.Single(TokenBars(ParseChart(imageUrl)));

Assert.True(data.GetProperty("weeklyTrendAvailable").GetBoolean());
Assert.Equal(tokens.ToString(CultureInfo.InvariantCulture), bar.Attribute("data-tokens")?.Value);
Assert.DoesNotContain("NaN", imageUrl, StringComparison.Ordinal);
Assert.DoesNotContain("Infinity", imageUrl, StringComparison.Ordinal);
}
}
6 changes: 3 additions & 3 deletions CodexUsageDock.Tests/CodexUsageTableTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public void RemappedWeeklyQuotaAppearsOnceAndOtherCategoriesStaySeparate()
DefaultBucketId = "codex",
Buckets = [new("codex", "codex", weekly, null), new("spark", "Spark", weekly, null)],
};
var view = new UsagePresentation(snapshot, [], [], new([], null), LocalTokenUsageSnapshot.Unavailable, false);
var view = new UsagePresentation(snapshot, [], [], new([], null), LocalTokenUsageSnapshot.Unavailable, false, AccountUsageSnapshot.Unavailable);
var body = CodexUsageTablePage.Format(view, now, TimeSpan.FromMinutes(1));
var groups = UsageQuotaGroups.Create(snapshot);
Assert.Equal(2, groups.Count);
Expand All @@ -36,7 +36,7 @@ public void FullTokenTotalsAndLocalDatesUseUnconstrainedTextRows()
var snapshot = CodexUsageSnapshot.Loading with { UpdatedAt = now, Source = UsageDataSource.AppServer };
var tokens = new LocalTokenUsageSnapshot([new(new DateOnly(2026, 9, 13), 51_732_933),
new(new DateOnly(2026, 9, 15), 9_999_999_999)], now, LocalTokenUsageStatus.Complete);
var view = new UsagePresentation(snapshot, [new(now, 51)], [new(now, 51)], new([], null), tokens, false);
var view = new UsagePresentation(snapshot, [new(now, 51)], [new(now, 51)], new([], null), tokens, false, AccountUsageSnapshot.Unavailable);
var body = CodexUsageTablePage.Format(view, now, TimeSpan.FromMinutes(1), timeZone);
Assert.Contains("Observed: Wed 16 Sep 2026 00:01", body, StringComparison.Ordinal);
Assert.Contains("Times are local (Test local)", body, StringComparison.Ordinal);
Expand All @@ -53,7 +53,7 @@ public void TextViewKeepsUnknownZeroExpiredAndUnconfirmedStatesDistinct()
var quota = new CodexUsageSnapshot(new(100, 300, now.AddHours(-1)), null, null, null, new(0, null),
now.AddHours(-2), UsageDataSource.LastConfirmed, null,
[new("extra", "Extra | quota", new(0, 60, now.AddHours(1)), null)], DefaultBucketId: "codex");
var view = new UsagePresentation(quota, [], [], new([], null), LocalTokenUsageSnapshot.Unavailable, false);
var view = new UsagePresentation(quota, [], [], new([], null), LocalTokenUsageSnapshot.Unavailable, false, AccountUsageSnapshot.Unavailable);
var body = CodexUsageTablePage.Format(view, now, TimeSpan.FromMinutes(1));
Assert.Contains("LastConfirmed", body, StringComparison.Ordinal);
Assert.Contains("Reset passed; refresh required", body, StringComparison.Ordinal);
Expand Down
3 changes: 2 additions & 1 deletion CodexUsageDock.Tests/UsageAlertTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ private static UsagePresentation Presentation(
weeklyHistory ?? Array.Empty<UsageHistoryEntry>(),
new AdaptiveWeeklyUsageHistory([], null),
LocalTokenUsageSnapshot.Unavailable,
isLoading);
isLoading,
AccountUsageSnapshot.Unavailable);
}
}
15 changes: 10 additions & 5 deletions CodexUsageDock.Tests/UsageDashboardTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@ private static CodexUsageSnapshot Snapshot() => CodexUsageSnapshot.Loading with
Source = UsageDataSource.AppServer,
Error = null,
DefaultBucketId = "codex",
AccountKey = "synthetic-account",
};

private static JsonDocument Render(CodexUsageSnapshot snapshot, UsageHistoryEntry[]? history = null,
bool showFiveHourLimit = true) =>
JsonDocument.Parse(CodexUsageDockPage.FormatMainDataJson(snapshot, Now, false, [],
history ?? [], TimeSpan.FromMinutes(1),
tokenUsage: new LocalTokenUsageSnapshot([new DailyTokenUsage(new DateOnly(2026, 9, 15), 1000)],
Now, LocalTokenUsageStatus.Complete), showFiveHourLimit: showFiveHourLimit));
accountUsage: new AccountUsageSnapshot("synthetic-account", Now,
[new DailyTokenUsage(new DateOnly(2026, 9, 15), 1000)], AccountUsageStatus.Available), showFiveHourLimit: showFiveHourLimit));

private static XDocument Chart(JsonElement data) => XDocument.Parse(
Uri.UnescapeDataString(data.GetProperty("weeklyTrendChartUrl").GetString()!["data:image/svg+xml;utf8,".Length..]));
Expand Down Expand Up @@ -79,7 +80,7 @@ public void HidingFiveHourLimitPreservesOtherDurationsAndOmitsEmptyAdditionalGro
}

[Fact]
public void ForecastBasisAppearsBelowChartWithoutRestoringTokenCaption()
public void ForecastBasisAndAccountTokenSourceAppearBelowChart()
{
var history = Enumerable.Range(0, 7).Select(index => new UsageHistoryEntry(Now.AddMinutes(-30 + index * 5), 90 - index * 5)).ToArray();
using var data = Render(Snapshot() with { Secondary = new(40, 10080, Now.AddDays(4)) }, history);
Expand All @@ -89,15 +90,19 @@ public void ForecastBasisAppearsBelowChartWithoutRestoringTokenCaption()
&& items.EnumerateArray().Any(child => child.TryGetProperty("url", out var url)
&& url.GetString() == "${weeklyTrendChartUrl}"));
var chartItems = chart.GetProperty("items");
Assert.Equal(2, chartItems.GetArrayLength());
Assert.Equal(3, chartItems.GetArrayLength());
Assert.Equal("${weeklyForecastStatus}", chartItems[1].GetProperty("text").GetString());
Assert.Equal("${hasForecastBasis}", chartItems[1].GetProperty("$when").GetString());
Assert.True(chartItems[1].GetProperty("wrap").GetBoolean());
Assert.Equal("${weeklyTokenSource}", chartItems[2].GetProperty("text").GetString());
Assert.True(chartItems[2].GetProperty("wrap").GetBoolean());
Assert.False(chartItems[2].TryGetProperty("$when", out _));
Assert.Contains("Codex account service", data.RootElement.GetProperty("weeklyTokenSource").GetString(), StringComparison.Ordinal);
Assert.True(data.RootElement.GetProperty("hasForecastBasis").GetBoolean());
Assert.DoesNotContain("weeklyTrendLegend", UsageDashboardCard.TemplateJson, StringComparison.Ordinal);
var details = CodexUsageDockPage.FormatForecastDetails(data.RootElement);
Assert.Contains("Quota forecast:", details, StringComparison.Ordinal);
Assert.Contains("Local token bars: available session logs.", details, StringComparison.Ordinal);
Assert.Contains("Codex account service", details, StringComparison.Ordinal);
Assert.Contains("Amber markers: reset or restored allowance.", details, StringComparison.Ordinal);
}

Expand Down
Loading