using ClawdDotNet.App.Services; using Shouldly; namespace ClawdDotNet.App.Tests.Services; /// /// #376: Jedes Modul schreibt in seine eigene Datei, und eingesammelt wurde Datei für /// Datei — also alphabetisch nach Modulname. In der Log-Ansicht standen die Zeilen /// damit in Blöcken je Modul statt in der Reihenfolge, in der sie entstanden sind. /// Beobachtet wurde eine Zeile von 14:34:40 über vier Zeilen von 14:34:39. /// /// Das Dateisystem wird hier nicht abstrahiert: ist genau der /// Umgang mit echten, nebenher wachsenden Dateien — eine Abstraktion würde das /// Verhalten verstecken, um das es geht. /// public sealed class LogTailTests : IDisposable { private readonly string _logDirectory; private readonly string _today; public LogTailTests() { _logDirectory = Path.Combine( Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"), "Logs"); // LogTail liest ausschließlich das Verzeichnis des heutigen Tages, und zwar // nach Ortszeit — die Schreibseite legt es genauso an. _today = Path.Combine(_logDirectory, DateTime.Now.ToString("yyyy-MM-dd")); Directory.CreateDirectory(_today); } public void Dispose() { try { Directory.Delete(Path.GetDirectoryName(_logDirectory)!, recursive: true); } catch { /* Aufräumen ist Nebensache */ } } private void Write(string module, params string[] lines) => File.AppendAllLines(Path.Combine(_today, $"{module}.log"), lines); private LogTail Tail() => new(_logDirectory); // ═══════════════════════════════════════════════════════════ // Reihenfolge über Moduldateien hinweg // ═══════════════════════════════════════════════════════════ [Fact] public void Zeilen_aus_mehreren_Modulen_stehen_in_zeitlicher_Reihenfolge() { // Genau der beobachtete Fall: "Deploymentcenter" steht alphabetisch vor // "Startup", seine Zeile ist aber die jüngste von allen. Write("Deploymentcenter", "[14:34:40.246] [INF] kein Token hinterlegt"); Write("Startup", "[14:34:39.840] [INF] ClawdDotNet startet", "[14:34:39.871] [INF] Instanz-Verzeichnis", "[14:34:40.238] [WRN] Kein OpenRouter API-Key konfiguriert"); var lines = Tail().ReadNew(); lines.Select(l => l.Text).ShouldBe([ "[14:34:39.840] [INF] ClawdDotNet startet", "[14:34:39.871] [INF] Instanz-Verzeichnis", "[14:34:40.238] [WRN] Kein OpenRouter API-Key konfiguriert", "[14:34:40.246] [INF] kein Token hinterlegt" ]); } [Fact] public void Bei_gleichem_Zeitstempel_bleibt_die_bisherige_Ordnung() { // Stabil sortiert: Zwei Zeilen derselben Millisekunde dürfen nicht die Plätze // tauschen, sonst zerfällt ein zusammenhängender Ablauf innerhalb eines Moduls. // // Über Moduldateien hinweg entscheidet dann der Modulname — "Alpha" vor "Beta". // Directory.GetFiles liefert unter Linux die Verzeichnisreihenfolge; ohne die // Sortierung in ReadNew stünde hier von Lauf zu Lauf etwas anderes. Write("Alpha", "[10:00:00.000] [INF] alpha eins", "[10:00:00.000] [INF] alpha zwei"); Write("Beta", "[10:00:00.000] [INF] beta eins"); var lines = Tail().ReadNew(); lines.Select(l => l.Text).ShouldBe([ "[10:00:00.000] [INF] alpha eins", "[10:00:00.000] [INF] alpha zwei", "[10:00:00.000] [INF] beta eins" ]); } [Fact] public void Ein_Stacktrace_bleibt_bei_seiner_Meldung() { // Eine Ausnahme hängt ihre Zeilen ohne eigenen Zeitstempel unter die Meldung. // Ohne Sonderbehandlung wären sie ohne Schlüssel nach ganz vorn gerutscht. Write("Alpha", "[10:00:00.500] [INF] danach"); Write("Engine", "[10:00:00.100] [ERR] Aufruf fehlgeschlagen", "System.InvalidOperationException: kaputt", " bei ClawdDotNet.Engine.Run()"); var lines = Tail().ReadNew(); lines.Select(l => l.Text).ShouldBe([ "[10:00:00.100] [ERR] Aufruf fehlgeschlagen", "System.InvalidOperationException: kaputt", " bei ClawdDotNet.Engine.Run()", "[10:00:00.500] [INF] danach" ]); } [Fact] public void Ein_Stacktrace_bleibt_auch_ueber_die_Taktgrenze_bei_seiner_Meldung() { // Der Takt kann mitten in einem Eintrag enden: Die Meldung ist gelesen, die // Fortsetzung kommt erst beim nächsten Aufruf. Der Zeitstempel muss dann aus // dem vorigen Takt weitergetragen werden. var tail = Tail(); Write("Engine", "[10:00:00.100] [ERR] Aufruf fehlgeschlagen"); tail.ReadNew(); Write("Alpha", "[10:00:00.500] [INF] danach"); Write("Engine", " bei ClawdDotNet.Engine.Run()"); var lines = tail.ReadNew(); lines.Select(l => l.Text).ShouldBe([ " bei ClawdDotNet.Engine.Run()", "[10:00:00.500] [INF] danach" ]); } [Fact] public void Eine_Uhrzeit_mitten_in_der_Meldung_ist_kein_Sortierschluessel() { // Gesucht wird der Zeitstempel am Zeilenanfang, nicht irgendeine Uhrzeit im Text. Write("Alpha", "[10:00:00.100] [INF] naechster Lauf um [23:59:59.999]"); Write("Beta", "[10:00:00.200] [INF] danach"); var lines = Tail().ReadNew(); lines.Select(l => l.Text).ShouldBe([ "[10:00:00.100] [INF] naechster Lauf um [23:59:59.999]", "[10:00:00.200] [INF] danach" ]); } // ═══════════════════════════════════════════════════════════ // Was die Sortierung nicht kaputtmachen darf // ═══════════════════════════════════════════════════════════ [Fact] public void Nur_das_Neue_kommt_zurueck() { var tail = Tail(); Write("Alpha", "[10:00:00.100] [INF] erste"); tail.ReadNew().Count.ShouldBe(1); Write("Alpha", "[10:00:00.200] [INF] zweite"); tail.ReadNew().Select(l => l.Text).ShouldBe(["[10:00:00.200] [INF] zweite"]); } [Fact] public void Der_Modulfilter_laesst_nur_sein_Modul_durch() { Write("Alpha", "[10:00:00.100] [INF] alpha"); Write("Beta", "[10:00:00.200] [INF] beta"); var lines = Tail().ReadNew(module: "Beta"); lines.Select(l => l.Text).ShouldBe(["[10:00:00.200] [INF] beta"]); } [Fact] public void Eine_weggefilterte_Zeile_traegt_ihren_Zeitstempel_trotzdem_weiter() { // Der Stufenfilter darf die Zuordnung der Fortsetzungszeilen nicht verlieren: // Die Meldung fällt weg, ihr Stacktrace nicht — er hat keine eigene Stufe und // wird bewusst durchgelassen. Er muss dann an ihrer Stelle stehen. Write("Alpha", "[10:00:00.500] [ERR] spaeter Fehler"); Write("Engine", "[10:00:00.100] [INF] harmlos", " Fortsetzung ohne Stufe"); var lines = Tail().ReadNew(level: LogLevelFilter.Error); lines.Select(l => l.Text).ShouldBe([ " Fortsetzung ohne Stufe", "[10:00:00.500] [ERR] spaeter Fehler" ]); } [Fact] public void Ohne_Tagesverzeichnis_kommt_eine_leere_Liste() { Directory.Delete(_today, recursive: true); Tail().ReadNew().ShouldBeEmpty(); } }