using ClawdDotNet.Core.Scheduling; using Shouldly; namespace ClawdDotNet.Core.Tests.Scheduling; /// /// Cron-Parsing und Terminberechnung (deckt Teile von B6/B7 ab). Der bisher ungetestete /// Basis-Scheduler bekommt hier sein Netz: gültige Ausdrücke rechnen richtig, ungültige /// werfen eine saubere statt einer rohen Parser-Exception /// oder einer Endlosschleife. /// public sealed class CronExpressionTests { private static DateTime Local(int y, int mo, int d, int h, int mi) => new(y, mo, d, h, mi, 0, DateTimeKind.Local); // ─── R1: korrekte nächste Zeitpunkte ─── [Fact] public void Alle_30_Minuten() { var cron = CronExpression.Parse("*/30 * * * *"); cron.GetNextOccurrence(Local(2026, 7, 31, 10, 5)).ShouldBe(Local(2026, 7, 31, 10, 30)); cron.GetNextOccurrence(Local(2026, 7, 31, 10, 45)).ShouldBe(Local(2026, 7, 31, 11, 0)); } [Fact] public void Werktags_um_drei_Uhr_frueh() { var cron = CronExpression.Parse("0 3 * * 1-5"); // Mo–Fr // 2026-08-01 ist ein Samstag → nächster Lauf Montag, 2026-08-03 03:00 cron.GetNextOccurrence(Local(2026, 8, 1, 4, 0)).ShouldBe(Local(2026, 8, 3, 3, 0)); } [Fact] public void Listen_von_Minuten() { var cron = CronExpression.Parse("15,45 * * * *"); cron.GetNextOccurrence(Local(2026, 7, 31, 10, 20)).ShouldBe(Local(2026, 7, 31, 10, 45)); cron.GetNextOccurrence(Local(2026, 7, 31, 10, 50)).ShouldBe(Local(2026, 7, 31, 11, 15)); } [Fact] public void Schaltjahr_29_Februar() { var cron = CronExpression.Parse("0 0 29 2 *"); // nur am 29.02. // 2028 ist das nächste Schaltjahr cron.GetNextOccurrence(Local(2026, 3, 1, 0, 0)).ShouldBe(Local(2028, 2, 29, 0, 0)); } // ─── R2: Invariante ─── [Theory] [InlineData("*/5 * * * *")] [InlineData("0 3 * * 1-5")] [InlineData("15,45 9-17 * * *")] [InlineData("0 0 1 * *")] public void Der_naechste_Termin_liegt_echt_in_der_Zukunft_und_passt(string expr) { var cron = CronExpression.Parse(expr); var now = Local(2026, 7, 31, 10, 23); var next = cron.GetNextOccurrence(now); next.ShouldNotBeNull(); next!.Value.ShouldBeGreaterThan(now); cron.Matches(next.Value).ShouldBeTrue(); } // ─── R3: ungültige Ausdrücke ─── [Theory] [InlineData("* * *")] // zu wenige Felder [InlineData("* * * * * *")] // zu viele Felder [InlineData("99 * * * *")] // Minute außerhalb 0-59 [InlineData("* 25 * * *")] // Stunde außerhalb 0-23 [InlineData("a b c d e")] // keine Zahlen [InlineData("*/0 * * * *")] // Schritt 0 (wäre Endlosschleife) [InlineData("5-2 * * * *")] // rückwärtiger Bereich [InlineData("* * * * ")] // ein leeres Feld nach Trim → 4 Felder public void Ungueltige_Ausdruecke_werfen_eine_saubere_FormatException(string expr) => Should.Throw(() => CronExpression.Parse(expr)); [Fact] public void Ein_gueltiger_Ausdruck_mit_Schritt_bleibt_endlich() { // Regressionswächter: */0 hätte früher eine Endlosschleife erzeugt. Should.CompleteIn(() => CronExpression.Parse("*/15 * * * *"), TimeSpan.FromSeconds(2)); } }