Files
ClawdDotNet/tests/ClawdDotNet.Core.Tests/Budget/BudgetGuardTests.cs
T
RichardandClaude Opus 5 cffd6f4b77 Bugtracker-Durchgang: 13 Bugs behoben, Build ohne Warnungen
Alle Behebungen auf der Linux-Test-VM gegengeprueft (Ubuntu, .NET 10.0.111):
Build 0 Warnungen / 0 Fehler, 637 Tests gruen, 1 uebersprungen (WindowsFact).

Sicherheit und Zielplattform
  #373 PathBoundary.Canonicalize fiel offen aus. File.ResolveLinkTarget wirft
       fuer einen fehlenden Abschnitt FileNotFoundException; der catch(IOException)
       verliess daraufhin die GANZE Aufloesung mit dem unaufgeloesten Pfad - und
       der besteht jede Praefixpruefung. Der Symlink-Schutz war damit bei jedem
       Schreibvorgang wirkungslos. Jetzt ueber FileSystemInfo.LinkTarget, und
       TryCanonicalize meldet "nicht bestimmbar", statt fail-open durchzureichen.
  #374 WorkspacePath weist den Backslash rundheraus ab, wie den Doppelpunkt.
       Eine Schreibweise statt einer Sonderregel je Windows-Form.
  #375 Log-Verzeichnis: leer heisst "Anwendungseinstellung"; ein relativer Wert
       zaehlt gegen das Instanzverzeichnis, nicht gegen das Arbeitsverzeichnis
       des Prozesses. Ein systemd-Dienst landet nicht mehr auf /Logs.

Agentenkommunikation
  #206 Delegationskette gegen A->B->A (Test A13). Zyklusfrei per Bauart, dazu
       MaxDelegationDepth gegen die Staffel, die niemand beauftragt hat.
  #461 Zwei unabhaengige Ketten ueber Kreuz - beim Beheben von #206 gefunden und
       reproduziert. Ein delegierter Lauf wartet nur noch begrenzt auf ein fremdes
       Gate; ein Chat vom Menschen reiht sich weiter unbegrenzt ein.
  #234 Instanz-Id wird durchgereicht statt aus einem nur beilaeufig gesetzten Feld
       gelesen.

Betrieb und Oberflaeche
  #376 Log-Ansicht chronologisch statt gruppiert je Moduldatei, mit Stacktraces
       an ihrer Meldung - auch ueber Taktgrenzen hinweg.
  #377 Info-Tabelle: feste Spaltenbreiten, damit ein langer Aenderungstext den
       anderen Spalten nicht den Platz nimmt.
  #378 Sicherung: Mindestabstand zwischen Ueberschrift und Schaltflaechenleiste.
  #379 Hellmodus: drei von vier gedaempften Textpinseln lagen unter 4,5:1. Die
       helle Palette ist als Ganzes verschoben, damit die Staffelung sichtbar
       bleibt statt auf einen Wert zusammenzufallen.

Hygiene
  #233 stock_add: der Indexstand wird innerhalb der Sperre festgehalten.
  #235 Zeitumstellung: eine nicht existierende Ortszeit wird auf den ersten
       Zeitpunkt nach der Luecke verschoben statt uebersprungen (R4, R5).
  #236 45 Nullable-Warnungen und eine CA1416 - nicht unterdrueckt, sondern die
       Stellen so umgebaut, dass die Zusage nachvollziehbar wird. Mail meldet
       jetzt, WELCHER Zugangsschluessel fehlt, statt an einer
       KeyNotFoundException zu scheitern.
  #203 Kompaktiert wird vor dem API-Aufruf statt danach; die gemessenen
       Prompt-Groessen kalibrieren die Schaetzung je Modell.

Neu: tests/ClawdDotNet.App.Tests - fuer #375 und #376 gab es keinen Ort fuer
Tests. Dazu Regressionstests fuer jede Behebung; jeder wurde gegen den alten
Stand geprueft, damit belegt ist, dass er den Fehler faengt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:04:48 +02:00

326 lines
12 KiB
C#

