using ClawdDotNet.Core.Analytics; using ClawdDotNet.Core.Audit; using ClawdDotNet.Core.Tests.Infrastructure; using Shouldly; namespace ClawdDotNet.Core.Tests.Analytics; /// /// Usage analysis (#660): filter, sort, page and bucket run receipts. Totals cover the whole /// filter, not only the page; runs refused before any model call are counted separately. /// public sealed class UsageAnalyticsTests { private static readonly DateTime Day = new(2026, 9, 19, 0, 0, 0, DateTimeKind.Utc); private static RunReceipt Receipt(string agent, int hour, decimal cost, string model = "gemini", string status = "Completed", string source = "task", string? task = null, int prompt = 1000, string result = "ERGEBNIS: ok") => new() { RunId = Guid.NewGuid().ToString("N"), AgentId = agent, Model = model, Source = source, TaskId = task, Status = status, StepCount = 3, PromptTokens = prompt, CompletionTokens = 100, CostUsd = cost, CostIsKnown = cost > 0, ResultRef = result, OccurredAt = Day.AddHours(hour) }; private static UsageAnalytics Analytics(params RunReceipt[] receipts) { var audit = new InMemoryAuditRepository(); foreach (var r in receipts) audit.Receipts.Enqueue(r); return new UsageAnalytics(audit, (id, _) => Task.FromResult(id == "t-1" ? "Wetter abrufen" : null)); } private static RunQuery All => new() { FromUtc = Day, ToUtc = Day.AddDays(1) }; [Fact] public async Task Totals_cover_the_whole_filter_not_only_the_page() { var analytics = Analytics( Receipt("wetter", 1, 0.01m), Receipt("wetter", 2, 0.02m), Receipt("kurse", 3, 0.04m), Receipt("kurse", 4, 0m, status: "BudgetExceeded", prompt: 0)); var page = await analytics.QueryRunsAsync(All with { Limit = 1 }, default); page.Items.Count.ShouldBe(1); page.Total.ShouldBe(4); page.Totals.CostUsd.ShouldBe(0.07m); page.Totals.NotRun.ShouldBe(1); page.Totals.RunsWithUnknownCost.ShouldBe(0, "a refused run has no cost to know"); } [Fact] public async Task Filters_combine() { var analytics = Analytics( Receipt("wetter", 1, 0.01m, model: "haiku"), Receipt("wetter", 2, 0.02m, model: "gemini"), Receipt("kurse", 3, 0.04m, model: "gemini")); var page = await analytics.QueryRunsAsync(All with { Agents = ["wetter"], Models = ["gemini"] }, default); page.Total.ShouldBe(1); page.Items.Single().Receipt.CostUsd.ShouldBe(0.02m); (await analytics.QueryRunsAsync(All with { MinCostUsd = 0.015m }, default)).Total.ShouldBe(2); } [Fact] public async Task The_search_covers_task_title_and_result() { var analytics = Analytics( Receipt("wetter", 1, 0.01m, task: "t-1"), Receipt("kurse", 2, 0.01m, result: "ERGEBNIS: NVDA bei 120")); (await analytics.QueryRunsAsync(All with { Search = "wetter abrufen" }, default)).Items.Single().TaskTitle.ShouldBe("Wetter abrufen"); (await analytics.QueryRunsAsync(All with { Search = "nvda" }, default)).Items.Single().Receipt.AgentId.ShouldBe("kurse"); } [Theory] [InlineData("cost", true, 0.04)] [InlineData("cost", false, 0.01)] [InlineData("time", true, 0.02)] public async Task Every_column_sorts(string sort, bool descending, double firstCost) { var analytics = Analytics(Receipt("a", 1, 0.01m), Receipt("b", 5, 0.02m), Receipt("c", 3, 0.04m)); var page = await analytics.QueryRunsAsync(All with { Sort = sort, Descending = descending }, default); page.Items[0].Receipt.CostUsd.ShouldBe((decimal)firstCost); } [Fact] public async Task Series_stack_per_bucket_and_group() { var analytics = Analytics(Receipt("wetter", 1, 0.01m), Receipt("wetter", 1, 0.02m), Receipt("kurse", 1, 0.04m), Receipt("wetter", 5, 0.1m)); var points = await analytics.SeriesAsync(All, "hour", "agent", TimeSpan.Zero, default); points.Count.ShouldBe(3); points.Single(p => p.Group == "wetter" && p.BucketStart == Day.AddHours(1)).CostUsd.ShouldBe(0.03m); points.Single(p => p.Group == "wetter" && p.BucketStart == Day.AddHours(1)).Runs.ShouldBe(2); } [Fact] public void A_day_is_the_viewers_day() { // 23:30 UTC is already the next day in Berlin (UTC+2). var bucket = UsageAnalytics.BucketStart(Day.AddHours(23.5), "day", TimeSpan.FromHours(2)); bucket.ShouldBe(Day.AddDays(1).AddHours(-2), "local midnight of 20.09 expressed in UTC"); } [Fact] public async Task Budget_denials_and_model_changes_become_events() { var analytics = Analytics( Receipt("wetter", 1, 0.01m, model: "haiku"), Receipt("wetter", 2, 0.01m, model: "gemini"), Receipt("wetter", 3, 0m, status: "BudgetExceeded", model: "gemini"), Receipt("wetter", 3, 0m, status: "BudgetExceeded", model: "gemini")); var events = await analytics.EventsAsync(Day, Day.AddDays(1), default); events.Single(e => e.Kind == "model_changed").Text.ShouldBe("haiku → gemini"); events.Single(e => e.Kind == "budget_exceeded").Text.ShouldContain("2 run(s)"); } [Fact] public async Task Facets_list_what_occurs() { var analytics = Analytics(Receipt("wetter", 1, 0.01m, source: "task"), Receipt("kurse", 1, 0.01m, source: "webui", model: "haiku")); var facets = await analytics.FacetsAsync(Day, Day.AddDays(1), default); facets.Agents.ShouldBe(["kurse", "wetter"]); facets.Sources.ShouldBe(["task", "webui"]); } }