diff --git a/example/05-explain-crontabs.php b/example/05-explain-crontabs.php new file mode 100644 index 0000000..7652a0e --- /dev/null +++ b/example/05-explain-crontabs.php @@ -0,0 +1,35 @@ +parseLine($line); + echo $expression + . " " . $command + . PHP_EOL . " -> " + . $explainer->explain($expression) + . PHP_EOL; +} diff --git a/example/README.md b/example/README.md index af97866..b92d3a5 100644 --- a/example/README.md +++ b/example/README.md @@ -7,6 +7,7 @@ php example/01-run-due-jobs.php php example/02-next-job.php php example/03-nickname-expressions.php php example/04-custom-expression-factory.php +php example/05-explain-crontabs.php ``` Each script embeds its own crontab string so you can read the schedule and the code together. diff --git a/src/Cli/RunCommand.php b/src/Cli/RunCommand.php index ae2fe07..3db12c1 100644 --- a/src/Cli/RunCommand.php +++ b/src/Cli/RunCommand.php @@ -8,8 +8,11 @@ use Gt\Cli\Parameter\Parameter; use Gt\Cli\Stream; use GT\Cron\CronException; +use GT\Cron\CronExplainer; use GT\Cron\CrontabNotFoundException; +use GT\Cron\CrontabParser; use GT\Cron\FunctionExecutionException; +use GT\Cron\ParseException; use GT\Cron\RunnerFactory; use GT\Cron\ScriptExecutionException; @@ -45,6 +48,11 @@ public function run(?ArgumentValueList $arguments = null):int { exit(0); } + if($arguments->contains("explain")) { + $this->explainCrontab(file_get_contents($filePath)); + return 0; + } + $runner->setRunCallback([$this, "cronRunStep"]); if($arguments->contains("now")) { @@ -139,6 +147,28 @@ protected function displayCommandName(string $command):string { return basename(str_replace("\\", "/", $script)); } + protected function explainCrontab(string $contents):void { + $parser = new CrontabParser(); + $explainer = new CronExplainer(); + + foreach(explode("\n", $contents) as $line) { + $line = trim($line); + if($line === "" || $line[0] === "#") { + continue; + } + + try { + [$crontab, $command] = $parser->parseLine($line); + $explanation = $explainer->explain($crontab); + } + catch(ParseException) { + throw new ParseException("Invalid syntax: $line"); + } + + $this->writeLine("$crontab $command\t\t$explanation"); + } + } + public function getName():string { return "run"; } @@ -179,6 +209,12 @@ public function getOptionalParameterList():array { null, "Check the syntax of the crontab file without running anything." ), + new Parameter( + false, + "explain", + null, + "List the cron jobs and explain when each one will run." + ), new Parameter( true, "now", diff --git a/src/CronExplainer.php b/src/CronExplainer.php new file mode 100644 index 0000000..f442496 --- /dev/null +++ b/src/CronExplainer.php @@ -0,0 +1,334 @@ + */ + private const NICKNAME_MAP = [ + "@yearly" => "0 0 1 1 *", + "@annually" => "0 0 1 1 *", + "@monthly" => "0 0 1 * *", + "@weekly" => "0 0 * * 0", + "@daily" => "0 0 * * *", + "@hourly" => "0 * * * *", + ]; + + /** @var array */ + private const WEEKDAY_NAME_MAP = [ + "0" => "Sunday", + "7" => "Sunday", + "SUN" => "Sunday", + "1" => "Monday", + "MON" => "Monday", + "2" => "Tuesday", + "TUE" => "Tuesday", + "3" => "Wednesday", + "WED" => "Wednesday", + "4" => "Thursday", + "THU" => "Thursday", + "5" => "Friday", + "FRI" => "Friday", + "6" => "Saturday", + "SAT" => "Saturday", + ]; + + /** @var array */ + private const ORDINAL_MAP = [ + 1 => "first", + 2 => "second", + 3 => "third", + 4 => "fourth", + 5 => "fifth", + ]; + + /** @var array */ + private const MONTH_NAME_MAP = [ + "1" => "January", + "JAN" => "January", + "2" => "February", + "FEB" => "February", + "3" => "March", + "MAR" => "March", + "4" => "April", + "APR" => "April", + "5" => "May", + "MAY" => "May", + "6" => "June", + "JUN" => "June", + "7" => "July", + "JUL" => "July", + "8" => "August", + "AUG" => "August", + "9" => "September", + "SEP" => "September", + "10" => "October", + "OCT" => "October", + "11" => "November", + "NOV" => "November", + "12" => "December", + "DEC" => "December", + ]; + + public function explain(string $expression):string { + $expression = trim($expression); + $expression = self::NICKNAME_MAP[strtolower($expression)] + ?? $expression; + new CronExpression($expression); + $parts = preg_split("/\s+/", $expression); + + if(!$parts || count($parts) !== 5) { + throw new InvalidArgumentException("$expression is not a valid CRON expression"); + } + + [$minute, $hour, $dayOfMonth, $month, $dayOfWeek] = $parts; + + if($this->isEveryHour($minute, $hour, $dayOfMonth, $month, $dayOfWeek)) { + return "Every hour"; + } + + $phraseList = [$this->explainTime($minute, $hour)]; + $dayPhrase = $this->explainDay($dayOfMonth, $dayOfWeek, $month); + if($dayPhrase) { + $phraseList []= $dayPhrase; + } + + return implode(", ", $phraseList); + } + + private function isEveryHour( + string $minute, + string $hour, + string $dayOfMonth, + string $month, + string $dayOfWeek + ):bool { + return $minute === "0" + && $hour === "*" + && $dayOfMonth === "*" + && $month === "*" + && $dayOfWeek === "*"; + } + + private function explainTime(string $minute, string $hour):string { + if(str_ends_with(strtolower($minute), "s") && $hour === "*") { + return $this->explainSecondTime($minute); + } + + $wildcardHourTime = $this->explainWildcardHourTime($minute, $hour); + if(!is_null($wildcardHourTime)) { + return $wildcardHourTime; + } + + return $this->explainFixedHourTime($minute, $hour); + } + + private function explainSecondTime(string $minute):string { + $second = substr($minute, 0, -1); + if(str_starts_with($second, "*/")) { + return "Every " . substr($second, 2) . " seconds"; + } + + if($this->isInteger($second)) { + return "At " . (int)$second . " seconds past every minute"; + } + + return "At second $second of every minute"; + } + + private function explainWildcardHourTime( + string $minute, + string $hour + ):?string { + if($hour !== "*") { + return null; + } + + if($minute === "*") { + return "Every minute"; + } + + if(str_starts_with($minute, "*/")) { + return "Every " . substr($minute, 2) . " minutes"; + } + + if($this->isInteger($minute)) { + return "At " . (int)$minute . " minutes past every hour"; + } + + return null; + } + + private function explainFixedHourTime(string $minute, string $hour):string { + if($this->isInteger($minute) && $this->isInteger($hour)) { + return "At " . $this->formatTime((int)$hour, (int)$minute); + } + + return "At minute $minute of hour $hour"; + } + + private function explainDay( + string $dayOfMonth, + string $dayOfWeek, + string $month + ):?string { + $monthPhrase = $this->formatMonth($month); + + if($dayOfMonth === "*" && $dayOfWeek === "*") { + return $monthPhrase ? "only in $monthPhrase" : null; + } + + if($dayOfMonth === "*" && str_contains($dayOfWeek, "#")) { + return $this->explainNthDayOfWeek($dayOfWeek, $monthPhrase); + } + + if($dayOfMonth === "*") { + return $this->explainDayOfWeek($dayOfWeek, $monthPhrase); + } + + if($dayOfWeek === "*") { + return $this->formatDayOfMonth($dayOfMonth, $monthPhrase); + } + + return $this->formatDayOfMonth($dayOfMonth, $monthPhrase) + . " or on " . $this->formatDayOfWeek($dayOfWeek); + } + + private function explainNthDayOfWeek( + string $dayOfWeek, + ?string $monthPhrase + ):string { + $phrase = "on " . $this->formatNthDayOfWeekList($dayOfWeek); + return $monthPhrase ? "$phrase in $monthPhrase" : $phrase; + } + + private function explainDayOfWeek( + string $dayOfWeek, + ?string $monthPhrase + ):string { + $phrase = "only on " . $this->formatDayOfWeek($dayOfWeek); + return $monthPhrase ? "$phrase in $monthPhrase" : $phrase; + } + + private function formatTime(int $hour, int $minute):string { + $suffix = $hour >= 12 ? "PM" : "AM"; + $hour12 = $hour % 12; + if($hour12 === 0) { + $hour12 = 12; + } + + return sprintf("%02d:%02d %s", $hour12, $minute, $suffix); + } + + private function formatNthDayOfWeek(string $dayOfWeek):string { + [$day, $nth] = explode("#", strtoupper($dayOfWeek), 2); + $ordinal = self::ORDINAL_MAP[(int)$nth] ?? "{$nth}th"; + return $ordinal . " " . $this->formatDayOfWeek($day); + } + + private function formatNthDayOfWeekList(string $dayOfWeek):string { + $phraseList = []; + + foreach(explode(",", $dayOfWeek) as $part) { + if(str_contains($part, "#")) { + $phraseList []= "the " . $this->formatNthDayOfWeek($part) + . " of the month"; + continue; + } + + $phraseList []= $this->formatDayOfWeek($part); + } + + return $this->formatList($phraseList); + } + + private function formatDayOfWeek(string $dayOfWeek):string { + $dayOfWeekList = array_map( + function(string $part):string { + return $this->formatDayOfWeekPart($part); + }, + explode(",", $dayOfWeek) + ); + + return $this->formatList($dayOfWeekList); + } + + private function formatDayOfWeekPart(string $dayOfWeek):string { + $dayOfWeek = strtoupper($dayOfWeek); + if(str_contains($dayOfWeek, "-")) { + [$start, $end] = explode("-", $dayOfWeek, 2); + return $this->formatDayOfWeekPart($start) + . " through " . $this->formatDayOfWeekPart($end); + } + + return self::WEEKDAY_NAME_MAP[$dayOfWeek] ?? $dayOfWeek; + } + + private function formatDayOfMonth( + string $dayOfMonth, + ?string $monthPhrase + ):string { + if($monthPhrase && $this->isInteger($dayOfMonth)) { + return "on " . $this->formatMonthDay((int)$dayOfMonth) + . " " . $monthPhrase; + } + + $phrase = "on day $dayOfMonth of the month"; + return $monthPhrase ? "$phrase in $monthPhrase" : $phrase; + } + + private function formatMonthDay(int $day):string { + $suffix = "th"; + if($day % 100 < 11 || $day % 100 > 13) { + $suffix = match($day % 10) { + 1 => "st", + 2 => "nd", + 3 => "rd", + default => "th", + }; + } + + return $day . $suffix; + } + + private function formatMonth(string $month):?string { + if($month === "*") { + return null; + } + + $monthList = array_map( + function(string $part):string { + return $this->formatMonthPart($part); + }, + explode(",", $month) + ); + + return $this->formatList($monthList); + } + + private function formatMonthPart(string $month):string { + $month = strtoupper($month); + if(str_contains($month, "-")) { + [$start, $end] = explode("-", $month, 2); + return $this->formatMonthPart($start) + . " through " . $this->formatMonthPart($end); + } + + return self::MONTH_NAME_MAP[$month] ?? $month; + } + + /** @param array $partList */ + private function formatList(array $partList):string { + if(count($partList) <= 1) { + return $partList[0] ?? ""; + } + + $last = array_pop($partList); + return implode(", ", $partList) . " and " . $last; + } + + private function isInteger(string $value):bool { + return (bool)preg_match("/^\d+$/", $value); + } +} diff --git a/src/CronExpression.php b/src/CronExpression.php index b62a1fe..ff6dfd1 100644 --- a/src/CronExpression.php +++ b/src/CronExpression.php @@ -58,6 +58,8 @@ class CronExpression implements Expression { private array $monthSet; /** @var array */ private array $dayOfWeekSet; + /** @var array> */ + private array $nthDayOfWeekSet = []; private bool $dayOfMonthWildcard; private bool $dayOfWeekWildcard; @@ -76,13 +78,11 @@ public function __construct(string $expression) { $this->hourSet = $this->fieldParser->parseField($parts[1], 0, 23); [$this->dayOfMonthSet, $this->dayOfMonthWildcard] = $this->fieldParser->parseFieldWithWildcard($parts[2], 1, 31); $this->monthSet = $this->fieldParser->parseField($parts[3], 1, 12, self::MONTH_MAP); - [$this->dayOfWeekSet, $this->dayOfWeekWildcard] = $this->fieldParser->parseFieldWithWildcard( - $parts[4], - 0, - 7, - self::WEEKDAY_MAP, - true - ); + [ + $this->dayOfWeekSet, + $this->dayOfWeekWildcard, + $this->nthDayOfWeekSet + ] = $this->parseDayOfWeekField($parts[4]); } public function isDue(DateTime $now):bool { @@ -117,7 +117,7 @@ public function getNextRunDate(?DateTime $now = null):DateTime { private function expandNickname(string $expression):string { $expression = trim($expression); - return self::NICKNAME_MAP[$expression] ?? $expression; + return self::NICKNAME_MAP[strtolower($expression)] ?? $expression; } private function matches(DateTime $candidate):bool { @@ -163,7 +163,8 @@ private function matchesSecond(int $second):bool { private function matchesDay(int $dayOfMonth, int $dayOfWeek):bool { $dayOfMonthMatches = isset($this->dayOfMonthSet[$dayOfMonth]); - $dayOfWeekMatches = isset($this->dayOfWeekSet[$dayOfWeek]); + $dayOfWeekMatches = isset($this->dayOfWeekSet[$dayOfWeek]) + || $this->matchesNthDayOfWeek($dayOfMonth, $dayOfWeek); if($this->dayOfMonthWildcard && $this->dayOfWeekWildcard) { return true; @@ -180,6 +181,85 @@ private function matchesDay(int $dayOfMonth, int $dayOfWeek):bool { return $dayOfMonthMatches || $dayOfWeekMatches; } + /** + * @return array{0:array,1:bool,2:array>} + */ + private function parseDayOfWeekField(string $field):array { + $field = trim($field); + $isWildcard = $field === "*" || $field === "?"; + $standardSegmentList = []; + $nthDayOfWeekSet = []; + + foreach(explode(",", $field) as $segment) { + $segment = trim($segment); + if($segment === "") { + throw new InvalidArgumentException("Invalid CRON field value $field"); + } + + if(!str_contains($segment, "#")) { + $standardSegmentList []= $segment; + continue; + } + + [$dayOfWeek, $nth] = $this->parseNthDayOfWeekSegment($segment); + $nthDayOfWeekSet[$dayOfWeek][$nth] = true; + } + + $dayOfWeekSet = []; + if($standardSegmentList) { + [$dayOfWeekSet] = $this->fieldParser->parseFieldWithWildcard( + implode(",", $standardSegmentList), + 0, + 7, + self::WEEKDAY_MAP, + true + ); + } + + return [$dayOfWeekSet, $isWildcard, $nthDayOfWeekSet]; + } + + /** @return array{0:int,1:int} */ + private function parseNthDayOfWeekSegment(string $segment):array { + [$dayOfWeekPart, $nthPart] = explode("#", strtoupper($segment), 2); + if($dayOfWeekPart === "" + || $nthPart === "" + || str_contains($nthPart, "#") + || !ctype_digit($nthPart)) { + throw new InvalidArgumentException("Invalid CRON field value $segment"); + } + + $nth = (int)$nthPart; + if($nth < 1 || $nth > 5) { + throw new InvalidArgumentException("Invalid CRON field value $segment"); + } + + $dayOfWeekSet = $this->fieldParser->parseField( + $dayOfWeekPart, + 0, + 7, + self::WEEKDAY_MAP, + true + ); + if(count($dayOfWeekSet) !== 1) { + throw new InvalidArgumentException("Invalid CRON field value $segment"); + } + + return [array_key_first($dayOfWeekSet), $nth]; + } + + private function matchesNthDayOfWeek( + int $dayOfMonth, + int $dayOfWeek + ):bool { + if(!isset($this->nthDayOfWeekSet[$dayOfWeek])) { + return false; + } + + $nth = intdiv($dayOfMonth - 1, 7) + 1; + return isset($this->nthDayOfWeekSet[$dayOfWeek][$nth]); + } + private function getMaxLookaheadSteps():int { if($this->hasSecondPrecision()) { return self::MAX_LOOKAHEAD_MINUTES * self::SECONDS_PER_MINUTE; diff --git a/test/phpunit/Command/RunCommandTest.php b/test/phpunit/Command/RunCommandTest.php index e7d2ee1..5c0069b 100644 --- a/test/phpunit/Command/RunCommandTest.php +++ b/test/phpunit/Command/RunCommandTest.php @@ -137,6 +137,71 @@ public function testRunInvalidSyntax() { ); } + public function testExplain():void { + $cronContents = <<writeCronContents($cronContents); + $stream = $this->getStream(); + chdir($this->projectDirectory); + + $args = new ArgumentValueList(); + $args->set("explain"); + $command = new RunCommand(); + $command->setStream($stream); + $command->run($args); + + $output = $this->getFullOutput($stream); + self::assertStringContainsString( + "0 0 * * FRI /backup\t\tAt 12:00 AM, only on Friday", + $output + ); + self::assertStringContainsString( + "0 * * * * /clean-cache\t\tEvery hour", + $output + ); + self::assertStringContainsString( + "05 01 * * SUN#1 /first-sunday\t\tAt 01:05 AM, on the first Sunday of the month", + $output + ); + } + + public function testExplainSkipsCommentsAndBlankLinesAndSupportsAliases():void { + $cronContents = <<<'CRON' +# This line should be ignored + +@DAILY /backup +*/10s * * * * /tick + +CRON; + $this->writeCronContents($cronContents); + $stream = $this->getStream(); + chdir($this->projectDirectory); + + $args = new ArgumentValueList(); + $args->set("explain"); + $command = new RunCommand(); + $command->setStream($stream); + $command->run($args); + + $output = $this->getFullOutput($stream); + self::assertStringContainsString( + "@DAILY /backup\t\tAt 12:00 AM", + $output + ); + self::assertStringContainsString( + "*/10s * * * * /tick\t\tEvery 10 seconds", + $output + ); + self::assertStringNotContainsString( + "# This line should be ignored", + $output + ); + self::assertSame(2, substr_count($output, PHP_EOL)); + } + public function testRunNowFunction() { $cronContents = <<explain($expression)); + } + + public static function simpleScheduleData():array { + return [ + "every minute" => ["* * * * *", "Every minute"], + "every ten minutes" => ["*/10 * * * *", "Every 10 minutes"], + "every hour" => ["0 * * * *", "Every hour"], + "fixed minute each hour" => [ + "15 * * * *", + "At 15 minutes past every hour", + ], + "midnight" => ["0 0 * * *", "At 12:00 AM"], + "noon" => ["0 12 * * *", "At 12:00 PM"], + "morning leading zeroes" => ["05 01 * * *", "At 01:05 AM"], + "afternoon" => ["5 13 * * *", "At 01:05 PM"], + "late evening" => ["59 23 * * *", "At 11:59 PM"], + "complex minute fallback" => [ + "5,35 9 * * *", + "At minute 5,35 of hour 9", + ], + "complex hour fallback" => [ + "0 9-17 * * *", + "At minute 0 of hour 9-17", + ], + ]; + } + + /** @dataProvider secondScheduleData */ + public function testSecondSchedules( + string $expression, + string $expected + ):void { + $explainer = new CronExplainer(); + + self::assertSame($expected, $explainer->explain($expression)); + } + + public static function secondScheduleData():array { + return [ + "every ten seconds" => ["*/10s * * * *", "Every 10 seconds"], + "fixed second" => [ + "5s * * * *", + "At 5 seconds past every minute", + ], + "complex second fallback" => [ + "5,10s * * * *", + "At second 5,10 of every minute", + ], + ]; + } + + /** @dataProvider nicknameScheduleData */ + public function testNicknameSchedules( + string $expression, + string $expected + ):void { + $explainer = new CronExplainer(); + + self::assertSame($expected, $explainer->explain($expression)); + } + + public static function nicknameScheduleData():array { + return [ + "hourly" => ["@hourly", "Every hour"], + "daily" => ["@daily", "At 12:00 AM"], + "weekly" => ["@weekly", "At 12:00 AM, only on Sunday"], + "monthly" => [ + "@monthly", + "At 12:00 AM, on day 1 of the month", + ], + "yearly" => ["@yearly", "At 12:00 AM, on 1st January"], + "annually" => ["@annually", "At 12:00 AM, on 1st January"], + "uppercase nickname" => ["@DAILY", "At 12:00 AM"], + ]; + } + + /** @dataProvider weekdayScheduleData */ + public function testWeekdaySchedules( + string $expression, + string $expected + ):void { + $explainer = new CronExplainer(); + + self::assertSame($expected, $explainer->explain($expression)); + } + + public static function weekdayScheduleData():array { + return [ + "weekday name" => [ + "0 0 * * FRI", + "At 12:00 AM, only on Friday", + ], + "weekday number" => [ + "0 0 * * 5", + "At 12:00 AM, only on Friday", + ], + "sunday zero" => [ + "0 0 * * 0", + "At 12:00 AM, only on Sunday", + ], + "sunday seven" => [ + "0 0 * * 7", + "At 12:00 AM, only on Sunday", + ], + "weekday range" => [ + "0 22 * * MON-FRI", + "At 10:00 PM, only on Monday through Friday", + ], + "weekday list" => [ + "0 9 * * MON,WED,FRI", + "At 09:00 AM, only on Monday, Wednesday and Friday", + ], + ]; + } + + /** @dataProvider dayOfMonthScheduleData */ + public function testDayOfMonthSchedules( + string $expression, + string $expected + ):void { + $explainer = new CronExplainer(); + + self::assertSame($expected, $explainer->explain($expression)); + } + + public static function dayOfMonthScheduleData():array { + return [ + "first day monthly" => [ + "0 0 1 * *", + "At 12:00 AM, on day 1 of the month", + ], + "thirteenth day or friday" => [ + "0 12 13 * FRI", + "At 12:00 PM, on day 13 of the month or on Friday", + ], + "complex day fallback" => [ + "0 0 1,15 * *", + "At 12:00 AM, on day 1,15 of the month", + ], + ]; + } + + /** @dataProvider monthScheduleData */ + public function testMonthSchedules( + string $expression, + string $expected + ):void { + $explainer = new CronExplainer(); + + self::assertSame($expected, $explainer->explain($expression)); + } + + public static function monthScheduleData():array { + return [ + "month name" => [ + "0 0 * JAN *", + "At 12:00 AM, only in January", + ], + "month number" => [ + "0 0 * 1 *", + "At 12:00 AM, only in January", + ], + "month list" => [ + "0 0 * JAN,MAR *", + "At 12:00 AM, only in January and March", + ], + "month range" => [ + "0 0 * JAN-MAR *", + "At 12:00 AM, only in January through March", + ], + "first of january" => [ + "0 0 1 JAN *", + "At 12:00 AM, on 1st January", + ], + "second of february" => [ + "0 0 2 FEB *", + "At 12:00 AM, on 2nd February", + ], + "third of march" => [ + "0 0 3 MAR *", + "At 12:00 AM, on 3rd March", + ], + "eleventh of april" => [ + "0 0 11 APR *", + "At 12:00 AM, on 11th April", + ], + "twenty second of may" => [ + "0 0 22 MAY *", + "At 12:00 AM, on 22nd May", + ], + ]; + } + + /** @dataProvider nthWeekdayScheduleData */ + public function testNthWeekdaySchedules( + string $expression, + string $expected + ):void { + $explainer = new CronExplainer(); + + self::assertSame($expected, $explainer->explain($expression)); + } + + public static function nthWeekdayScheduleData():array { + return [ + "first sunday" => [ + "05 01 * * SUN#1", + "At 01:05 AM, on the first Sunday of the month", + ], + "second sunday in may" => [ + "05 01 * MAY SUN#2", + "At 01:05 AM, on the second Sunday of the month in May", + ], + "third numeric weekday" => [ + "0 0 * * 3#3", + "At 12:00 AM, on the third Wednesday of the month", + ], + "fifth friday" => [ + "0 0 * * FRI#5", + "At 12:00 AM, on the fifth Friday of the month", + ], + "sunday seven" => [ + "05 01 * * 7#1", + "At 01:05 AM, on the first Sunday of the month", + ], + "mixed nth and standard weekday syntax" => [ + "0 0 * * SUN#1,FRI", + "At 12:00 AM, on the first Sunday of the month and Friday", + ], + ]; + } + + /** @dataProvider invalidExpressionData */ + public function testInvalidExpressionsThrow(string $expression):void { + $explainer = new CronExplainer(); + + self::expectException(InvalidArgumentException::class); + + $explainer->explain($expression); + } + + public static function invalidExpressionData():array { + return [ + "empty" => [""], + "unknown nickname" => ["@sometimes"], + "too few fields" => ["0 0 * *"], + "too many fields" => ["0 0 * * * *"], + "invalid minute" => ["ABC 0 * * *"], + "minute out of range" => ["60 0 * * *"], + "hour out of range" => ["0 24 * * *"], + "day of month out of range" => ["0 0 32 * *"], + "month out of range" => ["0 0 * 13 *"], + "weekday out of range" => ["0 0 * * 8"], + "nth weekday zero" => ["0 0 * * SUN#0"], + "nth weekday too high" => ["0 0 * * SUN#6"], + "nth weekday range" => ["0 0 * * MON-FRI#1"], + "invalid seconds" => ["*/0s * * * *"], + ]; + } +} diff --git a/test/phpunit/CronExpressionTest.php b/test/phpunit/CronExpressionTest.php index 7b48783..eac1ba7 100644 --- a/test/phpunit/CronExpressionTest.php +++ b/test/phpunit/CronExpressionTest.php @@ -64,6 +64,41 @@ public function testNicknameExpansion():void { self::assertSame("2026-03-12 00:00:00", $nextRunDate->format("Y-m-d H:i:s")); } + public function testNicknameExpansionIsCaseInsensitive():void { + $expression = new CronExpression("@DAILY"); + $nextRunDate = $expression->getNextRunDate(new DateTime("2026-03-11 12:34:00")); + self::assertSame("2026-03-12 00:00:00", $nextRunDate->format("Y-m-d H:i:s")); + } + + public function testNthWeekdayOfMonth():void { + $expression = new CronExpression("05 01 * * SUN#1"); + + self::assertTrue($expression->isDue(new DateTime("2026-03-01 01:05:00"))); + self::assertFalse($expression->isDue(new DateTime("2026-03-08 01:05:00"))); + self::assertFalse($expression->isDue(new DateTime("2026-03-01 01:06:00"))); + } + + public function testNthWeekdayOfMonthSupportsSundaySeven():void { + $expression = new CronExpression("05 01 * * 7#1"); + + self::assertTrue($expression->isDue(new DateTime("2026-03-01 01:05:00"))); + self::assertFalse($expression->isDue(new DateTime("2026-03-08 01:05:00"))); + } + + public function testNthWeekdayCanBeCombinedWithStandardWeekdaySyntax():void { + $expression = new CronExpression("0 0 * * SUN#1,FRI"); + + self::assertTrue($expression->isDue(new DateTime("2026-03-01 00:00:00"))); + self::assertTrue($expression->isDue(new DateTime("2026-03-13 00:00:00"))); + self::assertFalse($expression->isDue(new DateTime("2026-03-12 00:00:00"))); + } + + public function testGetNextRunDateForNthWeekdaySchedule():void { + $expression = new CronExpression("05 01 * * SUN#1"); + $nextRunDate = $expression->getNextRunDate(new DateTime("2026-03-01 01:05:00")); + self::assertSame("2026-04-05 01:05:00", $nextRunDate->format("Y-m-d H:i:s")); + } + public function testGetNextRunDateUsesSecondPrecisionForSecondStepSyntax():void { $expression = new CronExpression("*/10s * * * *"); $nextRunDate = $expression->getNextRunDate(new DateTime("2026-03-11 12:34:25"));