feat(usage): split throttle thresholds per bucket, add draggable gauge markers
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user