681 lines
25 KiB
C#
681 lines
25 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;
|
|
|
|
/// <summary>When set, the snapshot fetch never completes — stands in for the slow first
|
|
/// transcript scan on the worker side.</summary>
|
|
public TaskCompletionSource<UsageSnapshotDto?>? SnapshotGate;
|
|
public Exception? SnapshotThrows;
|
|
|
|
public override Task<UsageSnapshotDto?> GetUsageSnapshotAsync()
|
|
{
|
|
if (SnapshotThrows is not null) throw SnapshotThrows;
|
|
return SnapshotGate?.Task ?? 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);
|
|
}
|
|
|
|
public AppSettingsDto? AppSettings;
|
|
public AppSettingsDto? SavedSettings;
|
|
|
|
public override Task<AppSettingsDto?> 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<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,
|
|
int throttleFiveHourSoftPct = 50,
|
|
int throttleFiveHourHardPct = 65,
|
|
int throttleSevenDaySoftPct = 50,
|
|
int throttleSevenDayHardPct = 65)
|
|
=> new(
|
|
null, null, null, null,
|
|
limits ?? Array.Empty<UsageLimitDto>(),
|
|
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<UsageSnapshotDto?>() };
|
|
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);
|
|
}
|
|
}
|