Stufe 4c (#764): Kommentare in C#, WebUI-Skripten, CSS und HTML auf Englisch
Rund 2.600 Kommentarblöcke in 310 C#-Dateien übersetzt, Begründungen vollständig erhalten: blockweise gleiche Anzahl wie vorher, kein Block unter 60 % seiner Länge, der Code außerhalb der Kommentare per Tokenizer byte-identisch geprüft. Zitate deutscher Oberflächentexte und Testdaten bleiben deutsch. WebUI: Kommentare in JS/CSS/HTML übersetzt, JSDoc-@param an die in 4b umbenannten Parameter angeglichen. Dabei behoben: - 4b hatte "tag" (HTML-Tag) als deutsches "Tag" gelesen und zu "dayValue" gemacht (makeElement, svg, Tagesübersicht) - zurück auf tag/day. - Aufgaben "InProgress" bekamen die Plakettenart "laufend", für die es nie eine CSS-Klasse gab - jetzt "success" wie laufende Instanzen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
da4ceac7d5
commit
837a634a22
@@ -6,10 +6,10 @@ 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.
|
||||
/// Step 4: the takeover of the old <c>coordination/*.md</c> files. Checked against the
|
||||
/// actual legacy formats (free Markdown, no frontmatter). Core claim: only real
|
||||
/// tasks (<c>task_*</c>) move into the board, everything else stays untouched, and the
|
||||
/// migration is idempotent.
|
||||
/// </summary>
|
||||
public sealed class CoordinationMigrationTests : IDisposable
|
||||
{
|
||||
@@ -37,7 +37,7 @@ public sealed class CoordinationMigrationTests : IDisposable
|
||||
{
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
catch { /* Cleaning up is secondary */ }
|
||||
}
|
||||
|
||||
private void Write(string name, string content)
|
||||
|
||||
@@ -4,8 +4,8 @@ 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.
|
||||
/// The assignee determines how a due task becomes a run (T7). The interpretation
|
||||
/// must be unambiguous — whether an agent runs fresh or with context depends on it.
|
||||
/// </summary>
|
||||
public sealed class TaskAssigneeTests
|
||||
{
|
||||
@@ -20,7 +20,7 @@ public sealed class TaskAssigneeTests
|
||||
[Theory]
|
||||
[InlineData("@crawler", "crawler")]
|
||||
[InlineData("@new:crawler", "crawler")]
|
||||
[InlineData("@new", "")] // frischer Lauf ohne benannten Agenten
|
||||
[InlineData("@new", "")] // fresh run without a named agent
|
||||
[InlineData("@human", "")]
|
||||
public void Die_Agent_Id_wird_richtig_herausgezogen(string assignee, string expected)
|
||||
=> TaskAssignee.AgentId(assignee).ShouldBe(expected);
|
||||
|
||||
@@ -4,10 +4,10 @@ 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.
|
||||
/// The frontmatter parser is the bridge between the human/agent-readable
|
||||
/// task file and the DB state. What it reads wrongly, the board imports wrongly —
|
||||
/// that's why the round trip is at the center here: serializing and reading back
|
||||
/// must yield the same definition.
|
||||
/// </summary>
|
||||
public sealed class TaskFrontmatterTests
|
||||
{
|
||||
@@ -98,7 +98,7 @@ public sealed class TaskFrontmatterTests
|
||||
[Fact]
|
||||
public void Ein_Assignee_mit_At_ueberlebt_den_Rundlauf()
|
||||
{
|
||||
// In YAML ist ein führendes '@' reserviert — ohne Quotierung ginge es verloren.
|
||||
// In YAML a leading '@' is reserved — without quoting it would get lost.
|
||||
var task = new TaskItem { Id = "t-1", Title = "x", Assignee = "@new" };
|
||||
|
||||
var text = TaskFrontmatter.Serialize(task);
|
||||
|
||||
@@ -5,12 +5,12 @@ 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.
|
||||
/// A1 taskboard: the execution state. Tested against a real SQLite file, because this is
|
||||
/// precisely about schema, locking behavior and atomic claiming — a fake would
|
||||
/// hide exactly what matters.
|
||||
///
|
||||
/// 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.
|
||||
/// The three invariants from the taskboard concept are at the center: never two claims
|
||||
/// on one occurrence, no dispatch with an open blocker, double tick = one run.
|
||||
/// </summary>
|
||||
public sealed class TaskRepositoryTests : IDisposable
|
||||
{
|
||||
@@ -29,7 +29,7 @@ public sealed class TaskRepositoryTests : IDisposable
|
||||
{
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
catch { /* Cleaning up is secondary */ }
|
||||
}
|
||||
|
||||
private Task<TaskItem> Seed(
|
||||
@@ -49,10 +49,10 @@ public sealed class TaskRepositoryTests : IDisposable
|
||||
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
|
||||
private static DateTime NoStaleClaims => Now.AddHours(-1); // lease cutoff far in the past
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Grundfunktionen: Import (Upsert), Lesen, Auflisten
|
||||
// Basic functions: import (upsert), read, list
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
@@ -80,14 +80,14 @@ public sealed class TaskRepositoryTests : IDisposable
|
||||
[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.
|
||||
// The crux of the split of truth: the file is the truth about the
|
||||
// definition, the DB about the execution state. A renewed import must not reset a
|
||||
// running or completed state.
|
||||
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").
|
||||
// The file is imported again (title changed, status in the frontmatter still "todo").
|
||||
await _repo.UpsertAsync(new TaskItem { Id = "t-1", Title = "geändert", Status = TaskItemStatus.Todo }, default);
|
||||
|
||||
var after = await _repo.GetAsync("t-1", default);
|
||||
@@ -132,7 +132,7 @@ public sealed class TaskRepositoryTests : IDisposable
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Invariante 1 & 3: nie zwei Claims, doppelter Tick = ein Lauf
|
||||
// Invariants 1 & 3: never two claims, double tick = one run
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
@@ -148,7 +148,7 @@ public sealed class TaskRepositoryTests : IDisposable
|
||||
[Fact]
|
||||
public async Task Gleichzeitige_Claims_auf_denselben_Termin_ergeben_genau_einen_Gewinner()
|
||||
{
|
||||
// Die Kern-Invariante. Mehrfach wiederholt, weil ein Race sporadisch auftritt.
|
||||
// The core invariant. Repeated several times, because a race occurs sporadically.
|
||||
for (var round = 0; round < 25; round++)
|
||||
{
|
||||
var id = $"t-race-{round}";
|
||||
@@ -168,11 +168,11 @@ public sealed class TaskRepositoryTests : IDisposable
|
||||
{
|
||||
await Seed("t-1");
|
||||
|
||||
// Erster Tick beansprucht und schließt ab.
|
||||
// The first tick claims and completes.
|
||||
(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.
|
||||
// Second tick in the same schedule window: the same occurrence key.
|
||||
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok2", Now, NoStaleClaims, default))
|
||||
.ShouldBeFalse("derselbe Termin darf nach Abschluss nicht erneut laufen");
|
||||
}
|
||||
@@ -180,11 +180,11 @@ public sealed class TaskRepositoryTests : IDisposable
|
||||
[Fact]
|
||||
public async Task Ein_gescheiterter_Lauf_wird_nicht_automatisch_wiederholt()
|
||||
{
|
||||
// Kein Retry-Sturm: Der Marker steht schon beim Claim, nicht erst beim Erfolg.
|
||||
// No retry storm: the marker is already set at claim time, not only on success.
|
||||
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.
|
||||
// Run fails → back to todo, but the same occurrence stays used up.
|
||||
await _repo.CompleteClaimAsync("t-1", "tok", TaskItemStatus.Todo, Now, default);
|
||||
|
||||
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok2", Now, NoStaleClaims, default))
|
||||
@@ -198,14 +198,14 @@ public sealed class TaskRepositoryTests : IDisposable
|
||||
(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.
|
||||
// A later occurrence key lies above the marker → due again.
|
||||
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
|
||||
// Invariant 2: no dispatch with an open blocker
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
@@ -231,7 +231,7 @@ public sealed class TaskRepositoryTests : IDisposable
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Claim-Abschluss, Lease und Reconciliation
|
||||
// Claim completion, lease and reconciliation
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
@@ -252,11 +252,11 @@ public sealed class TaskRepositoryTests : IDisposable
|
||||
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).
|
||||
// Claim with a point in time that lies far back already (crashed run).
|
||||
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.
|
||||
// Reconciliation: claims older than 5 minutes count as orphaned.
|
||||
var reset = await _repo.ReleaseStaleClaimsAsync(Now.AddMinutes(-5), Now, default);
|
||||
|
||||
reset.ShouldBe(1);
|
||||
@@ -286,7 +286,7 @@ public sealed class TaskRepositoryTests : IDisposable
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Nebenläufigkeit und Persistenz
|
||||
// Concurrency and persistence
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -7,9 +7,9 @@ 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.
|
||||
/// The scanner core — the delicate part marked in the roadmap for Opus 5/Fable. Tested
|
||||
/// against real SQLite (atomic claiming is the point) with a fake for the
|
||||
/// execution, so no engine is needed. The three invariants are at the center.
|
||||
/// </summary>
|
||||
public sealed class TaskScannerTests : IDisposable
|
||||
{
|
||||
@@ -29,13 +29,13 @@ public sealed class TaskScannerTests : IDisposable
|
||||
{
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
catch { /* Cleaning up is secondary */ }
|
||||
}
|
||||
|
||||
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>
|
||||
/// <summary>A task due immediately (no schedule, never run).</summary>
|
||||
private Task<TaskItem> SeedDue(
|
||||
string id, string assignee = "@new",
|
||||
TaskItemStatus status = TaskItemStatus.Todo,
|
||||
@@ -52,7 +52,7 @@ public sealed class TaskScannerTests : IDisposable
|
||||
OnlyWhenMarketOpen = onlyWhenMarketOpen
|
||||
}, default);
|
||||
|
||||
// ─── Grundfluss ───
|
||||
// ─── Basic flow ───
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_faellige_Aufgabe_wird_ausgefuehrt_und_abgeschlossen()
|
||||
@@ -87,12 +87,12 @@ public sealed class TaskScannerTests : IDisposable
|
||||
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.
|
||||
// Second tick: the marker is set, the same occurrence does not run again.
|
||||
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 ───
|
||||
// ─── Invariant 2: no dispatch with an open blocker / to humans ───
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_blockierte_Aufgabe_wird_nicht_angestossen()
|
||||
@@ -115,7 +115,7 @@ public sealed class TaskScannerTests : IDisposable
|
||||
(await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Todo, "sie wartet unverändert");
|
||||
}
|
||||
|
||||
// ─── Invariante 3: doppelter Takt = ein Lauf ───
|
||||
// ─── Invariant 3: double tick = one run ───
|
||||
|
||||
[Fact]
|
||||
public async Task Zwei_gleichzeitige_Takte_stossen_eine_Aufgabe_nur_einmal_an()
|
||||
@@ -135,7 +135,7 @@ public sealed class TaskScannerTests : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Wiederkehrende Tasks (ersetzt den Alt-Scheduler) ───
|
||||
// ─── Recurring tasks (replaces the legacy scheduler) ───
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_wiederkehrender_Task_bleibt_nach_dem_Feuern_auf_todo()
|
||||
@@ -183,7 +183,7 @@ public sealed class TaskScannerTests : IDisposable
|
||||
dispatcher.Dispatched.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ─── C1-Haken: Marktkalender ───
|
||||
// ─── C1 hook: market calendar ───
|
||||
|
||||
[Fact]
|
||||
public async Task Bei_geschlossenem_Markt_bleibt_ein_marktabhaengiger_Termin_liegen()
|
||||
@@ -198,7 +198,7 @@ public sealed class TaskScannerTests : IDisposable
|
||||
(await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Todo);
|
||||
}
|
||||
|
||||
// ─── Auto-Dispatch: Blocker fertig → Wartende frei ───
|
||||
// ─── Auto-dispatch: blocker done → waiting ones released ───
|
||||
|
||||
[Fact]
|
||||
public async Task Wird_der_letzte_Blocker_fertig_gibt_das_die_wartende_Aufgabe_frei()
|
||||
@@ -216,8 +216,8 @@ public sealed class TaskScannerTests : IDisposable
|
||||
[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-b1"); // finishes in this tick
|
||||
await SeedDue("t-b2", assignee: "@human"); // a human — stays open
|
||||
await SeedDue("t-wartend", status: TaskItemStatus.Blocked, blockedBy: ["t-b1", "t-b2"]);
|
||||
|
||||
await Scanner(new FakeDispatcher()).ScanOnceAsync(default);
|
||||
@@ -226,7 +226,7 @@ public sealed class TaskScannerTests : IDisposable
|
||||
TaskItemStatus.Blocked, "nicht alle Blocker sind erledigt");
|
||||
}
|
||||
|
||||
// ─── #643: Nicht gelaufene Aufgaben ───
|
||||
// ─── #643: tasks that did not run ───
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_einmalige_Aufgabe_ohne_Budget_bleibt_faellig_statt_liegenzubleiben()
|
||||
@@ -260,7 +260,7 @@ public sealed class TaskScannerTests : IDisposable
|
||||
(await _repo.GetAsync("t-kaputt", default))!.LastOccurrence.ShouldNotBeNull("kein Retry-Sturm nach echtem Fehlschlag");
|
||||
}
|
||||
|
||||
// ─── Attrappen ───
|
||||
// ─── Fakes ───
|
||||
|
||||
private sealed class FakeDispatcher(bool succeed = true) : ITaskDispatcher
|
||||
{
|
||||
|
||||
@@ -4,16 +4,16 @@ 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.
|
||||
/// The schedule calculation — answer to B6 (no delays, only "what is due now")
|
||||
/// and B7 (every occurrence is explicitly interpreted in its time zone). A pure function,
|
||||
/// therefore without DB and without a fake clock: "now" is passed in directly.
|
||||
/// </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);
|
||||
|
||||
// ─── #597: ungültiger Cron-Ausdruck ───
|
||||
// ─── #597: invalid cron expression ───
|
||||
|
||||
[Fact]
|
||||
public void Ungueltiger_Cron_Ausdruck_wird_benannt_statt_verschwiegen()
|
||||
@@ -38,7 +38,7 @@ public sealed class TaskScheduleTests
|
||||
};
|
||||
|
||||
TaskSchedule.InvalidCron(task).ShouldBeNull();
|
||||
// 13:00 Berlin (MESZ) = 11:00 UTC
|
||||
// 13:00 Berlin (CEST) = 11:00 UTC
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 9, 14, 11, 0)).ShouldBe("2026-09-14T11:00:00.0000000Z");
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ public sealed class TaskScheduleTests
|
||||
Id = "t-1", When = new TaskWhen { Kind = TaskWhenKind.Every, Value = "kaputt" }
|
||||
}).ShouldBeNull();
|
||||
|
||||
// ─── Einmalig, ohne Termin ───
|
||||
// ─── One-time, without a schedule ───
|
||||
|
||||
[Fact]
|
||||
public void Ohne_Termin_ist_die_Aufgabe_faellig_bis_sie_lief()
|
||||
@@ -60,7 +60,7 @@ public sealed class TaskScheduleTests
|
||||
TaskSchedule.DueOccurrence(task with { LastOccurrence = "x" }, Utc(2026, 7, 31, 7, 0)).ShouldBeNull();
|
||||
}
|
||||
|
||||
// ─── at: einmaliger Zeitpunkt ───
|
||||
// ─── at: one-time point in time ───
|
||||
|
||||
[Fact]
|
||||
public void Ein_at_Termin_ist_erst_ab_dem_Zeitpunkt_faellig()
|
||||
@@ -71,7 +71,7 @@ public sealed class TaskScheduleTests
|
||||
When = new TaskWhen { Kind = TaskWhenKind.At, Value = "2026-07-31T09:00:00", TimeZone = "Europe/Berlin" }
|
||||
};
|
||||
|
||||
// 09:00 Berlin (Sommerzeit) = 07:00 UTC
|
||||
// 09:00 Berlin (summer time) = 07:00 UTC
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 6, 59)).ShouldBeNull();
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 7, 0)).ShouldNotBeNull();
|
||||
}
|
||||
@@ -102,7 +102,7 @@ public sealed class TaskScheduleTests
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 9, 0)).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
// ─── every: Intervall ───
|
||||
// ─── every: interval ───
|
||||
|
||||
[Fact]
|
||||
public void Ein_Intervall_wird_erst_nach_Ablauf_faellig()
|
||||
@@ -132,7 +132,7 @@ public sealed class TaskScheduleTests
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 8, 0)).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
// ─── cron: mit Zeitzone (der Kern von B7) ───
|
||||
// ─── cron: with time zone (the core of B7) ───
|
||||
|
||||
[Fact]
|
||||
public void Ein_Cron_Termin_wird_in_seiner_Zeitzone_gedeutet_Sommer()
|
||||
@@ -144,7 +144,7 @@ public sealed class TaskScheduleTests
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "Europe/Berlin" }
|
||||
};
|
||||
|
||||
// 09:00 Berlin im Sommer (CEST, UTC+2) = 07:00 UTC
|
||||
// 09:00 Berlin in summer (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");
|
||||
}
|
||||
@@ -159,7 +159,7 @@ public sealed class TaskScheduleTests
|
||||
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.
|
||||
// 09:00 Berlin in winter (CET, UTC+1) = 08:00 UTC — exactly what B7 demands.
|
||||
var occ = TaskSchedule.DueOccurrence(task, Utc(2026, 1, 15, 8, 5));
|
||||
occ.ShouldBe("2026-01-15T08:00:00.0000000Z");
|
||||
}
|
||||
@@ -174,7 +174,7 @@ public sealed class TaskScheduleTests
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "Europe/Berlin" }
|
||||
};
|
||||
|
||||
// 06:00 UTC = 08:00 Berlin, vor der 09:00-Marke
|
||||
// 06:00 UTC = 08:00 Berlin, before the 09:00 mark
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 6, 0)).ShouldBeNull();
|
||||
}
|
||||
|
||||
@@ -184,11 +184,11 @@ public sealed class TaskScheduleTests
|
||||
var task = new TaskItem
|
||||
{
|
||||
Id = "t-1",
|
||||
CreatedAt = Utc(2026, 7, 31, 8, 0), // nach der heutigen 09:00-Berlin-Marke (07:00 UTC)
|
||||
CreatedAt = Utc(2026, 7, 31, 8, 0), // after today's 09:00 Berlin mark (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.
|
||||
// Now 10:00 UTC — today's 09:00 mark was before creation, so do not catch up.
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 10, 0)).ShouldBeNull();
|
||||
}
|
||||
|
||||
@@ -202,10 +202,10 @@ public sealed class TaskScheduleTests
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "Europe/Berlin" }
|
||||
};
|
||||
|
||||
// Später am selben Tag: der 09:00-Termin ist schon abgehakt.
|
||||
// Later on the same day: the 09:00 occurrence is already ticked off.
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 10, 0)).ShouldBeNull();
|
||||
|
||||
// Der nächste Tag ist wieder fällig.
|
||||
// The next day is due again.
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 8, 1, 7, 5)).ShouldBe("2026-08-01T07:00:00.0000000Z");
|
||||
}
|
||||
|
||||
@@ -223,9 +223,9 @@ public sealed class TaskScheduleTests
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// Formerly an unknown zone silently fell back to UTC. That is the worse
|
||||
/// error: a task for 09:00 local time ran at 07:00 in summer without anything
|
||||
/// being noticed anywhere. Now it does not fire at all — and the scanner reports once why.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Eine_unbekannte_Zeitzone_laesst_den_Termin_nicht_faellig_werden()
|
||||
@@ -242,9 +242,9 @@ public sealed class TaskScheduleTests
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// The core of the port: the same time zone, two spellings. A task file
|
||||
/// created under Windows must yield the same fire time under Linux —
|
||||
/// and vice versa.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("Europe/Berlin")]
|
||||
@@ -260,7 +260,7 @@ public sealed class TaskScheduleTests
|
||||
|
||||
TaskSchedule.UnresolvableTimeZone(task).ShouldBeNull();
|
||||
|
||||
// 09:00 Ortszeit im Sommer (MESZ, UTC+2) = 07:00 UTC.
|
||||
// 09:00 local time in summer (CEST, UTC+2) = 07:00 UTC.
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 9, 5))
|
||||
.ShouldBe("2026-07-31T07:00:00.0000000Z");
|
||||
}
|
||||
@@ -281,12 +281,12 @@ public sealed class TaskScheduleTests
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Zeitumstellung — R4 und R5 der Teststrategie
|
||||
// Daylight saving changes — R4 and R5 of the test strategy
|
||||
//
|
||||
// Europe/Berlin 2026: Am 29. März springt die Uhr um 02:00 auf 03:00, am
|
||||
// 25. Oktober um 03:00 zurück auf 02:00. Ein täglicher Termin um 02:00 trifft
|
||||
// also einmal im Jahr eine Uhrzeit, die es nicht gibt, und einmal eine, die es
|
||||
// doppelt gibt.
|
||||
// Europe/Berlin 2026: on 29 March the clock jumps at 02:00 to 03:00, on
|
||||
// 25 October at 03:00 back to 02:00. A daily occurrence at 02:00 thus hits
|
||||
// once a year a time that does not exist, and once one that exists
|
||||
// twice.
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
private static TaskItem TaeglichUmZwei(DateTime angelegt) => new()
|
||||
@@ -296,15 +296,15 @@ public sealed class TaskScheduleTests
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 2 * * *", TimeZone = "Europe/Berlin" }
|
||||
};
|
||||
|
||||
/// <summary>R5 — der Fall, um den es in #235 geht.</summary>
|
||||
/// <summary>R5 — the case #235 is about.</summary>
|
||||
[Fact]
|
||||
public void Zeitumstellung_Maerz_wird_nicht_uebersprungen()
|
||||
{
|
||||
var task = TaeglichUmZwei(Utc(2026, 3, 28, 0, 0));
|
||||
|
||||
// "02:00" existiert am 29. März nicht. Der Termin fällt trotzdem an, und zwar
|
||||
// auf dem ersten Zeitpunkt danach: 03:00 MESZ = 01:00 UTC.
|
||||
// Vorher kam hier null — die Aufgabe fiel an diesem Tag ersatzlos aus.
|
||||
// "02:00" does not exist on 29 March. The occurrence still happens, namely
|
||||
// at the first point in time after it: 03:00 CEST = 01:00 UTC.
|
||||
// Previously null came here — the task was dropped on that day without replacement.
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 3, 29, 2, 0))
|
||||
.ShouldBe("2026-03-29T01:00:00.0000000Z");
|
||||
}
|
||||
@@ -321,15 +321,15 @@ public sealed class TaskScheduleTests
|
||||
.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>R4 — die andere Richtung.</summary>
|
||||
/// <summary>R4 — the other direction.</summary>
|
||||
[Fact]
|
||||
public void Zeitumstellung_Oktober_feuert_genau_einmal()
|
||||
{
|
||||
var task = TaeglichUmZwei(Utc(2026, 10, 24, 0, 0));
|
||||
|
||||
// 02:00 kommt am 25. Oktober zweimal vor: in Sommerzeit (00:00 UTC) und in
|
||||
// Normalzeit (01:00 UTC). Beide Durchläufe ergeben denselben Occurrence-Key —
|
||||
// der Marker fängt den zweiten ab.
|
||||
// 02:00 occurs twice on 25 October: in summer time (00:00 UTC) and in
|
||||
// standard time (01:00 UTC). Both passes result in the same occurrence key —
|
||||
// the marker catches the second.
|
||||
var ersterDurchlauf = TaskSchedule.DueOccurrence(task, Utc(2026, 10, 25, 0, 30));
|
||||
ersterDurchlauf.ShouldNotBeNull();
|
||||
|
||||
@@ -341,10 +341,10 @@ public sealed class TaskScheduleTests
|
||||
[Fact]
|
||||
public void An_gewoehnlichen_Tagen_bleibt_es_bei_zwei_Uhr_Ortszeit()
|
||||
{
|
||||
// Die Gegenprobe: Die Verschiebung darf nur in der Lücke greifen.
|
||||
// The counter-check: the shift may only apply in the gap.
|
||||
var task = TaeglichUmZwei(Utc(2026, 3, 21, 0, 0));
|
||||
|
||||
// 02:00 MEZ = 01:00 UTC.
|
||||
// 02:00 CET = 01:00 UTC.
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 3, 22, 2, 0))
|
||||
.ShouldBe("2026-03-22T01:00:00.0000000Z");
|
||||
}
|
||||
@@ -352,7 +352,7 @@ public sealed class TaskScheduleTests
|
||||
[Fact]
|
||||
public void Auch_ein_einmaliger_Termin_in_der_Luecke_faellt_nicht_aus()
|
||||
{
|
||||
// Derselbe Umgang auf dem at-Pfad — dort galt dieselbe Regel und dieselbe Lücke.
|
||||
// The same handling on the at path — the same rule and the same gap applied there.
|
||||
var task = new TaskItem
|
||||
{
|
||||
Id = "t-1",
|
||||
@@ -364,7 +364,7 @@ public sealed class TaskScheduleTests
|
||||
}
|
||||
};
|
||||
|
||||
// 02:30 gibt es nicht; es wird 03:00 MESZ = 01:00 UTC.
|
||||
// 02:30 does not exist; it becomes 03:00 CEST = 01:00 UTC.
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 3, 29, 0, 59)).ShouldBeNull();
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 3, 29, 1, 0))
|
||||
.ShouldBe("2026-03-29T01:00:00.0000000Z");
|
||||
|
||||
@@ -4,8 +4,8 @@ using Shouldly;
|
||||
namespace ClawdDotNet.Core.Tests.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Termine pruefen, naechster Lauf und Laeufe je Tag (#594, #595, #597) sowie der Hinweis
|
||||
/// auf wachsenden Kontext (#598).
|
||||
/// Checking schedules, next run and runs per day (#594, #595, #597) as well as the hint
|
||||
/// about growing context (#598).
|
||||
/// </summary>
|
||||
public sealed class TaskboardAusbauTests
|
||||
{
|
||||
@@ -14,7 +14,7 @@ public sealed class TaskboardAusbauTests
|
||||
private static TaskWhen Cron(string value, string tz = "Europe/Berlin")
|
||||
=> new() { Kind = TaskWhenKind.Cron, Value = value, TimeZone = tz };
|
||||
|
||||
// ─── #597 Termin pruefen ───
|
||||
// ─── #597 checking schedules ───
|
||||
|
||||
[Fact]
|
||||
public void Gueltiger_Termin_mit_Bereich_und_Schritt_besteht_die_Pruefung()
|
||||
@@ -37,14 +37,14 @@ public sealed class TaskboardAusbauTests
|
||||
=> (TaskSchedule.ValidateWhen(new TaskWhen { Kind = kind, Value = value, TimeZone = "Europe/Berlin" }) is null)
|
||||
.ShouldBe(gueltig);
|
||||
|
||||
// ─── #594 naechster Lauf ───
|
||||
// ─── #594 next run ───
|
||||
|
||||
[Fact]
|
||||
public void Naechster_Lauf_eines_Cron_Termins_in_Berliner_Zeit()
|
||||
{
|
||||
var task = new TaskItem { Id = "t-7e0bbe", When = Cron("0 7-22/3 * * *") };
|
||||
|
||||
// 12:30 Berlin (MESZ) -> naechster Termin 13:00 Berlin = 11:00 UTC
|
||||
// 12:30 Berlin (CEST) -> next occurrence 13:00 Berlin = 11:00 UTC
|
||||
TaskSchedule.NextOccurrenceUtc(task, Utc(2026, 9, 14, 10, 30)).ShouldBe(Utc(2026, 9, 14, 11, 0));
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ public sealed class TaskboardAusbauTests
|
||||
TaskSchedule.NextOccurrenceUtc(new TaskItem { Id = "t-1", LastOccurrence = "x" }, jetzt).ShouldBeNull();
|
||||
}
|
||||
|
||||
// ─── #595 Laeufe je Tag ───
|
||||
// ─── #595 runs per day ───
|
||||
|
||||
[Fact]
|
||||
public void Laeufe_je_Tag_aus_Cron_und_Intervall()
|
||||
@@ -82,7 +82,7 @@ public sealed class TaskboardAusbauTests
|
||||
TaskSchedule.RunsPerDay(new TaskItem(), jetzt).ShouldBe(0);
|
||||
}
|
||||
|
||||
// ─── #598 Hinweis ───
|
||||
// ─── #598 hint ───
|
||||
|
||||
[Fact]
|
||||
public void Wiederkehrend_im_Chatverlauf_bekommt_den_Hinweis()
|
||||
|
||||
@@ -5,9 +5,9 @@ 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.
|
||||
/// The split of truth in action: the service keeps the Markdown file (definition) and
|
||||
/// DB (execution state) in step. Tested against a real file system and real
|
||||
/// SQLite — both sides are the point.
|
||||
/// </summary>
|
||||
public sealed class TaskboardServiceTests : IDisposable
|
||||
{
|
||||
@@ -30,7 +30,7 @@ public sealed class TaskboardServiceTests : IDisposable
|
||||
{
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
catch { /* Cleaning up is secondary */ }
|
||||
}
|
||||
|
||||
private static TaskItem Def(string title = "Aufgabe", string assignee = "@new")
|
||||
@@ -44,10 +44,10 @@ public sealed class TaskboardServiceTests : IDisposable
|
||||
created.Id.ShouldNotBeNullOrWhiteSpace();
|
||||
created.Id.ShouldStartWith("t-");
|
||||
|
||||
// DB-Seite
|
||||
// DB side
|
||||
(await _repo.GetAsync(created.Id, default)).ShouldNotBeNull();
|
||||
|
||||
// Datei-Seite: existiert und lässt sich zur selben Definition zurücklesen
|
||||
// File side: exists and can be read back to the same definition
|
||||
var path = Path.Combine(_tasksDir, created.FileName);
|
||||
File.Exists(path).ShouldBeTrue();
|
||||
|
||||
@@ -76,7 +76,7 @@ public sealed class TaskboardServiceTests : IDisposable
|
||||
updated!.Title.ShouldBe("Neu");
|
||||
updated.Status.ShouldBe(TaskItemStatus.Done);
|
||||
|
||||
// Beide Seiten spiegeln die Änderung
|
||||
// Both sides mirror the change
|
||||
(await _repo.GetAsync(created.Id, default))!.Status.ShouldBe(TaskItemStatus.Done);
|
||||
TaskFrontmatter.TryParse(
|
||||
File.ReadAllText(Path.Combine(_tasksDir, created.FileName)), out var fromFile, out _);
|
||||
@@ -126,7 +126,7 @@ public sealed class TaskboardServiceTests : IDisposable
|
||||
imported.ShouldNotBeNull();
|
||||
imported!.Id.ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
// Die Id wurde in die Datei zurückgeschrieben — überlebt den nächsten Start.
|
||||
// The ID was written back into the file — survives the next start.
|
||||
TaskFrontmatter.TryParse(File.ReadAllText(path), out var fromFile, out _);
|
||||
fromFile.Id.ShouldBe(imported.Id);
|
||||
}
|
||||
@@ -139,7 +139,7 @@ public sealed class TaskboardServiceTests : IDisposable
|
||||
|
||||
await _board.ImportAllAsync(default);
|
||||
|
||||
// Die Historie bleibt: die Zeile ist archiviert, nicht weg.
|
||||
// The history stays: the row is archived, not gone.
|
||||
var after = await _repo.GetAsync(created.Id, default);
|
||||
after.ShouldNotBeNull();
|
||||
after!.Status.ShouldBe(TaskItemStatus.Archived);
|
||||
@@ -148,14 +148,14 @@ public sealed class TaskboardServiceTests : IDisposable
|
||||
[Fact]
|
||||
public async Task Ein_Re_Import_setzt_einen_laufenden_Status_nicht_zurueck()
|
||||
{
|
||||
// Die Kernregel der Wahrheitsaufteilung über den Dienst geprüft.
|
||||
// The core rule of the split of truth, checked via the service.
|
||||
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.
|
||||
// The file still says "todo" (that is how it was created) — an import must not
|
||||
// impose that over the done status reached.
|
||||
await _board.ImportAllAsync(default);
|
||||
|
||||
(await _repo.GetAsync(created.Id, default))!.Status.ShouldBe(TaskItemStatus.Done);
|
||||
|
||||
@@ -10,8 +10,8 @@ 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.
|
||||
/// The tool_job dispatch — replaces the ToolJobScheduler. A due poll task ticks an
|
||||
/// <see cref="IToolJobProvider"/> and only wakes the agent if the tick reports something.
|
||||
/// </summary>
|
||||
public sealed class ToolJobDispatchTests
|
||||
{
|
||||
@@ -61,13 +61,13 @@ public sealed class ToolJobDispatchTests
|
||||
[Fact]
|
||||
public async Task Ein_unbekanntes_Poll_Tool_meldet_einen_Fehler()
|
||||
{
|
||||
var fixture = new EngineFixture(); // PollTool nicht registriert
|
||||
var fixture = new EngineFixture(); // PollTool not registered
|
||||
fixture.AddAgent("agent-a");
|
||||
|
||||
(await Dispatcher(fixture).DispatchAsync(PollTask(), default)).ShouldBe(TaskLauf.Gescheitert);
|
||||
}
|
||||
|
||||
/// <summary>Ein Tool, das zugleich Poll-Provider ist — zählt Ticks, liefert ein festes Ergebnis.</summary>
|
||||
/// <summary>A tool that is at the same time a poll provider — counts ticks, delivers a fixed result.</summary>
|
||||
private sealed class FakeJobTool(ToolJobResult result) : IAgentTool, IToolJobProvider
|
||||
{
|
||||
public int Ticks { get; private set; }
|
||||
|
||||
Reference in New Issue
Block a user