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 ───────────────────────────────────────────────────
@@ -100,8 +100,10 @@ public sealed class QueueStateMcpToolsTests : IDisposable
var repo = new AppSettingsRepository(ctx);
var settings = await repo.GetAsync();
settings.MaxParallelExecutions = maxParallel;
settings.UsageThrottleSoftPct = softPct;
settings.UsageThrottleHardPct = hardPct;
settings.UsageThrottleFiveHourSoftPct = softPct;
settings.UsageThrottleFiveHourHardPct = hardPct;
settings.UsageThrottleSevenDaySoftPct = softPct;
settings.UsageThrottleSevenDayHardPct = hardPct;
await repo.UpdateAsync(settings);
}
@@ -81,8 +81,10 @@ public sealed class QueueServiceTests : IDisposable
var repo = new AppSettingsRepository(ctx);
var settings = await repo.GetAsync();
settings.MaxParallelExecutions = maxParallel;
settings.UsageThrottleSoftPct = softPct;
settings.UsageThrottleHardPct = hardPct;
settings.UsageThrottleFiveHourSoftPct = softPct;
settings.UsageThrottleFiveHourHardPct = hardPct;
settings.UsageThrottleSevenDaySoftPct = softPct;
settings.UsageThrottleSevenDayHardPct = hardPct;
settings.UsageGateFiveHourPct = gateFive;
settings.UsageGateSevenDayPct = gateSeven;
await repo.UpdateAsync(settings);
@@ -139,6 +139,35 @@ public class TranscriptUsageReaderTests : IDisposable
Assert.Equal(1, row.Messages);
}
[Fact]
public async Task Files_Last_Written_Before_The_Window_Are_Not_Read()
{
// Deliberate heuristic: a transcript whose mtime predates the window cannot contain a
// record inside it, so it is skipped unread. Here the content would match the window —
// proving the file was never opened, which is what keeps a 7-day range off the full history.
var path = WriteSession("proj", "old.jsonl",
AssistantLine(@"C:\Dev\App", "2026-06-02T08:00:00Z", "claude-sonnet-5", 5, 5, 0, 0));
File.SetLastWriteTime(path, new DateTime(2026, 5, 1, 12, 0, 0));
var reader = MakeReader();
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3));
Assert.Empty(result);
}
[Fact]
public async Task File_Written_On_The_Window_Start_Day_Is_Still_Read()
{
var path = WriteSession("proj", "edge.jsonl",
AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 5, 5, 0, 0));
File.SetLastWriteTime(path, new DateTime(2026, 6, 1, 0, 5, 0));
var reader = MakeReader();
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3));
Assert.Single(result);
}
[Fact]
public async Task Malformed_Line_Does_Not_Abort_The_Run()
{
@@ -149,11 +149,61 @@ public sealed class UsageSnapshotBuilderTests : IDisposable
var repo = new AppSettingsRepository(ctx);
var settings = await repo.GetAsync();
settings.MaxParallelExecutions = maxParallel;
settings.UsageThrottleSoftPct = softPct;
settings.UsageThrottleHardPct = hardPct;
settings.UsageThrottleFiveHourSoftPct = softPct;
settings.UsageThrottleFiveHourHardPct = hardPct;
settings.UsageThrottleSevenDaySoftPct = softPct;
settings.UsageThrottleSevenDayHardPct = hardPct;
await repo.UpdateAsync(settings);
}
private async Task SetPerBucketThrottleAsync(
int maxParallel, int fiveSoft, int fiveHard, int sevenSoft, int sevenHard)
{
using var ctx = _db.CreateContext();
var repo = new AppSettingsRepository(ctx);
var settings = await repo.GetAsync();
settings.MaxParallelExecutions = maxParallel;
settings.UsageThrottleFiveHourSoftPct = fiveSoft;
settings.UsageThrottleFiveHourHardPct = fiveHard;
settings.UsageThrottleSevenDaySoftPct = sevenSoft;
settings.UsageThrottleSevenDayHardPct = sevenHard;
await repo.UpdateAsync(settings);
}
[Fact]
public async Task Per_bucket_throttle_stages_are_reported_for_the_gauges()
{
await SetThresholdsAsync(80, 90);
await SetPerBucketThrottleAsync(maxParallel: 3, fiveSoft: 45, fiveHard: 60, sevenSoft: 70, sevenHard: 85);
var state = new UsageState();
state.ReportSuccess(new UsageSnapshot(
new UsageBucket(10, null), new UsageBucket(10, null), Array.Empty<UsageLimitRow>(), DateTime.UtcNow));
var dto = await CreateBuilder(state, new UsageGateDecision(false, null)).BuildAsync();
Assert.Equal(45, dto.ThrottleFiveHourSoftPct);
Assert.Equal(60, dto.ThrottleFiveHourHardPct);
Assert.Equal(70, dto.ThrottleSevenDaySoftPct);
Assert.Equal(85, dto.ThrottleSevenDayHardPct);
}
[Fact]
public async Task Per_bucket_stages_apply_independently_to_effective_slots()
{
await SetThresholdsAsync(80, 90);
// Both buckets sit at 60%: past the 5h soft stage (45) but below every 7d stage (70/85).
await SetPerBucketThrottleAsync(maxParallel: 3, fiveSoft: 45, fiveHard: 90, sevenSoft: 70, sevenHard: 85);
var state = new UsageState();
state.ReportSuccess(new UsageSnapshot(
new UsageBucket(60, null), new UsageBucket(60, null), Array.Empty<UsageLimitRow>(), DateTime.UtcNow));
var dto = await CreateBuilder(state, new UsageGateDecision(false, null)).BuildAsync();
Assert.Equal(2, dto.EffectiveSlots);
}
[Fact]
public async Task Throttled_slots_and_decisive_bucket_reported()
{
@@ -4,13 +4,11 @@ namespace ClaudeDo.Worker.Tests.Usage;
public sealed class UsageThrottleTests
{
private const int Soft = 50;
private const int Hard = 65;
private const int GateFive = 80;
private const int GateSeven = 90;
private static readonly UsageThresholds FiveHour = new(SoftPct: 50, HardPct: 65, GatePct: 80);
private static readonly UsageThresholds SevenDay = new(SoftPct: 50, HardPct: 65, GatePct: 90);
private static int Effective(double? five, double? seven, int configured = 3) =>
UsageThrottle.EffectiveSlots(configured, five, seven, Soft, Hard, GateFive, GateSeven);
UsageThrottle.EffectiveSlots(configured, five, FiveHour, seven, SevenDay);
[Fact]
public void BelowSoftThreshold_ReturnsFullConfiguredSlots()
@@ -94,13 +92,19 @@ public sealed class UsageThrottleTests
[Fact]
public void ZeroSoftAndHardThresholds_NeverThrottleBelowGate()
{
Assert.Equal(3, UsageThrottle.EffectiveSlots(3, 79, 89, softPct: 0, hardPct: 0, gateFiveHourPct: GateFive, gateSevenDayPct: GateSeven));
var five = new UsageThresholds(0, 0, 80);
var seven = new UsageThresholds(0, 0, 90);
Assert.Equal(3, UsageThrottle.EffectiveSlots(3, 79, five, 89, seven));
}
[Fact]
public void ZeroGateThresholds_NeverHardBlock()
{
Assert.Equal(1, UsageThrottle.EffectiveSlots(3, 99, 99, softPct: Soft, hardPct: Hard, gateFiveHourPct: 0, gateSevenDayPct: 0));
var five = new UsageThresholds(50, 65, 0);
var seven = new UsageThresholds(50, 65, 0);
Assert.Equal(1, UsageThrottle.EffectiveSlots(3, 99, five, 99, seven));
}
[Fact]
@@ -108,4 +112,46 @@ public sealed class UsageThrottleTests
{
Assert.Equal(1, Effective(10, 10, configured: 0));
}
// ── Per-bucket thresholds are independent ───────────────────────────────
[Fact]
public void PerBucket_SameUtilization_DifferentStagesPerBucket()
{
// 60% is past the 5h soft (50) but still under the 7d soft (70): the 5h bucket decides.
var five = new UsageThresholds(50, 65, 80);
var seven = new UsageThresholds(70, 85, 90);
Assert.Equal(2, UsageThrottle.EffectiveSlots(3, 60, five, 60, seven));
}
[Fact]
public void PerBucket_LessUtilizedBucketCanStillBeTheStricterOne()
{
// 7d sits lower (40%) but has the tighter thresholds, so it — not the busier 5h — throttles.
var five = new UsageThresholds(90, 95, 99);
var seven = new UsageThresholds(20, 35, 90);
Assert.Equal(1, UsageThrottle.EffectiveSlots(3, 80, five, 40, seven));
}
[Fact]
public void PerBucket_StrictestStageWins()
{
// 5h is only in its soft stage (2 slots), 7d is past its hard stage (1 slot) → 1 wins.
var five = new UsageThresholds(50, 65, 80);
var seven = new UsageThresholds(30, 40, 90);
Assert.Equal(1, UsageThrottle.EffectiveSlots(3, 55, five, 45, seven));
}
[Fact]
public void PerBucket_MissingBucketNeverThrottles()
{
// No 7d reading at all: only the 5h bucket may step parallelism down.
var five = new UsageThresholds(50, 65, 80);
var seven = new UsageThresholds(1, 2, 3);
Assert.Equal(2, UsageThrottle.EffectiveSlots(3, 55, five, null, seven));
}
}