diff --git a/.gitattributes b/.gitattributes index 5f220cf3..a325062b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,8 +1,12 @@ * text=auto -.gitattributes export-ignore -.gitignore export-ignore -.php_cs export-ignore -.styleci.yml export-ignore -.travis.yml export-ignore -phpunit.xml.dist export-ignore +/.github export-ignore +/tests export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.php_cs export-ignore +/.styleci.yml export-ignore +/.travis.yml export-ignore +/phpstan.neon export-ignore +/phpunit.xml.dist export-ignore diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..f61deb96 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [dragonmantank] diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..fe7ea1bd --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,77 @@ +on: + push: + branches: + - master + pull_request: + branches: + - master + +name: Tests + +jobs: + phpstan: + name: PHPStan - PHP ${{ matrix.php }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: [7.2, 7.3, 7.4, '8.0', 8.1, 8.2, 8.3, 8.4] + + steps: + - name: "Checkout" + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: json, mbstring + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction + + - name: Start Static Analyze + run: composer phpstan -n + + phpunit: + name: PHPUnit - PHP ${{ matrix.php }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: [7.2, 7.3, 7.4, 8.0, 8.1, 8.2, 8.3] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: json, mbstring + coverage: pcov + env: + COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup problem matchers for PHPUnit + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Get Composer cache directory + id: composercache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composercache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction + + - name: Analyze & test + run: composer test -- -v --coverage-clover=coverage.xml diff --git a/.php_cs b/.php_cs index 2c39ae5e..fc1237ea 100644 --- a/.php_cs +++ b/.php_cs @@ -1,15 +1,82 @@ in(__DIR__); -$fixers = array( - '-psr0', - 'long_array_syntax', -); +$config = PhpCsFixer\Config::create() + ->setRules([ + '@Symfony' => true, + 'declare_strict_types' => true, + 'native_function_invocation' => [ + 'include' => ['@compiler_optimized'], + 'scope' => 'namespaced', + ], + 'phpdoc_align' => [ + 'align' => 'left', + ], + 'blank_line_before_statement' => true, + 'align_multiline_comment' => false, + 'concat_space' => [ + 'spacing' => 'one', + ], + 'array_syntax' => [ + 'syntax' => 'short', + ], + 'combine_consecutive_issets' => true, + 'combine_consecutive_unsets' => true, + 'array_indentation' => true, + 'no_extra_blank_lines' => [ + 'break', + 'case', + 'continue', + 'curly_brace_block', + 'default', + 'extra', + 'parenthesis_brace_block', + 'return', + 'square_brace_block', + 'switch', + 'throw', + 'use', + 'useTrait', + 'use_trait', + ], + 'compact_nullable_typehint' => true, + 'escape_implicit_backslashes' => true, + 'explicit_indirect_variable' => true, + 'explicit_string_variable' => true, + 'final_internal_class' => true, + 'fully_qualified_strict_types' => true, + 'function_to_constant' => [ + 'functions' => [ + 'get_class', + 'get_called_class', + 'php_sapi_name', + 'phpversion', + 'pi', + ], + ], + 'list_syntax' => [ + 'syntax' => 'short', + ], + 'logical_operators' => true, + 'no_alternative_syntax' => true, + 'no_null_property_initialization' => true, + 'no_short_echo_tag' => true, + 'no_superfluous_elseif' => false, + 'no_unreachable_default_argument_value' => true, + 'no_unset_on_property' => false, + 'no_useless_else' => false, + 'ordered_imports' => true, + 'phpdoc_add_missing_param_annotation' => true, + 'phpdoc_order' => true, + 'phpdoc_trim_consecutive_blank_line_separation' => true, + 'phpdoc_types_order' => true, + 'return_assignment' => true, + 'string_line_ending' => true, + 'strict_param' => true, + 'strict_comparison' => true, + ]) + ->setFinder($finder); -return Symfony\CS\Config\Config::create() - ->finder($finder) - ->fixers($fixers) - ->level(Symfony\CS\FixerInterface::PSR2_LEVEL) - ->setUsingCache(true); +return $config; \ No newline at end of file diff --git a/.styleci.yml b/.styleci.yml index 9b04de91..db567493 100644 --- a/.styleci.yml +++ b/.styleci.yml @@ -1,5 +1,5 @@ preset: psr2 enabled: - - long_array_syntax + - short_array_syntax - method_separation diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 2aaaa54b..00000000 --- a/.travis.yml +++ /dev/null @@ -1,17 +0,0 @@ -language: php - -php: - - 7.0 - - 7.1 - - hhvm - -sudo: false - -install: travis_retry composer install --no-interaction --prefer-dist - -script: phpunit --coverage-text - -matrix: - allow_failures: - - php: hhvm - fast_finish: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 336e69a5..17ab2ce4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,200 @@ # Change Log -## [Unreleased] +## [3.3.3] - 2024-08-10 + +### Added +- N/A + +### Changed +- N/A + +### Fixed +- Added fixes for making sure `?` is not passed for both DOM and DOW (#148, thank you https://github.com/LeoVie) +- Fixed bug in Next Execution Time by sorting minutes properly (#160, thank you https://github.com/imyip) + +## [3.3.2] - 2022-09-19 + +### Added +- N/A + +### Changed +- Skip some daylight savings time tests for PHP 8.1 daylight savings time weirdness (#146) + +### Fixed +- Changed string interpolations to work better with PHP 8.2 (#142) + +## [3.3.1] - 2022-01-18 + +### Added +- N/A + +### Changed +- N/A + +### Fixed +- Fixed issue when timezones had no transition, which can occur over very short timespans (#134) + +## [3.3.0] - 2022-01-13 + +### Added +- Added ability to register your own expression aliases (#132) + +### Changed +- Changed how Day of Week and Day of Month resolve when one or the other is `*` or `?` + +### Fixed +- PHPStan should no longer error out + +## [3.2.4] - 2022-01-12 + +### Added +- N/A + +### Changed +- Changed how Day of Week increment/decrement to help with DST changes (#131) + +### Fixed +- N/A + +## [3.2.3] - 2022-01-05 + +### Added +- N/A + +### Changed +- Changed how minutes and hours increment/decrement to help with DST changes (#131) + +### Fixed +- N/A + +## [3.2.2] - 2022-01-05 + +### Added +- N/A + +### Changed +- Marked some methods `@internal` (#124) + +### Fixed +- Fixed issue with small ranges and large steps that caused an error with `range()` (#88) +- Fixed issue where wraparound logic incorrectly considered high bound on range (#89) + +## [3.2.1] - 2022-01-04 + +### Added +- N/A + +### Changed +- Added PHP 8.1 to testing (#125) + +### Fixed +- Allow better mixture of ranges, steps, and lists (#122) +- Fixed return order when multiple dates are requested and inverted (#121) +- Better handling over DST (#115) +- Fixed PHPStan tests (#130) + +## [3.2.0] - 2022-01-04 + +### Added +- Added alias for `@midnight` (#117) + +### Changed +- Improved testing for instance of field in tests (#105) +- Optimization for determining multiple run dates (#75) +- `CronExpression` properties changed from private to protected (#106) + +### Fixed +- N/A + +## [3.1.0] - 2020-11-24 + +### Added +- Added `CronExpression::getParts()` method to get parts of the expression as an array (#83) + +### Changed +- Changed to Interfaces for some type hints (#97, #86) +- Dropped minimum PHP version to 7.2 +- Few syntax changes for phpstan compatibility (#93) + +### Fixed +- N/A + +### Deprecated +- Deprecated `CronExpression::factory` in favor of the constructor (#56) +- Deprecated `CronExpression::YEAR` as a formality, the functionality is already removed (#87) + +## [3.0.1] - 2020-10-12 +### Added +- Added support for PHP 8 (#92) +### Changed +- N/A +### Fixed +- N/A + +## [3.0.0] - 2020-03-25 + +**MAJOR CHANGE** - In previous versions of this library, setting both a "Day of Month" and a "Day of Week" would be interpreted as an `AND` statement, not an `OR` statement. For example: + +`30 0 1 * 1` + +would evaluate to "Run 30 minutes after the 0 hour when the Day Of Month is 1 AND a Monday" instead of "Run 30 minutes after the 0 hour on Day Of Month 1 OR a Monday", where the latter is more inline with most cron systems. This means that if your cron expression has both of these fields set, you may see your expression fire more often starting with v3.0.0. + +### Added +- Additional docblocks for IDE and documentation +- Added phpstan as a development dependency +- Added a `Cron\FieldFactoryInterface` to make migrations easier (#38) +### Changed +- Changed some DI testing during TravisCI runs +- `\Cron\CronExpression::determineTimezone()` now checks for `\DateTimeInterface` instead of just `\DateTime` +- Errors with fields now report a more human-understandable error and are 1-based instead of 0-based +- Better support for `\DateTimeImmutable` across the library by typehinting for `\DateTimeInterface` now +- Literals should now be less case-sensative across the board +- Changed logic for when both a Day of Week and a Day of Month are supplied to now be an OR statement, not an AND +### Fixed +- Fixed infinite loop when determining last day of week from literals +- Fixed bug where single number ranges were allowed (ex: `1/10`) +- Fixed nullable FieldFactory in CronExpression where no factory could be supplied +- Fixed issue where logic for dropping seconds to 0 could lead to a timezone change + +## [2.3.1] - 2020-10-12 +### Added +- Added support for PHP 8 (#92) +### Changed +- N/A +### Fixed +- N/A + +## [2.3.0] - 2019-03-30 +### Added +- Added support for DateTimeImmutable via DateTimeInterface +- Added support for PHP 7.3 +- Started listing projects that use the library +### Changed +- Errors should now report a human readable position in the cron expression, instead of starting at 0 +### Fixed +- N/A + +## [2.2.0] - 2018-06-05 +### Added +- Added support for steps larger than field ranges (#6) +## Changed +- N/A +### Fixed +- Fixed validation for numbers with leading 0s (#12) + +## [2.1.0] - 2018-04-06 +### Added +- N/A +### Changed +- Upgraded to PHPUnit 6 (#2) +### Fixed +- Refactored timezones to deal with some inconsistent behavior (#3) +- Allow ranges and lists in same expression (#5) +- Fixed regression where literals were not converted to their numerical counterpart (#) + +## [2.0.0] - 2017-10-12 ### Added +- N/A ### Changed - Dropped support for PHP 5.x diff --git a/LICENSE b/LICENSE index c6d88ac6..3e38bbc8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2011 Michael Dowling and contributors +Copyright (c) 2011 Michael Dowling , 2016 Chris Tankersley , and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 57cdbd96..b9df3db5 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,27 @@ PHP Cron Expression Parser ========================== -[![Latest Stable Version](https://poser.pugx.org/mtdowling/cron-expression/v/stable.png)](https://packagist.org/packages/mtdowling/cron-expression) [![Total Downloads](https://poser.pugx.org/mtdowling/cron-expression/downloads.png)](https://packagist.org/packages/mtdowling/cron-expression) [![Build Status](https://secure.travis-ci.org/mtdowling/cron-expression.png)](http://travis-ci.org/mtdowling/cron-expression) - -**NOTE** This fork has been deprecated and development moved to [https://github.com/dragonmantank/cron-expression](https://github.com/dragonmantank/cron-expression). More information can be found in the blog post [here](http://ctankersley.com/2017/10/12/cron-expression-update/). tl;dr - v2.0.0 is a major breaking change, and @dragonmantank can better take care of the project in a separate fork. +[![Latest Stable Version](https://poser.pugx.org/dragonmantank/cron-expression/v/stable.png)](https://packagist.org/packages/dragonmantank/cron-expression) [![Total Downloads](https://poser.pugx.org/dragonmantank/cron-expression/downloads.png)](https://packagist.org/packages/dragonmantank/cron-expression) [![Tests](https://github.com/dragonmantank/cron-expression/actions/workflows/tests.yml/badge.svg)](https://github.com/dragonmantank/cron-expression/actions/workflows/tests.yml) [![StyleCI](https://github.styleci.io/repos/103715337/shield?branch=master)](https://github.styleci.io/repos/103715337) The PHP cron expression parser can parse a CRON expression, determine if it is due to run, calculate the next run date of the expression, and calculate the previous run date of the expression. You can calculate dates far into the future or past by -skipping n number of matching dates. +skipping **n** number of matching dates. The parser can handle increments of ranges (e.g. */12, 2-59/3), intervals (e.g. 0-9), -lists (e.g. 1,2,3), W to find the nearest weekday for a given day of the month, L to -find the last day of the month, L to find the last given weekday of a month, and hash +lists (e.g. 1,2,3), **W** to find the nearest weekday for a given day of the month, **L** to +find the last day of the month, **L** to find the last given weekday of a month, and hash (#) to find the nth weekday of a given month. +More information about this fork can be found in the blog post [here](http://ctankersley.com/2017/10/12/cron-expression-update/). tl;dr - v2.0.0 is a major breaking change, and @dragonmantank can better take care of the project in a separate fork. + Installing ========== Add the dependency to your project: ```bash -composer require mtdowling/cron-expression +composer require dragonmantank/cron-expression ``` Usage @@ -32,21 +32,21 @@ Usage require_once '/vendor/autoload.php'; // Works with predefined scheduling definitions -$cron = Cron\CronExpression::factory('@daily'); +$cron = new Cron\CronExpression('@daily'); $cron->isDue(); echo $cron->getNextRunDate()->format('Y-m-d H:i:s'); echo $cron->getPreviousRunDate()->format('Y-m-d H:i:s'); // Works with complex expressions -$cron = Cron\CronExpression::factory('3-59/15 2,6-12 */15 1 2-5'); +$cron = new Cron\CronExpression('3-59/15 6-12 */15 1 2-5'); echo $cron->getNextRunDate()->format('Y-m-d H:i:s'); // Calculate a run date two iterations into the future -$cron = Cron\CronExpression::factory('@daily'); +$cron = new Cron\CronExpression('@daily'); echo $cron->getNextRunDate(null, 2)->format('Y-m-d H:i:s'); // Calculate a run date relative to a specific time -$cron = Cron\CronExpression::factory('@monthly'); +$cron = new Cron\CronExpression('@monthly'); echo $cron->getNextRunDate('2010-01-12 00:00:00')->format('Y-m-d H:i:s'); ``` @@ -55,19 +55,77 @@ CRON Expressions A CRON expression is a string representing the schedule for a particular command to execute. The parts of a CRON schedule are as follows: - * * * * * - - - - - - - | | | | | - | | | | | - | | | | +----- day of week (0 - 7) (Sunday=0 or 7) - | | | +---------- month (1 - 12) - | | +--------------- day of month (1 - 31) - | +-------------------- hour (0 - 23) - +------------------------- min (0 - 59) +``` +* * * * * +- - - - - +| | | | | +| | | | | +| | | | +----- day of week (0-7) (Sunday = 0 or 7) (or SUN-SAT) +| | | +--------- month (1-12) (or JAN-DEC) +| | +------------- day of month (1-31) +| +----------------- hour (0-23) ++--------------------- minute (0-59) +``` + +Each part of expression can also use wildcard, lists, ranges and steps: + +- wildcard - match always + - `* * * * *` - At every minute. + - day of week and day of month also support `?`, an alias to `*` +- lists - match list of values, ranges and steps + - e.g. `15,30 * * * *` - At minute 15 and 30. +- ranges - match values in range + - e.g. `1-9 * * * *` - At every minute from 1 through 9. +- steps - match every nth value in range + - e.g. `*/5 * * * *` - At every 5th minute. + - e.g. `0-30/5 * * * *` - At every 5th minute from 0 through 30. +- combinations + - e.g. `0-14,30-44 * * * *` - At every minute from 0 through 14 and every minute from 30 through 44. + +You can also use macro instead of an expression: + +- `@yearly`, `@annually` - At 00:00 on 1st of January. (same as `0 0 1 1 *`) +- `@monthly` - At 00:00 on day-of-month 1. (same as `0 0 1 * *`) +- `@weekly` - At 00:00 on Sunday. (same as `0 0 * * 0`) +- `@daily`, `@midnight` - At 00:00. (same as `0 0 * * *`) +- `@hourly` - At minute 0. (same as `0 * * * *`) + +Day of month extra features: + +- nearest weekday - weekday (Monday-Friday) nearest to the given day + - e.g. `* * 15W * *` - At every minute on a weekday nearest to the 15th. + - If you were to specify `15W` as the value, the meaning is: "the nearest weekday to the 15th of the month" + So if the 15th is a Saturday, the trigger will fire on Friday the 14th. + If the 15th is a Sunday, the trigger will fire on Monday the 16th. + If the 15th is a Tuesday, then it will fire on Tuesday the 15th. + - However, if you specify `1W` as the value for day-of-month, + and the 1st is a Saturday, the trigger will fire on Monday the 3rd, + as it will not 'jump' over the boundary of a month's days. +- last day of the month + - e.g. `* * L * *` - At every minute on a last day-of-month. +- last weekday of the month + - e.g. `* * LW * *` - At every minute on a last weekday. + +Day of week extra features: + +- nth day + - e.g. `* * * * 7#4` - At every minute on 4th Sunday. + - 1-5 + - Every day of week repeats 4-5 times a month. To target the last one, use "last day" feature instead. +- last day + - e.g. `* * * * 7L` - At every minute on the last Sunday. Requirements ============ -- PHP 7.0+ +- PHP 7.2+ - PHPUnit is required to run the unit tests - Composer is required to run the unit tests + +Projects that Use cron-expression +================================= +* Part of the [Laravel Framework](https://github.com/laravel/framework/) +* Available as a [Symfony Bundle - setono/cron-expression-bundle](https://github.com/Setono/CronExpressionBundle) +* Framework agnostic, PHP-based job scheduler - [Crunz](https://github.com/crunzphp/crunz) +* Framework agnostic job scheduler - with locks, parallelism, per-second scheduling and more - [orisai/scheduler](https://github.com/orisai/scheduler) +* Explain expression in English (and other languages) with [orisai/cron-expression-explainer](https://github.com/orisai/cron-expression-explainer) diff --git a/composer.json b/composer.json index a8fe85e3..3d98ced4 100644 --- a/composer.json +++ b/composer.json @@ -1,19 +1,23 @@ { - "name": "mtdowling/cron-expression", + "name": "dragonmantank/cron-expression", "type": "library", "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", "keywords": ["cron", "schedule"], "license": "MIT", - "authors": [{ - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], "require": { - "php": ">=7.0.0" + "php": "^7.2|^8.0" }, "require-dev": { - "phpunit/phpunit": "~5.7" + "phpstan/phpstan": "^1.0", + "phpunit/phpunit": "^7.0|^8.0|^9.0", + "phpstan/extension-installer": "^1.0" }, "autoload": { "psr-4": { @@ -22,7 +26,25 @@ }, "autoload-dev": { "psr-4": { - "Tests\\": "tests/Cron/" + "Cron\\Tests\\": "tests/Cron/" + } + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "scripts": { + "phpstan": "./vendor/bin/phpstan analyze", + "test": "phpunit" + }, + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "config": { + "allow-plugins": { + "ocramius/package-versions": true, + "phpstan/extension-installer": true } } } diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 00000000..bea9cb0d --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,15 @@ +parameters: + checkMissingIterableValueType: false + + ignoreErrors: + - '#Call to an undefined method DateTimeInterface::add\(\)#' + - '#Call to an undefined method DateTimeInterface::modify\(\)#' + - '#Call to an undefined method DateTimeInterface::setDate\(\)#' + - '#Call to an undefined method DateTimeInterface::setTime\(\)#' + - '#Call to an undefined method DateTimeInterface::setTimezone\(\)#' + - '#Call to an undefined method DateTimeInterface::sub\(\)#' + + level: max + + paths: + - src/ diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 484b611f..e525edf0 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,5 +1,6 @@ - + + + ./src/Cron + + + ./tests - - - ./src/Cron - - - diff --git a/src/Cron/AbstractField.php b/src/Cron/AbstractField.php index 86db3063..df2848df 100644 --- a/src/Cron/AbstractField.php +++ b/src/Cron/AbstractField.php @@ -1,128 +1,154 @@ fullRange = range($this->rangeStart, $this->rangeEnd); } /** - * Check to see if a field is satisfied by a value + * Check to see if a field is satisfied by a value. * - * @param string $dateValue Date value to check - * @param string $value Value to test + * @internal + * @param int $dateValue Date value to check + * @param string $value Value to test * * @return bool */ - public function isSatisfied($dateValue, $value) + public function isSatisfied(int $dateValue, string $value): bool { if ($this->isIncrementsOfRanges($value)) { return $this->isInIncrementsOfRanges($dateValue, $value); - } elseif ($this->isRange($value)) { + } + + if ($this->isRange($value)) { return $this->isInRange($dateValue, $value); } - return $value == '*' || $dateValue == $value; + return '*' === $value || $dateValue === (int) $value; } /** - * Check if a value is a range + * Check if a value is a range. * + * @internal * @param string $value Value to test * * @return bool */ - public function isRange($value) + public function isRange(string $value): bool { - return strpos($value, '-') !== false; + return false !== strpos($value, '-'); } /** - * Check if a value is an increments of ranges + * Check if a value is an increments of ranges. * + * @internal * @param string $value Value to test * * @return bool */ - public function isIncrementsOfRanges($value) + public function isIncrementsOfRanges(string $value): bool { - return strpos($value, '/') !== false; + return false !== strpos($value, '/'); } /** - * Test if a value is within a range + * Test if a value is within a range. * - * @param string $dateValue Set date value - * @param string $value Value to test + * @internal + * @param int $dateValue Set date value + * @param string $value Value to test * * @return bool */ - public function isInRange($dateValue, $value) + public function isInRange(int $dateValue, $value): bool { - $parts = array_map('trim', explode('-', $value, 2)); + $parts = array_map( + function ($value) { + $value = trim($value); + + return $this->convertLiterals($value); + }, + explode('-', $value, 2) + ); return $dateValue >= $parts[0] && $dateValue <= $parts[1]; } /** - * Test if a value is within an increments of ranges (offset[-to]/step size) + * Test if a value is within an increments of ranges (offset[-to]/step size). * - * @param string $dateValue Set date value - * @param string $value Value to test + * @internal + * @param int $dateValue Set date value + * @param string $value Value to test * * @return bool */ - public function isInIncrementsOfRanges($dateValue, $value) + public function isInIncrementsOfRanges(int $dateValue, string $value): bool { $chunks = array_map('trim', explode('/', $value, 2)); $range = $chunks[0]; - $step = isset($chunks[1]) ? $chunks[1] : 0; + $step = $chunks[1] ?? 0; // No step or 0 steps aren't cool - if (is_null($step) || '0' === $step || 0 === $step) { + /** @phpstan-ignore-next-line */ + if (null === $step || '0' === $step || 0 === $step) { return false; } // Expand the * to a full range - if ('*' == $range) { + if ('*' === $range) { $range = $this->rangeStart . '-' . $this->rangeEnd; } // Generate the requested small range $rangeChunks = explode('-', $range, 2); - $rangeStart = $rangeChunks[0]; - $rangeEnd = isset($rangeChunks[1]) ? $rangeChunks[1] : $rangeStart; + $rangeStart = (int) $rangeChunks[0]; + $rangeEnd = $rangeChunks[1] ?? $rangeStart; + $rangeEnd = (int) $rangeEnd; if ($rangeStart < $this->rangeStart || $rangeStart > $this->rangeEnd || $rangeStart > $rangeEnd) { throw new \OutOfRangeException('Invalid range start requested'); @@ -132,59 +158,94 @@ public function isInIncrementsOfRanges($dateValue, $value) throw new \OutOfRangeException('Invalid range end requested'); } - if ($step > ($rangeEnd - $rangeStart) + 1) { - throw new \OutOfRangeException('Step cannot be greater than total range'); + // Steps larger than the range need to wrap around and be handled + // slightly differently than smaller steps + + // UPDATE - This is actually false. The C implementation will allow a + // larger step as valid syntax, it never wraps around. It will stop + // once it hits the end. Unfortunately this means in future versions + // we will not wrap around. However, because the logic exists today + // per the above documentation, fixing the bug from #89 + if ($step > $this->rangeEnd) { + $thisRange = [$this->fullRange[$step % \count($this->fullRange)]]; + } else { + if ($step > ($rangeEnd - $rangeStart)) { + $thisRange[$rangeStart] = (int) $rangeStart; + } else { + $thisRange = range($rangeStart, $rangeEnd, (int) $step); + } } - $thisRange = range($rangeStart, $rangeEnd, $step); - - return in_array($dateValue, $thisRange); + return \in_array($dateValue, $thisRange, true); } /** - * Returns a range of values for the given cron expression + * Returns a range of values for the given cron expression. * * @param string $expression The expression to evaluate - * @param int $max Maximum offset for range + * @param int $max Maximum offset for range * * @return array */ - public function getRangeForExpression($expression, $max) + public function getRangeForExpression(string $expression, int $max): array { - $values = array(); + $values = []; + $expression = $this->convertLiterals($expression); + + if (false !== strpos($expression, ',')) { + $ranges = explode(',', $expression); + $values = []; + foreach ($ranges as $range) { + $expanded = $this->getRangeForExpression($range, $this->rangeEnd); + $values = array_merge($values, $expanded); + } + + return $values; + } if ($this->isRange($expression) || $this->isIncrementsOfRanges($expression)) { if (!$this->isIncrementsOfRanges($expression)) { - list ($offset, $to) = explode('-', $expression); + [$offset, $to] = explode('-', $expression); + $offset = $this->convertLiterals($offset); + $to = $this->convertLiterals($to); $stepSize = 1; - } - else { + } else { $range = array_map('trim', explode('/', $expression, 2)); - $stepSize = isset($range[1]) ? $range[1] : 0; + $stepSize = $range[1] ?? 0; $range = $range[0]; $range = explode('-', $range, 2); $offset = $range[0]; - $to = isset($range[1]) ? $range[1] : $max; + $to = $range[1] ?? $max; } - $offset = $offset == '*' ? 0 : $offset; - for ($i = $offset; $i <= $to; $i += $stepSize) { - $values[] = $i; + $offset = '*' === $offset ? $this->rangeStart : $offset; + if ($stepSize >= $this->rangeEnd) { + $values = [$this->fullRange[$stepSize % \count($this->fullRange)]]; + } else { + for ($i = $offset; $i <= $to; $i += $stepSize) { + $values[] = (int) $i; + } } sort($values); - } - else { - $values = array($expression); + } else { + $values = [$expression]; } return $values; } - protected function convertLiterals($value) + /** + * Convert literal. + * + * @param string $value + * + * @return string + */ + protected function convertLiterals(string $value): string { - if (count($this->literals)) { - $key = array_search($value, $this->literals); - if ($key !== false) { - return $key; + if (\count($this->literals)) { + $key = array_search(strtoupper($value), $this->literals, true); + if (false !== $key) { + return (string) $key; } } @@ -192,12 +253,13 @@ protected function convertLiterals($value) } /** - * Checks to see if a value is valid for the field + * Checks to see if a value is valid for the field. * * @param string $value + * * @return bool */ - public function validate($value) + public function validate(string $value): bool { $value = $this->convertLiterals($value); @@ -206,17 +268,29 @@ public function validate($value) return true; } - // You cannot have a range and a list at the same time - if (strpos($value, ',') !== false && strpos($value, '-') !== false) { - return false; + // Validate each chunk of a list individually + if (false !== strpos($value, ',')) { + foreach (explode(',', $value) as $listItem) { + if (!$this->validate($listItem)) { + return false; + } + } + + return true; } - if (strpos($value, '/') !== false) { - list($range, $step) = explode('/', $value); + if (false !== strpos($value, '/')) { + [$range, $step] = explode('/', $value); + + // Don't allow numeric ranges + if (is_numeric($range)) { + return false; + } + return $this->validate($range) && filter_var($step, FILTER_VALIDATE_INT); } - if (strpos($value, '-') !== false) { + if (false !== strpos($value, '-')) { if (substr_count($value, '-') > 1) { return false; } @@ -225,28 +299,48 @@ public function validate($value) $chunks[0] = $this->convertLiterals($chunks[0]); $chunks[1] = $this->convertLiterals($chunks[1]); - if ('*' == $chunks[0] || '*' == $chunks[1]) { + if ('*' === $chunks[0] || '*' === $chunks[1]) { return false; } return $this->validate($chunks[0]) && $this->validate($chunks[1]); } - // Validate each chunk of a list individually - if (strpos($value, ',') !== false) { - foreach (explode(',', $value) as $listItem) { - if (!$this->validate($listItem)) { - return false; - } - } - return true; + if (!is_numeric($value)) { + return false; + } + + if (false !== strpos($value, '.')) { + return false; } // We should have a numeric by now, so coerce this into an integer - if (filter_var($value, FILTER_VALIDATE_INT) !== false) { - $value = (int) $value; + $value = (int) $value; + + return \in_array($value, $this->fullRange, true); + } + + protected function timezoneSafeModify(DateTimeInterface $dt, string $modification): DateTimeInterface + { + $timezone = $dt->getTimezone(); + $dt = $dt->setTimezone(new \DateTimeZone("UTC")); + $dt = $dt->modify($modification); + $dt = $dt->setTimezone($timezone); + return $dt; + } + + protected function setTimeHour(DateTimeInterface $date, bool $invert, int $originalTimestamp): DateTimeInterface + { + $date = $date->setTime((int)$date->format('H'), ($invert ? 59 : 0)); + + // setTime caused the offset to change, moving time in the wrong direction + $actualTimestamp = $date->format('U'); + if ((! $invert) && ($actualTimestamp <= $originalTimestamp)) { + $date = $this->timezoneSafeModify($date, "+1 hour"); + } elseif ($invert && ($actualTimestamp >= $originalTimestamp)) { + $date = $this->timezoneSafeModify($date, "-1 hour"); } - return in_array($value, $this->fullRange, true); + return $date; } } diff --git a/src/Cron/CronExpression.php b/src/Cron/CronExpression.php index e8095529..f3d8eb00 100644 --- a/src/Cron/CronExpression.php +++ b/src/Cron/CronExpression.php @@ -1,12 +1,16 @@ '0 0 1 1 *', + '@annually' => '0 0 1 1 *', + '@monthly' => '0 0 1 * *', + '@weekly' => '0 0 * * 0', + '@daily' => '0 0 * * *', + '@midnight' => '0 0 * * *', + '@hourly' => '0 * * * *', + ]; /** * @var array CRON expression parts */ - private $cronParts; + protected $cronParts; /** - * @var FieldFactory CRON field factory + * @var FieldFactoryInterface CRON field factory */ - private $fieldFactory; + protected $fieldFactory; /** * @var int Max iteration count when searching for next run date */ - private $maxIterationCount = 1000; + protected $maxIterationCount = 1000; /** * @var array Order in which to test of cron parts */ - private static $order = array(self::YEAR, self::MONTH, self::DAY, self::WEEKDAY, self::HOUR, self::MINUTE); + protected static $order = [ + self::YEAR, + self::MONTH, + self::DAY, + self::WEEKDAY, + self::HOUR, + self::MINUTE, + ]; /** - * Factory method to create a new CronExpression. - * - * @param string $expression The CRON expression to create. There are - * several special predefined values which can be used to substitute the - * CRON expression: + * @var array + */ + private static $registeredAliases = self::MAPPINGS; + + /** + * Registered a user defined CRON Expression Alias. * - * `@yearly`, `@annually` - Run once a year, midnight, Jan. 1 - 0 0 1 1 * - * `@monthly` - Run once a month, midnight, first of month - 0 0 1 * * - * `@weekly` - Run once a week, midnight on Sun - 0 0 * * 0 - * `@daily` - Run once a day, midnight - 0 0 * * * - * `@hourly` - Run once an hour, first minute - 0 * * * * - * @param FieldFactory $fieldFactory Field factory to use + * @throws LogicException If the expression or the alias name are invalid + * or if the alias is already registered. + */ + public static function registerAlias(string $alias, string $expression): void + { + try { + new self($expression); + } catch (InvalidArgumentException $exception) { + throw new LogicException("The expression `$expression` is invalid", 0, $exception); + } + + $shortcut = strtolower($alias); + if (1 !== preg_match('/^@\w+$/', $shortcut)) { + throw new LogicException("The alias `$alias` is invalid. It must start with an `@` character and contain alphanumeric (letters, numbers, regardless of case) plus underscore (_)."); + } + + if (isset(self::$registeredAliases[$shortcut])) { + throw new LogicException("The alias `$alias` is already registered."); + } + + self::$registeredAliases[$shortcut] = $expression; + } + + /** + * Unregistered a user defined CRON Expression Alias. * - * @return CronExpression + * @throws LogicException If the user tries to unregister a built-in alias */ - public static function factory($expression, FieldFactory $fieldFactory = null) + public static function unregisterAlias(string $alias): bool { - $mappings = array( - '@yearly' => '0 0 1 1 *', - '@annually' => '0 0 1 1 *', - '@monthly' => '0 0 1 * *', - '@weekly' => '0 0 * * 0', - '@daily' => '0 0 * * *', - '@hourly' => '0 * * * *' - ); - - if (isset($mappings[$expression])) { - $expression = $mappings[$expression]; + $shortcut = strtolower($alias); + if (isset(self::MAPPINGS[$shortcut])) { + throw new LogicException("The alias `$alias` is a built-in alias; it can not be unregistered."); + } + + if (!isset(self::$registeredAliases[$shortcut])) { + return false; } - return new static($expression, $fieldFactory ?: new FieldFactory()); + unset(self::$registeredAliases[$shortcut]); + + return true; + } + + /** + * Tells whether a CRON Expression alias is registered. + */ + public static function supportsAlias(string $alias): bool + { + return isset(self::$registeredAliases[strtolower($alias)]); + } + + /** + * Returns all registered aliases as an associated array where the aliases are the key + * and their associated expressions are the values. + * + * @return array + */ + public static function getAliases(): array + { + return self::$registeredAliases; + } + + /** + * @deprecated since version 3.0.2, use __construct instead. + */ + public static function factory(string $expression, ?FieldFactoryInterface $fieldFactory = null): CronExpression + { + /** @phpstan-ignore-next-line */ + return new static($expression, $fieldFactory); } /** * Validate a CronExpression. * - * @param string $expression The CRON expression to validate. + * @param string $expression the CRON expression to validate * * @return bool True if a valid CRON expression was passed. False if not. - * @see \Cron\CronExpression::factory */ - public static function isValidExpression($expression) + public static function isValidExpression(string $expression): bool { try { - self::factory($expression); + new CronExpression($expression); } catch (InvalidArgumentException $e) { return false; } @@ -104,34 +172,56 @@ public static function isValidExpression($expression) } /** - * Parse a CRON expression + * Parse a CRON expression. * - * @param string $expression CRON expression (e.g. '8 * * * *') - * @param FieldFactory $fieldFactory Factory to create cron fields + * @param string $expression CRON expression (e.g. '8 * * * *') + * @param null|FieldFactoryInterface $fieldFactory Factory to create cron fields + * @throws InvalidArgumentException */ - public function __construct($expression, FieldFactory $fieldFactory) + public function __construct(string $expression, ?FieldFactoryInterface $fieldFactory = null) { - $this->fieldFactory = $fieldFactory; + $shortcut = strtolower($expression); + $expression = self::$registeredAliases[$shortcut] ?? $expression; + + $this->fieldFactory = $fieldFactory ?: new FieldFactory(); $this->setExpression($expression); } /** - * Set or change the CRON expression + * Set or change the CRON expression. * * @param string $value CRON expression (e.g. 8 * * * *) * - * @return CronExpression * @throws \InvalidArgumentException if not a valid CRON expression + * + * @return CronExpression */ - public function setExpression($value) + public function setExpression(string $value): CronExpression { - $this->cronParts = preg_split('/\s/', $value, -1, PREG_SPLIT_NO_EMPTY); - if (count($this->cronParts) < 5) { + $split = preg_split('/\s/', $value, -1, PREG_SPLIT_NO_EMPTY); + + if (!\is_array($split)) { throw new InvalidArgumentException( $value . ' is not a valid CRON expression' ); } + $notEnoughParts = \count($split) < 5; + + $questionMarkInInvalidPart = array_key_exists(0, $split) && $split[0] === '?' + || array_key_exists(1, $split) && $split[1] === '?' + || array_key_exists(3, $split) && $split[3] === '?'; + + $tooManyQuestionMarks = array_key_exists(2, $split) && $split[2] === '?' + && array_key_exists(4, $split) && $split[4] === '?'; + + if ($notEnoughParts || $questionMarkInInvalidPart || $tooManyQuestionMarks) { + throw new InvalidArgumentException( + $value . ' is not a valid CRON expression' + ); + } + + $this->cronParts = $split; foreach ($this->cronParts as $position => $part) { $this->setPart($position, $part); } @@ -140,15 +230,16 @@ public function setExpression($value) } /** - * Set part of the CRON expression + * Set part of the CRON expression. * - * @param int $position The position of the CRON expression to set - * @param string $value The value to set + * @param int $position The position of the CRON expression to set + * @param string $value The value to set * - * @return CronExpression * @throws \InvalidArgumentException if the value is not valid for the part + * + * @return CronExpression */ - public function setPart($position, $value) + public function setPart(int $position, string $value): CronExpression { if (!$this->fieldFactory->getField($position)->validate($value)) { throw new InvalidArgumentException( @@ -162,113 +253,154 @@ public function setPart($position, $value) } /** - * Set max iteration count for searching next run dates + * Set max iteration count for searching next run dates. * * @param int $maxIterationCount Max iteration count when searching for next run date * * @return CronExpression */ - public function setMaxIterationCount($maxIterationCount) + public function setMaxIterationCount(int $maxIterationCount): CronExpression { $this->maxIterationCount = $maxIterationCount; - + return $this; } /** * Get a next run date relative to the current date or a specific date * - * @param string|\DateTime $currentTime Relative calculation date - * @param int $nth Number of matches to skip before returning a - * matching next run date. 0, the default, will return the current - * date and time if the next run date falls on the current date and - * time. Setting this value to 1 will skip the first match and go to - * the second match. Setting this value to 2 will skip the first 2 - * matches and so on. - * @param bool $allowCurrentDate Set to TRUE to return the current date if - * it matches the cron expression. - * @param null|string $timeZone Timezone to use instead of the system default + * @param string|\DateTimeInterface $currentTime Relative calculation date + * @param int $nth Number of matches to skip before returning a + * matching next run date. 0, the default, will return the + * current date and time if the next run date falls on the + * current date and time. Setting this value to 1 will + * skip the first match and go to the second match. + * Setting this value to 2 will skip the first 2 + * matches and so on. + * @param bool $allowCurrentDate Set to TRUE to return the current date if + * it matches the cron expression. + * @param null|string $timeZone TimeZone to use instead of the system default * - * @return \DateTime * @throws \RuntimeException on too many iterations + * @throws \Exception + * + * @return \DateTime */ - public function getNextRunDate($currentTime = 'now', $nth = 0, $allowCurrentDate = false, $timeZone = null) + public function getNextRunDate($currentTime = 'now', int $nth = 0, bool $allowCurrentDate = false, $timeZone = null): DateTime { return $this->getRunDate($currentTime, $nth, false, $allowCurrentDate, $timeZone); } /** - * Get a previous run date relative to the current date or a specific date + * Get a previous run date relative to the current date or a specific date. * - * @param string|\DateTime $currentTime Relative calculation date - * @param int $nth Number of matches to skip before returning - * @param bool $allowCurrentDate Set to TRUE to return the - * current date if it matches the cron expression - * @param null|string $timeZone Timezone to use instead of the system default + * @param string|\DateTimeInterface $currentTime Relative calculation date + * @param int $nth Number of matches to skip before returning + * @param bool $allowCurrentDate Set to TRUE to return the + * current date if it matches the cron expression + * @param null|string $timeZone TimeZone to use instead of the system default * - * @return \DateTime * @throws \RuntimeException on too many iterations + * @throws \Exception + * + * @return \DateTime + * * @see \Cron\CronExpression::getNextRunDate */ - public function getPreviousRunDate($currentTime = 'now', $nth = 0, $allowCurrentDate = false, $timeZone = null) + public function getPreviousRunDate($currentTime = 'now', int $nth = 0, bool $allowCurrentDate = false, $timeZone = null): DateTime { return $this->getRunDate($currentTime, $nth, true, $allowCurrentDate, $timeZone); } /** - * Get multiple run dates starting at the current date or a specific date + * Get multiple run dates starting at the current date or a specific date. * - * @param int $total Set the total number of dates to calculate - * @param string|\DateTime $currentTime Relative calculation date - * @param bool $invert Set to TRUE to retrieve previous dates - * @param bool $allowCurrentDate Set to TRUE to return the - * current date if it matches the cron expression - * @param null|string $timeZone Timezone to use instead of the system default + * @param int $total Set the total number of dates to calculate + * @param string|\DateTimeInterface|null $currentTime Relative calculation date + * @param bool $invert Set to TRUE to retrieve previous dates + * @param bool $allowCurrentDate Set to TRUE to return the + * current date if it matches the cron expression + * @param null|string $timeZone TimeZone to use instead of the system default * - * @return array Returns an array of run dates + * @return \DateTime[] Returns an array of run dates */ - public function getMultipleRunDates($total, $currentTime = 'now', $invert = false, $allowCurrentDate = false, $timeZone = null) + public function getMultipleRunDates(int $total, $currentTime = 'now', bool $invert = false, bool $allowCurrentDate = false, $timeZone = null): array { - $matches = array(); - for ($i = 0; $i < max(0, $total); $i++) { + $timeZone = $this->determineTimeZone($currentTime, $timeZone); + + if ('now' === $currentTime) { + $currentTime = new DateTime(); + } elseif ($currentTime instanceof DateTime) { + $currentTime = clone $currentTime; + } elseif ($currentTime instanceof DateTimeImmutable) { + $currentTime = DateTime::createFromFormat('U', $currentTime->format('U')); + } elseif (\is_string($currentTime)) { + $currentTime = new DateTime($currentTime); + } + + if (!$currentTime instanceof DateTime) { + throw new InvalidArgumentException('invalid current time'); + } + + $currentTime->setTimezone(new DateTimeZone($timeZone)); + + $matches = []; + for ($i = 0; $i < $total; ++$i) { try { - $matches[] = $this->getRunDate($currentTime, $i, $invert, $allowCurrentDate, $timeZone); + $result = $this->getRunDate($currentTime, 0, $invert, $allowCurrentDate, $timeZone); } catch (RuntimeException $e) { break; } + + $allowCurrentDate = false; + $currentTime = clone $result; + $matches[] = $result; } return $matches; } /** - * Get all or part of the CRON expression + * Get all or part of the CRON expression. * - * @param string $part Specify the part to retrieve or NULL to get the full - * cron schedule string. + * @param int|string|null $part specify the part to retrieve or NULL to get the full + * cron schedule string * - * @return string|null Returns the CRON expression, a part of the + * @return null|string Returns the CRON expression, a part of the * CRON expression, or NULL if the part was specified but not found */ - public function getExpression($part = null) + public function getExpression($part = null): ?string { if (null === $part) { return implode(' ', $this->cronParts); - } elseif (array_key_exists($part, $this->cronParts)) { + } + + if (array_key_exists($part, $this->cronParts)) { return $this->cronParts[$part]; } return null; } + /** + * Gets the parts of the cron expression as an array. + * + * @return string[] + * The array of parts that make up this expression. + */ + public function getParts() + { + return $this->cronParts; + } + /** * Helper method to output the full expression. * * @return string Full CRON expression */ - public function __toString() + public function __toString(): string { - return $this->getExpression(); + return (string) $this->getExpression(); } /** @@ -276,81 +408,87 @@ public function __toString() * specific date. This method assumes that the current number of * seconds are irrelevant, and should be called once per minute. * - * @param string|\DateTime $currentTime Relative calculation date - * @param null|string $timeZone Timezone to use instead of the system default + * @param string|\DateTimeInterface $currentTime Relative calculation date + * @param null|string $timeZone TimeZone to use instead of the system default * * @return bool Returns TRUE if the cron is due to run or FALSE if not */ - public function isDue($currentTime = 'now', $timeZone = null) + public function isDue($currentTime = 'now', $timeZone = null): bool { - if (is_null($timeZone)) { - $timeZone = date_default_timezone_get(); - } - + $timeZone = $this->determineTimeZone($currentTime, $timeZone); + if ('now' === $currentTime) { - $currentDate = date('Y-m-d H:i'); - $currentTime = strtotime($currentDate); + $currentTime = new DateTime(); } elseif ($currentTime instanceof DateTime) { - $currentDate = clone $currentTime; - // Ensure time in 'current' timezone is used - $currentDate->setTimezone(new DateTimeZone($timeZone)); - $currentDate = $currentDate->format('Y-m-d H:i'); - $currentTime = strtotime($currentDate); + $currentTime = clone $currentTime; } elseif ($currentTime instanceof DateTimeImmutable) { - $currentDate = DateTime::createFromFormat('U', $currentTime->format('U')); - $currentDate->setTimezone(new DateTimeZone($timeZone)); - $currentDate = $currentDate->format('Y-m-d H:i'); - $currentTime = strtotime($currentDate); - } else { + $currentTime = DateTime::createFromFormat('U', $currentTime->format('U')); + } elseif (\is_string($currentTime)) { $currentTime = new DateTime($currentTime); - $currentTime->setTime($currentTime->format('H'), $currentTime->format('i'), 0); - $currentDate = $currentTime->format('Y-m-d H:i'); - $currentTime = $currentTime->getTimeStamp(); } + if (!$currentTime instanceof DateTime) { + throw new InvalidArgumentException('invalid current time'); + } + + $currentTime->setTimezone(new DateTimeZone($timeZone)); + + // drop the seconds to 0 + $currentTime->setTime((int) $currentTime->format('H'), (int) $currentTime->format('i'), 0); + try { - return $this->getNextRunDate($currentDate, 0, true)->getTimestamp() == $currentTime; + return $this->getNextRunDate($currentTime, 0, true)->getTimestamp() === $currentTime->getTimestamp(); } catch (Exception $e) { return false; } } /** - * Get the next or previous run date of the expression relative to a date + * Get the next or previous run date of the expression relative to a date. * - * @param string|\DateTime $currentTime Relative calculation date - * @param int $nth Number of matches to skip before returning - * @param bool $invert Set to TRUE to go backwards in time - * @param bool $allowCurrentDate Set to TRUE to return the - * current date if it matches the cron expression - * @param string|null $timeZone Timezone to use instead of the system default + * @param string|\DateTimeInterface|null $currentTime Relative calculation date + * @param int $nth Number of matches to skip before returning + * @param bool $invert Set to TRUE to go backwards in time + * @param bool $allowCurrentDate Set to TRUE to return the + * current date if it matches the cron expression + * @param string|null $timeZone TimeZone to use instead of the system default * - * @return \DateTime * @throws \RuntimeException on too many iterations + * @throws Exception + * + * @return \DateTime */ - protected function getRunDate($currentTime = null, $nth = 0, $invert = false, $allowCurrentDate = false, $timeZone = null) + protected function getRunDate($currentTime = null, int $nth = 0, bool $invert = false, bool $allowCurrentDate = false, $timeZone = null): DateTime { - if (is_null($timeZone)) { - $timeZone = date_default_timezone_get(); - } - + $timeZone = $this->determineTimeZone($currentTime, $timeZone); + if ($currentTime instanceof DateTime) { $currentDate = clone $currentTime; } elseif ($currentTime instanceof DateTimeImmutable) { $currentDate = DateTime::createFromFormat('U', $currentTime->format('U')); - $currentDate->setTimezone($currentTime->getTimezone()); + } elseif (\is_string($currentTime)) { + $currentDate = new DateTime($currentTime); } else { - $currentDate = new DateTime($currentTime ?: 'now'); - $currentDate->setTimezone(new DateTimeZone($timeZone)); + $currentDate = new DateTime('now'); + } + + if (!$currentDate instanceof DateTime) { + throw new InvalidArgumentException('invalid current date'); } - $currentDate->setTime($currentDate->format('H'), $currentDate->format('i'), 0); + $currentDate->setTimezone(new DateTimeZone($timeZone)); + // Workaround for setTime causing an offset change: https://bugs.php.net/bug.php?id=81074 + $currentDate = DateTime::createFromFormat("!Y-m-d H:iO", $currentDate->format("Y-m-d H:iP"), $currentDate->getTimezone()); + if ($currentDate === false) { + throw new \RuntimeException('Unable to create date from format'); + } + $currentDate->setTimezone(new DateTimeZone($timeZone)); + $nextRun = clone $currentDate; - $nth = (int) $nth; // We don't have to satisfy * or null fields - $parts = array(); - $fields = array(); + $parts = []; + $fields = []; foreach (self::$order as $position) { $part = $this->getExpression($position); if (null === $part || '*' === $part) { @@ -360,20 +498,49 @@ protected function getRunDate($currentTime = null, $nth = 0, $invert = false, $a $fields[$position] = $this->fieldFactory->getField($position); } - // Set a hard limit to bail on an impossible date - for ($i = 0; $i < $this->maxIterationCount; $i++) { + if (isset($parts[self::DAY]) && isset($parts[self::WEEKDAY])) { + $domExpression = sprintf('%s %s %s %s *', $this->getExpression(0), $this->getExpression(1), $this->getExpression(2), $this->getExpression(3)); + $dowExpression = sprintf('%s %s * %s %s', $this->getExpression(0), $this->getExpression(1), $this->getExpression(3), $this->getExpression(4)); + + $domExpression = new self($domExpression); + $dowExpression = new self($dowExpression); + $domRunDates = $domExpression->getMultipleRunDates($nth + 1, $currentTime, $invert, $allowCurrentDate, $timeZone); + $dowRunDates = $dowExpression->getMultipleRunDates($nth + 1, $currentTime, $invert, $allowCurrentDate, $timeZone); + + if ($parts[self::DAY] === '?' || $parts[self::DAY] === '*') { + $domRunDates = []; + } + + if ($parts[self::WEEKDAY] === '?' || $parts[self::WEEKDAY] === '*') { + $dowRunDates = []; + } + + $combined = array_merge($domRunDates, $dowRunDates); + usort($combined, function ($a, $b) { + return $a->format('Y-m-d H:i:s') <=> $b->format('Y-m-d H:i:s'); + }); + if ($invert) { + $combined = array_reverse($combined); + } + + return $combined[$nth]; + } + + // Set a hard limit to bail on an impossible date + for ($i = 0; $i < $this->maxIterationCount; ++$i) { foreach ($parts as $position => $part) { $satisfied = false; // Get the field object used to validate this part $field = $fields[$position]; // Check if this is singular or a list - if (strpos($part, ',') === false) { - $satisfied = $field->isSatisfiedBy($nextRun, $part); + if (false === strpos($part, ',')) { + $satisfied = $field->isSatisfiedBy($nextRun, $part, $invert); } else { foreach (array_map('trim', explode(',', $part)) as $listPart) { - if ($field->isSatisfiedBy($nextRun, $listPart)) { + if ($field->isSatisfiedBy($nextRun, $listPart, $invert)) { $satisfied = true; + break; } } @@ -382,13 +549,14 @@ protected function getRunDate($currentTime = null, $nth = 0, $invert = false, $a // If the field is not satisfied, then start over if (!$satisfied) { $field->increment($nextRun, $invert, $part); + continue 2; } } // Skip this match if needed if ((!$allowCurrentDate && $nextRun == $currentDate) || --$nth > -1) { - $this->fieldFactory->getField(0)->increment($nextRun, $invert, isset($parts[0]) ? $parts[0] : null); + $this->fieldFactory->getField(self::MINUTE)->increment($nextRun, $invert, $parts[self::MINUTE] ?? null); continue; } @@ -399,4 +567,25 @@ protected function getRunDate($currentTime = null, $nth = 0, $invert = false, $a throw new RuntimeException('Impossible CRON expression'); // @codeCoverageIgnoreEnd } + + /** + * Workout what timeZone should be used. + * + * @param string|\DateTimeInterface|null $currentTime Relative calculation date + * @param string|null $timeZone TimeZone to use instead of the system default + * + * @return string + */ + protected function determineTimeZone($currentTime, ?string $timeZone): string + { + if (null !== $timeZone) { + return $timeZone; + } + + if ($currentTime instanceof DateTimeInterface) { + return $currentTime->getTimezone()->getName(); + } + + return date_default_timezone_get(); + } } diff --git a/src/Cron/DayOfMonthField.php b/src/Cron/DayOfMonthField.php index abf59690..39ff5978 100644 --- a/src/Cron/DayOfMonthField.php +++ b/src/Cron/DayOfMonthField.php @@ -1,11 +1,14 @@ format('N'); if ($currentWeekday < 6) { @@ -47,75 +62,93 @@ private static function getNearestWeekday($currentYear, $currentMonth, $targetDa } $lastDayOfMonth = $target->format('t'); - - foreach (array(-1, 1, -2, 2) as $i) { + foreach ([-1, 1, -2, 2] as $i) { $adjusted = $targetDay + $i; if ($adjusted > 0 && $adjusted <= $lastDayOfMonth) { $target->setDate($currentYear, $currentMonth, $adjusted); - if ($target->format('N') < 6 && $target->format('m') == $currentMonth) { + + if ((int) $target->format('N') < 6 && (int) $target->format('m') === $currentMonth) { return $target; } } } + + return null; } - public function isSatisfiedBy(DateTime $date, $value) + /** + * {@inheritdoc} + */ + public function isSatisfiedBy(DateTimeInterface $date, $value, bool $invert): bool { // ? states that the field value is to be skipped - if ($value == '?') { + if ('?' === $value) { return true; } $fieldValue = $date->format('d'); // Check to see if this is the last day of the month - if ($value == 'L') { - return $fieldValue == $date->format('t'); + if ('L' === $value) { + return $fieldValue === $date->format('t'); } // Check to see if this is the nearest weekday to a particular value - if (strpos($value, 'W')) { + if ($wPosition = strpos($value, 'W')) { // Parse the target day - $targetDay = substr($value, 0, strpos($value, 'W')); + $targetDay = (int) substr($value, 0, $wPosition); // Find out if the current day is the nearest day of the week - return $date->format('j') == self::getNearestWeekday( - $date->format('Y'), - $date->format('m'), + $nearest = self::getNearestWeekday( + (int) $date->format('Y'), + (int) $date->format('m'), $targetDay - )->format('j'); + ); + if ($nearest) { + return $date->format('j') === $nearest->format('j'); + } + + throw new \RuntimeException('Unable to return nearest weekday'); } - return $this->isSatisfied($date->format('d'), $value); + return $this->isSatisfied((int) $date->format('d'), $value); } - public function increment(DateTime $date, $invert = false) + /** + * @inheritDoc + * + * @param \DateTime|\DateTimeImmutable $date + */ + public function increment(DateTimeInterface &$date, $invert = false, $parts = null): FieldInterface { - if ($invert) { - $date->modify('previous day'); - $date->setTime(23, 59); + if (! $invert) { + $date = $date->add(new \DateInterval('P1D')); + $date = $date->setTime(0, 0); } else { - $date->modify('next day'); - $date->setTime(0, 0); + $date = $date->sub(new \DateInterval('P1D')); + $date = $date->setTime(23, 59); } return $this; } /** - * @inheritDoc + * {@inheritdoc} */ - public function validate($value) + public function validate(string $value): bool { $basicChecks = parent::validate($value); // Validate that a list don't have W or L - if (strpos($value, ',') !== false && (strpos($value, 'W') !== false || strpos($value, 'L') !== false)) { + if (false !== strpos($value, ',') && (false !== strpos($value, 'W') || false !== strpos($value, 'L'))) { return false; } if (!$basicChecks) { + if ('?' === $value) { + return true; + } - if ($value === 'L') { + if ('L' === $value) { return true; } diff --git a/src/Cron/DayOfWeekField.php b/src/Cron/DayOfWeekField.php index e1780134..b9bbf48b 100644 --- a/src/Cron/DayOfWeekField.php +++ b/src/Cron/DayOfWeekField.php @@ -1,13 +1,14 @@ 'MON', 2 => 'TUE', 3 => 'WED', 4 => 'THU', 5 => 'FRI', 6 => 'SAT', 7 => 'SUN']; + /** + * Constructor + */ public function __construct() { $this->nthRange = range(1, 5); parent::__construct(); } - public function isSatisfiedBy(DateTime $date, $value) + /** + * @inheritDoc + */ + public function isSatisfiedBy(DateTimeInterface $date, $value, bool $invert): bool { - if ($value == '?') { + if ('?' === $value) { return true; } // Convert text day of the week values to integers $value = $this->convertLiterals($value); - $currentYear = $date->format('Y'); - $currentMonth = $date->format('m'); - $lastDayOfMonth = $date->format('t'); + $currentYear = (int) $date->format('Y'); + $currentMonth = (int) $date->format('m'); + $lastDayOfMonth = (int) $date->format('t'); // Find out if this is the last specific weekday of the month - if (strpos($value, 'L')) { - $weekday = str_replace('7', '0', substr($value, 0, strpos($value, 'L'))); - $tdate = clone $date; - $tdate->setDate($currentYear, $currentMonth, $lastDayOfMonth); - while ($tdate->format('w') != $weekday) { - $tdateClone = new DateTime(); - $tdate = $tdateClone - ->setTimezone($tdate->getTimezone()) - ->setDate($currentYear, $currentMonth, --$lastDayOfMonth); - } + if ($lPosition = strpos($value, 'L')) { + $weekday = $this->convertLiterals(substr($value, 0, $lPosition)); + $weekday %= 7; - return $date->format('j') == $lastDayOfMonth; + $daysInMonth = (int) $date->format('t'); + $remainingDaysInMonth = $daysInMonth - (int) $date->format('d'); + return (($weekday === (int) $date->format('w')) && ($remainingDaysInMonth < 7)); } // Handle # hash tokens if (strpos($value, '#')) { - list($weekday, $nth) = explode('#', $value); + [$weekday, $nth] = explode('#', $value); if (!is_numeric($nth)) { throw new InvalidArgumentException("Hashed weekdays must be numeric, {$nth} given"); @@ -73,87 +88,96 @@ public function isSatisfiedBy(DateTime $date, $value) } // 0 and 7 are both Sunday, however 7 matches date('N') format ISO-8601 - if ($weekday === '0') { + if ('0' === $weekday) { $weekday = 7; } - $weekday = $this->convertLiterals($weekday); + $weekday = (int) $this->convertLiterals((string) $weekday); // Validate the hash fields if ($weekday < 0 || $weekday > 7) { throw new InvalidArgumentException("Weekday must be a value between 0 and 7. {$weekday} given"); } - if (!in_array($nth, $this->nthRange)) { + if (!\in_array($nth, $this->nthRange, true)) { throw new InvalidArgumentException("There are never more than 5 or less than 1 of a given weekday in a month, {$nth} given"); } // The current weekday must match the targeted weekday to proceed - if ($date->format('N') != $weekday) { + if ((int) $date->format('N') !== $weekday) { return false; } $tdate = clone $date; - $tdate->setDate($currentYear, $currentMonth, 1); + $tdate = $tdate->setDate($currentYear, $currentMonth, 1); $dayCount = 0; $currentDay = 1; while ($currentDay < $lastDayOfMonth + 1) { - if ($tdate->format('N') == $weekday) { + if ((int) $tdate->format('N') === $weekday) { if (++$dayCount >= $nth) { break; } } - $tdate->setDate($currentYear, $currentMonth, ++$currentDay); + $tdate = $tdate->setDate($currentYear, $currentMonth, ++$currentDay); } - return $date->format('j') == $currentDay; + return (int) $date->format('j') === $currentDay; } // Handle day of the week values - if (strpos($value, '-')) { + if (false !== strpos($value, '-')) { $parts = explode('-', $value); - if ($parts[0] == '7') { - $parts[0] = '0'; - } elseif ($parts[1] == '0') { - $parts[1] = '7'; + if ('7' === $parts[0]) { + $parts[0] = 0; + } elseif ('0' === $parts[1]) { + $parts[1] = 7; } $value = implode('-', $parts); } // Test to see which Sunday to use -- 0 == 7 == Sunday - $format = in_array(7, str_split($value)) ? 'N' : 'w'; - $fieldValue = $date->format($format); + $format = \in_array(7, array_map(function ($value) { + return (int) $value; + }, str_split($value)), true) ? 'N' : 'w'; + $fieldValue = (int) $date->format($format); return $this->isSatisfied($fieldValue, $value); } - public function increment(DateTime $date, $invert = false) + /** + * @inheritDoc + */ + public function increment(DateTimeInterface &$date, $invert = false, $parts = null): FieldInterface { - if ($invert) { - $date->modify('-1 day'); - $date->setTime(23, 59, 0); + if (! $invert) { + $date = $date->add(new \DateInterval('P1D')); + $date = $date->setTime(0, 0); } else { - $date->modify('+1 day'); - $date->setTime(0, 0, 0); + $date = $date->sub(new \DateInterval('P1D')); + $date = $date->setTime(23, 59); } return $this; } /** - * @inheritDoc + * {@inheritdoc} */ - public function validate($value) + public function validate(string $value): bool { $basicChecks = parent::validate($value); if (!$basicChecks) { + if ('?' === $value) { + return true; + } + // Handle the # value - if (strpos($value, '#') !== false) { + if (false !== strpos($value, '#')) { $chunks = explode('#', $value); $chunks[0] = $this->convertLiterals($chunks[0]); - if (parent::validate($chunks[0]) && is_numeric($chunks[1]) && in_array($chunks[1], $this->nthRange)) { + if (parent::validate($chunks[0]) && is_numeric($chunks[1]) && \in_array((int) $chunks[1], $this->nthRange, true)) { return true; } } diff --git a/src/Cron/FieldFactory.php b/src/Cron/FieldFactory.php index fd27352d..839b2757 100644 --- a/src/Cron/FieldFactory.php +++ b/src/Cron/FieldFactory.php @@ -1,54 +1,52 @@ fields[$position] ?? $this->fields[$position] = $this->instantiateField($position); + } + + private function instantiateField(int $position): FieldInterface { - if (!isset($this->fields[$position])) { - switch ($position) { - case 0: - $this->fields[$position] = new MinutesField(); - break; - case 1: - $this->fields[$position] = new HoursField(); - break; - case 2: - $this->fields[$position] = new DayOfMonthField(); - break; - case 3: - $this->fields[$position] = new MonthField(); - break; - case 4: - $this->fields[$position] = new DayOfWeekField(); - break; - default: - throw new InvalidArgumentException( - $position . ' is not a valid position' - ); - } + switch ($position) { + case CronExpression::MINUTE: + return new MinutesField(); + case CronExpression::HOUR: + return new HoursField(); + case CronExpression::DAY: + return new DayOfMonthField(); + case CronExpression::MONTH: + return new MonthField(); + case CronExpression::WEEKDAY: + return new DayOfWeekField(); } - return $this->fields[$position]; + throw new InvalidArgumentException( + ($position + 1) . ' is not a valid position' + ); } } diff --git a/src/Cron/FieldFactoryInterface.php b/src/Cron/FieldFactoryInterface.php new file mode 100644 index 00000000..8bd3c658 --- /dev/null +++ b/src/Cron/FieldFactoryInterface.php @@ -0,0 +1,8 @@ +format('H'); + $retval = $this->isSatisfied($checkValue, $value); + if ($retval) { + return $retval; + } + + // Are we on the edge of a transition + $lastTransition = $this->getPastTransition($date); + if (($lastTransition !== null) && ($lastTransition["ts"] > ((int) $date->format('U') - 3600))) { + $dtLastOffset = clone $date; + $this->timezoneSafeModify($dtLastOffset, "-1 hour"); + $lastOffset = $dtLastOffset->getOffset(); + + $dtNextOffset = clone $date; + $this->timezoneSafeModify($dtNextOffset, "+1 hour"); + $nextOffset = $dtNextOffset->getOffset(); + + $offsetChange = $nextOffset - $lastOffset; + if ($offsetChange >= 3600) { + $checkValue -= 1; + return $this->isSatisfied($checkValue, $value); + } + if ((! $invert) && ($offsetChange <= -3600)) { + $checkValue += 1; + return $this->isSatisfied($checkValue, $value); + } + } + + return $retval; + } + + public function getPastTransition(DateTimeInterface $date): ?array { - return $this->isSatisfied($date->format('H'), $value); + $currentTimestamp = (int) $date->format('U'); + if ( + ($this->transitions === null) + || ($this->transitionsStart < ($currentTimestamp + 86400)) + || ($this->transitionsEnd > ($currentTimestamp - 86400)) + ) { + // We start a day before current time so we can differentiate between the first transition entry + // and a change that happens now + $dtLimitStart = clone $date; + $dtLimitStart = $dtLimitStart->modify("-12 months"); + $dtLimitEnd = clone $date; + $dtLimitEnd = $dtLimitEnd->modify('+12 months'); + + $this->transitions = $date->getTimezone()->getTransitions( + $dtLimitStart->getTimestamp(), + $dtLimitEnd->getTimestamp() + ); + if (empty($this->transitions)) { + return null; + } + $this->transitionsStart = $dtLimitStart->getTimestamp(); + $this->transitionsEnd = $dtLimitEnd->getTimestamp(); + } + + $nextTransition = null; + foreach ($this->transitions as $transition) { + if ($transition["ts"] > $currentTimestamp) { + continue; + } + + if (($nextTransition !== null) && ($transition["ts"] < $nextTransition["ts"])) { + continue; + } + + $nextTransition = $transition; + } + + return ($nextTransition ?? null); } - public function increment(DateTime $date, $invert = false, $parts = null) + /** + * {@inheritdoc} + * + * @param string|null $parts + */ + public function increment(DateTimeInterface &$date, $invert = false, $parts = null): FieldInterface { + $originalTimestamp = (int) $date->format('U'); + // Change timezone to UTC temporarily. This will // allow us to go back or forwards and hour even // if DST will be changed between the hours. - if (is_null($parts) || $parts == '*') { - $timezone = $date->getTimezone(); - $date->setTimezone(new DateTimeZone('UTC')); + if (null === $parts || '*' === $parts) { if ($invert) { - $date->modify('-1 hour'); + $date = $date->sub(new \DateInterval('PT1H')); } else { - $date->modify('+1 hour'); + $date = $date->add(new \DateInterval('PT1H')); } - $date->setTimezone($timezone); - $date->setTime($date->format('H'), $invert ? 59 : 0); + $date = $this->setTimeHour($date, $invert, $originalTimestamp); return $this; } - $parts = strpos($parts, ',') !== false ? explode(',', $parts) : array($parts); - $hours = array(); + $parts = false !== strpos($parts, ',') ? explode(',', $parts) : [$parts]; + $hours = []; foreach ($parts as $part) { $hours = array_merge($hours, $this->getRangeForExpression($part, 23)); } - $current_hour = $date->format('H'); - $position = $invert ? count($hours) - 1 : 0; - if (count($hours) > 1) { - for ($i = 0; $i < count($hours) - 1; $i++) { + $current_hour = (int) $date->format('H'); + $position = $invert ? \count($hours) - 1 : 0; + $countHours = \count($hours); + if ($countHours > 1) { + for ($i = 0; $i < $countHours - 1; ++$i) { if ((!$invert && $current_hour >= $hours[$i] && $current_hour < $hours[$i + 1]) || ($invert && $current_hour > $hours[$i] && $current_hour <= $hours[$i + 1])) { $position = $invert ? $i : $i + 1; + break; } } } - $hour = $hours[$position]; - if ((!$invert && $date->format('H') >= $hour) || ($invert && $date->format('H') <= $hour)) { - $date->modify(($invert ? '-' : '+') . '1 day'); - $date->setTime($invert ? 23 : 0, $invert ? 59 : 0); + $target = (int) $hours[$position]; + $originalHour = (int)$date->format('H'); + + $originalDay = (int)$date->format('d'); + $previousOffset = $date->getOffset(); + + if (! $invert) { + if ($originalHour >= $target) { + $distance = 24 - $originalHour; + $date = $this->timezoneSafeModify($date, "+{$distance} hours"); + + $actualDay = (int)$date->format('d'); + $actualHour = (int)$date->format('H'); + if (($actualDay !== ($originalDay + 1)) && ($actualHour !== 0)) { + $offsetChange = ($previousOffset - $date->getOffset()); + $date = $this->timezoneSafeModify($date, "+{$offsetChange} seconds"); + } + + $originalHour = (int)$date->format('H'); + } + + $distance = $target - $originalHour; + $date = $this->timezoneSafeModify($date, "+{$distance} hours"); + } else { + if ($originalHour <= $target) { + $distance = ($originalHour + 1); + $date = $this->timezoneSafeModify($date, "-" . $distance . " hours"); + + $actualDay = (int)$date->format('d'); + $actualHour = (int)$date->format('H'); + if (($actualDay !== ($originalDay - 1)) && ($actualHour !== 23)) { + $offsetChange = ($previousOffset - $date->getOffset()); + $date = $this->timezoneSafeModify($date, "+{$offsetChange} seconds"); + } + + $originalHour = (int)$date->format('H'); + } + + $distance = $originalHour - $target; + $date = $this->timezoneSafeModify($date, "-{$distance} hours"); } - else { - $date->setTime($hour, $invert ? 59 : 0); + + $date = $this->setTimeHour($date, $invert, $originalTimestamp); + + $actualHour = (int)$date->format('H'); + if ($invert && ($actualHour === ($target - 1) || (($actualHour === 23) && ($target === 0)))) { + $date = $this->timezoneSafeModify($date, "+1 hour"); } return $this; diff --git a/src/Cron/MinutesField.php b/src/Cron/MinutesField.php index 59bb386f..f077e6ec 100644 --- a/src/Cron/MinutesField.php +++ b/src/Cron/MinutesField.php @@ -1,58 +1,95 @@ isSatisfied($date->format('i'), $value); + if ($value === '?') { + return true; + } + + return $this->isSatisfied((int)$date->format('i'), $value); } - public function increment(DateTime $date, $invert = false, $parts = null) + /** + * {@inheritdoc} + * {@inheritDoc} + * + * @param string|null $parts + */ + public function increment(DateTimeInterface &$date, $invert = false, $parts = null): FieldInterface { if (is_null($parts)) { - if ($invert) { - $date->modify('-1 minute'); - } else { - $date->modify('+1 minute'); - } + $date = $this->timezoneSafeModify($date, ($invert ? "-" : "+") ."1 minute"); return $this; } - $parts = strpos($parts, ',') !== false ? explode(',', $parts) : array($parts); - $minutes = array(); + $current_minute = (int) $date->format('i'); + + $parts = false !== strpos($parts, ',') ? explode(',', $parts) : [$parts]; + sort($parts); + $minutes = []; foreach ($parts as $part) { $minutes = array_merge($minutes, $this->getRangeForExpression($part, 59)); } - $current_minute = $date->format('i'); - $position = $invert ? count($minutes) - 1 : 0; - if (count($minutes) > 1) { - for ($i = 0; $i < count($minutes) - 1; $i++) { + $position = $invert ? \count($minutes) - 1 : 0; + if (\count($minutes) > 1) { + for ($i = 0; $i < \count($minutes) - 1; ++$i) { if ((!$invert && $current_minute >= $minutes[$i] && $current_minute < $minutes[$i + 1]) || ($invert && $current_minute > $minutes[$i] && $current_minute <= $minutes[$i + 1])) { $position = $invert ? $i : $i + 1; + break; } } } - if ((!$invert && $current_minute >= $minutes[$position]) || ($invert && $current_minute <= $minutes[$position])) { - $date->modify(($invert ? '-' : '+') . '1 hour'); - $date->setTime($date->format('H'), $invert ? 59 : 0); - } - else { - $date->setTime($date->format('H'), $minutes[$position]); + $target = (int) $minutes[$position]; + $originalMinute = (int) $date->format("i"); + + if (! $invert) { + if ($originalMinute >= $target) { + $distance = 60 - $originalMinute; + $date = $this->timezoneSafeModify($date, "+{$distance} minutes"); + + $originalMinute = (int) $date->format("i"); + } + + $distance = $target - $originalMinute; + $date = $this->timezoneSafeModify($date, "+{$distance} minutes"); + } else { + if ($originalMinute <= $target) { + $distance = ($originalMinute + 1); + $date = $this->timezoneSafeModify($date, "-{$distance} minutes"); + + $originalMinute = (int) $date->format("i"); + } + + $distance = $originalMinute - $target; + $date = $this->timezoneSafeModify($date, "-{$distance} minutes"); } return $this; diff --git a/src/Cron/MonthField.php b/src/Cron/MonthField.php index 79fdf3cf..5a15fbb8 100644 --- a/src/Cron/MonthField.php +++ b/src/Cron/MonthField.php @@ -1,38 +1,61 @@ 'JAN', 2 => 'FEB', 3 => 'MAR', 4 => 'APR', 5 => 'MAY', 6 => 'JUN', 7 => 'JUL', - 8 => 'AUG', 9 => 'SEP', 10 => 'OCT', 11 => 'NOV', 12 => 'DEC']; + 8 => 'AUG', 9 => 'SEP', 10 => 'OCT', 11 => 'NOV', 12 => 'DEC', ]; - public function isSatisfiedBy(DateTime $date, $value) + /** + * {@inheritdoc} + */ + public function isSatisfiedBy(DateTimeInterface $date, $value, bool $invert): bool { + if ($value === '?') { + return true; + } + $value = $this->convertLiterals($value); - return $this->isSatisfied($date->format('m'), $value); + return $this->isSatisfied((int) $date->format('m'), $value); } - public function increment(DateTime $date, $invert = false) + /** + * @inheritDoc + * + * @param \DateTime|\DateTimeImmutable $date + */ + public function increment(DateTimeInterface &$date, $invert = false, $parts = null): FieldInterface { - if ($invert) { - $date->modify('last day of previous month'); - $date->setTime(23, 59); + if (! $invert) { + $date = $date->modify('first day of next month'); + $date = $date->setTime(0, 0); } else { - $date->modify('first day of next month'); - $date->setTime(0, 0); + $date = $date->modify('last day of previous month'); + $date = $date->setTime(23, 59); } return $this; } - - } diff --git a/tests/Cron/AbstractFieldTest.php b/tests/Cron/AbstractFieldTest.php index 8fac8349..e953abfc 100644 --- a/tests/Cron/AbstractFieldTest.php +++ b/tests/Cron/AbstractFieldTest.php @@ -1,8 +1,11 @@ assertTrue($f->isRange('1-2')); @@ -25,7 +28,7 @@ public function testTestsIfRange() /** * @covers \Cron\AbstractField::isIncrementsOfRanges */ - public function testTestsIfIncrementsOfRanges() + public function testTestsIfIncrementsOfRanges(): void { $f = new DayOfWeekField(); $this->assertFalse($f->isIncrementsOfRanges('1-2')); @@ -37,83 +40,104 @@ public function testTestsIfIncrementsOfRanges() /** * @covers \Cron\AbstractField::isInRange */ - public function testTestsIfInRange() + public function testTestsIfInRange(): void { $f = new DayOfWeekField(); - $this->assertTrue($f->isInRange('1', '1-2')); - $this->assertTrue($f->isInRange('2', '1-2')); - $this->assertTrue($f->isInRange('5', '4-12')); - $this->assertFalse($f->isInRange('3', '4-12')); - $this->assertFalse($f->isInRange('13', '4-12')); + $this->assertTrue($f->isInRange(1, '1-2')); + $this->assertTrue($f->isInRange(2, '1-2')); + $this->assertTrue($f->isInRange(5, '4-12')); + $this->assertFalse($f->isInRange(3, '4-12')); + $this->assertFalse($f->isInRange(13, '4-12')); } /** * @covers \Cron\AbstractField::isInIncrementsOfRanges */ - public function testTestsIfInIncrementsOfRangesOnZeroStartRange() + public function testTestsIfInIncrementsOfRangesOnZeroStartRange(): void { $f = new MinutesField(); - $this->assertTrue($f->isInIncrementsOfRanges('3', '3-59/2')); - $this->assertTrue($f->isInIncrementsOfRanges('13', '3-59/2')); - $this->assertTrue($f->isInIncrementsOfRanges('15', '3-59/2')); - $this->assertTrue($f->isInIncrementsOfRanges('14', '*/2')); - $this->assertFalse($f->isInIncrementsOfRanges('2', '3-59/13')); - $this->assertFalse($f->isInIncrementsOfRanges('14', '*/13')); - $this->assertFalse($f->isInIncrementsOfRanges('14', '3-59/2')); - $this->assertFalse($f->isInIncrementsOfRanges('3', '2-59')); - $this->assertFalse($f->isInIncrementsOfRanges('3', '2')); - $this->assertFalse($f->isInIncrementsOfRanges('3', '*')); - $this->assertFalse($f->isInIncrementsOfRanges('0', '*/0')); - $this->assertFalse($f->isInIncrementsOfRanges('1', '*/0')); + $this->assertTrue($f->isInIncrementsOfRanges(3, '3-59/2')); + $this->assertTrue($f->isInIncrementsOfRanges(13, '3-59/2')); + $this->assertTrue($f->isInIncrementsOfRanges(15, '3-59/2')); + $this->assertTrue($f->isInIncrementsOfRanges(14, '*/2')); + $this->assertFalse($f->isInIncrementsOfRanges(2, '3-59/13')); + $this->assertFalse($f->isInIncrementsOfRanges(14, '*/13')); + $this->assertFalse($f->isInIncrementsOfRanges(14, '3-59/2')); + $this->assertFalse($f->isInIncrementsOfRanges(3, '2-59')); + $this->assertFalse($f->isInIncrementsOfRanges(3, '2')); + $this->assertFalse($f->isInIncrementsOfRanges(3, '*')); + $this->assertFalse($f->isInIncrementsOfRanges(0, '*/0')); + $this->assertFalse($f->isInIncrementsOfRanges(1, '*/0')); - $this->assertTrue($f->isInIncrementsOfRanges('4', '4/1')); - $this->assertFalse($f->isInIncrementsOfRanges('14', '4/1')); - $this->assertFalse($f->isInIncrementsOfRanges('34', '4/1')); + $this->assertTrue($f->isInIncrementsOfRanges(4, '4/1')); + $this->assertFalse($f->isInIncrementsOfRanges(14, '4/1')); + $this->assertFalse($f->isInIncrementsOfRanges(34, '4/1')); } /** * @covers \Cron\AbstractField::isInIncrementsOfRanges */ - public function testTestsIfInIncrementsOfRangesOnOneStartRange() + public function testTestsIfInIncrementsOfRangesOnOneStartRange(): void { $f = new MonthField(); - $this->assertTrue($f->isInIncrementsOfRanges('3', '3-12/2')); - $this->assertFalse($f->isInIncrementsOfRanges('13', '3-12/2')); - $this->assertFalse($f->isInIncrementsOfRanges('15', '3-12/2')); - $this->assertTrue($f->isInIncrementsOfRanges('3', '*/2')); - $this->assertFalse($f->isInIncrementsOfRanges('3', '*/3')); - $this->assertTrue($f->isInIncrementsOfRanges('7', '*/3')); - $this->assertFalse($f->isInIncrementsOfRanges('14', '3-12/2')); - $this->assertFalse($f->isInIncrementsOfRanges('3', '2-12')); - $this->assertFalse($f->isInIncrementsOfRanges('3', '2')); - $this->assertFalse($f->isInIncrementsOfRanges('3', '*')); - $this->assertFalse($f->isInIncrementsOfRanges('0', '*/0')); - $this->assertFalse($f->isInIncrementsOfRanges('1', '*/0')); + $this->assertTrue($f->isInIncrementsOfRanges(3, '3-12/2')); + $this->assertFalse($f->isInIncrementsOfRanges(13, '3-12/2')); + $this->assertFalse($f->isInIncrementsOfRanges(15, '3-12/2')); + $this->assertTrue($f->isInIncrementsOfRanges(3, '*/2')); + $this->assertFalse($f->isInIncrementsOfRanges(3, '*/3')); + $this->assertTrue($f->isInIncrementsOfRanges(7, '*/3')); + $this->assertFalse($f->isInIncrementsOfRanges(14, '3-12/2')); + $this->assertFalse($f->isInIncrementsOfRanges(3, '2-12')); + $this->assertFalse($f->isInIncrementsOfRanges(3, '2')); + $this->assertFalse($f->isInIncrementsOfRanges(3, '*')); + $this->assertFalse($f->isInIncrementsOfRanges(0, '*/0')); + $this->assertFalse($f->isInIncrementsOfRanges(1, '*/0')); - $this->assertTrue($f->isInIncrementsOfRanges('4', '4/1')); - $this->assertFalse($f->isInIncrementsOfRanges('14', '4/1')); - $this->assertFalse($f->isInIncrementsOfRanges('34', '4/1')); + $this->assertTrue($f->isInIncrementsOfRanges(4, '4/1')); + $this->assertFalse($f->isInIncrementsOfRanges(14, '4/1')); + $this->assertFalse($f->isInIncrementsOfRanges(34, '4/1')); } - public function testStepCannotBeLargerThanRange() + /** + * @covers \Cron\AbstractField::isSatisfied + */ + public function testTestsIfSatisfied(): void { - $this->expectException(\OutOfRangeException::class); - $f = new MonthField(); - $f->isInIncrementsOfRanges('2', '3-12/13'); + $f = new DayOfWeekField(); + $this->assertTrue($f->isSatisfied(12, '3-13')); + $this->assertFalse($f->isSatisfied(15, '3-7/2')); + $this->assertTrue($f->isSatisfied(12, '*')); + $this->assertTrue($f->isSatisfied(12, '12')); + $this->assertFalse($f->isSatisfied(12, '3-11')); + $this->assertFalse($f->isSatisfied(12, '3-7/2')); + $this->assertFalse($f->isSatisfied(12, '11')); } /** - * @covers \Cron\AbstractField::isSatisfied + * Allows ranges and lists to coexist in the same expression. + * + * @see https://github.com/dragonmantank/cron-expression/issues/5 */ - public function testTestsIfSatisfied() + public function testAllowRangesAndLists(): void { - $f = new DayOfWeekField(); - $this->assertTrue($f->isSatisfied('12', '3-13')); - $this->assertFalse($f->isSatisfied('15', '3-7/2')); - $this->assertTrue($f->isSatisfied('12', '*')); - $this->assertTrue($f->isSatisfied('12', '12')); - $this->assertFalse($f->isSatisfied('12', '3-11')); - $this->assertFalse($f->isSatisfied('12', '3-7/2')); - $this->assertFalse($f->isSatisfied('12', '11')); + $expression = '5-7,11-13'; + $f = new HoursField(); + $this->assertTrue($f->validate($expression)); + } + + /** + * Makes sure that various types of ranges expand out properly. + * + * @see https://github.com/dragonmantank/cron-expression/issues/5 + */ + public function testGetRangeForExpressionExpandsCorrectly(): void + { + $f = new HoursField(); + $this->assertSame([5, 6, 7, 11, 12, 13], $f->getRangeForExpression('5-7,11-13', 23)); + $this->assertSame(['5', '6', '7', '11', '12', '13'], $f->getRangeForExpression('5,6,7,11,12,13', 23)); + $this->assertSame([0, 6, 12, 18], $f->getRangeForExpression('*/6', 23)); + $this->assertSame([5, 11], $f->getRangeForExpression('5-13/6', 23)); + $this->assertSame([1, 2, 3, 4, 11, 13, 21, 24, 27, 40, 50], $f->getRangeForExpression('1-4,11-14/2,21-27/3,40-59/10', 59)); + $this->assertSame(['1', '3', 5, 6, 7, 11, 12, 13, 14, 15, 17, 19, 21, '23'], $f->getRangeForExpression('1,3,5-7,11-15/1,17-22/2,23', 23)); } } diff --git a/tests/Cron/CronExpressionTest.php b/tests/Cron/CronExpressionTest.php index 61e5137f..e28f929c 100644 --- a/tests/Cron/CronExpressionTest.php +++ b/tests/Cron/CronExpressionTest.php @@ -1,12 +1,18 @@ @@ -14,13 +20,15 @@ class CronExpressionTest extends TestCase { /** - * @covers \Cron\CronExpression::factory + * @covers \Cron\CronExpression::__construct */ - public function testFactoryRecognizesTemplates() + public function testConstructorRecognizesTemplates(): void { - $this->assertSame('0 0 1 1 *', CronExpression::factory('@annually')->getExpression()); - $this->assertSame('0 0 1 1 *', CronExpression::factory('@yearly')->getExpression()); - $this->assertSame('0 0 * * 0', CronExpression::factory('@weekly')->getExpression()); + $this->assertSame('0 0 1 1 *', (new CronExpression('@annually'))->getExpression()); + $this->assertSame('0 0 1 1 *', (new CronExpression('@yearly'))->getExpression()); + $this->assertSame('0 0 * * 0', (new CronExpression('@weekly'))->getExpression()); + $this->assertSame('0 0 * * *', (new CronExpression('@daily'))->getExpression()); + $this->assertSame('0 0 * * *', (new CronExpression('@midnight'))->getExpression()); } /** @@ -28,10 +36,10 @@ public function testFactoryRecognizesTemplates() * @covers \Cron\CronExpression::getExpression * @covers \Cron\CronExpression::__toString */ - public function testParsesCronSchedule() + public function testParsesCronSchedule(): void { // '2010-09-10 12:00:00' - $cron = CronExpression::factory('1 2-4 * 4,5,6 */3'); + $cron = new CronExpression('1 2-4 * 4,5,6 */3'); $this->assertSame('1', $cron->getExpression(CronExpression::MINUTE)); $this->assertSame('2-4', $cron->getExpression(CronExpression::HOUR)); $this->assertSame('*', $cron->getExpression(CronExpression::DAY)); @@ -46,22 +54,24 @@ public function testParsesCronSchedule() * @covers \Cron\CronExpression::__construct * @covers \Cron\CronExpression::getExpression * @covers \Cron\CronExpression::__toString - * @expectedException \InvalidArgumentException - * @expectedExceptionMessage Invalid CRON field value A at position 0 */ - public function testParsesCronScheduleThrowsAnException() + public function testParsesCronScheduleThrowsAnException(): void { - CronExpression::factory('A 1 2 3 4'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid CRON field value A at position 0'); + new CronExpression('A 1 2 3 4'); } /** * @covers \Cron\CronExpression::__construct * @covers \Cron\CronExpression::getExpression * @dataProvider scheduleWithDifferentSeparatorsProvider + * + * @param mixed $schedule */ - public function testParsesCronScheduleWithAnySpaceCharsAsSeparators($schedule, array $expected) + public function testParsesCronScheduleWithAnySpaceCharsAsSeparators($schedule, array $expected): void { - $cron = CronExpression::factory($schedule); + $cron = new CronExpression($schedule); $this->assertSame($expected[0], $cron->getExpression(CronExpression::MINUTE)); $this->assertSame($expected[1], $cron->getExpression(CronExpression::HOUR)); $this->assertSame($expected[2], $cron->getExpression(CronExpression::DAY)); @@ -70,109 +80,132 @@ public function testParsesCronScheduleWithAnySpaceCharsAsSeparators($schedule, a } /** - * Data provider for testParsesCronScheduleWithAnySpaceCharsAsSeparators + * Data provider for testParsesCronScheduleWithAnySpaceCharsAsSeparators. * * @return array */ - public static function scheduleWithDifferentSeparatorsProvider() + public static function scheduleWithDifferentSeparatorsProvider(): array { - return array( - array("*\t*\t*\t*\t*\t", array('*', '*', '*', '*', '*', '*')), - array("* * * * * ", array('*', '*', '*', '*', '*', '*')), - array("* \t * \t * \t * \t * \t", array('*', '*', '*', '*', '*', '*')), - array("*\t \t*\t \t*\t \t*\t \t*\t \t", array('*', '*', '*', '*', '*', '*')), - ); + return [ + ["*\t*\t*\t*\t*\t", ['*', '*', '*', '*', '*', '*']], + ['* * * * * ', ['*', '*', '*', '*', '*', '*']], + ["* \t * \t * \t * \t * \t", ['*', '*', '*', '*', '*', '*']], + ["*\t \t*\t \t*\t \t*\t \t*\t \t", ['*', '*', '*', '*', '*', '*']], + ]; } /** * @covers \Cron\CronExpression::__construct * @covers \Cron\CronExpression::setExpression * @covers \Cron\CronExpression::setPart - * @expectedException InvalidArgumentException */ - public function testInvalidCronsWillFail() + public function testInvalidCronsWillFail(): void { + $this->expectException(InvalidArgumentException::class); // Only four values - $cron = CronExpression::factory('* * * 1'); + $cron = new CronExpression('* * * 1'); } /** * @covers \Cron\CronExpression::setPart - * @expectedException InvalidArgumentException */ - public function testInvalidPartsWillFail() + public function testInvalidPartsWillFail(): void { + $this->expectException(InvalidArgumentException::class); // Only four values - $cron = CronExpression::factory('* * * * *'); + $cron = new CronExpression('* * * * *'); $cron->setPart(1, 'abc'); } /** - * Data provider for cron schedule + * Data provider for cron schedule. * * @return array */ - public function scheduleProvider() + public function scheduleProvider(): array { - return array( - array('*/2 */2 * * *', '2015-08-10 21:47:27', '2015-08-10 22:00:00', false), - array('* * * * *', '2015-08-10 21:50:37', '2015-08-10 21:50:00', true), - array('* 20,21,22 * * *', '2015-08-10 21:50:00', '2015-08-10 21:50:00', true), + return [ + ['*/2 */2 * * *', '2015-08-10 21:47:27', '2015-08-10 22:00:00', false], + ['* * * * *', '2015-08-10 21:50:37', '2015-08-10 21:50:00', true], + ['* 20,21,22 * * *', '2015-08-10 21:50:00', '2015-08-10 21:50:00', true], // Handles CSV values - array('* 20,22 * * *', '2015-08-10 21:50:00', '2015-08-10 22:00:00', false), + ['* 20,22 * * *', '2015-08-10 21:50:00', '2015-08-10 22:00:00', false], // CSV values can be complex - array('7-9 * */9 * *', '2015-08-10 22:02:33', '2015-08-10 22:07:00', false), + ['7-9 * */9 * *', '2015-08-10 22:02:33', '2015-08-10 22:07:00', false], // 15th minute, of the second hour, every 15 days, in January, every Friday - array('1 * * * 7', '2015-08-10 21:47:27', '2015-08-16 00:01:00', false), + ['1 * * * 7', '2015-08-10 21:47:27', '2015-08-16 00:01:00', false], // Test with exact times - array('47 21 * * *', strtotime('2015-08-10 21:47:30'), '2015-08-10 21:47:00', true), + ['47 21 * * *', strtotime('2015-08-10 21:47:30'), '2015-08-10 21:47:00', true], // Test Day of the week (issue #1) // According cron implementation, 0|7 = sunday, 1 => monday, etc - array('* * * * 0', strtotime('2011-06-15 23:09:00'), '2011-06-19 00:00:00', false), - array('* * * * 7', strtotime('2011-06-15 23:09:00'), '2011-06-19 00:00:00', false), - array('* * * * 1', strtotime('2011-06-15 23:09:00'), '2011-06-20 00:00:00', false), + ['* * * * 0', strtotime('2011-06-15 23:09:00'), '2011-06-19 00:00:00', false], + ['* * * * 7', strtotime('2011-06-15 23:09:00'), '2011-06-19 00:00:00', false], + ['* * * * 1', strtotime('2011-06-15 23:09:00'), '2011-06-20 00:00:00', false], // Should return the sunday date as 7 equals 0 - array('0 0 * * MON,SUN', strtotime('2011-06-15 23:09:00'), '2011-06-19 00:00:00', false), - array('0 0 * * 1,7', strtotime('2011-06-15 23:09:00'), '2011-06-19 00:00:00', false), - array('0 0 * * 0-4', strtotime('2011-06-15 23:09:00'), '2011-06-16 00:00:00', false), - array('0 0 * * 7-4', strtotime('2011-06-15 23:09:00'), '2011-06-16 00:00:00', false), - array('0 0 * * 4-7', strtotime('2011-06-15 23:09:00'), '2011-06-16 00:00:00', false), - array('0 0 * * 7-3', strtotime('2011-06-15 23:09:00'), '2011-06-19 00:00:00', false), - array('0 0 * * 3-7', strtotime('2011-06-15 23:09:00'), '2011-06-16 00:00:00', false), - array('0 0 * * 3-7', strtotime('2011-06-18 23:09:00'), '2011-06-19 00:00:00', false), + ['0 0 * * MON,SUN', strtotime('2011-06-15 23:09:00'), '2011-06-19 00:00:00', false], + ['0 0 * * 1,7', strtotime('2011-06-15 23:09:00'), '2011-06-19 00:00:00', false], + ['0 0 * * 0-4', strtotime('2011-06-15 23:09:00'), '2011-06-16 00:00:00', false], + ['0 0 * * 7-4', strtotime('2011-06-15 23:09:00'), '2011-06-16 00:00:00', false], + ['0 0 * * 4-7', strtotime('2011-06-15 23:09:00'), '2011-06-16 00:00:00', false], + ['0 0 * * 7-3', strtotime('2011-06-15 23:09:00'), '2011-06-19 00:00:00', false], + ['0 0 * * 3-7', strtotime('2011-06-15 23:09:00'), '2011-06-16 00:00:00', false], + ['0 0 * * 3-7', strtotime('2011-06-18 23:09:00'), '2011-06-19 00:00:00', false], // Test lists of values and ranges (Abhoryo) - array('0 0 * * 2-7', strtotime('2011-06-20 23:09:00'), '2011-06-21 00:00:00', false), - array('0 0 * * 2-7', strtotime('2011-06-18 23:09:00'), '2011-06-19 00:00:00', false), - array('0 0 * * 4-7', strtotime('2011-07-19 00:00:00'), '2011-07-21 00:00:00', false), + ['0 0 * * 2-7', strtotime('2011-06-20 23:09:00'), '2011-06-21 00:00:00', false], + ['0 0 * * 2-7', strtotime('2011-06-18 23:09:00'), '2011-06-19 00:00:00', false], + ['0 0 * * 4-7', strtotime('2011-07-19 00:00:00'), '2011-07-21 00:00:00', false], // Test increments of ranges - array('0-12/4 * * * *', strtotime('2011-06-20 12:04:00'), '2011-06-20 12:04:00', true), - array('4-59/2 * * * *', strtotime('2011-06-20 12:04:00'), '2011-06-20 12:04:00', true), - array('4-59/2 * * * *', strtotime('2011-06-20 12:06:00'), '2011-06-20 12:06:00', true), - array('4-59/3 * * * *', strtotime('2011-06-20 12:06:00'), '2011-06-20 12:07:00', false), + ['0-12/4 * * * *', strtotime('2011-06-20 12:04:00'), '2011-06-20 12:04:00', true], + ['4-59/2 * * * *', strtotime('2011-06-20 12:04:00'), '2011-06-20 12:04:00', true], + ['4-59/2 * * * *', strtotime('2011-06-20 12:06:00'), '2011-06-20 12:06:00', true], + ['4-59/3 * * * *', strtotime('2011-06-20 12:06:00'), '2011-06-20 12:07:00', false], // Test Day of the Week and the Day of the Month (issue #1) - array('0 0 1 1 0', strtotime('2011-06-15 23:09:00'), '2012-01-01 00:00:00', false), - array('0 0 1 JAN 0', strtotime('2011-06-15 23:09:00'), '2012-01-01 00:00:00', false), - array('0 0 1 * 0', strtotime('2011-06-15 23:09:00'), '2012-01-01 00:00:00', false), + ['0 0 1 1 0', strtotime('2011-06-15 23:09:00'), '2012-01-01 00:00:00', false], + ['0 0 1 JAN 0', strtotime('2011-06-15 23:09:00'), '2012-01-01 00:00:00', false], + ['0 0 1 * 0', strtotime('2011-06-15 23:09:00'), '2011-06-19 00:00:00', false], // Test the W day of the week modifier for day of the month field - array('0 0 2W * *', strtotime('2011-07-01 00:00:00'), '2011-07-01 00:00:00', true), - array('0 0 1W * *', strtotime('2011-05-01 00:00:00'), '2011-05-02 00:00:00', false), - array('0 0 1W * *', strtotime('2011-07-01 00:00:00'), '2011-07-01 00:00:00', true), - array('0 0 3W * *', strtotime('2011-07-01 00:00:00'), '2011-07-04 00:00:00', false), - array('0 0 16W * *', strtotime('2011-07-01 00:00:00'), '2011-07-15 00:00:00', false), - array('0 0 28W * *', strtotime('2011-07-01 00:00:00'), '2011-07-28 00:00:00', false), - array('0 0 30W * *', strtotime('2011-07-01 00:00:00'), '2011-07-29 00:00:00', false), - array('0 0 31W * *', strtotime('2011-07-01 00:00:00'), '2011-07-29 00:00:00', false), + ['0 0 2W * *', strtotime('2011-07-01 00:00:00'), '2011-07-01 00:00:00', true], + ['0 0 1W * *', strtotime('2011-05-01 00:00:00'), '2011-05-02 00:00:00', false], + ['0 0 1W * *', strtotime('2011-07-01 00:00:00'), '2011-07-01 00:00:00', true], + ['0 0 3W * *', strtotime('2011-07-01 00:00:00'), '2011-07-04 00:00:00', false], + ['0 0 16W * *', strtotime('2011-07-01 00:00:00'), '2011-07-15 00:00:00', false], + ['0 0 28W * *', strtotime('2011-07-01 00:00:00'), '2011-07-28 00:00:00', false], + ['0 0 30W * *', strtotime('2011-07-01 00:00:00'), '2011-07-29 00:00:00', false], + ['0 0 31W * *', strtotime('2011-07-01 00:00:00'), '2011-07-29 00:00:00', false], // Test the last weekday of a month - array('* * * * 5L', strtotime('2011-07-01 00:00:00'), '2011-07-29 00:00:00', false), - array('* * * * 6L', strtotime('2011-07-01 00:00:00'), '2011-07-30 00:00:00', false), - array('* * * * 7L', strtotime('2011-07-01 00:00:00'), '2011-07-31 00:00:00', false), - array('* * * * 1L', strtotime('2011-07-24 00:00:00'), '2011-07-25 00:00:00', false), - array('* * * 1 5L', strtotime('2011-12-25 00:00:00'), '2012-01-27 00:00:00', false), + ['* * * * 5L', strtotime('2011-07-01 00:00:00'), '2011-07-29 00:00:00', false], + ['* * * * 6L', strtotime('2011-07-01 00:00:00'), '2011-07-30 00:00:00', false], + ['* * * * 7L', strtotime('2011-07-01 00:00:00'), '2011-07-31 00:00:00', false], + ['* * * * 1L', strtotime('2011-07-24 00:00:00'), '2011-07-25 00:00:00', false], + ['* * * 1 5L', strtotime('2011-12-25 00:00:00'), '2012-01-27 00:00:00', false], // Test the hash symbol for the nth weekday of a given month - array('* * * * 5#2', strtotime('2011-07-01 00:00:00'), '2011-07-08 00:00:00', false), - array('* * * * 5#1', strtotime('2011-07-01 00:00:00'), '2011-07-01 00:00:00', true), - array('* * * * 3#4', strtotime('2011-07-01 00:00:00'), '2011-07-27 00:00:00', false), - ); + ['* * * * 5#2', strtotime('2011-07-01 00:00:00'), '2011-07-08 00:00:00', false], + ['* * * * 5#1', strtotime('2011-07-01 00:00:00'), '2011-07-01 00:00:00', true], + ['* * * * 3#4', strtotime('2011-07-01 00:00:00'), '2011-07-27 00:00:00', false], + + // Issue #7, documented example failed + ['3-59/15 6-12 */15 1 2-5', strtotime('2017-01-08 00:00:00'), '2017-01-10 06:03:00', false], + + // https://github.com/laravel/framework/commit/07d160ac3cc9764d5b429734ffce4fa311385403 + ['* * * * MON-FRI', strtotime('2017-01-08 00:00:00'), strtotime('2017-01-09 00:00:00'), false], + ['* * * * TUE', strtotime('2017-01-08 00:00:00'), strtotime('2017-01-10 00:00:00'), false], + + // Issue #60, make sure that casing is less relevant for shortcuts, months, and days + ['0 1 15 JUL mon,Wed,FRi', strtotime('2019-11-14 00:00:00'), strtotime('2020-07-01 01:00:00'), false], + ['0 1 15 jul mon,Wed,FRi', strtotime('2019-11-14 00:00:00'), strtotime('2020-07-01 01:00:00'), false], + ['@Weekly', strtotime('2019-11-14 00:00:00'), strtotime('2019-11-17 00:00:00'), false], + ['@WEEKLY', strtotime('2019-11-14 00:00:00'), strtotime('2019-11-17 00:00:00'), false], + ['@WeeklY', strtotime('2019-11-14 00:00:00'), strtotime('2019-11-17 00:00:00'), false], + + // Issue #76, DOW and DOM do not support ? + ['0 12 * * ?', strtotime('2020-08-20 00:00:00'), strtotime('2020-08-20 12:00:00'), false], + ['0 12 ? * *', strtotime('2020-08-20 00:00:00'), strtotime('2020-08-20 12:00:00'), false], + ['0-59/59 10 * * *', strtotime('2021-08-25 10:00:00'), strtotime('2021-08-25 10:00:00'), true], + ['0-59/59 10 * * *', strtotime('2021-08-25 09:00:00'), strtotime('2021-08-25 10:00:00'), false], + ['0-59/59 10 * * *', strtotime('2021-08-25 10:01:00'), strtotime('2021-08-25 10:59:00'), false], + ['0-59/65 10 * * *', strtotime('2021-08-25 10:01:00'), strtotime('2021-08-25 10:05:00'), false], + ['41-59/24 5 * * *', strtotime('2021-08-25 10:00:00'), strtotime('2021-08-26 05:41:00'), false], + ]; } /** @@ -185,72 +218,106 @@ public function scheduleProvider() * @covers \Cron\MonthField * @covers \Cron\CronExpression::getRunDate * @dataProvider scheduleProvider + * + * @param mixed $schedule + * @param mixed $relativeTime + * @param mixed $nextRun + * @param mixed $isDue */ - public function testDeterminesIfCronIsDue($schedule, $relativeTime, $nextRun, $isDue) + public function testDeterminesIfCronIsDue($schedule, $relativeTime, $nextRun, $isDue): void { - $relativeTimeString = is_int($relativeTime) ? date('Y-m-d H:i:s', $relativeTime) : $relativeTime; - // Test next run date - $cron = CronExpression::factory($schedule); - if (is_string($relativeTime)) { + $cron = new CronExpression($schedule); + if (\is_string($relativeTime)) { $relativeTime = new DateTime($relativeTime); - } elseif (is_int($relativeTime)) { + } elseif (\is_int($relativeTime)) { $relativeTime = date('Y-m-d H:i:s', $relativeTime); } + + $nextRunDate = new DateTime(); + if (\is_string($nextRun)) { + $nextRunDate = new DateTime($nextRun); + } elseif (\is_int($nextRun)) { + $nextRunDate = new DateTime(); + $nextRunDate->setTimestamp($nextRun); + } $this->assertSame($isDue, $cron->isDue($relativeTime)); $next = $cron->getNextRunDate($relativeTime, 0, true); - $this->assertEquals(new DateTime($nextRun), $next); + + $this->assertEquals($nextRunDate, $next); } /** * @covers \Cron\CronExpression::isDue */ - public function testIsDueHandlesDifferentDates() + public function testIsDueHandlesDifferentDates(): void { - $cron = CronExpression::factory('* * * * *'); + $cron = new CronExpression('* * * * *'); $this->assertTrue($cron->isDue()); $this->assertTrue($cron->isDue('now')); $this->assertTrue($cron->isDue(new DateTime('now'))); $this->assertTrue($cron->isDue(date('Y-m-d H:i'))); + $this->assertTrue($cron->isDue(new DateTimeImmutable('now'))); } /** * @covers \Cron\CronExpression::isDue */ - public function testIsDueHandlesDifferentTimezones() + public function testIsDueHandlesDifferentDefaultTimezones(): void { - $cron = CronExpression::factory('0 15 * * 3'); //Wednesday at 15:00 + $originalTimezone = date_default_timezone_get(); + $cron = new CronExpression('0 15 * * 3'); //Wednesday at 15:00 $date = '2014-01-01 15:00'; //Wednesday - $utc = new DateTimeZone('UTC'); - $amsterdam = new DateTimeZone('Europe/Amsterdam'); - $tokyo = new DateTimeZone('Asia/Tokyo'); date_default_timezone_set('UTC'); - $this->assertTrue($cron->isDue(new DateTime($date, $utc))); - $this->assertFalse($cron->isDue(new DateTime($date, $amsterdam))); - $this->assertFalse($cron->isDue(new DateTime($date, $tokyo))); + $this->assertTrue($cron->isDue(new DateTime($date), 'UTC')); + $this->assertFalse($cron->isDue(new DateTime($date), 'Europe/Amsterdam')); + $this->assertFalse($cron->isDue(new DateTime($date), 'Asia/Tokyo')); date_default_timezone_set('Europe/Amsterdam'); - $this->assertFalse($cron->isDue(new DateTime($date, $utc))); - $this->assertTrue($cron->isDue(new DateTime($date, $amsterdam))); - $this->assertFalse($cron->isDue(new DateTime($date, $tokyo))); + $this->assertFalse($cron->isDue(new DateTime($date), 'UTC')); + $this->assertTrue($cron->isDue(new DateTime($date), 'Europe/Amsterdam')); + $this->assertFalse($cron->isDue(new DateTime($date), 'Asia/Tokyo')); date_default_timezone_set('Asia/Tokyo'); - $this->assertFalse($cron->isDue(new DateTime($date, $utc))); - $this->assertFalse($cron->isDue(new DateTime($date, $amsterdam))); - $this->assertTrue($cron->isDue(new DateTime($date, $tokyo))); + $this->assertFalse($cron->isDue(new DateTime($date), 'UTC')); + $this->assertFalse($cron->isDue(new DateTime($date), 'Europe/Amsterdam')); + $this->assertTrue($cron->isDue(new DateTime($date), 'Asia/Tokyo')); + + date_default_timezone_set($originalTimezone); } - /** - * @covers Cron\CronExpression::isDue + /** + * @covers \Cron\CronExpression::isDue */ - public function testIsDueHandlesDifferentTimezonesAsArgument() + public function testIsDueHandlesDifferentSuppliedTimezones(): void { - $cron = CronExpression::factory('0 15 * * 3'); //Wednesday at 15:00 - $date = '2014-01-01 15:00'; //Wednesday - $utc = new \DateTimeZone('UTC'); + $cron = new CronExpression('0 15 * * 3'); //Wednesday at 15:00 + $date = '2014-01-01 15:00'; //Wednesday + + $this->assertTrue($cron->isDue(new DateTime($date, new DateTimeZone('UTC')), 'UTC')); + $this->assertFalse($cron->isDue(new DateTime($date, new DateTimeZone('UTC')), 'Europe/Amsterdam')); + $this->assertFalse($cron->isDue(new DateTime($date, new DateTimeZone('UTC')), 'Asia/Tokyo')); + + $this->assertFalse($cron->isDue(new DateTime($date, new DateTimeZone('Europe/Amsterdam')), 'UTC')); + $this->assertTrue($cron->isDue(new DateTime($date, new DateTimeZone('Europe/Amsterdam')), 'Europe/Amsterdam')); + $this->assertFalse($cron->isDue(new DateTime($date, new DateTimeZone('Europe/Amsterdam')), 'Asia/Tokyo')); + + $this->assertFalse($cron->isDue(new DateTime($date, new DateTimeZone('Asia/Tokyo')), 'UTC')); + $this->assertFalse($cron->isDue(new DateTime($date, new DateTimeZone('Asia/Tokyo')), 'Europe/Amsterdam')); + $this->assertTrue($cron->isDue(new DateTime($date, new DateTimeZone('Asia/Tokyo')), 'Asia/Tokyo')); + } + + /** + * @covers \Cron\CronExpression::isDue + */ + public function testIsDueHandlesDifferentTimezonesAsArgument(): void + { + $cron = new CronExpression('0 15 * * 3'); //Wednesday at 15:00 + $date = '2014-01-01 15:00'; //Wednesday + $utc = new \DateTimeZone('UTC'); $amsterdam = new \DateTimeZone('Europe/Amsterdam'); - $tokyo = new \DateTimeZone('Asia/Tokyo'); + $tokyo = new \DateTimeZone('Asia/Tokyo'); $this->assertTrue($cron->isDue(new DateTime($date, $utc), 'UTC')); $this->assertFalse($cron->isDue(new DateTime($date, $amsterdam), 'UTC')); $this->assertFalse($cron->isDue(new DateTime($date, $tokyo), 'UTC')); @@ -261,23 +328,62 @@ public function testIsDueHandlesDifferentTimezonesAsArgument() $this->assertFalse($cron->isDue(new DateTime($date, $amsterdam), 'Asia/Tokyo')); $this->assertTrue($cron->isDue(new DateTime($date, $tokyo), 'Asia/Tokyo')); } - + + /** + * @covers \Cron\CronExpression::isDue + */ + public function testRecognisesTimezonesAsPartOfDateTime(): void + { + $cron = new CronExpression('0 7 * * *'); + $tzCron = 'America/New_York'; + $tzServer = new \DateTimeZone('Europe/London'); + + $dtCurrent = \DateTime::createFromFormat('!Y-m-d H:i:s', '2017-10-17 10:00:00', $tzServer); + if (!$dtCurrent instanceof \DateTime) { + throw new InvalidArgumentException('invalid current date time'); + } + + $dtPrev = $cron->getPreviousRunDate($dtCurrent, 0, true, $tzCron); + $this->assertEquals('1508151600 : 2017-10-16T07:00:00-04:00 : America/New_York', $dtPrev->format('U \\: c \\: e')); + + $dtCurrent = \DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', '2017-10-17 10:00:00', $tzServer); + if (!$dtCurrent instanceof \DateTimeImmutable) { + throw new InvalidArgumentException('invalid current date time immutable'); + } + $dtPrev = $cron->getPreviousRunDate($dtCurrent, 0, true, $tzCron); + $this->assertEquals('1508151600 : 2017-10-16T07:00:00-04:00 : America/New_York', $dtPrev->format('U \\: c \\: e')); + + $dtCurrent = \DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', '2017-10-17 10:00:00', $tzServer); + if (!$dtCurrent instanceof \DateTimeImmutable) { + throw new InvalidArgumentException('invalid current date time immutable'); + } + $dtPrev = $cron->getPreviousRunDate($dtCurrent->format('c'), 0, true, $tzCron); + $this->assertEquals('1508151600 : 2017-10-16T07:00:00-04:00 : America/New_York', $dtPrev->format('U \\: c \\: e')); + + $dtCurrent = \DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', '2017-10-17 10:00:00', $tzServer); + if (!$dtCurrent instanceof \DateTimeImmutable) { + throw new InvalidArgumentException('invalid current date time immutable'); + } + $dtPrev = $cron->getPreviousRunDate($dtCurrent->format('\\@U'), 0, true, $tzCron); + $this->assertEquals('1508151600 : 2017-10-16T07:00:00-04:00 : America/New_York', $dtPrev->format('U \\: c \\: e')); + } + /** * @covers \Cron\CronExpression::getPreviousRunDate */ - public function testCanGetPreviousRunDates() + public function testCanGetPreviousRunDates(): void { - $cron = CronExpression::factory('* * * * *'); + $cron = new CronExpression('* * * * *'); $next = $cron->getNextRunDate('now'); $two = $cron->getNextRunDate('now', 1); $this->assertEquals($next, $cron->getPreviousRunDate($two)); - $cron = CronExpression::factory('* */2 * * *'); + $cron = new CronExpression('* */2 * * *'); $next = $cron->getNextRunDate('now'); $two = $cron->getNextRunDate('now', 1); $this->assertEquals($next, $cron->getPreviousRunDate($two)); - $cron = CronExpression::factory('* * * */2 *'); + $cron = new CronExpression('* * * */2 *'); $next = $cron->getNextRunDate('now'); $two = $cron->getNextRunDate('now', 1); $this->assertEquals($next, $cron->getPreviousRunDate($two)); @@ -286,26 +392,27 @@ public function testCanGetPreviousRunDates() /** * @covers \Cron\CronExpression::getMultipleRunDates */ - public function testProvidesMultipleRunDates() + public function testProvidesMultipleRunDates(): void { - $cron = CronExpression::factory('*/2 * * * *'); - $this->assertEquals(array( + $cron = new CronExpression('*/2 * * * *'); + $this->assertEquals([ new DateTime('2008-11-09 00:00:00'), new DateTime('2008-11-09 00:02:00'), new DateTime('2008-11-09 00:04:00'), - new DateTime('2008-11-09 00:06:00') - ), $cron->getMultipleRunDates(4, '2008-11-09 00:00:00', false, true)); + new DateTime('2008-11-09 00:06:00'), + ], $cron->getMultipleRunDates(4, '2008-11-09 00:00:00', false, true)); } /** * @covers \Cron\CronExpression::getMultipleRunDates * @covers \Cron\CronExpression::setMaxIterationCount */ - public function testProvidesMultipleRunDatesForTheFarFuture() { + public function testProvidesMultipleRunDatesForTheFarFuture(): void + { // Fails with the default 1000 iteration limit - $cron = CronExpression::factory('0 0 12 1 *'); + $cron = new CronExpression('0 0 12 1 *'); $cron->setMaxIterationCount(2000); - $this->assertEquals(array( + $this->assertEquals([ new DateTime('2016-01-12 00:00:00'), new DateTime('2017-01-12 00:00:00'), new DateTime('2018-01-12 00:00:00'), @@ -315,39 +422,63 @@ public function testProvidesMultipleRunDatesForTheFarFuture() { new DateTime('2022-01-12 00:00:00'), new DateTime('2023-01-12 00:00:00'), new DateTime('2024-01-12 00:00:00'), - ), $cron->getMultipleRunDates(9, '2015-04-28 00:00:00', false, true)); + ], $cron->getMultipleRunDates(9, '2015-04-28 00:00:00', false, true)); } /** * @covers \Cron\CronExpression */ - public function testCanIterateOverNextRuns() + public function testCanIterateOverNextRuns(): void { - $cron = CronExpression::factory('@weekly'); - $nextRun = $cron->getNextRunDate("2008-11-09 08:00:00"); - $this->assertEquals($nextRun, new DateTime("2008-11-16 00:00:00")); - - // true is cast to 1 - $nextRun = $cron->getNextRunDate("2008-11-09 00:00:00", true, true); - $this->assertEquals($nextRun, new DateTime("2008-11-16 00:00:00")); + $cron = new CronExpression('@weekly'); + $nextRun = $cron->getNextRunDate('2008-11-09 08:00:00'); + $this->assertEquals($nextRun, new DateTime('2008-11-16 00:00:00')); // You can iterate over them - $nextRun = $cron->getNextRunDate($cron->getNextRunDate("2008-11-09 00:00:00", 1, true), 1, true); - $this->assertEquals($nextRun, new DateTime("2008-11-23 00:00:00")); + $nextRun = $cron->getNextRunDate($cron->getNextRunDate('2008-11-09 00:00:00', 1, true), 1, true); + $this->assertEquals($nextRun, new DateTime('2008-11-23 00:00:00')); // You can skip more than one - $nextRun = $cron->getNextRunDate("2008-11-09 00:00:00", 2, true); - $this->assertEquals($nextRun, new DateTime("2008-11-23 00:00:00")); - $nextRun = $cron->getNextRunDate("2008-11-09 00:00:00", 3, true); - $this->assertEquals($nextRun, new DateTime("2008-11-30 00:00:00")); + $nextRun = $cron->getNextRunDate('2008-11-09 00:00:00', 2, true); + $this->assertEquals($nextRun, new DateTime('2008-11-23 00:00:00')); + $nextRun = $cron->getNextRunDate('2008-11-09 00:00:00', 3, true); + $this->assertEquals($nextRun, new DateTime('2008-11-30 00:00:00')); + } + + /** + * @covers \Cron\CronExpression::getRunDate + */ + public function testGetRunDateHandlesDifferentDates(): void + { + $cron = new CronExpression('@weekly'); + $date = new DateTime("2019-03-10 00:00:00"); + $this->assertEquals($date, $cron->getNextRunDate("2019-03-03 08:00:00")); + $this->assertEquals($date, $cron->getNextRunDate(new DateTime("2019-03-03 08:00:00"))); + $this->assertEquals($date, $cron->getNextRunDate(new DateTimeImmutable("2019-03-03 08:00:00"))); + } + + /** + * If both day of month and day of week are set in an expression, + * we have to return a date which among dates matching either of two criteria is closest to the current date. + * + * Previously the earliest of dates was always returned, which was incorrect for previous run date. + * + * @covers \Cron\CronExpression::getRunDate + */ + public function testGetRunDateHandlesSimultaneousDayOfMonthAndDayOfWeek(): void + { + $cron = new CronExpression('0 0 13 * 3'); + $date = new DateTime("2021-07-15 00:00:00"); + $this->assertEquals(new DateTime("2021-07-21 00:00:00"), $cron->getNextRunDate($date)); + $this->assertEquals(new DateTime("2021-07-14 00:00:00"), $cron->getPreviousRunDate($date)); } /** * @covers \Cron\CronExpression::getRunDate */ - public function testSkipsCurrentDateByDefault() + public function testSkipsCurrentDateByDefault(): void { - $cron = CronExpression::factory('* * * * *'); + $cron = new CronExpression('* * * * *'); $current = new DateTime('now'); $next = $cron->getNextRunDate($current); $nextPrev = $cron->getPreviousRunDate($next); @@ -358,9 +489,9 @@ public function testSkipsCurrentDateByDefault() * @covers \Cron\CronExpression::getRunDate * @ticket 7 */ - public function testStripsForSeconds() + public function testStripsForSeconds(): void { - $cron = CronExpression::factory('* * * * *'); + $cron = new CronExpression('* * * * *'); $current = new DateTime('2011-09-27 10:10:54'); $this->assertSame('2011-09-27 10:11:00', $cron->getNextRunDate($current)->format('Y-m-d H:i:s')); } @@ -368,15 +499,15 @@ public function testStripsForSeconds() /** * @covers \Cron\CronExpression::getRunDate */ - public function testFixesPhpBugInDateIntervalMonth() + public function testFixesPhpBugInDateIntervalMonth(): void { - $cron = CronExpression::factory('0 0 27 JAN *'); + $cron = new CronExpression('0 0 27 JAN *'); $this->assertSame('2011-01-27 00:00:00', $cron->getPreviousRunDate('2011-08-22 00:00:00')->format('Y-m-d H:i:s')); } - public function testIssue29() + public function testIssue29(): void { - $cron = CronExpression::factory('@weekly'); + $cron = new CronExpression('@weekly'); $this->assertSame( '2013-03-10 00:00:00', $cron->getPreviousRunDate('2013-03-17 00:00:00')->format('Y-m-d H:i:s') @@ -386,18 +517,19 @@ public function testIssue29() /** * @see https://github.com/mtdowling/cron-expression/issues/20 */ - public function testIssue20() { - $e = CronExpression::factory('* * * * MON#1'); + public function testIssue20(): void + { + $e = new CronExpression('* * * * MON#1'); $this->assertTrue($e->isDue(new DateTime('2014-04-07 00:00:00'))); $this->assertFalse($e->isDue(new DateTime('2014-04-14 00:00:00'))); $this->assertFalse($e->isDue(new DateTime('2014-04-21 00:00:00'))); - $e = CronExpression::factory('* * * * SAT#2'); + $e->setExpression('* * * * SAT#2'); $this->assertFalse($e->isDue(new DateTime('2014-04-05 00:00:00'))); $this->assertTrue($e->isDue(new DateTime('2014-04-12 00:00:00'))); $this->assertFalse($e->isDue(new DateTime('2014-04-19 00:00:00'))); - $e = CronExpression::factory('* * * * SUN#3'); + $e->setExpression('* * * * SUN#3'); $this->assertFalse($e->isDue(new DateTime('2014-04-13 00:00:00'))); $this->assertTrue($e->isDue(new DateTime('2014-04-20 00:00:00'))); $this->assertFalse($e->isDue(new DateTime('2014-04-27 00:00:00'))); @@ -406,23 +538,22 @@ public function testIssue20() { /** * @covers \Cron\CronExpression::getRunDate */ - public function testKeepOriginalTime() + public function testKeepOriginalTime(): void { - $now = new \DateTime; + $now = new \DateTime(); $strNow = $now->format(DateTime::ISO8601); - $cron = CronExpression::factory('0 0 * * *'); + $cron = new CronExpression('0 0 * * *'); $cron->getPreviousRunDate($now); $this->assertSame($strNow, $now->format(DateTime::ISO8601)); } /** * @covers \Cron\CronExpression::__construct - * @covers \Cron\CronExpression::factory * @covers \Cron\CronExpression::isValidExpression * @covers \Cron\CronExpression::setExpression * @covers \Cron\CronExpression::setPart */ - public function testValidationWorks() + public function testValidationWorks(): void { // Invalid. Only four values $this->assertFalse(CronExpression::isValidExpression('* * * 1')); @@ -430,15 +561,279 @@ public function testValidationWorks() $this->assertTrue(CronExpression::isValidExpression('* * * * 1')); // Issue #156, 13 is an invalid month - $this->assertFalse(CronExpression::isValidExpression("* * * 13 * ")); + $this->assertFalse(CronExpression::isValidExpression('* * * 13 * ')); // Issue #155, 90 is an invalid second $this->assertFalse(CronExpression::isValidExpression('90 * * * *')); // Issue #154, 24 is an invalid hour - $this->assertFalse(CronExpression::isValidExpression("0 24 1 12 0")); + $this->assertFalse(CronExpression::isValidExpression('0 24 1 12 0')); // Issue #125, this is just all sorts of wrong $this->assertFalse(CronExpression::isValidExpression('990 14 * * mon-fri0345345')); + + // Issue #137, multiple question marks are not allowed + $this->assertFalse(CronExpression::isValidExpression('0 8 ? * ?')); + // Question marks are only allowed in dom and dow part + $this->assertFalse(CronExpression::isValidExpression('? * * * *')); + $this->assertFalse(CronExpression::isValidExpression('* ? * * *')); + $this->assertFalse(CronExpression::isValidExpression('* * * ? *')); + + // see https://github.com/dragonmantank/cron-expression/issues/5 + $this->assertTrue(CronExpression::isValidExpression('2,17,35,47 5-7,11-13 * * *')); + } + + /** + * Makes sure that 00 is considered a valid value for 0-based fields + * cronie allows numbers with a leading 0, so adding support for this as well. + * + * @see https://github.com/dragonmantank/cron-expression/issues/12 + */ + public function testDoubleZeroIsValid(): void + { + $this->assertTrue(CronExpression::isValidExpression('00 * * * *')); + $this->assertTrue(CronExpression::isValidExpression('01 * * * *')); + $this->assertTrue(CronExpression::isValidExpression('* 00 * * *')); + $this->assertTrue(CronExpression::isValidExpression('* 01 * * *')); + + $e = new CronExpression('00 * * * *'); + $this->assertTrue($e->isDue(new DateTime('2014-04-07 00:00:00'))); + $e->setExpression('01 * * * *'); + $this->assertTrue($e->isDue(new DateTime('2014-04-07 00:01:00'))); + + $e->setExpression('* 00 * * *'); + $this->assertTrue($e->isDue(new DateTime('2014-04-07 00:00:00'))); + $e->setExpression('* 01 * * *'); + $this->assertTrue($e->isDue(new DateTime('2014-04-07 01:00:00'))); + } + + /** + * Ranges with large steps should "wrap around" to the appropriate value + * cronie allows for steps that are larger than the range of a field, with it wrapping around like a ring buffer. We + * should do the same. + * + * @see https://github.com/dragonmantank/cron-expression/issues/6 + */ + public function testRangesWrapAroundWithLargeSteps(): void + { + $f = new MonthField(); + $this->assertTrue($f->validate('*/123')); + $this->assertSame([4], $f->getRangeForExpression('*/123', 12)); + + $e = new CronExpression('* * * */123 *'); + $this->assertTrue($e->isDue(new DateTime('2014-04-07 00:00:00'))); + + $nextRunDate = $e->getNextRunDate(new DateTime('2014-04-07 00:00:00')); + $this->assertSame('2014-04-07 00:01:00', $nextRunDate->format('Y-m-d H:i:s')); + + $nextRunDate = $e->getNextRunDate(new DateTime('2014-05-07 00:00:00')); + $this->assertSame('2015-04-01 00:00:00', $nextRunDate->format('Y-m-d H:i:s')); + } + + /** + * When there is an issue with a field, we should report the human readable position. + * + * @see https://github.com/dragonmantank/cron-expression/issues/29 + */ + public function testFieldPositionIsHumanAdjusted(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('6 is not a valid position'); + + $e = new CronExpression('0 * * * * ? *'); + } + + /** + * @see https://github.com/dragonmantank/cron-expression/issues/35 + */ + public function testMakeDayOfWeekAnOrSometimes(): void + { + $cron = new CronExpression('30 0 1 * 1'); + $runs = $cron->getMultipleRunDates(5, date("2019-10-10 23:20:00"), false, true); + + $this->assertSame("2019-10-14 00:30:00", $runs[0]->format('Y-m-d H:i:s')); + $this->assertSame("2019-10-21 00:30:00", $runs[1]->format('Y-m-d H:i:s')); + $this->assertSame("2019-10-28 00:30:00", $runs[2]->format('Y-m-d H:i:s')); + $this->assertSame("2019-11-01 00:30:00", $runs[3]->format('Y-m-d H:i:s')); + $this->assertSame("2019-11-04 00:30:00", $runs[4]->format('Y-m-d H:i:s')); + } + + /** + * Make sure that getNextRunDate() does not add arbitrary minutes + * + * @see https://github.com/mtdowling/cron-expression/issues/152 + */ + public function testNextRunDateShouldNotAddMinutes(): void + { + $e = new CronExpression('* 19 * * *'); + $tz = new \DateTimeZone("Europe/London"); + $dt = new \DateTimeImmutable("2021-05-31 18:15:00", $tz); + $nextRunDate = $e->getNextRunDate($dt); + + $this->assertSame("00", $nextRunDate->format("i")); + } + + /** + * Tests the getParts function. + */ + public function testGetParts(): void + { + $e = CronExpression::factory('0 22 * * 1-5'); + $parts = $e->getParts(); + + $this->assertSame('0', $parts[0]); + $this->assertSame('22', $parts[1]); + $this->assertSame('*', $parts[2]); + $this->assertSame('*', $parts[3]); + $this->assertSame('1-5', $parts[4]); + } + + public function testBerlinShouldAdvanceProperlyOverDST() + { + $e = new CronExpression('0 0 1 * *'); + $expected = new \DateTime('2022-11-01 00:00:00', new \DateTimeZone('Europe/Berlin')); + $next = $e->getNextRunDate(new \DateTime('2022-10-30', new \DateTimeZone('Europe/Berlin'))); + $this->assertEquals($expected, $next); + } + + /** + * Helps validate additional test cases that were failing as part of #131's fix + * + * @see https://github.com/dragonmantank/cron-expression/issues/131 + */ + public function testIssue131() + { + $e = new CronExpression('* * * * 2'); + $expected = new \DateTime('2020-10-27 00:00:00', new \DateTimeZone('Europe/Berlin')); + $next = $e->getNextRunDate(new DateTime('2020-10-23 15:31:45', new \DateTimeZone('Europe/Berlin'))); + $this->assertEquals($expected, $next); + + $expected = new \DateTime('2020-10-20 23:59:00', new \DateTimeZone('Europe/Berlin')); + $prev = $e->getPreviousRunDate(new DateTime('2020-10-23 15:31:45', new \DateTimeZone('Europe/Berlin'))); + $this->assertEquals($expected, $prev); + + $e = new CronExpression('15 1 1 9,11 *'); + $expected = new \DateTime('2022-09-01 01:15:00'); + $next = $e->getNextRunDate(new \DateTime('2022-08-20 03:44:02')); + $this->assertEquals($expected, $next); + + $expected = new \DateTime('2021-11-01 01:15:00'); + $prev = $e->getPreviousRunDate(new \DateTime('2022-08-20 03:44:02')); + $this->assertEquals($expected, $prev); + } + + public function testIssue128() + { + $e = new CronExpression('0 20 L 6,12 ?'); + $expected = new \DateTime('2022-12-31 20:00:00'); + $next = $e->getNextRunDate(new \DateTime('2022-08-20 03:44:02')); + $this->assertEquals($expected, $next); + + $expected = new \DateTime('2023-12-31 20:00:00'); + $next = $e->getNextRunDate(new \DateTime('2022-08-20 03:44:02'), 2); + $this->assertEquals($expected, $next); + + $expected = new \DateTime('2022-06-30 20:00:00'); + $prev = $e->getPreviousRunDate(new \DateTime('2022-08-20 03:44:02')); + $this->assertEquals($expected, $prev); + + $expected = new \DateTime('2021-12-31 20:00:00'); + $prev = $e->getPreviousRunDate(new \DateTime('2022-08-20 03:44:02'), 1); + $this->assertEquals($expected, $prev); + + $e = new CronExpression('0 20 L 6,12 0-6'); + $expected = new \DateTime('2022-12-01 20:00:00'); + $next = $e->getNextRunDate(new \DateTime('2022-08-20 03:44:02')); + $this->assertEquals($expected, $next); + + $e = new CronExpression('0 20 L 6,12 0-6'); + $expected = new \DateTime('2022-12-02 20:00:00'); + $next = $e->getNextRunDate(new \DateTime('2022-08-20 03:44:02'), 1); + $this->assertEquals($expected, $next); + + $e = new CronExpression('0 20 L 6,12 0-6'); + $expected = new \DateTime('2022-12-03 20:00:00'); + $next = $e->getNextRunDate(new \DateTime('2022-08-20 03:44:02'), 2); + $this->assertEquals($expected, $next); + + $e = new CronExpression('0 20 * 6,12 *'); + $expected = new \DateTime('2022-12-01 20:00:00'); + $next = $e->getNextRunDate(new \DateTime('2022-08-20 03:44:02')); + $this->assertEquals($expected, $next); + + $e = new CronExpression('0 20 * 6,12 *'); + $expected = new \DateTime('2022-12-06 20:00:00'); + $next = $e->getNextRunDate(new \DateTime('2022-08-20 03:44:02'), 5); + $this->assertEquals($expected, $next); + } + + public function testItCanRegisterAnValidExpression(): void + { + CronExpression::registerAlias('@every', '* * * * *'); + + self::assertCount(8, CronExpression::getAliases()); + self::assertArrayHasKey('@every', CronExpression::getAliases()); + self::assertTrue(CronExpression::supportsAlias('@every')); + self::assertEquals(new CronExpression('@every'), new CronExpression('* * * * *')); + + self::assertTrue(CronExpression::unregisterAlias('@every')); + self::assertFalse(CronExpression::unregisterAlias('@every')); + + self::assertCount(7, CronExpression::getAliases()); + self::assertArrayNotHasKey('@every', CronExpression::getAliases()); + self::assertFalse(CronExpression::supportsAlias('@every')); + + $this->expectException(LogicException::class); + new CronExpression('@every'); + } + + public function testItWillFailToRegisterAnInvalidExpression(): void + { + $this->expectException(LogicException::class); + + CronExpression::registerAlias('@every', 'foobar'); + } + + public function testItWillFailToRegisterAnInvalidName(): void + { + $this->expectException(LogicException::class); + + CronExpression::registerAlias('every', '* * * * *'); + } + + public function testItWillFailToRegisterAnInvalidName2(): void + { + $this->expectException(LogicException::class); + + CronExpression::registerAlias('@évery', '* * * * *'); + } + + public function testItWillFailToRegisterAValidNameTwice(): void + { + CronExpression::registerAlias('@Ev_eR_y', '* * * * *'); + + $this->expectException(LogicException::class); + CronExpression::registerAlias('@eV_Er_Y', '2 2 2 2 2'); + } + + public function testItWillFailToUnregisterADefaultExpression(): void + { + $this->expectException(LogicException::class); + + CronExpression::unregisterAlias('@daily'); + } + + public function testIssue134ForeachInvalidArgumentOnHours() + { + $cron = new CronExpression('0 0 1 1 *'); + $prev = $cron->getPreviousRunDate(new \DateTimeImmutable('2021-09-07T09:36:00Z')); + $this->assertEquals(new \DateTime('2021-01-01 00:00:00'), $prev); + } + + public function testIssue151ExpressionSupportLW() + { + $cron = new CronExpression('0 10 LW * *'); + $this->assertTrue($cron->isDue(new \DateTimeImmutable('2023-08-31 10:00:00'))); + $this->assertFalse($cron->isDue(new \DateTimeImmutable('2023-08-30 10:00:00'))); } } diff --git a/tests/Cron/DayOfMonthFieldTest.php b/tests/Cron/DayOfMonthFieldTest.php index b728fe98..c4f3bb9e 100644 --- a/tests/Cron/DayOfMonthFieldTest.php +++ b/tests/Cron/DayOfMonthFieldTest.php @@ -1,9 +1,12 @@ assertTrue($f->validate('1')); $this->assertTrue($f->validate('*')); $this->assertTrue($f->validate('L')); $this->assertTrue($f->validate('5W')); + $this->assertTrue($f->validate('?')); + $this->assertTrue($f->validate('01')); $this->assertFalse($f->validate('5W,L')); $this->assertFalse($f->validate('1.')); } @@ -28,16 +33,17 @@ public function testValidatesField() /** * @covers \Cron\DayOfMonthField::isSatisfiedBy */ - public function testChecksIfSatisfied() + public function testChecksIfSatisfied(): void { $f = new DayOfMonthField(); - $this->assertTrue($f->isSatisfiedBy(new DateTime(), '?')); + $this->assertTrue($f->isSatisfiedBy(new DateTime(), '?', false)); + $this->assertTrue($f->isSatisfiedBy(new DateTimeImmutable(), '?', false)); } /** * @covers \Cron\DayOfMonthField::increment */ - public function testIncrementsDate() + public function testIncrementsDate(): void { $d = new DateTime('2011-03-15 11:15:00'); $f = new DayOfMonthField(); @@ -49,15 +55,70 @@ public function testIncrementsDate() $this->assertSame('2011-03-14 23:59:00', $d->format('Y-m-d H:i:s')); } + /** + * @covers \Cron\DayOfMonthField::increment + */ + public function testIncrementsDateTimeImmutable(): void + { + $d = new DateTimeImmutable('2011-03-15 11:15:00'); + $f = new DayOfMonthField(); + $f->increment($d); + $this->assertSame('2011-03-16 00:00:00', $d->format('Y-m-d H:i:s')); + } + /** * Day of the month cannot accept a 0 value, it must be between 1 and 31 - * See Github issue #120 + * See Github issue #120. * * @since 2017-01-22 */ - public function testDoesNotAccept0Date() + public function testDoesNotAccept0Date(): void + { + $f = new DayOfMonthField(); + $this->assertFalse($f->validate('0')); + } + + /** + * @covers \Cron\MinutesField::increment + */ + public function testIncrementAcrossDstChangeBerlin(): void + { + $tz = new \DateTimeZone("Europe/Berlin"); + $d = \DateTimeImmutable::createFromFormat("!Y-m-d H:i:s", "2021-03-28 01:59:00", $tz); + $f = new DayOfMonthField(); + $f->increment($d); + $this->assertSame("2021-03-29 00:00:00", $d->format("Y-m-d H:i:s")); + + $f->increment($d, true); + $this->assertSame("2021-03-28 23:59:00", $d->format("Y-m-d H:i:s")); + $f->increment($d, true); + $this->assertSame("2021-03-27 23:59:00", $d->format("Y-m-d H:i:s")); + } + + /** + * @covers \Cron\MinutesField::increment + */ + public function testIncrementAcrossDstChangeLondon(): void + { + $tz = new \DateTimeZone("Europe/London"); + $d = \DateTimeImmutable::createFromFormat("!Y-m-d H:i:s", "2021-03-28 00:59:00", $tz); + $f = new DayOfMonthField(); + $f->increment($d); + $this->assertSame("2021-03-29 00:00:00", $d->format("Y-m-d H:i:s")); + $f->increment($d); + $this->assertSame("2021-03-30 00:00:00", $d->format("Y-m-d H:i:s")); + + $f->increment($d, true); + $this->assertSame("2021-03-29 23:59:00", $d->format("Y-m-d H:i:s")); + $f->increment($d, true); + $this->assertSame("2021-03-28 23:59:00", $d->format("Y-m-d H:i:s")); + $f->increment($d, true); + $this->assertSame("2021-03-27 23:59:00", $d->format("Y-m-d H:i:s")); + } + + public function testIssue151DOMFieldSupportLW() { $f = new DayOfMonthField(); - $this->assertFalse($f->validate(0)); + $this->assertTrue($f->validate('LW')); } } diff --git a/tests/Cron/DayOfWeekFieldTest.php b/tests/Cron/DayOfWeekFieldTest.php index f8b02320..883c031e 100644 --- a/tests/Cron/DayOfWeekFieldTest.php +++ b/tests/Cron/DayOfWeekFieldTest.php @@ -1,9 +1,13 @@ assertTrue($f->validate('1')); + $this->assertTrue($f->validate('01')); + $this->assertTrue($f->validate('00')); $this->assertTrue($f->validate('*')); + $this->assertTrue($f->validate('?')); $this->assertFalse($f->validate('*/3,1,1-12')); $this->assertTrue($f->validate('SUN-2')); $this->assertFalse($f->validate('1.')); @@ -27,16 +34,17 @@ public function testValidatesField() /** * @covers \Cron\DayOfWeekField::isSatisfiedBy */ - public function testChecksIfSatisfied() + public function testChecksIfSatisfied(): void { $f = new DayOfWeekField(); - $this->assertTrue($f->isSatisfiedBy(new DateTime(), '?')); + $this->assertTrue($f->isSatisfiedBy(new DateTime(), '?', false)); + $this->assertTrue($f->isSatisfiedBy(new DateTimeImmutable(), '?', false)); } /** * @covers \Cron\DayOfWeekField::increment */ - public function testIncrementsDate() + public function testIncrementsDate(): void { $d = new DateTime('2011-03-15 11:15:00'); $f = new DayOfWeekField(); @@ -48,32 +56,43 @@ public function testIncrementsDate() $this->assertSame('2011-03-14 23:59:00', $d->format('Y-m-d H:i:s')); } + /** + * @covers \Cron\DayOfWeekField::increment + */ + public function testIncrementsDateTimeImmutable(): void + { + $d = new DateTimeImmutable('2011-03-15 11:15:00'); + $f = new DayOfWeekField(); + $f->increment($d); + $this->assertSame('2011-03-16 00:00:00', $d->format('Y-m-d H:i:s')); + } + /** * @covers \Cron\DayOfWeekField::isSatisfiedBy - * @expectedException InvalidArgumentException - * @expectedExceptionMessage Weekday must be a value between 0 and 7. 12 given */ - public function testValidatesHashValueWeekday() + public function testValidatesHashValueWeekday(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Weekday must be a value between 0 and 7. 12 given'); $f = new DayOfWeekField(); - $this->assertTrue($f->isSatisfiedBy(new DateTime(), '12#1')); + $this->assertTrue($f->isSatisfiedBy(new DateTime(), '12#1', false)); } /** * @covers \Cron\DayOfWeekField::isSatisfiedBy - * @expectedException InvalidArgumentException - * @expectedExceptionMessage There are never more than 5 or less than 1 of a given weekday in a month */ - public function testValidatesHashValueNth() + public function testValidatesHashValueNth(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('There are never more than 5 or less than 1 of a given weekday in a month'); $f = new DayOfWeekField(); - $this->assertTrue($f->isSatisfiedBy(new DateTime(), '3#6')); + $this->assertTrue($f->isSatisfiedBy(new DateTime(), '3#6', false)); } /** * @covers \Cron\DayOfWeekField::validate */ - public function testValidateWeekendHash() + public function testValidateWeekendHash(): void { $f = new DayOfWeekField(); $this->assertTrue($f->validate('MON#1')); @@ -89,22 +108,35 @@ public function testValidateWeekendHash() /** * @covers \Cron\DayOfWeekField::isSatisfiedBy */ - public function testHandlesZeroAndSevenDayOfTheWeekValues() + public function testHandlesZeroAndSevenDayOfTheWeekValues(): void { $f = new DayOfWeekField(); - $this->assertTrue($f->isSatisfiedBy(new DateTime('2011-09-04 00:00:00'), '0-2')); - $this->assertTrue($f->isSatisfiedBy(new DateTime('2011-09-04 00:00:00'), '6-0')); + $this->assertTrue($f->isSatisfiedBy(new DateTime('2011-09-04 00:00:00'), '0-2', false)); + $this->assertTrue($f->isSatisfiedBy(new DateTime('2011-09-04 00:00:00'), '6-0', false)); + + $this->assertTrue($f->isSatisfiedBy(new DateTime('2014-04-20 00:00:00'), 'SUN', false)); + $this->assertTrue($f->isSatisfiedBy(new DateTime('2014-04-20 00:00:00'), 'SUN#3', false)); + $this->assertTrue($f->isSatisfiedBy(new DateTime('2014-04-20 00:00:00'), '0#3', false)); + $this->assertTrue($f->isSatisfiedBy(new DateTime('2014-04-20 00:00:00'), '7#3', false)); + } - $this->assertTrue($f->isSatisfiedBy(new DateTime('2014-04-20 00:00:00'), 'SUN')); - $this->assertTrue($f->isSatisfiedBy(new DateTime('2014-04-20 00:00:00'), 'SUN#3')); - $this->assertTrue($f->isSatisfiedBy(new DateTime('2014-04-20 00:00:00'), '0#3')); - $this->assertTrue($f->isSatisfiedBy(new DateTime('2014-04-20 00:00:00'), '7#3')); + /** + * @covers \Cron\DayOfWeekField::isSatisfiedBy + */ + public function testHandlesLastWeekdayOfTheMonth(): void + { + $f = new DayOfWeekField(); + $this->assertTrue($f->isSatisfiedBy(new DateTime('2018-12-28 00:00:00'), 'FRIL', false)); + $this->assertTrue($f->isSatisfiedBy(new DateTime('2018-12-28 00:00:00'), '5L', false)); + $this->assertFalse($f->isSatisfiedBy(new DateTime('2018-12-21 00:00:00'), 'FRIL', false)); + $this->assertFalse($f->isSatisfiedBy(new DateTime('2018-12-21 00:00:00'), '5L', false)); } /** * @see https://github.com/mtdowling/cron-expression/issues/47 */ - public function testIssue47() { + public function testIssue47(): void + { $f = new DayOfWeekField(); $this->assertFalse($f->validate('mon,')); $this->assertFalse($f->validate('mon-')); @@ -114,4 +146,30 @@ public function testIssue47() { $this->assertFalse($f->validate('*-')); $this->assertFalse($f->validate(',-')); } + + /** + * @see https://github.com/laravel/framework/commit/07d160ac3cc9764d5b429734ffce4fa311385403 + */ + public function testLiteralsExpandProperly(): void + { + $f = new DayOfWeekField(); + $this->assertTrue($f->validate('MON-FRI')); + $this->assertSame([1, 2, 3, 4, 5], $f->getRangeForExpression('MON-FRI', 7)); + } + + /** + * Incoming literals should ignore case + * + * @author Chris Tankersley assertTrue($f->validate('MON')); + $this->assertTrue($f->validate('Mon')); + $this->assertTrue($f->validate('mon')); + $this->assertTrue($f->validate('Mon,Wed,Fri')); + } } diff --git a/tests/Cron/DaylightSavingsTest.php b/tests/Cron/DaylightSavingsTest.php new file mode 100644 index 00000000..7a8c6606 --- /dev/null +++ b/tests/Cron/DaylightSavingsTest.php @@ -0,0 +1,456 @@ +createDateTimeExactly("2021-03-28 02:00+01:00", $tz); + + $dtCurrent = \DateTimeImmutable::createFromFormat("!Y-m-d H:i:s", "2021-03-28 14:55:03", $tz); + $dtActual = $cron->getPreviousRunDate($dtCurrent, 0, false, $tz->getName()); + $this->assertEquals($dtExpected, $dtActual); + + $dtCurrent = $this->createDateTimeExactly("2021-04-21 00:00+01:00", $tz); + $dtActual = $cron->getPreviousRunDate($dtCurrent, 3, true, $tz->getName()); + $this->assertEquals($dtExpected, $dtActual); + } + + public function testIssue112(): void + { + $expression = "15 2 * * 0"; + $cron = new CronExpression($expression); + $tz = new \DateTimeZone("America/Winnipeg"); + + $dtCurrent = $this->createDateTimeExactly("2021-03-08 08:15-06:00", $tz); + $dtExpected = $this->createDateTimeExactly("2021-03-14 03:15-05:00", $tz); + $this->assertEquals($dtExpected, $cron->getNextRunDate($dtCurrent)); + } + + /** + * Create a DateTimeImmutable that represents the given exact moment in time. + * This is a bit finicky because DateTime likes to override the timezone with the offset even when it's valid + * and in some cases necessary during DST changes. + * Assertions verify no unexpected behavior changes in PHP. + */ + protected function createDateTimeExactly($dtString, \DateTimeZone $timezone) + { + $dt = \DateTimeImmutable::createFromFormat("!Y-m-d H:iO", $dtString, $timezone); + $dt = $dt->setTimezone($timezone); + $this->assertEquals($dtString, $dt->format("Y-m-d H:iP")); + $this->assertEquals($timezone->getName(), $dt->format("e")); + return $dt; + } + + public function testOffsetIncrementsNextRunDate(): void + { + $tz = new \DateTimeZone("Europe/London"); + $cron = new CronExpression("0 1 * * 0"); + + $dtCurrent = $this->createDateTimeExactly("2021-03-21 00:00+00:00", $tz); + $dtExpected = $this->createDateTimeExactly("2021-03-21 01:00+00:00", $tz); + $this->assertEquals($dtExpected, $cron->getNextRunDate($dtCurrent, 0, false, $tz->getName())); + + $dtCurrent = $this->createDateTimeExactly("2021-03-21 02:00+00:00", $tz); + $dtExpected = $this->createDateTimeExactly("2021-03-28 02:00+01:00", $tz); + $this->assertEquals($dtExpected, $cron->getNextRunDate($dtCurrent, 0, false, $tz->getName())); + + $dtCurrent = $this->createDateTimeExactly("2021-03-28 00:00+00:00", $tz); + $dtExpected = $this->createDateTimeExactly("2021-03-28 02:00+01:00", $tz); + $this->assertEquals($dtExpected, $cron->getNextRunDate($dtCurrent, 0, false, $tz->getName())); + + $dtCurrent = $this->createDateTimeExactly("2021-03-28 02:00+01:00", $tz); + $dtExpected = $this->createDateTimeExactly("2021-03-28 02:00+01:00", $tz); + $this->assertEquals($dtExpected, $cron->getNextRunDate($dtCurrent, 0, true, $tz->getName())); + + $dtCurrent = $this->createDateTimeExactly("2021-03-28 02:00+01:00", $tz); + $dtExpected = $this->createDateTimeExactly("2021-04-04 01:00+01:00", $tz); + $this->assertEquals($dtExpected, $cron->getNextRunDate($dtCurrent, 0, false, $tz->getName())); + + $dtCurrent = $this->createDateTimeExactly("2021-03-28 02:05+01:00", $tz); + $dtExpected = $this->createDateTimeExactly("2021-04-04 01:00+01:00", $tz); + $this->assertEquals($dtExpected, $cron->getNextRunDate($dtCurrent, 0, false, $tz->getName())); + } + + public function testOffsetIncrementsPreviousRunDate(): void + { + $tz = new \DateTimeZone("Europe/London"); + $cron = new CronExpression("0 1 * * 0"); + + $dtCurrent = $this->createDateTimeExactly("2021-04-04 02:00+01:00", $tz); + $dtExpected = $this->createDateTimeExactly("2021-04-04 01:00+01:00", $tz); + $this->assertEquals($dtExpected, $cron->getPreviousRunDate($dtCurrent, 0, false, $tz->getName())); + + $dtCurrent = $this->createDateTimeExactly("2021-04-04 00:00+01:00", $tz); + $dtExpected = $this->createDateTimeExactly("2021-03-28 02:00+01:00", $tz); + $this->assertEquals($dtExpected, $cron->getPreviousRunDate($dtCurrent, 0, false, $tz->getName())); + + $dtCurrent = $this->createDateTimeExactly("2021-03-28 03:00+01:00", $tz); + $dtExpected = $this->createDateTimeExactly("2021-03-28 02:00+01:00", $tz); + $this->assertEquals($dtExpected, $cron->getPreviousRunDate($dtCurrent, 0, false, $tz->getName())); + + $dtCurrent = $this->createDateTimeExactly("2021-03-28 02:00+01:00", $tz); + $dtExpected = $this->createDateTimeExactly("2021-03-21 01:00+00:00", $tz); + $this->assertEquals($dtExpected, $cron->getPreviousRunDate($dtCurrent, 0, false, $tz->getName())); + + $dtCurrent = $this->createDateTimeExactly("2021-03-28 03:00+01:00", $tz); + $dtExpected = $this->createDateTimeExactly("2021-03-28 02:00+01:00", $tz); + $this->assertEquals($dtExpected, $cron->getPreviousRunDate($dtCurrent, 0, true, $tz->getName())); + + $dtCurrent = $this->createDateTimeExactly("2021-03-28 00:00+00:00", $tz); + $dtExpected = $this->createDateTimeExactly("2021-03-21 01:00+00:00", $tz); + $this->assertEquals($dtExpected, $cron->getPreviousRunDate($dtCurrent, 0, false, $tz->getName())); + } + + /** + * Skip due to PHP 8.1 date instabilities. + * For further references, see https://github.com/dragonmantank/cron-expression/issues/133 + * @requires PHP <= 8.0.22 + */ + public function testOffsetDecrementsNextRunDateAllowCurrent(): void + { + $tz = new \DateTimeZone("Europe/London"); + $cron = new CronExpression("0 1 * * 0"); + + $dtCurrent = $this->createDateTimeExactly("2020-10-18 00:00+01:00", $tz); + $dtExpected = $this->createDateTimeExactly("2020-10-18 01:00+01:00", $tz); + $this->assertEquals($dtExpected, $cron->getNextRunDate($dtCurrent, 0, true, $tz->getName())); + + $dtCurrent = $this->createDateTimeExactly("2020-10-18 01:05+01:00", $tz); + $dtExpected = $this->createDateTimeExactly("2020-10-25 01:00+00:00", $tz); + $this->assertEquals($dtExpected, $cron->getNextRunDate($dtCurrent, 0, true, $tz->getName())); + + $dtCurrent = $this->createDateTimeExactly("2020-10-25 00:00+01:00", $tz); + $dtExpected = $this->createDateTimeExactly("2020-10-25 01:00+00:00", $tz); + $this->assertEquals($dtExpected, $cron->getNextRunDate($dtCurrent, 0, true, $tz->getName())); + + $dtCurrent = $this->createDateTimeExactly("2020-10-25 01:00+01:00", $tz); + $dtExpected = $this->createDateTimeExactly("2020-10-25 01:00+01:00", $tz); + $this->assertEquals($dtExpected, $cron->getNextRunDate($dtCurrent, 0, true, $tz->getName())); + + $dtCurrent = $this->createDateTimeExactly("2020-10-25 01:00+00:00", $tz); + $dtExpected = $this->createDateTimeExactly("2020-10-25 01:00+00:00", $tz); + $this->assertEquals($dtExpected, $cron->getNextRunDate($dtCurrent, 0, true, $tz->getName())); + + $dtCurrent = $this->createDateTimeExactly("2020-10-25 01:05+00:00", $tz); + $dtExpected = $this->createDateTimeExactly("2020-11-01 01:00+00:00", $tz); + $this->assertEquals($dtExpected, $cron->getNextRunDate($dtCurrent, 0, true, $tz->getName())); + } + + /** + * The fact that crons will run twice using this setup is expected. + * This can be avoided by using disallowing the current date or with additional checks outside this library + * Skip due to PHP 8.1 date instabilities. + * For further references, see https://github.com/dragonmantank/cron-expression/issues/133 + * @requires PHP <= 8.0.22 + */ + public function testOffsetDecrementsNextRunDateDisallowCurrent(): void + { + $tz = new \DateTimeZone("Europe/London"); + $cron = new CronExpression("0 1 * * 0"); + + $dtCurrent = $this->createDateTimeExactly("2020-10-18 00:00+01:00", $tz); + $dtExpected = $this->createDateTimeExactly("2020-10-18 01:00+01:00", $tz); + $this->assertEquals($dtExpected, $cron->getNextRunDate($dtCurrent, 0, false, $tz->getName())); + + $dtCurrent = $this->createDateTimeExactly("2020-10-18 01:05+01:00", $tz); + $dtExpected = $this->createDateTimeExactly("2020-10-25 01:00+00:00", $tz); + $this->assertEquals($dtExpected, $cron->getNextRunDate($dtCurrent, 0, false, $tz->getName())); + + $dtCurrent = $this->createDateTimeExactly("2020-10-25 00:00+01:00", $tz); + $dtExpected = $this->createDateTimeExactly("2020-10-25 01:00+00:00", $tz); + $this->assertEquals($dtExpected, $cron->getNextRunDate($dtCurrent, 0, false, $tz->getName())); + + $dtCurrent = $this->createDateTimeExactly("2020-10-25 01:00+01:00", $tz); + $dtExpected = $this->createDateTimeExactly("2020-10-25 01:00+00:00", $tz); + $this->assertEquals($dtExpected, $cron->getNextRunDate($dtCurrent, 0, false, $tz->getName())); + + $dtCurrent = $this->createDateTimeExactly("2020-10-25 01:05+01:00", $tz); + $dtExpected = $this->createDateTimeExactly("2020-10-25 01:00+00:00", $tz); + $this->assertEquals($dtExpected, $cron->getNextRunDate($dtCurrent, 0, false, $tz->getName())); + + $dtCurrent = $this->createDateTimeExactly("2020-10-25 01:00+00:00", $tz); + $dtExpected = $this->createDateTimeExactly("2020-11-01 01:00+00:00", $tz); + $this->assertEquals($dtExpected, $cron->getNextRunDate($dtCurrent, 0, false, $tz->getName())); + + $dtCurrent = $this->createDateTimeExactly("2020-10-25 01:05+00:00", $tz); + $dtExpected = $this->createDateTimeExactly("2020-11-01 01:00+00:00", $tz); + $this->assertEquals($dtExpected, $cron->getNextRunDate($dtCurrent, 0, false, $tz->getName())); + } + + public function testOffsetDecrementsPreviousRunDate(): void + { + $tz = new \DateTimeZone("Europe/London"); + $cron = new CronExpression("0 1 * * 0"); + + $dtCurrent = $this->createDateTimeExactly("2021-04-04 02:00+01:00", $tz); + $dtExpected = $this->createDateTimeExactly("2021-04-04 01:00+01:00", $tz); + $this->assertEquals($dtExpected, $cron->getPreviousRunDate($dtCurrent, 0, false, $tz->getName())); + + $dtCurrent = $this->createDateTimeExactly("2021-04-04 00:00+01:00", $tz); + $dtExpected = $this->createDateTimeExactly("2021-03-28 02:00+01:00", $tz); + $this->assertEquals($dtExpected, $cron->getPreviousRunDate($dtCurrent, 0, false, $tz->getName())); + + $dtCurrent = $this->createDateTimeExactly("2021-03-28 03:00+01:00", $tz); + $dtExpected = $this->createDateTimeExactly("2021-03-28 02:00+01:00", $tz); + $this->assertEquals($dtExpected, $cron->getPreviousRunDate($dtCurrent, 0, false, $tz->getName())); + + $dtCurrent = $this->createDateTimeExactly("2021-03-28 00:00+00:00", $tz); + $dtExpected = $this->createDateTimeExactly("2021-03-21 01:00+00:00", $tz); + $this->assertEquals($dtExpected, $cron->getPreviousRunDate($dtCurrent, 0, false, $tz->getName())); + } + + public function testOffsetIncrementsMultipleRunDates(): void + { + $expression = "0 1 * * 0"; + $cron = new CronExpression($expression); + $tz = new \DateTimeZone("Europe/London"); + + $expected = [ + $this->createDateTimeExactly("2021-03-14 01:00+00:00", $tz), + $this->createDateTimeExactly("2021-03-21 01:00+00:00", $tz), + $this->createDateTimeExactly("2021-03-28 02:00+01:00", $tz), + $this->createDateTimeExactly("2021-04-04 01:00+01:00", $tz), + $this->createDateTimeExactly("2021-04-11 01:00+01:00", $tz), + ]; + + $dtCurrent = $this->createDateTimeExactly("2021-03-13 00:00+00:00", $tz); + $actual = $cron->getMultipleRunDates(5, $dtCurrent, false, true, $tz->getName()); + foreach ($expected as $dtExpected) { + $this->assertContainsEquals($dtExpected, $actual); + } + + $dtCurrent = $this->createDateTimeExactly("2021-04-12 00:00+01:00", $tz); + $actual = $cron->getMultipleRunDates(5, $dtCurrent, true, true, $tz->getName()); + foreach ($expected as $dtExpected) { + $this->assertContainsEquals($dtExpected, $actual); + } + } + + /** + * Skip due to PHP 8.1 date instabilities. + * For further references, see https://github.com/dragonmantank/cron-expression/issues/133 + * @requires PHP <= 8.0.22 + */ + public function testOffsetDecrementsMultipleRunDates(): void + { + $expression = "0 1 * * 0"; + $cron = new CronExpression($expression); + $tz = new \DateTimeZone("Europe/London"); + + $expected = [ + $this->createDateTimeExactly("2020-10-11 01:00+01:00", $tz), + $this->createDateTimeExactly("2020-10-18 01:00+01:00", $tz), + $this->createDateTimeExactly("2020-10-25 01:00+00:00", $tz), + $this->createDateTimeExactly("2020-11-01 01:00+00:00", $tz), + $this->createDateTimeExactly("2020-11-08 01:00+00:00", $tz), + ]; + + $dtCurrent = $this->createDateTimeExactly("2020-10-10 00:00+01:00", $tz); + $actual = $cron->getMultipleRunDates(5, $dtCurrent, false, true, $tz->getName()); + foreach ($expected as $dtExpected) { + $this->assertContainsEquals($dtExpected, $actual); + } + + $expected = [ + $this->createDateTimeExactly("2020-10-18 01:00+01:00", $tz), + $this->createDateTimeExactly("2020-10-25 01:00+01:00", $tz), + $this->createDateTimeExactly("2020-10-25 01:00+00:00", $tz), + $this->createDateTimeExactly("2020-11-01 01:00+00:00", $tz), + $this->createDateTimeExactly("2020-11-08 01:00+00:00", $tz), + ]; + + $dtCurrent = $this->createDateTimeExactly("2020-11-12 00:00+00:00", $tz); + $actual = $cron->getMultipleRunDates(5, $dtCurrent, true, true, $tz->getName()); + + foreach ($expected as $dtExpected) { + $this->assertContainsEquals($dtExpected, $actual); + } + } + + public function testOffsetIncrementsEveryOtherHour(): void + { + $expression = "0 */2 * * *"; + $cron = new CronExpression($expression); + $tz = new \DateTimeZone("Europe/London"); + + $expected = [ + $this->createDateTimeExactly("2021-03-27 22:00+00:00", $tz), + $this->createDateTimeExactly("2021-03-28 00:00+00:00", $tz), + $this->createDateTimeExactly("2021-03-28 02:00+01:00", $tz), + $this->createDateTimeExactly("2021-03-28 04:00+01:00", $tz), + $this->createDateTimeExactly("2021-03-28 06:00+01:00", $tz), + ]; + + $dtCurrent = $this->createDateTimeExactly("2021-03-27 22:00+00:00", $tz); + $actual = $cron->getMultipleRunDates(5, $dtCurrent, false, true, $tz->getName()); + foreach ($expected as $dtExpected) { + $this->assertContainsEquals($dtExpected, $actual); + } + + $dtCurrent = $this->createDateTimeExactly("2021-03-28 06:00+01:00", $tz); + $actual = $cron->getMultipleRunDates(5, $dtCurrent, true, true, $tz->getName()); + foreach ($expected as $dtExpected) { + $this->assertContainsEquals($dtExpected, $actual); + } + + $expression = "0 1-23/2 * * *"; + $cron = new CronExpression($expression); + $tz = new \DateTimeZone("Europe/London"); + + $expected = [ + $this->createDateTimeExactly("2021-03-27 23:00+00:00", $tz), + $this->createDateTimeExactly("2021-03-28 02:00+01:00", $tz), + $this->createDateTimeExactly("2021-03-28 03:00+01:00", $tz), + $this->createDateTimeExactly("2021-03-28 05:00+01:00", $tz), + $this->createDateTimeExactly("2021-03-28 07:00+01:00", $tz), + ]; + + $dtCurrent = $this->createDateTimeExactly("2021-03-27 23:00+00:00", $tz); + $actual = $cron->getMultipleRunDates(5, $dtCurrent, false, true, $tz->getName()); + foreach ($expected as $dtExpected) { + $this->assertContainsEquals($dtExpected, $actual); + } + + $dtCurrent = $this->createDateTimeExactly("2021-03-28 07:00+01:00", $tz); + $actual = $cron->getMultipleRunDates(5, $dtCurrent, true, true, $tz->getName()); + foreach ($expected as $dtExpected) { + $this->assertContainsEquals($dtExpected, $actual); + } + } + + /** + * Skip due to PHP 8.1 date instabilities. + * For further references, see https://github.com/dragonmantank/cron-expression/issues/133 + * @requires PHP <= 8.0.22 + */ + public function testOffsetDecrementsEveryOtherHour(): void + { + $expression = "0 */2 * * *"; + $cron = new CronExpression($expression); + $tz = new \DateTimeZone("Europe/London"); + + $expected = [ + $this->createDateTimeExactly("2020-10-24 22:00+01:00", $tz), + $this->createDateTimeExactly("2020-10-25 00:00+01:00", $tz), + $this->createDateTimeExactly("2020-10-25 01:00+00:00", $tz), + $this->createDateTimeExactly("2020-10-25 02:00+00:00", $tz), + $this->createDateTimeExactly("2020-10-25 04:00+00:00", $tz), + ]; + + $dtCurrent = $this->createDateTimeExactly("2020-10-24 22:00+01:00", $tz); + $actual = $cron->getMultipleRunDates(5, $dtCurrent, false, true, $tz->getName()); + foreach ($expected as $dtExpected) { + $this->assertContainsEquals($dtExpected, $actual); + } + + $expected = [ + $this->createDateTimeExactly("2020-10-24 20:00+01:00", $tz), + $this->createDateTimeExactly("2020-10-24 22:00+01:00", $tz), + $this->createDateTimeExactly("2020-10-25 00:00+01:00", $tz), + $this->createDateTimeExactly("2020-10-25 02:00+00:00", $tz), + $this->createDateTimeExactly("2020-10-25 04:00+00:00", $tz), + ]; + + $dtCurrent = $this->createDateTimeExactly("2020-10-25 04:00+00:00", $tz); + $actual = $cron->getMultipleRunDates(5, $dtCurrent, true, true, $tz->getName()); + foreach ($expected as $dtExpected) { + $this->assertContainsEquals($dtExpected, $actual); + } + + $expression = "0 1-23/2 * * *"; + $cron = new CronExpression($expression); + $tz = new \DateTimeZone("Europe/London"); + + $expected = [ + $this->createDateTimeExactly("2020-10-24 23:00+01:00", $tz), + $this->createDateTimeExactly("2020-10-25 01:00+00:00", $tz), + $this->createDateTimeExactly("2020-10-25 03:00+00:00", $tz), + $this->createDateTimeExactly("2020-10-25 05:00+00:00", $tz), + $this->createDateTimeExactly("2020-10-25 07:00+00:00", $tz), + ]; + + $dtCurrent = $this->createDateTimeExactly("2020-10-24 23:00+01:00", $tz); + $actual = $cron->getMultipleRunDates(5, $dtCurrent, false, true, $tz->getName()); + foreach ($expected as $dtExpected) { + $this->assertContainsEquals($dtExpected, $actual); + } + + $expected = [ + $this->createDateTimeExactly("2020-10-25 01:00+01:00", $tz), + $this->createDateTimeExactly("2020-10-25 01:00+00:00", $tz), + $this->createDateTimeExactly("2020-10-25 03:00+00:00", $tz), + $this->createDateTimeExactly("2020-10-25 05:00+00:00", $tz), + $this->createDateTimeExactly("2020-10-25 07:00+00:00", $tz), + ]; + + $dtCurrent = $this->createDateTimeExactly("2020-10-25 07:00+00:00", $tz); + $actual = $cron->getMultipleRunDates(5, $dtCurrent, true, true, $tz->getName()); + foreach ($expected as $dtExpected) { + $this->assertContainsEquals($dtExpected, $actual); + } + } + + public function testOffsetIncrementsMidnight(): void + { + $expression = '@hourly'; + $cron = new CronExpression($expression); + $tz = new \DateTimeZone("America/Asuncion"); + + $expected = [ + $this->createDateTimeExactly("2021-03-27 22:00-03:00", $tz), + $this->createDateTimeExactly("2021-03-27 23:00-03:00", $tz), + $this->createDateTimeExactly("2021-03-27 23:00-04:00", $tz), + $this->createDateTimeExactly("2021-03-28 00:00-04:00", $tz), + $this->createDateTimeExactly("2021-03-28 01:00-04:00", $tz), + ]; + + $dtCurrent = $this->createDateTimeExactly("2021-03-27 22:00-03:00", $tz); + $actual = $cron->getMultipleRunDates(5, $dtCurrent, false, true, $tz->getName()); + foreach ($expected as $dtExpected) { + $this->assertContainsEquals($dtExpected, $actual); + } + + $dtCurrent = $this->createDateTimeExactly("2021-03-28 01:00-04:00", $tz); + $actual = $cron->getMultipleRunDates(5, $dtCurrent, true, true, $tz->getName()); + foreach ($expected as $dtExpected) { + $this->assertContainsEquals($dtExpected, $actual); + } + } + + public function testOffsetDecrementsMidnight(): void + { + $expression = '@hourly'; + $cron = new CronExpression($expression); + $tz = new \DateTimeZone("America/Asuncion"); + + $expected = [ + $this->createDateTimeExactly("2020-10-03 22:00-04:00", $tz), + $this->createDateTimeExactly("2020-10-03 23:00-04:00", $tz), + $this->createDateTimeExactly("2020-10-04 01:00-03:00", $tz), + $this->createDateTimeExactly("2020-10-04 02:00-03:00", $tz), + $this->createDateTimeExactly("2020-10-04 03:00-03:00", $tz), + ]; + + $dtCurrent = $this->createDateTimeExactly("2020-10-03 22:00-04:00", $tz); + $actual = $cron->getMultipleRunDates(5, $dtCurrent, false, true, $tz->getName()); + foreach ($expected as $dtExpected) { + $this->assertContainsEquals($dtExpected, $actual); + } + + $dtCurrent = $this->createDateTimeExactly("2020-10-04 03:00-03:00", $tz); + $actual = $cron->getMultipleRunDates(5, $dtCurrent, true, true, $tz->getName()); + foreach ($expected as $dtExpected) { + $this->assertContainsEquals($dtExpected, $actual); + } + } +} diff --git a/tests/Cron/FieldFactoryTest.php b/tests/Cron/FieldFactoryTest.php index a6e66b0e..a6412b59 100644 --- a/tests/Cron/FieldFactoryTest.php +++ b/tests/Cron/FieldFactoryTest.php @@ -1,8 +1,11 @@ 'Cron\MinutesField', 1 => 'Cron\HoursField', 2 => 'Cron\DayOfMonthField', 3 => 'Cron\MonthField', 4 => 'Cron\DayOfWeekField', - ); + ]; $f = new FieldFactory(); foreach ($mappings as $position => $class) { - $this->assertSame($class, get_class($f->getField($position))); + $this->assertInstanceOf($class, $f->getField($position)); } } /** * @covers \Cron\FieldFactory::getField - * @expectedException InvalidArgumentException */ - public function testValidatesFieldPosition() + public function testValidatesFieldPosition(): void { + $this->expectException(InvalidArgumentException::class); $f = new FieldFactory(); $f->getField(-1); } diff --git a/tests/Cron/HoursFieldTest.php b/tests/Cron/HoursFieldTest.php index 03f09091..b8f885ce 100644 --- a/tests/Cron/HoursFieldTest.php +++ b/tests/Cron/HoursFieldTest.php @@ -1,9 +1,12 @@ assertTrue($f->validate('1')); + $this->assertTrue($f->validate('00')); + $this->assertTrue($f->validate('01')); $this->assertTrue($f->validate('*')); - $this->assertFalse($f->validate('*/3,1,1-12')); - } + $this->assertTrue($f->validate('*/3,1,1-12')); + $this->assertFalse($f->validate('1/10')); + } /** * @covers \Cron\HoursField::increment */ - public function testIncrementsDate() + public function testIncrementsDate(): void { $d = new DateTime('2011-03-15 11:15:00'); $f = new HoursField(); @@ -40,7 +46,18 @@ public function testIncrementsDate() /** * @covers \Cron\HoursField::increment */ - public function testIncrementsDateWithThirtyMinuteOffsetTimezone() + public function testIncrementsDateTimeImmutable(): void + { + $d = new DateTimeImmutable('2011-03-15 11:15:00'); + $f = new HoursField(); + $f->increment($d); + $this->assertSame('2011-03-15 12:00:00', $d->format('Y-m-d H:i:s')); + } + + /** + * @covers \Cron\HoursField::increment + */ + public function testIncrementsDateWithThirtyMinuteOffsetTimezone(): void { $tz = date_default_timezone_get(); date_default_timezone_set('America/St_Johns'); @@ -58,7 +75,7 @@ public function testIncrementsDateWithThirtyMinuteOffsetTimezone() /** * @covers \Cron\HoursField::increment */ - public function testIncrementDateWithFifteenMinuteOffsetTimezone() + public function testIncrementDateWithFifteenMinuteOffsetTimezone(): void { $tz = date_default_timezone_get(); date_default_timezone_set('Asia/Kathmandu'); @@ -72,4 +89,50 @@ public function testIncrementDateWithFifteenMinuteOffsetTimezone() $this->assertSame('2011-03-15 10:59:00', $d->format('Y-m-d H:i:s')); date_default_timezone_set($tz); } + + /** + * @covers \Cron\HoursField::increment + */ + public function testIncrementAcrossDstChangeBerlin(): void + { + $tz = new \DateTimeZone("Europe/Berlin"); + $d = \DateTimeImmutable::createFromFormat("!Y-m-d H:i:s", "2021-03-27 23:00:00", $tz); + $f = new HoursField(); + $f->increment($d); + $this->assertSame("2021-03-28 00:00:00", $d->format("Y-m-d H:i:s")); + $f->increment($d); + $this->assertSame("2021-03-28 01:00:00", $d->format("Y-m-d H:i:s")); + $f->increment($d); + $this->assertSame("2021-03-28 03:00:00", $d->format("Y-m-d H:i:s")); + + $f->increment($d, true); + $this->assertSame("2021-03-28 01:59:00", $d->format("Y-m-d H:i:s")); + $f->increment($d, true); + $this->assertSame("2021-03-28 00:59:00", $d->format("Y-m-d H:i:s")); + $f->increment($d, true); + $this->assertSame("2021-03-27 23:59:00", $d->format("Y-m-d H:i:s")); + } + + /** + * @covers \Cron\HoursField::increment + */ + public function testIncrementAcrossDstChangeLondon(): void + { + $tz = new \DateTimeZone("Europe/London"); + $d = \DateTimeImmutable::createFromFormat("!Y-m-d H:i:s", "2021-03-27 23:00:00", $tz); + $f = new HoursField(); + $f->increment($d); + $this->assertSame("2021-03-28 00:00:00", $d->format("Y-m-d H:i:s")); + $f->increment($d); + $this->assertSame("2021-03-28 02:00:00", $d->format("Y-m-d H:i:s")); + $f->increment($d); + $this->assertSame("2021-03-28 03:00:00", $d->format("Y-m-d H:i:s")); + + $f->increment($d, true); + $this->assertSame("2021-03-28 02:59:00", $d->format("Y-m-d H:i:s")); + $f->increment($d, true); + $this->assertSame("2021-03-28 00:59:00", $d->format("Y-m-d H:i:s")); + $f->increment($d, true); + $this->assertSame("2021-03-27 23:59:00", $d->format("Y-m-d H:i:s")); + } } diff --git a/tests/Cron/MinutesFieldTest.php b/tests/Cron/MinutesFieldTest.php index b91bffac..585d464e 100644 --- a/tests/Cron/MinutesFieldTest.php +++ b/tests/Cron/MinutesFieldTest.php @@ -1,9 +1,12 @@ assertTrue($f->validate('1')); $this->assertTrue($f->validate('*')); - $this->assertFalse($f->validate('*/3,1,1-12')); + $this->assertTrue($f->validate('*/3,1,1-12')); + $this->assertFalse($f->validate('1/10')); + } + + /** + * @covers \Cron\MinutesField::isSatisfiedBy + */ + public function testChecksIfSatisfied(): void + { + $f = new MinutesField(); + $this->assertTrue($f->isSatisfiedBy(new DateTime(), '?', false)); + $this->assertTrue($f->isSatisfiedBy(new DateTimeImmutable(), '?', false)); } /** * @covers \Cron\MinutesField::increment */ - public function testIncrementsDate() + public function testIncrementsDate(): void { $d = new DateTime('2011-03-15 11:15:00'); $f = new MinutesField(); $f->increment($d); $this->assertSame('2011-03-15 11:16:00', $d->format('Y-m-d H:i:s')); + $f->increment($d, true); $this->assertSame('2011-03-15 11:15:00', $d->format('Y-m-d H:i:s')); } + /** + * @covers \Cron\MinutesField::increment + */ + public function testIncrementsDateTimeImmutable(): void + { + $d = new DateTimeImmutable('2011-03-15 11:15:00'); + $f = new MinutesField(); + $f->increment($d); + $this->assertSame('2011-03-15 11:16:00', $d->format('Y-m-d H:i:s')); + } + /** * Various bad syntaxes that are reported to work, but shouldn't. * * @author Chris Tankersley + * * @since 2017-08-18 */ - public function testBadSyntaxesShouldNotValidate() + public function testBadSyntaxesShouldNotValidate(): void { $f = new MinutesField(); $this->assertFalse($f->validate('*-1')); $this->assertFalse($f->validate('1-2-3')); $this->assertFalse($f->validate('-1')); } + + /** + * Ranges that are invalid should not validate. + * In this case `0/5` would be invalid because `0` is not part of the minute range. + * + * @author Chris Tankersley + * @since 2019-07-29 + * @see https://github.com/dragonmantank/cron-expression/issues/18 + */ + public function testInvalidRangeShouldNotValidate(): void + { + $f = new MinutesField(); + $this->assertFalse($f->validate('0/5')); + } + + /** + * @covers \Cron\MinutesField::increment + */ + public function testIncrementAcrossDstChangeBerlin(): void + { + $tz = new \DateTimeZone("Europe/Berlin"); + $d = \DateTimeImmutable::createFromFormat("!Y-m-d H:i:s", "2021-03-28 01:59:00", $tz); + $f = new MinutesField(); + $f->increment($d); + $this->assertSame("2021-03-28 03:00:00", $d->format("Y-m-d H:i:s")); + + $f->increment($d, true); + $this->assertSame("2021-03-28 01:59:00", $d->format("Y-m-d H:i:s")); + $f->increment($d, true); + $this->assertSame("2021-03-28 01:58:00", $d->format("Y-m-d H:i:s")); + } + + /** + * @covers \Cron\MinutesField::increment + */ + public function testIncrementAcrossDstChangeLondon(): void + { + $tz = new \DateTimeZone("Europe/London"); + $d = \DateTimeImmutable::createFromFormat("!Y-m-d H:i:s", "2021-03-28 00:59:00", $tz); + $f = new MinutesField(); + $f->increment($d); + $this->assertSame("2021-03-28 02:00:00", $d->format("Y-m-d H:i:s")); + $f->increment($d); + $this->assertSame("2021-03-28 02:01:00", $d->format("Y-m-d H:i:s")); + + $f->increment($d, true); + $this->assertSame("2021-03-28 02:00:00", $d->format("Y-m-d H:i:s")); + $f->increment($d, true); + $this->assertSame("2021-03-28 00:59:00", $d->format("Y-m-d H:i:s")); + $f->increment($d, true); + $this->assertSame("2021-03-28 00:58:00", $d->format("Y-m-d H:i:s")); + } } diff --git a/tests/Cron/MonthFieldTest.php b/tests/Cron/MonthFieldTest.php index 83f0f164..4016711a 100644 --- a/tests/Cron/MonthFieldTest.php +++ b/tests/Cron/MonthFieldTest.php @@ -1,9 +1,12 @@ assertTrue($f->validate('12')); $this->assertTrue($f->validate('*')); - $this->assertFalse($f->validate('*/10,2,1-12')); + $this->assertTrue($f->validate('*/10,2,1-12')); $this->assertFalse($f->validate('1.fix-regexp')); + $this->assertFalse($f->validate('1/10')); + } + + /** + * @covers \Cron\MonthField::isSatisfiedBy + */ + public function testChecksIfSatisfied(): void + { + $f = new MonthField(); + $this->assertTrue($f->isSatisfiedBy(new DateTime(), '?', false)); + $this->assertTrue($f->isSatisfiedBy(new DateTimeImmutable(), '?', false)); } /** * @covers \Cron\MonthField::increment */ - public function testIncrementsDate() + public function testIncrementsDate(): void { $d = new DateTime('2011-03-15 11:15:00'); $f = new MonthField(); @@ -41,7 +55,18 @@ public function testIncrementsDate() /** * @covers \Cron\MonthField::increment */ - public function testIncrementsDateWithThirtyMinuteTimezone() + public function testIncrementsDateTimeImmutable(): void + { + $d = new DateTimeImmutable('2011-03-15 11:15:00'); + $f = new MonthField(); + $f->increment($d); + $this->assertSame('2011-04-01 00:00:00', $d->format('Y-m-d H:i:s')); + } + + /** + * @covers \Cron\MonthField::increment + */ + public function testIncrementsDateWithThirtyMinuteTimezone(): void { $tz = date_default_timezone_get(); date_default_timezone_set('America/St_Johns'); @@ -56,11 +81,10 @@ public function testIncrementsDateWithThirtyMinuteTimezone() date_default_timezone_set($tz); } - /** * @covers \Cron\MonthField::increment */ - public function testIncrementsYearAsNeeded() + public function testIncrementsYearAsNeeded(): void { $f = new MonthField(); $d = new DateTime('2011-12-15 00:00:00'); @@ -71,11 +95,26 @@ public function testIncrementsYearAsNeeded() /** * @covers \Cron\MonthField::increment */ - public function testDecrementsYearAsNeeded() + public function testDecrementsYearAsNeeded(): void { $f = new MonthField(); $d = new DateTime('2011-01-15 00:00:00'); $f->increment($d, true); $this->assertSame('2010-12-31 23:59:00', $d->format('Y-m-d H:i:s')); } + + /** + * Incoming literals should ignore case + * + * @author Chris Tankersley assertTrue($f->validate('JAN')); + $this->assertTrue($f->validate('Jan')); + $this->assertTrue($f->validate('jan')); + } }