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;
///
/// 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.
///
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);
/// Eine sofort fällige Aufgabe (kein Termin, nie gelaufen).
private Task SeedDue(
string id, string assignee = "@new",
TaskItemStatus status = TaskItemStatus.Todo,
bool requireApproval = false, IReadOnlyList? 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 Dispatched { get; } = [];
public Task 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;
}
}