using ClawdDotNet.Core.Accounting;
using ClawdDotNet.Core.Budget;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Storage;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Budget;
/// <summary>
/// K5 aus der Bestandsaufnahme: Es gab keine Obergrenze. Ein Agent in einer Schleife —
/// etwa durch gegenseitige send_message-Aufrufe — konnte unbeaufsichtigt Guthaben
/// verbrennen; die Credits-Anzeige war rein informativ.
/// </summary>
public sealed class BudgetGuardTests : IDisposable
{
private readonly string _directory;
private readonly SqliteUsageRepository _usage;
private readonly DateTime _heute = new(2026, 7, 28, 14, 0, 0, DateTimeKind.Local);
public BudgetGuardTests()
{
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
_usage = new SqliteUsageRepository(new SqliteStorage(Path.Combine(_directory, "state.db")));
}
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
private BudgetGuard Guard() => new(_usage, () => _heute);
private static AgentConfig Agent(decimal costLimit = 0, long tokenLimit = 0, string id = "agent-a") => new()
{
AgentId = id,
Budget = new AgentBudget { DailyCostUsd = costLimit, DailyTokens = tokenLimit }
};
private Task Record(decimal cost, int prompt = 1000, int completion = 100,
string agentId = "agent-a", bool costKnown = true, DateTime? when = null)
=> _usage.RecordAsync(new RunUsage
{
AgentId = agentId,
Model = "test/model",
PromptTokens = prompt,
CompletionTokens = completion,
CostUsd = cost,
CostIsKnown = costKnown,
OccurredAt = when ?? _heute
}, default);
// ═══════════════════════════════════════════════════════════
// Ohne Grenzen
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Ohne_gesetzte_Grenze_darf_immer_gelaufen_werden()
{
await Record(cost: 1_000m);
var status = await Guard().CheckAsync(Agent(), InstanceBudget.Unlimited, default);
status.IsAllowed.ShouldBeTrue();
}
// ═══════════════════════════════════════════════════════════
// Kostengrenze
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Unterhalb_der_Kostengrenze_laeuft_der_Agent_weiter()
{
await Record(cost: 1.50m);
var status = await Guard().CheckAsync(Agent(costLimit: 5m), InstanceBudget.Unlimited, default);
status.IsAllowed.ShouldBeTrue();
}
[Fact]
public async Task Bei_erreichter_Kostengrenze_wird_kein_Lauf_mehr_gestartet()
{
await Record(cost: 3m);
await Record(cost: 2m);
var status = await Guard().CheckAsync(Agent(costLimit: 5m), InstanceBudget.Unlimited, default);
status.IsAllowed.ShouldBeFalse();
status.Limit.ShouldBe(BudgetLimit.AgentCost);
status.Reason.ShouldNotBeNull().ShouldContain("5");
}
// ═══════════════════════════════════════════════════════════
// Token-Grenze — greift auch ohne Preisangaben
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Die_Tokengrenze_wirkt_auch_wenn_keine_Preise_vorliegen()
{
// Der eigentliche Zweck der zweiten Grenze: Bei unbekanntem Modell bleibt die
// Kostensumme 0 und eine reine Kostengrenze wäre wirkungslos.
await Record(cost: 0m, prompt: 60_000, completion: 5_000, costKnown: false);
var agent = Agent(costLimit: 10m, tokenLimit: 50_000);
var status = await Guard().CheckAsync(agent, InstanceBudget.Unlimited, default);
status.IsAllowed.ShouldBeFalse();
status.Limit.ShouldBe(BudgetLimit.AgentTokens);
}
[Fact]
public async Task Fehlende_Preise_werden_in_der_Begruendung_erwaehnt()
{
// Sonst wirkte ein niedriger Verbrauch wie ein noch offener Spielraum.
await Record(cost: 0m, prompt: 10_000, completion: 1_000, costKnown: false);
await Record(cost: 5m);
var status = await Guard().CheckAsync(Agent(costLimit: 5m), InstanceBudget.Unlimited, default);
status.IsAllowed.ShouldBeFalse();
status.Reason.ShouldNotBeNull().ShouldContain("keine Preise");
}
// ═══════════════════════════════════════════════════════════
// Instanzgrenze
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Die_Instanzgrenze_zaehlt_alle_Agenten_zusammen()
{
await Record(cost: 3m, agentId: "agent-a");
await Record(cost: 3m, agentId: "agent-b");
await Record(cost: 3m, agentId: "agent-c");
var instance = new InstanceBudget { DailyCostUsd = 8m };
var status = await Guard().CheckAsync(Agent(), instance, default);
status.IsAllowed.ShouldBeFalse();
status.Limit.ShouldBe(BudgetLimit.InstanceCost);
}
[Fact]
public async Task Ein_einzelner_Agent_bleibt_unter_der_Instanzgrenze_erlaubt()
{
await Record(cost: 2m, agentId: "agent-a");
var instance = new InstanceBudget { DailyCostUsd = 10m };
var status = await Guard().CheckAsync(Agent(costLimit: 5m), instance, default);
status.IsAllowed.ShouldBeTrue();
}
// ═══════════════════════════════════════════════════════════
// Tageswechsel
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Der_Verbrauch_des_Vortages_zaehlt_nicht_mit()
{
await Record(cost: 100m, when: _heute.AddDays(-1));
var status = await Guard().CheckAsync(Agent(costLimit: 5m), InstanceBudget.Unlimited, default);
status.IsAllowed.ShouldBeTrue();
}
[Fact]
public async Task Am_naechsten_Tag_steht_das_Budget_wieder_zur_Verfuegung()
{
await Record(cost: 10m);
var morgen = new BudgetGuard(_usage, () => _heute.AddDays(1));
var status = await morgen.CheckAsync(Agent(costLimit: 5m), InstanceBudget.Unlimited, default);
status.IsAllowed.ShouldBeTrue();
}
// ═══════════════════════════════════════════════════════════
// Restbudget
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Das_Restbudget_wird_korrekt_berechnet()
{
await Record(cost: 1.25m);
var remaining = await Guard().GetRemainingCostAsync(Agent(costLimit: 5m), default);
remaining.ShouldBe(3.75m);
}
[Fact]
public async Task Ohne_Kostengrenze_gibt_es_kein_Restbudget()
{
(await Guard().GetRemainingCostAsync(Agent(), default)).ShouldBeNull();
}
[Fact]
public async Task Das_Restbudget_wird_nicht_negativ()
{
await Record(cost: 20m);
(await Guard().GetRemainingCostAsync(Agent(costLimit: 5m), default)).ShouldBe(0m);
}
}
public sealed class UsageRepositoryTests : IDisposable
{
private readonly string _directory;
private readonly SqliteUsageRepository _usage;
private static readonly DateTime Tag = new(2026, 7, 28, 10, 0, 0, DateTimeKind.Local);
public UsageRepositoryTests()
{
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
_usage = new SqliteUsageRepository(new SqliteStorage(Path.Combine(_directory, "state.db")));
}
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { Directory.Delete(_directory, recursive: true); }
catch { }
}
private Task Record(decimal cost, string agentId = "a", DateTime? when = null, bool known = true)
=> _usage.RecordAsync(new RunUsage
{
AgentId = agentId,
Model = "test/model",
PromptTokens = 1000,
CompletionTokens = 200,
CostUsd = cost,
CostIsKnown = known,
Status = "Completed",
OccurredAt = when ?? Tag
}, default);
[Fact]
public async Task Betraege_ueberstehen_den_Rundlauf_ohne_Rundungsfehler()
{
// Als Fliesskommazahl abgelegt wuerden sich Cent-Betraege ueber tausende
// Laeufe zu einem sichtbaren Fehler summieren.
for (var i = 0; i < 1000; i++)
await Record(0.0001m);
var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), "a", default);
daily.CostUsd.ShouldBe(0.1m);
}
[Fact]
public async Task Der_Tagesverbrauch_summiert_Tokens_und_Laeufe()
{
await Record(1m);
await Record(2m);
var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), "a", default);
daily.RunCount.ShouldBe(2);
daily.PromptTokens.ShouldBe(2000);
daily.CompletionTokens.ShouldBe(400);
daily.TotalTokens.ShouldBe(2400);
}
[Fact]
public async Task Ohne_Agentenfilter_zaehlt_die_ganze_Instanz()
{
await Record(1m, agentId: "a");
await Record(2m, agentId: "b");
var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), agentId: "", default);
daily.CostUsd.ShouldBe(3m);
daily.RunCount.ShouldBe(2);
}
[Fact]
public async Task Ein_Lauf_ohne_Preisangabe_macht_den_Tag_unvollstaendig()
{
await Record(1m);
await Record(0m, known: false);
var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), "a", default);
daily.CostIsComplete.ShouldBeFalse();
}
[Fact]
public async Task Ein_Tag_ohne_Laeufe_ist_leer()
{
var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), "a", default);
daily.RunCount.ShouldBe(0);
daily.CostUsd.ShouldBe(0m);
daily.CostIsComplete.ShouldBeTrue();
}
[Fact]
public async Task Alte_Eintraege_lassen_sich_entfernen()
{
await Record(1m, when: Tag.AddDays(-40));
await Record(2m, when: Tag);
var removed = await _usage.PurgeBeforeAsync(DateOnly.FromDateTime(Tag.AddDays(-30)), default);
removed.ShouldBe(1);
(await _usage.GetRecentAsync(10, default)).Count.ShouldBe(1);
}
[Fact]
public async Task Viele_Laeufe_koennen_gleichzeitig_erfasst_werden()
{
// Der alte Weg schrieb TokenUsage.json bei jedem Lauf komplett neu, unter einem
// globalen Lock — genau das skalierte nicht.
await Task.WhenAll(Enumerable.Range(0, 100).Select(i => Record(0.01m, agentId: $"agent-{i % 8}")));
var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), agentId: "", default);
daily.RunCount.ShouldBe(100);
daily.CostUsd.ShouldBe(1.00m);
}
}