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 UsageSnapshotDto? RefreshedSnapshot; public int RefreshCalls; public Exception? RefreshThrows; /// When set, the snapshot fetch never completes — stands in for the slow first /// transcript scan on the worker side. public TaskCompletionSource? SnapshotGate; public Exception? SnapshotThrows; public override Task GetUsageSnapshotAsync() { if (SnapshotThrows is not null) throw SnapshotThrows; return SnapshotGate?.Task ?? Task.FromResult(Snapshot); } public override Task RefreshUsageAsync() { RefreshCalls++; if (RefreshThrows is not null) throw RefreshThrows; return Task.FromResult(RefreshedSnapshot); } 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); } public AppSettingsDto? AppSettings; public AppSettingsDto? SavedSettings; public override Task GetAppSettingsAsync() => Task.FromResult(AppSettings); public override Task UpdateAppSettingsAsync(AppSettingsDto dto) { SavedSettings = dto; return Task.CompletedTask; } } private static AppSettingsDto AppSettings() => new(DefaultClaudeInstructions: "", DefaultModel: "sonnet", DefaultMaxTurns: 30, DefaultPermissionMode: "auto", MaxParallelExecutions: 3, WorktreeStrategy: "sibling", CentralWorktreeRoot: null, WorktreeAutoCleanupEnabled: false, WorktreeAutoCleanupDays: 7, ReportExcludedPaths: null, StandupWeekday: 3, DailyPrepMaxTasks: 5); 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, int configuredSlots = 1, int effectiveSlots = 1, string? throttleBucket = null, int throttleFiveHourSoftPct = 50, int throttleFiveHourHardPct = 65, int throttleSevenDaySoftPct = 50, int throttleSevenDayHardPct = 65) => new( null, null, null, null, limits ?? Array.Empty(), fiveHourThresholdPct, sevenDayThresholdPct, isGateBlocked, gateReason, fetchedAtUtc ?? DateTime.UtcNow, isStale, lastError, configuredSlots, effectiveSlots, throttleBucket, throttleFiveHourSoftPct, throttleFiveHourHardPct, throttleSevenDaySoftPct, throttleSevenDayHardPct); // ── BeginLoad: the modal must open before the data lands ──────────────── [Fact] public void BeginLoad_ReturnsWhileWorkerStillPending_AndShowsBusy() { var worker = new FakeWorker { SnapshotGate = new TaskCompletionSource() }; var vm = new UsageMonitorModalViewModel(worker); vm.BeginLoad(); Assert.True(vm.IsBusy); Assert.False(vm.ModelsEmpty); Assert.False(vm.TasksEmpty); } [Fact] public void BeginLoad_WorkerThrows_ReportsErrorInsteadOfCrashing() { var worker = new FakeWorker { SnapshotThrows = new InvalidOperationException("worker offline") }; var vm = new UsageMonitorModalViewModel(worker); string? reported = null; vm.ErrorReported += m => reported = m; vm.BeginLoad(); Assert.NotNull(reported); Assert.Contains("worker offline", reported); Assert.False(vm.IsBusy); } [Fact] public async Task LoadAsync_WorkerThrows_ReportsErrorAndClearsBusy() { var worker = new FakeWorker { SnapshotThrows = new InvalidOperationException("worker offline") }; var vm = new UsageMonitorModalViewModel(worker); string? reported = null; vm.ErrorReported += m => reported = m; await vm.LoadAsync(); Assert.NotNull(reported); Assert.False(vm.IsBusy); } // ── 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].GatePct); } [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].GatePct); Assert.False(vm.GaugeRows[0].IsAdjustable); } // ── Draggable stage markers ───────────────────────────────────────────── [Fact] public async Task GaugeRow_Session_CarriesPerBucketThrottleStages() { var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("session") }, fiveHourThresholdPct: 80, throttleFiveHourSoftPct: 45, throttleFiveHourHardPct: 60), }; var vm = new UsageMonitorModalViewModel(worker); await vm.LoadAsync(); var row = vm.GaugeRows[0]; Assert.Equal("five_hour", row.Bucket); Assert.Equal(45, row.SoftPct); Assert.Equal(60, row.HardPct); Assert.Equal(80, row.GatePct); Assert.True(row.IsAdjustable); } [Fact] public async Task GaugeRow_WeeklyAll_CarriesSevenDayStages() { var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("weekly_all") }, sevenDayThresholdPct: 90, throttleSevenDaySoftPct: 70, throttleSevenDayHardPct: 85), }; var vm = new UsageMonitorModalViewModel(worker); await vm.LoadAsync(); var row = vm.GaugeRows[0]; Assert.Equal("seven_day", row.Bucket); Assert.Equal(70, row.SoftPct); Assert.Equal(85, row.HardPct); Assert.Equal(90, row.GatePct); } [Fact] public async Task Commit_WritesOnlyTheDraggedBucket_AndKeepsEverythingElse() { var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("session") }, fiveHourThresholdPct: 80, throttleFiveHourSoftPct: 50, throttleFiveHourHardPct: 65), AppSettings = AppSettings(), }; var vm = new UsageMonitorModalViewModel(worker); await vm.LoadAsync(); var row = vm.GaugeRows[0]; row.SoftPct = 40; row.HardPct = 55; row.GatePct = 75; await row.CommitCommand.ExecuteAsync(null); Assert.NotNull(worker.SavedSettings); Assert.Equal(40, worker.SavedSettings!.UsageThrottleFiveHourSoftPct); Assert.Equal(55, worker.SavedSettings.UsageThrottleFiveHourHardPct); Assert.Equal(75, worker.SavedSettings.UsageGateFiveHourPct); // The 7d bucket and unrelated settings ride along untouched. Assert.Equal(50, worker.SavedSettings.UsageThrottleSevenDaySoftPct); Assert.Equal(65, worker.SavedSettings.UsageThrottleSevenDayHardPct); Assert.Equal(90, worker.SavedSettings.UsageGateSevenDayPct); Assert.Equal(3, worker.SavedSettings.MaxParallelExecutions); } [Fact] public async Task Commit_WorkerOffline_ReportsErrorAndSavesNothing() { var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("session") }), AppSettings = null, }; var vm = new UsageMonitorModalViewModel(worker); await vm.LoadAsync(); string? reported = null; vm.ErrorReported += m => reported = m; await vm.GaugeRows[0].CommitCommand.ExecuteAsync(null); Assert.NotNull(reported); Assert.Null(worker.SavedSettings); } [Fact] public async Task Commit_NonAdjustableRow_SavesNothing() { var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("weekly_scoped", scopeModelDisplayName: "Opus") }), AppSettings = AppSettings(), }; var vm = new UsageMonitorModalViewModel(worker); await vm.LoadAsync(); await vm.GaugeRows[0].CommitCommand.ExecuteAsync(null); Assert.Null(worker.SavedSettings); } [Fact] public async Task LiveSnapshot_UpdatesRowsInPlace_WithoutReplacingInstances() { // A poll landing mid-interaction must not swap the row the gauge is bound to. var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("session", percent: 20) }) }; var vm = new UsageMonitorModalViewModel(worker); await vm.LoadAsync(); var before = vm.GaugeRows[0]; vm.Snapshot = Snapshot(new[] { Limit("session", percent: 55) }, throttleFiveHourSoftPct: 44); Assert.Same(before, vm.GaugeRows[0]); Assert.Equal(55, vm.GaugeRows[0].Percent); Assert.Equal(44, vm.GaugeRows[0].SoftPct); } [Fact] public async Task LiveSnapshot_NewLimitKind_AddsARow() { var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("session") }) }; var vm = new UsageMonitorModalViewModel(worker); await vm.LoadAsync(); vm.Snapshot = Snapshot(new[] { Limit("session"), Limit("weekly_all") }); Assert.Equal(2, vm.GaugeRows.Count); } // ── Legend input boxes ─────────────────────────────────────────────────── [Fact] public async Task TypedStage_SavesTheEditedBucket() { var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("session") }, fiveHourThresholdPct: 80), AppSettings = AppSettings(), }; var vm = new UsageMonitorModalViewModel(worker); await vm.LoadAsync(); var row = vm.GaugeRows[0]; row.HardPct = 58; await row.CommitHardCommand.ExecuteAsync(null); Assert.Equal(58, worker.SavedSettings!.UsageThrottleFiveHourHardPct); Assert.Equal(50, worker.SavedSettings.UsageThrottleFiveHourSoftPct); Assert.Equal(80, worker.SavedSettings.UsageGateFiveHourPct); } [Fact] public async Task TypedStage_OutOfOrder_IsPinned_AndLeavesNeighboursAlone() { var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("session") }, fiveHourThresholdPct: 80, throttleFiveHourSoftPct: 50, throttleFiveHourHardPct: 65), AppSettings = AppSettings(), }; var vm = new UsageMonitorModalViewModel(worker); await vm.LoadAsync(); // Typing 95 into the soft box may not push past hard — and must not drag hard along. var row = vm.GaugeRows[0]; row.SoftPct = 95; await row.CommitSoftCommand.ExecuteAsync(null); Assert.Equal(65, row.SoftPct); Assert.Equal(65, row.HardPct); Assert.Equal(80, row.GatePct); Assert.Equal(65, worker.SavedSettings!.UsageThrottleFiveHourSoftPct); } [Fact] public async Task TypedGate_BelowHard_IsPinnedToHard() { var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("weekly_all") }, sevenDayThresholdPct: 90, throttleSevenDaySoftPct: 50, throttleSevenDayHardPct: 65), AppSettings = AppSettings(), }; var vm = new UsageMonitorModalViewModel(worker); await vm.LoadAsync(); var row = vm.GaugeRows[0]; row.GatePct = 20; await row.CommitGateCommand.ExecuteAsync(null); Assert.Equal(65, row.GatePct); Assert.Equal(65, worker.SavedSettings!.UsageGateSevenDayPct); } [Fact] public async Task TypedStage_OnNonAdjustableRow_SavesNothing() { var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("weekly_scoped", scopeModelDisplayName: "Opus") }), AppSettings = AppSettings(), }; var vm = new UsageMonitorModalViewModel(worker); await vm.LoadAsync(); await vm.GaugeRows[0].CommitSoftCommand.ExecuteAsync(null); Assert.Null(worker.SavedSettings); } // ── Drag math ──────────────────────────────────────────────────────────── [Theory] [InlineData(30, UsageThresholdDrag.Stage.Soft, 30, 65, 80)] // free move below hard [InlineData(90, UsageThresholdDrag.Stage.Soft, 65, 65, 80)] // pinned to hard [InlineData(-5, UsageThresholdDrag.Stage.Soft, 0, 65, 80)] // clamped at 0 [InlineData(70, UsageThresholdDrag.Stage.Hard, 50, 70, 80)] // free move between soft and gate [InlineData(10, UsageThresholdDrag.Stage.Hard, 50, 50, 80)] // pinned to soft [InlineData(95, UsageThresholdDrag.Stage.Hard, 50, 80, 80)] // pinned to gate [InlineData(120, UsageThresholdDrag.Stage.Gate, 50, 65, 100)] // clamped at 100 [InlineData(20, UsageThresholdDrag.Stage.Gate, 50, 65, 65)] // pinned to hard public void Drag_KeepsStagesOrderedAndInRange( double dragTo, UsageThresholdDrag.Stage stage, int expectedSoft, int expectedHard, int expectedGate) { var result = UsageThresholdDrag.Apply(50, 65, 80, stage, dragTo); Assert.Equal((expectedSoft, expectedHard, expectedGate), result); } [Fact] public void Drag_RoundsToWholePercent() { Assert.Equal((37, 65, 80), UsageThresholdDrag.Apply(50, 65, 80, UsageThresholdDrag.Stage.Soft, 36.7)); } [Fact] public void Drag_NeighbourAtZeroIsOff_AndDoesNotPinTheMarker() { // hard = 0 means "hard stage off" — soft must still be draggable up to the gate. Assert.Equal((70, 0, 80), UsageThresholdDrag.Apply(50, 0, 80, UsageThresholdDrag.Stage.Soft, 70)); } [Fact] public void Drag_InconsistentStoredConfig_DoesNotThrow() { // soft above gate (only reachable by hand-editing the DB) must degrade, not crash. var result = UsageThresholdDrag.Apply(90, 95, 50, UsageThresholdDrag.Stage.Hard, 60); Assert.Equal(50, result.Hard); } [Theory] [InlineData(50, UsageThresholdDrag.Stage.Soft)] [InlineData(63, UsageThresholdDrag.Stage.Hard)] [InlineData(82, UsageThresholdDrag.Stage.Gate)] public void Nearest_PicksTheClosestMarkerInReach(double percent, UsageThresholdDrag.Stage expected) { Assert.Equal(expected, UsageThresholdDrag.Nearest(50, 65, 80, percent, tolerancePercent: 5)); } [Fact] public void Nearest_OutOfReach_GrabsNothing() { Assert.Null(UsageThresholdDrag.Nearest(50, 65, 80, percent: 20, tolerancePercent: 5)); } // ── 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); } }