Files
ClaudeDo/tests/ClaudeDo.Ui.Tests/ViewModels/UsageMonitorModalViewModelTests.cs
T
mika kuns 2700c3d817 fix(usage): stop 429s with an activity-dependent poll cadence + manual refresh
The usage monitor polled the undocumented OAuth usage endpoint every 60s and
earned 429s. It now polls every 5 min while any task is Running and every
15 min while idle (usage_poll_interval_active_seconds / _idle_seconds, both
clamped to >= 60; the old single usage_poll_interval_seconds key is gone).

A 429 comes back as UsageRateLimitedException carrying Retry-After and adds
exponential backoff on top, capped at 30 min and never shorter than the normal
cadence; the strike count resets on the first success. The schedule arithmetic
is the pure static UsagePollSchedule.NextDelay.

Since the idle cadence is slow on purpose, WorkerHub.RefreshUsage drives
UsageMonitorService.RefreshNowAsync behind a Refresh now button in the Usage
Monitor modal: an out-of-band poll that pushes the loop's next-due time out so
no double poll follows, with a 10s cooldown so click-spam can't earn a 429.

Staleness now measures against the slower (idle) interval so an idle worker
isn't flagged stale just for not polling.
2026-08-05 16:40:34 +02:00

336 lines
12 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using ClaudeDo.Localization;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals;
using Xunit;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class UsageMonitorModalViewModelTests
{
public UsageMonitorModalViewModelTests()
{
var dir = AppContext.BaseDirectory;
while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales")))
dir = Path.GetDirectoryName(dir);
Loc.Current = new Localizer(
LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en");
}
private sealed class FakeWorker : StubWorkerClient
{
public UsageSnapshotDto? Snapshot;
public IReadOnlyList<ModelUsageRowDto> ModelRows = Array.Empty<ModelUsageRowDto>();
public IReadOnlyList<TaskUsageRowDto> TaskRows = Array.Empty<TaskUsageRowDto>();
public int ModelUsageCalls;
public int TaskUsageCalls;
public UsageSnapshotDto? RefreshedSnapshot;
public int RefreshCalls;
public Exception? RefreshThrows;
public override Task<UsageSnapshotDto?> GetUsageSnapshotAsync() => Task.FromResult(Snapshot);
public override Task<UsageSnapshotDto?> RefreshUsageAsync()
{
RefreshCalls++;
if (RefreshThrows is not null) throw RefreshThrows;
return Task.FromResult(RefreshedSnapshot);
}
public override Task<IReadOnlyList<ModelUsageRowDto>> GetModelUsageAsync(DateOnly from, DateOnly to)
{
ModelUsageCalls++;
return Task.FromResult(ModelRows);
}
public override Task<IReadOnlyList<TaskUsageRowDto>> GetTaskUsageAsync(DateOnly from, DateOnly to)
{
TaskUsageCalls++;
return Task.FromResult(TaskRows);
}
}
private static UsageLimitDto Limit(
string kind, double percent = 10, string severity = "normal",
DateTimeOffset? resetsAt = null, string? scopeModelDisplayName = null, bool isActive = true)
=> new(kind, "group", percent, severity, resetsAt, scopeModelDisplayName, isActive);
private static UsageSnapshotDto Snapshot(
IReadOnlyList<UsageLimitDto>? limits = null,
int fiveHourThresholdPct = 80,
int sevenDayThresholdPct = 90,
bool isGateBlocked = false,
string? gateReason = null,
bool isStale = false,
string? lastError = null,
DateTime? fetchedAtUtc = null,
int configuredSlots = 1,
int effectiveSlots = 1,
string? throttleBucket = null)
=> new(
null, null, null, null,
limits ?? Array.Empty<UsageLimitDto>(),
fiveHourThresholdPct, sevenDayThresholdPct,
isGateBlocked, gateReason, fetchedAtUtc ?? DateTime.UtcNow, isStale, lastError,
configuredSlots, effectiveSlots, throttleBucket);
// ── Manual refresh ──────────────────────────────────────────────────────
[Fact]
public async Task Refresh_ReplacesSnapshotAndReloadsTables()
{
var worker = new FakeWorker
{
Snapshot = Snapshot(new[] { Limit("session") }),
RefreshedSnapshot = Snapshot(new[] { Limit("session"), Limit("weekly_all") }),
};
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
var callsAfterLoad = worker.ModelUsageCalls;
await vm.RefreshCommand.ExecuteAsync(null);
Assert.Equal(1, worker.RefreshCalls);
Assert.Equal(2, vm.GaugeRows.Count);
Assert.Equal(callsAfterLoad + 1, worker.ModelUsageCalls);
Assert.False(vm.IsRefreshing);
}
[Fact]
public async Task Refresh_NullResult_KeepsPreviousSnapshot()
{
var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("session") }) };
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
await vm.RefreshCommand.ExecuteAsync(null);
Assert.Single(vm.GaugeRows);
}
[Fact]
public async Task Refresh_Failure_ReportsErrorAndClearsBusyFlag()
{
var worker = new FakeWorker
{
Snapshot = Snapshot(new[] { Limit("session") }),
RefreshThrows = new InvalidOperationException("worker offline"),
};
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
string? reported = null;
vm.ErrorReported += m => reported = m;
await vm.RefreshCommand.ExecuteAsync(null);
Assert.NotNull(reported);
Assert.Contains("worker offline", reported);
Assert.False(vm.IsRefreshing);
}
// ── Gauge label derivation ──────────────────────────────────────────────
[Fact]
public async Task GaugeLabel_Session_UsesSessionLabel()
{
var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("session") }) };
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
Assert.Equal(Loc.T("modals.usageMonitor.gaugeSession"), vm.GaugeRows[0].Label);
}
[Fact]
public async Task GaugeLabel_WeeklyAll_UsesWeeklyAllLabel()
{
var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("weekly_all") }) };
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
Assert.Equal(Loc.T("modals.usageMonitor.gaugeWeeklyAll"), vm.GaugeRows[0].Label);
}
[Fact]
public async Task GaugeLabel_WeeklyScoped_IncludesDisplayName()
{
var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("weekly_scoped", scopeModelDisplayName: "Fable") }) };
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
Assert.Equal(Loc.T("modals.usageMonitor.gaugeWeeklyScopedFormat", "Fable"), vm.GaugeRows[0].Label);
Assert.Contains("Fable", vm.GaugeRows[0].Label);
}
[Fact]
public async Task GaugeLabel_UnknownKind_ShowsRawKind()
{
var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("mystery_bucket") }) };
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
Assert.Equal("mystery_bucket", vm.GaugeRows[0].Label);
}
// ── Gauge list follows Limits ───────────────────────────────────────────
[Fact]
public async Task GaugeRows_ThreeLimits_ProducesThreeGauges()
{
var worker = new FakeWorker
{
Snapshot = Snapshot(new[] { Limit("session"), Limit("weekly_all"), Limit("weekly_scoped", scopeModelDisplayName: "Opus") })
};
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
Assert.Equal(3, vm.GaugeRows.Count);
}
[Fact]
public async Task GaugeRows_NoSnapshot_EmptyNoCrash()
{
var worker = new FakeWorker { Snapshot = null };
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
Assert.Empty(vm.GaugeRows);
}
// ── Gate threshold marks ────────────────────────────────────────────────
[Fact]
public async Task GaugeRow_Session_CarriesFiveHourThreshold()
{
var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("session") }, fiveHourThresholdPct: 80) };
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
Assert.Equal(80, vm.GaugeRows[0].ThresholdPercent);
}
[Fact]
public async Task GaugeRow_WeeklyScoped_HasNoThreshold()
{
var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("weekly_scoped", scopeModelDisplayName: "Fable") }) };
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
Assert.Null(vm.GaugeRows[0].ThresholdPercent);
}
// ── Stale / gate bands ───────────────────────────────────────────────────
[Fact]
public async Task StaleSnapshot_SetsIsStale()
{
var worker = new FakeWorker { Snapshot = Snapshot(isStale: true, lastError: "timeout") };
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
Assert.True(vm.IsStale);
Assert.Equal("timeout", vm.LastError);
}
[Fact]
public async Task BlockedGate_SetsIsGateBlocked_WithReason()
{
var worker = new FakeWorker { Snapshot = Snapshot(isGateBlocked: true, gateReason: "5h-Limit 80% >= 80%") };
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
Assert.True(vm.IsGateBlocked);
Assert.Equal("5h-Limit 80% >= 80%", vm.GateReason);
}
// ── Model tab: scope split + sums ───────────────────────────────────────
[Fact]
public async Task ModelRows_SplitsClaudeDoVsOther_AndSumsTokens()
{
var worker = new FakeWorker
{
ModelRows = new[]
{
new ModelUsageRowDto(new DateOnly(2026, 8, 1), "sonnet", "ClaudeDo", 100, 50, 10, 5, 1),
new ModelUsageRowDto(new DateOnly(2026, 8, 2), "sonnet", "ClaudeDo", 20, 10, 0, 0, 1),
new ModelUsageRowDto(new DateOnly(2026, 8, 1), "sonnet", "Other", 5, 5, 0, 0, 1),
new ModelUsageRowDto(new DateOnly(2026, 8, 1), "opus", "Other", 1000, 1000, 0, 0, 1),
}
};
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
var sonnet = vm.ModelRows.Single(r => r.Model == "sonnet");
Assert.Equal(120, sonnet.ClaudeDoInputTokens);
Assert.Equal(60, sonnet.ClaudeDoOutputTokens);
Assert.Equal(15, sonnet.ClaudeDoCacheTokens);
Assert.Equal(5, sonnet.OtherInputTokens);
Assert.Equal(5, sonnet.OtherOutputTokens);
Assert.Equal(205, sonnet.GrandTotal);
// Sorted descending by grand total — opus (2000) before sonnet (210).
Assert.Equal("opus", vm.ModelRows[0].Model);
}
[Fact]
public async Task ModelRows_Empty_ReportsEmptyState()
{
var worker = new FakeWorker();
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
Assert.True(vm.ModelsEmpty);
}
// ── Task tab ─────────────────────────────────────────────────────────────
[Fact]
public async Task TaskRows_SortedDescendingByTotalTokens()
{
var worker = new FakeWorker
{
TaskRows = new[]
{
new TaskUsageRowDto("t1", "Small task", "l1", "List", "sonnet", 1, 10, 10),
new TaskUsageRowDto("t2", "Big task", "l1", "List", "opus", 3, 1000, 2000),
}
};
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
Assert.Equal("t2", vm.TaskRows[0].TaskId);
Assert.Equal(3000, vm.TaskRows[0].TotalTokens);
}
[Fact]
public async Task TaskRows_Empty_ReportsEmptyState()
{
var worker = new FakeWorker();
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
Assert.True(vm.TasksEmpty);
}
// ── Period change triggers exactly one reload ───────────────────────────
[Fact]
public async Task ChangingPreset_TriggersExactlyOneReload()
{
var worker = new FakeWorker();
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
var callsAfterInitialLoad = worker.ModelUsageCalls;
await vm.SetPreset30DaysCommand.ExecuteAsync(null);
Assert.Equal(callsAfterInitialLoad + 1, worker.ModelUsageCalls);
Assert.Equal(callsAfterInitialLoad + 1, worker.TaskUsageCalls);
}
}