Files
ClawdDotNet/tests/ClawdDotNet.Core.Tests/Accounting/KostenPrognoseTests.cs
T
RichardandClaude Opus 5 0b6ea73ec1 Entscheidungen aus dem Testbetrieb umgesetzt (#572): #577 #579 #581 #589 #193 #205 #594 #595 #597 #598 #599 #588
- Engine: leere Antworten nachfassen, Aufgaben enden mit Ergebniszeile (EmptyResponse/Incomplete),
  gleiche Fehlschlaege erkennen (RepeatedFailure), Zeitzeile vor Chatnachrichten,
  Werkzeugergebnisse gerahmt, Vorpruefung vor dem Staging, Belegkosten wie RunUsage
- Staging: gescheiterte Freigaben als Failed; AgentEditor-Aenderungen freigabepflichtig,
  Selbstbearbeitung gesperrt (allowSelfEdit)
- Taskboard: Termine beim Anlegen pruefen, naechster/letzter Lauf, pausieren, Sofortlauf,
  Kostenprognose, Hinweis auf wachsenden Kontext; Jobs-Reiter und /api/jobs entfernt
- Release: Instanzmanager samt Weboberflaeche im Paket, setup.json fragt manager.json ab,
  Token nicht mehr ins systemd-Journal
Version 0.1.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 11:23:10 +02:00

133 lines
5.2 KiB
C#

using ClawdDotNet.Core.Accounting;
using ClawdDotNet.Core.Api;
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Tasks;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Accounting;
/// <summary>Die Kostenprognose (#595): Grundlage je Aufgabe, Hochrechnung, sonstige Laeufe.</summary>
public sealed class KostenPrognoseTests
{
private static readonly DateTime Jetzt = new(2026, 9, 15, 10, 0, 0, DateTimeKind.Utc);
private static readonly AgentConfig Nachrichten = new()
{
AgentId = "nachrichten",
Model = "anthropic/claude-haiku-4.5",
SystemPrompt = new string('x', 4000)
};
private static TaskItem Stuendlich(string id, string assignee = "@new:nachrichten", TaskItemStatus status = TaskItemStatus.Todo)
=> new()
{
Id = id, Title = id, Assignee = assignee, Status = status,
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "5 * * * *", TimeZone = "Europe/Berlin" }
};
private static RunReceipt Beleg(string taskId, decimal usd, int stundenZurueck = 1)
=> new()
{
TaskId = taskId, AgentId = "nachrichten", CostUsd = usd, CostIsKnown = true,
OccurredAt = Jetzt.AddHours(-stundenZurueck)
};
private static Dictionary<string, IReadOnlyList<RunReceipt>> Belege(params (string Id, RunReceipt[] Liste)[] eintraege)
=> eintraege.ToDictionary(e => e.Id, e => (IReadOnlyList<RunReceipt>)e.Liste);
private static InstanzPrognose Rechne(
IReadOnlyList<TaskItem> aufgaben,
Dictionary<string, IReadOnlyList<RunReceipt>>? belege = null,
IReadOnlyList<RunUsage>? verbrauch = null,
Func<string, int, int, CostEstimate>? schaetzen = null)
=> KostenPrognose.Berechne(aufgaben, belege ?? [], verbrauch ?? [], [Nachrichten], schaetzen, Jetzt);
[Fact]
public void Mittelwert_der_letzten_Laeufe_mal_Laeufe_je_Tag()
{
var p = Rechne([Stuendlich("t-1")], Belege(("t-1", [Beleg("t-1", 0.01m), Beleg("t-1", 0.03m, 2)])));
var zeile = p.Aufgaben.Single();
zeile.Grundlage.ShouldBe(PrognoseGrundlage.Laeufe);
zeile.Stichprobe.ShouldBe(2);
zeile.KostenJeLauf.ShouldBe(0.02m);
zeile.LaeufeProTag.ShouldBe(24);
zeile.KostenProTag.ShouldBe(0.48m);
p.SummeProMonat.ShouldBe(14.4m);
}
[Fact]
public void Ohne_eigene_Laeufe_gilt_der_Schnitt_des_Agenten()
{
var p = Rechne([Stuendlich("t-1"), Stuendlich("t-2")], Belege(("t-1", [Beleg("t-1", 0.02m)])));
var neu = p.Aufgaben.Single(z => z.TaskId == "t-2");
neu.Grundlage.ShouldBe(PrognoseGrundlage.Agent);
neu.KostenJeLauf.ShouldBe(0.02m);
}
[Fact]
public void Vor_dem_ersten_Lauf_schaetzt_sie_aus_dem_Modellpreis()
{
var aufgabe = Stuendlich("t-1");
CostEstimate Preis(string modell, int prompt, int completion)
=> new(prompt / 1_000_000m + completion * 5 / 1_000_000m, true, modell);
var p = Rechne([aufgabe], schaetzen: Preis);
var zeile = p.Aufgaben.Single();
zeile.Grundlage.ShouldBe(PrognoseGrundlage.Schaetzung);
var erwartet = KostenPrognose.GeschaetztePromptTokens(aufgabe, Nachrichten) / 1_000_000m
+ KostenPrognose.AnnahmeSchritte * KostenPrognose.AnnahmeAntwortTokensJeSchritt * 5 / 1_000_000m;
zeile.KostenJeLauf.ShouldBe(Math.Round(erwartet, 4));
}
[Fact]
public void Ohne_Preis_und_Laeufe_bleibt_sie_ehrlich_unbekannt()
{
var p = Rechne([Stuendlich("t-1")], schaetzen: (m, _, _) => new CostEstimate(0m, false, m));
var zeile = p.Aufgaben.Single();
zeile.Grundlage.ShouldBe(PrognoseGrundlage.Unbekannt);
zeile.KostenJeLauf.ShouldBeNull();
zeile.KostenProTag.ShouldBe(0m);
p.OhneKostenbasis.ShouldBe(1);
}
[Fact]
public void Pausierte_und_menschliche_Aufgaben_kosten_nichts()
=> Rechne([Stuendlich("t-p", status: TaskItemStatus.Backlog), Stuendlich("t-h", "@human")])
.Aufgaben.ShouldBeEmpty();
[Fact]
public void Sonstige_Laeufe_sind_das_Tagesmittel_ausserhalb_der_Aufgaben()
{
var p = Rechne(
[Stuendlich("t-1")],
Belege(("t-1", [Beleg("t-1", 0.14m)])),
[new RunUsage { AgentId = "nachrichten", CostUsd = 0.70m, CostIsKnown = true, OccurredAt = Jetzt.AddDays(-1) }]);
p.SonstigeProTag.ShouldBe(0.08m); // (0,70 - 0,14) / 7
p.AufgabenProTag.ShouldBe(3.36m); // 0,14 * 24
p.SummeProTag.ShouldBe(3.44m);
}
[Fact]
public void Werkzeugjob_zaehlt_nur_tatsaechliche_Weckungen()
{
var job = new TaskItem
{
Id = "tj", Title = "Telegram", Assignee = "@nachrichten", Type = TaskItemType.ToolJob,
Status = TaskItemStatus.Todo,
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "* * * * *", TimeZone = "UTC" }
};
var p = Rechne([job], Belege(("tj", [Beleg("tj", 0.07m), Beleg("tj", 0.07m, 30)])));
var zeile = p.Aufgaben.Single();
zeile.KostenProTag.ShouldBe(0.02m, "zwei Weckungen in sieben Tagen, nicht 1.440 Ticks je Tag");
zeile.LaeufeProTag.ShouldBe(0.29);
}
}