From 5d6c9a7e4e637aa1694db37d2839302245620b34 Mon Sep 17 00:00:00 2001 From: AllenJB Date: Wed, 5 Jan 2022 03:59:05 +0000 Subject: [PATCH] DST handling fixes (fixes #111, #112) (#115) * DST fix (attempt 1) * More tests and further fixes WIP as I believe the change to HoursField::isSatisfiedBy is going to break expressions with multiple parts - but one thing at a time! * WIP More tests and further fixes; API change: isSatisfiedBy now requires knowledge of which direction we're travelling in to handle checking initial values correctly * WIP Save Point - Trying to fix one thing breaks another, but I have an idea... * WIP Abstracted NextRunDateTime which keeps track of offset changes, regardless of whether they occurred when changing minute or hour (and to persist them until a next change is made) * WIP Fix easy fixes * WIP More test fixes * All current tests pass! * Fix for issue #112; Use a cache of timezone transitions to avoid having to modify date/time objects every single time we want to check if hour is satisfied All tests pass * Cleanup All tests pass * Cleanup All tests pass * Cleanup - backing out the NextRunDateTime abstraction; All tests pass * Cleanup; All tests pass * Cleanup (diff tidy, restoring deleted tests); All tests pass * Cleanup (diff tidy); All tests pass * Fix CI issues * Fix CI issues Co-authored-by: Chris Tankersley --- src/Cron/AbstractField.php | 26 ++ src/Cron/CronExpression.php | 37 ++- src/Cron/DayOfMonthField.php | 10 +- src/Cron/DayOfWeekField.php | 27 +- src/Cron/FieldInterface.php | 2 +- src/Cron/HoursField.php | 150 +++++++++- src/Cron/MinutesField.php | 36 ++- src/Cron/MonthField.php | 12 +- tests/Cron/CronExpressionTest.php | 4 +- tests/Cron/DayOfMonthFieldTest.php | 4 +- tests/Cron/DayOfWeekFieldTest.php | 28 +- tests/Cron/DaylightSavingsTest.php | 438 +++++++++++++++++++++++++++++ tests/Cron/HoursFieldTest.php | 23 ++ tests/Cron/MinutesFieldTest.php | 5 +- tests/Cron/MonthFieldTest.php | 4 +- 15 files changed, 724 insertions(+), 82 deletions(-) create mode 100644 tests/Cron/DaylightSavingsTest.php diff --git a/src/Cron/AbstractField.php b/src/Cron/AbstractField.php index 8aa5be78..f13e59a5 100644 --- a/src/Cron/AbstractField.php +++ b/src/Cron/AbstractField.php @@ -4,6 +4,8 @@ namespace Cron; +use DateTimeInterface; + /** * Abstract CRON expression field. */ @@ -300,4 +302,28 @@ public function validate(string $value): bool 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 $date; + } } diff --git a/src/Cron/CronExpression.php b/src/Cron/CronExpression.php index 8cdbd97c..f71490e1 100644 --- a/src/Cron/CronExpression.php +++ b/src/Cron/CronExpression.php @@ -240,14 +240,32 @@ public function getPreviousRunDate($currentTime = 'now', int $nth = 0, bool $all */ public function getMultipleRunDates(int $total, $currentTime = 'now', bool $invert = false, bool $allowCurrentDate = false, $timeZone = null): array { + $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); + } + + Assert::isInstanceOf($currentTime, DateTime::class); + $currentTime->setTimezone(new DateTimeZone($timeZone)); + $matches = []; for ($i = 0; $i < $total; ++$i) { try { - $currentTime = $this->getRunDate($currentTime, 0, $invert, $i === 0 ? $allowCurrentDate : false, $timeZone); - $matches[] = $currentTime; + $result = $this->getRunDate($currentTime, 0, $invert, $allowCurrentDate, $timeZone); } catch (RuntimeException $e) { break; } + + $allowCurrentDate = false; + $currentTime = clone $result; + $matches[] = $result; } return $matches; @@ -364,7 +382,9 @@ protected function getRunDate($currentTime = null, int $nth = 0, bool $invert = Assert::isInstanceOf($currentDate, DateTime::class); $currentDate->setTimezone(new DateTimeZone($timeZone)); - $currentDate->setTime((int) $currentDate->format('H'), (int) $currentDate->format('i'), 0); + // 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()); + $currentDate->setTimezone(new DateTimeZone($timeZone)); $nextRun = clone $currentDate; @@ -380,7 +400,7 @@ protected function getRunDate($currentTime = null, int $nth = 0, bool $invert = $fields[$position] = $this->fieldFactory->getField($position); } - if (isset($parts[2]) && isset($parts[4])) { + 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)); @@ -409,10 +429,10 @@ protected function getRunDate($currentTime = null, int $nth = 0, bool $invert = $field = $fields[$position]; // Check if this is singular or a list if (false === strpos($part, ',')) { - $satisfied = $field->isSatisfiedBy($nextRun, $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; @@ -430,8 +450,7 @@ protected function getRunDate($currentTime = null, int $nth = 0, bool $invert = // Skip this match if needed if ((!$allowCurrentDate && $nextRun == $currentDate) || --$nth > -1) { - $this->fieldFactory->getField(0)->increment($nextRun, $invert, $parts[0] ?? null); - + $this->fieldFactory->getField(self::MINUTE)->increment($nextRun, $invert, $parts[self::MINUTE] ?? null); continue; } @@ -458,7 +477,7 @@ protected function determineTimeZone($currentTime, ?string $timeZone): string } if ($currentTime instanceof DateTimeInterface) { - return $currentTime->getTimeZone()->getName(); + return $currentTime->getTimezone()->getName(); } return date_default_timezone_get(); diff --git a/src/Cron/DayOfMonthField.php b/src/Cron/DayOfMonthField.php index 21c2d97e..e0871830 100644 --- a/src/Cron/DayOfMonthField.php +++ b/src/Cron/DayOfMonthField.php @@ -79,7 +79,7 @@ private static function getNearestWeekday(int $currentYear, int $currentMonth, i /** * {@inheritdoc} */ - public function isSatisfiedBy(DateTimeInterface $date, $value): bool + public function isSatisfiedBy(DateTimeInterface $date, $value, bool $invert): bool { // ? states that the field value is to be skipped if ('?' === $value) { @@ -117,10 +117,12 @@ public function isSatisfiedBy(DateTimeInterface $date, $value): bool */ public function increment(DateTimeInterface &$date, $invert = false, $parts = null): FieldInterface { - if ($invert) { - $date = $date->modify('previous day')->setTime(23, 59); + if (! $invert) { + $date = $this->timezoneSafeModify($date, '+1 day'); + $date = $date->setTime(0, 0); } else { - $date = $date->modify('next day')->setTime(0, 0); + $date = $this->timezoneSafeModify($date, '-1 day'); + $date = $date->setTime(23, 59); } return $this; diff --git a/src/Cron/DayOfWeekField.php b/src/Cron/DayOfWeekField.php index 98056951..40db9af1 100644 --- a/src/Cron/DayOfWeekField.php +++ b/src/Cron/DayOfWeekField.php @@ -4,7 +4,6 @@ namespace Cron; -use DateTime; use DateTimeInterface; use InvalidArgumentException; @@ -54,10 +53,8 @@ public function __construct() /** * @inheritDoc - * - * @param \DateTime|\DateTimeImmutable $date */ - public function isSatisfiedBy(DateTimeInterface $date, $value): bool + public function isSatisfiedBy(DateTimeInterface $date, $value, bool $invert): bool { if ('?' === $value) { return true; @@ -76,15 +73,9 @@ public function isSatisfiedBy(DateTimeInterface $date, $value): bool $weekday = $this->convertLiterals(substr($value, 0, strpos($value, 'L'))); $weekday %= 7; - $tdate = clone $date; - $tdate = $tdate->setDate($currentYear, $currentMonth, $lastDayOfMonth); - while ($tdate->format('w') != $weekday) { - $tdateClone = new DateTime(); - $tdate = $tdateClone->setTimezone($tdate->getTimezone()) - ->setDate($currentYear, $currentMonth, --$lastDayOfMonth); - } - - return (int) $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 @@ -156,15 +147,15 @@ public function isSatisfiedBy(DateTimeInterface $date, $value): bool /** * @inheritDoc - * - * @param \DateTime|\DateTimeImmutable $date */ public function increment(DateTimeInterface &$date, $invert = false, $parts = null): FieldInterface { - if ($invert) { - $date = $date->modify('-1 day')->setTime(23, 59, 0); + if (! $invert) { + $date = $this->timezoneSafeModify($date, '+1 day'); + $date = $date->setTime(0, 0); } else { - $date = $date->modify('+1 day')->setTime(0, 0, 0); + $date = $this->timezoneSafeModify($date, '-1 day'); + $date = $date->setTime(23, 59); } return $this; diff --git a/src/Cron/FieldInterface.php b/src/Cron/FieldInterface.php index eba0558f..38c72a8f 100644 --- a/src/Cron/FieldInterface.php +++ b/src/Cron/FieldInterface.php @@ -19,7 +19,7 @@ interface FieldInterface * * @return bool Returns TRUE if satisfied, FALSE otherwise */ - public function isSatisfiedBy(DateTimeInterface $date, $value): bool; + public function isSatisfiedBy(DateTimeInterface $date, $value, bool $invert): bool; /** * When a CRON expression is not satisfied, this method is used to increment diff --git a/src/Cron/HoursField.php b/src/Cron/HoursField.php index 78a5af42..a9b756e1 100644 --- a/src/Cron/HoursField.php +++ b/src/Cron/HoursField.php @@ -22,32 +22,111 @@ class HoursField extends AbstractField */ protected $rangeEnd = 23; + /** + * @var array|null Transitions returned by DateTimeZone::getTransitions() + */ + protected $transitions = null; + + /** + * @var int|null Timestamp of the start of the transitions range + */ + protected $transitionsStart = null; + + /** + * @var int|null Timestamp of the end of the transitions range + */ + protected $transitionsEnd = null; + /** * {@inheritdoc} */ - public function isSatisfiedBy(DateTimeInterface $date, $value): bool + public function isSatisfiedBy(DateTimeInterface $date, $value, bool $invert): bool { - return $this->isSatisfied((int) $date->format('H'), $value); + $checkValue = (int) $date->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"] > ($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 + { + $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() + ); + $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); } /** * {@inheritdoc} * - * @param \DateTime|\DateTimeImmutable $date * @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 (null === $parts || '*' === $parts) { - $timezone = $date->getTimezone(); - $date = $date->setTimezone(new DateTimeZone('UTC')); - $date = $date->modify(($invert ? '-' : '+') . '1 hour'); - $date = $date->setTimezone($timezone); - - $date = $date->setTime((int)$date->format('H'), $invert ? 59 : 0); + $date = $this->timezoneSafeModify($date, ($invert ? "-" : "+") ."1 hour"); + $date = $this->setTimeHour($date, $invert, $originalTimestamp); return $this; } @@ -57,7 +136,7 @@ public function increment(DateTimeInterface &$date, $invert = false, $parts = nu $hours = array_merge($hours, $this->getRangeForExpression($part, 23)); } - $current_hour = $date->format('H'); + $current_hour = (int) $date->format('H'); $position = $invert ? \count($hours) - 1 : 0; $countHours = \count($hours); if ($countHours > 1) { @@ -71,12 +150,53 @@ public function increment(DateTimeInterface &$date, $invert = false, $parts = nu } } - $hour = (int) $hours[$position]; - if ((!$invert && (int) $date->format('H') >= $hour) || ($invert && (int) $date->format('H') <= $hour)) { - $date = $date->modify(($invert ? '-' : '+') . '1 day'); - $date = $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 { - $date = $date->setTime($hour, $invert ? 59 : 0); + 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"); + } + + $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 d95ba749..aabd44ab 100644 --- a/src/Cron/MinutesField.php +++ b/src/Cron/MinutesField.php @@ -24,9 +24,9 @@ class MinutesField extends AbstractField /** * {@inheritdoc} */ - public function isSatisfiedBy(DateTimeInterface $date, $value):bool + public function isSatisfiedBy(DateTimeInterface $date, $value, bool $invert):bool { - if ($value == '?') { + if ($value === '?') { return true; } @@ -37,23 +37,23 @@ public function isSatisfiedBy(DateTimeInterface $date, $value):bool * {@inheritdoc} * {@inheritDoc} * - * @param \DateTime|\DateTimeImmutable $date * @param string|null $parts */ public function increment(DateTimeInterface &$date, $invert = false, $parts = null): FieldInterface { if (is_null($parts)) { - $date = $date->modify(($invert ? '-' : '+') . '1 minute'); + $date = $date->modify(($invert ? '-' : '+'). '1 minute'); return $this; } + $current_minute = (int) $date->format('i'); + $parts = false !== strpos($parts, ',') ? explode(',', $parts) : [$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) { @@ -66,11 +66,29 @@ public function increment(DateTimeInterface &$date, $invert = false, $parts = nu } } - if ((!$invert && $current_minute >= $minutes[$position]) || ($invert && $current_minute <= $minutes[$position])) { - $date = $date->modify(($invert ? '-' : '+') . '1 hour'); - $date = $date->setTime((int) $date->format('H'), $invert ? 59 : 0); + $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 { - $date = $date->setTime((int) $date->format('H'), (int) $minutes[$position]); + 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 06bdbf46..5a15fbb8 100644 --- a/src/Cron/MonthField.php +++ b/src/Cron/MonthField.php @@ -30,9 +30,9 @@ class MonthField extends AbstractField /** * {@inheritdoc} */ - public function isSatisfiedBy(DateTimeInterface $date, $value): bool + public function isSatisfiedBy(DateTimeInterface $date, $value, bool $invert): bool { - if ($value == '?') { + if ($value === '?') { return true; } @@ -48,10 +48,12 @@ public function isSatisfiedBy(DateTimeInterface $date, $value): bool */ public function increment(DateTimeInterface &$date, $invert = false, $parts = null): FieldInterface { - if ($invert) { - $date = $date->modify('last day of previous month')->setTime(23, 59); + if (! $invert) { + $date = $date->modify('first day of next month'); + $date = $date->setTime(0, 0); } else { - $date = $date->modify('first day of next month')->setTime(0, 0); + $date = $date->modify('last day of previous month'); + $date = $date->setTime(23, 59); } return $this; diff --git a/tests/Cron/CronExpressionTest.php b/tests/Cron/CronExpressionTest.php index 72f60be2..744c984d 100644 --- a/tests/Cron/CronExpressionTest.php +++ b/tests/Cron/CronExpressionTest.php @@ -644,7 +644,9 @@ public function testMakeDayOfWeekAnOrSometimes(): void public function testNextRunDateShouldNotAddMinutes(): void { $e = new CronExpression('* 19 * * *'); - $nextRunDate = $e->getNextRunDate(); + $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")); } diff --git a/tests/Cron/DayOfMonthFieldTest.php b/tests/Cron/DayOfMonthFieldTest.php index 15396b8e..0d18c308 100644 --- a/tests/Cron/DayOfMonthFieldTest.php +++ b/tests/Cron/DayOfMonthFieldTest.php @@ -36,8 +36,8 @@ public function testValidatesField(): void public function testChecksIfSatisfied(): void { $f = new DayOfMonthField(); - $this->assertTrue($f->isSatisfiedBy(new DateTime(), '?')); - $this->assertTrue($f->isSatisfiedBy(new DateTimeImmutable(), '?')); + $this->assertTrue($f->isSatisfiedBy(new DateTime(), '?', false)); + $this->assertTrue($f->isSatisfiedBy(new DateTimeImmutable(), '?', false)); } /** diff --git a/tests/Cron/DayOfWeekFieldTest.php b/tests/Cron/DayOfWeekFieldTest.php index 6a414313..883c031e 100644 --- a/tests/Cron/DayOfWeekFieldTest.php +++ b/tests/Cron/DayOfWeekFieldTest.php @@ -37,8 +37,8 @@ public function testValidatesField(): void public function testChecksIfSatisfied(): void { $f = new DayOfWeekField(); - $this->assertTrue($f->isSatisfiedBy(new DateTime(), '?')); - $this->assertTrue($f->isSatisfiedBy(new DateTimeImmutable(), '?')); + $this->assertTrue($f->isSatisfiedBy(new DateTime(), '?', false)); + $this->assertTrue($f->isSatisfiedBy(new DateTimeImmutable(), '?', false)); } /** @@ -75,7 +75,7 @@ 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)); } /** @@ -86,7 +86,7 @@ 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)); } /** @@ -111,13 +111,13 @@ public function testValidateWeekendHash(): void 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')); - $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')); + $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)); } /** @@ -126,10 +126,10 @@ public function testHandlesZeroAndSevenDayOfTheWeekValues(): void public function testHandlesLastWeekdayOfTheMonth(): void { $f = new DayOfWeekField(); - $this->assertTrue($f->isSatisfiedBy(new DateTime('2018-12-28 00:00:00'), 'FRIL')); - $this->assertTrue($f->isSatisfiedBy(new DateTime('2018-12-28 00:00:00'), '5L')); - $this->assertFalse($f->isSatisfiedBy(new DateTime('2018-12-21 00:00:00'), 'FRIL')); - $this->assertFalse($f->isSatisfiedBy(new DateTime('2018-12-21 00:00:00'), '5L')); + $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)); } /** diff --git a/tests/Cron/DaylightSavingsTest.php b/tests/Cron/DaylightSavingsTest.php new file mode 100644 index 00000000..82cfe0a8 --- /dev/null +++ b/tests/Cron/DaylightSavingsTest.php @@ -0,0 +1,438 @@ +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())); + } + + 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 + */ + 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); + } + } + + 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); + } + } + + 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/HoursFieldTest.php b/tests/Cron/HoursFieldTest.php index 37a5ce42..649ea531 100644 --- a/tests/Cron/HoursFieldTest.php +++ b/tests/Cron/HoursFieldTest.php @@ -89,4 +89,27 @@ public function testIncrementDateWithFifteenMinuteOffsetTimezone(): void $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 testIncrementAcrossDstChange(): 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 e6446180..67cc47c9 100644 --- a/tests/Cron/MinutesFieldTest.php +++ b/tests/Cron/MinutesFieldTest.php @@ -32,8 +32,8 @@ public function testValidatesField(): void public function testChecksIfSatisfied(): void { $f = new MinutesField(); - $this->assertTrue($f->isSatisfiedBy(new DateTime(), '?')); - $this->assertTrue($f->isSatisfiedBy(new DateTimeImmutable(), '?')); + $this->assertTrue($f->isSatisfiedBy(new DateTime(), '?', false)); + $this->assertTrue($f->isSatisfiedBy(new DateTimeImmutable(), '?', false)); } /** @@ -45,6 +45,7 @@ public function testIncrementsDate(): void $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')); } diff --git a/tests/Cron/MonthFieldTest.php b/tests/Cron/MonthFieldTest.php index 7a686a5d..4016711a 100644 --- a/tests/Cron/MonthFieldTest.php +++ b/tests/Cron/MonthFieldTest.php @@ -33,8 +33,8 @@ public function testValidatesField(): void public function testChecksIfSatisfied(): void { $f = new MonthField(); - $this->assertTrue($f->isSatisfiedBy(new DateTime(), '?')); - $this->assertTrue($f->isSatisfiedBy(new DateTimeImmutable(), '?')); + $this->assertTrue($f->isSatisfiedBy(new DateTime(), '?', false)); + $this->assertTrue($f->isSatisfiedBy(new DateTimeImmutable(), '?', false)); } /**