feat(usage): split throttle thresholds per bucket, add draggable gauge markers
This commit is contained in:
@@ -33,7 +33,16 @@ public class UsageMonitorModalViewModelTests
|
||||
public int RefreshCalls;
|
||||
public Exception? RefreshThrows;
|
||||
|
||||
public override Task<UsageSnapshotDto?> GetUsageSnapshotAsync() => Task.FromResult(Snapshot);
|
||||
/// <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()
|
||||
{
|
||||
@@ -53,8 +62,25 @@ public class UsageMonitorModalViewModelTests
|
||||
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)
|
||||
@@ -71,13 +97,63 @@ public class UsageMonitorModalViewModelTests
|
||||
DateTime? fetchedAtUtc = null,
|
||||
int configuredSlots = 1,
|
||||
int effectiveSlots = 1,
|
||||
string? throttleBucket = null)
|
||||
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);
|
||||
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 ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -210,7 +286,7 @@ public class UsageMonitorModalViewModelTests
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.Equal(80, vm.GaugeRows[0].ThresholdPercent);
|
||||
Assert.Equal(80, vm.GaugeRows[0].GatePct);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -220,7 +296,276 @@ public class UsageMonitorModalViewModelTests
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.Null(vm.GaugeRows[0].ThresholdPercent);
|
||||
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 ───────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user