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.
336 lines
12 KiB
C#
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);
|
|
}
|
|
}
|