using System.Text.Json; using ClawdDotNet.Core.State; using ClawdDotNet.Core.Tools; using ClawdDotNet.Tools.FileRW; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Tools.Tests.FileRW; /// /// Die Nachschau nach einem Absturz (#555) und das atomare Schreiben (#553). /// /// Das Dateisystem wird auch hier nicht abstrahiert — es geht gerade darum, was auf der /// Platte steht, nachdem ein Aufruf unterbrochen wurde. /// public sealed class NachschauTests : IDisposable { private readonly string _root; private readonly FileRWTool _tool = new(); public NachschauTests() { _root = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"), "Workspace"); Directory.CreateDirectory(_root); } public void Dispose() { try { Directory.Delete(Path.GetDirectoryName(_root)!, recursive: true); } catch { /* Aufräumen ist Nebensache */ } } private AgentToolContext Context() => new( "a1", "inst", new Dictionary(), new NullStateStore(), NullLogger.Instance, CancellationToken.None, WorkspacePath: _root); private Task Probe(string json) => _tool.ProbeAsync(JsonDocument.Parse(json).RootElement, Context(), CancellationToken.None); private Task Run(string json) => _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, Context(), CancellationToken.None); // ═══════════════════ #553: atomar schreiben ═══════════════════ [Fact] public async Task Schreiben_hinterlaesst_keine_Nebendateien() { var result = await Run("""{"action":"write","path":"a.txt","content":"Inhalt"}"""); result.Success.ShouldBeTrue(); // Das atomare Schreiben geht über eine Nebendatei — die muss danach weg sein. Directory.GetFiles(_root).ShouldHaveSingleItem(); (await File.ReadAllTextAsync(Path.Combine(_root, "a.txt"))).ShouldBe("Inhalt"); } [Fact] public async Task Ueberschreiben_ersetzt_den_Inhalt_vollstaendig() { await Run("""{"action":"write","path":"a.txt","content":"ein sehr langer alter Inhalt"}"""); await Run("""{"action":"write","path":"a.txt","content":"kurz"}"""); // Beim Ersetzen darf kein Rest der alten, längeren Fassung stehenbleiben. (await File.ReadAllTextAsync(Path.Combine(_root, "a.txt"))).ShouldBe("kurz"); } // ═══════════════════ #555: Nachschau bei write ═══════════════════ [Fact] public async Task Fehlende_Datei_heisst_nichts_geschehen() { var befund = await Probe("""{"action":"write","path":"nie.txt","content":"x"}"""); befund.Outcome.ShouldBe(ProbeOutcome.NotStarted); } [Fact] public async Task Der_beabsichtigte_Inhalt_heisst_durchgekommen() { await Run("""{"action":"write","path":"a.txt","content":"fertig"}"""); var befund = await Probe("""{"action":"write","path":"a.txt","content":"fertig"}"""); befund.Outcome.ShouldBe(ProbeOutcome.Complete); } [Fact] public async Task Der_alte_Inhalt_heisst_nicht_durchgekommen() { await Run("""{"action":"write","path":"a.txt","content":"alt"}"""); // So sähe es aus, wenn der Absturz vor dem Ersetzen kam. var befund = await Probe("""{"action":"write","path":"a.txt","content":"neu"}"""); befund.Outcome.ShouldBe(ProbeOutcome.NotStarted); } // ═══════════════════ #555: Nachschau bei append ═══════════════════ [Fact] public async Task Vollstaendig_angehaengter_Text_wird_erkannt() { await Run("""{"action":"write","path":"log.txt","content":"Zeile 1\n"}"""); await Run("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}"""); var befund = await Probe("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}"""); befund.Outcome.ShouldBe(ProbeOutcome.Complete); } [Fact] public async Task Ein_halb_angehaengter_Text_wird_als_Riss_erkannt() { // Anhängen ist nicht atomar — genau dieser Zustand kann wirklich entstehen. // Hier wird er von Hand hergestellt, weil ein echter Riss nicht reproduzierbar ist. var pfad = Path.Combine(_root, "log.txt"); await File.WriteAllTextAsync(pfad, "Zeile 1\nZeile 2 ist nur halb ang"); var befund = await Probe( """{"action":"append","path":"log.txt","content":"Zeile 2 ist nur halb angekommen\n"}"""); befund.Outcome.ShouldBe(ProbeOutcome.Partial); befund.Evidence.ShouldContain("of"); } [Fact] public async Task Fehlt_der_Text_ganz_gilt_der_Aufruf_als_nicht_geschehen() { await Run("""{"action":"write","path":"log.txt","content":"Zeile 1\n"}"""); var befund = await Probe("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}"""); befund.Outcome.ShouldBe(ProbeOutcome.NotStarted); } // ═══════════════════ Die Grenzen der Nachschau ═══════════════════ [Theory] [InlineData("read")] [InlineData("delete")] [InlineData("list")] public async Task Fuer_andere_Aktionen_wird_nichts_behauptet(string aktion) { var befund = await Probe($$"""{"action":"{{aktion}}","path":"a.txt"}"""); befund.Outcome.ShouldBe(ProbeOutcome.Unknown); } [Fact] public async Task Ein_unbrauchbarer_Pfad_ergibt_Unbekannt_statt_einer_Ausnahme() { // Die Nachschau darf nie werfen: Ein Fehlschlag der Nachschau ist eine Aussage // über die Nachschau, nicht über die Welt. var befund = await Probe("""{"action":"write","path":"../../ausserhalb.txt","content":"x"}"""); befund.Outcome.ShouldBe(ProbeOutcome.Unknown); } } internal sealed class NullStateStore : IStateStore { public Task GetAsync(string key, CancellationToken ct) => Task.FromResult(null); public Task SetAsync(string key, string value, CancellationToken ct) => Task.CompletedTask; public Task DeleteAsync(string key, CancellationToken ct) => Task.CompletedTask; }