using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Tasks; using Shouldly; namespace ClawdDotNet.Core.Tests.Tasks; /// /// Die Wahrheitsaufteilung in Aktion: Der Dienst hält Markdown-Datei (Definition) und /// DB (Ausführungszustand) im Gleichschritt. Getestet gegen echtes Dateisystem und echte /// SQLite — beide Seiten sind der Punkt. /// public sealed class TaskboardServiceTests : IDisposable { private readonly string _directory; private readonly string _tasksDir; private readonly SqliteStorage _storage; private readonly SqliteTaskRepository _repo; private readonly TaskboardService _board; public TaskboardServiceTests() { _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); _tasksDir = Path.Combine(_directory, "SharedWorkspace", "tasks"); _storage = new SqliteStorage(Path.Combine(_directory, "state.db")); _repo = new SqliteTaskRepository(_storage); _board = new TaskboardService(_repo, _tasksDir); } public void Dispose() { Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); try { Directory.Delete(_directory, recursive: true); } catch { /* Aufräumen ist Nebensache */ } } private static TaskItem Def(string title = "Aufgabe", string assignee = "@new") => new() { Title = title, Assignee = assignee, Body = "Tu etwas." }; [Fact] public async Task Anlegen_erzeugt_Datei_und_DB_Zeile_in_einem_Zug() { var created = await _board.CreateAsync(Def("NVDA recherchieren"), default); created.Id.ShouldNotBeNullOrWhiteSpace(); created.Id.ShouldStartWith("t-"); // DB-Seite (await _repo.GetAsync(created.Id, default)).ShouldNotBeNull(); // Datei-Seite: existiert und lässt sich zur selben Definition zurücklesen var path = Path.Combine(_tasksDir, created.FileName); File.Exists(path).ShouldBeTrue(); TaskFrontmatter.TryParse(File.ReadAllText(path), out var fromFile, out _).ShouldBeTrue(); fromFile.Id.ShouldBe(created.Id); fromFile.Title.ShouldBe("NVDA recherchieren"); fromFile.Assignee.ShouldBe("@new"); } [Fact] public async Task Der_Dateiname_ist_beschreibend() { var created = await _board.CreateAsync(Def("NVDA Earnings recherchieren"), default); created.FileName.ShouldStartWith("nvda-earnings-recherchieren-"); created.FileName.ShouldEndWith(".md"); } [Fact] public async Task Aendern_schreibt_Datei_und_DB_fort() { var created = await _board.CreateAsync(Def("Alt"), default); var updated = await _board.UpdateAsync(created.Id, c => c with { Title = "Neu", Status = TaskItemStatus.Done }, default); updated!.Title.ShouldBe("Neu"); updated.Status.ShouldBe(TaskItemStatus.Done); // Beide Seiten spiegeln die Änderung (await _repo.GetAsync(created.Id, default))!.Status.ShouldBe(TaskItemStatus.Done); TaskFrontmatter.TryParse( File.ReadAllText(Path.Combine(_tasksDir, created.FileName)), out var fromFile, out _); fromFile.Title.ShouldBe("Neu"); fromFile.Status.ShouldBe(TaskItemStatus.Done); } [Fact] public async Task Ein_Kommentar_landet_im_Rumpf() { var created = await _board.CreateAsync(Def(), default); await _board.AddCommentAsync(created.Id, "agent-a", "Erstes Zwischenergebnis.", default); await _board.AddCommentAsync(created.Id, "agent-b", "Bitte nachbessern.", default); var body = (await _repo.GetAsync(created.Id, default))!.Body; body.ShouldContain("Erstes Zwischenergebnis."); body.ShouldContain("agent-b"); body.ShouldContain("Bitte nachbessern."); } [Fact] public async Task Import_spiegelt_vorhandene_Dateien_in_die_DB() { Directory.CreateDirectory(_tasksDir); File.WriteAllText(Path.Combine(_tasksDir, "eins.md"), "---\nid: t-eins\ntitle: Eins\nassignee: \"@new\"\n---\nMach eins."); File.WriteAllText(Path.Combine(_tasksDir, "zwei.md"), "---\nid: t-zwei\ntitle: Zwei\nassignee: \"@new\"\n---\nMach zwei."); var imported = await _board.ImportAllAsync(default); imported.ShouldBe(2); (await _repo.GetAsync("t-eins", default))!.Title.ShouldBe("Eins"); (await _repo.GetAsync("t-zwei", default))!.Title.ShouldBe("Zwei"); } [Fact] public async Task Eine_von_Hand_angelegte_Datei_ohne_Id_bekommt_eine_und_behaelt_sie() { Directory.CreateDirectory(_tasksDir); var path = Path.Combine(_tasksDir, "handarbeit.md"); File.WriteAllText(path, "---\ntitle: Von Hand\nassignee: \"@human\"\n---\nManuell angelegt."); var imported = await _board.ImportFileAsync(path, default); imported.ShouldNotBeNull(); imported!.Id.ShouldNotBeNullOrWhiteSpace(); // Die Id wurde in die Datei zurückgeschrieben — überlebt den nächsten Start. TaskFrontmatter.TryParse(File.ReadAllText(path), out var fromFile, out _); fromFile.Id.ShouldBe(imported.Id); } [Fact] public async Task Eine_verschwundene_Datei_wird_archiviert_statt_geloescht() { var created = await _board.CreateAsync(Def("Vergänglich"), default); File.Delete(Path.Combine(_tasksDir, created.FileName)); await _board.ImportAllAsync(default); // Die Historie bleibt: die Zeile ist archiviert, nicht weg. var after = await _repo.GetAsync(created.Id, default); after.ShouldNotBeNull(); after!.Status.ShouldBe(TaskItemStatus.Archived); } [Fact] public async Task Ein_Re_Import_setzt_einen_laufenden_Status_nicht_zurueck() { // Die Kernregel der Wahrheitsaufteilung über den Dienst geprüft. var created = await _board.CreateAsync(Def(), default); await _repo.TryClaimAsync(created.Id, "2026-07-31T07:00:00.0000000Z", "tok", DateTime.UtcNow, DateTime.UtcNow.AddHours(-1), default); await _repo.CompleteClaimAsync(created.Id, "tok", TaskItemStatus.Done, DateTime.UtcNow, default); // Die Datei sagt weiterhin "todo" (so wurde sie angelegt) — ein Import darf das // nicht über den erreichten Done-Status stülpen. await _board.ImportAllAsync(default); (await _repo.GetAsync(created.Id, default))!.Status.ShouldBe(TaskItemStatus.Done); } }