diff --git a/lib/Recur/RRuleIterator.php b/lib/Recur/RRuleIterator.php index 554507f19..1e10183e7 100644 --- a/lib/Recur/RRuleIterator.php +++ b/lib/Recur/RRuleIterator.php @@ -2,7 +2,6 @@ namespace Sabre\VObject\Recur; -use DateTimeImmutable; use DateTimeInterface; use Iterator; use Sabre\VObject\DateTimeParser; @@ -85,30 +84,28 @@ public function rewind() /** * Goes on to the next iteration. + * + * @param int $amount */ - public function next() + public function next($amount = 1) { // Otherwise, we find the next event in the normal RRULE // sequence. switch ($this->frequency) { case 'hourly': - $this->nextHourly(); + $this->nextHourly($amount); break; - case 'daily': - $this->nextDaily(); + $this->nextDaily($amount); break; - case 'weekly': - $this->nextWeekly(); + $this->nextWeekly($amount); break; - case 'monthly': - $this->nextMonthly(); + $this->nextMonthly($amount); break; - case 'yearly': - $this->nextYearly(); + $this->nextYearly($amount); break; } ++$this->counter; @@ -134,9 +131,49 @@ public function isInfinite() */ public function fastForward(DateTimeInterface $dt) { + if (!isset($this->count)) { + do { + $diff = $this->currentDate->diff($dt); + switch ($this->frequency) { + case 'hourly': + $i = $diff->days * 24; + break; + case 'daily': + $i = $diff->days; + break; + case 'weekly': + $i = $diff->days / 7; + break; + case 'monthly': + $i = $diff->days / 30; + break; + case 'yearly': + $i = $diff->days / 365; + break; + } + $i /= $this->interval; + $i /= 4; + $i = floor($i); + $i = max(1, $i); + do { + $previousDate = clone $this->currentDate; + $this->next($i); + } while ($this->valid() && $this->currentDate < $dt); + + $this->currentDate = $previousDate; + // do one step to avoid deadlock + $this->next(); + } while ($i > 5 && $this->valid() && $this->currentDate < $dt); + } + while ($this->valid() && $this->currentDate < $dt) { $this->next(); } + + if (!isset($this->count)) { + // We don't know the counter at this point anymore + $this->counter = NAN; + } } /** @@ -306,18 +343,18 @@ public function fastForward(DateTimeInterface $dt) /** * Does the processing for advancing the iterator for hourly frequency. */ - protected function nextHourly() + protected function nextHourly($amount = 1) { - $this->currentDate = $this->currentDate->modify('+'.$this->interval.' hours'); + $this->currentDate = $this->currentDate->modify('+'.$amount * $this->interval.' hours'); } /** * Does the processing for advancing the iterator for daily frequency. */ - protected function nextDaily() + protected function nextDaily($amount = 1) { if (!$this->byHour && !$this->byDay) { - $this->currentDate = $this->currentDate->modify('+'.$this->interval.' days'); + $this->currentDate = $this->currentDate->modify('+'.$amount * $this->interval.' days'); return; } @@ -338,12 +375,14 @@ protected function nextDaily() if ($this->byHour) { if ('23' == $this->currentDate->format('G')) { // to obey the interval rule - $this->currentDate = $this->currentDate->modify('+'.$this->interval - 1 .' days'); + $this->currentDate = $this->currentDate->modify('+'.(($amount * $this->interval) - 1).' days'); + $amount = 1; } $this->currentDate = $this->currentDate->modify('+1 hours'); } else { - $this->currentDate = $this->currentDate->modify('+'.$this->interval.' days'); + $this->currentDate = $this->currentDate->modify('+'.($amount * $this->interval).' days'); + $amount = 1; } // Current month of the year @@ -364,10 +403,10 @@ protected function nextDaily() /** * Does the processing for advancing the iterator for weekly frequency. */ - protected function nextWeekly() + protected function nextWeekly($amount = 1) { if (!$this->byHour && !$this->byDay) { - $this->currentDate = $this->currentDate->modify('+'.$this->interval.' weeks'); + $this->currentDate = $this->currentDate->modify('+'.($amount * $this->interval).' weeks'); return; } @@ -398,8 +437,8 @@ protected function nextWeekly() // We need to roll over to the next week if ($currentDay === $firstDay && (!$this->byHour || '0' == $currentHour)) { - $this->currentDate = $this->currentDate->modify('+'.$this->interval - 1 .' weeks'); - + $this->currentDate = $this->currentDate->modify('+'.(($amount * $this->interval) - 1).' weeks'); + $amount = 1; // We need to go to the first day of this week, but only if we // are not already on this first day of this week. if ($this->currentDate->format('w') != $firstDay) { @@ -414,17 +453,20 @@ protected function nextWeekly() /** * Does the processing for advancing the iterator for monthly frequency. */ - protected function nextMonthly() + protected function nextMonthly($amount = 1) { $currentDayOfMonth = $this->currentDate->format('j'); + $currentHourOfMonth = $this->currentDate->format('G'); + $currentMinuteOfMonth = $this->currentDate->format('i'); + $currentSecondOfMonth = $this->currentDate->format('s'); if (!$this->byMonthDay && !$this->byDay) { // If the current day is higher than the 28th, rollover can // occur to the next month. We Must skip these invalid // entries. if ($currentDayOfMonth < 29) { - $this->currentDate = $this->currentDate->modify('+'.$this->interval.' months'); + $this->currentDate = $this->currentDate->modify('+'.($amount * $this->interval).' months'); } else { - $increase = 0; + $increase = $amount - 1; do { ++$increase; $tempDate = clone $this->currentDate; @@ -440,9 +482,25 @@ protected function nextMonthly() $occurrences = $this->getMonthlyOccurrences(); foreach ($occurrences as $occurrence) { - // The first occurrence thats higher than the current + // The first occurrence that's higher than the current // day of the month wins. - if ($occurrence > $currentDayOfMonth) { + if ($occurrence[0] > $currentDayOfMonth) { + break 2; + } elseif ($occurrence[0] < $currentDayOfMonth) { + continue; + } + if ($occurrence[1] > $currentHourOfMonth) { + break 2; + } elseif ($occurrence[1] < $currentHourOfMonth) { + continue; + } + + if ($occurrence[2] > $currentMinuteOfMonth) { + break 2; + } elseif ($occurrence[2] < $currentMinuteOfMonth) { + continue; + } + if ($occurrence[3] > $currentSecondOfMonth) { break 2; } } @@ -450,34 +508,41 @@ protected function nextMonthly() // If we made it all the way here, it means there were no // valid occurrences, and we need to advance to the next // month. - // - // This line does not currently work in hhvm. Temporary workaround - // follows: - // $this->currentDate->modify('first day of this month'); - $this->currentDate = new DateTimeImmutable($this->currentDate->format('Y-m-1 H:i:s'), $this->currentDate->getTimezone()); + $this->currentDate = $this->currentDate->setDate( + (int) $this->currentDate->format('Y'), + (int) $this->currentDate->format('n'), + 1 + ); // end of workaround - $this->currentDate = $this->currentDate->modify('+ '.$this->interval.' months'); + $this->currentDate = $this->currentDate->modify('+ '.($amount * $this->interval).' months'); + $amount = 1; // This goes to 0 because we need to start counting at the // beginning. $currentDayOfMonth = 0; + $currentHourOfMonth = 0; + $currentMinuteOfMonth = 0; + $currentSecondOfMonth = 0; } $this->currentDate = $this->currentDate->setDate( (int) $this->currentDate->format('Y'), (int) $this->currentDate->format('n'), - (int) $occurrence - ); + $occurrence[0] + )->setTime($occurrence[1], $occurrence[2], $occurrence[3]); } /** * Does the processing for advancing the iterator for yearly frequency. */ - protected function nextYearly() + protected function nextYearly($amount = 1) { - $currentMonth = $this->currentDate->format('n'); $currentYear = $this->currentDate->format('Y'); + $currentMonth = $this->currentDate->format('n'); $currentDayOfMonth = $this->currentDate->format('j'); + $currentHourOfMonth = $this->currentDate->format('G'); + $currentMinuteOfMonth = $this->currentDate->format('i'); + $currentSecondOfMonth = $this->currentDate->format('s'); // No sub-rules, so we just advance by year if (empty($this->byMonth)) { @@ -537,7 +602,8 @@ protected function nextYearly() } // if there is no date found, check the next year - $currentYear += $this->interval; + $currentYear += $amount * $this->interval; + $amount = 1; } } @@ -559,11 +625,12 @@ protected function nextYearly() // loop through all YearDay and Days to check all the combinations foreach ($this->byYearDay as $byYearDay) { $date = clone $this->currentDate; - $date = $date->setDate($currentYear, 1, 1); if ($byYearDay > 0) { - $date = $date->add(new \DateInterval('P'.$byYearDay.'D')); + $date = $date->setDate($currentYear, 1, 1); + $date = $date->add(new \DateInterval('P'.($byYearDay - 1).'D')); } else { - $date = $date->sub(new \DateInterval('P'.abs($byYearDay).'D')); + $date = $date->setDate($currentYear, 12, 31); + $date = $date->sub(new \DateInterval('P'.abs($byYearDay + 1).'D')); } if ($date > $this->currentDate && in_array($date->format('N'), $dayOffsets)) { @@ -578,35 +645,49 @@ protected function nextYearly() } // if there is no date found, check the next year - $currentYear += $this->interval; + $currentYear += ($amount * $this->interval); + $amount = 1; } } // The easiest form - $this->currentDate = $this->currentDate->modify('+'.$this->interval.' years'); + $this->currentDate = $this->currentDate->modify('+'.($amount * $this->interval).' years'); return; } - $currentMonth = $this->currentDate->format('n'); - $currentYear = $this->currentDate->format('Y'); - $currentDayOfMonth = $this->currentDate->format('j'); - $advancedToNewMonth = false; // If we got a byDay or getMonthDay filter, we must first expand // further. if ($this->byDay || $this->byMonthDay) { while (true) { - $occurrences = $this->getMonthlyOccurrences(); - - foreach ($occurrences as $occurrence) { - // The first occurrence that's higher than the current - // day of the month wins. - // If we advanced to the next month or year, the first - // occurrence is always correct. - if ($occurrence > $currentDayOfMonth || $advancedToNewMonth) { - break 2; + // If the start date is incorrect we must directly jump to the next value + if (in_array($currentMonth, $this->byMonth)) { + $occurrences = $this->getMonthlyOccurrences(); + foreach ($occurrences as $occurrence) { + // The first occurrence that's higher than the current + // day of the month wins. + // If we advanced to the next month or year, the first + // occurrence is always correct. + if ($occurrence[0] > $currentDayOfMonth || $advancedToNewMonth) { + break 2; + } elseif ($occurrence[0] < $currentDayOfMonth) { + continue; + } + if ($occurrence[1] > $currentHourOfMonth) { + break 2; + } elseif ($occurrence[1] < $currentHourOfMonth) { + continue; + } + if ($occurrence[2] > $currentMinuteOfMonth) { + break 2; + } elseif ($occurrence[2] < $currentMinuteOfMonth) { + continue; + } + if ($occurrence[3] > $currentSecondOfMonth) { + break 2; + } } } @@ -617,7 +698,8 @@ protected function nextYearly() do { ++$currentMonth; if ($currentMonth > 12) { - $currentYear += $this->interval; + $currentYear += ($amount * $this->interval); + $amount = 1; $currentMonth = 1; } } while (!in_array($currentMonth, $this->byMonth)); @@ -633,8 +715,8 @@ protected function nextYearly() $this->currentDate = $this->currentDate->setDate( (int) $currentYear, (int) $currentMonth, - (int) $occurrence - ); + (int) $occurrence[0] + )->setTime($occurrence[1], $occurrence[2], $occurrence[3]); return; } else { @@ -798,7 +880,8 @@ protected function parseRRule($rrule) * Returns all the occurrences for a monthly frequency with a 'byDay' or * 'byMonthDay' expansion for the current month. * - * The returned list is an array of integers with the day of month (1-31). + * The returned list is an array of arrays with as first element the day of month (1-31); + * the hour; the minute and second of the occurence * * @return array */ @@ -884,8 +967,23 @@ protected function getMonthlyOccurrences() } else { $result = $byDayResults; } - $result = array_unique($result); - sort($result, SORT_NUMERIC); + + $result = $this->addDailyOccurences($result); + $result = array_unique($result, SORT_REGULAR); + $sortLex = function ($a, $b) { + if ($a[0] != $b[0]) { + return $a[0] - $b[0]; + } + if ($a[1] != $b[1]) { + return $a[1] - $b[1]; + } + if ($a[2] != $b[2]) { + return $a[2] - $b[2]; + } + + return $a[3] - $b[3]; + }; + usort($result, $sortLex); // The last thing that needs checking is the BYSETPOS. If it's set, it // means only certain items in the set survive the filter. @@ -903,11 +1001,40 @@ protected function getMonthlyOccurrences() } } - sort($filteredResult, SORT_NUMERIC); + usort($result, $sortLex); return $filteredResult; } + /** + * Expends daily occurrences to an array of days that an event occurs on. + * + * @param array $result an array of integers with the day of month (1-31); + * + * @return array an array of arrays with the day of the month, hours, minute and seconds of the occurence + */ + protected function addDailyOccurences(array $result) + { + $output = []; + $hour = (int) $this->currentDate->format('G'); + $minute = (int) $this->currentDate->format('i'); + $second = (int) $this->currentDate->format('s'); + foreach ($result as $day) { + $seconds = $this->bySecond ? $this->bySecond : [$second]; + $minutes = $this->byMinute ? $this->byMinute : [$minute]; + $hours = $this->byHour ? $this->byHour : [$hour]; + foreach ($hours as $h) { + foreach ($minutes as $m) { + foreach ($seconds as $s) { + $output[] = [(int) $day, (int) $h, (int) $m, (int) $s]; + } + } + } + } + + return $output; + } + /** * Simple mapping from iCalendar day names to day numbers. * diff --git a/tests/VObject/Recur/FastForwardTest.php b/tests/VObject/Recur/FastForwardTest.php new file mode 100644 index 000000000..df9120561 --- /dev/null +++ b/tests/VObject/Recur/FastForwardTest.php @@ -0,0 +1,431 @@ +fastForward($ffDate); + $ru = getrusage(); + $endTime = $ru['ru_utime.tv_sec'] * 1000000 + $ru['ru_utime.tv_usec']; + $this->assertLessThan(self::FF_TIMEOUT, $endTime - $startTime); + } + + public function testFastForwardYearlyBasic() + { + $startDate = new DateTime('1970-10-23 00:00:00', new DateTimeZone('zulu')); + $ffDate = new DateTime('midnight', new DateTimeZone('zulu')); + $ffDate->setDate(99999, 1, 1); + $rrule = new RRuleIterator('FREQ=YEARLY', $startDate); + + $this->fastForward($rrule, $ffDate); + + $year = 60 * 60 * 24 * 365; + $expected = (new DateTime()) + ->setTimezone(new DateTimeZone('zulu')) + ->setDate(99999, 10, 23) + ->setTime(0, 0, 0) + ->getTimestamp(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + $rrule->next(); + // It's a leap + $expected += $year + 24 * 60 * 60; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + $rrule->next(); + $expected += $year; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + $rrule->next(); + $expected += $year; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + $rrule->next(); + $expected += $year; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + $rrule->next(); + // leap + $expected += $year + 24 * 60 * 60; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + $rrule->next(); + $expected += $year; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + $rrule->next(); + $expected += $year; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + $rrule->next(); + $expected += $year; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + $rrule->next(); + // leap + $expected += $year + 24 * 60 * 60; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + $rrule->next(); + } + + public function testFastForwardYearlyByYearDay() + { + $startDate = new \DateTime('1970-10-23 00:00:00', new \DateTimeZone('zulu')); + $ffDate = new \DateTime('midnight', new DateTimeZone('zulu')); + $ffDate->setDate(99998, 12, 31); + $rrule = new RRuleIterator('FREQ=YEARLY;BYYEARDAY=1,20,300', $startDate); + + $this->fastForward($rrule, $ffDate); + + $day = 60 * 60 * 24; + $expected = (new DateTime()) + ->setTimezone(new DateTimeZone('zulu')) + ->setDate(99999, 1, 1)// 20th day + ->setTime(0, 0, 0) + ->getTimestamp(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + $rrule->next(); + // 300th day + $expected += 19 * $day; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + $rrule->next(); + // 1st day + $expected += 280 * $day; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + // 20th day + $expected += 66 * $day; + $rrule->next(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + // 300th day + $rrule->next(); + $expected += 19 * $day; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + $rrule->next(); + $expected += 280 * $day; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + $rrule->next(); + // 1st day (leap year, we have 366 days in this year) + $expected += 67 * $day; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + $rrule->next(); + $expected += 19 * $day; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + } + + public function testFastForwardYearlyByWeekNo() + { + $startDate = new \DateTime('1970-10-23 00:00:00', new \DateTimeZone('zulu')); + $ffDate = new \DateTime('midnight', new DateTimeZone('zulu')); + $ffDate->setDate(99999, 1, 1); + $rrule = new RRuleIterator('FREQ=YEARLY;BYWEEKNO=1,20', $startDate); + + $this->fastForward($rrule, $ffDate); + + $day = 60 * 60 * 24; + $week = 7 * $day; + $expected = (new DateTime()) + ->setTimezone(new DateTimeZone('zulu')) + ->setDate(99999, 1, 4)// 1st day + ->setTime(0, 0, 0) + ->getTimestamp(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + $rrule->next(); + $expected += $week * 19; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + } + + public function testFastForwardYearlyAdvanced() + { + $startDate = new \DateTime('1970-10-23 12:34:56', new \DateTimeZone('zulu')); + $ffDate = new \DateTime('midnight', new DateTimeZone('zulu')); + $ffDate->setDate(9999, 1, 20)->setTime(0, 0, 13); + $rrule = new RRuleIterator('FREQ=YEARLY;INTERVAL=2;BYMONTH=1;BYDAY=SU;BYHOUR=8,9;BYMINUTE=30', $startDate); + + $this->fastForward($rrule, $ffDate); + + $expected = (new DateTime('midnight', new DateTimeZone('zulu'))) + ->setDate(10000, 1, 2) + ->setTime(8, 30, 56) + ->getTimestamp(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + $rrule->next(); + $expected += 60 * 60; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + $rrule->next(); + $expected += 7 * 24 * 60 * 60 - 60 * 60; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + $rrule->next(); + $expected += 60 * 60; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + $rrule->next(); + $expected += 7 * 24 * 60 * 60 - 60 * 60; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + $rrule->next(); + $expected += 60 * 60; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + $rrule->next(); + $expected += 7 * 24 * 60 * 60 - 60 * 60; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + $rrule->next(); + $expected += 60 * 60; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + $rrule->next(); + $expected += 7 * 24 * 60 * 60 - 60 * 60; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + $rrule->next(); + $expected += 60 * 60; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + // jump to 6th january 10002 + $rrule->next(); + $expected = (new DateTime('midnight', new DateTimeZone('zulu'))) + ->setDate(10002, 1, 6) + ->setTime(8, 30, 56) + ->getTimestamp(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + $rrule->next(); + $expected += 60 * 60; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + } + + public function testFastForwardMonthlyBasic() + { + $startDate = new \DateTime('1970-10-23 22:42:31', new \DateTimeZone('zulu')); + $ffDate = new \DateTime('midnight', new DateTimeZone('zulu')); + $ffDate->setDate(18000, 1, 1); + $rrule = new RRuleIterator('FREQ=MONTHLY', $startDate); + + $this->fastForward($rrule, $ffDate); + + $expected = (new DateTime('midnight', new DateTimeZone('zulu'))) + ->setDate(18000, 1, 23) + ->setTime(22, 42, 31) + ->getTimestamp(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + // february + $rrule->next(); + $expected += 31 * 24 * 60 * 60; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + // march + $rrule->next(); + $expected += 29 * 24 * 60 * 60; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + // april + $rrule->next(); + $expected += 31 * 24 * 60 * 60; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + // may + $rrule->next(); + $expected += 30 * 24 * 60 * 60; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + // june + $rrule->next(); + $expected += 31 * 24 * 60 * 60; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + // july + $rrule->next(); + $expected += 30 * 24 * 60 * 60; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + // august + $rrule->next(); + $expected += 31 * 24 * 60 * 60; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + } + + public function testFastForwardMonthly31thDay() + { + $timezone = 'America/New_York'; + $startDate = new \DateTime('1970-01-31 00:00:00', new \DateTimeZone($timezone)); + $ffDate = new \DateTime('midnight', new DateTimeZone('zulu')); + $ffDate->setDate(18000, 1, 1); + $rrule = new RRuleIterator('FREQ=MONTHLY', $startDate); + + $this->fastForward($rrule, $ffDate); + + $expected = (new DateTime('midnight', new DateTimeZone('America/New_York'))) + ->setDate(18000, 1, 31) + ->getTimestamp(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + // march + $rrule->next(); + $expected += (29 + 31) * 24 * 60 * 60; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + // may + $rrule->next(); + $expected += (30 + 31) * 24 * 60 * 60; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + // july + $rrule->next(); + $expected += (30 + 31) * 24 * 60 * 60; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + // august + $rrule->next(); + $expected += 31 * 24 * 60 * 60; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + // october + $rrule->next(); + $expected += (30 + 31) * 24 * 60 * 60; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + // december + $rrule->next(); + $expected += (30 + 31) * 24 * 60 * 60; + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + } + + public function testFastForwardMonthlyAdvanced() + { + $timezone = 'America/New_York'; + $startDate = new \DateTime('1970-01-31 00:00:00', new DateTimeZone($timezone)); + $ffDate = new \DateTime('midnight', new DateTimeZone('zulu')); + $ffDate->setDate(8000, 1, 1); + $rrule = new RRuleIterator('FREQ=MONTHLY;INTERVAL=2;BYDAY=1MO,2TU,3WE,4TH', $startDate); + + $this->fastForward($rrule, $ffDate); + + // monday + $expected = (new DateTime('midnight', new DateTimeZone($timezone))) + ->setDate(8000, 1, 3) + ->getTimestamp(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + // tuesday + $expected += 8 * 24 * 60 * 60; + $rrule->next(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + // wednesday + $expected += 8 * 24 * 60 * 60; + $rrule->next(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + // thursday + $expected += 8 * 24 * 60 * 60; + $rrule->next(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + // monday march + $expected += (29 + 10) * 24 * 60 * 60; + $rrule->next(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + // tuesday + $expected += 8 * 24 * 60 * 60; + $rrule->next(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + // wednesday (this month starts on wednesday so that's just the next day) + $expected += 1 * 24 * 60 * 60; + $rrule->next(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + // thursday + $expected += 8 * 24 * 60 * 60; + $rrule->next(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + } + + public function testFastForwardDailyBasic() + { + $timezone = 'America/New_York'; + $startDate = new \DateTime('1970-10-23 00:00:00', new \DateTimeZone($timezone)); + $ffDate = new \DateTime('midnight', new DateTimeZone('zulu')); + $ffDate->setDate(4000, 1, 1); + $rrule = new RRuleIterator('FREQ=DAILY', $startDate); + + $this->fastForward($rrule, $ffDate); + + $expected = (new DateTime('midnight', new DateTimeZone($timezone))) + ->setDate(4000, 1, 1) + ->getTimestamp(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + $expected += 24 * 60 * 60; + $rrule->next(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + $expected += 24 * 60 * 60; + $rrule->next(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + $expected += 24 * 60 * 60; + $rrule->next(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + $expected += 24 * 60 * 60; + $rrule->next(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + $expected += 24 * 60 * 60; + $rrule->next(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + $expected += 24 * 60 * 60; + $rrule->next(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + } + + public function testFastForwardDailyAdvanced() + { + $timezone = 'America/New_York'; + $startDate = new \DateTime('1970-10-23 00:00:00', new \DateTimeZone($timezone)); + $ffDate = new \DateTime('midnight', new DateTimeZone('zulu')); + $ffDate->setDate(4000, 1, 1); + $rrule = new RRuleIterator('FREQ=DAILY;BYHOUR=16,17,18;INTERVAL=10', $startDate); + + $this->fastForward($rrule, $ffDate); + + $expected = (new DateTime('midnight', new DateTimeZone($timezone))) + ->setDate(4000, 1, 4) + ->setTime(16, 0, 0) + ->getTimestamp(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + // 17:00 + $expected += 60 * 60; + $rrule->next(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + // 18:00 + $expected += 60 * 60; + $rrule->next(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + // 16:00 + $expected += 10 * 24 * 60 * 60 - 2 * 60 * 60; + $rrule->next(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + // 17:00 + $expected += 60 * 60; + $rrule->next(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + // 18:00 + $expected += 60 * 60; + $rrule->next(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + + // 16:00 + $expected += 10 * 24 * 60 * 60 - 2 * 60 * 60; + $rrule->next(); + $this->assertEquals($expected, $rrule->current()->getTimestamp()); + } +} diff --git a/tests/VObject/Recur/RRuleIteratorTest.php b/tests/VObject/Recur/RRuleIteratorTest.php index b8b1706d8..6e7d47dd7 100644 --- a/tests/VObject/Recur/RRuleIteratorTest.php +++ b/tests/VObject/Recur/RRuleIteratorTest.php @@ -488,19 +488,36 @@ public function testYearlyByMonthByDay() ); } + public function testYearlyNewYearsEve() + { + $this->parse( + 'FREQ=YEARLY;COUNT=7;INTERVAL=2;BYYEARDAY=1', + '2011-01-01 03:07:00', + [ + '2011-01-01 03:07:00', + '2013-01-01 03:07:00', + '2015-01-01 03:07:00', + '2017-01-01 03:07:00', + '2019-01-01 03:07:00', + '2021-01-01 03:07:00', + '2023-01-01 03:07:00', + ] + ); + } + public function testYearlyByYearDay() { $this->parse( 'FREQ=YEARLY;COUNT=7;INTERVAL=2;BYYEARDAY=190', - '2011-07-10 03:07:00', + '2011-07-09 03:07:00', [ - '2011-07-10 03:07:00', - '2013-07-10 03:07:00', - '2015-07-10 03:07:00', - '2017-07-10 03:07:00', - '2019-07-10 03:07:00', - '2021-07-10 03:07:00', - '2023-07-10 03:07:00', + '2011-07-09 03:07:00', + '2013-07-09 03:07:00', + '2015-07-09 03:07:00', + '2017-07-09 03:07:00', + '2019-07-09 03:07:00', + '2021-07-09 03:07:00', + '2023-07-09 03:07:00', ] ); } @@ -521,7 +538,7 @@ public function testYearlyByYearDayImmutable() $parser->next(); $item = $parser->current(); - $this->assertEquals($item->format('Y-m-d H:i:s'), '2013-07-10 03:07:00'); + $this->assertEquals($item->format('Y-m-d H:i:s'), '2013-07-09 03:07:00'); } public function testYearlyByYearDayMultiple() @@ -531,13 +548,13 @@ public function testYearlyByYearDayMultiple() '2011-07-10 14:53:11', [ '2011-07-10 14:53:11', - '2011-10-29 14:53:11', - '2014-07-10 14:53:11', - '2014-10-29 14:53:11', - '2017-07-10 14:53:11', - '2017-10-29 14:53:11', - '2020-07-09 14:53:11', - '2020-10-28 14:53:11', + '2011-10-28 14:53:11', + '2014-07-09 14:53:11', + '2014-10-28 14:53:11', + '2017-07-09 14:53:11', + '2017-10-28 14:53:11', + '2020-07-08 14:53:11', + '2020-10-27 14:53:11', ] ); } @@ -549,11 +566,11 @@ public function testYearlyByYearDayByDay() '2001-04-07 14:53:11', [ '2001-04-07 14:53:11', - '2006-04-08 14:53:11', - '2012-04-07 14:53:11', - '2017-04-08 14:53:11', - '2023-04-08 14:53:11', - '2034-04-08 14:53:11', + '2007-04-07 14:53:11', + '2018-04-07 14:53:11', + '2024-04-06 14:53:11', + '2029-04-07 14:53:11', + '2035-04-07 14:53:11', ] ); } @@ -576,6 +593,46 @@ public function testYearlyByYearDayNegative() ); } + public function testFirstLastSundayEveryOtherYearAt1530and1730InJanuary() + { + $this->parse('FREQ=YEARLY;INTERVAL=2;BYMONTH=1;BYDAY=1SU,-1SU;BYHOUR=15,17;BYMINUTE=30,35;BYSECOND=15,56', + '1999-12-01 12:34:56', + [ + '1999-12-01 12:34:56', + '2001-01-07 15:30:15', '2001-01-07 15:30:56', '2001-01-07 15:35:15', '2001-01-07 15:35:56', + '2001-01-07 17:30:15', '2001-01-07 17:30:56', '2001-01-07 17:35:15', '2001-01-07 17:35:56', + + '2001-01-28 15:30:15', '2001-01-28 15:30:56', '2001-01-28 15:35:15', '2001-01-28 15:35:56', + '2001-01-28 17:30:15', '2001-01-28 17:30:56', '2001-01-28 17:35:15', '2001-01-28 17:35:56', + + '2003-01-05 15:30:15', '2003-01-05 15:30:56', '2003-01-05 15:35:15', '2003-01-05 15:35:56', + '2003-01-05 17:30:15', '2003-01-05 17:30:56', '2003-01-05 17:35:15', '2003-01-05 17:35:56', + + '2003-01-26 15:30:15', '2003-01-26 15:30:56', '2003-01-26 15:35:15', '2003-01-26 15:35:56', + '2003-01-26 17:30:15', '2003-01-26 17:30:56', '2003-01-26 17:35:15', '2003-01-26 17:35:56', + ]); + } + + public function testFirstFourthSundayEveryOtherMonthAt830and930() + { + $this->parse('FREQ=MONTHLY;INTERVAL=2;BYDAY=1SU,4SU;BYHOUR=15,17;BYMINUTE=30,32;BYSECOND=11,12', + '2001-01-01 12:34:56', + [ + '2001-01-01 12:34:56', + '2001-01-07 15:30:11', '2001-01-07 15:30:12', '2001-01-07 15:32:11', '2001-01-07 15:32:12', + '2001-01-07 17:30:11', '2001-01-07 17:30:12', '2001-01-07 17:32:11', '2001-01-07 17:32:12', + + '2001-01-28 15:30:11', '2001-01-28 15:30:12', '2001-01-28 15:32:11', '2001-01-28 15:32:12', + '2001-01-28 17:30:11', '2001-01-28 17:30:12', '2001-01-28 17:32:11', '2001-01-28 17:32:12', + + '2001-03-04 15:30:11', '2001-03-04 15:30:12', '2001-03-04 15:32:11', '2001-03-04 15:32:12', + '2001-03-04 17:30:11', '2001-03-04 17:30:12', '2001-03-04 17:32:11', '2001-03-04 17:32:12', + + '2001-03-25 15:30:11', '2001-03-25 15:30:12', '2001-03-25 15:32:11', '2001-03-25 15:32:12', + '2001-03-25 17:30:11', '2001-03-25 17:30:12', '2001-03-25 17:32:11', '2001-03-25 17:32:12', + ]); + } + /** * @expectedException \Sabre\VObject\InvalidDataException */