Files
ClawdDotNet/tests/ClawdDotNet.Core.Tests/Accounting/UnitCostsTests.cs
T
RichardandClaude Opus 5.5 da34b215f7 Kosten je Einheit aufrechnen und Abweichungen melden (#699)
- IUsageRepository.GetDailyByAgentAsync: Tagessummen je Agent, Abo-Tokens getrennt
- UnitCosts: je Einheit samt Untereinheiten heute/7/30 Tage, Monat, Vormonat,
  Durchschnittstag (28 Tage, ab 7 Tagen Verlauf), Maximum = Summe der Agentengrenzen
- Meldungen: Abweichung vom eigenen Verlauf, Naehe zur Grenzsumme, Monatsschwellen
  der Instanz; CostWatch alle 15 min, je Einheit/Tag einmal (Tabelle CostAlerts),
  als Aufgabe an den Menschen; schaltet nichts ab
- API /usage/units, /usage/cost-alerts; Zeitreihe gruppiert nach Einheit
- WebUI: Karte "Kosten je Einheit" mit Meldungen in der Verbrauchsansicht; i18n en/de
- Einstellungen instance.json costWatch
- Tests: UnitCostsTests (8)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 09:09:42 +02:00

140 lines
6.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// #699: Costs per unit are added up, not capped — and what changed is reported, once.
/// </summary>
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);
/// <summary>marketing (ads, listing) › amazon (seller); bookkeeper on the top level.</summary>
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);
/// <summary>Four weeks of the same course, then today.</summary>
private static List<AgentDayUsage> 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<AgentDayUsage> { 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);
}
}