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 ModelRows = Array.Empty(); public IReadOnlyList TaskRows = Array.Empty(); public int ModelUsageCalls; public int TaskUsageCalls; public override Task GetUsageSnapshotAsync() => Task.FromResult(Snapshot); public override Task> GetModelUsageAsync(DateOnly from, DateOnly to) { ModelUsageCalls++; return Task.FromResult(ModelRows); } public override Task> 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? 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(), 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.SetPresetCommand.ExecuteAsync(30); Assert.Equal(callsAfterInitialLoad + 1, worker.ModelUsageCalls); Assert.Equal(callsAfterInitialLoad + 1, worker.TaskUsageCalls); } }