using System.Text.Json; using ClaudeDo.Ui.Services; using Xunit; namespace ClaudeDo.Ui.Tests.Services; public class OperationTimingTests { [Fact] public void Record_AppendsOneWellFormedNdjsonLinePerOperation() { var dir = Path.Combine(Path.GetTempPath(), "claudedo_optiming_" + Guid.NewGuid().ToString("N")); var path = Path.Combine(dir, "operation-timing.ndjson"); var sink = new OperationTiming(path); try { sink.Record("hub", "RunNow", TimeSpan.FromMilliseconds(42), ok: true); sink.Record("db", "TasksIsland.LoadForList", TimeSpan.FromMilliseconds(7), ok: false); var lines = File.ReadAllLines(path); Assert.Equal(2, lines.Length); using var doc1 = JsonDocument.Parse(lines[0]); var root1 = doc1.RootElement; Assert.Equal("hub", root1.GetProperty("kind").GetString()); Assert.Equal("RunNow", root1.GetProperty("op").GetString()); Assert.Equal(42, root1.GetProperty("ms").GetInt64()); Assert.True(root1.GetProperty("ok").GetBoolean()); Assert.True(root1.TryGetProperty("ts", out _)); using var doc2 = JsonDocument.Parse(lines[1]); var root2 = doc2.RootElement; Assert.Equal("db", root2.GetProperty("kind").GetString()); Assert.Equal("TasksIsland.LoadForList", root2.GetProperty("op").GetString()); Assert.Equal(7, root2.GetProperty("ms").GetInt64()); Assert.False(root2.GetProperty("ok").GetBoolean()); // Runs of the app interleave in one file — without the pid a restart is // indistinguishable from concurrent work inside one instance. Assert.Equal(Environment.ProcessId, root1.GetProperty("pid").GetInt32()); } finally { if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); } } [Fact] public void Record_DropsFastSuccessesButKeepsEveryFailure() { var dir = Path.Combine(Path.GetTempPath(), "claudedo_optiming_" + Guid.NewGuid().ToString("N")); var path = Path.Combine(dir, "operation-timing.ndjson"); var sink = new OperationTiming(path, minMs: 25); try { sink.Record("db", "Fast", TimeSpan.FromMilliseconds(3), ok: true); // noise sink.Record("db", "Cancelled", TimeSpan.FromMilliseconds(1), ok: false); // churn signal sink.Record("db", "Slow", TimeSpan.FromMilliseconds(25), ok: true); // boundary: kept var ops = File.ReadAllLines(path) .Select(l => JsonDocument.Parse(l).RootElement.GetProperty("op").GetString()) .ToArray(); Assert.Equal(new[] { "Cancelled", "Slow" }, ops); } finally { if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); } } [Fact] public void Record_RollsTheFileOverOnceItPassesTheSizeCap() { var dir = Path.Combine(Path.GetTempPath(), "claudedo_optiming_" + Guid.NewGuid().ToString("N")); var path = Path.Combine(dir, "operation-timing.ndjson"); var sink = new OperationTiming(path, maxBytes: 200); try { for (var i = 0; i < 12; i++) sink.Record("db", "Op" + i, TimeSpan.FromMilliseconds(100), ok: true); Assert.True(File.Exists(path + ".1"), "expected one rolled-over file"); Assert.True(new FileInfo(path).Length < 200 + 200, "live file should have been truncated by the roll"); // The newest line survives the roll — rotation must never eat the current write. var last = File.ReadAllLines(path)[^1]; Assert.Equal("Op11", JsonDocument.Parse(last).RootElement.GetProperty("op").GetString()); } finally { if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); } } [Fact] public void Record_DoesNothingWhenDisabled() { var dir = Path.Combine(Path.GetTempPath(), "claudedo_optiming_" + Guid.NewGuid().ToString("N")); var path = Path.Combine(dir, "operation-timing.ndjson"); var sink = new OperationTiming(path, enabled: false); try { var exception = Record.Exception(() => sink.Record("hub", "RunNow", TimeSpan.FromMilliseconds(1), ok: false)); Assert.Null(exception); Assert.False(File.Exists(path)); Assert.False(Directory.Exists(dir)); } finally { if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); } } [Fact] public void Record_SwallowsWriteFailureInsteadOfThrowing() { // A plain file sitting where the sink expects a directory makes Directory.CreateDirectory // throw -- standing in for the disk-full/locked-file/no-permission failures this sink // must survive without disturbing the caller. var root = Path.Combine(Path.GetTempPath(), "claudedo_optiming_invalid_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var blockingFile = Path.Combine(root, "not-a-directory"); File.WriteAllText(blockingFile, ""); var targetPath = Path.Combine(blockingFile, "timing.ndjson"); var sink = new OperationTiming(targetPath); try { var exception = Record.Exception(() => sink.Record("hub", "RunNow", TimeSpan.FromMilliseconds(1), ok: true)); Assert.Null(exception); Assert.False(File.Exists(targetPath)); } finally { Directory.Delete(root, recursive: true); } } }