Mit Roslyn umbenannt (Verweise folgen, Zeichenketten bleiben unberuehrt). Nicht umbenannt, weil gespeichert oder ueber die API gebunden (Stufe 4e): Properties, Record-Parameter, Enum-Member sowie die Parameter der Endpunkt-Handler (Routen-/Query-Bindung ueber den Namen). Die JSON-Felder anonymer Objekte bleiben ausdruecklich benannt (JsonFilter: pfad). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
112 lines
4.3 KiB
C#
112 lines
4.3 KiB
C#
using ClawdDotNet.Core.Scheduling;
|
||
using Shouldly;
|
||
|
||
namespace ClawdDotNet.Core.Tests.Scheduling;
|
||
|
||
/// <summary>
|
||
/// Cron parsing and schedule calculation (covers parts of B6/B7). The previously untested
|
||
/// base scheduler gets its safety net here: valid expressions calculate correctly, invalid ones
|
||
/// throw a clean <see cref="FormatException"/> instead of a raw parser exception
|
||
/// or an endless loop.
|
||
/// </summary>
|
||
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: correct next points in time ───
|
||
|
||
[Fact]
|
||
public void Every_30_minutes()
|
||
{
|
||
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 Weekdays_at_three_in_the_morning()
|
||
{
|
||
var cron = CronExpression.Parse("0 3 * * 1-5"); // Mo–Fr
|
||
// 2026-08-01 is a Saturday → next run Monday, 2026-08-03 03:00
|
||
cron.GetNextOccurrence(Local(2026, 8, 1, 4, 0)).ShouldBe(Local(2026, 8, 3, 3, 0));
|
||
}
|
||
|
||
[Fact]
|
||
public void Lists_of_minutes()
|
||
{
|
||
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 Leap_year_29_February()
|
||
{
|
||
var cron = CronExpression.Parse("0 0 29 2 *"); // only on 29 Feb
|
||
// 2028 is the next leap year
|
||
cron.GetNextOccurrence(Local(2026, 3, 1, 0, 0)).ShouldBe(Local(2028, 2, 29, 0, 0));
|
||
}
|
||
|
||
[Fact]
|
||
public void Range_with_step_every_three_hours_during_the_day()
|
||
{
|
||
// #597: "0 7-22/3 * * *" used to throw a FormatException, the task never ran.
|
||
var cron = CronExpression.Parse("0 7-22/3 * * *"); // 7, 10, 13, 16, 19, 22
|
||
cron.GetNextOccurrence(Local(2026, 9, 14, 12, 0)).ShouldBe(Local(2026, 9, 14, 13, 0));
|
||
cron.GetNextOccurrence(Local(2026, 9, 14, 22, 0)).ShouldBe(Local(2026, 9, 15, 7, 0));
|
||
cron.Matches(Local(2026, 9, 14, 8, 0)).ShouldBeFalse();
|
||
}
|
||
|
||
[Fact]
|
||
public void Number_with_step_runs_to_the_end_of_the_field()
|
||
{
|
||
var cron = CronExpression.Parse("10/20 * * * *"); // 10, 30, 50
|
||
cron.GetNextOccurrence(Local(2026, 9, 14, 12, 31)).ShouldBe(Local(2026, 9, 14, 12, 50));
|
||
cron.GetNextOccurrence(Local(2026, 9, 14, 12, 50)).ShouldBe(Local(2026, 9, 14, 13, 10));
|
||
}
|
||
|
||
// ─── R2: invariant ───
|
||
|
||
[Theory]
|
||
[InlineData("*/5 * * * *")]
|
||
[InlineData("0 3 * * 1-5")]
|
||
[InlineData("15,45 9-17 * * *")]
|
||
[InlineData("0 0 1 * *")]
|
||
public void The_next_occurrence_is_strictly_in_the_future_and_matches(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: invalid expressions ───
|
||
|
||
[Theory]
|
||
[InlineData("* * *")] // too few fields
|
||
[InlineData("* * * * * *")] // too many fields
|
||
[InlineData("99 * * * *")] // minute outside 0-59
|
||
[InlineData("* 25 * * *")] // hour outside 0-23
|
||
[InlineData("a b c d e")] // no numbers
|
||
[InlineData("*/0 * * * *")] // step 0 (would be an endless loop)
|
||
[InlineData("5-2 * * * *")] // backwards range
|
||
[InlineData("0 22-7/3 * * *")] // backwards range with step
|
||
[InlineData("0 7-25/3 * * *")] // range end outside 0-23
|
||
[InlineData("0 7-/3 * * *")] // half a range
|
||
[InlineData("* * * * ")] // an empty field after trim → 4 fields
|
||
public void Invalid_expressions_throw_a_clean_FormatException(string expr)
|
||
=> Should.Throw<FormatException>(() => CronExpression.Parse(expr));
|
||
|
||
[Fact]
|
||
public void A_valid_expression_with_step_stays_finite()
|
||
{
|
||
// Regression guard: */0 would formerly have produced an endless loop.
|
||
Should.CompleteIn(() => CronExpression.Parse("*/15 * * * *"), TimeSpan.FromSeconds(2));
|
||
}
|
||
}
|