using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Tests.Infrastructure; using ClaudeDo.Worker.Usage; using ClaudeDo.Worker.Usage.Interfaces; namespace ClaudeDo.Worker.Tests.Usage; public sealed class UsageSnapshotBuilderTests : IDisposable { private readonly DbFixture _db = new(); public void Dispose() => _db.Dispose(); private sealed class FakeGate : IUsageGate { private readonly UsageGateDecision _decision; public FakeGate(UsageGateDecision decision) => _decision = decision; public Task EvaluateAsync(CancellationToken ct = default) => Task.FromResult(_decision); } private async Task SetThresholdsAsync(int fiveHourPct, int sevenDayPct) { using var ctx = _db.CreateContext(); var repo = new AppSettingsRepository(ctx); var settings = await repo.GetAsync(); settings.UsageGateFiveHourPct = fiveHourPct; settings.UsageGateSevenDayPct = sevenDayPct; await repo.UpdateAsync(settings); } private UsageSnapshotBuilder CreateBuilder(UsageState state, UsageGateDecision decision, WorkerConfig? cfg = null) => new(state, new FakeGate(decision), _db.CreateFactory(), cfg ?? new WorkerConfig()); [Fact] public async Task Maps_buckets_limits_thresholds_and_gate_state() { await SetThresholdsAsync(70, 85); var fiveHourReset = DateTimeOffset.UtcNow.AddHours(2); var sevenDayReset = DateTimeOffset.UtcNow.AddDays(3); var limit = new UsageLimitRow("session", "default", 42.5, "warn", fiveHourReset, "sonnet", true); var snapshot = new UsageSnapshot( new UsageBucket(55.5, fiveHourReset), new UsageBucket(88.8, sevenDayReset), new[] { limit }, DateTime.UtcNow); var state = new UsageState(); state.ReportSuccess(snapshot); var builder = CreateBuilder(state, new UsageGateDecision(true, "5h-Limit 90% >= 70%")); var dto = await builder.BuildAsync(); Assert.Equal(55.5, dto.FiveHourPercent); Assert.Equal(fiveHourReset, dto.FiveHourResetsAt); Assert.Equal(88.8, dto.SevenDayPercent); Assert.Equal(sevenDayReset, dto.SevenDayResetsAt); Assert.Equal(70, dto.FiveHourThresholdPct); Assert.Equal(85, dto.SevenDayThresholdPct); Assert.True(dto.IsGateBlocked); Assert.Equal("5h-Limit 90% >= 70%", dto.GateReason); Assert.Equal(snapshot.FetchedAtUtc, dto.FetchedAtUtc); Assert.False(dto.IsStale); Assert.Null(dto.LastError); var mappedLimit = Assert.Single(dto.Limits); Assert.Equal(limit.Kind, mappedLimit.Kind); Assert.Equal(limit.Group, mappedLimit.Group); Assert.Equal(limit.Percent, mappedLimit.Percent); Assert.Equal(limit.Severity, mappedLimit.Severity); Assert.Equal(limit.ResetsAt, mappedLimit.ResetsAt); Assert.Equal(limit.ScopeModelDisplayName, mappedLimit.ScopeModelDisplayName); Assert.Equal(limit.IsActive, mappedLimit.IsActive); } [Fact] public async Task No_snapshot_yields_null_percents_stale_and_not_blocked() { await SetThresholdsAsync(80, 90); var state = new UsageState(); var builder = CreateBuilder(state, new UsageGateDecision(false, null)); var dto = await builder.BuildAsync(); Assert.Null(dto.FiveHourPercent); Assert.Null(dto.SevenDayPercent); Assert.Empty(dto.Limits); Assert.Null(dto.FetchedAtUtc); Assert.True(dto.IsStale); Assert.False(dto.IsGateBlocked); } [Fact] public async Task Snapshot_older_than_4x_poll_interval_is_stale() { await SetThresholdsAsync(80, 90); var cfg = new WorkerConfig { UsagePollIntervalActiveSeconds = 60, UsagePollIntervalIdleSeconds = 60 }; var state = new UsageState(); state.ReportSuccess(new UsageSnapshot( new UsageBucket(10, null), null, Array.Empty(), DateTime.UtcNow.AddSeconds(-4 * 60))); var builder = CreateBuilder(state, new UsageGateDecision(false, null), cfg); var dto = await builder.BuildAsync(); Assert.True(dto.IsStale); } [Fact] public async Task Fresh_snapshot_is_not_stale() { await SetThresholdsAsync(80, 90); var cfg = new WorkerConfig { UsagePollIntervalActiveSeconds = 60, UsagePollIntervalIdleSeconds = 60 }; var state = new UsageState(); state.ReportSuccess(new UsageSnapshot( new UsageBucket(10, null), null, Array.Empty(), DateTime.UtcNow)); var builder = CreateBuilder(state, new UsageGateDecision(false, null), cfg); var dto = await builder.BuildAsync(); Assert.False(dto.IsStale); } [Fact] public async Task Failed_last_poll_marks_stale_even_with_fresh_snapshot() { await SetThresholdsAsync(80, 90); var state = new UsageState(); state.ReportSuccess(new UsageSnapshot( new UsageBucket(10, null), null, Array.Empty(), DateTime.UtcNow)); state.ReportFailure("boom", DateTime.UtcNow); var builder = CreateBuilder(state, new UsageGateDecision(false, null)); var dto = await builder.BuildAsync(); Assert.True(dto.IsStale); Assert.Equal("boom", dto.LastError); } private async Task SetThrottleAsync(int maxParallel, int softPct, int hardPct) { using var ctx = _db.CreateContext(); var repo = new AppSettingsRepository(ctx); var settings = await repo.GetAsync(); settings.MaxParallelExecutions = maxParallel; settings.UsageThrottleSoftPct = softPct; settings.UsageThrottleHardPct = hardPct; await repo.UpdateAsync(settings); } [Fact] public async Task Throttled_slots_and_decisive_bucket_reported() { await SetThresholdsAsync(80, 90); await SetThrottleAsync(maxParallel: 3, softPct: 50, hardPct: 65); var state = new UsageState(); state.ReportSuccess(new UsageSnapshot( new UsageBucket(70, null), new UsageBucket(20, null), Array.Empty(), DateTime.UtcNow)); var builder = CreateBuilder(state, new UsageGateDecision(false, null)); var dto = await builder.BuildAsync(); Assert.Equal(3, dto.ConfiguredSlots); Assert.Equal(1, dto.EffectiveSlots); Assert.Equal("five_hour", dto.ThrottleBucket); } [Fact] public async Task SevenDay_decisive_bucket_reported_when_higher() { await SetThresholdsAsync(80, 90); await SetThrottleAsync(maxParallel: 3, softPct: 50, hardPct: 65); var state = new UsageState(); state.ReportSuccess(new UsageSnapshot( new UsageBucket(10, null), new UsageBucket(70, null), Array.Empty(), DateTime.UtcNow)); var builder = CreateBuilder(state, new UsageGateDecision(false, null)); var dto = await builder.BuildAsync(); Assert.Equal("seven_day", dto.ThrottleBucket); } [Fact] public async Task Not_throttled_reports_null_bucket_and_equal_slots() { await SetThresholdsAsync(80, 90); await SetThrottleAsync(maxParallel: 3, softPct: 50, hardPct: 65); var state = new UsageState(); state.ReportSuccess(new UsageSnapshot( new UsageBucket(10, null), new UsageBucket(20, null), Array.Empty(), DateTime.UtcNow)); var builder = CreateBuilder(state, new UsageGateDecision(false, null)); var dto = await builder.BuildAsync(); Assert.Equal(3, dto.ConfiguredSlots); Assert.Equal(3, dto.EffectiveSlots); Assert.Null(dto.ThrottleBucket); } [Fact] public async Task No_snapshot_falls_back_to_configured_slots_without_throttling() { await SetThresholdsAsync(80, 90); await SetThrottleAsync(maxParallel: 3, softPct: 50, hardPct: 65); var state = new UsageState(); var builder = CreateBuilder(state, new UsageGateDecision(false, null)); var dto = await builder.BuildAsync(); Assert.Equal(3, dto.ConfiguredSlots); Assert.Equal(3, dto.EffectiveSlots); Assert.Null(dto.ThrottleBucket); } }