using System.Text.Json;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Usage;
namespace ClaudeDo.Worker.Tests.Usage;
///
/// Covers what is left of the reader after the model/scope aggregation moved to TokenTracker:
/// the per-session totals that back task_runs' per-run token delta.
///
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 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_Malformed_Line_Does_Not_Abort_The_Read()
{
WriteSession("proj", "sess-4.jsonl",
"this is not json",
AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 5, 5, 0, 0, requestId: "r1"));
var reader = MakeReader();
var totals = await reader.ReadSessionTotalsAsync("sess-4");
Assert.NotNull(totals);
Assert.Equal(5, totals!.InputTokens);
}
[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);
}
}