feat(usage): split throttle thresholds per bucket, add draggable gauge markers

This commit is contained in:
mika kuns
2026-08-07 11:01:45 +02:00
parent 231b063751
commit 7eeb8f5086
33 changed files with 2289 additions and 147 deletions
@@ -43,6 +43,33 @@ public class SettingsModalViewModelTests
SessionSkills: null, ModelPresets: null,
UsageGateFiveHourPct: fiveHourPct, UsageGateSevenDayPct: sevenDayPct);
[Fact]
public async Task Save_carries_dragged_throttle_stages_through_untouched()
{
// The throttle stages are only editable by dragging the usage-monitor gauges. Saving the
// Settings modal rebuilds the whole DTO, so it must not reset them to the defaults.
var worker = new FakeWorker
{
AppToReturn = DtoWith(65, 95) with
{
UsageThrottleFiveHourSoftPct = 42,
UsageThrottleFiveHourHardPct = 58,
UsageThrottleSevenDaySoftPct = 71,
UsageThrottleSevenDayHardPct = 88,
},
};
var vm = MakeVm(worker);
await vm.LoadAsync();
await vm.SaveCommand.ExecuteAsync(null);
Assert.NotNull(worker.Saved);
Assert.Equal(42, worker.Saved!.UsageThrottleFiveHourSoftPct);
Assert.Equal(58, worker.Saved.UsageThrottleFiveHourHardPct);
Assert.Equal(71, worker.Saved.UsageThrottleSevenDaySoftPct);
Assert.Equal(88, worker.Saved.UsageThrottleSevenDayHardPct);
}
private static SettingsModalViewModel MakeVm(FakeWorker worker) =>
new(worker, new PrimeClaudeTabViewModel(new FakePrimeApi()), new OnlineLoginService(),
MakeLocalizer(), new AppSettings());
@@ -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 ───────────────────────────────────────────────────