RelayCommand<int> casts its object parameter directly rather than
converting it, so a string CommandParameter ("7"/"30") would throw
InvalidCastException at runtime. Split into two parameterless commands.
267 lines
9.9 KiB
C#
267 lines
9.9 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 override Task<UsageSnapshotDto?> GetUsageSnapshotAsync() => Task.FromResult(Snapshot);
|
|
|
|
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)
|
|
=> new(
|
|
null, null, null, null,
|
|
limits ?? Array.Empty<UsageLimitDto>(),
|
|
fiveHourThresholdPct, sevenDayThresholdPct,
|
|
isGateBlocked, gateReason, fetchedAtUtc ?? DateTime.UtcNow, isStale, lastError);
|
|
|
|
// ── 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);
|
|
}
|
|
}
|