using System.Text; using AcDream.Launcher.Core.Status; namespace AcDream.Launcher.Core.Tests.Status; public sealed class StatusFileTailerTests : IDisposable { private readonly string _root; private readonly string _path; public StatusFileTailerTests() { _root = Path.Combine( Path.GetTempPath(), "acdream-launcher-tailer-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_root); _path = Path.Combine(_root, "status.jsonl"); } public void Dispose() { if (Directory.Exists(_root)) { Directory.Delete(_root, recursive: true); } } [Fact] public void ReturnsNoEventsWhenTheFileDoesNotExistYet() { var tailer = new StatusFileTailer(_path); IReadOnlyList events = tailer.ReadNewEvents(); Assert.Empty(events); } [Fact] public void ReturnsNoEventsWhenNothingHasBeenAppendedSinceTheLastPoll() { AppendShared(Line("started", "s1")); var tailer = new StatusFileTailer(_path); Assert.Single(tailer.ReadNewEvents()); IReadOnlyList events = tailer.ReadNewEvents(); Assert.Empty(events); } [Fact] public void ReadsMultipleCompleteLinesInOnePoll() { AppendShared(Line("started", "s1") + Line("connected", "s1")); var tailer = new StatusFileTailer(_path); IReadOnlyList events = tailer.ReadNewEvents(); Assert.Equal(2, events.Count); Assert.IsType(events[0]); Assert.IsType(events[1]); } [Fact] public void TolerateAPartialLastLineAndCompletesItOnALaterPoll() { string full = Line("started", "s1"); int splitAt = full.Length - 10; // cut mid-object, before the closing brace/newline AppendShared(full[..splitAt]); var tailer = new StatusFileTailer(_path); IReadOnlyList firstPoll = tailer.ReadNewEvents(); Assert.Empty(firstPoll); AppendShared(full[splitAt..]); IReadOnlyList secondPoll = tailer.ReadNewEvents(); StatusEvent onlyEvent = Assert.Single(secondPoll); Assert.IsType(onlyEvent); } [Fact] public void APartialLineFollowedByAFullLineOnlyEmitsTheCompleteOne() { AppendShared(Line("started", "s1")); string partial = """{"v":1,"e":"connected","t":"2026-08-14T12:00:00Z","sessionId":"s1"""; // no closing AppendShared(partial); var tailer = new StatusFileTailer(_path); IReadOnlyList events = tailer.ReadNewEvents(); StatusEvent onlyEvent = Assert.Single(events); Assert.IsType(onlyEvent); // Completing the second line on a later poll produces exactly // one more event, proving the partial bytes were retained (not // dropped and not double-counted). AppendShared("\"}\n"); IReadOnlyList secondPoll = tailer.ReadNewEvents(); StatusEvent completed = Assert.Single(secondPoll); Assert.IsType(completed); } [Fact] public void SkipsBlankLines() { AppendShared("\n" + Line("started", "s1") + "\n" + Line("connected", "s1")); var tailer = new StatusFileTailer(_path); IReadOnlyList events = tailer.ReadNewEvents(); Assert.Equal(2, events.Count); } [Fact] public void ReadsWithAWriterHoldingTheFileOpenForAppend() { // Share-tolerant reads: the writer's handle stays open the whole // time (FileShare.ReadWrite on both sides), matching a live host // process appending status.jsonl while the launcher tails it. using var writer = new FileStream( _path, FileMode.Create, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete); var tailer = new StatusFileTailer(_path); byte[] first = Encoding.UTF8.GetBytes(Line("started", "s1")); writer.Write(first, 0, first.Length); writer.Flush(); IReadOnlyList firstPoll = tailer.ReadNewEvents(); Assert.Single(firstPoll); byte[] second = Encoding.UTF8.GetBytes(Line("connected", "s1")); writer.Write(second, 0, second.Length); writer.Flush(); IReadOnlyList secondPoll = tailer.ReadNewEvents(); Assert.Single(secondPoll); Assert.IsType(secondPoll[0]); } [Fact] public void RestartsFromTheTopWhenTheFileIsTruncatedOrReplaced() { AppendShared(Line("started", "s1") + Line("connected", "s1")); var tailer = new StatusFileTailer(_path); Assert.Equal(2, tailer.ReadNewEvents().Count); File.Delete(_path); AppendShared(Line("started", "s2")); IReadOnlyList events = tailer.ReadNewEvents(); StatusEvent onlyEvent = Assert.Single(events); Assert.Equal("s2", onlyEvent.SessionId); } private static string Line(string e, string sessionId) => $$"""{"v":1,"e":"{{e}}","t":"2026-08-14T12:00:00Z","sessionId":"{{sessionId}}"}""" + "\n"; private void AppendShared(string text) { using var stream = new FileStream( _path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete); byte[] bytes = Encoding.UTF8.GetBytes(text); stream.Write(bytes, 0, bytes.Length); } }