using System.Text.Json; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Usage; namespace ClaudeDo.Worker.Tests.Usage; public class TranscriptUsageReaderTests : IDisposable { private readonly string _root; private readonly WorkerConfig _cfg; public TranscriptUsageReaderTests() { _root = Path.Combine(Path.GetTempPath(), $"tur_{Guid.NewGuid():N}"); Directory.CreateDirectory(_root); _cfg = new WorkerConfig { CentralWorktreeRoot = Path.Combine(_root, "central-worktrees"), SandboxRoot = Path.Combine(_root, "sandbox"), }; } public void Dispose() { try { Directory.Delete(_root, true); } catch { } } private string WriteSession(string projectDir, string file, params string[] lines) { var dir = Path.Combine(_root, "projects", projectDir); Directory.CreateDirectory(dir); var path = Path.Combine(dir, file); File.WriteAllLines(path, lines); return path; } private static string AssistantLine( string cwd, string ts, string model, long input, long output, long cacheRead, long cacheCreation, string? requestId = null, string? messageId = null) => JsonSerializer.Serialize(new Dictionary { ["type"] = "assistant", ["cwd"] = cwd, ["timestamp"] = ts, ["requestId"] = requestId, ["message"] = new Dictionary { ["id"] = messageId, ["model"] = model, ["usage"] = new Dictionary { ["input_tokens"] = input, ["output_tokens"] = output, ["cache_read_input_tokens"] = cacheRead, ["cache_creation_input_tokens"] = cacheCreation, }, }, }); private TranscriptUsageReader MakeReader() => new(_cfg, Path.Combine(_root, "projects")); [Fact] public async Task Aggregates_By_Date_And_Model_Including_Cache_Tokens() { WriteSession("proj", "s.jsonl", AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 10, 20, 3, 1), AssistantLine(@"C:\Dev\App", "2026-06-01T09:00:00Z", "claude-sonnet-5", 5, 6, 1, 0), AssistantLine(@"C:\Dev\App", "2026-06-02T08:00:00Z", "claude-opus-5", 100, 200, 30, 10)); var reader = MakeReader(); var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3)); Assert.Equal(2, result.Count); var day1 = Assert.Single(result, r => r.Model == "claude-sonnet-5"); Assert.Equal(new DateOnly(2026, 6, 1), day1.Date); Assert.Equal(15, day1.InputTokens); Assert.Equal(26, day1.OutputTokens); Assert.Equal(4, day1.CacheReadTokens); Assert.Equal(1, day1.CacheCreationTokens); Assert.Equal(2, day1.Messages); var day2 = Assert.Single(result, r => r.Model == "claude-opus-5"); Assert.Equal(new DateOnly(2026, 6, 2), day2.Date); Assert.Equal(100, day2.InputTokens); Assert.Equal(1, day2.Messages); } [Fact] public async Task Same_RequestId_Across_Files_Counts_Once() { WriteSession("proj-a", "s1.jsonl", AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 10, 20, 0, 0, requestId: "req-1")); WriteSession("proj-b", "s2.jsonl", AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 10, 20, 0, 0, requestId: "req-1")); var reader = MakeReader(); var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3)); var row = Assert.Single(result); Assert.Equal(1, row.Messages); Assert.Equal(10, row.InputTokens); } [Fact] public async Task Scope_Split_Covers_Sibling_Central_And_Sandbox_Roots() { var siblingCwd = Path.Combine(_root, "some-repo", ".claudedo-worktrees", "list-slug", "task-id"); var centralCwd = Path.Combine(_cfg.CentralWorktreeRoot, "list-slug", "task-id"); var sandboxCwd = Path.Combine(_cfg.SandboxRoot, "task-id"); var otherCwd = Path.Combine(_root, "some-repo"); WriteSession("proj", "s.jsonl", AssistantLine(siblingCwd, "2026-06-01T08:00:00Z", "claude-sonnet-5", 1, 1, 0, 0, requestId: "sibling"), AssistantLine(centralCwd, "2026-06-01T08:00:00Z", "claude-sonnet-5", 1, 1, 0, 0, requestId: "central"), AssistantLine(sandboxCwd, "2026-06-01T08:00:00Z", "claude-sonnet-5", 1, 1, 0, 0, requestId: "sandbox"), AssistantLine(otherCwd, "2026-06-01T08:00:00Z", "claude-sonnet-5", 1, 1, 0, 0, requestId: "other")); var reader = MakeReader(); var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3)); Assert.Equal(2, result.Count); var claudeDo = Assert.Single(result, r => r.Scope == UsageScope.ClaudeDo); Assert.Equal(3, claudeDo.Messages); var other = Assert.Single(result, r => r.Scope == UsageScope.Other); Assert.Equal(1, other.Messages); } [Fact] public async Task Date_Filter_Excludes_Lines_Outside_Window() { WriteSession("proj", "s.jsonl", AssistantLine(@"C:\Dev\App", "2026-05-01T08:00:00Z", "claude-sonnet-5", 1, 1, 0, 0), AssistantLine(@"C:\Dev\App", "2026-06-02T08:00:00Z", "claude-sonnet-5", 5, 5, 0, 0), AssistantLine(@"C:\Dev\App", "2026-07-01T08:00:00Z", "claude-sonnet-5", 1, 1, 0, 0)); var reader = MakeReader(); var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3)); var row = Assert.Single(result); Assert.Equal(5, row.InputTokens); Assert.Equal(1, row.Messages); } [Fact] public async Task Malformed_Line_Does_Not_Abort_The_Run() { WriteSession("proj", "s.jsonl", "this is not json", AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 5, 5, 0, 0)); var reader = MakeReader(); var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3)); var row = Assert.Single(result); Assert.Equal(1, row.Messages); } [Fact] public async Task Cache_Skips_Unchanged_File_And_Picks_Up_Appended_Lines() { var path = WriteSession("proj", "s.jsonl", AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 5, 5, 0, 0, requestId: "req-a")); var reader = MakeReader(); var first = Assert.Single(await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3))); Assert.Equal(1, first.Messages); // Overwrite with different content but the SAME length and mtime: if the reader honored // the cache it must still return the ORIGINAL aggregate, proving it did not re-read the file. var originalBytes = File.ReadAllBytes(path); var originalWriteUtc = File.GetLastWriteTimeUtc(path); var tamperedLine = AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 9, 9, 0, 0, requestId: "req-9"); // Pad/truncate to the EXACT same byte length as the original file so the (length, mtime) // cache key still matches — the reader must then serve the cached (stale) aggregate. var tamperedPadded = tamperedLine.Length + 1 <= originalBytes.Length ? tamperedLine.PadRight(originalBytes.Length - 1) + "\n" : tamperedLine[..(originalBytes.Length - 1)] + "\n"; var tamperedBytes = System.Text.Encoding.UTF8.GetBytes(tamperedPadded); Assert.Equal(originalBytes.Length, tamperedBytes.Length); File.WriteAllBytes(path, tamperedBytes); File.SetLastWriteTimeUtc(path, originalWriteUtc); var stale = Assert.Single(await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3))); Assert.Equal(5, stale.InputTokens); // Now really append a new line: length/mtime change, so the file must be re-read. File.AppendAllLines(path, new[] { AssistantLine(@"C:\Dev\App", "2026-06-01T09:00:00Z", "claude-sonnet-5", 3, 3, 0, 0, requestId: "req-b"), }); var updated = Assert.Single(await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3))); Assert.Equal(2, updated.Messages); } [Fact] public async Task Missing_ProjectsRoot_Returns_Empty_Without_Throwing() { var reader = new TranscriptUsageReader(_cfg, Path.Combine(_root, "does-not-exist")); var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3)); Assert.Empty(result); } [Fact] public async Task ReadSessionTotalsAsync_Sums_All_Assistant_Messages_In_The_Session_File() { WriteSession("proj", "sess-1.jsonl", AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 10, 20, 100, 5, requestId: "r1"), AssistantLine(@"C:\Dev\App", "2026-06-01T09:00:00Z", "claude-sonnet-5", 3, 7, 200, 0, requestId: "r2")); var reader = MakeReader(); var totals = await reader.ReadSessionTotalsAsync("sess-1"); Assert.NotNull(totals); Assert.Equal(13, totals!.InputTokens); Assert.Equal(27, totals.OutputTokens); Assert.Equal(300, totals.CacheReadTokens); Assert.Equal(5, totals.CacheCreationTokens); } [Fact] public async Task ReadSessionTotalsAsync_Skips_Synthetic_Model_Messages() { WriteSession("proj", "sess-2.jsonl", AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 10, 20, 0, 0, requestId: "r1"), AssistantLine(@"C:\Dev\App", "2026-06-01T09:00:00Z", "", 999, 999, 999, 999, requestId: "r2")); var reader = MakeReader(); var totals = await reader.ReadSessionTotalsAsync("sess-2"); Assert.NotNull(totals); Assert.Equal(10, totals!.InputTokens); Assert.Equal(20, totals.OutputTokens); } [Fact] public async Task ReadSessionTotalsAsync_Dedupes_By_RequestId_Within_The_Session() { WriteSession("proj", "sess-3.jsonl", AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 10, 20, 0, 0, requestId: "dup"), AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:01Z", "claude-sonnet-5", 10, 20, 0, 0, requestId: "dup")); var reader = MakeReader(); var totals = await reader.ReadSessionTotalsAsync("sess-3"); Assert.NotNull(totals); Assert.Equal(10, totals!.InputTokens); Assert.Equal(20, totals.OutputTokens); } [Fact] public async Task ReadSessionTotalsAsync_Returns_Null_When_No_Matching_Transcript_File() { var reader = MakeReader(); var totals = await reader.ReadSessionTotalsAsync("does-not-exist"); Assert.Null(totals); } [Fact] public async Task ReadSessionTotalsAsync_Returns_Null_When_ProjectsRoot_Missing() { var reader = new TranscriptUsageReader(_cfg, Path.Combine(_root, "does-not-exist")); var totals = await reader.ReadSessionTotalsAsync("sess-1"); Assert.Null(totals); } }