using ClawdDotNet.Core.Accounting; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Organisation; using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Tasks; using ClawdDotNet.Core.Tests.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Core.Tests.Accounting; /// /// #699: Costs per unit are added up, not capped — and what changed is reported, once. /// public sealed class UnitCostsTests : IDisposable { private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "unitcosts-" + Guid.NewGuid().ToString("N")[..8]); private static readonly DateOnly Today = new(2027, 3, 10); /// marketing (ads, listing) › amazon (seller); bookkeeper on the top level. private static OrgChart Org(decimal adsLimit = 10) => new( [new UnitConfig { Id = "marketing", Name = "Marketing" }, new UnitConfig { Id = "amazon", Name = "Amazon", ParentId = "marketing" }], [ new AgentConfig { AgentId = "ads", UnitId = "marketing", Budget = { DailyCostUsd = adsLimit } }, new AgentConfig { AgentId = "listing", UnitId = "marketing", Budget = { DailyCostUsd = 5 } }, new AgentConfig { AgentId = "seller", UnitId = "amazon", Budget = { DailyCostUsd = 5 } }, new AgentConfig { AgentId = "bookkeeper", Budget = { DailyCostUsd = 2 } } ]); public void Dispose() { SqliteStorage.ReleasePooledConnections(_directory); try { Directory.Delete(_directory, recursive: true); } catch { /* cleaning up is secondary */ } } private static AgentDayUsage Day(int daysAgo, string agent, decimal cost, long subscriptionTokens = 0) => new(Today.AddDays(-daysAgo), agent, cost, 1000, subscriptionTokens, 1); /// Four weeks of the same course, then today. private static List Course(decimal perDay, decimal today, string agent = "ads") { var rows = Enumerable.Range(1, 28).Select(d => Day(d, agent, perDay)).ToList(); rows.Add(Day(0, agent, today)); return rows; } [Fact] public void A_unit_adds_up_itself_and_everything_below_it() { var lines = UnitCosts.Build(Org(), [Day(0, "ads", 3), Day(0, "seller", 2), Day(0, "bookkeeper", 1)], Today); lines.Single(l => l.UnitId is null).Today.ShouldBe(6); lines.Single(l => l.UnitId == "marketing").Today.ShouldBe(5, "amazon is below marketing"); lines.Single(l => l.UnitId == "amazon").Today.ShouldBe(2); lines.Single(l => l.UnitId == "").Today.ShouldBe(1, "the top level stands apart"); lines.Select(l => l.Depth).ShouldBe([0, 1, 2, 1]); } [Fact] public void The_maximum_is_the_sum_of_the_agents_limits_and_unknown_when_one_has_none() { UnitCosts.Build(Org(), [], Today).Single(l => l.UnitId == "marketing").MaxPerDay.ShouldBe(20); UnitCosts.Build(Org(adsLimit: 0), [], Today).Single(l => l.UnitId == "marketing").MaxPerDay.ShouldBeNull(); } [Fact] public void Months_are_compared_and_subscription_runs_count_as_quota_not_as_free() { var lines = UnitCosts.Build(Org(), [Day(0, "ads", 4, subscriptionTokens: 900), Day(12, "ads", 6)], Today); var marketing = lines.Single(l => l.UnitId == "marketing"); marketing.ThisMonth.ShouldBe(4); marketing.PreviousMonth.ShouldBe(6, "12 days before 10 March lies in February"); marketing.SubscriptionTokensToday.ShouldBe(900); } [Fact] public void A_day_far_above_the_own_course_is_reported_though_nothing_is_exceeded() { var alerts = UnitCosts.Alerts(UnitCosts.Build(Org(), Course(1.5m, 4m), Today), new CostWatchSettings(), Today); alerts.ShouldContain(a => a.Kind == CostAlertKind.Deviation && a.Scope == "marketing"); alerts.ShouldNotContain(a => a.Kind == CostAlertKind.NearLimit, "4 $ of 20 $ is far from the limits"); } [Fact] public void Small_amounts_and_a_short_history_raise_nothing() { UnitCosts.Alerts(UnitCosts.Build(Org(), Course(0.01m, 0.5m), Today), new CostWatchSettings(), Today) .ShouldNotContain(a => a.Kind == CostAlertKind.Deviation, "below a dollar a day nothing deviates"); var shortHistory = new List { Day(1, "ads", 1), Day(2, "ads", 1), Day(0, "ads", 9) }; UnitCosts.Alerts(UnitCosts.Build(Org(), shortHistory, Today), new CostWatchSettings(), Today) .ShouldNotContain(a => a.Kind == CostAlertKind.Deviation, "two days are no course"); } [Fact] public void A_unit_near_the_sum_of_its_limits_is_reported_before_its_agents_run_into_them() { var alerts = UnitCosts.Alerts(UnitCosts.Build(Org(), [Day(0, "ads", 9), Day(0, "listing", 4), Day(0, "seller", 4)], Today), new CostWatchSettings(), Today); alerts.ShouldContain(a => a.Kind == CostAlertKind.NearLimit && a.Scope == "marketing"); } [Fact] public void A_monthly_total_is_reported_at_the_highest_threshold_crossed() { var settings = new CostWatchSettings { MonthlyThresholdsUsd = [5, 10, 50] }; var alert = UnitCosts.Alerts(UnitCosts.Build(Org(), [Day(0, "ads", 7), Day(3, "seller", 5)], Today), settings, Today) .Single(a => a.Kind == CostAlertKind.MonthlyThreshold); alert.Scope.ShouldBe("instance:10"); } [Fact] public async Task The_watch_raises_an_alert_once_and_hands_it_to_the_human() { var storage = new SqliteStorage(Path.Combine(_directory, "state.db")); var usage = new SqliteUsageRepository(storage); var tasks = new SqliteTaskRepository(storage); var board = new TaskboardService(tasks, Path.Combine(_directory, "tasks")); var now = DateTime.Now; foreach (var d in Enumerable.Range(1, 28)) await usage.RecordAsync(new RunUsage { AgentId = "ads", Model = "m", CostUsd = 1m, CostIsKnown = true, Status = "Completed", OccurredAt = now.AddDays(-d) }, default); await usage.RecordAsync(new RunUsage { AgentId = "ads", Model = "m", CostUsd = 5m, CostIsKnown = true, Status = "Completed", OccurredAt = now }, default); var watch = new CostWatch(storage, usage, () => Org(), () => new CostWatchSettings(), board, NullLogger.Instance); (await watch.CheckAsync(default)).ShouldContain(a => a.Kind == CostAlertKind.Deviation && a.Scope == "marketing"); (await watch.CheckAsync(default)).ShouldBeEmpty("once a day, not every quarter of an hour"); (await board.ListAsync(new TaskQuery { Assignee = TaskAssignee.Human }, default)).ShouldContain(t => t.Title.Contains("marketing")); (await watch.RecentAsync(7, default)).ShouldNotBeEmpty(); (await watch.ReportAsync(default)).Single(l => l.UnitId == "marketing").Today.ShouldBe(5); } }