feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar

This commit is contained in:
Richard
2026-08-10 10:48:34 +02:00
parent a0e18d2a57
commit b5bf97ae74
187 changed files with 20054 additions and 882 deletions
@@ -0,0 +1,118 @@
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Tasks;
/// <summary>
/// Schritt 4: die Übernahme der alten <c>coordination/*.md</c>-Dateien. Geprüft an den
/// tatsächlichen Altformaten (freies Markdown, kein Frontmatter). Kernanspruch: nur echte
/// Aufgaben (<c>task_*</c>) wandern ins Board, alles andere bleibt unangetastet, und die
/// Migration ist idempotent.
/// </summary>
public sealed class CoordinationMigrationTests : IDisposable
{
private readonly string _directory;
private readonly string _coordinationDir;
private readonly SqliteStorage _storage;
private readonly SqliteTaskRepository _repo;
private readonly TaskboardService _board;
private readonly CoordinationMigration _migration;
public CoordinationMigrationTests()
{
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
var shared = Path.Combine(_directory, "SharedWorkspace");
_coordinationDir = Path.Combine(shared, "coordination");
Directory.CreateDirectory(_coordinationDir);
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
_repo = new SqliteTaskRepository(_storage);
_board = new TaskboardService(_repo, Path.Combine(shared, "tasks"));
_migration = new CoordinationMigration(_board, _coordinationDir, NullLoggerFactory.Instance);
}
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
private void Write(string name, string content)
=> File.WriteAllText(Path.Combine(_coordinationDir, name), content);
[Fact]
public async Task Nur_task_Dateien_werden_uebernommen()
{
Write("task_video.md", "# Task: Video-Zusammenfassung\n## Status: ASSIGNED\nAnalysiere das Video.");
Write("status_senior.md", "# Senior Developer - Status\n## Status: ONLINE");
Write("broadcast.md", "# Broadcast\nBitte meldet euren Status.");
Write("incident_x.md", "# Incident Report\nProblem-Chain ...");
Write("data.json", "{ \"foo\": 1 }");
var migrated = await _migration.RunAsync(default);
migrated.ShouldBe(1);
var tasks = await _repo.ListAsync(new TaskQuery { IncludeArchived = true }, default);
tasks.Count.ShouldBe(1);
tasks[0].Title.ShouldBe("Video-Zusammenfassung");
tasks[0].Status.ShouldBe(TaskItemStatus.Backlog, "erst nach menschlicher Sichtung bereit");
tasks[0].Assignee.ShouldBe(TaskAssignee.Human);
tasks[0].Body.ShouldContain("Analysiere das Video.");
}
[Fact]
public async Task Die_uebernommene_Datei_wandert_nach_migrated_und_bleibt_erhalten()
{
Write("task_a.md", "# Task: Etwas\nInhalt.");
await _migration.RunAsync(default);
File.Exists(Path.Combine(_coordinationDir, "task_a.md")).ShouldBeFalse("aus der obersten Ebene entfernt");
File.Exists(Path.Combine(_coordinationDir, "migrated", "task_a.md")).ShouldBeTrue("aber nicht gelöscht");
}
[Fact]
public async Task Nicht_Aufgaben_bleiben_unangetastet()
{
Write("status_x.md", "# Status\nONLINE");
Write("report.json", "{}");
await _migration.RunAsync(default);
File.Exists(Path.Combine(_coordinationDir, "status_x.md")).ShouldBeTrue();
File.Exists(Path.Combine(_coordinationDir, "report.json")).ShouldBeTrue();
(await _repo.CountAsync(default)).ShouldBe(0);
}
[Fact]
public async Task Die_Migration_ist_idempotent()
{
Write("task_a.md", "# Task: Einmalig\nInhalt.");
(await _migration.RunAsync(default)).ShouldBe(1);
(await _migration.RunAsync(default)).ShouldBe(0, "zweiter Lauf findet keine task_*-Datei mehr");
(await _repo.CountAsync(default)).ShouldBe(1, "keine Dublette");
}
[Fact]
public async Task Ohne_Task_Ueberschrift_kommt_der_Titel_aus_dem_Dateinamen()
{
Write("task_video_analysis_progress.md", "## Status: IN PROGRESS\nOhne echte Überschrift.");
await _migration.RunAsync(default);
var tasks = await _repo.ListAsync(new TaskQuery(), default);
tasks[0].Title.ShouldBe("video analysis progress");
}
[Fact]
public async Task Ohne_coordination_Verzeichnis_passiert_nichts()
{
Directory.Delete(_coordinationDir, recursive: true);
(await _migration.RunAsync(default)).ShouldBe(0);
}
}
@@ -0,0 +1,27 @@
using ClawdDotNet.Core.Tasks;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Tasks;
/// <summary>
/// Der Assignee bestimmt, wie eine fällige Aufgabe zu einem Lauf wird (T7). Die Deutung
/// muss eindeutig sein — davon hängt ab, ob ein Agent frisch oder mit Kontext läuft.
/// </summary>
public sealed class TaskAssigneeTests
{
[Theory]
[InlineData("@human", TaskAssigneeKind.Human)]
[InlineData("@new", TaskAssigneeKind.New)]
[InlineData("@new:crawler", TaskAssigneeKind.New)]
[InlineData("@crawler", TaskAssigneeKind.Agent)]
public void Die_Art_wird_richtig_erkannt(string assignee, TaskAssigneeKind expected)
=> TaskAssignee.KindOf(assignee).ShouldBe(expected);
[Theory]
[InlineData("@crawler", "crawler")]
[InlineData("@new:crawler", "crawler")]
[InlineData("@new", "")] // frischer Lauf ohne benannten Agenten
[InlineData("@human", "")]
public void Die_Agent_Id_wird_richtig_herausgezogen(string assignee, string expected)
=> TaskAssignee.AgentId(assignee).ShouldBe(expected);
}
@@ -0,0 +1,188 @@
using ClawdDotNet.Core.Tasks;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Tasks;
/// <summary>
/// Der Frontmatter-Parser ist die Brücke zwischen der menschen-/agentenlesbaren
/// Task-Datei und dem DB-Zustand. Was er falsch liest, importiert das Board falsch —
/// deshalb steht hier der Rundlauf im Mittelpunkt: Serialisieren und wieder Einlesen
/// muss dieselbe Definition ergeben.
/// </summary>
public sealed class TaskFrontmatterTests
{
[Fact]
public void Ein_vollstaendiges_Frontmatter_wird_gelesen()
{
var text = """
---
id: t-8f3a2c
title: NVDA Earnings recherchieren
status: todo
type: work
priority: 4
assignee: "@crawler"
when:
kind: cron
value: "0 7 * * 1-5"
tz: Europe/Berlin
require_approval: true
acceptance: |
Aktuelle Zahlen mit Datum und Quelle.
In SharedWorkspace/data/nvda.json abgelegt.
blocked_by: [t-4b1e, t-9a2c]
onlyWhenMarketOpen: false
---
Recherchiere die neuesten Quartalszahlen.
""";
TaskFrontmatter.TryParse(text, out var task, out var error).ShouldBeTrue(error);
task.Id.ShouldBe("t-8f3a2c");
task.Title.ShouldBe("NVDA Earnings recherchieren");
task.Status.ShouldBe(TaskItemStatus.Todo);
task.Type.ShouldBe(TaskItemType.Work);
task.Priority.ShouldBe(4);
task.Assignee.ShouldBe("@crawler");
task.When.ShouldNotBeNull();
task.When!.Kind.ShouldBe(TaskWhenKind.Cron);
task.When.Value.ShouldBe("0 7 * * 1-5");
task.When.TimeZone.ShouldBe("Europe/Berlin");
task.RequireApproval.ShouldBeTrue();
task.Acceptance.ShouldContain("nvda.json");
task.Acceptance.ShouldContain("Aktuelle Zahlen");
task.BlockedBy.ShouldBe(["t-4b1e", "t-9a2c"]);
task.OnlyWhenMarketOpen.ShouldBeFalse();
task.Body.ShouldBe("Recherchiere die neuesten Quartalszahlen.");
}
[Fact]
public void Serialisieren_und_wieder_Einlesen_ergibt_dieselbe_Definition()
{
var original = new TaskItem
{
Id = "t-abc123",
Title = "Titel mit: Doppelpunkt und # Raute",
Status = TaskItemStatus.InReview,
Type = TaskItemType.Approval,
Priority = 5,
Assignee = "@human",
When = new TaskWhen { Kind = TaskWhenKind.Every, Value = "30m", TimeZone = "UTC" },
RequireApproval = true,
Acceptance = "Zeile eins\nZeile zwei",
BlockedBy = ["t-1", "t-2"],
OnlyWhenMarketOpen = true,
Body = "Mehrzeiliger\nRumpf mit Umlauten äöü und 🦀."
};
var text = TaskFrontmatter.Serialize(original);
TaskFrontmatter.TryParse(text, out var round, out var error).ShouldBeTrue(error);
round.Id.ShouldBe(original.Id);
round.Title.ShouldBe(original.Title);
round.Status.ShouldBe(original.Status);
round.Type.ShouldBe(original.Type);
round.Priority.ShouldBe(original.Priority);
round.Assignee.ShouldBe(original.Assignee);
round.When!.Kind.ShouldBe(TaskWhenKind.Every);
round.When.Value.ShouldBe("30m");
round.When.TimeZone.ShouldBe("UTC");
round.RequireApproval.ShouldBeTrue();
round.Acceptance.ShouldBe(original.Acceptance);
round.BlockedBy.ShouldBe(["t-1", "t-2"]);
round.OnlyWhenMarketOpen.ShouldBeTrue();
round.Body.ShouldBe(original.Body);
}
[Fact]
public void Ein_Assignee_mit_At_ueberlebt_den_Rundlauf()
{
// In YAML ist ein führendes '@' reserviert — ohne Quotierung ginge es verloren.
var task = new TaskItem { Id = "t-1", Title = "x", Assignee = "@new" };
var text = TaskFrontmatter.Serialize(task);
TaskFrontmatter.TryParse(text, out var round, out _).ShouldBeTrue();
round.Assignee.ShouldBe("@new");
TaskAssignee.KindOf(round.Assignee).ShouldBe(TaskAssigneeKind.New);
}
[Fact]
public void Ohne_when_ist_es_eine_einmalige_Aufgabe()
{
var text = """
---
id: t-1
title: Einmalig
assignee: "@new"
---
Mach das einmal.
""";
TaskFrontmatter.TryParse(text, out var task, out _).ShouldBeTrue();
task.When.ShouldBeNull();
}
[Fact]
public void Fehlende_Felder_fallen_auf_sichere_Standards_zurueck()
{
var text = """
---
id: t-1
title: Minimal
---
""";
TaskFrontmatter.TryParse(text, out var task, out _).ShouldBeTrue();
task.Status.ShouldBe(TaskItemStatus.Todo);
task.Type.ShouldBe(TaskItemType.Work);
task.Priority.ShouldBe(3);
task.Assignee.ShouldBe(TaskAssignee.Human, "ohne Zuordnung wartet die Aufgabe auf einen Menschen");
task.RequireApproval.ShouldBeFalse();
task.BlockedBy.ShouldBeEmpty();
}
[Fact]
public void Eine_unbekannte_Terminart_wird_verworfen_statt_geraten()
{
var text = """
---
id: t-1
title: x
when:
kind: phantasie
value: irgendwas
tz: UTC
---
""";
TaskFrontmatter.TryParse(text, out var task, out _).ShouldBeTrue();
task.When.ShouldBeNull();
}
[Fact]
public void Fehlt_der_Frontmatter_Block_meldet_der_Parser_das()
{
TaskFrontmatter.TryParse("Nur Text, kein Frontmatter.", out _, out var error).ShouldBeFalse();
error.ShouldNotBeNull();
}
[Fact]
public void Ein_nicht_geschlossener_Block_wird_gemeldet()
{
var text = "---\nid: t-1\ntitle: x\n";
TaskFrontmatter.TryParse(text, out _, out var error).ShouldBeFalse();
error.ShouldNotBeNull();
}
[Fact]
public void Windows_Zeilenenden_stoeren_nicht()
{
var text = "---\r\nid: t-1\r\ntitle: Mit CRLF\r\n---\r\nRumpf\r\n";
TaskFrontmatter.TryParse(text, out var task, out var error).ShouldBeTrue(error);
task.Title.ShouldBe("Mit CRLF");
task.Body.ShouldBe("Rumpf");
}
}
@@ -0,0 +1,310 @@
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Tasks;
/// <summary>
/// A1 Taskboard: der Ausführungszustand. Getestet gegen eine echte SQLite-Datei, weil es
/// gerade um Schema, Sperrverhalten und atomares Claiming geht — eine Attrappe würde
/// genau das verstecken, worauf es ankommt.
///
/// Die drei Invarianten aus dem Taskboard-Konzept stehen im Mittelpunkt: nie zwei Claims
/// auf einen Termin, kein Dispatch bei offenem Blocker, doppelter Tick = ein Lauf.
/// </summary>
public sealed class TaskRepositoryTests : IDisposable
{
private readonly string _directory;
private readonly SqliteStorage _storage;
private readonly SqliteTaskRepository _repo;
public TaskRepositoryTests()
{
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
_repo = new SqliteTaskRepository(_storage);
}
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
private Task<TaskItem> Seed(
string id, TaskItemStatus status = TaskItemStatus.Todo,
string assignee = "@new", int priority = 3,
string title = "Aufgabe", IReadOnlyList<string>? blockedBy = null)
=> _repo.UpsertAsync(new TaskItem
{
Id = id,
Title = title,
Status = status,
Assignee = assignee,
Priority = priority,
BlockedBy = blockedBy ?? []
}, default);
private static string Occ(DateTime t) => t.ToUniversalTime().ToString("O");
private static readonly DateTime T0 = new(2026, 7, 31, 7, 0, 0, DateTimeKind.Utc);
private static DateTime Now => DateTime.UtcNow;
private static DateTime NoStaleClaims => Now.AddHours(-1); // Lease-Cutoff weit in der Vergangenheit
// ═══════════════════════════════════════════════════════════
// Grundfunktionen: Import (Upsert), Lesen, Auflisten
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Eine_Aufgabe_wird_angelegt_und_wiedergefunden()
{
await Seed("t-1", title: "Recherche");
var found = await _repo.GetAsync("t-1", default);
found.ShouldNotBeNull();
found!.Title.ShouldBe("Recherche");
found.Status.ShouldBe(TaskItemStatus.Todo);
}
[Fact]
public async Task Der_Import_ist_idempotent_und_erzeugt_keine_Dublette()
{
await Seed("t-1", title: "Erste Fassung");
await Seed("t-1", title: "Zweite Fassung");
(await _repo.CountAsync(default)).ShouldBe(1);
(await _repo.GetAsync("t-1", default))!.Title.ShouldBe("Zweite Fassung");
}
[Fact]
public async Task Ein_Re_Import_laesst_den_Ausfuehrungszustand_unangetastet()
{
// Der springende Punkt der Wahrheitsaufteilung: Die Datei ist Wahrheit über die
// Definition, die DB über den Ausführungszustand. Ein erneuter Import darf einen
// laufenden oder abgeschlossenen Zustand nicht zurücksetzen.
await Seed("t-1");
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok", Now, NoStaleClaims, default)).ShouldBeTrue();
await _repo.CompleteClaimAsync("t-1", "tok", TaskItemStatus.Done, Now, default);
// Datei wird erneut importiert (Titel geändert, Status im Frontmatter noch "todo").
await _repo.UpsertAsync(new TaskItem { Id = "t-1", Title = "geändert", Status = TaskItemStatus.Todo }, default);
var after = await _repo.GetAsync("t-1", default);
after!.Title.ShouldBe("geändert", "die Definition wird übernommen");
after.Status.ShouldBe(TaskItemStatus.Done, "der Ausführungszustand bleibt");
after.LastOccurrence.ShouldBe(Occ(T0), "der Marker bleibt erhalten");
}
[Fact]
public async Task Die_Liste_filtert_nach_Status_und_blendet_Archiviertes_aus()
{
await Seed("t-1", TaskItemStatus.Todo);
await Seed("t-2", TaskItemStatus.Done);
await Seed("t-3", TaskItemStatus.Archived);
(await _repo.ListAsync(new TaskQuery { Status = TaskItemStatus.Todo }, default)).Count.ShouldBe(1);
(await _repo.ListAsync(new TaskQuery(), default)).Count.ShouldBe(2, "Archiviertes ist standardmäßig ausgeblendet");
(await _repo.ListAsync(new TaskQuery { IncludeArchived = true }, default)).Count.ShouldBe(3);
}
[Fact]
public async Task Die_Liste_ordnet_nach_Prioritaet()
{
await Seed("t-low", priority: 1, title: "nebensächlich");
await Seed("t-high", priority: 5, title: "dringend");
await Seed("t-mid", priority: 3, title: "mittel");
var list = await _repo.ListAsync(new TaskQuery(), default);
list[0].Title.ShouldBe("dringend");
list[^1].Title.ShouldBe("nebensächlich");
}
[Fact]
public async Task Die_Liste_filtert_nach_Assignee_und_Freitext()
{
await Seed("t-1", assignee: "@crawler", title: "NVDA Zahlen");
await Seed("t-2", assignee: "@analyst", title: "TSLA Bericht");
(await _repo.ListAsync(new TaskQuery { Assignee = "@crawler" }, default)).Count.ShouldBe(1);
(await _repo.ListAsync(new TaskQuery { Search = "nvda" }, default)).Count.ShouldBe(1);
}
// ═══════════════════════════════════════════════════════════
// Invariante 1 & 3: nie zwei Claims, doppelter Tick = ein Lauf
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Ein_Termin_laesst_sich_genau_einmal_beanspruchen()
{
await Seed("t-1");
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok-a", Now, NoStaleClaims, default)).ShouldBeTrue();
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok-b", Now, NoStaleClaims, default))
.ShouldBeFalse("der Termin ist bereits beansprucht");
}
[Fact]
public async Task Gleichzeitige_Claims_auf_denselben_Termin_ergeben_genau_einen_Gewinner()
{
// Die Kern-Invariante. Mehrfach wiederholt, weil ein Race sporadisch auftritt.
for (var round = 0; round < 25; round++)
{
var id = $"t-race-{round}";
await Seed(id);
var attempts = Enumerable.Range(0, 32)
.Select(i => _repo.TryClaimAsync(id, Occ(T0), $"tok-{i}", Now, NoStaleClaims, default));
var results = await Task.WhenAll(attempts);
results.Count(won => won).ShouldBe(1, $"Runde {round}: genau ein Lauf darf den Termin ziehen");
}
}
[Fact]
public async Task Ein_zweiter_Tick_auf_denselben_Termin_loest_keinen_zweiten_Lauf_aus()
{
await Seed("t-1");
// Erster Tick beansprucht und schließt ab.
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok", Now, NoStaleClaims, default)).ShouldBeTrue();
await _repo.CompleteClaimAsync("t-1", "tok", TaskItemStatus.Todo, Now, default);
// Zweiter Tick im selben Terminfenster: derselbe Occurrence-Key.
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok2", Now, NoStaleClaims, default))
.ShouldBeFalse("derselbe Termin darf nach Abschluss nicht erneut laufen");
}
[Fact]
public async Task Ein_gescheiterter_Lauf_wird_nicht_automatisch_wiederholt()
{
// Kein Retry-Sturm: Der Marker steht schon beim Claim, nicht erst beim Erfolg.
await Seed("t-1");
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok", Now, NoStaleClaims, default)).ShouldBeTrue();
// Lauf scheitert → zurück auf todo, aber derselbe Termin bleibt verbraucht.
await _repo.CompleteClaimAsync("t-1", "tok", TaskItemStatus.Todo, Now, default);
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok2", Now, NoStaleClaims, default))
.ShouldBeFalse("derselbe Termin wird nicht erneut versucht");
}
[Fact]
public async Task Der_naechste_planmaessige_Termin_laesst_sich_wieder_beanspruchen()
{
await Seed("t-1");
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok", Now, NoStaleClaims, default)).ShouldBeTrue();
await _repo.CompleteClaimAsync("t-1", "tok", TaskItemStatus.Todo, Now, default);
// Ein späterer Occurrence-Key liegt über dem Marker → wieder fällig.
var later = Occ(T0.AddDays(1));
(await _repo.TryClaimAsync("t-1", later, "tok3", Now, NoStaleClaims, default))
.ShouldBeTrue("ein neuer Termin darf laufen");
}
// ═══════════════════════════════════════════════════════════
// Invariante 2: kein Dispatch bei offenem Blocker
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Eine_blockierte_Aufgabe_laesst_sich_nicht_beanspruchen()
{
await Seed("t-1", TaskItemStatus.Blocked, blockedBy: ["t-0"]);
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok", Now, NoStaleClaims, default))
.ShouldBeFalse("solange der Blocker offen ist, läuft nichts");
}
[Fact]
public async Task Wartende_Aufgaben_lassen_sich_ueber_ihren_Blocker_finden()
{
await Seed("t-a", blockedBy: ["t-blocker"]);
await Seed("t-b", blockedBy: ["t-blocker", "t-anderer"]);
await Seed("t-c", blockedBy: ["t-blocker10"]);
var waiting = await _repo.ListBlockedByAsync("t-blocker", default);
waiting.Select(t => t.Id).OrderBy(x => x).ShouldBe(["t-a", "t-b"]);
waiting.ShouldNotContain(t => t.Id == "t-c", "t-blocker darf nicht t-blocker10 treffen");
}
// ═══════════════════════════════════════════════════════════
// Claim-Abschluss, Lease und Reconciliation
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Ein_Lauf_ohne_gueltigen_Claim_kann_nichts_ueberschreiben()
{
await Seed("t-1");
(await _repo.TryClaimAsync("t-1", Occ(T0), "echt", Now, NoStaleClaims, default)).ShouldBeTrue();
(await _repo.CompleteClaimAsync("t-1", "falsch", TaskItemStatus.Done, Now, default))
.ShouldBeFalse("ein fremdes Token darf den Abschluss nicht setzen");
(await _repo.CompleteClaimAsync("t-1", "echt", TaskItemStatus.Done, Now, default))
.ShouldBeTrue();
(await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Done);
}
[Fact]
public async Task Verwaiste_Claims_werden_beim_Start_zurueckgesetzt()
{
await Seed("t-stuck");
// Claim mit einem Zeitpunkt, der bereits weit zurückliegt (abgestürzter Lauf).
var longAgo = Now.AddMinutes(-30);
(await _repo.TryClaimAsync("t-stuck", Occ(T0), "tok", longAgo, longAgo.AddMinutes(-1), default)).ShouldBeTrue();
// Reconciliation: Claims älter als 5 Minuten gelten als verwaist.
var reset = await _repo.ReleaseStaleClaimsAsync(Now.AddMinutes(-5), Now, default);
reset.ShouldBe(1);
var after = await _repo.GetAsync("t-stuck", default);
after!.Status.ShouldBe(TaskItemStatus.Todo);
after.ClaimToken.ShouldBeNull();
}
[Fact]
public async Task Ein_frischer_Claim_wird_bei_der_Reconciliation_nicht_angetastet()
{
await Seed("t-live");
(await _repo.TryClaimAsync("t-live", Occ(T0), "tok", Now, NoStaleClaims, default)).ShouldBeTrue();
var reset = await _repo.ReleaseStaleClaimsAsync(Now.AddMinutes(-5), Now, default);
reset.ShouldBe(0, "ein laufender Lauf darf nicht abgeräumt werden");
(await _repo.GetAsync("t-live", default))!.Status.ShouldBe(TaskItemStatus.InProgress);
}
[Fact]
public async Task Der_Status_laesst_sich_direkt_setzen()
{
await Seed("t-1");
(await _repo.SetStatusAsync("t-1", TaskItemStatus.Canceled, Now, default)).ShouldBeTrue();
(await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Canceled);
}
// ═══════════════════════════════════════════════════════════
// Nebenläufigkeit und Persistenz
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Viele_Aufgaben_lassen_sich_gleichzeitig_importieren()
{
var imports = Enumerable.Range(0, 60).Select(i => Seed($"t-{i}"));
await Task.WhenAll(imports);
(await _repo.CountAsync(default)).ShouldBe(60);
}
[Fact]
public async Task Aufgaben_ueberdauern_das_Schliessen_der_Datenbank()
{
await Seed("t-1", title: "muss einen Neustart überleben");
var reopened = new SqliteTaskRepository(new SqliteStorage(Path.Combine(_directory, "state.db")));
(await reopened.GetAsync("t-1", default))!.Title.ShouldBe("muss einen Neustart überleben");
}
}
@@ -0,0 +1,248 @@
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Tasks;
/// <summary>
/// Der Scanner-Kern — der in der Roadmap für Opus 5/Fable markierte heikle Teil. Getestet
/// gegen echte SQLite (das atomare Claiming ist der Punkt) mit einer Attrappe für die
/// Ausführung, damit keine Engine nötig ist. Die drei Invarianten stehen im Mittelpunkt.
/// </summary>
public sealed class TaskScannerTests : IDisposable
{
private readonly string _directory;
private readonly SqliteStorage _storage;
private readonly SqliteTaskRepository _repo;
private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2026, 7, 31, 12, 0, 0, TimeSpan.Zero));
public TaskScannerTests()
{
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
_repo = new SqliteTaskRepository(_storage);
}
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
private TaskScanner Scanner(ITaskDispatcher dispatcher, IMarketCalendar? market = null)
=> new(_repo, dispatcher, NullLoggerFactory.Instance, market, _clock);
/// <summary>Eine sofort fällige Aufgabe (kein Termin, nie gelaufen).</summary>
private Task<TaskItem> SeedDue(
string id, string assignee = "@new",
TaskItemStatus status = TaskItemStatus.Todo,
bool requireApproval = false, IReadOnlyList<string>? blockedBy = null,
bool onlyWhenMarketOpen = false)
=> _repo.UpsertAsync(new TaskItem
{
Id = id,
Title = id,
Assignee = assignee,
Status = status,
RequireApproval = requireApproval,
BlockedBy = blockedBy ?? [],
OnlyWhenMarketOpen = onlyWhenMarketOpen
}, default);
// ─── Grundfluss ───
[Fact]
public async Task Eine_faellige_Aufgabe_wird_ausgefuehrt_und_abgeschlossen()
{
await SeedDue("t-1");
var dispatcher = new FakeDispatcher();
var dispatched = await Scanner(dispatcher).ScanOnceAsync(default);
dispatched.ShouldBe(1);
dispatcher.Dispatched.ShouldBe(["t-1"]);
(await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Done);
}
[Fact]
public async Task Mit_require_approval_landet_das_Ergebnis_im_Review()
{
await SeedDue("t-1", requireApproval: true);
await Scanner(new FakeDispatcher()).ScanOnceAsync(default);
(await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.InReview);
}
[Fact]
public async Task Ein_gescheiterter_Lauf_geht_zurueck_auf_todo_und_wird_nicht_wiederholt()
{
await SeedDue("t-1");
var dispatcher = new FakeDispatcher(succeed: false);
var scanner = Scanner(dispatcher);
await scanner.ScanOnceAsync(default);
(await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Todo);
// Zweiter Takt: der Marker ist gesetzt, derselbe Termin läuft nicht erneut.
await scanner.ScanOnceAsync(default);
dispatcher.Dispatched.Count.ShouldBe(1, "kein Retry-Sturm für denselben Termin");
}
// ─── Invariante 2: kein Dispatch bei offenem Blocker / an Menschen ───
[Fact]
public async Task Eine_blockierte_Aufgabe_wird_nicht_angestossen()
{
await SeedDue("t-1", status: TaskItemStatus.Blocked, blockedBy: ["t-0"]);
var dispatcher = new FakeDispatcher();
(await Scanner(dispatcher).ScanOnceAsync(default)).ShouldBe(0);
dispatcher.Dispatched.ShouldBeEmpty();
}
[Fact]
public async Task Eine_Aufgabe_fuer_einen_Menschen_wird_nicht_angestossen()
{
await SeedDue("t-1", assignee: "@human");
var dispatcher = new FakeDispatcher();
(await Scanner(dispatcher).ScanOnceAsync(default)).ShouldBe(0);
dispatcher.Dispatched.ShouldBeEmpty();
(await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Todo, "sie wartet unverändert");
}
// ─── Invariante 3: doppelter Takt = ein Lauf ───
[Fact]
public async Task Zwei_gleichzeitige_Takte_stossen_eine_Aufgabe_nur_einmal_an()
{
for (var round = 0; round < 20; round++)
{
var id = $"t-{round}";
await SeedDue(id);
var dispatcher = new FakeDispatcher();
var scanner = Scanner(dispatcher);
await Task.WhenAll(
scanner.ScanOnceAsync(default),
scanner.ScanOnceAsync(default));
dispatcher.Dispatched.Count(x => x == id).ShouldBe(1, $"Runde {round}");
}
}
// ─── Wiederkehrende Tasks (ersetzt den Alt-Scheduler) ───
[Fact]
public async Task Ein_wiederkehrender_Task_bleibt_nach_dem_Feuern_auf_todo()
{
_clock.SetUtcNow(new DateTimeOffset(2027, 1, 1, 9, 0, 0, TimeSpan.Zero));
await _repo.UpsertAsync(new TaskItem
{
Id = "t-rec", Title = "täglich", Assignee = "@new", Status = TaskItemStatus.Todo,
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "UTC" }
}, default);
await Scanner(new FakeDispatcher()).ScanOnceAsync(default);
(await _repo.GetAsync("t-rec", default))!.Status.ShouldBe(
TaskItemStatus.Todo, "sonst liefe ein Cron-Task nur ein einziges Mal");
}
[Fact]
public async Task Ein_wiederkehrender_Task_feuert_an_jedem_Termin_erneut()
{
await _repo.UpsertAsync(new TaskItem
{
Id = "t-rec", Title = "täglich", Assignee = "@new", Status = TaskItemStatus.Todo,
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "UTC" }
}, default);
var dispatcher = new FakeDispatcher();
var scanner = Scanner(dispatcher);
_clock.SetUtcNow(new DateTimeOffset(2027, 1, 1, 9, 0, 0, TimeSpan.Zero));
await scanner.ScanOnceAsync(default);
_clock.SetUtcNow(new DateTimeOffset(2027, 1, 2, 9, 0, 0, TimeSpan.Zero));
await scanner.ScanOnceAsync(default);
dispatcher.Dispatched.Count(x => x == "t-rec").ShouldBe(2, "zwei Termine, zwei Läufe");
}
[Fact]
public async Task Ein_backlog_Task_wird_nicht_ausgefuehrt()
{
await SeedDue("t-1", status: TaskItemStatus.Backlog);
var dispatcher = new FakeDispatcher();
(await Scanner(dispatcher).ScanOnceAsync(default)).ShouldBe(0);
dispatcher.Dispatched.ShouldBeEmpty();
}
// ─── C1-Haken: Marktkalender ───
[Fact]
public async Task Bei_geschlossenem_Markt_bleibt_ein_marktabhaengiger_Termin_liegen()
{
await SeedDue("t-1", onlyWhenMarketOpen: true);
var dispatcher = new FakeDispatcher();
var scanner = Scanner(dispatcher, new ClosedMarket());
(await scanner.ScanOnceAsync(default)).ShouldBe(0);
dispatcher.Dispatched.ShouldBeEmpty();
(await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Todo);
}
// ─── Auto-Dispatch: Blocker fertig → Wartende frei ───
[Fact]
public async Task Wird_der_letzte_Blocker_fertig_gibt_das_die_wartende_Aufgabe_frei()
{
await SeedDue("t-blocker");
await SeedDue("t-wartend", status: TaskItemStatus.Blocked, blockedBy: ["t-blocker"]);
await Scanner(new FakeDispatcher()).ScanOnceAsync(default);
(await _repo.GetAsync("t-blocker", default))!.Status.ShouldBe(TaskItemStatus.Done);
(await _repo.GetAsync("t-wartend", default))!.Status.ShouldBe(
TaskItemStatus.Todo, "der Blocker ist erledigt, also ist sie jetzt bereit");
}
[Fact]
public async Task Solange_ein_Blocker_offen_ist_bleibt_die_wartende_Aufgabe_blockiert()
{
await SeedDue("t-b1"); // wird in diesem Takt fertig
await SeedDue("t-b2", assignee: "@human"); // ein Mensch — bleibt offen
await SeedDue("t-wartend", status: TaskItemStatus.Blocked, blockedBy: ["t-b1", "t-b2"]);
await Scanner(new FakeDispatcher()).ScanOnceAsync(default);
(await _repo.GetAsync("t-wartend", default))!.Status.ShouldBe(
TaskItemStatus.Blocked, "nicht alle Blocker sind erledigt");
}
// ─── Attrappen ───
private sealed class FakeDispatcher(bool succeed = true) : ITaskDispatcher
{
private readonly Lock _lock = new();
public List<string> Dispatched { get; } = [];
public Task<bool> DispatchAsync(TaskItem task, CancellationToken ct)
{
lock (_lock)
Dispatched.Add(task.Id);
return Task.FromResult(succeed);
}
}
private sealed class ClosedMarket : IMarketCalendar
{
public bool IsOpen(DateTime nowUtc) => false;
}
}
@@ -0,0 +1,246 @@
using ClawdDotNet.Core.Tasks;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Tasks;
/// <summary>
/// Die Terminberechnung — Antwort auf B6 (keine Delays, nur „was ist jetzt fällig")
/// und B7 (jeder Termin wird explizit in seiner Zeitzone gedeutet). Reine Funktion,
/// deshalb ohne DB und ohne Uhr-Attrappe: „jetzt" wird direkt übergeben.
/// </summary>
public sealed class TaskScheduleTests
{
private static DateTime Utc(int y, int mo, int d, int h, int mi)
=> new(y, mo, d, h, mi, 0, DateTimeKind.Utc);
// ─── Einmalig, ohne Termin ───
[Fact]
public void Ohne_Termin_ist_die_Aufgabe_faellig_bis_sie_lief()
{
var task = new TaskItem { Id = "t-1", CreatedAt = Utc(2026, 7, 31, 6, 0) };
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 7, 0)).ShouldNotBeNull();
TaskSchedule.DueOccurrence(task with { LastOccurrence = "x" }, Utc(2026, 7, 31, 7, 0)).ShouldBeNull();
}
// ─── at: einmaliger Zeitpunkt ───
[Fact]
public void Ein_at_Termin_ist_erst_ab_dem_Zeitpunkt_faellig()
{
var task = new TaskItem
{
Id = "t-1",
When = new TaskWhen { Kind = TaskWhenKind.At, Value = "2026-07-31T09:00:00", TimeZone = "Europe/Berlin" }
};
// 09:00 Berlin (Sommerzeit) = 07:00 UTC
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 6, 59)).ShouldBeNull();
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 7, 0)).ShouldNotBeNull();
}
[Fact]
public void Ein_at_Termin_feuert_genau_einmal()
{
var task = new TaskItem
{
Id = "t-1",
LastOccurrence = "2026-07-31T07:00:00.0000000Z",
When = new TaskWhen { Kind = TaskWhenKind.At, Value = "2026-07-31T09:00:00", TimeZone = "Europe/Berlin" }
};
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 12, 0)).ShouldBeNull();
}
[Fact]
public void Ein_at_Termin_mit_Z_wird_als_UTC_gedeutet()
{
var task = new TaskItem
{
Id = "t-1",
When = new TaskWhen { Kind = TaskWhenKind.At, Value = "2026-07-31T09:00:00Z", TimeZone = "Europe/Berlin" }
};
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 8, 59)).ShouldBeNull();
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 9, 0)).ShouldNotBeNull();
}
// ─── every: Intervall ───
[Fact]
public void Ein_Intervall_wird_erst_nach_Ablauf_faellig()
{
var task = new TaskItem
{
Id = "t-1",
CreatedAt = Utc(2026, 7, 31, 7, 0),
When = new TaskWhen { Kind = TaskWhenKind.Every, Value = "30m", TimeZone = "UTC" }
};
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 7, 20)).ShouldBeNull();
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 7, 30)).ShouldNotBeNull();
}
[Fact]
public void Nach_dem_Marker_zaehlt_das_Intervall_neu()
{
var task = new TaskItem
{
Id = "t-1",
LastOccurrence = "2026-07-31T07:30:00.0000000Z",
When = new TaskWhen { Kind = TaskWhenKind.Every, Value = "30m", TimeZone = "UTC" }
};
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 7, 55)).ShouldBeNull();
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 8, 0)).ShouldNotBeNull();
}
// ─── cron: mit Zeitzone (der Kern von B7) ───
[Fact]
public void Ein_Cron_Termin_wird_in_seiner_Zeitzone_gedeutet_Sommer()
{
var task = new TaskItem
{
Id = "t-1",
CreatedAt = Utc(2026, 7, 31, 0, 0),
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "Europe/Berlin" }
};
// 09:00 Berlin im Sommer (CEST, UTC+2) = 07:00 UTC
var occ = TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 7, 5));
occ.ShouldBe("2026-07-31T07:00:00.0000000Z");
}
[Fact]
public void Derselbe_Cron_liegt_im_Winter_auf_einer_anderen_UTC_Stunde()
{
var task = new TaskItem
{
Id = "t-1",
CreatedAt = Utc(2026, 1, 15, 0, 0),
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "Europe/Berlin" }
};
// 09:00 Berlin im Winter (CET, UTC+1) = 08:00 UTC — genau das, was B7 verlangt.
var occ = TaskSchedule.DueOccurrence(task, Utc(2026, 1, 15, 8, 5));
occ.ShouldBe("2026-01-15T08:00:00.0000000Z");
}
[Fact]
public void Vor_der_geplanten_Zeit_ist_der_Cron_nicht_faellig()
{
var task = new TaskItem
{
Id = "t-1",
CreatedAt = Utc(2026, 7, 31, 5, 0),
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "Europe/Berlin" }
};
// 06:00 UTC = 08:00 Berlin, vor der 09:00-Marke
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 6, 0)).ShouldBeNull();
}
[Fact]
public void Eine_frische_Cron_Aufgabe_holt_keinen_Termin_von_vor_ihrer_Anlage_nach()
{
var task = new TaskItem
{
Id = "t-1",
CreatedAt = Utc(2026, 7, 31, 8, 0), // nach der heutigen 09:00-Berlin-Marke (07:00 UTC)
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "Europe/Berlin" }
};
// Jetzt 10:00 UTC — die heutige 09:00-Marke lag vor der Anlage, also nicht nachholen.
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 10, 0)).ShouldBeNull();
}
[Fact]
public void Derselbe_Cron_Termin_feuert_nicht_zweimal()
{
var task = new TaskItem
{
Id = "t-1",
LastOccurrence = "2026-07-31T07:00:00.0000000Z",
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "Europe/Berlin" }
};
// Später am selben Tag: der 09:00-Termin ist schon abgehakt.
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 10, 0)).ShouldBeNull();
// Der nächste Tag ist wieder fällig.
TaskSchedule.DueOccurrence(task, Utc(2026, 8, 1, 7, 5)).ShouldBe("2026-08-01T07:00:00.0000000Z");
}
[Fact]
public void Ein_kaputter_Cron_Ausdruck_kippt_den_Scanner_nicht()
{
var task = new TaskItem
{
Id = "t-1",
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "kein cron", TimeZone = "UTC" }
};
Should.NotThrow(() => TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 9, 0)))
.ShouldBeNull();
}
/// <summary>
/// Früher fiel eine unbekannte Zone still auf UTC zurück. Das ist der schlechtere
/// Fehler: Ein Task für 09:00 Ortszeit lief im Sommer um 07:00, ohne dass irgendwo
/// etwas auffiel. Jetzt feuert er gar nicht — und der Scanner meldet einmal, warum.
/// </summary>
[Fact]
public void Eine_unbekannte_Zeitzone_laesst_den_Termin_nicht_faellig_werden()
{
var task = new TaskItem
{
Id = "t-1",
CreatedAt = Utc(2026, 7, 31, 0, 0),
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "Phantasie/Ort" }
};
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 9, 5)).ShouldBeNull();
TaskSchedule.UnresolvableTimeZone(task).ShouldBe("Phantasie/Ort");
}
/// <summary>
/// Der Kern der Portierung: Dieselbe Zeitzone, zwei Schreibweisen. Eine Task-Datei,
/// die unter Windows entstanden ist, muss unter Linux dieselbe Feuerzeit ergeben —
/// und umgekehrt.
/// </summary>
[Theory]
[InlineData("Europe/Berlin")]
[InlineData("W. Europe Standard Time")]
public void Beide_Schreibweisen_derselben_Zone_ergeben_dieselbe_Feuerzeit(string tz)
{
var task = new TaskItem
{
Id = "t-1",
CreatedAt = Utc(2026, 7, 31, 0, 0),
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = tz }
};
TaskSchedule.UnresolvableTimeZone(task).ShouldBeNull();
// 09:00 Ortszeit im Sommer (MESZ, UTC+2) = 07:00 UTC.
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 9, 5))
.ShouldBe("2026-07-31T07:00:00.0000000Z");
}
[Fact]
public void Keine_Zeitzonenangabe_bedeutet_weiterhin_UTC()
{
var task = new TaskItem
{
Id = "t-1",
CreatedAt = Utc(2026, 7, 31, 0, 0),
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "" }
};
TaskSchedule.UnresolvableTimeZone(task).ShouldBeNull();
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 9, 5))
.ShouldBe("2026-07-31T09:00:00.0000000Z");
}
}
@@ -0,0 +1,163 @@
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Tasks;
/// <summary>
/// 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.
/// </summary>
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);
}
}
@@ -0,0 +1,93 @@
using System.Text.Json;
using ClawdDotNet.Core.State;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tests.Infrastructure;
using ClawdDotNet.Core.Tools;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Tasks;
/// <summary>
/// Der tool_job-Dispatch — ersetzt den ToolJobScheduler. Ein fälliger Poll-Task tickt einen
/// <see cref="IToolJobProvider"/> und weckt den Agenten nur, wenn der Tick etwas meldet.
/// </summary>
public sealed class ToolJobDispatchTests
{
private static TaskItem PollTask(string agent = "agent-a") => new()
{
Id = "tj-1",
Title = "Poll",
Type = TaskItemType.ToolJob,
ToolName = "PollTool",
JobTypeId = "poll",
Assignee = "@" + agent
};
private static EngineTaskDispatcher Dispatcher(EngineFixture fixture)
=> new(fixture.Engine, () => fixture.Agents, "test-instance",
fixture.Registry, fixture.StateStore, NullLoggerFactory.Instance);
[Fact]
public async Task Ein_Poll_ohne_Fund_weckt_den_Agenten_nicht()
{
var tool = new FakeJobTool(ToolJobResult.NoAction("nichts Neues"));
var fixture = new EngineFixture().WithTool(tool);
fixture.AddAgent("agent-a", "PollTool");
var ok = await Dispatcher(fixture).DispatchAsync(PollTask(), default);
ok.ShouldBeTrue("ein Poll ohne Fund ist kein Fehler");
tool.Ticks.ShouldBe(1);
fixture.Client.ReceivedRequests.ShouldBeEmpty("kein Agent-Lauf, wenn nichts zu wecken war");
}
[Fact]
public async Task Ein_Poll_mit_Fund_weckt_den_Agenten()
{
var tool = new FakeJobTool(ToolJobResult.Wake("Neue Nachricht eingetroffen"));
var fixture = new EngineFixture().WithTool(tool);
fixture.AddAgent("agent-a", "PollTool");
fixture.Client.RespondsWithText("verarbeitet");
var ok = await Dispatcher(fixture).DispatchAsync(PollTask(), default);
ok.ShouldBeTrue();
tool.Ticks.ShouldBe(1);
fixture.Client.ReceivedRequests.ShouldNotBeEmpty("der Agent wurde geweckt");
}
[Fact]
public async Task Ein_unbekanntes_Poll_Tool_meldet_einen_Fehler()
{
var fixture = new EngineFixture(); // PollTool nicht registriert
fixture.AddAgent("agent-a");
(await Dispatcher(fixture).DispatchAsync(PollTask(), default)).ShouldBeFalse();
}
/// <summary>Ein Tool, das zugleich Poll-Provider ist — zählt Ticks, liefert ein festes Ergebnis.</summary>
private sealed class FakeJobTool(ToolJobResult result) : IAgentTool, IToolJobProvider
{
public int Ticks { get; private set; }
public string Name => "PollTool";
public string Description => "Test-Poll";
public JsonElement InputSchema { get; } =
JsonDocument.Parse("""{ "type": "object" }""").RootElement.Clone();
public Task<ToolResult> ExecuteAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
=> Task.FromResult(ToolResult.Ok("ok"));
public IReadOnlyList<ToolJobDefinition> GetJobDefinitions() => [];
public Task<ToolJobResult> ExecuteJobAsync(
string jobTypeId, IReadOnlyDictionary<string, object?> toolConfig, IStateStore stateStore,
ILogger logger, CancellationToken ct, string? agentId = null, string? workspacePath = null)
{
Ticks++;
return Task.FromResult(result);
}
}
}