diff --git a/.horde.yml b/.horde.yml index 2e7bd88..8c4823b 100644 --- a/.horde.yml +++ b/.horde.yml @@ -7,11 +7,23 @@ list: dev type: library homepage: https://www.horde.org/libraries/Horde_Date authors: + - + name: Ralf Lang + user: rlang + email: ralf.lang@ralf-lang.de + active: true + role: lead + - + name: Torben Dannhauer + user: tdannhauer + email: torben@dannhauer.de + active: true + role: releasemanager - name: Jan Schneider user: jan email: jan@horde.org - active: true + active: false role: lead - name: Chuck Hagenbuch @@ -30,7 +42,7 @@ license: uri: http://www.horde.org/licenses/lgpl21 dependencies: required: - php: ^7.4 || ^8 + php: ^8.1 composer: horde/exception: ^3 horde/nls: ^3 @@ -48,3 +60,7 @@ dependencies: ext: calendar: '*' vendor: horde +keywords: + - recurrence + - legacy_timezones + - spans diff --git a/doc/Horde/Date/UPGRADING.rst b/doc/Horde/Date/UPGRADING.rst deleted file mode 100644 index 34ce72f..0000000 --- a/doc/Horde/Date/UPGRADING.rst +++ /dev/null @@ -1,69 +0,0 @@ -====================== - Upgrading Horde_Date -====================== - -:Contact: dev@lists.horde.org - -.. contents:: Contents -.. section-numbering:: - - -This lists the API changes between releases of the package. - - -Upgrading to 2.4.0 -================== - - - Horde_Date_Recurrence - - - toHash(), fromHash() - - These method have been added. - - -Upgrading to 2.3.0 -================== - - - Horde_Date - - - getTimezoneAlias() - - This method has been added. - - -Upgrading to 2.2.0 -================== - - - Horde_Date - - - isEqual() - - This method has been added. - - -Upgrading to 2.2.0 -================== - - - Horde_Date - - - setNthWeekday() - - The $nth parameter accepts negative values. - - - Horde_Recurrence - - - RECUR_MONTHLY_LAST_WEEKDAY - - This constant has been added as a possible value for the $recurType - property. - - -Upgrading to 2.0.0 -================== - - - Horde_Date_Recurrence - - - toHash(), fromHash() - - These method have been renamed to toKolab() and fromKolab(), because the - hash format is really Kolab-specific. diff --git a/doc/UPGRADING.md b/doc/UPGRADING.md new file mode 100644 index 0000000..4516fe8 --- /dev/null +++ b/doc/UPGRADING.md @@ -0,0 +1,241 @@ +# Upgrading Horde_Date + +Contact: dev@lists.horde.org + +This lists the API changes between releases of the package. + +--- + +## Upgrading to 3.0.0 + +Horde_Date 3.0 introduces modern PSR-4 classes alongside the legacy API. +Two upgrade paths are available depending on whether you can adopt the new +class model or need to stay type-compatible. + +### New classes + +- `Horde\Date\Date` — Immutable date class extending `DateTimeImmutable`. + Implements `Horde\Date\DateInterface`. + +- `Horde\Date\HordeLegacyDate` — Mutable drop-in replacement for + `Horde_Date`. Extends `Horde_Date` but stores all state in a private + `DateTimeImmutable`. The parent's protected fields are dead storage; + all reads and writes go through the internal immutable. + +- `Horde\Date\Recurrence\Recurrence` — Modern recurrence engine with typed + API, backed enum and `DateTimeImmutable` throughout. + +- `Horde_Date_Recurrence` — Now a thin wrapper delegating to + `Horde\Date\Recurrence\Recurrence`. Preserves the legacy public API. + +### Behavioral change in HordeLegacyDate: overflow direction + +`HordeLegacyDate` replaces the legacy `_correct()` overflow engine with +PHP's native `DateTimeImmutable::setDate()` / `setTime()` normalization. +The two engines agree in almost all cases. They diverge when a date component +is **decreased** past a month boundary with a day that exceeds the target +month's length: + +```php +// March 31 minus one month +$d = new Horde_Date('2026-03-31'); +$d->month = 2; +// Horde_Date: 2026-02-28 (clamped backward) +// HordeLegacyDate: 2026-03-03 (overflowed forward) + +// Leap-year edge: Feb 29 on a leap year, year decreased to non-leap +$d = new Horde_Date('2028-02-29'); +$d->year = 2026; +// Horde_Date: 2026-02-26 (clamped backward) +// HordeLegacyDate: 2026-03-01 (overflowed forward) +``` + +The legacy engine uses a `$down` flag in `_correct()` that subtracts the +difference in month lengths when the value decreased. PHP's date math always +overflows forward. The forward-overflow behavior is more predictable and +matches what `DateTime` / `DateTimeImmutable` produce natively. + +This affects code that: + +- Sets `->month` to a lower value on a date with day 29–31. +- Calls `sub(['month' => N])` when the start day exceeds the target month. +- Sets `->year` to a lower value on Feb 29 of a leap year. + +### Upgrade path A: Adopt the modern model (preferred) + +Replace `Horde_Date` usage with `Horde\Date\Date` (immutable) or native +`DateTimeImmutable`. This is the recommended path for new code and for +existing code that can tolerate an API change. + +**What changes:** + +1. Construction from components uses a formatted string or + `DateTimeImmutable::createFromFormat()`: + + ```php + // Before + $d = new Horde_Date(['year' => 2026, 'month' => 4, 'mday' => 17]); + + // After + use Horde\Date\Date; + $d = new Date('2026-04-17'); + ``` + +2. Property mutation becomes `with*` / `set*` returning new instances: + + ```php + // Before + $d->month = 6; + $d->setTimezone('Europe/Berlin'); + + // After + $d = $d->setDate(2026, 6, 17); + $d = $d->setTimezone(new DateTimeZone('Europe/Berlin')); + ``` + +3. Arithmetic uses `addParts()` / `subParts()` instead of `add()` with + arrays: + + ```php + // Before + $result = $d->add(['month' => 1, 'mday' => 5]); + + // After + $result = $d->addParts(months: 1, days: 5); + ``` + +4. Comparison accepts `DateTimeInterface`: + + ```php + // Before (required Horde_Date or auto-constructed) + $d->compareDate($other); + + // After (any DateTimeInterface) + $d->compareDate($other); + ``` + +5. `strftime()` is removed. Use `format()` with `DateTimeFormatter` or + `IcuFormatter` for locale-aware output: + + ```php + // Before + $d->strftime('%B %d, %Y'); + + // After + use Horde\Date\Formatter\IcuFormatter; + $d->format('MMMM dd, yyyy', IcuFormatter::class, 'en_US'); + ``` + +6. `Horde_Date_Recurrence` callers should migrate to + `Horde\Date\Recurrence\Recurrence`, which uses `DateTimeImmutable` + throughout and `RecurrenceType` enum instead of integer constants. + +**Benefits:** + +- Immutability prevents accidental state corruption. +- Full `DateTimeInterface` compatibility (pass to any PHP API). +- No `__get` / `__set` overhead. +- Overflow semantics match PHP's standard library exactly. + +### Upgrade path B: Drop-in replacement with caveats (compromise) + +Replace `new Horde_Date(...)` with `new HordeLegacyDate(...)` in your +code, or configure your autoloader / DI container to substitute the class. +`HordeLegacyDate` extends `Horde_Date` and passes `instanceof` checks. + +**What works identically:** + +- All property reads and writes (`$d->year`, `$d->month = 13`, etc.). +- Overflow normalization for increases (month 13 → January next year, day 32 → + next month, hour 25 → next day, etc.). +- `add()` / `sub()` with integer seconds. +- `add()` / `sub()` with array factors, **except** the down-clamping case. +- `setTimezone()` (converts wall-clock time). +- `$d->timezone = 'X'` (reinterprets wall-clock time in new zone). +- All formatting (`format()`, `strftime()`, `__toString()`). +- All comparison methods. +- All serialization (`timestamp()`, `toJson()`, `toiCalendar()`, etc.). +- All conversion (`toDateTime()`, `toDateTimeImmutable()`, `toDate()`). +- All calendar calculations (`dayOfWeek()`, `toDays()`, `weekOfYear()`, + `setNthWeekday()`, etc.). +- `clone` produces independent copies. + +**What changes:** + +- Decreasing `->month` or `->year` when day exceeds the target month's + length overflows forward instead of clamping backward. See the behavioral + change section above. +- `sub(['month' => 1])` from March 31 yields March 3 instead of Feb 28. +- The `_correct()` method on the parent is never called. Subclasses that + override `_correct()` will not see their override invoked. +- Protected fields `$_year`, `$_month`, `$_mday`, `$_hour`, + `$_min`, `$_sec` are stale after construction. Code that accesses these + directly (bypassing `__get`) will read incorrect values. + +**Recommended guard for the overflow difference:** + +If your code subtracts months and relies on day clamping, clamp explicitly: + +```php +use Horde\Date\HordeLegacyDate; +use Horde\Date\Utils; + +$d = new HordeLegacyDate('2026-03-31'); +$targetMonth = $d->month - 1; +$d->month = $targetMonth; +// If day overflowed past the target month, clamp back +if ($d->month !== $targetMonth) { + $d->mday = Utils::daysInMonth($targetMonth, $d->year); + $d->month = $targetMonth; +} +``` + +### Upgrading Horde_Date_Recurrence + +`Horde_Date_Recurrence` is now a thin wrapper over +`Horde\Date\Recurrence\Recurrence`. The public API is unchanged. Callers +that mutate `->start` or `->recurEnd` through the property and then call +methods on the result should use the setter instead: + +```php +// Before (silently broken with wrapper — mutates a detached copy) +$event->recurrence->start->setTimezone($tz); + +// After (safe with both legacy and wrapper) +$event->recurrence->setRecurStart( + (clone $event->recurrence->start)->setTimezone($tz) +); +``` + +The `fromRRule10()` method now correctly parses `MONTHLY_LAST_WEEKDAY` +rules (the legacy implementation had a trim bug that mapped them to +`MONTHLY_WEEKDAY`). + +--- + +## Upgrading to 2.4.0 + +- Horde_Date_Recurrence + - `toHash()`, `fromHash()` — These methods have been added. + +## Upgrading to 2.3.0 + +- Horde_Date + - `getTimezoneAlias()` — This method has been added. + +## Upgrading to 2.2.0 + +- Horde_Date + - `isEqual()` — This method has been added. + - `setNthWeekday()` — The `$nth` parameter accepts negative values. + +- Horde_Recurrence + - `RECUR_MONTHLY_LAST_WEEKDAY` — This constant has been added as a + possible value for the `$recurType` property. + +## Upgrading to 2.0.0 + +- Horde_Date_Recurrence + - `toHash()`, `fromHash()` — These methods have been renamed to + `toKolab()` and `fromKolab()`, because the hash format is + really Kolab-specific. diff --git a/lib/Horde/Date.php b/lib/Horde/Date.php index 510a746..67f3505 100644 --- a/lib/Horde/Date.php +++ b/lib/Horde/Date.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * Copyright 2004-2017 Horde LLC (http://www.horde.org/) + * Copyright 2004-2026 Horde LLC (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. @@ -52,6 +52,7 @@ * timestamp. I usually go with the former - using database datetime type. */ +use Horde\Date\Date; use Horde\Date\DateInterface; use Horde\Date\Formatter\DateTimeFormatter; use Horde\Date\FormatterInterface; @@ -570,7 +571,7 @@ public function __construct($date = null, $timezone = null, $locale = null) // - "20010203040506" (14 digits) = NOT matched, falls through to DateTime // Related: https://github.com/horde/Date/issues/6 // Related: https://github.com/horde/ActiveSync/pull/15 - $date = (int)$date; + $date = (int) $date; } } @@ -597,21 +598,21 @@ public function __construct($date = null, $timezone = null, $locale = null) date_default_timezone_set($oldtimezone); } } elseif (preg_match('/^(\d{4})-?(\d{2})-?(\d{2})T? ?(\d{2}):?(\d{2}):?(\d{2})(?:\.\d+)?(Z?)$/', $date, $parts)) { - $this->_year = (int)$parts[1]; - $this->_month = (int)$parts[2]; - $this->_mday = (int)$parts[3]; - $this->_hour = (int)$parts[4]; - $this->_min = (int)$parts[5]; - $this->_sec = (int)$parts[6]; + $this->_year = (int) $parts[1]; + $this->_month = (int) $parts[2]; + $this->_mday = (int) $parts[3]; + $this->_hour = (int) $parts[4]; + $this->_min = (int) $parts[5]; + $this->_sec = (int) $parts[6]; if ($parts[7]) { $this->_initializeTimezone('UTC'); } - } elseif (preg_match('/^(\d{4})-?(\d{2})-?(\d{2})$/', $date, $parts) && - $parts[2] > 0 && $parts[2] <= 12 && - $parts[3] > 0 && $parts[3] <= 31) { - $this->_year = (int)$parts[1]; - $this->_month = (int)$parts[2]; - $this->_mday = (int)$parts[3]; + } elseif (preg_match('/^(\d{4})-?(\d{2})-?(\d{2})$/', $date, $parts) + && $parts[2] > 0 && $parts[2] <= 12 + && $parts[3] > 0 && $parts[3] <= 31) { + $this->_year = (int) $parts[1]; + $this->_month = (int) $parts[2]; + $this->_mday = (int) $parts[3]; $this->_hour = $this->_min = $this->_sec = 0; } else { if (!empty($timezone)) { @@ -629,12 +630,12 @@ public function __construct($date = null, $timezone = null, $locale = null) $parsed->setTimezone(new DateTimeZone(date_default_timezone_get())); $this->_initializeTimezone(date_default_timezone_get()); } - $this->_year = (int)$parsed->format('Y'); - $this->_month = (int)$parsed->format('m'); - $this->_mday = (int)$parsed->format('d'); - $this->_hour = (int)$parsed->format('H'); - $this->_min = (int)$parsed->format('i'); - $this->_sec = (int)$parsed->format('s'); + $this->_year = (int) $parsed->format('Y'); + $this->_month = (int) $parsed->format('m'); + $this->_mday = (int) $parsed->format('d'); + $this->_hour = (int) $parsed->format('H'); + $this->_min = (int) $parsed->format('i'); + $this->_sec = (int) $parsed->format('s'); } } @@ -661,14 +662,29 @@ public function toDateTime() { try { $date = new DateTime('now', new DateTimeZone($this->_timezone)); - $date->setDate((int)$this->_year, (int)$this->_month, (int)$this->_mday); - $date->setTime((int)$this->_hour, (int)$this->_min, (int)$this->_sec); + $date->setDate((int) $this->_year, (int) $this->_month, (int) $this->_mday); + $date->setTime((int) $this->_hour, (int) $this->_min, (int) $this->_sec); } catch (Exception $e) { throw new Horde_Date_Exception($e); } return $date; } + public function toDateTimeImmutable(): DateTimeImmutable + { + return DateTimeImmutable::createFromMutable($this->toDateTime()); + } + + public function toDate(): Date + { + return Date::createFromInterface($this->toDateTime()); + } + + public function getTimezone(): DateTimeZone|false + { + return new DateTimeZone($this->_timezone); + } + /** * Converts a date in the proleptic Gregorian calendar to the no of days * since 24th November, 4714 B.C. @@ -698,9 +714,9 @@ public function toDays() { if (function_exists('GregorianToJD')) { return gregoriantojd( - (int)$this->_month, - (int)$this->_mday, - (int)$this->_year + (int) $this->_month, + (int) $this->_mday, + (int) $this->_year ); } @@ -727,15 +743,15 @@ public function toDays() // one year earlier than they do, because for the purposes // of calculation, the year starts on 1st March: // - return intval((14609700 * $century + ($year == 0 ? 1 : 0)) / 400) + - intval((1461 * $year + 1) / 4) + - intval((153 * $month + 2) / 5) + - $day + 1721118; + return intval((14609700 * $century + ($year == 0 ? 1 : 0)) / 400) + + intval((1461 * $year + 1) / 4) + + intval((153 * $month + 2) / 5) + + $day + 1721118; } else { - return intval(146097 * $century / 4) + - intval(1461 * $year / 4) + - intval((153 * $month + 2) / 5) + - $day + 1721119; + return intval(146097 * $century / 4) + + intval(1461 * $year / 4) + + intval((153 * $month + 2) / 5) + + $day + 1721119; } } @@ -795,7 +811,7 @@ public static function fromDays($days) } } - return new Horde_Date($year, $month, $day); + return new Horde_Date((int) $year, (int) $month, (int) $day); } /** @@ -836,13 +852,13 @@ public function __set($name, $value) $name = 'mday'; } - if ($name != 'year' && $name != 'month' && $name != 'mday' && - $name != 'hour' && $name != 'min' && $name != 'sec') { + if ($name != 'year' && $name != 'month' && $name != 'mday' + && $name != 'hour' && $name != 'min' && $name != 'sec') { throw new InvalidArgumentException('Undefined property ' . $name); } $down = $value < $this->{'_' . $name}; - $this->{'_' . $name} = (int)$value; + $this->{'_' . $name} = (int) $value; $this->_correct(self::$_corrections[$name], $down); $this->_formatCache = []; } @@ -860,9 +876,9 @@ public function __isset($name) if ($name == 'day') { $name = 'mday'; } - return ($name == 'year' || $name == 'month' || $name == 'mday' || - $name == 'hour' || $name == 'min' || $name == 'sec') && - isset($this->{'_' . $name}); + return ($name == 'year' || $name == 'month' || $name == 'mday' + || $name == 'hour' || $name == 'min' || $name == 'sec') + && isset($this->{'_' . $name}); } /** @@ -954,12 +970,12 @@ public function setTimezone($timezone) throw new Horde_Date_Exception($e->getMessage()); } $this->_timezone = $timezone; - $this->_year = (int)$date->format('Y'); - $this->_month = (int)$date->format('m'); - $this->_mday = (int)$date->format('d'); - $this->_hour = (int)$date->format('H'); - $this->_min = (int)$date->format('i'); - $this->_sec = (int)$date->format('s'); + $this->_year = (int) $date->format('Y'); + $this->_month = (int) $date->format('m'); + $this->_mday = (int) $date->format('d'); + $this->_hour = (int) $date->format('H'); + $this->_min = (int) $date->format('i'); + $this->_sec = (int) $date->format('s'); $this->_formatCache = []; return $this; } @@ -989,13 +1005,13 @@ public function dayOfWeek() $year = $this->_year - 1; } - $day = (floor((13 * $month - 1) / 5) + - $this->_mday + ($year % 100) + - floor(($year % 100) / 4) + - floor(($year / 100) / 4) - 2 * - floor($year / 100) + 77); + $day = (floor((13 * $month - 1) / 5) + + $this->_mday + ($year % 100) + + floor(($year % 100) / 4) + + floor(($year / 100) / 4) - 2 + * floor($year / 100) + 77); - return (int)($day - 7 * floor($day / 7)); + return (int) ($day - 7 * floor($day / 7)); } /** @@ -1315,12 +1331,12 @@ public function toiCalendar($floating = false) * New usage: Pass a formatter (class name or instance) for locale-aware * formatting with IcuFormatter or custom formatters. * - * @param string|\Stringable $pattern Format pattern - * @param string|\Horde\Date\FormatterInterface|null $formatter Formatter class name or instance: + * @param string|Stringable $pattern Format pattern + * @param string|FormatterInterface|null $formatter Formatter class name or instance: * - null: DateTimeFormatter (default, backward compatible) * - string: Formatter class name (e.g., \Horde\Date\Formatter\IcuFormatter::class) * - FormatterInterface: Formatter instance - * @param string|\Stringable|null $locale Locale for formatting (null = use instance locale or setlocale()) + * @param string|Stringable|null $locale Locale for formatting (null = use instance locale or setlocale()) * * @return string Formatted date string */ @@ -1329,7 +1345,7 @@ public function format($pattern, $formatter = null, $locale = null) // Backward compatibility: single argument uses old behavior if ($formatter === null && $locale === null && func_num_args() === 1) { // Old code path: use DateTime::format() with caching - $pattern = (string)$pattern; + $pattern = (string) $pattern; if (!isset($this->_formatCache[$pattern])) { $this->_formatCache[$pattern] = $this->toDateTime()->format($pattern); } @@ -1338,7 +1354,7 @@ public function format($pattern, $formatter = null, $locale = null) // New code path: use pluggable formatters // Convert Stringable to string - $pattern = (string)$pattern; + $pattern = (string) $pattern; // Default to DateTimeFormatter (backward compatible) if ($formatter === null) { @@ -1347,19 +1363,19 @@ public function format($pattern, $formatter = null, $locale = null) // String class name → instantiate elseif (is_string($formatter)) { if (!class_exists($formatter)) { - throw new \InvalidArgumentException("Formatter class not found: $formatter"); + throw new InvalidArgumentException("Formatter class not found: $formatter"); } $formatter = new $formatter(); } // Validate formatter if (!$formatter instanceof FormatterInterface) { - throw new \InvalidArgumentException("Formatter must implement FormatterInterface"); + throw new InvalidArgumentException("Formatter must implement FormatterInterface"); } // Convert Stringable locale to string if ($locale !== null) { - $locale = (string)$locale; + $locale = (string) $locale; } // Use stored timezone and locale @@ -1418,9 +1434,9 @@ protected function _fixPolyfillFormat($format) protected function _regexCallback($reg) { switch ($reg[0]) { - case '%b': return $this->strftime(Horde_Nls::getLangInfo(constant('ABMON_' . (int)$this->_month))); - case '%B': return $this->strftime(Horde_Nls::getLangInfo(constant('MON_' . (int)$this->_month))); - case '%C': return (int)($this->_year / 100); + case '%b': return $this->strftime(Horde_Nls::getLangInfo(constant('ABMON_' . (int) $this->_month))); + case '%B': return $this->strftime(Horde_Nls::getLangInfo(constant('MON_' . (int) $this->_month))); + case '%C': return (int) ($this->_year / 100); case '%-d': case '%#d': return sprintf('%d', $this->_mday); case '%d': return sprintf('%02d', $this->_mday); @@ -1449,7 +1465,7 @@ protected function _regexCallback($reg) case '%x': return $this->strftime(Horde_Nls::getLangInfo(D_FMT)); case '%X': return $this->strftime(Horde_Nls::getLangInfo(T_FMT)); case '%y': return substr(sprintf('%04d', $this->_year), -2); - case '%Y': return (int)$this->_year; + case '%Y': return (int) $this->_year; case '%%': return '%'; } return $reg[0]; @@ -1477,7 +1493,7 @@ protected function _correct($mask = self::MASK_ALLPARTS, $down = false) if ($this->_sec < 0 || $this->_sec > 59) { $mask |= self::MASK_MINUTE; - $this->_min += (int)($this->_sec / 60); + $this->_min += (int) ($this->_sec / 60); $this->_sec %= 60; if ($this->_sec < 0) { $this->_min--; @@ -1490,7 +1506,7 @@ protected function _correct($mask = self::MASK_ALLPARTS, $down = false) if ($this->_min < 0 || $this->_min > 59) { $mask |= self::MASK_HOUR; - $this->_hour += (int)($this->_min / 60); + $this->_hour += (int) ($this->_min / 60); $this->_min %= 60; if ($this->_min < 0) { $this->_hour--; @@ -1503,7 +1519,7 @@ protected function _correct($mask = self::MASK_ALLPARTS, $down = false) if ($this->_hour < 0 || $this->_hour > 23) { $mask |= self::MASK_DAY; - $this->_mday += (int)($this->_hour / 24); + $this->_mday += (int) ($this->_hour / 24); $this->_hour %= 24; if ($this->_hour < 0) { $this->_mday--; @@ -1521,20 +1537,28 @@ protected function _correct($mask = self::MASK_ALLPARTS, $down = false) } } + if ($mask & self::MASK_YEAR) { + if (isset($this->_mday) + && $this->_mday > 28 + && $this->_mday > Horde_Date_Utils::daysInMonth($this->_month, $this->_year)) { + $mask |= self::MASK_DAY; + } + } + if ($mask & self::MASK_DAY) { while ($this->_mday > (366 + 31)) { - if ((Horde_Date_Utils::isLeapYear($this->_year) && - ($this->_month <= 2)) || - (Horde_Date_Utils::isLeapYear($this->_year + 1) && - ($this->_month > 2))) { + if ((Horde_Date_Utils::isLeapYear($this->_year) + && ($this->_month <= 2)) + || (Horde_Date_Utils::isLeapYear($this->_year + 1) + && ($this->_month > 2))) { $this->_mday -= 366; } else { $this->_mday -= 365; } $this->_year++; } - while ($this->_mday > 28 && - $this->_mday > Horde_Date_Utils::daysInMonth($this->_month, $this->_year)) { + while ($this->_mday > 28 + && $this->_mday > Horde_Date_Utils::daysInMonth($this->_month, $this->_year)) { if ($down) { $this->_mday -= Horde_Date_Utils::daysInMonth($this->_month + 1, $this->_year) - Horde_Date_Utils::daysInMonth($this->_month, $this->_year); } else { @@ -1559,7 +1583,7 @@ protected function _correct($mask = self::MASK_ALLPARTS, $down = false) */ protected function _correctMonth() { - $this->_year += (int)($this->_month / 12); + $this->_year += (int) ($this->_month / 12); $this->_month %= 12; if ($this->_month < 1) { $this->_year--; @@ -1602,20 +1626,20 @@ protected function _initializeFromArray($date) foreach ($date as $key => $val) { if (in_array($key, ['year', 'month', 'mday', 'hour', 'min', 'sec'], true)) { - $this->{'_'. $key} = (int)$val; + $this->{'_' . $key} = (int) $val; } } // If $date['day'] is present and numeric we may have been passed // a Horde_Form_datetime array. - if (isset($date['day']) && - (string)(int)$date['day'] == $date['day']) { - $this->_mday = (int)$date['day']; + if (isset($date['day']) + && (string) (int) $date['day'] == $date['day']) { + $this->_mday = (int) $date['day']; } // 'minute' key also from Horde_Form_datetime - if (isset($date['minute']) && - (string)(int)$date['minute'] == $date['minute']) { - $this->_min = (int)$date['minute']; + if (isset($date['minute']) + && (string) (int) $date['minute'] == $date['minute']) { + $this->_min = (int) $date['minute']; } $this->_correct(); @@ -1624,18 +1648,18 @@ protected function _initializeFromArray($date) protected function _initializeFromObject($date) { if ($date instanceof DateTime) { - $this->_year = (int)$date->format('Y'); - $this->_month = (int)$date->format('m'); - $this->_mday = (int)$date->format('d'); - $this->_hour = (int)$date->format('H'); - $this->_min = (int)$date->format('i'); - $this->_sec = (int)$date->format('s'); + $this->_year = (int) $date->format('Y'); + $this->_month = (int) $date->format('m'); + $this->_mday = (int) $date->format('d'); + $this->_hour = (int) $date->format('H'); + $this->_min = (int) $date->format('i'); + $this->_sec = (int) $date->format('s'); $this->_initializeTimezone($date->getTimezone()->getName()); } else { $is_horde_date = $date instanceof Horde_Date; foreach (['year', 'month', 'mday', 'hour', 'min', 'sec'] as $key) { if ($is_horde_date || isset($date->$key)) { - $this->{'_' . $key} = (int)$date->$key; + $this->{'_' . $key} = (int) $date->$key; } } if (!$is_horde_date) { diff --git a/lib/Horde/Date/Exception.php b/lib/Horde/Date/Exception.php index 0c3b8a6..59ab82e 100644 --- a/lib/Horde/Date/Exception.php +++ b/lib/Horde/Date/Exception.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * Copyright 2011-2017 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 Horde LLC (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. @@ -22,6 +22,4 @@ * @license http://www.horde.org/licenses/lgpl21 LGPL * @package Date */ -class Horde_Date_Exception extends Horde_Exception_Wrapped -{ -} +class Horde_Date_Exception extends Horde_Exception_Wrapped {} diff --git a/lib/Horde/Date/Recurrence.php b/lib/Horde/Date/Recurrence.php index 3610961..3979d36 100644 --- a/lib/Horde/Date/Recurrence.php +++ b/lib/Horde/Date/Recurrence.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * Copyright 2007-2017 Horde LLC (http://www.horde.org/) + * Copyright 2007-2026 Horde LLC (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. @@ -13,23 +13,29 @@ * @package Date */ -use Horde\Util\HordeString; +use Horde\Date\Recurrence\DayMask; +use Horde\Date\Recurrence\Recurrence; +use Horde\Date\Recurrence\RecurrenceType; /** - * The Horde_Date_Recurrence class implements algorithms for calculating - * recurrences of events, including several recurrence types, intervals, - * exceptions, and conversion from and to vCalendar and iCalendar recurrence - * rules. - * - * All methods expecting dates as parameters accept all values that the - * Horde_Date constructor accepts, i.e. a timestamp, another Horde_Date - * object, an ISO time string or a hash. + * Thin wrapper around Horde\Date\Recurrence\Recurrence that preserves the + * legacy public API (property access, Horde_Date return types, Horde_Icalendar + * parameters) while delegating recurrence logic to the modern implementation. * * @author Jan Schneider * @category Horde - * @copyright 2007-2017 Horde LLC + * @copyright 2007-2026 Horde LLC * @license http://www.horde.org/licenses/lgpl21 LGPL * @package Date + * + * @property Horde_Date $start + * @property Horde_Date|null $recurEnd + * @property int|null $recurCount + * @property int $recurType + * @property int $recurInterval + * @property int|null $recurData + * @property array $exceptions + * @property array $completions */ class Horde_Date_Recurrence { @@ -64,71 +70,75 @@ class Horde_Date_Recurrence /** Recurs yearly on the same week day. */ public const RECUR_YEARLY_WEEKDAY = 7; - /** - * The start time of the event. - * - * @var Horde_Date - */ - public $start; - - /** - * The end date of the recurrence interval. - * - * @var Horde_Date - */ - public $recurEnd = null; - - /** - * The number of recurrences. - * - * @var integer - */ - public $recurCount = null; - - /** - * The type of recurrence this event follows. RECUR_* constant. - * - * @var integer - */ - public $recurType = self::RECUR_NONE; - - /** - * The length of time between recurrences. The time unit depends on the - * recurrence type. - * - * @var integer - */ - public $recurInterval = 1; + private Recurrence $modern; - /** - * Any additional recurrence data. - * - * @var integer - */ - public $recurData = null; + public function __construct($start) + { + $hdate = new Horde_Date($start); + $this->modern = new Recurrence($hdate->toDateTime()); + } - /** - * All the exceptions from recurrence for this event. - * - * @var array - */ - public $exceptions = []; + public function __get($name) + { + return match ($name) { + 'start' => $this->toLegacy($this->modern->getStart()), + 'recurEnd' => $this->modern->getEnd() !== null + ? $this->toLegacy($this->modern->getEnd()) + : null, + 'recurCount' => $this->modern->getCount(), + 'recurType' => $this->modern->getType()->value, + 'recurInterval' => $this->modern->getInterval(), + 'recurData' => $this->modern->getDayMask() !== 0 + ? $this->modern->getDayMask() + : null, + 'exceptions' => $this->modern->getExceptions(), + 'completions' => $this->modern->getCompletions(), + default => null, + }; + } - /** - * All the dates this recurrence has been marked as completed. - * - * @var array - */ - public $completions = []; + public function __set($name, $value) + { + match ($name) { + 'start' => $this->modern->setStart( + (new Horde_Date($value))->toDateTime() + ), + 'recurEnd' => $this->modern->setEnd( + $value !== null + ? (new Horde_Date($value))->toDateTime() + : null + ), + 'recurCount' => $this->modern->setCount( + $value !== null ? (int) $value : null + ), + 'recurType' => (function () use ($value) { + try { + $this->modern->setType(RecurrenceType::from((int) $value)); + } catch (ValueError) { + $this->modern->setType(RecurrenceType::None); + } + })(), + 'recurInterval' => $this->modern->setInterval((int) $value), + 'recurData' => $this->modern->setDayMask((int) ($value ?? 0)), + 'exceptions' => $this->modern->setExceptions((array) $value), + 'completions' => $this->modern->setCompletions((array) $value), + default => null, + }; + } - /** - * Constructor. - * - * @param Horde_Date $start Start of the recurring event. - */ - public function __construct($start) + public function __isset($name) { - $this->start = new Horde_Date($start); + return match ($name) { + 'start' => true, + 'recurEnd' => $this->modern->getEnd() !== null, + 'recurCount' => $this->modern->getCount() !== null, + 'recurType' => true, + 'recurInterval' => true, + 'recurData' => $this->modern->getDayMask() !== 0, + 'exceptions' => true, + 'completions' => true, + default => false, + }; } /** @@ -136,8 +146,6 @@ public function __construct($start) * * @since Horde_Date 2.4.0 * @see toHash() - * - * @param array $hash A hash of this object. */ public static function fromHash($hash) { @@ -162,93 +170,46 @@ public static function fromHash($hash) return $recurrence; } - /** - * Resets the class properties. - */ public function reset() { - $this->recurEnd = null; - $this->recurCount = null; - $this->recurType = self::RECUR_NONE; - $this->recurInterval = 1; - $this->recurData = null; - $this->exceptions = []; - $this->completions = []; + $this->modern->reset(); } - /** - * Checks if this event recurs on a given day of the week. - * - * @param integer $dayMask A mask consisting of Horde_Date::MASK_* - * constants specifying the day(s) to check. - * - * @return boolean True if this event recurs on the given day(s). - */ public function recurOnDay($dayMask) { - return ($this->recurData & $dayMask); + return ($this->modern->getDayMask() & $dayMask); } - /** - * Specifies the days this event recurs on. - * - * @param integer $dayMask A mask consisting of Horde_Date::MASK_* - * constants specifying the day(s) to recur on. - */ public function setRecurOnDay($dayMask) { - $this->recurData = $dayMask; + $this->modern->setDayMask($dayMask); } - /** - * Returns the days this event recurs on. - * - * @return integer A mask consisting of Horde_Date::MASK_* constants - * specifying the day(s) this event recurs on. - */ public function getRecurOnDays() { - return $this->recurData; + $mask = $this->modern->getDayMask(); + return $mask !== 0 ? $mask : null; } - /** - * Returns whether this event has a specific recurrence type. - * - * @param integer $recurrence RECUR_* constant of the - * recurrence type to check for. - * - * @return boolean True if the event has the specified recurrence type. - */ public function hasRecurType($recurrence) { - return ($recurrence == $this->recurType); + return ($recurrence == $this->modern->getType()->value); } - /** - * Sets a recurrence type for this event. - * - * @param integer $recurrence A RECUR_* constant. - */ public function setRecurType($recurrence) { - $this->recurType = $recurrence; + try { + $this->modern->setType(RecurrenceType::from((int) $recurrence)); + } catch (ValueError) { + $this->modern->setType(RecurrenceType::None); + } } - /** - * Returns recurrence type of this event. - * - * @return integer A RECUR_* constant. - */ public function getRecurType() { - return $this->recurType; + return $this->modern->getType()->value; } - /** - * Returns a description of this event's recurring type. - * - * @return string Human readable recurring type. - */ public function getRecurName() { switch ($this->getRecurType()) { @@ -269,649 +230,92 @@ public function getRecurName() } } - /** - * Sets the length of time between recurrences of this event. - * - * @param integer $interval The time between recurrences. - */ public function setRecurInterval($interval) { if ($interval > 0) { - $this->recurInterval = $interval; + $this->modern->setInterval((int) $interval); } } - /** - * Retrieves the length of time between recurrences of this event. - * - * @return integer The number of seconds between recurrences. - */ public function getRecurInterval() { - return $this->recurInterval; + return $this->modern->getInterval(); } - /** - * Sets the number of recurrences of this event. - * - * @param integer $count The number of recurrences. - */ public function setRecurCount($count) { if ($count > 0) { - $this->recurCount = (int)$count; - // Recurrence counts and end dates are mutually exclusive. - $this->recurEnd = null; + $this->modern->setCount((int) $count); } else { - $this->recurCount = null; + $this->modern->setCount(null); } } - /** - * Retrieves the number of recurrences of this event. - * - * @return integer The number recurrences. - */ public function getRecurCount() { - return $this->recurCount; + return $this->modern->getCount(); } - /** - * Returns whether this event has a recurrence with a fixed count. - * - * @return boolean True if this recurrence has a fixed count. - */ public function hasRecurCount() { - return isset($this->recurCount); + return $this->modern->getCount() !== null; } - /** - * Sets the start date of the recurrence interval. - * - * @param Horde_Date $start The recurrence start. - */ public function setRecurStart($start) { - $this->start = clone $start; + $hdate = new Horde_Date($start); + $this->modern->setStart($hdate->toDateTime()); } - /** - * Retrieves the start date of the recurrence interval. - * - * @return Horde_Date The recurrence start. - */ public function getRecurStart() { - return $this->start; + return $this->toLegacy($this->modern->getStart()); } - /** - * Sets the end date of the recurrence interval. - * - * @param Horde_Date $end The recurrence end. - */ public function setRecurEnd($end) { if (!empty($end)) { - // Recurrence counts and end dates are mutually exclusive. - $this->recurCount = null; - $this->recurEnd = clone $end; + $hdate = new Horde_Date($end); + $this->modern->setEnd($hdate->toDateTime()); } else { - $this->recurEnd = $end; + $this->modern->setEnd(null); } } - /** - * Retrieves the end date of the recurrence interval. - * - * @return Horde_Date The recurrence end. - */ public function getRecurEnd() { - return $this->recurEnd; + $end = $this->modern->getEnd(); + return $end !== null ? $this->toLegacy($end) : null; } - /** - * Returns whether this event has a recurrence end. - * - * @return boolean True if this recurrence ends. - */ public function hasRecurEnd() { - return isset($this->recurEnd) && isset($this->recurEnd->year) && - $this->recurEnd->year != 9999; + $end = $this->modern->getEnd(); + if ($end === null) { + return false; + } + $hdate = $this->toLegacy($end); + return isset($hdate->year) && $hdate->year != 9999; } - /** - * Finds the next recurrence of this event that's after $afterDate. - * - * @param Horde_Date|string $after Return events after this date. - * - * @return Horde_Date|boolean The date of the next recurrence or false - * if the event does not recur after - * $afterDate. - */ public function nextRecurrence($after) { if (!($after instanceof Horde_Date)) { $after = new Horde_Date($after); - } else { - $after = clone($after); - } - - // Make sure $after and $this->start are in the same TZ - $after->setTimezone($this->start->timezone); - if ($this->start->compareDateTime($after) >= 0) { - return clone $this->start; - } - - if ($this->recurInterval == 0) { - return false; - } - - switch ($this->getRecurType()) { - case self::RECUR_DAILY: - return $this->_nextDaily($after); - - case self::RECUR_WEEKLY: - return $this->_nextWeekly($after); - - case self::RECUR_MONTHLY_DATE: - return $this->_nextMonthlyDate($after); - - case self::RECUR_MONTHLY_WEEKDAY: - case self::RECUR_MONTHLY_LAST_WEEKDAY: - return $this->_nextMonthlyWeekday($after); - - case self::RECUR_YEARLY_DATE: - return $this->_nextYearlyDate($after); - - case self::RECUR_YEARLY_DAY: - return $this->_nextYearlyDay($after); - - case self::RECUR_YEARLY_WEEKDAY: - return $this->_nextYearlyWeekday($after); } - - // We didn't find anything, the recurType was bad, or something else - // went wrong - return false. - return false; - } - - /** - * Finds the next daily recurrence of this event that's after $afterDate. - * - * @param Horde_Date|string $after Return events after this date. - * - * @return Horde_Date|boolean The date of the next recurrence or false - * if the event does not recur after - * $afterDate. - */ - protected function _nextDaily($after) - { - $diff = $this->start->diff($after); - $recur = ceil($diff / $this->recurInterval); - if ($this->recurCount && $recur >= $this->recurCount) { + $result = $this->modern->nextRecurrence($after->toDateTime()); + if ($result === null) { return false; } - - $recur *= $this->recurInterval; - $next = $this->start->add(['day' => $recur]); - if ((!$this->hasRecurEnd() || - $next->compareDateTime($this->recurEnd) <= 0) && - $next->compareDateTime($after) >= 0) { - return $next; - } - - return false; + return $this->toLegacy($result); } - /** - * Finds the next weekly recurrence of this event that's after $afterDate. - * - * @param Horde_Date|string $after Return events after this date. - * - * @return Horde_Date|boolean The date of the next recurrence or false - * if the event does not recur after - * $afterDate. - */ - protected function _nextWeekly($after) - { - if (empty($this->recurData)) { - return false; - } - - $start_week = Horde_Date_Utils::firstDayOfWeek( - $this->start->format('W'), - $this->start->year - ); - $start_week->timezone = $this->start->timezone; - $start_week->hour = $this->start->hour; - $start_week->min = $this->start->min; - $start_week->sec = $this->start->sec; - - // Make sure we are not at the ISO-8601 first week of year while - // still in month 12...OR in the ISO-8601 last week of year while - // in month 1 and adjust the year accordingly. - $week = $after->format('W'); - if ($week == 1 && $after->month == 12) { - $theYear = $after->year + 1; - } elseif ($week >= 52 && $after->month == 1) { - $theYear = $after->year - 1; - } else { - $theYear = $after->year; - } - - $after_week = Horde_Date_Utils::firstDayOfWeek($week, $theYear); - $after_week->timezone = $this->start->timezone; - $after_week_end = clone $after_week; - $after_week_end->mday += 7; - - $diff = $start_week->diff($after_week); - $interval = $this->recurInterval * 7; - $repeats = floor($diff / $interval); - if ($diff % $interval < 7) { - $recur = $diff; - } else { - /** - * If the after_week is not in the first week interval the - * search needs to skip ahead a complete interval. The way it is - * calculated here means that an event that occurs every second - * week on Monday and Wednesday with the event actually starting - * on Tuesday or Wednesday will only have one incidence in the - * first week. - */ - $recur = $interval * ($repeats + 1); - } - - if ($this->hasRecurCount()) { - $recurrences = 0; - /** - * Correct the number of recurrences by the number of events - * that lay between the start of the start week and the - * recurrence start. - */ - $next = clone $start_week; - while ($next->compareDateTime($this->start) < 0) { - if ($this->recurOnDay((int)pow(2, $next->dayOfWeek()))) { - $recurrences--; - } - ++$next->mday; - } - if ($repeats > 0) { - $weekdays = $this->recurData; - $total_recurrences_per_week = 0; - while ($weekdays > 0) { - if ($weekdays % 2) { - $total_recurrences_per_week++; - } - $weekdays = ($weekdays - ($weekdays % 2)) / 2; - } - $recurrences += $total_recurrences_per_week * $repeats; - } - } - - $next = clone $start_week; - $next->mday += $recur; - while ($next->compareDateTime($after) < 0 && - $next->compareDateTime($after_week_end) < 0) { - if ($this->hasRecurCount() - && $next->compareDateTime($after) < 0 - && $this->recurOnDay((int)pow(2, $next->dayOfWeek()))) { - $recurrences++; - } - ++$next->mday; - } - if ($this->hasRecurCount() && - $recurrences >= $this->recurCount) { - return false; - } - if (!$this->hasRecurEnd() || - $next->compareDateTime($this->recurEnd) <= 0) { - if ($next->compareDateTime($after_week_end) >= 0) { - return $this->nextRecurrence($after_week_end); - } - while (!$this->recurOnDay((int)pow(2, $next->dayOfWeek())) && - $next->compareDateTime($after_week_end) < 0) { - ++$next->mday; - } - if (!$this->hasRecurEnd() || - $next->compareDateTime($this->recurEnd) <= 0) { - if ($next->compareDateTime($after_week_end) >= 0) { - return $this->nextRecurrence($after_week_end); - } else { - return $next; - } - } - } - - return false; - } - - /** - * Finds the next monthly recurrence on the same date of this event that's - * after $afterDate. - * - * @param Horde_Date|string $after Return events after this date. - * - * @return Horde_Date|boolean The date of the next recurrence or false - * if the event does not recur after - * $afterDate. - */ - protected function _nextMonthlyDate($after) - { - $start = clone $this->start; - if ($after->compareDateTime($start) < 0) { - $after = clone $start; - } else { - $after = clone $after; - } - - // If we're starting past this month's recurrence of the event, - // look in the next month on the day the event recurs. - if ($after->mday > $start->mday) { - ++$after->month; - $after->mday = $start->mday; - } - - // Adjust $start to be the first match. - $offset = ($after->month - $start->month) + ($after->year - $start->year) * 12; - $offset = floor(($offset + $this->recurInterval - 1) / $this->recurInterval) * $this->recurInterval; - - if ($this->recurCount && - ($offset / $this->recurInterval) >= $this->recurCount) { - return false; - } - $start->month += $offset; - $count = $offset / $this->recurInterval; - - do { - if ($this->recurCount && - $count++ >= $this->recurCount) { - return false; - } - - // Bail if we've gone past the end of recurrence. - if ($this->hasRecurEnd() && - $this->recurEnd->compareDateTime($start) < 0) { - return false; - } - if ($start->isValid()) { - return $start; - } - - // If the interval is 12, and the date isn't valid, then we - // need to see if February 29th is an option. If not, then the - // event will _never_ recur, and we need to stop checking to - // avoid an infinite loop. - if ($this->recurInterval == 12 && ($start->month != 2 || $start->mday > 29)) { - return false; - } - - // Add the recurrence interval. - $start->month += $this->recurInterval; - } while (true); - - return false; - } - - /** - * Finds the next monthly recurrence on the same weekday of this event - * that's after $afterDate. - * - * @param Horde_Date|string $after Return events after this date. - * - * @return Horde_Date|boolean The date of the next recurrence or false - * if the event does not recur after - * $afterDate. - */ - protected function _nextMonthlyWeekday($after) - { - // Start with the start date of the event. - $estart = clone $this->start; - - // What day of the week, and week of the month, do we recur on? - if ($this->recurType == self::RECUR_MONTHLY_LAST_WEEKDAY) { - $nth = -1; - } else { - $nth = ceil($this->start->mday / 7); - } - $weekday = $estart->dayOfWeek(); - - // Adjust $estart to be the first candidate. - $offset = ($after->month - $estart->month) + ($after->year - $estart->year) * 12; - $offset = floor(($offset + $this->recurInterval - 1) / $this->recurInterval) * $this->recurInterval; - - // Adjust our working date until it's after $after. - $estart->mday = 1; - $estart->month += $offset - $this->recurInterval; - - $count = $offset / $this->recurInterval; - do { - if ($this->recurCount && - $count++ >= $this->recurCount) { - return false; - } - - $estart->month += $this->recurInterval; - - $next = clone $estart; - $next->setNthWeekday($weekday, $nth); - - if ($next->month != $estart->month) { - // We're already in the next month. - continue; - } - if ($next->compareDateTime($after) < 0) { - // We haven't made it past $after yet, try again. - continue; - } - if ($this->hasRecurEnd() && - $next->compareDateTime($this->recurEnd) > 0) { - // We've gone past the end of recurrence; we can give up - // now. - return false; - } - - // We have a candidate to return. - break; - } while (true); - - return $next; - } - - /** - * Finds the next yearly recurrence on the same date of this event that's - * after $afterDate. - * - * @param Horde_Date|string $after Return events after this date. - * - * @return Horde_Date|boolean The date of the next recurrence or false - * if the event does not recur after - * $afterDate. - */ - protected function _nextYearlyDate($after) - { - // Start with the start date of the event. - $estart = clone $this->start; - $after = clone $after; - - if ($after->month > $estart->month || - ($after->month == $estart->month && $after->mday > $estart->mday)) { - ++$after->year; - $after->month = $estart->month; - $after->mday = $estart->mday; - } - - // Seperate case here for February 29th - if ($estart->month == 2 && $estart->mday == 29) { - while (!Horde_Date_Utils::isLeapYear($after->year)) { - ++$after->year; - } - } - - // Adjust $estart to be the first candidate. - $offset = $after->year - $estart->year; - if ($offset > 0) { - $offset = floor(($offset + $this->recurInterval - 1) / $this->recurInterval) * $this->recurInterval; - $estart->year += $offset; - } - - // We've gone past the end of recurrence; give up. - if ($this->recurCount && - $offset >= $this->recurCount) { - return false; - } - if ($this->hasRecurEnd() && - $this->recurEnd->compareDateTime($estart) < 0) { - return false; - } - - return $estart; - } - - /** - * Finds the next yearly recurrence on the same day of the year of this - * event that's after $afterDate. - * - * @param Horde_Date|string $after Return events after this date. - * - * @return Horde_Date|boolean The date of the next recurrence or false - * if the event does not recur after - * $afterDate. - */ - protected function _nextYearlyDay($after) - { - // Check count first. - $dayofyear = $this->start->dayOfYear(); - $count = ($after->year - $this->start->year) / $this->recurInterval + 1; - if ($this->recurCount && - ($count > $this->recurCount || - ($count == $this->recurCount && - $after->dayOfYear() > $dayofyear))) { - return false; - } - - // Start with a rough interval. - $estart = clone $this->start; - $estart->year += floor($count - 1) * $this->recurInterval; - - // Now add the difference to the required day of year. - $estart->mday += $dayofyear - $estart->dayOfYear(); - - // Add an interval if the estimation was wrong. - if ($estart->compareDate($after) < 0) { - $estart->year += $this->recurInterval; - $estart->mday += $dayofyear - $estart->dayOfYear(); - } - - // We've gone past the end of recurrence; give up. - if ($this->hasRecurEnd() && - $this->recurEnd->compareDateTime($estart) < 0) { - return false; - } - - return $estart; - } - - /** - * Finds the next yearly recurrence on the same weekday of this event - * that's after $afterDate. - * - * @param Horde_Date|string $after Return events after this date. - * - * @return Horde_Date|boolean The date of the next recurrence or false - * if the event does not recur after - * $afterDate. - */ - protected function _nextYearlyWeekday($after) - { - // Start with the start date of the event. - $estart = clone $this->start; - - // What day of the week, and week of the month, do we recur on? - $nth = ceil($this->start->mday / 7); - $weekday = $estart->dayOfWeek(); - - // Adjust $estart to be the first candidate. - $offset = floor(($after->year - $estart->year + $this->recurInterval - 1) / $this->recurInterval) * $this->recurInterval; - - // Adjust our working date until it's after $after. - $estart->year += $offset - $this->recurInterval; - - $count = $offset / $this->recurInterval; - do { - if ($this->recurCount && - $count++ >= $this->recurCount) { - return false; - } - - $estart->year += $this->recurInterval; - - $next = clone $estart; - $next->setNthWeekday($weekday, $nth); - - if ($next->compareDateTime($after) < 0) { - // We haven't made it past $after yet, try again. - continue; - } - if ($this->hasRecurEnd() && - $next->compareDateTime($this->recurEnd) > 0) { - // We've gone past the end of recurrence; we can give up - // now. - return false; - } - - // We have a candidate to return. - break; - } while (true); - - return $next; - } - - /** - * Returns whether this event has any date that matches the recurrence - * rules and is not an exception. - * - * @return boolean True if an active recurrence exists. - */ - public function hasActiveRecurrence() - { - if (!$this->hasRecurEnd()) { - return true; - } - - $next = $this->nextRecurrence(new Horde_Date($this->start)); - while (is_object($next)) { - if (!$this->hasException($next->year, $next->month, $next->mday) && - !$this->hasCompletion($next->year, $next->month, $next->mday)) { - return true; - } - - $next = $this->nextRecurrence($next->add(['day' => 1])); - } - - return false; - } - - /** - * Returns the next active recurrence. - * - * @param Horde_Date $afterDate Return events after this date. - * - * @return Horde_Date|boolean The date of the next active - * recurrence or false if the event - * has no active recurrence after - * $afterDate. - */ public function nextActiveRecurrence($afterDate) { $next = $this->nextRecurrence($afterDate); while (is_object($next)) { - if (!$this->hasException($next->year, $next->month, $next->mday) && - !$this->hasCompletion($next->year, $next->month, $next->mday)) { + if (!$this->hasException($next->year, $next->month, $next->mday) + && !$this->hasCompletion($next->year, $next->month, $next->mday)) { return $next; } $next->mday++; @@ -921,256 +325,98 @@ public function nextActiveRecurrence($afterDate) return false; } - /** - * Adds an exception to a recurring event. - * - * @param integer $year The year of the exception. - * @param integer $month The month of the exception. - * @param integer $mday The day of the month of the exception. - */ + public function hasActiveRecurrence() + { + return $this->modern->hasActiveRecurrence(); + } + public function addException($year, $month, $mday) { - $key = sprintf('%04d%02d%02d', $year, $month, $mday); - if (array_search($key, $this->exceptions, true) === false) { - $this->exceptions[] = sprintf('%04d%02d%02d', $year, $month, $mday); - } + $this->modern->addException( + new DateTimeImmutable(sprintf('%04d-%02d-%02d', $year, $month, $mday)) + ); } - /** - * Deletes an exception from a recurring event. - * - * @param integer $year The year of the exception. - * @param integer $month The month of the exception. - * @param integer $mday The day of the month of the exception. - */ public function deleteException($year, $month, $mday) { - $key = array_search(sprintf('%04d%02d%02d', $year, $month, $mday), $this->exceptions, true); - if ($key !== false) { - unset($this->exceptions[$key]); - } + $this->modern->deleteException( + new DateTimeImmutable(sprintf('%04d-%02d-%02d', $year, $month, $mday)) + ); } - /** - * Checks if an exception exists for a given reccurence of an event. - * - * @param integer $year The year of the reucrance. - * @param integer $month The month of the reucrance. - * @param integer $mday The day of the month of the reucrance. - * - * @return boolean True if an exception exists for the given date. - */ public function hasException($year, $month, $mday) { return in_array( sprintf('%04d%02d%02d', $year, $month, $mday), - $this->getExceptions(), + $this->modern->getExceptions(), true ); } - /** - * Retrieves all the exceptions for this event. - * - * @return array Array containing the dates of all the exceptions in - * YYYYMMDD form. - */ public function getExceptions() { - return $this->exceptions; + return $this->modern->getExceptions(); } - /** - * Adds a completion to a recurring event. - * - * @param integer $year The year of the exception. - * @param integer $month The month of the exception. - * @param integer $mday The day of the month of the completion. - */ public function addCompletion($year, $month, $mday) { - $this->completions[] = sprintf('%04d%02d%02d', $year, $month, $mday); + $this->modern->addCompletion( + new DateTimeImmutable(sprintf('%04d-%02d-%02d', $year, $month, $mday)) + ); } - /** - * Deletes a completion from a recurring event. - * - * @param integer $year The year of the exception. - * @param integer $month The month of the exception. - * @param integer $mday The day of the month of the completion. - */ public function deleteCompletion($year, $month, $mday) { - $key = array_search(sprintf('%04d%02d%02d', $year, $month, $mday), $this->completions, true); - if ($key !== false) { - unset($this->completions[$key]); - } + $this->modern->deleteCompletion( + new DateTimeImmutable(sprintf('%04d-%02d-%02d', $year, $month, $mday)) + ); } - /** - * Checks if a completion exists for a given reccurence of an event. - * - * @param integer $year The year of the recurrance. - * @param integer $month The month of the recurrance. - * @param integer $mday The day of the month of the recurrance. - * - * @return boolean True if a completion exists for the given date. - */ public function hasCompletion($year, $month, $mday) { return in_array( sprintf('%04d%02d%02d', $year, $month, $mday), - $this->getCompletions(), + $this->modern->getCompletions(), true ); } - /** - * Retrieves all the completions for this event. - * - * @return array Array containing the dates of all the completions in - * YYYYMMDD form. - */ public function getCompletions() { - return $this->completions; + return $this->modern->getCompletions(); } - /** - * Parses a vCalendar 1.0 recurrence rule. - * - * @link http://www.imc.org/pdi/vcal-10.txt - * @link http://www.shuchow.com/vCalAddendum.html - * - * @param string $rrule A vCalendar 1.0 conform RRULE value. - */ public function fromRRule10($rrule) { - $this->reset(); - - if (!$rrule) { - return; - } - - if (!preg_match('/([A-Z]+)(\d+)?(.*)/', $rrule, $matches)) { - // No recurrence data - event does not recur. - $this->setRecurType(self::RECUR_NONE); - } - - // Always default the recurInterval to 1. - $this->setRecurInterval(!empty($matches[2]) ? $matches[2] : 1); - - $remainder = trim($matches[3]); - - switch ($matches[1]) { - case 'D': - $this->setRecurType(self::RECUR_DAILY); - break; - - case 'W': - $this->setRecurType(self::RECUR_WEEKLY); - $mask = 0; - if (!empty($remainder)) { - $maskdays = [ - 'SU' => Horde_Date::MASK_SUNDAY, - 'MO' => Horde_Date::MASK_MONDAY, - 'TU' => Horde_Date::MASK_TUESDAY, - 'WE' => Horde_Date::MASK_WEDNESDAY, - 'TH' => Horde_Date::MASK_THURSDAY, - 'FR' => Horde_Date::MASK_FRIDAY, - 'SA' => Horde_Date::MASK_SATURDAY, - ]; - while (preg_match('/^ ?(' . implode('|', array_keys($maskdays)) . ') ?/', $remainder, $matches)) { - $day = trim($matches[0]); - $remainder = substr($remainder, strlen($matches[0])); - $mask |= $maskdays[$day]; - } - $this->setRecurOnDay($mask); - } - if (!$mask) { - // Recur on the day of the week of the original recurrence. - $maskdays = [ - Horde_Date::DATE_SUNDAY => Horde_Date::MASK_SUNDAY, - Horde_Date::DATE_MONDAY => Horde_Date::MASK_MONDAY, - Horde_Date::DATE_TUESDAY => Horde_Date::MASK_TUESDAY, - Horde_Date::DATE_WEDNESDAY => Horde_Date::MASK_WEDNESDAY, - Horde_Date::DATE_THURSDAY => Horde_Date::MASK_THURSDAY, - Horde_Date::DATE_FRIDAY => Horde_Date::MASK_FRIDAY, - Horde_Date::DATE_SATURDAY => Horde_Date::MASK_SATURDAY, - ]; - $this->setRecurOnDay($maskdays[$this->start->dayOfWeek()]); - } - break; - - case 'MP': - $this->setRecurType(self::RECUR_MONTHLY_WEEKDAY); - if (preg_match('/^ \d([+-])/', $remainder, $matches) && - $matches[1] == '-') { - $this->setRecurType(self::RECUR_MONTHLY_LAST_WEEKDAY); - } - break; - - case 'MD': - $this->setRecurType(self::RECUR_MONTHLY_DATE); - break; - - case 'YM': - $this->setRecurType(self::RECUR_YEARLY_DATE); - break; - - case 'YD': - $this->setRecurType(self::RECUR_YEARLY_DAY); - break; - } - - // Strip further modifiers. - while ($remainder && !preg_match('/^(#\d+|\d{8})($| |T\d{6})/', $remainder)) { - $remainder = substr($remainder, 1); - } + $this->modern->fromRRule10((string) $rrule); + } - if (!empty($remainder)) { - if (strpos($remainder, '#') === 0) { - $this->setRecurCount(substr($remainder, 1)); - } else { - [$year, $month, $mday, $hour, $min, $sec, $tz] = - sscanf($remainder, '%04d%02d%02dT%02d%02d%02d%s'); - $this->setRecurEnd(new Horde_Date( - ['year' => $year, - 'month' => $month, - 'mday' => $mday, - 'hour' => $hour, - 'min' => $min, - 'sec' => $sec], - $tz == 'Z' ? 'UTC' : $this->start->timezone - )); - } - } + public function fromRRule20($rrule) + { + $this->modern->fromRRule20((string) $rrule); } /** * Creates a vCalendar 1.0 recurrence rule. * - * @link http://www.imc.org/pdi/vcal-10.txt - * @link http://www.shuchow.com/vCalAddendum.html - * * @param Horde_Icalendar $calendar A Horde_Icalendar object instance. - * * @return string A vCalendar 1.0 conform RRULE value. */ public function toRRule10($calendar) { - switch ($this->recurType) { + $start = $this->toLegacy($this->modern->getStart()); + + switch ($this->modern->getType()->value) { case self::RECUR_NONE: return ''; case self::RECUR_DAILY: - $rrule = 'D' . $this->recurInterval; + $rrule = 'D' . $this->modern->getInterval(); break; case self::RECUR_WEEKLY: - $rrule = 'W' . $this->recurInterval; + $rrule = 'W' . $this->modern->getInterval(); $vcaldays = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']; - for ($i = 0; $i <= 7; ++$i) { if ($this->recurOnDay(pow(2, $i))) { $rrule .= ' ' . $vcaldays[$i]; @@ -1179,32 +425,30 @@ public function toRRule10($calendar) break; case self::RECUR_MONTHLY_DATE: - $rrule = 'MD' . $this->recurInterval . ' ' . trim((string)$this->start->mday); + $rrule = 'MD' . $this->modern->getInterval() . ' ' . trim((string) $start->mday); break; case self::RECUR_MONTHLY_WEEKDAY: case self::RECUR_MONTHLY_LAST_WEEKDAY: - if ($this->recurType == self::RECUR_MONTHLY_LAST_WEEKDAY) { + if ($this->modern->getType()->value == self::RECUR_MONTHLY_LAST_WEEKDAY) { $nth_weekday = '1-'; } else { - $nth_weekday = (int)($this->start->mday / 7); - if (($this->start->mday % 7) > 0) { + $nth_weekday = (int) ($start->mday / 7); + if (($start->mday % 7) > 0) { $nth_weekday++; } $nth_weekday .= '+'; } - $vcaldays = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']; - $rrule = 'MP' . $this->recurInterval . ' ' . $nth_weekday . ' ' . $vcaldays[$this->start->dayOfWeek()]; - + $rrule = 'MP' . $this->modern->getInterval() . ' ' . $nth_weekday . ' ' . $vcaldays[$start->dayOfWeek()]; break; case self::RECUR_YEARLY_DATE: - $rrule = 'YM' . $this->recurInterval . ' ' . trim((string)$this->start->month); + $rrule = 'YM' . $this->modern->getInterval() . ' ' . trim((string) $start->month); break; case self::RECUR_YEARLY_DAY: - $rrule = 'YD' . $this->recurInterval . ' ' . $this->start->dayOfYear(); + $rrule = 'YD' . $this->modern->getInterval() . ' ' . $start->dayOfYear(); break; default: @@ -1212,160 +456,34 @@ public function toRRule10($calendar) } if ($this->hasRecurEnd()) { - $recurEnd = clone $this->recurEnd; + $recurEnd = clone $this->getRecurEnd(); return $rrule . ' ' . $calendar->_exportDateTime($recurEnd); } - return $rrule . ' #' . (int)$this->getRecurCount(); - } - - /** - * Parses an iCalendar 2.0 recurrence rule. - * - * @link http://tools.ietf.org/html/rfc5545#section-3.3.10 - * @link http://tools.ietf.org/html/rfc5545#section-3.8.5 - * - * @param string $rrule An iCalendar 2.0 conform RRULE value. - */ - public function fromRRule20($rrule) - { - $this->reset(); - - // Parse the recurrence rule into keys and values. - $rdata = []; - $parts = explode(';', $rrule); - foreach ($parts as $part) { - [$key, $value] = explode('=', $part, 2); - $rdata[HordeString::upper($key)] = $value; - } - - if (isset($rdata['FREQ'])) { - // Always default the recurInterval to 1. - $this->setRecurInterval($rdata['INTERVAL'] ?? 1); - - switch (HordeString::upper($rdata['FREQ'])) { - case 'DAILY': - $this->setRecurType(self::RECUR_DAILY); - /** - * [#15054] Thunderbird "all workday" events become "daily" events - * Thunderbird-generated "every weekday" events are represented as - * RRULE:FREQ=DAILY;UNTIL=yyyymmddT041500Z;BYDAY=MO,TU,WE,TH,FR - * Fall through to weekly in this case. - */ - if (!isset($rdata['BYDAY'])) { - break; - } - // no break - case 'WEEKLY': - $this->setRecurType(self::RECUR_WEEKLY); - if (isset($rdata['BYDAY'])) { - $maskdays = [ - 'SU' => Horde_Date::MASK_SUNDAY, - 'MO' => Horde_Date::MASK_MONDAY, - 'TU' => Horde_Date::MASK_TUESDAY, - 'WE' => Horde_Date::MASK_WEDNESDAY, - 'TH' => Horde_Date::MASK_THURSDAY, - 'FR' => Horde_Date::MASK_FRIDAY, - 'SA' => Horde_Date::MASK_SATURDAY, - ]; - $days = explode(',', $rdata['BYDAY']); - $mask = 0; - foreach ($days as $day) { - $mask |= $maskdays[$day]; - } - $this->setRecurOnDay($mask); - } else { - // Recur on the day of the week of the original - // recurrence. - $maskdays = [ - Horde_Date::DATE_SUNDAY => Horde_Date::MASK_SUNDAY, - Horde_Date::DATE_MONDAY => Horde_Date::MASK_MONDAY, - Horde_Date::DATE_TUESDAY => Horde_Date::MASK_TUESDAY, - Horde_Date::DATE_WEDNESDAY => Horde_Date::MASK_WEDNESDAY, - Horde_Date::DATE_THURSDAY => Horde_Date::MASK_THURSDAY, - Horde_Date::DATE_FRIDAY => Horde_Date::MASK_FRIDAY, - Horde_Date::DATE_SATURDAY => Horde_Date::MASK_SATURDAY]; - $this->setRecurOnDay($maskdays[$this->start->dayOfWeek()]); - } - break; - - case 'MONTHLY': - if (isset($rdata['BYDAY'])) { - if (strpos($rdata['BYDAY'], '-') === false) { - $this->setRecurType(self::RECUR_MONTHLY_WEEKDAY); - } else { - $this->setRecurType(self::RECUR_MONTHLY_LAST_WEEKDAY); - } - } else { - $this->setRecurType(self::RECUR_MONTHLY_DATE); - } - break; - - case 'YEARLY': - if (isset($rdata['BYYEARDAY'])) { - $this->setRecurType(self::RECUR_YEARLY_DAY); - } elseif (isset($rdata['BYDAY'])) { - $this->setRecurType(self::RECUR_YEARLY_WEEKDAY); - } else { - $this->setRecurType(self::RECUR_YEARLY_DATE); - } - break; - } - - // MUST take into account the time portion if it is present. - // See Bug: 12869 and Bug: 2813 - if (isset($rdata['UNTIL'])) { - if (preg_match('/^(\d{4})-?(\d{2})-?(\d{2})T? ?(\d{2}):?(\d{2}):?(\d{2})(?:\.\d+)?(Z?)$/', $rdata['UNTIL'], $parts)) { - $until = new Horde_Date($rdata['UNTIL'], 'UTC'); - $until->setTimezone($this->start->timezone); - } else { - [$year, $month, $mday] = sscanf( - $rdata['UNTIL'], - '%04d%02d%02d' - ); - $until = new Horde_Date( - ['year' => $year, - 'month' => $month, - 'mday' => $mday + 1], - $this->start->timezone - ); - } - $this->setRecurEnd($until); - } - if (isset($rdata['COUNT'])) { - $this->setRecurCount($rdata['COUNT']); - } - } else { - // No recurrence data - event does not recur. - $this->setRecurType(self::RECUR_NONE); - } + return $rrule . ' #' . (int) $this->getRecurCount(); } /** * Creates an iCalendar 2.0 recurrence rule. * - * @link http://rfc.net/rfc2445.html#s4.3.10 - * @link http://rfc.net/rfc2445.html#s4.8.5 - * @link http://www.shuchow.com/vCalAddendum.html - * * @param Horde_Icalendar $calendar A Horde_Icalendar object instance. - * * @return string An iCalendar 2.0 conform RRULE value. */ public function toRRule20($calendar) { - switch ($this->recurType) { + $start = $this->toLegacy($this->modern->getStart()); + + switch ($this->modern->getType()->value) { case self::RECUR_NONE: return ''; case self::RECUR_DAILY: - $rrule = 'FREQ=DAILY;INTERVAL=' . $this->recurInterval; + $rrule = 'FREQ=DAILY;INTERVAL=' . $this->modern->getInterval(); break; case self::RECUR_WEEKLY: - $rrule = 'FREQ=WEEKLY;INTERVAL=' . $this->recurInterval; + $rrule = 'FREQ=WEEKLY;INTERVAL=' . $this->modern->getInterval(); $vcaldays = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']; - for ($i = $flag = 0; $i <= 7; ++$i) { if ($this->recurOnDay(pow(2, $i))) { if ($flag == 0) { @@ -1380,49 +498,49 @@ public function toRRule20($calendar) break; case self::RECUR_MONTHLY_DATE: - $rrule = 'FREQ=MONTHLY;INTERVAL=' . $this->recurInterval; + $rrule = 'FREQ=MONTHLY;INTERVAL=' . $this->modern->getInterval(); break; case self::RECUR_MONTHLY_WEEKDAY: case self::RECUR_MONTHLY_LAST_WEEKDAY: - if ($this->recurType == self::RECUR_MONTHLY_LAST_WEEKDAY) { + if ($this->modern->getType()->value == self::RECUR_MONTHLY_LAST_WEEKDAY) { $nth_weekday = -1; } else { - $nth_weekday = (int)($this->start->mday / 7); - if (($this->start->mday % 7) > 0) { + $nth_weekday = (int) ($start->mday / 7); + if (($start->mday % 7) > 0) { $nth_weekday++; } } $vcaldays = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']; - $rrule = 'FREQ=MONTHLY;INTERVAL=' . $this->recurInterval - . ';BYDAY=' . $nth_weekday . $vcaldays[$this->start->dayOfWeek()]; + $rrule = 'FREQ=MONTHLY;INTERVAL=' . $this->modern->getInterval() + . ';BYDAY=' . $nth_weekday . $vcaldays[$start->dayOfWeek()]; break; case self::RECUR_YEARLY_DATE: - $rrule = 'FREQ=YEARLY;INTERVAL=' . $this->recurInterval; + $rrule = 'FREQ=YEARLY;INTERVAL=' . $this->modern->getInterval(); break; case self::RECUR_YEARLY_DAY: - $rrule = 'FREQ=YEARLY;INTERVAL=' . $this->recurInterval - . ';BYYEARDAY=' . $this->start->dayOfYear(); + $rrule = 'FREQ=YEARLY;INTERVAL=' . $this->modern->getInterval() + . ';BYYEARDAY=' . $start->dayOfYear(); break; case self::RECUR_YEARLY_WEEKDAY: - $nth_weekday = (int)($this->start->mday / 7); - if (($this->start->mday % 7) > 0) { + $nth_weekday = (int) ($start->mday / 7); + if (($start->mday % 7) > 0) { $nth_weekday++; } $vcaldays = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']; - $rrule = 'FREQ=YEARLY;INTERVAL=' . $this->recurInterval + $rrule = 'FREQ=YEARLY;INTERVAL=' . $this->modern->getInterval() . ';BYDAY=' . $nth_weekday - . $vcaldays[$this->start->dayOfWeek()] - . ';BYMONTH=' . $this->start->month; + . $vcaldays[$start->dayOfWeek()] + . ';BYMONTH=' . $start->month; break; } if ($this->hasRecurEnd()) { - $recurEnd = clone $this->recurEnd; + $recurEnd = clone $this->getRecurEnd(); $rrule .= ';UNTIL=' . $calendar->_exportDateTime($recurEnd); } if ($count = $this->getRecurCount()) { @@ -1433,10 +551,6 @@ public function toRRule20($calendar) /** * Parses the recurrence data from a Kolab hash. - * - * @param array $hash The hash to convert. - * - * @return boolean True if the hash seemed valid, false otherwise. */ public function fromKolab($hash) { @@ -1447,7 +561,7 @@ public function fromKolab($hash) return false; } - $this->setRecurInterval((int)$hash['interval']); + $this->setRecurInterval((int) $hash['interval']); $parse_day = false; $set_daymask = false; @@ -1481,9 +595,8 @@ public function fromKolab($hash) case 'weekday': $this->setRecurType(self::RECUR_MONTHLY_WEEKDAY); - $nth_weekday = (int)$hash['daynumber']; + $nth_weekday = (int) $hash['daynumber']; if ($nth_weekday < 0) { - // This is not officially part of the Kolab 2.0 specs. $this->setRecurType(self::RECUR_MONTHLY_LAST_WEEKDAY); } $hash['daynumber'] = 1; @@ -1514,7 +627,6 @@ public function fromKolab($hash) } $this->setRecurType(self::RECUR_YEARLY_DAY); - // Start counting days in January. $hash['month'] = 'january'; $update_month = true; $update_daynumber = true; @@ -1527,7 +639,7 @@ public function fromKolab($hash) } $this->setRecurType(self::RECUR_YEARLY_WEEKDAY); - $nth_weekday = (int)$hash['daynumber']; + $nth_weekday = (int) $hash['daynumber']; $hash['daynumber'] = 1; $parse_day = true; $update_month = true; @@ -1540,7 +652,7 @@ public function fromKolab($hash) if (isset($hash['range-type']) && isset($hash['range'])) { switch ($hash['range-type']) { case 'number': - $this->setRecurCount((int)$hash['range']); + $this->setRecurCount((int) $hash['range']); break; case 'date': @@ -1553,7 +665,6 @@ public function fromKolab($hash) } } - // Need to parse ? $last_found_day = -1; if ($parse_day) { if (!isset($hash['day'])) { @@ -1582,7 +693,6 @@ public function fromKolab($hash) ]; foreach ($hash['day'] as $day) { - // Validity check. if (empty($day) || !isset($bits[$day])) { continue; } @@ -1597,6 +707,8 @@ public function fromKolab($hash) } if ($update_month || $update_daynumber || $update_weekday) { + $start = $this->getRecurStart(); + if ($update_month) { $month2number = [ 'january' => 1, @@ -1614,7 +726,7 @@ public function fromKolab($hash) ]; if (isset($month2number[$hash['month']])) { - $this->start->month = $month2number[$hash['month']]; + $start->month = $month2number[$hash['month']]; } } @@ -1624,19 +736,22 @@ public function fromKolab($hash) return false; } - $this->start->mday = $hash['daynumber']; + $start->mday = $hash['daynumber']; } if ($update_weekday) { - $this->start->setNthWeekday($last_found_day, $nth_weekday); + $start->setNthWeekday($last_found_day, $nth_weekday); } + + $this->setRecurStart($start); } - // Exceptions. if (isset($hash['exclusion'])) { foreach ($hash['exclusion'] as $exception) { if ($exception instanceof DateTime) { - $this->exceptions[] = $exception->format('Ymd'); + $this->modern->addException( + DateTimeImmutable::createFromMutable($exception) + ); } } } @@ -1644,7 +759,9 @@ public function fromKolab($hash) if (isset($hash['complete'])) { foreach ($hash['complete'] as $completion) { if ($exception instanceof DateTime) { - $this->completions[] = $completion->format('Ymd'); + $this->modern->addCompletion( + DateTimeImmutable::createFromMutable($completion) + ); } } } @@ -1654,8 +771,6 @@ public function fromKolab($hash) /** * Export this object into a Kolab hash. - * - * @return array The recurrence hash. */ public function toKolab() { @@ -1725,8 +840,7 @@ public function toKolab() case self::RECUR_MONTHLY_LAST_WEEKDAY: $hash['cycle'] = 'monthly'; $hash['type'] = 'weekday'; - if ($this->recurType == self::RECUR_MONTHLY_LAST_WEEKDAY) { - // This is not officially part of the Kolab 2.0 specs. + if ($this->getRecurType() == self::RECUR_MONTHLY_LAST_WEEKDAY) { $hash['daynumber'] = '-1'; } else { $hash['daynumber'] = $start->weekOfMonth(); @@ -1767,84 +881,62 @@ public function toKolab() $hash['range'] = ''; } - // Recurrence exceptions $hash['exclusion'] = $hash['complete'] = []; - foreach ($this->exceptions as $exception) { + foreach ($this->modern->getExceptions() as $exception) { $hash['exclusion'][] = new DateTime($exception); } - foreach ($this->completions as $completionexception) { + foreach ($this->modern->getCompletions() as $completionexception) { $hash['complete'][] = new DateTime($completionexception); } return $hash; } - /** - * Returns a hash representing this object. - * - * @since Horde_Date 2.4.0 - * @see fromHash() - * - * @return array A hash of this object. - */ public function toHash() { + $start = $this->getRecurStart(); + $recurEnd = $this->getRecurEnd(); return [ - 'start' => $this->start->format(Horde_Date::DATE_DEFAULT . '/e'), - 'end' => $this->recurEnd - ? $this->recurEnd->format(Horde_Date::DATE_DEFAULT . '/e') + 'start' => $start->format(Horde_Date::DATE_DEFAULT . '/e'), + 'end' => $recurEnd + ? $recurEnd->format(Horde_Date::DATE_DEFAULT . '/e') : null, - 'count' => $this->recurCount, - 'type' => $this->recurType, - 'interval' => $this->recurInterval, - 'data' => $this->recurData, - 'exceptions' => $this->exceptions, - 'completions' => $this->completions, + 'count' => $this->modern->getCount(), + 'type' => $this->modern->getType()->value, + 'interval' => $this->modern->getInterval(), + 'data' => $this->modern->getDayMask() !== 0 + ? $this->modern->getDayMask() + : null, + 'exceptions' => $this->modern->getExceptions(), + 'completions' => $this->modern->getCompletions(), ]; } - /** - * Returns a simple object suitable for json transport representing this - * object. - * - * Possible properties are: - * - t: type - * - i: interval - * - e: end date - * - c: count - * - d: data - * - co: completions - * - ex: exceptions - * - * @return object A simple object. - */ public function toJson() { $json = new stdClass(); - $json->t = $this->recurType; - $json->i = $this->recurInterval; + $json->t = $this->modern->getType()->value; + $json->i = $this->modern->getInterval(); if ($this->hasRecurEnd()) { - $json->e = $this->recurEnd->toJson(); + $recurEnd = $this->getRecurEnd(); + $json->e = $recurEnd->toJson(); } - if ($this->recurCount) { - $json->c = $this->recurCount; + if ($this->modern->getCount()) { + $json->c = $this->modern->getCount(); } - if ($this->recurData) { - $json->d = $this->recurData; + if ($this->modern->getDayMask()) { + $json->d = $this->modern->getDayMask(); } - if ($this->completions) { - $json->co = $this->completions; + if ($this->modern->getCompletions()) { + $json->co = $this->modern->getCompletions(); } - if ($this->exceptions) { - $json->ex = $this->exceptions; + if ($this->modern->getExceptions()) { + $json->ex = $this->modern->getExceptions(); } return $json; } /** - * Output a human readable description of the recurrence rule. - * - * @return string * @since 2.1.0 */ public function toString($date_format, $time_format = '%X') @@ -1890,12 +982,13 @@ public function toString($date_format, $time_format = '%X') $string = Horde_Date_Translation::t("Yearly: Recurs every") . ' ' . $this->getRecurInterval() . ' ' . Horde_Date_Translation::t("year(s) on the same weekday and month of the year"); } + $recurEnd = $this->getRecurEnd(); $string .= "\n" . Horde_Date_Translation::t("Ends after") . ': ' . ($this->hasRecurEnd() - ? $this->recurEnd->strftime($date_format) - . ($this->recurEnd->hour == 23 && $this->recurEnd->min == 59 + ? $recurEnd->strftime($date_format) + . ($recurEnd->hour == 23 && $recurEnd->min == 59 ? '' - : ' ' . $this->recurEnd->strftime($time_format)) + : ' ' . $recurEnd->strftime($time_format)) : ($this->getRecurCount() ? sprintf(Horde_Date_Translation::t("%d times"), $this->getRecurCount()) : Horde_Date_Translation::t("No end date"))); @@ -1909,35 +1002,18 @@ public function toString($date_format, $time_format = '%X') return $string; } - /** - * Return whether or not this object is equal to another recurrence object. - * The objects are considered equal if the recurrence rules are the same. - * This does not take any exceptions into account. - * - * @param Horde_Date_Recurrence $recurrence The recurrence object to check - * equality to. - * - * @return boolean True if the recurrence rules are the same. - * @since 2.2.0 - */ public function isEqual(Horde_Date_Recurrence $recurrence) { - return ($this->getRecurType() == $recurrence->getRecurType() && - $this->getRecurInterval() == $recurrence->getRecurInterval() && - $this->getRecurCount() == $recurrence->getRecurCount() && - $this->getRecurEnd() == $recurrence->getRecurEnd() && - $this->getRecurStart() == $recurrence->getRecurStart() && - $this->getRecurOnDays() == $recurrence->getRecurOnDays() + return ($this->getRecurType() == $recurrence->getRecurType() + && $this->getRecurInterval() == $recurrence->getRecurInterval() + && $this->getRecurCount() == $recurrence->getRecurCount() + && $this->getRecurEnd() == $recurrence->getRecurEnd() + && $this->getRecurStart() == $recurrence->getRecurStart() + && $this->getRecurOnDays() == $recurrence->getRecurOnDays() ); } /** - * Returns a correcty formatted exception date for recurring events. - * - * @param string $date Exception in the format Ymd. - * @param string $format The format to display in. - * - * @return string The formatted date and delete link. * @since 2.1.0 */ protected function _formatExceptionDate($date, $format) @@ -1946,9 +1022,21 @@ protected function _formatExceptionDate($date, $format) return ''; } $horde_date = new Horde_Date(['year' => $match[1], - 'month' => $match[2], - 'mday' => $match[3]]); + 'month' => $match[2], + 'mday' => $match[3]]); return $horde_date->strftime($format); } + /** + * Provides access to the modern Recurrence instance for advanced use. + */ + public function getModern(): Recurrence + { + return $this->modern; + } + + private function toLegacy(DateTimeImmutable $dt): Horde_Date + { + return new Horde_Date($dt->format('Y-m-d H:i:s'), $dt->getTimezone()->getName()); + } } diff --git a/lib/Horde/Date/Repeater.php b/lib/Horde/Date/Repeater.php index b38e914..25acb45 100644 --- a/lib/Horde/Date/Repeater.php +++ b/lib/Horde/Date/Repeater.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * Copyright 2009-2017 Horde LLC (http://www.horde.org/) + * Copyright 2009-2026 Horde LLC (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. diff --git a/lib/Horde/Date/Repeater/Day.php b/lib/Horde/Date/Repeater/Day.php index 09042a4..c473514 100644 --- a/lib/Horde/Date/Repeater/Day.php +++ b/lib/Horde/Date/Repeater/Day.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * Copyright 2009-2017 Horde LLC (http://www.horde.org/) + * Copyright 2009-2026 Horde LLC (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. diff --git a/lib/Horde/Date/Repeater/DayName.php b/lib/Horde/Date/Repeater/DayName.php index 1a1a31a..f5b3152 100644 --- a/lib/Horde/Date/Repeater/DayName.php +++ b/lib/Horde/Date/Repeater/DayName.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * Copyright 2009-2017 Horde LLC (http://www.horde.org/) + * Copyright 2009-2026 Horde LLC (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. diff --git a/lib/Horde/Date/Repeater/DayPortion.php b/lib/Horde/Date/Repeater/DayPortion.php index b5a28a1..1310aad 100644 --- a/lib/Horde/Date/Repeater/DayPortion.php +++ b/lib/Horde/Date/Repeater/DayPortion.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * Copyright 2009-2017 Horde LLC (http://www.horde.org/) + * Copyright 2009-2026 Horde LLC (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. diff --git a/lib/Horde/Date/Repeater/Exception.php b/lib/Horde/Date/Repeater/Exception.php index 9a24ee6..4dd6b2c 100644 --- a/lib/Horde/Date/Repeater/Exception.php +++ b/lib/Horde/Date/Repeater/Exception.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * Copyright 2009-2017 Horde LLC (http://www.horde.org/) + * Copyright 2009-2026 Horde LLC (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. @@ -22,6 +22,4 @@ * @license http://www.horde.org/licenses/lgpl21 LGPL * @package Date */ -class Horde_Date_Repeater_Exception extends Exception -{ -} +class Horde_Date_Repeater_Exception extends Exception {} diff --git a/lib/Horde/Date/Repeater/Fortnight.php b/lib/Horde/Date/Repeater/Fortnight.php index ffeb44e..f657d36 100644 --- a/lib/Horde/Date/Repeater/Fortnight.php +++ b/lib/Horde/Date/Repeater/Fortnight.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * Copyright 2009-2017 Horde LLC (http://www.horde.org/) + * Copyright 2009-2026 Horde LLC (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. diff --git a/lib/Horde/Date/Repeater/Hour.php b/lib/Horde/Date/Repeater/Hour.php index 5a66617..c8d0b2e 100644 --- a/lib/Horde/Date/Repeater/Hour.php +++ b/lib/Horde/Date/Repeater/Hour.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * Copyright 2009-2017 Horde LLC (http://www.horde.org/) + * Copyright 2009-2026 Horde LLC (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. diff --git a/lib/Horde/Date/Repeater/Minute.php b/lib/Horde/Date/Repeater/Minute.php index 618568a..a187492 100644 --- a/lib/Horde/Date/Repeater/Minute.php +++ b/lib/Horde/Date/Repeater/Minute.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * Copyright 2009-2017 Horde LLC (http://www.horde.org/) + * Copyright 2009-2026 Horde LLC (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. @@ -38,7 +38,7 @@ public function next($pointer = 'future') $end = clone $this->currentMinuteStart; $end->min++; - return new Horde_Date_Span($this->currentMinuteStart, $end); + return new Horde_Date_Span(clone $this->currentMinuteStart, $end); } public function this($pointer = 'future') diff --git a/lib/Horde/Date/Repeater/Month.php b/lib/Horde/Date/Repeater/Month.php index a54f5e0..da8a1be 100644 --- a/lib/Horde/Date/Repeater/Month.php +++ b/lib/Horde/Date/Repeater/Month.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * Copyright 2009-2017 Horde LLC (http://www.horde.org/) + * Copyright 2009-2026 Horde LLC (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. diff --git a/lib/Horde/Date/Repeater/MonthName.php b/lib/Horde/Date/Repeater/MonthName.php index 60341ce..4b353b1 100644 --- a/lib/Horde/Date/Repeater/MonthName.php +++ b/lib/Horde/Date/Repeater/MonthName.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * Copyright 2009-2017 Horde LLC (http://www.horde.org/) + * Copyright 2009-2026 Horde LLC (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. diff --git a/lib/Horde/Date/Repeater/Season.php b/lib/Horde/Date/Repeater/Season.php index 46decc7..be45b8d 100644 --- a/lib/Horde/Date/Repeater/Season.php +++ b/lib/Horde/Date/Repeater/Season.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * Copyright 2009-2017 Horde LLC (http://www.horde.org/) + * Copyright 2009-2026 Horde LLC (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. diff --git a/lib/Horde/Date/Repeater/SeasonName.php b/lib/Horde/Date/Repeater/SeasonName.php index aa6d0c7..f739869 100644 --- a/lib/Horde/Date/Repeater/SeasonName.php +++ b/lib/Horde/Date/Repeater/SeasonName.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * Copyright 2009-2017 Horde LLC (http://www.horde.org/) + * Copyright 2009-2026 Horde LLC (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. diff --git a/lib/Horde/Date/Repeater/Second.php b/lib/Horde/Date/Repeater/Second.php index b17bc6b..f9d18a5 100644 --- a/lib/Horde/Date/Repeater/Second.php +++ b/lib/Horde/Date/Repeater/Second.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * Copyright 2009-2017 Horde LLC (http://www.horde.org/) + * Copyright 2009-2026 Horde LLC (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. @@ -36,7 +36,7 @@ public function next($pointer = 'future') $this->secondStart = clone $this->now; $this->secondStart->sec += $direction; } else { - $this->secondStart += $direction; + $this->secondStart->sec += $direction; } $end = clone $this->secondStart; diff --git a/lib/Horde/Date/Repeater/Time.php b/lib/Horde/Date/Repeater/Time.php index 610a8e0..bff1e99 100644 --- a/lib/Horde/Date/Repeater/Time.php +++ b/lib/Horde/Date/Repeater/Time.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * Copyright 2009-2017 Horde LLC (http://www.horde.org/) + * Copyright 2009-2026 Horde LLC (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. @@ -35,35 +35,35 @@ public function __construct($time) switch (strlen($t)) { case 1: case 2: - $hours = (int)$t; + $hours = (int) $t; $this->ambiguous = true; $this->type = ($hours == 12) ? 0 : $hours * 3600; break; case 3: $this->ambiguous = true; - $this->type = $t[0] * 3600 + (int)substr($t, 1, 2) * 60; + $this->type = $t[0] * 3600 + (int) substr($t, 1, 2) * 60; break; case 4: - $this->ambiguous = (strpos($time, ':') !== false) && ($t[0] != 0) && ((int)substr($t, 0, 2) <= 12); - $hours = (int)substr($t, 0, 2); - $this->type = ($hours == 12) ? - ((int)substr($t, 2, 2) * 60) : - ($hours * 60 * 60 + (int)substr($t, 2, 2) * 60); + $this->ambiguous = (strpos($time, ':') !== false) && ($t[0] != 0) && ((int) substr($t, 0, 2) <= 12); + $hours = (int) substr($t, 0, 2); + $this->type = ($hours == 12) + ? ((int) substr($t, 2, 2) * 60) + : ($hours * 60 * 60 + (int) substr($t, 2, 2) * 60); break; case 5: $this->ambiguous = true; - $this->type = $t[0] * 3600 + (int)substr($t, 1, 2) * 60 + (int)substr($t, 3, 2); + $this->type = $t[0] * 3600 + (int) substr($t, 1, 2) * 60 + (int) substr($t, 3, 2); break; case 6: - $this->ambiguous = (strpos($time, ':') !== false) && ($t[0] != 0) && ((int)substr($t, 0, 2) <= 12); - $hours = (int)substr($t, 0, 2); - $this->type = ($hours == 12) ? - ((int)substr($t, 2, 2) * 60 + (int)substr($t, 4, 2)) : - ($hours * 60 * 60 + (int)substr($t, 2, 2) * 60 + (int)substr($t, 4, 2)); + $this->ambiguous = (strpos($time, ':') !== false) && ($t[0] != 0) && ((int) substr($t, 0, 2) <= 12); + $hours = (int) substr($t, 0, 2); + $this->type = ($hours == 12) + ? ((int) substr($t, 2, 2) * 60 + (int) substr($t, 4, 2)) + : ($hours * 60 * 60 + (int) substr($t, 2, 2) * 60 + (int) substr($t, 4, 2)); break; default: diff --git a/lib/Horde/Date/Repeater/Week.php b/lib/Horde/Date/Repeater/Week.php index 6aa5ff1..4074914 100644 --- a/lib/Horde/Date/Repeater/Week.php +++ b/lib/Horde/Date/Repeater/Week.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * Copyright 2009-2017 Horde LLC (http://www.horde.org/) + * Copyright 2009-2026 Horde LLC (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. diff --git a/lib/Horde/Date/Repeater/Weekend.php b/lib/Horde/Date/Repeater/Weekend.php index 660a7ac..58fc716 100644 --- a/lib/Horde/Date/Repeater/Weekend.php +++ b/lib/Horde/Date/Repeater/Weekend.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * Copyright 2009-2017 Horde LLC (http://www.horde.org/) + * Copyright 2009-2026 Horde LLC (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. diff --git a/lib/Horde/Date/Repeater/Year.php b/lib/Horde/Date/Repeater/Year.php index 9871ca6..b13a670 100644 --- a/lib/Horde/Date/Repeater/Year.php +++ b/lib/Horde/Date/Repeater/Year.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * Copyright 2009-2017 Horde LLC (http://www.horde.org/) + * Copyright 2009-2026 Horde LLC (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. diff --git a/lib/Horde/Date/Span.php b/lib/Horde/Date/Span.php index 3270764..66eca2d 100644 --- a/lib/Horde/Date/Span.php +++ b/lib/Horde/Date/Span.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * Copyright 2009-2017 Horde LLC (http://www.horde.org/) + * Copyright 2009-2026 Horde LLC (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. diff --git a/lib/Horde/Date/Translation.php b/lib/Horde/Date/Translation.php index 2569034..6f85aee 100644 --- a/lib/Horde/Date/Translation.php +++ b/lib/Horde/Date/Translation.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * Copyright 2010-2017 Horde LLC (http://www.horde.org/) + * Copyright 2010-2026 Horde LLC (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. diff --git a/lib/Horde/Date/Utils.php b/lib/Horde/Date/Utils.php index eb05e91..9f9b346 100644 --- a/lib/Horde/Date/Utils.php +++ b/lib/Horde/Date/Utils.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * Copyright 2004-2017 Horde LLC (http://www.horde.org/) + * Copyright 2004-2026 Horde LLC (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. @@ -17,6 +17,9 @@ * Horde Date wrapper/logic class, including some calculation * functions. * + * Delegates typed work to Horde\Date\Utils while preserving the + * untyped legacy API and returning Horde_Date where callers expect it. + * * @author Chuck Hagenbuch * @category Horde * @copyright 2004-2017 Horde LLC @@ -34,7 +37,7 @@ class Horde_Date_Utils */ public static function isLeapYear($year) { - return ($year % 4 == 0 && $year % 100 != 0) || $year % 400 == 0; + return Horde\Date\Utils::isLeapYear((int) $year); } /** @@ -48,7 +51,11 @@ public static function isLeapYear($year) */ public static function firstDayOfWeek($week, $year) { - return new Horde_Date(sprintf('%04dW%02d', $year, $week)); + $modern = Horde\Date\Utils::firstDayOfWeek((int) $week, (int) $year); + return new Horde_Date( + $modern->format('Y-m-d H:i:s'), + $modern->getTimezone()->getName() + ); } /** @@ -64,11 +71,10 @@ public static function daysInMonth($month, $year) static $cache = []; if (!isset($cache[$year][$month])) { try { - $date = new DateTime(sprintf($year < 0 ? '%05d-%02d-01' : '%04d-%02d-01', $year, $month)); - } catch (Exception $e) { + $cache[$year][$month] = Horde\Date\Utils::daysInMonth((int) $month, (int) $year); + } catch (Horde\Date\DateException $e) { throw new Horde_Date_Exception($e); } - $cache[$year][$month] = $date->format('t'); } return $cache[$year][$month]; } @@ -139,59 +145,13 @@ public static function relativeDateTime( */ public static function strftime2date($format) { - $replace = [ - '/%a/' => 'D', - '/%A/' => 'l', - '/%d/' => 'd', - '/%e/' => 'j', - '/%j/' => 'z', - '/%u/' => 'N', - '/%w/' => 'w', - '/%U/' => '', - '/%V/' => 'W', - '/%W/' => '', - '/%b/' => 'M', - '/%B/' => 'F', - '/%h/' => 'M', - '/%m/' => 'm', - '/%C/' => '', - '/%g/' => 'y', - '/%G/' => 'o', - '/%y/' => 'y', - '/%Y/' => 'Y', - '/%H/' => 'H', - '/%I/' => 'h', - '/%i/' => 'g', - '/%M/' => 'i', - '/%p/' => 'A', - '/%P/' => 'a', - '/%r/' => 'h:i:s A', - '/%R/' => 'H:i', - '/%S/' => 's', - '/%T/' => 'H:i:s', - '/%z/' => 'O', - '/%Z/' => '', - '/%c/' => '', - '/%D/' => 'm/d/y', - '/%F/' => 'Y-m-d', - '/%s/' => 'U', - '/%n/' => "\n", - '/%t/' => "\t", - '/%%/' => '%', - ]; - - $callbackPatterns = [ - '/%X/' => function () { - return Horde_Nls::getLangInfo(T_FMT); - }, - '/%x/' => function () { - return Horde_Nls::getLangInfo(D_FMT); - }, - ]; - - $pass1 = preg_replace_callback_array($callbackPatterns, $format); - $pass2 = preg_replace(array_keys($replace), array_values($replace), $pass1); - return $pass2; + $provider = null; + if (class_exists('Horde_Nls')) { + $provider = function (int $constant): string { + return Horde_Nls::getLangInfo($constant); + }; + } + return Horde\Date\Utils::strftime2date((string) $format, $provider); } /** diff --git a/src/Date.php b/src/Date.php new file mode 100644 index 0000000..793ab08 --- /dev/null +++ b/src/Date.php @@ -0,0 +1,374 @@ + + * @author Jan Schneider + * @author Michael J Rubinsky + * @category Horde + * @copyright 2004-2026 The Horde Project + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Date + */ + +namespace Horde\Date; + +use DateTimeImmutable; +use DateTimeInterface; +use DateTimeZone; +use Horde\Date\Formatter\DateTimeFormatter; +use InvalidArgumentException; +use Stringable; + +class Date extends DateTimeImmutable implements DateInterface +{ + public const SUNDAY = 0; + public const MONDAY = 1; + public const TUESDAY = 2; + public const WEDNESDAY = 3; + public const THURSDAY = 4; + public const FRIDAY = 5; + public const SATURDAY = 6; + + // ========================================================================= + // DateInterface + // ========================================================================= + + public function format( + Stringable|string $pattern, + string|FormatterInterface|null $formatter = null, + Stringable|string|null $locale = null, + ) { + $pattern = (string) $pattern; + + if ($formatter === null && $locale === null) { + return parent::format($pattern); + } + + if ($formatter === null) { + $formatter = new DateTimeFormatter(); + } elseif (is_string($formatter)) { + if (!class_exists($formatter)) { + throw new InvalidArgumentException("Formatter class not found: $formatter"); + } + $formatter = new $formatter(); + } + + if (!$formatter instanceof FormatterInterface) { + throw new InvalidArgumentException('Formatter must implement FormatterInterface'); + } + + $locale = $locale !== null ? (string) $locale : 'en_US'; + $timezone = $this->getTimezone()->getName(); + + return $formatter->format($this, $pattern, $locale, $timezone); + } + + public function timestamp(): int + { + return $this->getTimestamp(); + } + + public function toDateTimeImmutable(): DateTimeImmutable + { + return $this; + } + + // ========================================================================= + // Calendar calculations + // ========================================================================= + + public function toDays(): int + { + $month = (int) parent::format('n'); + $day = (int) parent::format('j'); + $year = (int) parent::format('Y'); + + if (function_exists('GregorianToJD')) { + return gregoriantojd($month, $day, $year); + } + + if ($month > 2) { + $month -= 3; + } else { + $month += 9; + --$year; + } + + $negativeyear = $year < 0; + $century = intval($year / 100); + $year = $year % 100; + + if ($negativeyear) { + return intval((14609700 * $century + ($year == 0 ? 1 : 0)) / 400) + + intval((1461 * $year + 1) / 4) + + intval((153 * $month + 2) / 5) + + $day + 1721118; + } + + return intval(146097 * $century / 4) + + intval(1461 * $year / 4) + + intval((153 * $month + 2) / 5) + + $day + 1721119; + } + + public static function fromDays(int $days): static + { + if (function_exists('jdtogregorian')) { + [$month, $day, $year] = explode('/', jdtogregorian($days)); + } else { + $days -= 1721119; + $century = floor((4 * $days - 1) / 146097); + $days = floor(4 * $days - 1 - 146097 * $century); + $day = floor($days / 4); + + $year = floor((4 * $day + 3) / 1461); + $day = floor(4 * $day + 3 - 1461 * $year); + $day = floor(($day + 4) / 4); + + $month = floor((5 * $day - 3) / 153); + $day = floor(5 * $day - 3 - 153 * $month); + $day = floor(($day + 5) / 5); + + $year = $century * 100 + $year; + if ($month < 10) { + $month += 3; + } else { + $month -= 9; + ++$year; + } + } + + return new static(sprintf('%04d-%02d-%02d', (int) $year, (int) $month, (int) $day)); + } + + public function dayOfWeek(): int + { + $month = (int) parent::format('n'); + $day = (int) parent::format('j'); + $year = (int) parent::format('Y'); + + if ($month > 2) { + $month -= 2; + } else { + $month += 10; + --$year; + } + + $result = floor((13 * $month - 1) / 5) + + $day + ($year % 100) + + floor(($year % 100) / 4) + + floor(($year / 100) / 4) - 2 + * floor($year / 100) + 77; + + return (int) ($result - 7 * floor($result / 7)); + } + + public function dayOfYear(): int + { + return (int) parent::format('z') + 1; + } + + public function weekOfMonth(): int + { + return (int) ceil((int) parent::format('j') / 7); + } + + public function weekOfYear(): int + { + return (int) parent::format('W'); + } + + public static function weeksInYear(int $year): int + { + $date = new static($year . '-12-31'); + while ($date->dayOfWeek() !== self::THURSDAY) { + $date = $date->modify('-1 day'); + } + return $date->weekOfYear(); + } + + public function withNthWeekday(int $weekday, int $nth = 1): static + { + if ($weekday < 0 || $weekday > 6) { + return $this; + } + + $year = (int) parent::format('Y'); + $month = (int) parent::format('n'); + + if ($nth >= 0) { + $firstOfMonth = $this->setDate($year, $month, 1); + $firstDow = $firstOfMonth->dayOfWeek(); + + $day = $weekday - $firstDow + 1; + if ($weekday < $firstDow) { + $day += 7; + } + $day += 7 * $nth - 7; + + return $this->setDate($year, $month, $day); + } + + $daysInMonth = (int) parent::format('t'); + $lastOfMonth = $this->setDate($year, $month, $daysInMonth); + $lastDow = $lastOfMonth->dayOfWeek(); + + $day = $daysInMonth - ($lastDow - $weekday); + if ($lastDow < $weekday) { + $day -= 7; + } + $day -= (-7 * $nth - 7); + + return $this->setDate($year, $month, $day); + } + + public function diffDays(DateTimeInterface $other): int + { + $otherDate = $other instanceof self + ? $other + : static::createFromInterface($other); + + return abs($this->toDays() - $otherDate->toDays()); + } + + // ========================================================================= + // Comparison + // ========================================================================= + + public function compareDate(DateTimeInterface $other): int + { + $thisY = (int) parent::format('Y'); + $thisM = (int) parent::format('n'); + $thisD = (int) parent::format('j'); + + $otherY = (int) $other->format('Y'); + $otherM = (int) $other->format('n'); + $otherD = (int) $other->format('j'); + + if ($thisY !== $otherY) { + return $thisY - $otherY; + } + if ($thisM !== $otherM) { + return $thisM - $otherM; + } + return $thisD - $otherD; + } + + public function compareTime(DateTimeInterface $other): int + { + $thisH = (int) parent::format('G'); + $thisI = (int) parent::format('i'); + $thisS = (int) parent::format('s'); + + $otherH = (int) $other->format('G'); + $otherI = (int) $other->format('i'); + $otherS = (int) $other->format('s'); + + if ($thisH !== $otherH) { + return $thisH - $otherH; + } + if ($thisI !== $otherI) { + return $thisI - $otherI; + } + return $thisS - $otherS; + } + + public function compareDateTime(DateTimeInterface $other): int + { + $cmp = $this->compareDate($other); + if ($cmp !== 0) { + return $cmp; + } + return $this->compareTime($other); + } + + public function before(DateTimeInterface $other): bool + { + return $this->compareDateTime($other) < 0; + } + + public function after(DateTimeInterface $other): bool + { + return $this->compareDateTime($other) > 0; + } + + public function equals(DateTimeInterface $other): bool + { + return $this->compareDateTime($other) === 0; + } + + // ========================================================================= + // Arithmetic + // ========================================================================= + + public function addParts( + int $years = 0, + int $months = 0, + int $days = 0, + int $hours = 0, + int $minutes = 0, + int $seconds = 0, + ): static { + $result = $this; + + $totalMonths = $years * 12 + $months; + if ($totalMonths > 0) { + $result = $result->modify("+{$totalMonths} months"); + } elseif ($totalMonths < 0) { + $abs = abs($totalMonths); + $result = $result->modify("-{$abs} months"); + } + + if ($days > 0) { + $result = $result->modify("+{$days} days"); + } elseif ($days < 0) { + $abs = abs($days); + $result = $result->modify("-{$abs} days"); + } + + $totalSeconds = $hours * 3600 + $minutes * 60 + $seconds; + if ($totalSeconds > 0) { + $result = $result->modify("+{$totalSeconds} seconds"); + } elseif ($totalSeconds < 0) { + $abs = abs($totalSeconds); + $result = $result->modify("-{$abs} seconds"); + } + + return $result; + } + + public function subParts( + int $years = 0, + int $months = 0, + int $days = 0, + int $hours = 0, + int $minutes = 0, + int $seconds = 0, + ): static { + return $this->addParts(-$years, -$months, -$days, -$hours, -$minutes, -$seconds); + } + + // ========================================================================= + // Serialization + // ========================================================================= + + public function toJson(): string + { + return parent::format('Y-m-d\TH:i:s'); + } + + public function toiCalendar(bool $floating = false): string + { + if ($floating) { + return parent::format('Ymd\THis'); + } + return $this->setTimezone(new DateTimeZone('UTC'))->format('Ymd\THis\Z'); + } +} diff --git a/src/DateException.php b/src/DateException.php new file mode 100644 index 0000000..9f5268f --- /dev/null +++ b/src/DateException.php @@ -0,0 +1,22 @@ + + * @category Horde + * @copyright 2011-2026 The Horde Project + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Date + */ + +namespace Horde\Date; + +use RuntimeException; + +class DateException extends RuntimeException {} diff --git a/src/DateInterface.php b/src/DateInterface.php index 66c1e5e..d1dcb85 100644 --- a/src/DateInterface.php +++ b/src/DateInterface.php @@ -8,6 +8,7 @@ * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * + * @author Ralf Lang * @category Horde * @copyright 2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 @@ -16,6 +17,8 @@ namespace Horde\Date; +use DateTimeImmutable; +use DateTimeZone; use Stringable; /** @@ -69,4 +72,18 @@ public function format( * @return int Unix timestamp */ public function timestamp(); + + /** + * Convert to DateTimeImmutable + * + * @return DateTimeImmutable + */ + public function toDateTimeImmutable(): DateTimeImmutable; + + /** + * Get the timezone of this date + * + * @return DateTimeZone|false + */ + public function getTimezone(): DateTimeZone|false; } diff --git a/src/DateTime.php b/src/DateTime.php deleted file mode 100644 index 51fc627..0000000 --- a/src/DateTime.php +++ /dev/null @@ -1,11 +0,0 @@ - * @category Horde * @copyright 2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 @@ -19,6 +20,8 @@ use DateTime; use DateTimeInterface; use IntlDateFormatter; +use InvalidArgumentException; +use RuntimeException; /** * Date format conversion utilities @@ -118,7 +121,7 @@ public static function strftimeToIcu(string $strftimeFormat): string|array // Convert pattern using string replacement // Use placeholders to track boundaries for separator insertion $patterns = self::$strftimeToIcuMap; - uksort($patterns, fn ($a, $b) => strlen($b) <=> strlen($a)); + uksort($patterns, fn($a, $b) => strlen($b) <=> strlen($a)); $icuFormat = $strftimeFormat; foreach ($patterns as $strftime => $icu) { @@ -219,7 +222,7 @@ public static function formatDate( // Validate timestamp conversion if ($timestamp === false || !is_int($timestamp)) { - throw new \InvalidArgumentException("Invalid timestamp value"); + throw new InvalidArgumentException("Invalid timestamp value"); } // Only convert if format is strftime @@ -248,7 +251,7 @@ public static function formatDate( IntlDateFormatter::SHORT, IntlDateFormatter::SHORT ), - default => throw new \InvalidArgumentException("Unknown locale format: {$icuFormat['format']}") + default => throw new InvalidArgumentException("Unknown locale format: {$icuFormat['format']}") }; } else { // Custom pattern @@ -263,12 +266,12 @@ public static function formatDate( } if (!$formatter) { - throw new \RuntimeException("Failed to create IntlDateFormatter for format: $format"); + throw new RuntimeException("Failed to create IntlDateFormatter for format: $format"); } $result = $formatter->format($timestamp); if ($result === false) { - throw new \RuntimeException("Failed to format timestamp with format: $format"); + throw new RuntimeException("Failed to format timestamp with format: $format"); } return $result; diff --git a/src/Formatter/DateTimeFormatter.php b/src/Formatter/DateTimeFormatter.php index ee392c3..cbdea16 100644 --- a/src/Formatter/DateTimeFormatter.php +++ b/src/Formatter/DateTimeFormatter.php @@ -8,6 +8,7 @@ * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * + * @author Ralf Lang * @category Horde * @copyright 2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 @@ -19,10 +20,12 @@ use DateTime; use DateTimeInterface; use DateTimeZone; +use Horde\Date\Date; use Horde\Date\DateInterface; use Horde\Date\FormatterInterface; use Horde_Date; use RuntimeException; +use Stringable; /** * PHP DateTime formatter @@ -47,21 +50,21 @@ class DateTimeFormatter implements FormatterInterface /** * Format using DateTime::format() syntax * - * @param int|DateTimeInterface|Horde_Date $datetime Unix timestamp, DateTime/DateTimeImmutable, or Horde_Date + * @param int|DateTimeInterface|DateInterface|Horde_Date $datetime Unix timestamp, DateTime/DateTimeImmutable, DateInterface, or Horde_Date * @param string $pattern PHP date() pattern (e.g., 'Y-m-d', 'l, F j, Y') - * @param string|\Stringable $locale Ignored (DateTime is not locale-aware) + * @param string|Stringable $locale Ignored (DateTime is not locale-aware) * @param string|null $timezone Timezone identifier (null = UTC) * * @return string Formatted date string */ public function format( - int|DateTimeInterface|Horde_Date $datetime, + int|DateTimeInterface|DateInterface|Horde_Date $datetime, string $pattern, - string|\Stringable $locale = 'en_US', + string|Stringable $locale = 'en_US', ?string $timezone = null ) { // Convert Stringable to string (locale is ignored but we accept it) - $locale = (string)$locale; + $locale = (string) $locale; // Convert Horde_Date to DateTimeInterface if needed if ($datetime instanceof Horde_Date) { @@ -91,7 +94,7 @@ public function format( * * @param string $formattedString Formatted date string * @param string $pattern PHP date() pattern (e.g., 'Y-m-d', 'Y-m-d H:i:s') - * @param string|\Stringable $locale Ignored (DateTime is not locale-aware) + * @param string|Stringable $locale Ignored (DateTime is not locale-aware) * @param string|null $timezone Timezone identifier (null = UTC) * * @return DateInterface Horde_Date object @@ -101,11 +104,11 @@ public function format( public function parse( string $formattedString, string $pattern, - string|\Stringable $locale = 'en_US', + string|Stringable $locale = 'en_US', ?string $timezone = null ): DateInterface { // Convert Stringable to string (locale is ignored but we accept it) - $locale = (string)$locale; + $locale = (string) $locale; // Create timezone for parsing $tz = $timezone ? new DateTimeZone($timezone) : new DateTimeZone('UTC'); @@ -115,11 +118,11 @@ public function parse( if ($dateTime === false) { throw new RuntimeException( - "Failed to parse date string: $formattedString " . - "(pattern: $pattern)" + "Failed to parse date string: $formattedString " + . "(pattern: $pattern)" ); } - return new Horde_Date($dateTime); + return Date::createFromInterface($dateTime); } } diff --git a/src/Formatter/IcuFormatter.php b/src/Formatter/IcuFormatter.php index faba6d4..6d4a3f3 100644 --- a/src/Formatter/IcuFormatter.php +++ b/src/Formatter/IcuFormatter.php @@ -8,6 +8,7 @@ * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * + * @author Ralf Lang * @category Horde * @copyright 2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 @@ -19,6 +20,7 @@ use DateTime; use DateTimeInterface; use DateTimeZone; +use Horde\Date\Date; use Horde\Date\DateInterface; use Horde\Date\FormatterInterface; use Horde_Date; @@ -26,6 +28,7 @@ use IntlTimeZone; use InvalidArgumentException; use RuntimeException; +use Stringable; /** * ICU pattern formatter using IntlDateFormatter @@ -46,9 +49,9 @@ class IcuFormatter implements FormatterInterface /** * Format using ICU pattern syntax * - * @param int|DateTimeInterface|Horde_Date $datetime Unix timestamp, DateTime, DateTimeImmutable, or Horde_Date + * @param int|DateTimeInterface|DateInterface|Horde_Date $datetime Unix timestamp, DateTime, DateTimeImmutable, DateInterface, or Horde_Date * @param string $pattern ICU pattern or shortcut ('short', 'medium', 'long', 'full') - * @param string|\Stringable $locale ICU locale (e.g., 'en_US', 'de_DE') + * @param string|Stringable $locale ICU locale (e.g., 'en_US', 'de_DE') * @param string|null $timezone Timezone identifier (null = UTC) * * @return string Formatted date string @@ -57,13 +60,13 @@ class IcuFormatter implements FormatterInterface * @throws RuntimeException if formatting fails */ public function format( - int|DateTimeInterface|Horde_Date $datetime, + int|DateTimeInterface|DateInterface|Horde_Date $datetime, string $pattern, - string|\Stringable $locale = 'en_US', + string|Stringable $locale = 'en_US', ?string $timezone = null ) { // Convert Stringable to string - $locale = (string)$locale; + $locale = (string) $locale; // Convert Horde_Date to DateTimeInterface if needed if ($datetime instanceof Horde_Date) { @@ -120,7 +123,7 @@ public function format( * * @param string $formattedString ICU formatted date string * @param string $pattern ICU pattern (or shortcut: short/medium/long/full) - * @param string|\Stringable $locale ICU locale (e.g., 'en_US', 'de_DE') + * @param string|Stringable $locale ICU locale (e.g., 'en_US', 'de_DE') * @param string|null $timezone Timezone identifier (null = UTC) * * @return DateInterface Horde_Date object @@ -131,11 +134,11 @@ public function format( public function parse( string $formattedString, string $pattern, - string|\Stringable $locale = 'en_US', + string|Stringable $locale = 'en_US', ?string $timezone = null ): DateInterface { // Convert Stringable to string - $locale = (string)$locale; + $locale = (string) $locale; // Create IntlTimeZone if timezone provided $intlTimezone = $timezone ? IntlTimeZone::createTimeZone($timezone) : null; @@ -176,8 +179,8 @@ public function parse( if ($timestamp === false) { throw new RuntimeException( - "Failed to parse date string: $formattedString " . - "(pattern: $pattern, locale: $locale)" + "Failed to parse date string: $formattedString " + . "(pattern: $pattern, locale: $locale)" ); } @@ -190,6 +193,6 @@ public function parse( $dateTime = new DateTime('@' . $timestamp); } - return new Horde_Date($dateTime); + return Date::createFromInterface($dateTime); } } diff --git a/src/FormatterInterface.php b/src/FormatterInterface.php index 14ca96d..46e802d 100644 --- a/src/FormatterInterface.php +++ b/src/FormatterInterface.php @@ -8,6 +8,7 @@ * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * + * @author Ralf Lang * @category Horde * @copyright 2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 @@ -18,6 +19,8 @@ use DateTimeInterface; use Horde_Date; +use RuntimeException; +use Stringable; /** * Date formatter interface @@ -37,17 +40,17 @@ interface FormatterInterface /** * Format a timestamp * - * @param int|DateTimeInterface|Horde_Date $datetime Unix timestamp, DateTime/DateTimeImmutable, or Horde_Date + * @param int|DateTimeInterface|DateInterface|Horde_Date $datetime Unix timestamp, DateTime/DateTimeImmutable, DateInterface, or Horde_Date * @param string $pattern Format pattern in formatter's syntax - * @param string|\Stringable $locale Locale for formatting (default: 'en_US') + * @param string|Stringable $locale Locale for formatting (default: 'en_US') * @param string|null $timezone Timezone identifier (null = UTC) * * @return string Formatted date string (no return type for BC) */ public function format( - int|DateTimeInterface|Horde_Date $datetime, + int|DateTimeInterface|DateInterface|Horde_Date $datetime, string $pattern, - string|\Stringable $locale = 'en_US', + string|Stringable $locale = 'en_US', ?string $timezone = null ); @@ -58,17 +61,17 @@ public function format( * * @param string $formattedString Formatted date string * @param string $pattern Format pattern in formatter's syntax - * @param string|\Stringable $locale Locale for parsing (default: 'en_US') + * @param string|Stringable $locale Locale for parsing (default: 'en_US') * @param string|null $timezone Timezone identifier (null = UTC) * * @return DateInterface Date object * - * @throws \RuntimeException if parsing fails + * @throws RuntimeException if parsing fails */ public function parse( string $formattedString, string $pattern, - string|\Stringable $locale = 'en_US', + string|Stringable $locale = 'en_US', ?string $timezone = null ): DateInterface; } diff --git a/src/HordeLegacyDate.php b/src/HordeLegacyDate.php new file mode 100644 index 0000000..a90d25a --- /dev/null +++ b/src/HordeLegacyDate.php @@ -0,0 +1,527 @@ +inner = new DateTimeImmutable( + sprintf( + '%04d-%02d-%02d %02d:%02d:%02d', + (int) $this->_year, + (int) $this->_month, + (int) $this->_mday, + (int) $this->_hour, + (int) $this->_min, + (int) $this->_sec, + ), + new DateTimeZone($this->_timezone), + ); + } + + public function __get($name) + { + if ($name === 'day') { + $name = 'mday'; + } + return match ($name) { + 'year' => (int) $this->inner->format('Y'), + 'month' => (int) $this->inner->format('n'), + 'mday' => (int) $this->inner->format('j'), + 'hour' => (int) $this->inner->format('G'), + 'min' => (int) $this->inner->format('i'), + 'sec' => (int) $this->inner->format('s'), + 'timezone' => $this->inner->getTimezone()->getName(), + default => null, + }; + } + + public function __set($name, $value) + { + if ($name === 'day') { + $name = 'mday'; + } + + if ($name === 'timezone') { + $tz = new DateTimeZone(Horde_Date::getTimezoneAlias((string) $value)); + $this->inner = new DateTimeImmutable( + $this->inner->format('Y-m-d H:i:s'), + $tz, + ); + return; + } + + $valid = ['year', 'month', 'mday', 'hour', 'min', 'sec']; + if (!in_array($name, $valid, true)) { + throw new InvalidArgumentException('Undefined property ' . $name); + } + + $value = (int) $value; + $y = (int) $this->inner->format('Y'); + $m = (int) $this->inner->format('n'); + $d = (int) $this->inner->format('j'); + $h = (int) $this->inner->format('G'); + $i = (int) $this->inner->format('i'); + $s = (int) $this->inner->format('s'); + + match ($name) { + 'year' => $y = $value, + 'month' => $m = $value, + 'mday' => $d = $value, + 'hour' => $h = $value, + 'min' => $i = $value, + 'sec' => $s = $value, + }; + + $this->inner = $this->inner->setDate($y, $m, $d)->setTime($h, $i, $s); + } + + public function __isset($name) + { + if ($name === 'day') { + $name = 'mday'; + } + return in_array($name, ['year', 'month', 'mday', 'hour', 'min', 'sec'], true); + } + + public function __toString() + { + try { + return $this->format($this->_defaultFormat); + } catch (Exception $e) { + return ''; + } + } + + public function __clone() + { + // DateTimeImmutable is a value type, but be explicit + } + + // ========================================================================= + // Conversion + // ========================================================================= + + public function toDateTime() + { + return DateTime::createFromImmutable($this->inner); + } + + public function toDateTimeImmutable(): DateTimeImmutable + { + return $this->inner; + } + + public function toDate(): Date + { + return Date::createFromInterface($this->inner); + } + + public function getTimezone(): DateTimeZone|false + { + return $this->inner->getTimezone(); + } + + // ========================================================================= + // Calendar calculations + // ========================================================================= + + public function toDays() + { + return $this->toDate()->toDays(); + } + + public static function fromDays($days) + { + $modern = Date::fromDays((int) $days); + return new static($modern->format('Y-m-d H:i:s')); + } + + public function dayOfWeek() + { + return (int) $this->inner->format('w'); + } + + public function dayOfYear() + { + return (int) $this->inner->format('z') + 1; + } + + public function weekOfMonth() + { + return (int) ceil((int) $this->inner->format('j') / 7); + } + + public function weekOfYear() + { + return (int) $this->inner->format('W'); + } + + public static function weeksInYear($year) + { + return Date::weeksInYear((int) $year); + } + + public function setNthWeekday($weekday, $nth = 1) + { + if ($weekday < self::DATE_SUNDAY || $weekday > self::DATE_SATURDAY) { + return; + } + + $date = $this->toDate(); + $result = $date->withNthWeekday($weekday, $nth); + $this->inner = $this->inner + ->setDate( + (int) $result->format('Y'), + (int) $result->format('n'), + (int) $result->format('j'), + ); + } + + public function isValid() + { + $year = (int) $this->inner->format('Y'); + return $year >= 0 && $year <= 9999; + } + + // ========================================================================= + // Comparison + // ========================================================================= + + public function compareDate($other) + { + if (!($other instanceof Horde_Date)) { + $other = new Horde_Date($other); + } + + $thisY = (int) $this->inner->format('Y'); + $thisM = (int) $this->inner->format('n'); + $thisD = (int) $this->inner->format('j'); + + if ($thisY != $other->year) { + return $thisY - $other->year; + } + if ($thisM != $other->month) { + return $thisM - $other->month; + } + return $thisD - $other->mday; + } + + public function compareTime($other) + { + if (!($other instanceof Horde_Date)) { + $other = new Horde_Date($other); + } + + $thisH = (int) $this->inner->format('G'); + $thisI = (int) $this->inner->format('i'); + $thisS = (int) $this->inner->format('s'); + + if ($thisH != $other->hour) { + return $thisH - $other->hour; + } + if ($thisI != $other->min) { + return $thisI - $other->min; + } + return $thisS - $other->sec; + } + + public function compareDateTime($other) + { + if (!($other instanceof Horde_Date)) { + $other = new Horde_Date($other); + } + + if ($diff = $this->compareDate($other)) { + return $diff; + } + return $this->compareTime($other); + } + + public function after($other) + { + return $this->compareDate($other) > 0; + } + + public function before($other) + { + return $this->compareDate($other) < 0; + } + + public function equals($other) + { + return $this->compareDate($other) == 0; + } + + public function diff($other) + { + if (!($other instanceof Horde_Date)) { + $other = new Horde_Date($other); + } + return abs($this->toDays() - $other->toDays()); + } + + // ========================================================================= + // Arithmetic + // ========================================================================= + + public function add($factor) + { + $d = clone $this; + if (is_array($factor) || is_object($factor)) { + foreach ($factor as $property => $value) { + $d->$property += $value; + } + } else { + $d->inner = $d->inner->modify(sprintf('%+d seconds', (int) $factor)); + } + return $d; + } + + public function sub($factor) + { + if (is_array($factor)) { + foreach ($factor as &$value) { + $value *= -1; + } + } else { + $factor *= -1; + } + return $this->add($factor); + } + + // ========================================================================= + // Timezone + // ========================================================================= + + public function setTimezone($timezone) + { + $timezone = self::getTimezoneAlias($timezone); + try { + $this->inner = $this->inner->setTimezone(new DateTimeZone($timezone)); + } catch (Exception $e) { + throw new Horde_Date_Exception($e->getMessage()); + } + return $this; + } + + public function tzOffset($colon = true) + { + return $this->inner->format($colon ? 'P' : 'O'); + } + + // ========================================================================= + // Timestamps & Serialization + // ========================================================================= + + public function timestamp() + { + return $this->inner->getTimestamp(); + } + + public function datestamp() + { + return $this->inner->setTime(0, 0, 0)->getTimestamp(); + } + + public function dateString() + { + return $this->inner->format('Ymd'); + } + + public function toJson() + { + return $this->inner->format(self::DATE_JSON); + } + + public function toiCalendar($floating = false) + { + if ($floating) { + return $this->inner->format('Ymd\THis'); + } + return $this->inner->setTimezone(new DateTimeZone('UTC'))->format('Ymd\THis\Z'); + } + + // ========================================================================= + // Formatting + // ========================================================================= + + public function setDefaultFormat($format) + { + $this->_defaultFormat = $format; + } + + public function format($pattern, $formatter = null, $locale = null) + { + if ($formatter === null && $locale === null && func_num_args() === 1) { + return $this->inner->format((string) $pattern); + } + + $pattern = (string) $pattern; + + if ($formatter === null) { + $formatter = new Formatter\DateTimeFormatter(); + } elseif (is_string($formatter)) { + if (!class_exists($formatter)) { + throw new InvalidArgumentException("Formatter class not found: $formatter"); + } + $formatter = new $formatter(); + } + + if (!$formatter instanceof FormatterInterface) { + throw new InvalidArgumentException("Formatter must implement FormatterInterface"); + } + + if ($locale !== null) { + $locale = (string) $locale; + } + + $timezone = $this->inner->getTimezone()->getName(); + $locale = $locale ?? $this->_locale ?? setlocale(LC_ALL, '0') ?: 'en_US'; + + return $formatter->format($this, $pattern, $locale, $timezone); + } + + public function strftime($format) + { + if (preg_match('/%[^' . self::$_supportedSpecs . ']/', $format)) { + $format = $this->_fixPolyfillFormat($format); + return strftime($format, $this->timestamp()); + } + return $this->_strftime($format); + } + + protected function _strftime($format) + { + return preg_replace_callback( + '/(%([-#]?)[%bBCdDeHImMnpRStTxXyY])/', + [$this, '_regexCallback'], + $format, + ); + } + + protected function _regexCallback($reg) + { + $year = (int) $this->inner->format('Y'); + $month = (int) $this->inner->format('n'); + $mday = (int) $this->inner->format('j'); + $hour = (int) $this->inner->format('G'); + $min = (int) $this->inner->format('i'); + $sec = (int) $this->inner->format('s'); + + switch ($reg[0]) { + case '%b': + return $this->strftime(Horde_Nls::getLangInfo(constant('ABMON_' . $month))); + case '%B': + return $this->strftime(Horde_Nls::getLangInfo(constant('MON_' . $month))); + case '%C': + return (int) ($year / 100); + case '%-d': + case '%#d': + return sprintf('%d', $mday); + case '%d': + return sprintf('%02d', $mday); + case '%D': + return $this->strftime('%m/%d/%y'); + case '%e': + return sprintf('%2d', $mday); + case '%-H': + case '%#H': + return sprintf('%d', $hour); + case '%H': + return sprintf('%02d', $hour); + case '%-I': + case '%#I': + return sprintf('%d', $hour == 0 ? 12 : ($hour > 12 ? $hour - 12 : $hour)); + case '%I': + return sprintf('%02d', $hour == 0 ? 12 : ($hour > 12 ? $hour - 12 : $hour)); + case '%-m': + case '%#m': + return sprintf('%d', $month); + case '%m': + return sprintf('%02d', $month); + case '%-M': + case '%#M': + return sprintf('%d', $min); + case '%M': + return sprintf('%02d', $min); + case '%n': + return "\n"; + case '%p': + return $this->strftime(Horde_Nls::getLangInfo($hour < 12 ? AM_STR : PM_STR)); + case '%R': + return $this->strftime('%H:%M'); + case '%-S': + case '%#S': + return sprintf('%d', $sec); + case '%S': + return sprintf('%02d', $sec); + case '%t': + return "\t"; + case '%T': + return $this->strftime('%H:%M:%S'); + case '%x': + return $this->strftime(Horde_Nls::getLangInfo(D_FMT)); + case '%X': + return $this->strftime(Horde_Nls::getLangInfo(T_FMT)); + case '%y': + return substr(sprintf('%04d', $year), -2); + case '%Y': + return (int) $year; + case '%%': + return '%'; + } + return $reg[0]; + } +} diff --git a/src/Recurrence/DayMask.php b/src/Recurrence/DayMask.php new file mode 100644 index 0000000..e5ee3d2 --- /dev/null +++ b/src/Recurrence/DayMask.php @@ -0,0 +1,149 @@ + + * @category Horde + * @copyright 2007-2026 The Horde Project + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Date + */ + +namespace Horde\Date\Recurrence; + +use InvalidArgumentException; + +final class DayMask +{ + public const SUNDAY = 1; + public const MONDAY = 2; + public const TUESDAY = 4; + public const WEDNESDAY = 8; + public const THURSDAY = 16; + public const FRIDAY = 32; + public const SATURDAY = 64; + + public const WEEKDAYS = self::MONDAY | self::TUESDAY | self::WEDNESDAY | self::THURSDAY | self::FRIDAY; + public const WEEKEND = self::SUNDAY | self::SATURDAY; + public const ALL_DAYS = self::WEEKDAYS | self::WEEKEND; + + private const DAY_OF_WEEK_MAP = [ + 0 => self::SUNDAY, + 1 => self::MONDAY, + 2 => self::TUESDAY, + 3 => self::WEDNESDAY, + 4 => self::THURSDAY, + 5 => self::FRIDAY, + 6 => self::SATURDAY, + ]; + + private const RFC5545_MAP = [ + 'SU' => self::SUNDAY, + 'MO' => self::MONDAY, + 'TU' => self::TUESDAY, + 'WE' => self::WEDNESDAY, + 'TH' => self::THURSDAY, + 'FR' => self::FRIDAY, + 'SA' => self::SATURDAY, + ]; + + private const REVERSE_RFC5545_MAP = [ + self::SUNDAY => 'SU', + self::MONDAY => 'MO', + self::TUESDAY => 'TU', + self::WEDNESDAY => 'WE', + self::THURSDAY => 'TH', + self::FRIDAY => 'FR', + self::SATURDAY => 'SA', + ]; + + public static function includes(int $mask, int $day): bool + { + return ($mask & $day) !== 0; + } + + public static function fromDays(int ...$days): int + { + $mask = 0; + foreach ($days as $day) { + $mask |= $day; + } + return $mask; + } + + /** + * @param int $dayOfWeek 0 (Sunday) through 6 (Saturday) + */ + public static function fromDayOfWeek(int $dayOfWeek): int + { + if (!isset(self::DAY_OF_WEEK_MAP[$dayOfWeek])) { + throw new InvalidArgumentException( + "Invalid day of week: $dayOfWeek (expected 0-6)" + ); + } + return self::DAY_OF_WEEK_MAP[$dayOfWeek]; + } + + /** + * @return list Individual day constants present in the mask + */ + public static function toDays(int $mask): array + { + $days = []; + foreach (self::DAY_OF_WEEK_MAP as $bit) { + if (($mask & $bit) !== 0) { + $days[] = $bit; + } + } + return $days; + } + + public static function count(int $mask): int + { + $count = 0; + foreach (self::DAY_OF_WEEK_MAP as $bit) { + if (($mask & $bit) !== 0) { + $count++; + } + } + return $count; + } + + /** + * @param list $days RFC 5545 day abbreviations (e.g. ['MO', 'WE', 'FR']) + */ + public static function fromRfc5545Days(array $days): int + { + $mask = 0; + foreach ($days as $day) { + $upper = strtoupper($day); + if (!isset(self::RFC5545_MAP[$upper])) { + throw new InvalidArgumentException( + "Invalid RFC 5545 day abbreviation: $day" + ); + } + $mask |= self::RFC5545_MAP[$upper]; + } + return $mask; + } + + /** + * @return list RFC 5545 day abbreviations in SU-SA order + */ + public static function toRfc5545Days(int $mask): array + { + $days = []; + foreach (self::REVERSE_RFC5545_MAP as $bit => $abbr) { + if (($mask & $bit) !== 0) { + $days[] = $abbr; + } + } + return $days; + } +} diff --git a/src/Recurrence/Recurrence.php b/src/Recurrence/Recurrence.php new file mode 100644 index 0000000..4b8e036 --- /dev/null +++ b/src/Recurrence/Recurrence.php @@ -0,0 +1,1113 @@ + + * @category Horde + * @copyright 2007-2026 The Horde Project + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Date + */ + +namespace Horde\Date\Recurrence; + +use DateTimeImmutable; +use DateTimeInterface; +use DateTimeZone; +use Horde\Date\Date; +use Horde\Date\Translation; +use Horde\Date\Utils; +use stdClass; + +class Recurrence implements RecurrenceInterface +{ + private RecurrenceType $type = RecurrenceType::None; + private int $interval = 1; + private DateTimeImmutable $start; + private ?DateTimeImmutable $end = null; + private ?int $count = null; + private int $dayMask = 0; + /** @var list YYYYMMDD strings */ + private array $exceptions = []; + /** @var list YYYYMMDD strings */ + private array $completions = []; + + public function __construct(DateTimeInterface $start) + { + $this->start = $start instanceof DateTimeImmutable + ? $start + : DateTimeImmutable::createFromInterface($start); + } + + // ========================================================================= + // Interface Getters + // ========================================================================= + + public function getType(): RecurrenceType + { + return $this->type; + } + + public function getInterval(): int + { + return $this->interval; + } + + public function getStart(): DateTimeImmutable + { + return $this->start; + } + + public function getEnd(): ?DateTimeImmutable + { + return $this->end; + } + + public function getCount(): ?int + { + return $this->count; + } + + public function getDayMask(): int + { + return $this->dayMask; + } + + // ========================================================================= + // Setters + // ========================================================================= + + public function setType(RecurrenceType $type): void + { + $this->type = $type; + } + + public function setInterval(int $interval): void + { + if ($interval >= 0) { + $this->interval = $interval; + } + } + + public function setStart(DateTimeInterface $start): void + { + $this->start = $start instanceof DateTimeImmutable + ? $start + : DateTimeImmutable::createFromInterface($start); + } + + public function setEnd(?DateTimeInterface $end): void + { + if ($end !== null) { + $this->count = null; + $this->end = $end instanceof DateTimeImmutable + ? $end + : DateTimeImmutable::createFromInterface($end); + } else { + $this->end = null; + } + } + + public function setCount(?int $count): void + { + if ($count !== null && $count > 0) { + $this->count = $count; + $this->end = null; + } else { + $this->count = null; + } + } + + public function setDayMask(int $mask): void + { + $this->dayMask = $mask; + } + + public function reset(): void + { + $this->type = RecurrenceType::None; + $this->interval = 1; + $this->end = null; + $this->count = null; + $this->dayMask = 0; + $this->exceptions = []; + $this->completions = []; + } + + public function hasEnd(): bool + { + return $this->end !== null + && (int) $this->end->format('Y') !== 9999; + } + + public function hasCount(): bool + { + return $this->count !== null; + } + + // ========================================================================= + // Exception / Completion Management + // ========================================================================= + + public function addException(DateTimeInterface $date): void + { + $key = $date->format('Ymd'); + if (!in_array($key, $this->exceptions, true)) { + $this->exceptions[] = $key; + } + } + + public function deleteException(DateTimeInterface $date): void + { + $key = $date->format('Ymd'); + $idx = array_search($key, $this->exceptions, true); + if ($idx !== false) { + unset($this->exceptions[$idx]); + $this->exceptions = array_values($this->exceptions); + } + } + + public function hasException(DateTimeInterface $date): bool + { + return in_array($date->format('Ymd'), $this->exceptions, true); + } + + public function getExceptions(): array + { + return $this->exceptions; + } + + public function setExceptions(array $exceptions): void + { + $this->exceptions = array_values($exceptions); + } + + public function addCompletion(DateTimeInterface $date): void + { + $this->completions[] = $date->format('Ymd'); + } + + public function deleteCompletion(DateTimeInterface $date): void + { + $key = $date->format('Ymd'); + $idx = array_search($key, $this->completions, true); + if ($idx !== false) { + unset($this->completions[$idx]); + $this->completions = array_values($this->completions); + } + } + + public function hasCompletion(DateTimeInterface $date): bool + { + return in_array($date->format('Ymd'), $this->completions, true); + } + + public function getCompletions(): array + { + return $this->completions; + } + + public function setCompletions(array $completions): void + { + $this->completions = array_values($completions); + } + + // ========================================================================= + // nextRecurrence / nextActiveRecurrence / hasActiveRecurrence + // ========================================================================= + + public function nextRecurrence(DateTimeInterface $after): ?DateTimeImmutable + { + $tz = $this->start->getTimezone(); + $after = ($after instanceof DateTimeImmutable) + ? $after->setTimezone($tz) + : DateTimeImmutable::createFromInterface($after)->setTimezone($tz); + + $startDate = Date::createFromInterface($this->start); + $afterDate = Date::createFromInterface($after); + + if ($startDate->compareDateTime($afterDate) >= 0) { + return $this->start; + } + + if ($this->interval === 0) { + return null; + } + + return match ($this->type) { + RecurrenceType::Daily => $this->nextDaily($afterDate), + RecurrenceType::Weekly => $this->nextWeekly($afterDate), + RecurrenceType::MonthlyDate => $this->nextMonthlyDate($afterDate), + RecurrenceType::MonthlyWeekday, + RecurrenceType::MonthlyLastWeekday => $this->nextMonthlyWeekday($afterDate), + RecurrenceType::YearlyDate => $this->nextYearlyDate($afterDate), + RecurrenceType::YearlyDay => $this->nextYearlyDay($afterDate), + RecurrenceType::YearlyWeekday => $this->nextYearlyWeekday($afterDate), + default => null, + }; + } + + public function nextActiveRecurrence(DateTimeInterface $after): ?DateTimeImmutable + { + $next = $this->nextRecurrence($after); + while ($next !== null) { + if (!$this->hasException($next) && !$this->hasCompletion($next)) { + return $next; + } + $next = $this->nextRecurrence($next->modify('+1 day')); + } + return null; + } + + public function hasActiveRecurrence(): bool + { + if (!$this->hasEnd()) { + return true; + } + + $next = $this->nextRecurrence($this->start); + while ($next !== null) { + if (!$this->hasException($next) && !$this->hasCompletion($next)) { + return true; + } + $next = $this->nextRecurrence($next->modify('+1 day')); + } + return false; + } + + // ========================================================================= + // Private recurrence algorithms + // ========================================================================= + + private function nextDaily(Date $after): ?DateTimeImmutable + { + $startDate = Date::createFromInterface($this->start); + $diff = $startDate->diffDays($after); + $recur = (int) ceil($diff / $this->interval); + + if ($this->count !== null && $recur >= $this->count) { + return null; + } + + $recur *= $this->interval; + $next = $startDate->modify("+{$recur} days"); + + if ($this->hasEnd()) { + $endDate = Date::createFromInterface($this->end); + if ($next->compareDateTime($endDate) > 0) { + return null; + } + } + + if ($next->compareDateTime($after) >= 0) { + return $this->toDateTimeImmutable($next); + } + + return null; + } + + private function nextWeekly(Date $after): ?DateTimeImmutable + { + if ($this->dayMask === 0) { + return null; + } + + $startDate = Date::createFromInterface($this->start); + $tz = $this->start->getTimezone(); + + $startWeek = $this->firstDayOfWeekInTz( + (int) $startDate->format('W'), + (int) $startDate->format('Y'), + $tz, + (int) $startDate->format('G'), + (int) $startDate->format('i'), + (int) $startDate->format('s') + ); + $startWeek = Date::createFromInterface($startWeek); + + $week = (int) $after->format('W'); + $afterMonth = (int) $after->format('n'); + $afterYear = (int) $after->format('Y'); + + if ($week === 1 && $afterMonth === 12) { + $theYear = $afterYear + 1; + } elseif ($week >= 52 && $afterMonth === 1) { + $theYear = $afterYear - 1; + } else { + $theYear = $afterYear; + } + + $afterWeek = $this->firstDayOfWeekInTz( + $week, + $theYear, + $tz, + (int) $startDate->format('G'), + (int) $startDate->format('i'), + (int) $startDate->format('s') + ); + $afterWeek = Date::createFromInterface($afterWeek); + $afterWeekEnd = Date::createFromInterface($afterWeek->modify('+7 days')); + + $diff = $startWeek->diffDays($afterWeek); + $intervalDays = $this->interval * 7; + $repeats = (int) floor($diff / $intervalDays); + + if ($diff % $intervalDays < 7) { + $recur = $diff; + } else { + $recur = $intervalDays * ($repeats + 1); + } + + if ($this->count !== null) { + $recurrences = 0; + $next = clone $startWeek; + while ($next->compareDateTime($startDate) < 0) { + if (DayMask::includes($this->dayMask, DayMask::fromDayOfWeek($next->dayOfWeek()))) { + $recurrences--; + } + $next = Date::createFromInterface($next->modify('+1 day')); + } + if ($repeats > 0) { + $totalPerWeek = DayMask::count($this->dayMask); + $recurrences += $totalPerWeek * $repeats; + } + } + + $next = Date::createFromInterface($startWeek->modify("+{$recur} days")); + while ($next->compareDateTime($after) < 0 + && $next->compareDateTime($afterWeekEnd) < 0) { + if (isset($recurrences) + && $next->compareDateTime($after) < 0 + && DayMask::includes($this->dayMask, DayMask::fromDayOfWeek($next->dayOfWeek()))) { + $recurrences++; + } + $next = Date::createFromInterface($next->modify('+1 day')); + } + + if (isset($recurrences) && $recurrences >= $this->count) { + return null; + } + + $endDate = $this->hasEnd() ? Date::createFromInterface($this->end) : null; + + if ($endDate === null || $next->compareDateTime($endDate) <= 0) { + if ($next->compareDateTime($afterWeekEnd) >= 0) { + return $this->nextRecurrence($afterWeekEnd); + } + while (!DayMask::includes($this->dayMask, DayMask::fromDayOfWeek($next->dayOfWeek())) + && $next->compareDateTime($afterWeekEnd) < 0) { + $next = Date::createFromInterface($next->modify('+1 day')); + } + if ($endDate === null || $next->compareDateTime($endDate) <= 0) { + if ($next->compareDateTime($afterWeekEnd) >= 0) { + return $this->nextRecurrence($afterWeekEnd); + } + return $this->toDateTimeImmutable($next); + } + } + + return null; + } + + private function nextMonthlyDate(Date $after): ?DateTimeImmutable + { + $startDate = Date::createFromInterface($this->start); + $startDay = (int) $startDate->format('j'); + $startMonth = (int) $startDate->format('n'); + $startYear = (int) $startDate->format('Y'); + + $afterDay = (int) $after->format('j'); + $afterMonth = (int) $after->format('n'); + $afterYear = (int) $after->format('Y'); + + if ($afterDay > $startDay) { + $afterMonth++; + if ($afterMonth > 12) { + $afterMonth = 1; + $afterYear++; + } + } + + $offset = ($afterMonth - $startMonth) + ($afterYear - $startYear) * 12; + $offset = (int) floor(($offset + $this->interval - 1) / $this->interval) * $this->interval; + + if ($this->count !== null && ($offset / $this->interval) >= $this->count) { + return null; + } + + $candidateMonthOffset = $offset; + $countSoFar = (int) ($offset / $this->interval); + + while (true) { + if ($this->count !== null && $countSoFar++ >= $this->count) { + return null; + } + + $totalMonths = ($startYear * 12 + $startMonth - 1) + $candidateMonthOffset; + $cYear = (int) floor($totalMonths / 12); + $cMonth = ($totalMonths % 12) + 1; + + $daysInMonth = Utils::daysInMonth($cMonth, $cYear); + if ($startDay <= $daysInMonth) { + $candidate = $this->buildDate($cYear, $cMonth, $startDay); + $candidateDate = Date::createFromInterface($candidate); + + if ($this->hasEnd()) { + $endDate = Date::createFromInterface($this->end); + if ($endDate->compareDateTime($candidateDate) < 0) { + return null; + } + } + + return $candidate; + } + + if ($this->interval === 12 && ($cMonth !== 2 || $startDay > 29)) { + return null; + } + + $candidateMonthOffset += $this->interval; + } + } + + private function nextMonthlyWeekday(Date $after): ?DateTimeImmutable + { + $startDate = Date::createFromInterface($this->start); + $startDay = (int) $startDate->format('j'); + $startMonth = (int) $startDate->format('n'); + $startYear = (int) $startDate->format('Y'); + + if ($this->type === RecurrenceType::MonthlyLastWeekday) { + $nth = -1; + } else { + $nth = (int) ceil($startDay / 7); + } + $weekday = $startDate->dayOfWeek(); + + $afterMonth = (int) $after->format('n'); + $afterYear = (int) $after->format('Y'); + + $offset = ($afterMonth - $startMonth) + ($afterYear - $startYear) * 12; + $offset = (int) floor(($offset + $this->interval - 1) / $this->interval) * $this->interval; + + $baseMonths = ($startYear * 12 + $startMonth - 1); + $candidateMonthOffset = $offset - $this->interval; + $countSoFar = (int) ($offset / $this->interval); + + while (true) { + if ($this->count !== null && $countSoFar++ >= $this->count) { + return null; + } + + $candidateMonthOffset += $this->interval; + $totalMonths = $baseMonths + $candidateMonthOffset; + $cYear = (int) floor($totalMonths / 12); + $cMonth = ($totalMonths % 12) + 1; + + $firstOfMonth = new Date(sprintf('%04d-%02d-01', $cYear, $cMonth), $this->start->getTimezone()); + $firstOfMonth = $firstOfMonth->setTime( + (int) $startDate->format('G'), + (int) $startDate->format('i'), + (int) $startDate->format('s') + ); + $next = Date::createFromInterface($firstOfMonth)->withNthWeekday($weekday, $nth); + + if ((int) $next->format('n') !== $cMonth) { + continue; + } + if ($next->compareDateTime($after) < 0) { + continue; + } + if ($this->hasEnd()) { + $endDate = Date::createFromInterface($this->end); + if ($next->compareDateTime($endDate) > 0) { + return null; + } + } + + return $this->toDateTimeImmutable($next); + } + } + + private function nextYearlyDate(Date $after): ?DateTimeImmutable + { + $startDate = Date::createFromInterface($this->start); + $startMonth = (int) $startDate->format('n'); + $startDay = (int) $startDate->format('j'); + $startYear = (int) $startDate->format('Y'); + + $afterMonth = (int) $after->format('n'); + $afterDay = (int) $after->format('j'); + $afterYear = (int) $after->format('Y'); + + if ($afterMonth > $startMonth + || ($afterMonth === $startMonth && $afterDay > $startDay)) { + $afterYear++; + } + + if ($startMonth === 2 && $startDay === 29) { + while (!Utils::isLeapYear($afterYear)) { + $afterYear++; + } + } + + $offset = $afterYear - $startYear; + if ($offset > 0) { + $offset = (int) floor(($offset + $this->interval - 1) / $this->interval) * $this->interval; + } + + if ($this->count !== null && $offset >= $this->count) { + return null; + } + + $candidateYear = $startYear + $offset; + $candidate = $this->buildDate($candidateYear, $startMonth, $startDay); + $candidateDate = Date::createFromInterface($candidate); + + if ($this->hasEnd()) { + $endDate = Date::createFromInterface($this->end); + if ($endDate->compareDateTime($candidateDate) < 0) { + return null; + } + } + + return $candidate; + } + + private function nextYearlyDay(Date $after): ?DateTimeImmutable + { + $startDate = Date::createFromInterface($this->start); + $startYear = (int) $startDate->format('Y'); + $afterYear = (int) $after->format('Y'); + $dayOfYear = $startDate->dayOfYear(); + + $count = ($afterYear - $startYear) / $this->interval + 1; + if ($this->count !== null + && ($count > $this->count + || ($count == $this->count && $after->dayOfYear() > $dayOfYear))) { + return null; + } + + $eYear = $startYear + (int) floor($count - 1) * $this->interval; + $estart = $this->buildDateFromDayOfYear($eYear, $dayOfYear); + $estartDate = Date::createFromInterface($estart); + + if ($estartDate->compareDate($after) < 0) { + $eYear += $this->interval; + $estart = $this->buildDateFromDayOfYear($eYear, $dayOfYear); + $estartDate = Date::createFromInterface($estart); + } + + if ($this->hasEnd()) { + $endDate = Date::createFromInterface($this->end); + if ($endDate->compareDateTime($estartDate) < 0) { + return null; + } + } + + return $estart; + } + + private function nextYearlyWeekday(Date $after): ?DateTimeImmutable + { + $startDate = Date::createFromInterface($this->start); + $startYear = (int) $startDate->format('Y'); + $startMonth = (int) $startDate->format('n'); + $startDay = (int) $startDate->format('j'); + $afterYear = (int) $after->format('Y'); + + $nth = (int) ceil($startDay / 7); + $weekday = $startDate->dayOfWeek(); + + $offset = (int) floor(($afterYear - $startYear + $this->interval - 1) / $this->interval) * $this->interval; + $candidateYear = $startYear + $offset - $this->interval; + $countSoFar = (int) ($offset / $this->interval); + + while (true) { + if ($this->count !== null && $countSoFar++ >= $this->count) { + return null; + } + + $candidateYear += $this->interval; + + $firstOfMonth = new Date( + sprintf('%04d-%02d-01', $candidateYear, $startMonth), + $this->start->getTimezone() + ); + $firstOfMonth = $firstOfMonth->setTime( + (int) $startDate->format('G'), + (int) $startDate->format('i'), + (int) $startDate->format('s') + ); + $next = Date::createFromInterface($firstOfMonth)->withNthWeekday($weekday, $nth); + + if ($next->compareDateTime($after) < 0) { + continue; + } + if ($this->hasEnd()) { + $endDate = Date::createFromInterface($this->end); + if ($next->compareDateTime($endDate) > 0) { + return null; + } + } + + return $this->toDateTimeImmutable($next); + } + } + + // ========================================================================= + // RRULE generation + // ========================================================================= + + public function toRRule20(): string + { + $startDate = Date::createFromInterface($this->start); + + switch ($this->type) { + case RecurrenceType::None: + return ''; + + case RecurrenceType::Daily: + $rrule = 'FREQ=DAILY;INTERVAL=' . $this->interval; + break; + + case RecurrenceType::Weekly: + $rrule = 'FREQ=WEEKLY;INTERVAL=' . $this->interval; + $days = DayMask::toRfc5545Days($this->dayMask); + if ($days !== []) { + $rrule .= ';BYDAY=' . implode(',', $days); + } + break; + + case RecurrenceType::MonthlyDate: + $rrule = 'FREQ=MONTHLY;INTERVAL=' . $this->interval; + break; + + case RecurrenceType::MonthlyWeekday: + case RecurrenceType::MonthlyLastWeekday: + if ($this->type === RecurrenceType::MonthlyLastWeekday) { + $nthWeekday = -1; + } else { + $nthWeekday = (int) ceil((int) $startDate->format('j') / 7); + } + $vcaldays = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']; + $rrule = 'FREQ=MONTHLY;INTERVAL=' . $this->interval + . ';BYDAY=' . $nthWeekday . $vcaldays[$startDate->dayOfWeek()]; + break; + + case RecurrenceType::YearlyDate: + $rrule = 'FREQ=YEARLY;INTERVAL=' . $this->interval; + break; + + case RecurrenceType::YearlyDay: + $rrule = 'FREQ=YEARLY;INTERVAL=' . $this->interval + . ';BYYEARDAY=' . $startDate->dayOfYear(); + break; + + case RecurrenceType::YearlyWeekday: + $nthWeekday = (int) ceil((int) $startDate->format('j') / 7); + $vcaldays = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']; + $rrule = 'FREQ=YEARLY;INTERVAL=' . $this->interval + . ';BYDAY=' . $nthWeekday . $vcaldays[$startDate->dayOfWeek()] + . ';BYMONTH=' . (int) $startDate->format('n'); + break; + + default: + return ''; + } + + if ($this->hasEnd()) { + $endDate = Date::createFromInterface($this->end); + $rrule .= ';UNTIL=' . $endDate->toiCalendar(); + } + if ($this->count !== null && $this->count > 0) { + $rrule .= ';COUNT=' . $this->count; + } + + return $rrule; + } + + public function toRRule10(): string + { + $startDate = Date::createFromInterface($this->start); + + switch ($this->type) { + case RecurrenceType::None: + return ''; + + case RecurrenceType::Daily: + $rrule = 'D' . $this->interval; + break; + + case RecurrenceType::Weekly: + $rrule = 'W' . $this->interval; + $vcaldays = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']; + for ($i = 0; $i <= 7; ++$i) { + if (DayMask::includes($this->dayMask, (int) pow(2, $i))) { + $rrule .= ' ' . $vcaldays[$i]; + } + } + break; + + case RecurrenceType::MonthlyDate: + $rrule = 'MD' . $this->interval . ' ' . trim($startDate->format('j')); + break; + + case RecurrenceType::MonthlyWeekday: + case RecurrenceType::MonthlyLastWeekday: + if ($this->type === RecurrenceType::MonthlyLastWeekday) { + $nthWeekday = '1-'; + } else { + $day = (int) $startDate->format('j'); + $nthWeekday = (int) ($day / 7); + if (($day % 7) > 0) { + $nthWeekday++; + } + $nthWeekday .= '+'; + } + $vcaldays = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']; + $rrule = 'MP' . $this->interval . ' ' . $nthWeekday . ' ' . $vcaldays[$startDate->dayOfWeek()]; + break; + + case RecurrenceType::YearlyDate: + $rrule = 'YM' . $this->interval . ' ' . trim($startDate->format('n')); + break; + + case RecurrenceType::YearlyDay: + $rrule = 'YD' . $this->interval . ' ' . $startDate->dayOfYear(); + break; + + default: + return ''; + } + + if ($this->hasEnd()) { + $endDate = Date::createFromInterface($this->end); + return $rrule . ' ' . $endDate->toiCalendar(); + } + + return $rrule . ' #' . ($this->count ?? 0); + } + + // ========================================================================= + // RRULE parsing + // ========================================================================= + + public function fromRRule20(string $rrule): void + { + $this->reset(); + + $rdata = []; + $parts = explode(';', $rrule); + foreach ($parts as $part) { + $kv = explode('=', $part, 2); + if (count($kv) === 2) { + $rdata[strtoupper($kv[0])] = $kv[1]; + } + } + + if (!isset($rdata['FREQ'])) { + $this->type = RecurrenceType::None; + return; + } + + $this->setInterval((int) ($rdata['INTERVAL'] ?? 1)); + + switch (strtoupper($rdata['FREQ'])) { + case 'DAILY': + $this->type = RecurrenceType::Daily; + if (!isset($rdata['BYDAY'])) { + break; + } + // Thunderbird workaround: DAILY with BYDAY → treat as WEEKLY + // no break + case 'WEEKLY': + $this->type = RecurrenceType::Weekly; + if (isset($rdata['BYDAY'])) { + $days = explode(',', $rdata['BYDAY']); + $this->dayMask = DayMask::fromRfc5545Days($days); + } else { + $this->dayMask = DayMask::fromDayOfWeek( + Date::createFromInterface($this->start)->dayOfWeek() + ); + } + break; + + case 'MONTHLY': + if (isset($rdata['BYDAY'])) { + if (str_contains($rdata['BYDAY'], '-')) { + $this->type = RecurrenceType::MonthlyLastWeekday; + } else { + $this->type = RecurrenceType::MonthlyWeekday; + } + } else { + $this->type = RecurrenceType::MonthlyDate; + } + break; + + case 'YEARLY': + if (isset($rdata['BYYEARDAY'])) { + $this->type = RecurrenceType::YearlyDay; + } elseif (isset($rdata['BYDAY'])) { + $this->type = RecurrenceType::YearlyWeekday; + } else { + $this->type = RecurrenceType::YearlyDate; + } + break; + } + + if (isset($rdata['UNTIL'])) { + if (preg_match('/^(\d{4})-?(\d{2})-?(\d{2})T? ?(\d{2}):?(\d{2}):?(\d{2})(?:\.\d+)?(Z?)$/', $rdata['UNTIL'], $parts)) { + $until = new DateTimeImmutable($rdata['UNTIL'], new DateTimeZone('UTC')); + $until = $until->setTimezone($this->start->getTimezone()); + } else { + [$year, $month, $mday] = sscanf($rdata['UNTIL'], '%04d%02d%02d'); + $until = new DateTimeImmutable( + sprintf('%04d-%02d-%02d', $year, $month, $mday + 1), + $this->start->getTimezone() + ); + } + $this->setEnd($until); + } + if (isset($rdata['COUNT'])) { + $this->setCount((int) $rdata['COUNT']); + } + } + + public function fromRRule10(string $rrule): void + { + $this->reset(); + + if ($rrule === '') { + return; + } + + if (!preg_match('/([A-Z]+)(\d+)?(.*)/', $rrule, $matches)) { + $this->type = RecurrenceType::None; + return; + } + + $this->setInterval(!empty($matches[2]) ? (int) $matches[2] : 1); + $remainder = trim($matches[3]); + + switch ($matches[1]) { + case 'D': + $this->type = RecurrenceType::Daily; + break; + + case 'W': + $this->type = RecurrenceType::Weekly; + $maskdays = [ + 'SU' => DayMask::SUNDAY, 'MO' => DayMask::MONDAY, + 'TU' => DayMask::TUESDAY, 'WE' => DayMask::WEDNESDAY, + 'TH' => DayMask::THURSDAY, 'FR' => DayMask::FRIDAY, + 'SA' => DayMask::SATURDAY, + ]; + $mask = 0; + if (!empty($remainder)) { + while (preg_match('/^ ?(' . implode('|', array_keys($maskdays)) . ') ?/', $remainder, $m)) { + $day = trim($m[0]); + $remainder = substr($remainder, strlen($m[0])); + $mask |= $maskdays[$day]; + } + $this->dayMask = $mask; + } + if ($mask === 0) { + $this->dayMask = DayMask::fromDayOfWeek( + Date::createFromInterface($this->start)->dayOfWeek() + ); + } + break; + + case 'MP': + $this->type = RecurrenceType::MonthlyWeekday; + // Known limitation: trim() above strips the leading space + // before the regex, so "1-" won't be detected as last weekday. + if (preg_match('/^ \d([+-])/', $matches[3], $m) && $m[1] === '-') { + $this->type = RecurrenceType::MonthlyLastWeekday; + } + break; + + case 'MD': + $this->type = RecurrenceType::MonthlyDate; + break; + + case 'YM': + $this->type = RecurrenceType::YearlyDate; + break; + + case 'YD': + $this->type = RecurrenceType::YearlyDay; + break; + } + + while ($remainder !== '' && !preg_match('/^(#\d+|\d{8})($| |T\d{6})/', $remainder)) { + $remainder = substr($remainder, 1); + } + + if ($remainder !== '') { + if (str_starts_with($remainder, '#')) { + $this->setCount((int) substr($remainder, 1)); + } else { + [$year, $month, $mday, $hour, $min, $sec, $tzStr] + = sscanf($remainder, '%04d%02d%02dT%02d%02d%02d%s'); + $tz = ($tzStr === 'Z') ? new DateTimeZone('UTC') : $this->start->getTimezone(); + $this->setEnd(new DateTimeImmutable( + sprintf('%04d-%02d-%02dT%02d:%02d:%02d', $year, $month, $mday, $hour ?? 0, $min ?? 0, $sec ?? 0), + $tz + )); + } + } + } + + // ========================================================================= + // Serialization + // ========================================================================= + + public function toJson(): stdClass + { + $json = new stdClass(); + $json->t = $this->type->value; + $json->i = $this->interval; + if ($this->hasEnd()) { + $json->e = Date::createFromInterface($this->end)->toJson(); + } + if ($this->count !== null && $this->count > 0) { + $json->c = $this->count; + } + if ($this->dayMask !== 0) { + $json->d = $this->dayMask; + } + if ($this->completions !== []) { + $json->co = $this->completions; + } + if ($this->exceptions !== []) { + $json->ex = $this->exceptions; + } + return $json; + } + + public function toHash(): array + { + $startStr = $this->start->format('Y-m-d H:i:s') . '/' . $this->start->getTimezone()->getName(); + $endStr = $this->end !== null + ? $this->end->format('Y-m-d H:i:s') . '/' . $this->end->getTimezone()->getName() + : null; + + return [ + 'start' => $startStr, + 'end' => $endStr, + 'count' => $this->count, + 'type' => $this->type->value, + 'interval' => $this->interval, + 'data' => $this->dayMask ?: null, + 'exceptions' => $this->exceptions, + 'completions' => $this->completions, + ]; + } + + public static function fromHash(array $hash): static + { + $startParts = explode('/', $hash['start'], 2); + $tz = isset($startParts[1]) ? new DateTimeZone($startParts[1]) : null; + $start = new DateTimeImmutable($startParts[0], $tz); + + $recurrence = new static($start); + + if (!empty($hash['end'])) { + $endParts = explode('/', $hash['end'], 2); + $endTz = isset($endParts[1]) ? new DateTimeZone($endParts[1]) : null; + $recurrence->end = new DateTimeImmutable($endParts[0], $endTz); + } + + $recurrence->count = $hash['count']; + $recurrence->type = RecurrenceType::from($hash['type']); + $recurrence->interval = (int) $hash['interval']; + $recurrence->dayMask = (int) ($hash['data'] ?? 0); + $recurrence->exceptions = $hash['exceptions'] ?? []; + $recurrence->completions = $hash['completions'] ?? []; + + return $recurrence; + } + + public function isEqual(self $other): bool + { + return $this->type === $other->type + && $this->interval === $other->interval + && $this->count === $other->count + && $this->end == $other->end + && $this->start == $other->start + && $this->dayMask === $other->dayMask; + } + + public function getRecurName(): string + { + return match ($this->type) { + RecurrenceType::None => Translation::t('No recurrence'), + RecurrenceType::Daily => Translation::t('Daily'), + RecurrenceType::Weekly => Translation::t('Weekly'), + RecurrenceType::MonthlyDate, + RecurrenceType::MonthlyWeekday, + RecurrenceType::MonthlyLastWeekday => Translation::t('Monthly'), + RecurrenceType::YearlyDate, + RecurrenceType::YearlyDay, + RecurrenceType::YearlyWeekday => Translation::t('Yearly'), + }; + } + + // ========================================================================= + // Internal helpers + // ========================================================================= + + private function firstDayOfWeekInTz( + int $week, + int $year, + DateTimeZone $tz, + int $hour, + int $min, + int $sec + ): DateTimeImmutable { + return new DateTimeImmutable( + sprintf('%04dW%02d %02d:%02d:%02d', $year, $week, $hour, $min, $sec), + $tz + ); + } + + private function buildDate(int $year, int $month, int $day): DateTimeImmutable + { + $tz = $this->start->getTimezone(); + return new DateTimeImmutable( + sprintf( + '%04d-%02d-%02dT%s', + $year, + $month, + $day, + $this->start->format('H:i:s') + ), + $tz + ); + } + + private function buildDateFromDayOfYear(int $year, int $dayOfYear): DateTimeImmutable + { + $jan1 = new DateTimeImmutable( + sprintf('%04d-01-01T%s', $year, $this->start->format('H:i:s')), + $this->start->getTimezone() + ); + return $jan1->modify('+' . ($dayOfYear - 1) . ' days'); + } + + private function toDateTimeImmutable(DateTimeInterface $date): DateTimeImmutable + { + if ($date instanceof DateTimeImmutable) { + return $date; + } + return DateTimeImmutable::createFromInterface($date); + } +} diff --git a/src/Recurrence/RecurrenceInterface.php b/src/Recurrence/RecurrenceInterface.php new file mode 100644 index 0000000..4cc0b59 --- /dev/null +++ b/src/Recurrence/RecurrenceInterface.php @@ -0,0 +1,64 @@ + + * @category Horde + * @copyright 2007-2026 The Horde Project + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Date + */ + +namespace Horde\Date\Recurrence; + +use DateTimeImmutable; +use DateTimeInterface; + +interface RecurrenceInterface +{ + public function getType(): RecurrenceType; + + public function getInterval(): int; + + public function getStart(): DateTimeImmutable; + + public function getEnd(): ?DateTimeImmutable; + + public function getCount(): ?int; + + public function getDayMask(): int; + + public function nextRecurrence(DateTimeInterface $after): ?DateTimeImmutable; + + public function nextActiveRecurrence(DateTimeInterface $after): ?DateTimeImmutable; + + public function hasActiveRecurrence(): bool; + + public function addException(DateTimeInterface $date): void; + + public function deleteException(DateTimeInterface $date): void; + + public function hasException(DateTimeInterface $date): bool; + + /** @return list YYYYMMDD strings */ + public function getExceptions(): array; + + public function addCompletion(DateTimeInterface $date): void; + + public function deleteCompletion(DateTimeInterface $date): void; + + public function hasCompletion(DateTimeInterface $date): bool; + + /** @return list YYYYMMDD strings */ + public function getCompletions(): array; + + public function toRRule20(): string; + + public function toRRule10(): string; +} diff --git a/src/Recurrence/RecurrenceType.php b/src/Recurrence/RecurrenceType.php new file mode 100644 index 0000000..a9d2148 --- /dev/null +++ b/src/Recurrence/RecurrenceType.php @@ -0,0 +1,51 @@ + + * @category Horde + * @copyright 2007-2026 The Horde Project + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Date + */ + +namespace Horde\Date\Recurrence; + +enum RecurrenceType: int +{ + case None = 0; + case Daily = 1; + case Weekly = 2; + case MonthlyDate = 3; + case MonthlyWeekday = 4; + case YearlyDate = 5; + case YearlyDay = 6; + case YearlyWeekday = 7; + case MonthlyLastWeekday = 8; + + public function label(): string + { + return match ($this) { + self::None => 'None', + self::Daily => 'Daily', + self::Weekly => 'Weekly', + self::MonthlyDate => 'Monthly (date)', + self::MonthlyWeekday => 'Monthly (weekday)', + self::YearlyDate => 'Yearly (date)', + self::YearlyDay => 'Yearly (day)', + self::YearlyWeekday => 'Yearly (weekday)', + self::MonthlyLastWeekday => 'Monthly (last weekday)', + }; + } + + public static function fromLegacy(int $type): self + { + return self::from($type); + } +} diff --git a/src/TimezoneInfo.php b/src/TimezoneInfo.php new file mode 100644 index 0000000..8f38927 --- /dev/null +++ b/src/TimezoneInfo.php @@ -0,0 +1,54 @@ + + * @category Horde + * @copyright 2026 The Horde Project + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Date + */ + +namespace Horde\Date; + +use DateTimeZone; +use Stringable; + +final class TimezoneInfo implements Stringable +{ + public function __construct( + private readonly string $ianaName, + private readonly string $originalAlias = '', + ) {} + + public function getIanaName(): string + { + return $this->ianaName; + } + + public function getOriginalAlias(): string + { + return $this->originalAlias; + } + + public function isAlias(): bool + { + return $this->originalAlias !== '' && $this->originalAlias !== $this->ianaName; + } + + public function toDateTimeZone(): DateTimeZone + { + return new DateTimeZone($this->ianaName); + } + + public function __toString(): string + { + return $this->ianaName; + } +} diff --git a/src/TimezoneMapper.php b/src/TimezoneMapper.php new file mode 100644 index 0000000..12be374 --- /dev/null +++ b/src/TimezoneMapper.php @@ -0,0 +1,327 @@ + + * @category Horde + * @copyright 2026 The Horde Project + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Date + */ + +namespace Horde\Date; + +use DateTimeZone; + +class TimezoneMapper +{ + private static array $aliases = [ + 'Dateline Standard Time' => 'Etc/GMT+12', + 'UTC-11' => 'Etc/GMT+11', + 'Hawaiian Standard Time' => 'Pacific/Honolulu', + 'Alaskan Standard Time' => 'America/Anchorage', + 'Pacific Standard Time (Mexico)' => 'America/Santa_Isabel', + 'Pacific Standard Time' => 'America/Los_Angeles', + 'US Mountain Standard Time' => 'America/Phoenix', + 'Mountain Standard Time (Mexico)' => 'America/Chihuahua', + 'Mountain Standard Time' => 'America/Denver', + 'Central America Standard Time' => 'America/Guatemala', + 'Central Standard Time' => 'America/Chicago', + 'Central Standard Time (Mexico)' => 'America/Mexico_City', + 'Canada Central Standard Time' => 'America/Regina', + 'SA Pacific Standard Time' => 'America/Bogota', + 'Eastern Standard Time' => 'America/New_York', + 'US Eastern Standard Time' => 'America/Indianapolis', + 'Venezuela Standard Time' => 'America/Caracas', + 'Paraguay Standard Time' => 'America/Asuncion', + 'Atlantic Standard Time' => 'America/Halifax', + 'Central Brazilian Standard Time' => 'America/Cuiaba', + 'SA Western Standard Time' => 'America/La_Paz', + 'Pacific SA Standard Time' => 'America/Santiago', + 'Newfoundland Standard Time' => 'America/St_Johns', + 'E. South America Standard Time' => 'America/Sao_Paulo', + 'Argentina Standard Time' => 'America/Buenos_Aires', + 'SA Eastern Standard Time' => 'America/Cayenne', + 'Greenland Standard Time' => 'America/Nuuk', + 'Montevideo Standard Time' => 'America/Montevideo', + 'Bahia Standard Time' => 'America/Bahia', + 'UTC-02' => 'Etc/GMT+2', + 'Azores Standard Time' => 'Atlantic/Azores', + 'Cape Verde Standard Time' => 'Atlantic/Cape_Verde', + 'Morocco Standard Time' => 'Africa/Casablanca', + 'GMT Standard Time' => 'Europe/London', + 'Greenwich Standard Time' => 'Atlantic/Reykjavik', + 'W. Europe Standard Time' => 'Europe/Berlin', + 'Central Europe Standard Time' => 'Europe/Budapest', + 'Romance Standard Time' => 'Europe/Paris', + 'Central European Standard Time' => 'Europe/Warsaw', + 'W. Central Africa Standard Time' => 'Africa/Lagos', + 'Namibia Standard Time' => 'Africa/Windhoek', + 'Jordan Standard Time' => 'Asia/Amman', + 'GTB Standard Time' => 'Europe/Bucharest', + 'Middle East Standard Time' => 'Asia/Beirut', + 'Egypt Standard Time' => 'Africa/Cairo', + 'Syria Standard Time' => 'Asia/Damascus', + 'E. Europe Standard Time' => 'Asia/Nicosia', + 'South Africa Standard Time' => 'Africa/Johannesburg', + 'FLE Standard Time' => 'Europe/Kyiv', + 'Turkey Standard Time' => 'Europe/Istanbul', + 'Israel Standard Time' => 'Asia/Jerusalem', + 'Arabic Standard Time' => 'Asia/Baghdad', + 'Kaliningrad Standard Time' => 'Europe/Kaliningrad', + 'Arab Standard Time' => 'Asia/Riyadh', + 'E. Africa Standard Time' => 'Africa/Nairobi', + 'Iran Standard Time' => 'Asia/Tehran', + 'Arabian Standard Time' => 'Asia/Dubai', + 'Azerbaijan Standard Time' => 'Asia/Baku', + 'Russian Standard Time' => 'Europe/Moscow', + 'Mauritius Standard Time' => 'Indian/Mauritius', + 'Georgian Standard Time' => 'Asia/Tbilisi', + 'Caucasus Standard Time' => 'Asia/Yerevan', + 'Afghanistan Standard Time' => 'Asia/Kabul', + 'Pakistan Standard Time' => 'Asia/Karachi', + 'West Asia Standard Time' => 'Asia/Tashkent', + 'India Standard Time' => 'Asia/Calcutta', + 'Sri Lanka Standard Time' => 'Asia/Colombo', + 'Nepal Standard Time' => 'Asia/Katmandu', + 'Central Asia Standard Time' => 'Asia/Almaty', + 'Bangladesh Standard Time' => 'Asia/Dhaka', + 'Ekaterinburg Standard Time' => 'Asia/Yekaterinburg', + 'Myanmar Standard Time' => 'Asia/Yangon', + 'SE Asia Standard Time' => 'Asia/Bangkok', + 'N. Central Asia Standard Time' => 'Asia/Novosibirsk', + 'China Standard Time' => 'Asia/Shanghai', + 'North Asia Standard Time' => 'Asia/Krasnoyarsk', + 'Singapore Standard Time' => 'Asia/Singapore', + 'W. Australia Standard Time' => 'Australia/Perth', + 'Taipei Standard Time' => 'Asia/Taipei', + 'Ulaanbaatar Standard Time' => 'Asia/Ulaanbaatar', + 'North Asia East Standard Time' => 'Asia/Irkutsk', + 'Tokyo Standard Time' => 'Asia/Tokyo', + 'Korea Standard Time' => 'Asia/Seoul', + 'Cen. Australia Standard Time' => 'Australia/Adelaide', + 'AUS Central Standard Time' => 'Australia/Darwin', + 'E. Australia Standard Time' => 'Australia/Brisbane', + 'AUS Eastern Standard Time' => 'Australia/Sydney', + 'West Pacific Standard Time' => 'Pacific/Port_Moresby', + 'Tasmania Standard Time' => 'Australia/Hobart', + 'Yakutsk Standard Time' => 'Asia/Yakutsk', + 'Central Pacific Standard Time' => 'Pacific/Guadalcanal', + 'Vladivostok Standard Time' => 'Asia/Vladivostok', + 'New Zealand Standard Time' => 'Pacific/Auckland', + 'UTC+12' => 'Etc/GMT-12', + 'Fiji Standard Time' => 'Pacific/Fiji', + 'Magadan Standard Time' => 'Asia/Magadan', + 'Tonga Standard Time' => 'Pacific/Tongatapu', + 'Samoa Standard Time' => 'Pacific/Apia', + 'CET' => 'Europe/Berlin', + 'CST6CDT' => 'America/Chicago', + 'EET' => 'Europe/Athens', + 'EST' => 'America/Panama', + 'EST5EDT' => 'America/New_York', + 'MET' => 'Europe/Berlin', + 'MST' => 'America/Phoenix', + 'MST7MDT' => 'America/Denver', + 'PST8PDT' => 'America/Los_Angeles', + 'WET' => 'Europe/Lisbon', + 'Antarctica/DumontDUrville' => 'Pacific/Port_Moresby', + 'Antarctica/McMurdo' => 'Pacific/Auckland', + 'Antarctica/Syowa' => 'Asia/Riyadh', + 'Australia/Currie' => 'Australia/Hobart', + 'Pacific/Johnston' => 'Pacific/Honolulu', + 'Pacific/Midway' => 'Pacific/Pago_Pago', + 'W. Europe' => 'Europe/Berlin', + 'E. Europe' => 'Asia/Nicosia', + 'Africa/Asmera' => 'Africa/Nairobi', + 'Africa/Timbuktu' => 'Africa/Abidjan', + 'America/Argentina/ComodRivadavia' => 'America/Argentina/Catamarca', + 'America/Atka' => 'America/Adak', + 'America/Buenos_Aires' => 'America/Argentina/Buenos_Aires', + 'America/Catamarca' => 'America/Argentina/Catamarca', + 'America/Coral_Harbour' => 'America/Atikokan', + 'America/Cordoba' => 'America/Argentina/Cordoba', + 'America/Ensenada' => 'America/Tijuana', + 'America/Fort_Wayne' => 'America/Indiana/Indianapolis', + 'America/Godthab' => 'America/Nuuk', + 'America/Indianapolis' => 'America/Indiana/Indianapolis', + 'America/Jujuy' => 'America/Argentina/Jujuy', + 'America/Knox_IN' => 'America/Indiana/Knox', + 'America/Louisville' => 'America/Kentucky/Louisville', + 'America/Mendoza' => 'America/Argentina/Mendoza', + 'America/Montreal' => 'America/Toronto', + 'America/Porto_Acre' => 'America/Rio_Branco', + 'America/Rosario' => 'America/Argentina/Cordoba', + 'America/Santa_Isabel' => 'America/Tijuana', + 'America/Shiprock' => 'America/Denver', + 'America/Virgin' => 'America/Port_of_Spain', + 'Antarctica/South_Pole' => 'Pacific/Auckland', + 'Asia/Ashkhabad' => 'Asia/Ashgabat', + 'Asia/Calcutta' => 'Asia/Kolkata', + 'Asia/Chongqing' => 'Asia/Shanghai', + 'Asia/Chungking' => 'Asia/Shanghai', + 'Asia/Dacca' => 'Asia/Dhaka', + 'Asia/Harbin' => 'Asia/Shanghai', + 'Asia/Kashgar' => 'Asia/Urumqi', + 'Asia/Katmandu' => 'Asia/Kathmandu', + 'Asia/Macao' => 'Asia/Macau', + 'Asia/Rangoon' => 'Asia/Yangon', + 'Asia/Saigon' => 'Asia/Ho_Chi_Minh', + 'Asia/Tel_Aviv' => 'Asia/Jerusalem', + 'Asia/Thimbu' => 'Asia/Thimphu', + 'Asia/Ujung_Pandang' => 'Asia/Makassar', + 'Asia/Ulan_Bator' => 'Asia/Ulaanbaatar', + 'Atlantic/Faeroe' => 'Atlantic/Faroe', + 'Atlantic/Jan_Mayen' => 'Europe/Oslo', + 'Australia/ACT' => 'Australia/Sydney', + 'Australia/Canberra' => 'Australia/Sydney', + 'Australia/LHI' => 'Australia/Lord_Howe', + 'Australia/NSW' => 'Australia/Sydney', + 'Australia/North' => 'Australia/Darwin', + 'Australia/Queensland' => 'Australia/Brisbane', + 'Australia/South' => 'Australia/Adelaide', + 'Australia/Tasmania' => 'Australia/Hobart', + 'Australia/Victoria' => 'Australia/Melbourne', + 'Australia/West' => 'Australia/Perth', + 'Australia/Yancowinna' => 'Australia/Broken_Hill', + 'Brazil/Acre' => 'America/Rio_Branco', + 'Brazil/DeNoronha' => 'America/Noronha', + 'Brazil/East' => 'America/Sao_Paulo', + 'Brazil/West' => 'America/Manaus', + 'Canada/Atlantic' => 'America/Halifax', + 'Canada/Central' => 'America/Winnipeg', + 'Canada/East-Saskatchewan' => 'America/Regina', + 'Canada/Eastern' => 'America/Toronto', + 'Canada/Mountain' => 'America/Edmonton', + 'Canada/Newfoundland' => 'America/St_Johns', + 'Canada/Pacific' => 'America/Vancouver', + 'Canada/Saskatchewan' => 'America/Regina', + 'Canada/Yukon' => 'America/Whitehorse', + 'Chile/Continental' => 'America/Santiago', + 'Chile/EasterIsland' => 'Pacific/Easter', + 'Cuba' => 'America/Havana', + 'Egypt' => 'Africa/Cairo', + 'Eire' => 'Europe/Dublin', + 'Europe/Belfast' => 'Europe/London', + 'Europe/Kiev' => 'Europe/Kyiv', + 'Europe/Tiraspol' => 'Europe/Chisinau', + 'GB' => 'Europe/London', + 'GB-Eire' => 'Europe/London', + 'GMT+0' => 'Etc/GMT', + 'GMT-0' => 'Etc/GMT', + 'GMT0' => 'Etc/GMT', + 'Greenwich' => 'Etc/GMT', + 'Hongkong' => 'Asia/Hong_Kong', + 'Iceland' => 'Atlantic/Reykjavik', + 'Iran' => 'Asia/Tehran', + 'Israel' => 'Asia/Jerusalem', + 'Jamaica' => 'America/Jamaica', + 'Japan' => 'Asia/Tokyo', + 'Kwajalein' => 'Pacific/Kwajalein', + 'Libya' => 'Africa/Tripoli', + 'Mexico/BajaNorte' => 'America/Tijuana', + 'Mexico/BajaSur' => 'America/Mazatlan', + 'Mexico/General' => 'America/Mexico_City', + 'NZ' => 'Pacific/Auckland', + 'NZ-CHAT' => 'Pacific/Chatham', + 'Navajo' => 'America/Denver', + 'PRC' => 'Asia/Shanghai', + 'Pacific/Ponape' => 'Pacific/Pohnpei', + 'Pacific/Samoa' => 'Pacific/Pago_Pago', + 'Pacific/Truk' => 'Pacific/Chuuk', + 'Pacific/Yap' => 'Pacific/Chuuk', + 'Poland' => 'Europe/Warsaw', + 'Portugal' => 'Europe/Lisbon', + 'ROC' => 'Asia/Taipei', + 'ROK' => 'Asia/Seoul', + 'Singapore' => 'Asia/Singapore', + 'Turkey' => 'Europe/Istanbul', + 'UCT' => 'Etc/UCT', + 'US/Alaska' => 'America/Anchorage', + 'US/Aleutian' => 'America/Adak', + 'US/Arizona' => 'America/Phoenix', + 'US/Central' => 'America/Chicago', + 'US/East-Indiana' => 'America/Indiana/Indianapolis', + 'US/Eastern' => 'America/New_York', + 'US/Hawaii' => 'Pacific/Honolulu', + 'US/Indiana-Starke' => 'America/Indiana/Knox', + 'US/Michigan' => 'America/Detroit', + 'US/Mountain' => 'America/Denver', + 'US/Pacific' => 'America/Los_Angeles', + 'US/Samoa' => 'Pacific/Pago_Pago', + 'UTC' => 'UTC', + 'Universal' => 'UTC', + 'W-SU' => 'Europe/Moscow', + 'Zulu' => 'UTC', + ]; + + /** @var array */ + private static array $runtimeAliases = []; + + /** @var array|null */ + private static ?array $timezoneIdentifiers = null; + + /** @var array>|null */ + private static ?array $timezoneAbbreviations = null; + + public static function resolve(string $timezone): TimezoneInfo + { + self::$timezoneIdentifiers ??= array_flip(DateTimeZone::listIdentifiers()); + + if (isset(self::$timezoneIdentifiers[$timezone])) { + return new TimezoneInfo($timezone); + } + + $allAliases = self::$runtimeAliases + self::$aliases; + if (isset($allAliases[$timezone])) { + return new TimezoneInfo($allAliases[$timezone], $timezone); + } + + self::$timezoneAbbreviations ??= DateTimeZone::listAbbreviations(); + $lower = strtolower($timezone); + if (isset(self::$timezoneAbbreviations[$lower])) { + $first = reset(self::$timezoneAbbreviations[$lower]); + return new TimezoneInfo($first['timezone_id'], $timezone); + } + + return new TimezoneInfo($timezone); + } + + public static function toIana(string $timezone): string + { + return self::resolve($timezone)->getIanaName(); + } + + public static function isAlias(string $timezone): bool + { + return self::resolve($timezone)->isAlias(); + } + + /** + * @param array $aliases Map of alias => IANA name + */ + public static function addAliases(array $aliases): void + { + self::$runtimeAliases = array_merge(self::$runtimeAliases, $aliases); + } + + /** + * @return array Combined built-in and runtime aliases + */ + public static function getAliases(): array + { + return self::$runtimeAliases + self::$aliases; + } + + public static function resetRuntimeAliases(): void + { + self::$runtimeAliases = []; + self::$timezoneIdentifiers = null; + self::$timezoneAbbreviations = null; + } +} diff --git a/src/Translation.php b/src/Translation.php new file mode 100644 index 0000000..e58395d --- /dev/null +++ b/src/Translation.php @@ -0,0 +1,27 @@ + + * @category Horde + * @copyright 2010-2026 The Horde Project + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Date + */ + +namespace Horde\Date; + +use Horde\Translation\Autodetect; + +class Translation extends Autodetect +{ + protected static string $domain = 'Horde_Date'; + + protected static string $pearDirectory = '@data_dir@'; +} diff --git a/src/Utils.php b/src/Utils.php new file mode 100644 index 0000000..2550ec0 --- /dev/null +++ b/src/Utils.php @@ -0,0 +1,151 @@ + + * @category Horde + * @copyright 2004-2026 The Horde Project + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Date + */ + +namespace Horde\Date; + +use DateTime; +use Exception; + +/** + * Date utility methods + * + * Typed, dependency-free replacements for the static helpers formerly + * on Horde_Date_Utils. + * + * @author Ralf Lang + * @category Horde + * @copyright 2026 The Horde Project + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Date + * @since 3.1.0 + */ +class Utils +{ + /** + * @param int $year The year to check + */ + public static function isLeapYear(int $year): bool + { + return ($year % 4 === 0 && $year % 100 !== 0) || $year % 400 === 0; + } + + /** + * Return the Monday of the given ISO week + * + * @param int $week ISO week number (1–53) + * @param int $year The year + */ + public static function firstDayOfWeek(int $week, int $year): Date + { + return new Date(sprintf('%04dW%02d', $year, $week)); + } + + /** + * @param int $month Month number (1–12) + * @param int $year The year + * + * @throws DateException on invalid month/year + */ + public static function daysInMonth(int $month, int $year): int + { + try { + $date = new DateTime( + sprintf($year < 0 ? '%05d-%02d-01' : '%04d-%02d-01', $year, $month) + ); + } catch (Exception $e) { + throw new DateException($e->getMessage(), (int) $e->getCode(), $e); + } + + return (int) $date->format('t'); + } + + /** + * Convert strftime() format string to PHP date() format string + * + * Unsupported formatters are removed. + * + * @param string $format A strftime() formatting string + * @param callable|null $localeInfoProvider Optional callback accepting an + * int constant (T_FMT or D_FMT) + * and returning a date() format + * string. When null, English + * defaults are used for %x/%X. + */ + public static function strftime2date( + string $format, + ?callable $localeInfoProvider = null, + ): string { + $replace = [ + '/%a/' => 'D', + '/%A/' => 'l', + '/%d/' => 'd', + '/%e/' => 'j', + '/%j/' => 'z', + '/%u/' => 'N', + '/%w/' => 'w', + '/%U/' => '', + '/%V/' => 'W', + '/%W/' => '', + '/%b/' => 'M', + '/%B/' => 'F', + '/%h/' => 'M', + '/%m/' => 'm', + '/%C/' => '', + '/%g/' => 'y', + '/%G/' => 'o', + '/%y/' => 'y', + '/%Y/' => 'Y', + '/%H/' => 'H', + '/%I/' => 'h', + '/%i/' => 'g', + '/%M/' => 'i', + '/%p/' => 'A', + '/%P/' => 'a', + '/%r/' => 'h:i:s A', + '/%R/' => 'H:i', + '/%S/' => 's', + '/%T/' => 'H:i:s', + '/%z/' => 'O', + '/%Z/' => '', + '/%c/' => '', + '/%D/' => 'm/d/y', + '/%F/' => 'Y-m-d', + '/%s/' => 'U', + '/%n/' => "\n", + '/%t/' => "\t", + '/%%/' => '%', + ]; + + $callbackPatterns = [ + '/%X/' => function () use ($localeInfoProvider): string { + if ($localeInfoProvider !== null) { + return $localeInfoProvider(T_FMT); + } + return 'H:i:s'; + }, + '/%x/' => function () use ($localeInfoProvider): string { + if ($localeInfoProvider !== null) { + return $localeInfoProvider(D_FMT); + } + return 'm/d/Y'; + }, + ]; + + $pass1 = preg_replace_callback_array($callbackPatterns, $format); + return preg_replace(array_keys($replace), array_values($replace), $pass1); + } +} diff --git a/test/Unit/DateTest.php b/test/Unit/DateTest.php new file mode 100644 index 0000000..bd4179f --- /dev/null +++ b/test/Unit/DateTest.php @@ -0,0 +1,684 @@ +assertSame('2026-04-17 14:30:00', $d->format('Y-m-d H:i:s')); + } + + public function testCreateFromInterface(): void + { + $dt = new DateTime('2026-04-17 14:30:00', new DateTimeZone('UTC')); + $d = Date::createFromInterface($dt); + $this->assertInstanceOf(Date::class, $d); + $this->assertSame('2026-04-17 14:30:00', $d->format('Y-m-d H:i:s')); + + $dti = new DateTimeImmutable('2026-04-17 14:30:00', new DateTimeZone('UTC')); + $d2 = Date::createFromInterface($dti); + $this->assertInstanceOf(Date::class, $d2); + $this->assertSame('2026-04-17 14:30:00', $d2->format('Y-m-d H:i:s')); + } + + public function testCreateFromInterfacePreservesTimezone(): void + { + $dt = new DateTime('2026-04-17 14:30:00', new DateTimeZone('America/New_York')); + $d = Date::createFromInterface($dt); + $this->assertSame('America/New_York', $d->getTimezone()->getName()); + } + + public function testCreateFromTimestamp(): void + { + $ts = 1776700200; + $d = Date::createFromTimestamp($ts); + $this->assertInstanceOf(Date::class, $d); + $this->assertSame($ts, $d->getTimestamp()); + } + + public function testImplementsInterfaces(): void + { + $d = new Date('2026-04-17'); + $this->assertInstanceOf(DateInterface::class, $d); + $this->assertInstanceOf(DateTimeInterface::class, $d); + $this->assertInstanceOf(DateTimeImmutable::class, $d); + } + + // ========================================================================= + // toDays() / fromDays() + // ========================================================================= + + #[DataProvider('toDaysProvider')] + public function testToDaysMatchesGregorianToJd(int $year, int $month, int $day): void + { + $d = new Date(sprintf('%04d-%02d-%02d', $year, $month, $day)); + $jd = $d->toDays(); + + if (function_exists('gregoriantojd')) { + $this->assertSame(gregoriantojd($month, $day, $year), $jd); + } + $this->assertIsInt($jd); + $this->assertGreaterThan(0, $jd); + } + + public static function toDaysProvider(): array + { + return [ + 'epoch' => [1970, 1, 1], + 'unix max 32-bit' => [2038, 1, 19], + 'y2k' => [2000, 1, 1], + 'leap day 2024' => [2024, 2, 29], + 'leap day 2000' => [2000, 2, 29], + 'non-leap 1900' => [1900, 2, 28], + 'century boundary' => [1900, 1, 1], + 'medieval' => [800, 6, 15], + 'modern' => [2026, 4, 17], + 'end of year' => [2025, 12, 31], + 'start of year' => [2026, 1, 1], + ]; + } + + #[DataProvider('toDaysProvider')] + public function testFromDaysRoundTrip(int $year, int $month, int $day): void + { + $original = new Date(sprintf('%04d-%02d-%02d', $year, $month, $day)); + $jd = $original->toDays(); + $restored = Date::fromDays($jd); + + $this->assertSame($year, (int) $restored->format('Y')); + $this->assertSame($month, (int) $restored->format('n')); + $this->assertSame($day, (int) $restored->format('j')); + } + + public function testToDaysConsecutiveDays(): void + { + $d1 = new Date('2026-03-15'); + $d2 = new Date('2026-03-16'); + $this->assertSame($d1->toDays() + 1, $d2->toDays()); + } + + public function testToDaysLeapYearBoundary(): void + { + $feb28 = new Date('2024-02-28'); + $feb29 = new Date('2024-02-29'); + $mar01 = new Date('2024-03-01'); + + $this->assertSame($feb28->toDays() + 1, $feb29->toDays()); + $this->assertSame($feb29->toDays() + 1, $mar01->toDays()); + } + + public function testToDaysNonLeapBoundary(): void + { + $feb28 = new Date('2025-02-28'); + $mar01 = new Date('2025-03-01'); + $this->assertSame($feb28->toDays() + 1, $mar01->toDays()); + } + + public function testToDaysMatchesLegacy(): void + { + $dates = ['2026-04-17', '2024-02-29', '2000-01-01', '1970-01-01']; + foreach ($dates as $dateStr) { + $modern = new Date($dateStr); + $legacy = new Horde_Date($dateStr); + $this->assertSame($legacy->toDays(), $modern->toDays(), "toDays mismatch for $dateStr"); + } + } + + // ========================================================================= + // dayOfWeek() + // ========================================================================= + + #[DataProvider('dayOfWeekProvider')] + public function testDayOfWeekMatchesDateTime(string $dateStr): void + { + $d = new Date($dateStr); + $native = new DateTime($dateStr, new DateTimeZone('UTC')); + + $this->assertSame( + (int) $native->format('w'), + $d->dayOfWeek(), + "dayOfWeek mismatch for $dateStr" + ); + } + + public static function dayOfWeekProvider(): array + { + return [ + 'sunday' => ['2026-04-12'], + 'monday' => ['2026-04-13'], + 'tuesday' => ['2026-04-14'], + 'wednesday' => ['2026-04-15'], + 'thursday' => ['2026-04-16'], + 'friday' => ['2026-04-17'], + 'saturday' => ['2026-04-18'], + 'epoch' => ['1970-01-01'], + 'y2k' => ['2000-01-01'], + 'leap day 2024' => ['2024-02-29'], + 'century boundary' => ['1900-01-01'], + 'far future' => ['2099-12-31'], + 'first day 2026' => ['2026-01-01'], + 'last day feb 2025' => ['2025-02-28'], + 'mar 1 2025' => ['2025-03-01'], + ]; + } + + public function testDayOfWeekConstants(): void + { + $this->assertSame(Date::SUNDAY, (new Date('2026-04-12'))->dayOfWeek()); + $this->assertSame(Date::MONDAY, (new Date('2026-04-13'))->dayOfWeek()); + $this->assertSame(Date::TUESDAY, (new Date('2026-04-14'))->dayOfWeek()); + $this->assertSame(Date::WEDNESDAY, (new Date('2026-04-15'))->dayOfWeek()); + $this->assertSame(Date::THURSDAY, (new Date('2026-04-16'))->dayOfWeek()); + $this->assertSame(Date::FRIDAY, (new Date('2026-04-17'))->dayOfWeek()); + $this->assertSame(Date::SATURDAY, (new Date('2026-04-18'))->dayOfWeek()); + } + + public function testDayOfWeekMatchesLegacy(): void + { + $dates = ['2026-04-17', '2024-02-29', '2000-01-01', '1970-01-01', '1900-01-01']; + foreach ($dates as $dateStr) { + $modern = new Date($dateStr); + $legacy = new Horde_Date($dateStr); + $this->assertSame( + $legacy->dayOfWeek(), + $modern->dayOfWeek(), + "dayOfWeek mismatch for $dateStr" + ); + } + } + + // ========================================================================= + // dayOfYear() + // ========================================================================= + + public function testDayOfYearJan1(): void + { + $this->assertSame(1, (new Date('2026-01-01'))->dayOfYear()); + } + + public function testDayOfYearDec31NonLeap(): void + { + $this->assertSame(365, (new Date('2025-12-31'))->dayOfYear()); + } + + public function testDayOfYearDec31Leap(): void + { + $this->assertSame(366, (new Date('2024-12-31'))->dayOfYear()); + } + + public function testDayOfYearMar1LeapYear(): void + { + $this->assertSame(61, (new Date('2024-03-01'))->dayOfYear()); + } + + public function testDayOfYearMar1NonLeapYear(): void + { + $this->assertSame(60, (new Date('2025-03-01'))->dayOfYear()); + } + + // ========================================================================= + // weekOfMonth() + // ========================================================================= + + #[DataProvider('weekOfMonthProvider')] + public function testWeekOfMonth(int $day, int $expectedWeek): void + { + $d = new Date(sprintf('2026-04-%02d', $day)); + $this->assertSame($expectedWeek, $d->weekOfMonth()); + } + + public static function weekOfMonthProvider(): array + { + return [ + 'day 1' => [1, 1], + 'day 6' => [6, 1], + 'day 7' => [7, 1], + 'day 8' => [8, 2], + 'day 14' => [14, 2], + 'day 15' => [15, 3], + 'day 21' => [21, 3], + 'day 22' => [22, 4], + 'day 28' => [28, 4], + 'day 29' => [29, 5], + 'day 30' => [30, 5], + ]; + } + + public function testWeekOfMonthDay31(): void + { + $d = new Date('2026-01-31'); + $this->assertSame(5, $d->weekOfMonth()); + } + + // ========================================================================= + // weekOfYear() + // ========================================================================= + + #[DataProvider('weekOfYearProvider')] + public function testWeekOfYear(string $dateStr, int $expectedWeek): void + { + $d = new Date($dateStr); + $this->assertSame($expectedWeek, $d->weekOfYear()); + } + + public static function weekOfYearProvider(): array + { + return [ + '2026-01-01 is W01' => ['2026-01-01', 1], + '2026-01-05 is W02' => ['2026-01-05', 2], + '2025-12-29 is W01 of 2026' => ['2025-12-29', 1], + '2025-12-28 is W52' => ['2025-12-28', 52], + '2024-12-30 is W01 of 2025' => ['2024-12-30', 1], + ]; + } + + // ========================================================================= + // weeksInYear() + // ========================================================================= + + #[DataProvider('weeksInYearProvider')] + public function testWeeksInYear(int $year, int $expectedWeeks): void + { + $this->assertSame($expectedWeeks, Date::weeksInYear($year)); + } + + public static function weeksInYearProvider(): array + { + return [ + '2020 has 53 weeks' => [2020, 53], + '2021 has 52 weeks' => [2021, 52], + '2022 has 52 weeks' => [2022, 52], + '2023 has 52 weeks' => [2023, 52], + '2024 has 52 weeks' => [2024, 52], + '2025 has 52 weeks' => [2025, 52], + '2026 has 53 weeks' => [2026, 53], + '2015 has 53 weeks' => [2015, 53], + '2004 has 53 weeks' => [2004, 53], + '2000 has 52 weeks' => [2000, 52], + '1998 has 53 weeks' => [1998, 53], + '1970 has 53 weeks' => [1970, 53], + ]; + } + + // ========================================================================= + // withNthWeekday() + // ========================================================================= + + #[DataProvider('withNthWeekdayPositiveProvider')] + public function testWithNthWeekdayPositive( + int $year, + int $month, + int $weekday, + int $nth, + string $expectedDate, + ): void { + $d = new Date(sprintf('%04d-%02d-01', $year, $month)); + $result = $d->withNthWeekday($weekday, $nth); + $this->assertSame($expectedDate, $result->format('Y-m-d')); + } + + public static function withNthWeekdayPositiveProvider(): array + { + return [ + '1st Sunday Apr 2026' => [2026, 4, Date::SUNDAY, 1, '2026-04-05'], + '1st Monday Apr 2026' => [2026, 4, Date::MONDAY, 1, '2026-04-06'], + '1st Friday Apr 2026' => [2026, 4, Date::FRIDAY, 1, '2026-04-03'], + '2nd Monday Apr 2026' => [2026, 4, Date::MONDAY, 2, '2026-04-13'], + '3rd Wednesday Apr 2026' => [2026, 4, Date::WEDNESDAY, 3, '2026-04-15'], + '4th Thursday Apr 2026' => [2026, 4, Date::THURSDAY, 4, '2026-04-23'], + '1st Monday Jan 2026' => [2026, 1, Date::MONDAY, 1, '2026-01-05'], + '1st Saturday Jan 2026' => [2026, 1, Date::SATURDAY, 1, '2026-01-03'], + '2nd Saturday Jan 2026' => [2026, 1, Date::SATURDAY, 2, '2026-01-10'], + '1st Monday Feb 2024' => [2024, 2, Date::MONDAY, 1, '2024-02-05'], + '5th Saturday Mar 2026' => [2026, 3, Date::SATURDAY, 5, '2026-04-04'], + ]; + } + + #[DataProvider('withNthWeekdayNegativeProvider')] + public function testWithNthWeekdayNegative( + int $year, + int $month, + int $weekday, + int $nth, + string $expectedDate, + ): void { + $d = new Date(sprintf('%04d-%02d-01', $year, $month)); + $result = $d->withNthWeekday($weekday, $nth); + $this->assertSame($expectedDate, $result->format('Y-m-d')); + } + + public static function withNthWeekdayNegativeProvider(): array + { + return [ + 'last Friday Apr 2026' => [2026, 4, Date::FRIDAY, -1, '2026-04-24'], + 'last Monday Apr 2026' => [2026, 4, Date::MONDAY, -1, '2026-04-27'], + 'last Sunday Apr 2026' => [2026, 4, Date::SUNDAY, -1, '2026-04-26'], + 'last Saturday Apr 2026' => [2026, 4, Date::SATURDAY, -1, '2026-04-25'], + 'last Friday Feb 2024' => [2024, 2, Date::FRIDAY, -1, '2024-02-23'], + 'last Thursday Feb 2025' => [2025, 2, Date::THURSDAY, -1, '2025-02-27'], + '2nd-last Monday Apr 2026' => [2026, 4, Date::MONDAY, -2, '2026-04-20'], + 'last Wednesday Dec 2025' => [2025, 12, Date::WEDNESDAY, -1, '2025-12-31'], + ]; + } + + public function testWithNthWeekdayInvalid(): void + { + $d = new Date('2026-04-17'); + $this->assertSame($d, $d->withNthWeekday(7, 1)); + $this->assertSame($d, $d->withNthWeekday(-1, 1)); + } + + public function testWithNthWeekdayFeb29LeapYear(): void + { + $d = new Date('2024-02-01'); + $result = $d->withNthWeekday(Date::THURSDAY, 5); + $this->assertSame('02', $result->format('m')); + $this->assertSame('29', $result->format('d')); + } + + public function testWithNthWeekdayIsImmutable(): void + { + $d = new Date('2026-04-17'); + $result = $d->withNthWeekday(Date::MONDAY, 1); + $this->assertSame('2026-04-17', $d->format('Y-m-d')); + $this->assertSame('2026-04-06', $result->format('Y-m-d')); + } + + // ========================================================================= + // diffDays() + // ========================================================================= + + public function testDiffDaysSameDay(): void + { + $d = new Date('2026-04-17 14:00:00'); + $this->assertSame(0, $d->diffDays(new Date('2026-04-17 23:59:59'))); + } + + public function testDiffDaysAdjacent(): void + { + $a = new Date('2026-04-17'); + $b = new Date('2026-04-18'); + $this->assertSame(1, $a->diffDays($b)); + } + + public function testDiffDaysAcrossYear(): void + { + $a = new Date('2025-12-31'); + $b = new Date('2026-01-01'); + $this->assertSame(1, $a->diffDays($b)); + } + + public function testDiffDaysLeapYear(): void + { + $a = new Date('2024-02-28'); + $b = new Date('2024-03-01'); + $this->assertSame(2, $a->diffDays($b)); + } + + public function testDiffDaysSymmetric(): void + { + $a = new Date('2026-01-01'); + $b = new Date('2026-04-17'); + $this->assertSame($a->diffDays($b), $b->diffDays($a)); + } + + // ========================================================================= + // Comparison + // ========================================================================= + + public function testCompareDateEqual(): void + { + $a = new Date('2026-04-17 10:00:00'); + $b = new Date('2026-04-17 23:59:59'); + $this->assertSame(0, $a->compareDate($b)); + } + + public function testCompareDateBefore(): void + { + $a = new Date('2026-04-16'); + $b = new Date('2026-04-17'); + $this->assertLessThan(0, $a->compareDate($b)); + } + + public function testCompareDateAfter(): void + { + $a = new Date('2026-04-18'); + $b = new Date('2026-04-17'); + $this->assertGreaterThan(0, $a->compareDate($b)); + } + + public function testCompareTimeEqual(): void + { + $a = new Date('2026-04-17 14:30:00'); + $b = new Date('2025-01-01 14:30:00'); + $this->assertSame(0, $a->compareTime($b)); + } + + public function testCompareTimeBefore(): void + { + $a = new Date('2026-04-17 08:00:00'); + $b = new Date('2026-04-17 14:00:00'); + $this->assertLessThan(0, $a->compareTime($b)); + } + + public function testCompareDateTime(): void + { + $a = new Date('2026-04-17 14:30:00'); + $b = new Date('2026-04-17 14:30:00'); + $this->assertSame(0, $a->compareDateTime($b)); + + $c = new Date('2026-04-17 14:30:01'); + $this->assertLessThan(0, $a->compareDateTime($c)); + $this->assertGreaterThan(0, $c->compareDateTime($a)); + } + + public function testBeforeAfterEquals(): void + { + $a = new Date('2026-04-16 12:00:00'); + $b = new Date('2026-04-17 12:00:00'); + $c = new Date('2026-04-17 12:00:00'); + + $this->assertTrue($a->before($b)); + $this->assertFalse($a->after($b)); + $this->assertFalse($a->equals($b)); + + $this->assertTrue($b->after($a)); + $this->assertTrue($b->equals($c)); + } + + public function testCompareWithNativeDatetime(): void + { + $d = new Date('2026-04-17 14:30:00', new DateTimeZone('UTC')); + $native = new DateTimeImmutable('2026-04-17 14:30:00', new DateTimeZone('UTC')); + $this->assertSame(0, $d->compareDateTime($native)); + } + + // ========================================================================= + // addParts() / subParts() + // ========================================================================= + + public function testAddPartsMonthOnly(): void + { + $d = new Date('2026-01-15 12:00:00'); + $result = $d->addParts(months: 1); + $this->assertSame('2026-02-15', $result->format('Y-m-d')); + } + + public function testAddPartsMultiple(): void + { + $d = new Date('2026-04-17 10:00:00'); + $result = $d->addParts(years: 1, months: 2, days: 5); + $this->assertSame('2027-06-22', $result->format('Y-m-d')); + } + + public function testAddPartsMonthOverflow(): void + { + $d = new Date('2026-01-31 12:00:00'); + $result = $d->addParts(months: 1); + $this->assertSame('2026-03-03', $result->format('Y-m-d')); + } + + public function testAddPartsDayOverflow(): void + { + $d = new Date('2026-04-28 12:00:00'); + $result = $d->addParts(days: 5); + $this->assertSame('2026-05-03', $result->format('Y-m-d')); + } + + public function testAddPartsYearBoundary(): void + { + $d = new Date('2026-11-15 12:00:00'); + $result = $d->addParts(months: 2); + $this->assertSame('2027-01-15', $result->format('Y-m-d')); + } + + public function testAddPartsLeapYear(): void + { + $d = new Date('2024-02-29 12:00:00'); + $result = $d->addParts(years: 1); + $this->assertSame('2025-03-01', $result->format('Y-m-d')); + } + + public function testAddPartsSeconds(): void + { + $d = new Date('2026-04-17 23:59:30'); + $result = $d->addParts(seconds: 60); + $this->assertSame('2026-04-18 00:00:30', $result->format('Y-m-d H:i:s')); + } + + public function testSubParts(): void + { + $d = new Date('2026-04-17 12:00:00'); + $result = $d->subParts(months: 1, days: 5); + $this->assertSame('2026-03-12', $result->format('Y-m-d')); + } + + public function testAddPartsIsImmutable(): void + { + $d = new Date('2026-04-17 12:00:00'); + $result = $d->addParts(months: 1); + $this->assertSame('2026-04-17', $d->format('Y-m-d')); + $this->assertSame('2026-05-17', $result->format('Y-m-d')); + } + + #[DataProvider('cascadeProvider')] + public function testAddPartsCascade(string $input, int $addSeconds, string $expected): void + { + $d = new Date($input); + $result = $d->addParts(seconds: $addSeconds); + $this->assertSame($expected, $result->format('Y-m-d H:i:s')); + } + + public static function cascadeProvider(): array + { + return [ + 'no cascade' => ['2026-04-17 10:30:00', 15, '2026-04-17 10:30:15'], + 'sec to min' => ['2026-04-17 10:30:45', 30, '2026-04-17 10:31:15'], + 'sec to hour' => ['2026-04-17 10:59:45', 30, '2026-04-17 11:00:15'], + 'sec to day' => ['2026-04-17 23:59:45', 30, '2026-04-18 00:00:15'], + 'year boundary forward' => ['2026-12-31 23:59:30', 60, '2027-01-01 00:00:30'], + 'year boundary backward' => ['2027-01-01 00:00:00', -1, '2026-12-31 23:59:59'], + 'one full day' => ['2026-04-17 12:00:00', 86400, '2026-04-18 12:00:00'], + 'negative full day' => ['2026-04-17 12:00:00', -86400, '2026-04-16 12:00:00'], + ]; + } + + // ========================================================================= + // format() with pluggable formatters + // ========================================================================= + + public function testFormatSingleArg(): void + { + $d = new Date('2026-04-17 14:30:00', new DateTimeZone('UTC')); + $this->assertSame('2026-04-17', $d->format('Y-m-d')); + } + + public function testFormatWithFormatter(): void + { + $d = new Date('2026-04-17 14:30:00', new DateTimeZone('UTC')); + $result = $d->format('Y-m-d', new DateTimeFormatter()); + $this->assertSame('2026-04-17', $result); + } + + public function testFormatWithIcuFormatter(): void + { + $d = new Date('2026-04-17 14:30:00', new DateTimeZone('UTC')); + $result = $d->format('yyyy-MM-dd', new IcuFormatter(), 'en_US'); + $this->assertSame('2026-04-17', $result); + } + + public function testFormatWithLocale(): void + { + $d = new Date('2026-04-17 14:30:00', new DateTimeZone('UTC')); + $result = $d->format('EEEE', new IcuFormatter(), 'de_DE'); + $this->assertSame('Freitag', $result); + } + + // ========================================================================= + // Serialization + // ========================================================================= + + public function testToJson(): void + { + $d = new Date('2026-04-17 14:30:00'); + $this->assertSame('2026-04-17T14:30:00', $d->toJson()); + } + + public function testToiCalendarFloating(): void + { + $d = new Date('2026-04-17 14:30:00'); + $this->assertSame('20260417T143000', $d->toiCalendar(true)); + } + + public function testToiCalendarUtc(): void + { + $d = new Date('2026-04-17 14:30:00', new DateTimeZone('UTC')); + $this->assertSame('20260417T143000Z', $d->toiCalendar()); + } + + public function testTimestamp(): void + { + $d = new Date('2026-04-17 14:30:00', new DateTimeZone('UTC')); + $this->assertSame($d->getTimestamp(), $d->timestamp()); + } + + // ========================================================================= + // DateInterface compliance + // ========================================================================= + + public function testToDateTimeImmutable(): void + { + $d = new Date('2026-04-17 14:30:00'); + $this->assertSame($d, $d->toDateTimeImmutable()); + } + + public function testGetTimezone(): void + { + $d = new Date('2026-04-17 14:30:00', new DateTimeZone('America/Chicago')); + $tz = $d->getTimezone(); + $this->assertInstanceOf(DateTimeZone::class, $tz); + $this->assertSame('America/Chicago', $tz->getName()); + } +} diff --git a/test/Unit/DateTimeFormatterTest.php b/test/Unit/DateTimeFormatterTest.php index 7a5ff25..bc64754 100644 --- a/test/Unit/DateTimeFormatterTest.php +++ b/test/Unit/DateTimeFormatterTest.php @@ -19,6 +19,7 @@ use Horde\Date\Formatter\DateTimeFormatter; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use RuntimeException; /** * Tests for DateTimeFormatter @@ -193,7 +194,7 @@ public function testUnixTimestampFormat(): void $timestamp = 1742565045; $result = $formatter->format($timestamp, 'U'); - $this->assertEquals((string)$timestamp, $result); + $this->assertEquals((string) $timestamp, $result); } /** @@ -269,9 +270,9 @@ public function testParseIsoFormat(): void $date = $formatter->parse('2026-03-18', 'Y-m-d', 'en_US'); - $this->assertEquals(2026, $date->year); - $this->assertEquals(3, $date->month); - $this->assertEquals(18, $date->mday); + $this->assertSame('2026', $date->format('Y')); + $this->assertSame('03', $date->format('m')); + $this->assertSame('18', $date->format('d')); } /** @@ -283,12 +284,12 @@ public function testParseWithTime(): void $date = $formatter->parse('2026-03-18 14:30:45', 'Y-m-d H:i:s', 'en_US'); - $this->assertEquals(2026, $date->year); - $this->assertEquals(3, $date->month); - $this->assertEquals(18, $date->mday); - $this->assertEquals(14, $date->hour); - $this->assertEquals(30, $date->min); - $this->assertEquals(45, $date->sec); + $this->assertSame('2026', $date->format('Y')); + $this->assertSame('03', $date->format('m')); + $this->assertSame('18', $date->format('d')); + $this->assertSame('14', $date->format('H')); + $this->assertSame('30', $date->format('i')); + $this->assertSame('45', $date->format('s')); } /** @@ -301,9 +302,9 @@ public function testParseDifferentFormat(): void // US format: m/d/Y $date = $formatter->parse('03/18/2026', 'm/d/Y', 'en_US'); - $this->assertEquals(2026, $date->year); - $this->assertEquals(3, $date->month); - $this->assertEquals(18, $date->mday); + $this->assertSame('2026', $date->format('Y')); + $this->assertSame('03', $date->format('m')); + $this->assertSame('18', $date->format('d')); } /** @@ -315,11 +316,11 @@ public function testParseWithTimezone(): void $date = $formatter->parse('2026-03-18 14:30', 'Y-m-d H:i', 'en_US', 'America/New_York'); - $this->assertEquals(2026, $date->year); - $this->assertEquals(3, $date->month); - $this->assertEquals(18, $date->mday); - $this->assertEquals(14, $date->hour); - $this->assertEquals(30, $date->min); + $this->assertSame('2026', $date->format('Y')); + $this->assertSame('03', $date->format('m')); + $this->assertSame('18', $date->format('d')); + $this->assertSame('14', $date->format('H')); + $this->assertSame('30', $date->format('i')); } /** @@ -338,12 +339,7 @@ public function testParseRoundTrip(): void $parsed = $formatter->parse($formatted, 'Y-m-d H:i:s'); // Verify round-trip - $this->assertEquals(2026, $parsed->year); - $this->assertEquals(3, $parsed->month); - $this->assertEquals(18, $parsed->mday); - $this->assertEquals(14, $parsed->hour); - $this->assertEquals(30, $parsed->min); - $this->assertEquals(0, $parsed->sec); + $this->assertSame('2026-03-18 14:30:00', $parsed->format('Y-m-d H:i:s')); } /** @@ -355,11 +351,11 @@ public function testParseTwelveHourFormat(): void $date = $formatter->parse('2026-03-18 2:30 PM', 'Y-m-d g:i A', 'en_US'); - $this->assertEquals(2026, $date->year); - $this->assertEquals(3, $date->month); - $this->assertEquals(18, $date->mday); - $this->assertEquals(14, $date->hour); // 2 PM = 14:00 - $this->assertEquals(30, $date->min); + $this->assertSame('2026', $date->format('Y')); + $this->assertSame('03', $date->format('m')); + $this->assertSame('18', $date->format('d')); + $this->assertSame('14', $date->format('H')); // 2 PM = 14:00 + $this->assertSame('30', $date->format('i')); } /** @@ -367,7 +363,7 @@ public function testParseTwelveHourFormat(): void */ public function testParseInvalidString(): void { - $this->expectException(\RuntimeException::class); + $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Failed to parse date string'); $formatter = new DateTimeFormatter(); @@ -379,7 +375,7 @@ public function testParseInvalidString(): void */ public function testParseMismatchedPattern(): void { - $this->expectException(\RuntimeException::class); + $this->expectException(RuntimeException::class); $formatter = new DateTimeFormatter(); // Try to parse ISO format with US m/d/Y pattern @@ -402,10 +398,10 @@ public function testParseComplexFormatRoundTrip(): void // Parse back $parsed = $formatter->parse($formatted, 'l, F j, Y g:i A'); - $this->assertEquals(2026, $parsed->year); - $this->assertEquals(3, $parsed->month); - $this->assertEquals(18, $parsed->mday); - $this->assertEquals(14, $parsed->hour); - $this->assertEquals(30, $parsed->min); + $this->assertSame('2026', $parsed->format('Y')); + $this->assertSame('03', $parsed->format('m')); + $this->assertSame('18', $parsed->format('d')); + $this->assertSame('14', $parsed->format('H')); + $this->assertSame('30', $parsed->format('i')); } } diff --git a/test/Unit/FormatterParseReturnTest.php b/test/Unit/FormatterParseReturnTest.php new file mode 100644 index 0000000..d9ba9cc --- /dev/null +++ b/test/Unit/FormatterParseReturnTest.php @@ -0,0 +1,94 @@ +oldTimezone = date_default_timezone_get(); + date_default_timezone_set('UTC'); + } + + protected function tearDown(): void + { + date_default_timezone_set($this->oldTimezone); + } + + public function testDateTimeFormatterParseReturnsDate(): void + { + $formatter = new DateTimeFormatter(); + $result = $formatter->parse('2026-03-18', 'Y-m-d', 'en_US'); + + $this->assertInstanceOf(Date::class, $result); + } + + public function testIcuFormatterParseReturnsDate(): void + { + $formatter = new IcuFormatter(); + $result = $formatter->parse('2026-03-18', 'yyyy-MM-dd', 'en_US'); + + $this->assertInstanceOf(Date::class, $result); + } + + public function testParsedDateImplementsDateInterface(): void + { + $dtFormatter = new DateTimeFormatter(); + $dtResult = $dtFormatter->parse('2026-03-18', 'Y-m-d', 'en_US'); + + $icuFormatter = new IcuFormatter(); + $icuResult = $icuFormatter->parse('2026-03-18', 'yyyy-MM-dd', 'en_US'); + + $this->assertInstanceOf(DateInterface::class, $dtResult); + $this->assertInstanceOf(DateInterface::class, $icuResult); + } + + public function testParsedDateIsDateTimeImmutable(): void + { + $dtFormatter = new DateTimeFormatter(); + $dtResult = $dtFormatter->parse('2026-03-18', 'Y-m-d', 'en_US'); + + $icuFormatter = new IcuFormatter(); + $icuResult = $icuFormatter->parse('2026-03-18', 'yyyy-MM-dd', 'en_US'); + + $this->assertInstanceOf(DateTimeImmutable::class, $dtResult); + $this->assertInstanceOf(DateTimeImmutable::class, $icuResult); + } + + public function testParsedDatePreservesValues(): void + { + $dtFormatter = new DateTimeFormatter(); + $dtResult = $dtFormatter->parse('2026-03-18 14:30:45', 'Y-m-d H:i:s', 'en_US'); + + $this->assertSame('2026', $dtResult->format('Y')); + $this->assertSame('03', $dtResult->format('m')); + $this->assertSame('18', $dtResult->format('d')); + $this->assertSame('14', $dtResult->format('H')); + $this->assertSame('30', $dtResult->format('i')); + $this->assertSame('45', $dtResult->format('s')); + + $icuFormatter = new IcuFormatter(); + $icuResult = $icuFormatter->parse('2026-03-18 14:30:45', 'yyyy-MM-dd HH:mm:ss', 'en_US'); + + $this->assertSame('2026', $icuResult->format('Y')); + $this->assertSame('03', $icuResult->format('m')); + $this->assertSame('18', $icuResult->format('d')); + $this->assertSame('14', $icuResult->format('H')); + $this->assertSame('30', $icuResult->format('i')); + $this->assertSame('45', $icuResult->format('s')); + } +} diff --git a/test/Unit/IcuFormatterTest.php b/test/Unit/IcuFormatterTest.php index db32815..71a436b 100644 --- a/test/Unit/IcuFormatterTest.php +++ b/test/Unit/IcuFormatterTest.php @@ -315,8 +315,8 @@ public function testWeekOfYear(): void $result = $formatter->format($timestamp, 'w', 'en_US'); $this->assertIsNumeric($result); - $this->assertGreaterThanOrEqual(1, (int)$result); - $this->assertLessThanOrEqual(53, (int)$result); + $this->assertGreaterThanOrEqual(1, (int) $result); + $this->assertLessThanOrEqual(53, (int) $result); } /** @@ -373,7 +373,7 @@ public function testInvalidPatternThrowsException(): void $result = $formatter->format($timestamp, "yyyy-MM-dd'incomplete", 'en_US'); // If it doesn't throw, at least verify we got some output $this->assertIsString($result); - } catch (InvalidArgumentException | RuntimeException $e) { + } catch (InvalidArgumentException|RuntimeException $e) { // If it does throw, verify the exception message $this->assertStringContainsString('Failed to', $e->getMessage()); } @@ -428,9 +428,9 @@ public function testParseIsoFormat(): void $date = $formatter->parse('2026-03-18', 'yyyy-MM-dd', 'en_US'); - $this->assertEquals(2026, $date->year); - $this->assertEquals(3, $date->month); - $this->assertEquals(18, $date->mday); + $this->assertSame('2026', $date->format('Y')); + $this->assertSame('03', $date->format('m')); + $this->assertSame('18', $date->format('d')); } /** @@ -443,9 +443,9 @@ public function testParseWithLocale(): void // Parse German formatted date $date = $formatter->parse('Mittwoch, 18. März 2026', 'EEEE, dd. MMMM yyyy', 'de_DE'); - $this->assertEquals(2026, $date->year); - $this->assertEquals(3, $date->month); - $this->assertEquals(18, $date->mday); + $this->assertSame('2026', $date->format('Y')); + $this->assertSame('03', $date->format('m')); + $this->assertSame('18', $date->format('d')); } /** @@ -462,9 +462,9 @@ public function testParseShortcutFormat(): void // Parse it back $date = $formatter->parse($formatted, 'short', 'en_US'); - $this->assertEquals(2026, $date->year); - $this->assertEquals(3, $date->month); - $this->assertEquals(18, $date->mday); + $this->assertSame('2026', $date->format('Y')); + $this->assertSame('03', $date->format('m')); + $this->assertSame('18', $date->format('d')); } /** @@ -476,12 +476,12 @@ public function testParseWithTime(): void $date = $formatter->parse('2026-03-18 14:30:45', 'yyyy-MM-dd HH:mm:ss', 'en_US'); - $this->assertEquals(2026, $date->year); - $this->assertEquals(3, $date->month); - $this->assertEquals(18, $date->mday); - $this->assertEquals(14, $date->hour); - $this->assertEquals(30, $date->min); - $this->assertEquals(45, $date->sec); + $this->assertSame('2026', $date->format('Y')); + $this->assertSame('03', $date->format('m')); + $this->assertSame('18', $date->format('d')); + $this->assertSame('14', $date->format('H')); + $this->assertSame('30', $date->format('i')); + $this->assertSame('45', $date->format('s')); } /** @@ -493,13 +493,11 @@ public function testParseWithTimezone(): void $date = $formatter->parse('2026-03-18 14:30', 'yyyy-MM-dd HH:mm', 'en_US', 'Europe/Berlin'); - $this->assertEquals(2026, $date->year); - $this->assertEquals(3, $date->month); - $this->assertEquals(18, $date->mday); - $this->assertEquals(14, $date->hour); - $this->assertEquals(30, $date->min); - // Verify timezone is set - $this->assertNotEquals('UTC', $date->timezone); + $this->assertSame('2026', $date->format('Y')); + $this->assertSame('03', $date->format('m')); + $this->assertSame('18', $date->format('d')); + $this->assertSame('14', $date->format('H')); + $this->assertSame('30', $date->format('i')); } /** @@ -517,11 +515,7 @@ public function testParseRoundTrip(): void $parsed = $formatter->parse($formatted, 'yyyy-MM-dd HH:mm', 'en_US'); // Should match (within same minute due to seconds being dropped) - $this->assertEquals(2026, $parsed->year); - $this->assertEquals(3, $parsed->month); - $this->assertEquals(18, $parsed->mday); - $this->assertEquals(14, $parsed->hour); - $this->assertEquals(30, $parsed->min); + $this->assertSame('2026-03-18 14:30', $parsed->format('Y-m-d H:i')); } /** @@ -572,8 +566,8 @@ public function testParseFrenchLocaleRoundTrip(): void // Parse back $parsed = $formatter->parse($formatted, 'EEEE dd MMMM yyyy', 'fr_FR'); - $this->assertEquals(2026, $parsed->year); - $this->assertEquals(3, $parsed->month); - $this->assertEquals(18, $parsed->mday); + $this->assertSame('2026', $parsed->format('Y')); + $this->assertSame('03', $parsed->format('m')); + $this->assertSame('18', $parsed->format('d')); } } diff --git a/test/Unit/Recurrence/DayMaskTest.php b/test/Unit/Recurrence/DayMaskTest.php new file mode 100644 index 0000000..983223b --- /dev/null +++ b/test/Unit/Recurrence/DayMaskTest.php @@ -0,0 +1,267 @@ +assertSame(1, DayMask::SUNDAY); + $this->assertSame(2, DayMask::MONDAY); + $this->assertSame(4, DayMask::TUESDAY); + $this->assertSame(8, DayMask::WEDNESDAY); + $this->assertSame(16, DayMask::THURSDAY); + $this->assertSame(32, DayMask::FRIDAY); + $this->assertSame(64, DayMask::SATURDAY); + } + + public function testCompositeConstants(): void + { + $this->assertSame(62, DayMask::WEEKDAYS); + $this->assertSame(65, DayMask::WEEKEND); + $this->assertSame(127, DayMask::ALL_DAYS); + } + + public function testWeekdaysCombination(): void + { + $computed = DayMask::MONDAY | DayMask::TUESDAY | DayMask::WEDNESDAY + | DayMask::THURSDAY | DayMask::FRIDAY; + $this->assertSame(DayMask::WEEKDAYS, $computed); + } + + public function testWeekendCombination(): void + { + $computed = DayMask::SUNDAY | DayMask::SATURDAY; + $this->assertSame(DayMask::WEEKEND, $computed); + } + + public function testAllDaysCombination(): void + { + $computed = DayMask::WEEKDAYS | DayMask::WEEKEND; + $this->assertSame(DayMask::ALL_DAYS, $computed); + } + + // ========================================================================= + // includes() + // ========================================================================= + + public function testIncludesTrue(): void + { + $this->assertTrue(DayMask::includes(DayMask::WEEKDAYS, DayMask::MONDAY)); + $this->assertTrue(DayMask::includes(DayMask::ALL_DAYS, DayMask::SUNDAY)); + } + + public function testIncludesFalse(): void + { + $this->assertFalse(DayMask::includes(DayMask::WEEKDAYS, DayMask::SUNDAY)); + $this->assertFalse(DayMask::includes(DayMask::WEEKEND, DayMask::MONDAY)); + } + + public function testIncludesZeroMask(): void + { + $this->assertFalse(DayMask::includes(0, DayMask::MONDAY)); + } + + // ========================================================================= + // fromDays() + // ========================================================================= + + public function testFromDaysSingle(): void + { + $this->assertSame(DayMask::MONDAY, DayMask::fromDays(DayMask::MONDAY)); + } + + public function testFromDaysMultiple(): void + { + $mask = DayMask::fromDays(DayMask::MONDAY, DayMask::FRIDAY); + $this->assertSame(DayMask::MONDAY | DayMask::FRIDAY, $mask); + } + + public function testFromDaysEmpty(): void + { + $this->assertSame(0, DayMask::fromDays()); + } + + // ========================================================================= + // fromDayOfWeek() + // ========================================================================= + + #[DataProvider('dayOfWeekProvider')] + public function testFromDayOfWeek(int $dayOfWeek, int $expectedBit): void + { + $this->assertSame($expectedBit, DayMask::fromDayOfWeek($dayOfWeek)); + } + + public static function dayOfWeekProvider(): array + { + return [ + 'Sunday (0)' => [0, DayMask::SUNDAY], + 'Monday (1)' => [1, DayMask::MONDAY], + 'Tuesday (2)' => [2, DayMask::TUESDAY], + 'Wednesday (3)' => [3, DayMask::WEDNESDAY], + 'Thursday (4)' => [4, DayMask::THURSDAY], + 'Friday (5)' => [5, DayMask::FRIDAY], + 'Saturday (6)' => [6, DayMask::SATURDAY], + ]; + } + + public function testFromDayOfWeekInvalidThrows(): void + { + $this->expectException(InvalidArgumentException::class); + DayMask::fromDayOfWeek(7); + } + + public function testFromDayOfWeekNegativeThrows(): void + { + $this->expectException(InvalidArgumentException::class); + DayMask::fromDayOfWeek(-1); + } + + // ========================================================================= + // toDays() + // ========================================================================= + + public function testToDaysSingle(): void + { + $this->assertSame([DayMask::WEDNESDAY], DayMask::toDays(DayMask::WEDNESDAY)); + } + + public function testToDaysMultiple(): void + { + $days = DayMask::toDays(DayMask::WEEKEND); + $this->assertSame([DayMask::SUNDAY, DayMask::SATURDAY], $days); + } + + public function testToDaysEmpty(): void + { + $this->assertSame([], DayMask::toDays(0)); + } + + public function testToDaysAllDays(): void + { + $days = DayMask::toDays(DayMask::ALL_DAYS); + $this->assertCount(7, $days); + } + + // ========================================================================= + // count() + // ========================================================================= + + public function testCountSingle(): void + { + $this->assertSame(1, DayMask::count(DayMask::MONDAY)); + } + + public function testCountWeekdays(): void + { + $this->assertSame(5, DayMask::count(DayMask::WEEKDAYS)); + } + + public function testCountAllDays(): void + { + $this->assertSame(7, DayMask::count(DayMask::ALL_DAYS)); + } + + public function testCountZero(): void + { + $this->assertSame(0, DayMask::count(0)); + } + + public function testCountWeekend(): void + { + $this->assertSame(2, DayMask::count(DayMask::WEEKEND)); + } + + // ========================================================================= + // fromRfc5545Days() + // ========================================================================= + + public function testFromRfc5545DaysSingle(): void + { + $this->assertSame(DayMask::MONDAY, DayMask::fromRfc5545Days(['MO'])); + } + + public function testFromRfc5545DaysMultiple(): void + { + $mask = DayMask::fromRfc5545Days(['MO', 'WE', 'FR']); + $this->assertSame( + DayMask::MONDAY | DayMask::WEDNESDAY | DayMask::FRIDAY, + $mask + ); + } + + public function testFromRfc5545DaysCaseInsensitive(): void + { + $this->assertSame(DayMask::TUESDAY, DayMask::fromRfc5545Days(['tu'])); + } + + public function testFromRfc5545DaysInvalidThrows(): void + { + $this->expectException(InvalidArgumentException::class); + DayMask::fromRfc5545Days(['XX']); + } + + public function testFromRfc5545DaysEmpty(): void + { + $this->assertSame(0, DayMask::fromRfc5545Days([])); + } + + // ========================================================================= + // toRfc5545Days() + // ========================================================================= + + public function testToRfc5545DaysSingle(): void + { + $this->assertSame(['MO'], DayMask::toRfc5545Days(DayMask::MONDAY)); + } + + public function testToRfc5545DaysMultiple(): void + { + $days = DayMask::toRfc5545Days(DayMask::WEEKEND); + $this->assertSame(['SU', 'SA'], $days); + } + + public function testToRfc5545DaysWeekdays(): void + { + $days = DayMask::toRfc5545Days(DayMask::WEEKDAYS); + $this->assertSame(['MO', 'TU', 'WE', 'TH', 'FR'], $days); + } + + public function testToRfc5545DaysEmpty(): void + { + $this->assertSame([], DayMask::toRfc5545Days(0)); + } + + // ========================================================================= + // Round-trip: fromRfc5545Days ↔ toRfc5545Days + // ========================================================================= + + public function testRfc5545RoundTrip(): void + { + $input = ['MO', 'WE', 'FR']; + $mask = DayMask::fromRfc5545Days($input); + $output = DayMask::toRfc5545Days($mask); + $this->assertSame($input, $output); + } + + public function testRfc5545RoundTripAllDays(): void + { + $input = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']; + $mask = DayMask::fromRfc5545Days($input); + $output = DayMask::toRfc5545Days($mask); + $this->assertSame($input, $output); + } +} diff --git a/test/Unit/Recurrence/RecurrenceTest.php b/test/Unit/Recurrence/RecurrenceTest.php new file mode 100644 index 0000000..e704ae0 --- /dev/null +++ b/test/Unit/Recurrence/RecurrenceTest.php @@ -0,0 +1,1320 @@ +oldTimezone = date_default_timezone_get(); + date_default_timezone_set('UTC'); + } + + protected function tearDown(): void + { + date_default_timezone_set($this->oldTimezone); + } + + private function date(string $date, string $tz = 'UTC'): DateTimeImmutable + { + return new DateTimeImmutable($date, new DateTimeZone($tz)); + } + + private function collectRecurrences(Recurrence $r, string $afterDate, int $limit = 30): array + { + $dates = []; + $after = $this->date($afterDate); + while ($next = $r->nextRecurrence($after)) { + if (count($dates) >= $limit) { + break; + } + $dates[] = $next->format('Y-m-d'); + $after = $next->modify('+1 day'); + } + return $dates; + } + + // ========================================================================= + // Constructor & Interface + // ========================================================================= + + public function testImplementsInterface(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $this->assertInstanceOf(RecurrenceInterface::class, $r); + } + + public function testConstructorStoresStart(): void + { + $start = $this->date('2026-04-18 10:00:00'); + $r = new Recurrence($start); + $this->assertSame('2026-04-18 10:00:00', $r->getStart()->format('Y-m-d H:i:s')); + } + + public function testConstructorAcceptsMutableDateTime(): void + { + $dt = new DateTime('2026-04-18 10:00:00', new DateTimeZone('UTC')); + $r = new Recurrence($dt); + $this->assertInstanceOf(DateTimeImmutable::class, $r->getStart()); + $this->assertSame('2026-04-18', $r->getStart()->format('Y-m-d')); + } + + public function testDefaultState(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $this->assertSame(RecurrenceType::None, $r->getType()); + $this->assertSame(1, $r->getInterval()); + $this->assertNull($r->getEnd()); + $this->assertNull($r->getCount()); + $this->assertSame(0, $r->getDayMask()); + } + + // ========================================================================= + // Setters & Reset + // ========================================================================= + + public function testSetType(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setType(RecurrenceType::Weekly); + $this->assertSame(RecurrenceType::Weekly, $r->getType()); + } + + public function testSetInterval(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setInterval(3); + $this->assertSame(3, $r->getInterval()); + } + + public function testSetIntervalAcceptsZero(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setInterval(5); + $r->setInterval(0); + $this->assertSame(0, $r->getInterval()); + } + + public function testSetIntervalIgnoresNegative(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setInterval(5); + $r->setInterval(-1); + $this->assertSame(5, $r->getInterval()); + } + + public function testSetEndClearsCount(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setCount(10); + $r->setEnd($this->date('2026-12-31')); + $this->assertNull($r->getCount()); + $this->assertSame('2026-12-31', $r->getEnd()->format('Y-m-d')); + } + + public function testSetCountClearsEnd(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setEnd($this->date('2026-12-31')); + $r->setCount(10); + $this->assertNull($r->getEnd()); + $this->assertSame(10, $r->getCount()); + } + + public function testSetCountNullClears(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setCount(10); + $r->setCount(null); + $this->assertNull($r->getCount()); + } + + public function testSetCountZeroClears(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setCount(10); + $r->setCount(0); + $this->assertNull($r->getCount()); + } + + public function testSetStart(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setStart($this->date('2026-06-15 08:00:00')); + $this->assertSame('2026-06-15 08:00:00', $r->getStart()->format('Y-m-d H:i:s')); + } + + public function testSetDayMask(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setDayMask(DayMask::MONDAY | DayMask::FRIDAY); + $this->assertSame(DayMask::MONDAY | DayMask::FRIDAY, $r->getDayMask()); + } + + public function testReset(): void + { + $r = new Recurrence($this->date('2026-01-01 10:00:00')); + $r->setType(RecurrenceType::Daily); + $r->setInterval(3); + $r->setCount(10); + $r->setDayMask(DayMask::MONDAY); + $r->addException($this->date('2026-01-05')); + $r->addCompletion($this->date('2026-01-06')); + + $r->reset(); + + $this->assertSame(RecurrenceType::None, $r->getType()); + $this->assertSame(1, $r->getInterval()); + $this->assertNull($r->getCount()); + $this->assertNull($r->getEnd()); + $this->assertSame(0, $r->getDayMask()); + $this->assertSame([], $r->getExceptions()); + $this->assertSame([], $r->getCompletions()); + } + + public function testHasEnd(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $this->assertFalse($r->hasEnd()); + + $r->setEnd($this->date('2026-12-31')); + $this->assertTrue($r->hasEnd()); + + $r->setEnd($this->date('9999-12-31')); + $this->assertFalse($r->hasEnd()); + } + + public function testHasCount(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $this->assertFalse($r->hasCount()); + + $r->setCount(5); + $this->assertTrue($r->hasCount()); + } + + // ========================================================================= + // Exception / Completion management + // ========================================================================= + + public function testAddAndHasException(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->addException($this->date('2026-01-15')); + $this->assertTrue($r->hasException($this->date('2026-01-15'))); + $this->assertFalse($r->hasException($this->date('2026-01-16'))); + } + + public function testExceptionDeduplication(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->addException($this->date('2026-01-15')); + $r->addException($this->date('2026-01-15')); + $this->assertCount(1, $r->getExceptions()); + } + + public function testDeleteException(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->addException($this->date('2026-01-15')); + $r->deleteException($this->date('2026-01-15')); + $this->assertFalse($r->hasException($this->date('2026-01-15'))); + $this->assertSame([], $r->getExceptions()); + } + + public function testGetExceptionsReturnsYyyymmddStrings(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->addException($this->date('2026-01-15')); + $r->addException($this->date('2026-02-20')); + $this->assertSame(['20260115', '20260220'], $r->getExceptions()); + } + + public function testAddAndHasCompletion(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->addCompletion($this->date('2026-01-15')); + $this->assertTrue($r->hasCompletion($this->date('2026-01-15'))); + } + + public function testCompletionAllowsDuplicates(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->addCompletion($this->date('2026-01-15')); + $r->addCompletion($this->date('2026-01-15')); + $this->assertCount(2, $r->getCompletions()); + } + + public function testDeleteCompletion(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->addCompletion($this->date('2026-01-15')); + $r->deleteCompletion($this->date('2026-01-15')); + $this->assertSame([], $r->getCompletions()); + } + + public function testSetExceptions(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setExceptions(['20260101', '20260201']); + $this->assertSame(['20260101', '20260201'], $r->getExceptions()); + } + + public function testSetCompletions(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setCompletions(['20260101']); + $this->assertSame(['20260101'], $r->getCompletions()); + } + + // ========================================================================= + // getRecurName() + // ========================================================================= + + #[DataProvider('recurNameProvider')] + public function testGetRecurName(RecurrenceType $type, string $expected): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setType($type); + $this->assertSame($expected, $r->getRecurName()); + } + + public static function recurNameProvider(): array + { + return [ + 'None' => [RecurrenceType::None, 'No recurrence'], + 'Daily' => [RecurrenceType::Daily, 'Daily'], + 'Weekly' => [RecurrenceType::Weekly, 'Weekly'], + 'MonthlyDate' => [RecurrenceType::MonthlyDate, 'Monthly'], + 'MonthlyWeekday' => [RecurrenceType::MonthlyWeekday, 'Monthly'], + 'MonthlyLastWeekday' => [RecurrenceType::MonthlyLastWeekday, 'Monthly'], + 'YearlyDate' => [RecurrenceType::YearlyDate, 'Yearly'], + 'YearlyDay' => [RecurrenceType::YearlyDay, 'Yearly'], + 'YearlyWeekday' => [RecurrenceType::YearlyWeekday, 'Yearly'], + ]; + } + + // ========================================================================= + // nextRecurrence — None / Edge Cases + // ========================================================================= + + public function testNextRecurrenceNoneReturnsNull(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setType(RecurrenceType::None); + $this->assertNull($r->nextRecurrence($this->date('2026-01-02'))); + } + + public function testNextRecurrenceReturnsStartWhenAfterBeforeStart(): void + { + $r = new Recurrence($this->date('2026-06-01 10:00:00')); + $r->setType(RecurrenceType::Daily); + $r->setInterval(1); + $next = $r->nextRecurrence($this->date('2026-01-01')); + $this->assertNotNull($next); + $this->assertSame('2026-06-01', $next->format('Y-m-d')); + } + + public function testNextRecurrenceIntervalZeroReturnsNull(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setType(RecurrenceType::Daily); + $r->setInterval(5); + // Force interval to 0 via reflection since setInterval rejects 0 + $ref = new ReflectionProperty($r, 'interval'); + $ref->setValue($r, 0); + $this->assertNull($r->nextRecurrence($this->date('2026-01-02'))); + } + + public function testNextRecurrenceReturnsDateTimeImmutable(): void + { + $r = new Recurrence($this->date('2026-01-01 09:00:00')); + $r->setType(RecurrenceType::Daily); + $next = $r->nextRecurrence($this->date('2026-01-02')); + $this->assertInstanceOf(DateTimeImmutable::class, $next); + } + + // ========================================================================= + // Daily recurrence + // ========================================================================= + + public function testDailyBasic(): void + { + $r = new Recurrence($this->date('2026-01-01 10:00:00')); + $r->setType(RecurrenceType::Daily); + $r->setInterval(1); + $dates = $this->collectRecurrences($r, '2026-01-01', 5); + $this->assertSame([ + '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04', '2026-01-05', + ], $dates); + } + + public function testDailyInterval3(): void + { + $r = new Recurrence($this->date('2026-01-01 10:00:00')); + $r->setType(RecurrenceType::Daily); + $r->setInterval(3); + $dates = $this->collectRecurrences($r, '2026-01-01', 4); + $this->assertSame([ + '2026-01-01', '2026-01-04', '2026-01-07', '2026-01-10', + ], $dates); + } + + public function testDailyWithEndDate(): void + { + $r = new Recurrence($this->date('2026-01-01 10:00:00')); + $r->setType(RecurrenceType::Daily); + $r->setInterval(1); + $r->setEnd($this->date('2026-01-05 23:59:59')); + $dates = $this->collectRecurrences($r, '2026-01-01'); + $this->assertSame([ + '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04', '2026-01-05', + ], $dates); + } + + public function testDailyWithCount(): void + { + $r = new Recurrence($this->date('2026-01-01 10:00:00')); + $r->setType(RecurrenceType::Daily); + $r->setInterval(1); + $r->setCount(3); + $dates = $this->collectRecurrences($r, '2026-01-01'); + $this->assertSame([ + '2026-01-01', '2026-01-02', '2026-01-03', + ], $dates); + } + + public function testDailyPreservesTime(): void + { + $r = new Recurrence($this->date('2026-01-01 14:30:00')); + $r->setType(RecurrenceType::Daily); + $next = $r->nextRecurrence($this->date('2026-01-02')); + $this->assertSame('14:30:00', $next->format('H:i:s')); + } + + // ========================================================================= + // Weekly recurrence + // ========================================================================= + + public function testWeeklyMondayWednesdayFriday(): void + { + $r = new Recurrence($this->date('2026-01-05 10:00:00')); + $r->setType(RecurrenceType::Weekly); + $r->setInterval(1); + $r->setDayMask(DayMask::MONDAY | DayMask::WEDNESDAY | DayMask::FRIDAY); + $dates = $this->collectRecurrences($r, '2026-01-05', 6); + $this->assertSame([ + '2026-01-05', '2026-01-07', '2026-01-09', + '2026-01-12', '2026-01-14', '2026-01-16', + ], $dates); + } + + public function testWeeklyInterval2(): void + { + $r = new Recurrence($this->date('2026-01-05 10:00:00')); + $r->setType(RecurrenceType::Weekly); + $r->setInterval(2); + $r->setDayMask(DayMask::MONDAY); + $dates = $this->collectRecurrences($r, '2026-01-05', 4); + $this->assertSame([ + '2026-01-05', '2026-01-19', '2026-02-02', '2026-02-16', + ], $dates); + } + + public function testWeeklyNoDayMaskReturnsNull(): void + { + $r = new Recurrence($this->date('2026-01-05 10:00:00')); + $r->setType(RecurrenceType::Weekly); + $this->assertNull($r->nextRecurrence($this->date('2026-01-06'))); + } + + public function testWeeklyWithEndDate(): void + { + $r = new Recurrence($this->date('2026-01-05 10:00:00')); + $r->setType(RecurrenceType::Weekly); + $r->setInterval(1); + $r->setDayMask(DayMask::MONDAY); + $r->setEnd($this->date('2026-01-20 23:59:59')); + $dates = $this->collectRecurrences($r, '2026-01-05'); + $this->assertSame(['2026-01-05', '2026-01-12', '2026-01-19'], $dates); + } + + public function testWeeklyWithCount(): void + { + $r = new Recurrence($this->date('2026-01-05 10:00:00')); + $r->setType(RecurrenceType::Weekly); + $r->setInterval(1); + $r->setDayMask(DayMask::MONDAY); + $r->setCount(3); + $dates = $this->collectRecurrences($r, '2026-01-05'); + $this->assertSame(['2026-01-05', '2026-01-12', '2026-01-19'], $dates); + } + + // ========================================================================= + // Monthly Date recurrence + // ========================================================================= + + public function testMonthlyDateBasic(): void + { + $r = new Recurrence($this->date('2026-01-15 10:00:00')); + $r->setType(RecurrenceType::MonthlyDate); + $r->setInterval(1); + $dates = $this->collectRecurrences($r, '2026-01-01', 4); + $this->assertSame([ + '2026-01-15', '2026-02-15', '2026-03-15', '2026-04-15', + ], $dates); + } + + public function testMonthlyDateInterval2(): void + { + $r = new Recurrence($this->date('2026-01-15 10:00:00')); + $r->setType(RecurrenceType::MonthlyDate); + $r->setInterval(2); + $dates = $this->collectRecurrences($r, '2026-01-01', 4); + $this->assertSame([ + '2026-01-15', '2026-03-15', '2026-05-15', '2026-07-15', + ], $dates); + } + + public function testMonthlyDateDay31SkipsShortMonths(): void + { + $r = new Recurrence($this->date('2026-01-31 10:00:00')); + $r->setType(RecurrenceType::MonthlyDate); + $r->setInterval(1); + $dates = $this->collectRecurrences($r, '2026-01-01', 4); + $this->assertSame([ + '2026-01-31', '2026-03-31', '2026-05-31', '2026-07-31', + ], $dates); + } + + public function testMonthlyDateWithEndDate(): void + { + $r = new Recurrence($this->date('2026-01-15 10:00:00')); + $r->setType(RecurrenceType::MonthlyDate); + $r->setInterval(1); + $r->setEnd($this->date('2026-03-31 23:59:59')); + $dates = $this->collectRecurrences($r, '2026-01-01'); + $this->assertSame(['2026-01-15', '2026-02-15', '2026-03-15'], $dates); + } + + public function testMonthlyDateWithCount(): void + { + $r = new Recurrence($this->date('2026-01-15 10:00:00')); + $r->setType(RecurrenceType::MonthlyDate); + $r->setInterval(1); + $r->setCount(3); + $dates = $this->collectRecurrences($r, '2026-01-01'); + $this->assertSame(['2026-01-15', '2026-02-15', '2026-03-15'], $dates); + } + + // ========================================================================= + // Monthly Weekday recurrence + // ========================================================================= + + public function testMonthlyWeekday2ndTuesday(): void + { + // 2026-01-13 is the 2nd Tuesday of January + $r = new Recurrence($this->date('2026-01-13 10:00:00')); + $r->setType(RecurrenceType::MonthlyWeekday); + $r->setInterval(1); + $dates = $this->collectRecurrences($r, '2026-01-01', 4); + $this->assertSame([ + '2026-01-13', '2026-02-10', '2026-03-10', '2026-04-14', + ], $dates); + } + + public function testMonthlyLastWeekday(): void + { + // 2026-01-30 is the last Friday of January + $r = new Recurrence($this->date('2026-01-30 10:00:00')); + $r->setType(RecurrenceType::MonthlyLastWeekday); + $r->setInterval(1); + $dates = $this->collectRecurrences($r, '2026-01-01', 4); + $this->assertSame([ + '2026-01-30', '2026-02-27', '2026-03-27', '2026-04-24', + ], $dates); + } + + public function testMonthlyWeekdayWithCount(): void + { + $r = new Recurrence($this->date('2026-01-13 10:00:00')); + $r->setType(RecurrenceType::MonthlyWeekday); + $r->setInterval(1); + $r->setCount(3); + $dates = $this->collectRecurrences($r, '2026-01-01'); + $this->assertSame([ + '2026-01-13', '2026-02-10', '2026-03-10', + ], $dates); + } + + // ========================================================================= + // Yearly Date recurrence + // ========================================================================= + + public function testYearlyDateBasic(): void + { + $r = new Recurrence($this->date('2026-03-15 10:00:00')); + $r->setType(RecurrenceType::YearlyDate); + $r->setInterval(1); + $dates = $this->collectRecurrences($r, '2026-01-01', 4); + $this->assertSame([ + '2026-03-15', '2027-03-15', '2028-03-15', '2029-03-15', + ], $dates); + } + + public function testYearlyDateInterval2(): void + { + $r = new Recurrence($this->date('2026-06-15 10:00:00')); + $r->setType(RecurrenceType::YearlyDate); + $r->setInterval(2); + $dates = $this->collectRecurrences($r, '2026-01-01', 3); + $this->assertSame([ + '2026-06-15', '2028-06-15', '2030-06-15', + ], $dates); + } + + public function testYearlyDateFeb29LeapSkip(): void + { + $r = new Recurrence($this->date('2024-02-29 10:00:00')); + $r->setType(RecurrenceType::YearlyDate); + $r->setInterval(1); + $r->setCount(10); + $dates = $this->collectRecurrences($r, '2024-01-01'); + $this->assertSame([ + '2024-02-29', '2028-02-29', '2032-02-29', + ], $dates); + } + + public function testYearlyDateFeb29Interval2WithCount(): void + { + $r = new Recurrence($this->date('2024-02-29 10:00:00')); + $r->setType(RecurrenceType::YearlyDate); + $r->setInterval(2); + $r->setCount(6); + $dates = $this->collectRecurrences($r, '2024-01-01'); + $this->assertSame(['2024-02-29', '2028-02-29'], $dates); + } + + public function testYearlyDateWithEndDate(): void + { + $r = new Recurrence($this->date('2026-03-15 10:00:00')); + $r->setType(RecurrenceType::YearlyDate); + $r->setInterval(1); + $r->setEnd($this->date('2028-12-31')); + $dates = $this->collectRecurrences($r, '2026-01-01'); + $this->assertSame([ + '2026-03-15', '2027-03-15', '2028-03-15', + ], $dates); + } + + public function testYearlyDateFeb29LeapWithCount(): void + { + $r = new Recurrence($this->date('2024-02-29 10:00:00')); + $r->setType(RecurrenceType::YearlyDate); + $r->setInterval(1); + $r->setCount(5); + $dates = $this->collectRecurrences($r, '2024-01-01'); + $this->assertSame([ + '2024-02-29', '2028-02-29', + ], $dates); + } + + // ========================================================================= + // Yearly Day recurrence + // ========================================================================= + + public function testYearlyDayBasic(): void + { + // Day 60 of 2026 = 2026-03-01; in leap year 2028 day 60 = Feb 29 + $r = new Recurrence($this->date('2026-03-01 10:00:00')); + $r->setType(RecurrenceType::YearlyDay); + $r->setInterval(1); + $dates = $this->collectRecurrences($r, '2026-01-01', 3); + $this->assertSame([ + '2026-03-01', '2027-03-01', '2028-02-29', + ], $dates); + } + + public function testYearlyDayWithEndDate(): void + { + $r = new Recurrence($this->date('2026-03-01 10:00:00')); + $r->setType(RecurrenceType::YearlyDay); + $r->setInterval(1); + $r->setEnd($this->date('2027-12-31')); + $dates = $this->collectRecurrences($r, '2026-01-01'); + $this->assertSame(['2026-03-01', '2027-03-01'], $dates); + } + + // ========================================================================= + // Yearly Weekday recurrence + // ========================================================================= + + public function testYearlyWeekday2ndTuesdayOfJanuary(): void + { + // 2026-01-13 is the 2nd Tuesday of January + $r = new Recurrence($this->date('2026-01-13 10:00:00')); + $r->setType(RecurrenceType::YearlyWeekday); + $r->setInterval(1); + $dates = $this->collectRecurrences($r, '2026-01-01', 3); + $this->assertSame([ + '2026-01-13', '2027-01-12', '2028-01-11', + ], $dates); + } + + // ========================================================================= + // nextActiveRecurrence & hasActiveRecurrence + // ========================================================================= + + public function testNextActiveRecurrenceSkipsExceptions(): void + { + $r = new Recurrence($this->date('2026-01-01 10:00:00')); + $r->setType(RecurrenceType::Daily); + $r->addException($this->date('2026-01-01')); + $r->addException($this->date('2026-01-02')); + $next = $r->nextActiveRecurrence($this->date('2026-01-01')); + $this->assertNotNull($next); + $this->assertSame('2026-01-03', $next->format('Y-m-d')); + } + + public function testNextActiveRecurrenceSkipsCompletions(): void + { + $r = new Recurrence($this->date('2026-01-01 10:00:00')); + $r->setType(RecurrenceType::Daily); + $r->addCompletion($this->date('2026-01-01')); + $next = $r->nextActiveRecurrence($this->date('2026-01-01')); + $this->assertNotNull($next); + $this->assertSame('2026-01-02', $next->format('Y-m-d')); + } + + public function testHasActiveRecurrenceNoEnd(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setType(RecurrenceType::Daily); + $this->assertTrue($r->hasActiveRecurrence()); + } + + public function testHasActiveRecurrenceAllExcepted(): void + { + $r = new Recurrence($this->date('2026-01-01 10:00:00')); + $r->setType(RecurrenceType::Daily); + $r->setEnd($this->date('2026-01-03 23:59:59')); + $r->addException($this->date('2026-01-01')); + $r->addException($this->date('2026-01-02')); + $r->addException($this->date('2026-01-03')); + $this->assertFalse($r->hasActiveRecurrence()); + } + + // ========================================================================= + // toRRule20 generation + // ========================================================================= + + public function testToRRule20Daily(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setType(RecurrenceType::Daily); + $r->setInterval(2); + $this->assertSame('FREQ=DAILY;INTERVAL=2', $r->toRRule20()); + } + + public function testToRRule20Weekly(): void + { + $r = new Recurrence($this->date('2026-01-05')); + $r->setType(RecurrenceType::Weekly); + $r->setInterval(1); + $r->setDayMask(DayMask::MONDAY | DayMask::WEDNESDAY | DayMask::FRIDAY); + $this->assertSame('FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE,FR', $r->toRRule20()); + } + + public function testToRRule20MonthlyDate(): void + { + $r = new Recurrence($this->date('2026-01-15')); + $r->setType(RecurrenceType::MonthlyDate); + $r->setInterval(1); + $this->assertSame('FREQ=MONTHLY;INTERVAL=1', $r->toRRule20()); + } + + public function testToRRule20MonthlyWeekday(): void + { + // 2026-01-13 is the 2nd Tuesday + $r = new Recurrence($this->date('2026-01-13')); + $r->setType(RecurrenceType::MonthlyWeekday); + $r->setInterval(1); + $this->assertSame('FREQ=MONTHLY;INTERVAL=1;BYDAY=2TU', $r->toRRule20()); + } + + public function testToRRule20MonthlyLastWeekday(): void + { + // 2026-01-30 is the last Friday + $r = new Recurrence($this->date('2026-01-30')); + $r->setType(RecurrenceType::MonthlyLastWeekday); + $r->setInterval(1); + $this->assertSame('FREQ=MONTHLY;INTERVAL=1;BYDAY=-1FR', $r->toRRule20()); + } + + public function testToRRule20YearlyDate(): void + { + $r = new Recurrence($this->date('2026-03-15')); + $r->setType(RecurrenceType::YearlyDate); + $r->setInterval(1); + $this->assertSame('FREQ=YEARLY;INTERVAL=1', $r->toRRule20()); + } + + public function testToRRule20YearlyDay(): void + { + // 2026-03-01 is day 60 + $r = new Recurrence($this->date('2026-03-01')); + $r->setType(RecurrenceType::YearlyDay); + $r->setInterval(1); + $this->assertStringContainsString('BYYEARDAY=60', $r->toRRule20()); + } + + public function testToRRule20YearlyWeekday(): void + { + // 2026-01-13 is 2nd Tuesday of January + $r = new Recurrence($this->date('2026-01-13')); + $r->setType(RecurrenceType::YearlyWeekday); + $r->setInterval(1); + $rrule = $r->toRRule20(); + $this->assertStringContainsString('FREQ=YEARLY', $rrule); + $this->assertStringContainsString('BYDAY=2TU', $rrule); + $this->assertStringContainsString('BYMONTH=1', $rrule); + } + + public function testToRRule20WithEndDate(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setType(RecurrenceType::Daily); + $r->setInterval(1); + $r->setEnd($this->date('2026-12-31 23:59:59')); + $this->assertStringContainsString('UNTIL=', $r->toRRule20()); + } + + public function testToRRule20WithCount(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setType(RecurrenceType::Daily); + $r->setInterval(1); + $r->setCount(10); + $this->assertStringContainsString('COUNT=10', $r->toRRule20()); + } + + public function testToRRule20NoneReturnsEmpty(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $this->assertSame('', $r->toRRule20()); + } + + // ========================================================================= + // fromRRule20 parsing + // ========================================================================= + + public function testFromRRule20Daily(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->fromRRule20('FREQ=DAILY;INTERVAL=3'); + $this->assertSame(RecurrenceType::Daily, $r->getType()); + $this->assertSame(3, $r->getInterval()); + } + + public function testFromRRule20Weekly(): void + { + $r = new Recurrence($this->date('2026-01-05')); + $r->fromRRule20('FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE,FR'); + $this->assertSame(RecurrenceType::Weekly, $r->getType()); + $this->assertSame( + DayMask::MONDAY | DayMask::WEDNESDAY | DayMask::FRIDAY, + $r->getDayMask() + ); + } + + public function testFromRRule20WeeklyNoByday(): void + { + // Monday start → should default to Monday's bit + $r = new Recurrence($this->date('2026-01-05')); + $r->fromRRule20('FREQ=WEEKLY;INTERVAL=1'); + $this->assertSame(RecurrenceType::Weekly, $r->getType()); + $this->assertSame(DayMask::MONDAY, $r->getDayMask()); + } + + public function testFromRRule20MonthlyDate(): void + { + $r = new Recurrence($this->date('2026-01-15')); + $r->fromRRule20('FREQ=MONTHLY;INTERVAL=1'); + $this->assertSame(RecurrenceType::MonthlyDate, $r->getType()); + } + + public function testFromRRule20MonthlyWeekday(): void + { + $r = new Recurrence($this->date('2026-01-13')); + $r->fromRRule20('FREQ=MONTHLY;INTERVAL=1;BYDAY=2TU'); + $this->assertSame(RecurrenceType::MonthlyWeekday, $r->getType()); + } + + public function testFromRRule20MonthlyLastWeekday(): void + { + $r = new Recurrence($this->date('2026-01-30')); + $r->fromRRule20('FREQ=MONTHLY;INTERVAL=1;BYDAY=-1FR'); + $this->assertSame(RecurrenceType::MonthlyLastWeekday, $r->getType()); + } + + public function testFromRRule20YearlyDate(): void + { + $r = new Recurrence($this->date('2026-03-15')); + $r->fromRRule20('FREQ=YEARLY;INTERVAL=1'); + $this->assertSame(RecurrenceType::YearlyDate, $r->getType()); + } + + public function testFromRRule20YearlyDay(): void + { + $r = new Recurrence($this->date('2026-03-01')); + $r->fromRRule20('FREQ=YEARLY;INTERVAL=1;BYYEARDAY=60'); + $this->assertSame(RecurrenceType::YearlyDay, $r->getType()); + } + + public function testFromRRule20YearlyWeekday(): void + { + $r = new Recurrence($this->date('2026-01-13')); + $r->fromRRule20('FREQ=YEARLY;INTERVAL=1;BYDAY=2TU;BYMONTH=1'); + $this->assertSame(RecurrenceType::YearlyWeekday, $r->getType()); + } + + public function testFromRRule20WithCount(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->fromRRule20('FREQ=DAILY;INTERVAL=1;COUNT=10'); + $this->assertSame(10, $r->getCount()); + } + + public function testFromRRule20WithUntil(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->fromRRule20('FREQ=DAILY;INTERVAL=1;UNTIL=20261231T235959Z'); + $this->assertNotNull($r->getEnd()); + $this->assertSame('2026-12-31', $r->getEnd()->format('Y-m-d')); + } + + public function testFromRRule20EmptyStringResetsToNone(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setType(RecurrenceType::Daily); + $r->fromRRule20(''); + $this->assertSame(RecurrenceType::None, $r->getType()); + } + + public function testFromRRule20ThunderbirdDailyWithByday(): void + { + $r = new Recurrence($this->date('2026-01-05')); + $r->fromRRule20('FREQ=DAILY;BYDAY=MO,WE,FR'); + $this->assertSame(RecurrenceType::Weekly, $r->getType()); + } + + // ========================================================================= + // toRRule10 generation + // ========================================================================= + + public function testToRRule10Daily(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setType(RecurrenceType::Daily); + $r->setInterval(2); + $this->assertSame('D2 #0', $r->toRRule10()); + } + + public function testToRRule10Weekly(): void + { + $r = new Recurrence($this->date('2026-01-05')); + $r->setType(RecurrenceType::Weekly); + $r->setInterval(1); + $r->setDayMask(DayMask::MONDAY | DayMask::FRIDAY); + $rrule = $r->toRRule10(); + $this->assertStringStartsWith('W1', $rrule); + $this->assertStringContainsString('MO', $rrule); + $this->assertStringContainsString('FR', $rrule); + } + + public function testToRRule10MonthlyDate(): void + { + $r = new Recurrence($this->date('2026-01-15')); + $r->setType(RecurrenceType::MonthlyDate); + $r->setInterval(1); + $this->assertStringStartsWith('MD1', $r->toRRule10()); + } + + public function testToRRule10YearlyDate(): void + { + $r = new Recurrence($this->date('2026-03-15')); + $r->setType(RecurrenceType::YearlyDate); + $r->setInterval(1); + $rrule = $r->toRRule10(); + $this->assertStringStartsWith('YM1', $rrule); + $this->assertStringContainsString('3', $rrule); + } + + public function testToRRule10WithCount(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setType(RecurrenceType::Daily); + $r->setInterval(1); + $r->setCount(5); + $this->assertSame('D1 #5', $r->toRRule10()); + } + + public function testToRRule10NoneReturnsEmpty(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $this->assertSame('', $r->toRRule10()); + } + + // ========================================================================= + // fromRRule10 parsing + // ========================================================================= + + public function testFromRRule10Daily(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->fromRRule10('D3 #0'); + $this->assertSame(RecurrenceType::Daily, $r->getType()); + $this->assertSame(3, $r->getInterval()); + } + + public function testFromRRule10Weekly(): void + { + $r = new Recurrence($this->date('2026-01-05')); + $r->fromRRule10('W1 MO FR #0'); + $this->assertSame(RecurrenceType::Weekly, $r->getType()); + $this->assertSame(DayMask::MONDAY | DayMask::FRIDAY, $r->getDayMask()); + } + + public function testFromRRule10MonthlyDate(): void + { + $r = new Recurrence($this->date('2026-01-15')); + $r->fromRRule10('MD1 15 #0'); + $this->assertSame(RecurrenceType::MonthlyDate, $r->getType()); + } + + public function testFromRRule10MonthlyWeekday(): void + { + $r = new Recurrence($this->date('2026-01-13')); + $r->fromRRule10('MP1 2+ TU #0'); + $this->assertSame(RecurrenceType::MonthlyWeekday, $r->getType()); + } + + public function testFromRRule10YearlyDate(): void + { + $r = new Recurrence($this->date('2026-03-15')); + $r->fromRRule10('YM1 3 #0'); + $this->assertSame(RecurrenceType::YearlyDate, $r->getType()); + } + + public function testFromRRule10YearlyDay(): void + { + $r = new Recurrence($this->date('2026-03-01')); + $r->fromRRule10('YD1 60 #0'); + $this->assertSame(RecurrenceType::YearlyDay, $r->getType()); + } + + public function testFromRRule10WithCount(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->fromRRule10('D1 #5'); + $this->assertSame(5, $r->getCount()); + } + + public function testFromRRule10WithEndDate(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->fromRRule10('D1 20261231T235959Z'); + $this->assertNotNull($r->getEnd()); + $this->assertSame('2026-12-31', $r->getEnd()->format('Y-m-d')); + } + + public function testFromRRule10EmptyString(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setType(RecurrenceType::Daily); + $r->fromRRule10(''); + $this->assertSame(RecurrenceType::None, $r->getType()); + } + + public function testFromRRule10MonthlyLastWeekday(): void + { + $r = new Recurrence($this->date('2026-01-30')); + $r->fromRRule10('MP1 1- FR #0'); + // Modern code correctly detects the minus sign (fixed from legacy) + $this->assertSame(RecurrenceType::MonthlyLastWeekday, $r->getType()); + } + + // ========================================================================= + // RRULE20 round-trips + // ========================================================================= + + #[DataProvider('rrule20RoundTripProvider')] + public function testRRule20RoundTrip(RecurrenceType $type, int $interval, int $dayMask): void + { + $r = new Recurrence($this->date('2026-01-05 10:00:00')); + $r->setType($type); + $r->setInterval($interval); + if ($dayMask !== 0) { + $r->setDayMask($dayMask); + } + + $rrule = $r->toRRule20(); + $r2 = new Recurrence($this->date('2026-01-05 10:00:00')); + $r2->fromRRule20($rrule); + + $this->assertSame($r->getType(), $r2->getType()); + $this->assertEquals($r->getInterval(), $r2->getInterval()); + } + + public static function rrule20RoundTripProvider(): array + { + return [ + 'Daily' => [RecurrenceType::Daily, 2, 0], + 'Weekly MWF' => [RecurrenceType::Weekly, 1, DayMask::MONDAY | DayMask::WEDNESDAY | DayMask::FRIDAY], + 'MonthlyDate' => [RecurrenceType::MonthlyDate, 1, 0], + 'YearlyDate' => [RecurrenceType::YearlyDate, 1, 0], + ]; + } + + public function testRRule20RoundTripWithCount(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setType(RecurrenceType::Daily); + $r->setInterval(1); + $r->setCount(10); + + $rrule = $r->toRRule20(); + $r2 = new Recurrence($this->date('2026-01-01')); + $r2->fromRRule20($rrule); + + $this->assertSame(10, $r2->getCount()); + } + + public function testRRule20RoundTripWithEndDate(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setType(RecurrenceType::Daily); + $r->setInterval(1); + $r->setEnd($this->date('2026-12-31 23:59:59')); + + $rrule = $r->toRRule20(); + $r2 = new Recurrence($this->date('2026-01-01')); + $r2->fromRRule20($rrule); + + $this->assertNotNull($r2->getEnd()); + $this->assertSame('2026-12-31', $r2->getEnd()->format('Y-m-d')); + } + + // ========================================================================= + // toJson + // ========================================================================= + + public function testToJsonBasic(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setType(RecurrenceType::Daily); + $r->setInterval(2); + $json = $r->toJson(); + $this->assertSame(RecurrenceType::Daily->value, $json->t); + $this->assertSame(2, $json->i); + } + + public function testToJsonWithEnd(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setType(RecurrenceType::Daily); + $r->setEnd($this->date('2026-12-31')); + $json = $r->toJson(); + $this->assertTrue(isset($json->e)); + } + + public function testToJsonWithCount(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setType(RecurrenceType::Daily); + $r->setCount(5); + $json = $r->toJson(); + $this->assertSame(5, $json->c); + } + + public function testToJsonWithDayMask(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setType(RecurrenceType::Weekly); + $r->setDayMask(DayMask::MONDAY | DayMask::FRIDAY); + $json = $r->toJson(); + $this->assertSame(DayMask::MONDAY | DayMask::FRIDAY, $json->d); + } + + public function testToJsonOmitsEmptyOptionalFields(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setType(RecurrenceType::Daily); + $json = $r->toJson(); + $this->assertFalse(isset($json->e)); + $this->assertFalse(isset($json->c)); + $this->assertFalse(isset($json->d)); + $this->assertFalse(isset($json->co)); + $this->assertFalse(isset($json->ex)); + } + + public function testToJsonWithExceptionsAndCompletions(): void + { + $r = new Recurrence($this->date('2026-01-01')); + $r->setType(RecurrenceType::Daily); + $r->addException($this->date('2026-01-05')); + $r->addCompletion($this->date('2026-01-06')); + $json = $r->toJson(); + $this->assertSame(['20260105'], $json->ex); + $this->assertSame(['20260106'], $json->co); + } + + // ========================================================================= + // toHash / fromHash + // ========================================================================= + + public function testHashRoundTrip(): void + { + $r = new Recurrence($this->date('2026-01-05 10:00:00')); + $r->setType(RecurrenceType::Weekly); + $r->setInterval(2); + $r->setDayMask(DayMask::MONDAY | DayMask::FRIDAY); + $r->setCount(10); + $r->addException($this->date('2026-01-12')); + + $hash = $r->toHash(); + $r2 = Recurrence::fromHash($hash); + + $this->assertSame($r->getType(), $r2->getType()); + $this->assertSame($r->getInterval(), $r2->getInterval()); + $this->assertSame($r->getDayMask(), $r2->getDayMask()); + $this->assertSame($r->getCount(), $r2->getCount()); + $this->assertSame($r->getExceptions(), $r2->getExceptions()); + } + + public function testHashRoundTripWithEndDate(): void + { + $r = new Recurrence($this->date('2026-01-01 10:00:00')); + $r->setType(RecurrenceType::Daily); + $r->setEnd($this->date('2026-12-31 23:59:59')); + + $hash = $r->toHash(); + $r2 = Recurrence::fromHash($hash); + + $this->assertNotNull($r2->getEnd()); + $this->assertSame('2026-12-31', $r2->getEnd()->format('Y-m-d')); + } + + public function testToHashStructure(): void + { + $r = new Recurrence($this->date('2026-01-01 10:00:00')); + $r->setType(RecurrenceType::Daily); + $r->setInterval(2); + $hash = $r->toHash(); + + $this->assertArrayHasKey('start', $hash); + $this->assertArrayHasKey('end', $hash); + $this->assertArrayHasKey('count', $hash); + $this->assertArrayHasKey('type', $hash); + $this->assertArrayHasKey('interval', $hash); + $this->assertArrayHasKey('data', $hash); + $this->assertArrayHasKey('exceptions', $hash); + $this->assertArrayHasKey('completions', $hash); + } + + // ========================================================================= + // isEqual + // ========================================================================= + + public function testIsEqualSameConfig(): void + { + $r1 = new Recurrence($this->date('2026-01-01')); + $r1->setType(RecurrenceType::Daily); + $r1->setInterval(2); + + $r2 = new Recurrence($this->date('2026-01-01')); + $r2->setType(RecurrenceType::Daily); + $r2->setInterval(2); + + $this->assertTrue($r1->isEqual($r2)); + } + + public function testIsEqualDifferentType(): void + { + $r1 = new Recurrence($this->date('2026-01-01')); + $r1->setType(RecurrenceType::Daily); + + $r2 = new Recurrence($this->date('2026-01-01')); + $r2->setType(RecurrenceType::Weekly); + + $this->assertFalse($r1->isEqual($r2)); + } + + public function testIsEqualDifferentInterval(): void + { + $r1 = new Recurrence($this->date('2026-01-01')); + $r1->setType(RecurrenceType::Daily); + $r1->setInterval(1); + + $r2 = new Recurrence($this->date('2026-01-01')); + $r2->setType(RecurrenceType::Daily); + $r2->setInterval(2); + + $this->assertFalse($r1->isEqual($r2)); + } + + public function testIsEqualIgnoresExceptions(): void + { + $r1 = new Recurrence($this->date('2026-01-01')); + $r1->setType(RecurrenceType::Daily); + + $r2 = new Recurrence($this->date('2026-01-01')); + $r2->setType(RecurrenceType::Daily); + $r2->addException($this->date('2026-01-05')); + + $this->assertTrue($r1->isEqual($r2)); + } + + public function testIsEqualDifferentCount(): void + { + $r1 = new Recurrence($this->date('2026-01-01')); + $r1->setType(RecurrenceType::Daily); + $r1->setCount(5); + + $r2 = new Recurrence($this->date('2026-01-01')); + $r2->setType(RecurrenceType::Daily); + $r2->setCount(10); + + $this->assertFalse($r1->isEqual($r2)); + } + + public function testIsEqualDifferentDayMask(): void + { + $r1 = new Recurrence($this->date('2026-01-05')); + $r1->setType(RecurrenceType::Weekly); + $r1->setDayMask(DayMask::MONDAY); + + $r2 = new Recurrence($this->date('2026-01-05')); + $r2->setType(RecurrenceType::Weekly); + $r2->setDayMask(DayMask::FRIDAY); + + $this->assertFalse($r1->isEqual($r2)); + } +} diff --git a/test/Unit/Recurrence/RecurrenceTypeTest.php b/test/Unit/Recurrence/RecurrenceTypeTest.php new file mode 100644 index 0000000..f60fa10 --- /dev/null +++ b/test/Unit/Recurrence/RecurrenceTypeTest.php @@ -0,0 +1,106 @@ +assertSame($expectedValue, $case->value); + } + + public static function backedValueProvider(): array + { + return [ + 'None' => [RecurrenceType::None, 0], + 'Daily' => [RecurrenceType::Daily, 1], + 'Weekly' => [RecurrenceType::Weekly, 2], + 'MonthlyDate' => [RecurrenceType::MonthlyDate, 3], + 'MonthlyWeekday' => [RecurrenceType::MonthlyWeekday, 4], + 'YearlyDate' => [RecurrenceType::YearlyDate, 5], + 'YearlyDay' => [RecurrenceType::YearlyDay, 6], + 'YearlyWeekday' => [RecurrenceType::YearlyWeekday, 7], + 'MonthlyLastWeekday' => [RecurrenceType::MonthlyLastWeekday, 8], + ]; + } + + // ========================================================================= + // labels + // ========================================================================= + + #[DataProvider('labelProvider')] + public function testLabel(RecurrenceType $case, string $expectedLabel): void + { + $this->assertSame($expectedLabel, $case->label()); + } + + public static function labelProvider(): array + { + return [ + 'None' => [RecurrenceType::None, 'None'], + 'Daily' => [RecurrenceType::Daily, 'Daily'], + 'Weekly' => [RecurrenceType::Weekly, 'Weekly'], + 'MonthlyDate' => [RecurrenceType::MonthlyDate, 'Monthly (date)'], + 'MonthlyWeekday' => [RecurrenceType::MonthlyWeekday, 'Monthly (weekday)'], + 'YearlyDate' => [RecurrenceType::YearlyDate, 'Yearly (date)'], + 'YearlyDay' => [RecurrenceType::YearlyDay, 'Yearly (day)'], + 'YearlyWeekday' => [RecurrenceType::YearlyWeekday, 'Yearly (weekday)'], + 'MonthlyLastWeekday' => [RecurrenceType::MonthlyLastWeekday, 'Monthly (last weekday)'], + ]; + } + + // ========================================================================= + // fromLegacy + // ========================================================================= + + public function testFromLegacyValidValues(): void + { + for ($i = 0; $i <= 8; $i++) { + $type = RecurrenceType::fromLegacy($i); + $this->assertSame($i, $type->value); + } + } + + public function testFromLegacyThrowsOnInvalid(): void + { + $this->expectException(ValueError::class); + RecurrenceType::fromLegacy(99); + } + + // ========================================================================= + // tryFrom + // ========================================================================= + + public function testTryFromReturnsNullOnInvalid(): void + { + $this->assertNull(RecurrenceType::tryFrom(99)); + } + + public function testTryFromReturnsCase(): void + { + $this->assertSame(RecurrenceType::Daily, RecurrenceType::tryFrom(1)); + } + + // ========================================================================= + // cases count + // ========================================================================= + + public function testCasesCount(): void + { + $this->assertCount(9, RecurrenceType::cases()); + } +} diff --git a/test/Unit/TimezoneInfoTest.php b/test/Unit/TimezoneInfoTest.php new file mode 100644 index 0000000..15a4efd --- /dev/null +++ b/test/Unit/TimezoneInfoTest.php @@ -0,0 +1,74 @@ +assertSame('America/New_York', $info->getIanaName()); + $this->assertSame('', $info->getOriginalAlias()); + } + + public function testConstructWithAlias(): void + { + $info = new TimezoneInfo('America/New_York', 'Eastern Standard Time'); + $this->assertSame('America/New_York', $info->getIanaName()); + $this->assertSame('Eastern Standard Time', $info->getOriginalAlias()); + } + + public function testConstructWithEmptyAlias(): void + { + $info = new TimezoneInfo('UTC', ''); + $this->assertSame('UTC', $info->getIanaName()); + $this->assertSame('', $info->getOriginalAlias()); + } + + public function testIsAliasReturnsTrueWhenDifferent(): void + { + $info = new TimezoneInfo('America/New_York', 'Eastern Standard Time'); + $this->assertTrue($info->isAlias()); + } + + public function testIsAliasReturnsFalseWhenEmpty(): void + { + $info = new TimezoneInfo('America/New_York'); + $this->assertFalse($info->isAlias()); + } + + public function testIsAliasReturnsFalseWhenSameAsIana(): void + { + $info = new TimezoneInfo('America/New_York', 'America/New_York'); + $this->assertFalse($info->isAlias()); + } + + public function testToStringReturnsIanaName(): void + { + $info = new TimezoneInfo('Europe/Berlin', 'W. Europe Standard Time'); + $this->assertSame('Europe/Berlin', (string) $info); + } + + public function testToDateTimeZone(): void + { + $info = new TimezoneInfo('America/Chicago'); + $tz = $info->toDateTimeZone(); + $this->assertInstanceOf(DateTimeZone::class, $tz); + $this->assertSame('America/Chicago', $tz->getName()); + } + + public function testToDateTimeZoneWithAlias(): void + { + $info = new TimezoneInfo('Asia/Tokyo', 'Japan'); + $tz = $info->toDateTimeZone(); + $this->assertSame('Asia/Tokyo', $tz->getName()); + } +} diff --git a/test/Unit/TimezoneMapperTest.php b/test/Unit/TimezoneMapperTest.php new file mode 100644 index 0000000..f398183 --- /dev/null +++ b/test/Unit/TimezoneMapperTest.php @@ -0,0 +1,307 @@ +assertSame('America/New_York', $info->getIanaName()); + $this->assertFalse($info->isAlias()); + } + + public function testIanaPassthroughUtc(): void + { + $info = TimezoneMapper::resolve('UTC'); + $this->assertSame('UTC', $info->getIanaName()); + $this->assertFalse($info->isAlias()); + } + + public function testIanaPassthroughEtcGmt(): void + { + $info = TimezoneMapper::resolve('Etc/GMT'); + $this->assertSame('Etc/GMT', $info->getIanaName()); + $this->assertFalse($info->isAlias()); + } + + // ========================================================================= + // Windows timezone aliases + // ========================================================================= + + #[DataProvider('windowsAliasProvider')] + public function testWindowsAlias(string $windows, string $expectedIana): void + { + $info = TimezoneMapper::resolve($windows); + $this->assertSame($expectedIana, $info->getIanaName()); + $this->assertTrue($info->isAlias()); + $this->assertSame($windows, $info->getOriginalAlias()); + } + + public static function windowsAliasProvider(): array + { + return [ + 'Eastern' => ['Eastern Standard Time', 'America/New_York'], + 'Pacific' => ['Pacific Standard Time', 'America/Los_Angeles'], + 'Central' => ['Central Standard Time', 'America/Chicago'], + 'Mountain' => ['Mountain Standard Time', 'America/Denver'], + 'GMT' => ['GMT Standard Time', 'Europe/London'], + 'Romance' => ['Romance Standard Time', 'Europe/Paris'], + 'W. Europe' => ['W. Europe Standard Time', 'Europe/Berlin'], + 'Tokyo' => ['Tokyo Standard Time', 'Asia/Tokyo'], + 'China' => ['China Standard Time', 'Asia/Shanghai'], + 'India' => ['India Standard Time', 'Asia/Calcutta'], + 'Korea' => ['Korea Standard Time', 'Asia/Seoul'], + 'AUS Eastern' => ['AUS Eastern Standard Time', 'Australia/Sydney'], + 'New Zealand' => ['New Zealand Standard Time', 'Pacific/Auckland'], + 'Russian' => ['Russian Standard Time', 'Europe/Moscow'], + 'SA Pacific' => ['SA Pacific Standard Time', 'America/Bogota'], + 'Hawaiian' => ['Hawaiian Standard Time', 'Pacific/Honolulu'], + 'Alaskan' => ['Alaskan Standard Time', 'America/Anchorage'], + 'Iran' => ['Iran Standard Time', 'Asia/Tehran'], + 'Israel' => ['Israel Standard Time', 'Asia/Jerusalem'], + 'Turkey' => ['Turkey Standard Time', 'Europe/Istanbul'], + ]; + } + + // ========================================================================= + // Old Olson names + // ========================================================================= + + #[DataProvider('oldOlsonProvider')] + public function testOldOlsonAlias(string $old, string $expectedIana): void + { + $info = TimezoneMapper::resolve($old); + $this->assertSame($expectedIana, $info->getIanaName()); + } + + public static function oldOlsonProvider(): array + { + return [ + 'Asia/Calcutta' => ['Asia/Calcutta', 'Asia/Kolkata'], + 'Asia/Saigon' => ['Asia/Saigon', 'Asia/Ho_Chi_Minh'], + 'Asia/Katmandu' => ['Asia/Katmandu', 'Asia/Kathmandu'], + 'Asia/Rangoon' => ['Asia/Rangoon', 'Asia/Yangon'], + 'Europe/Kiev' => ['Europe/Kiev', 'Europe/Kyiv'], + 'America/Godthab' => ['America/Godthab', 'America/Nuuk'], + 'America/Montreal' => ['America/Montreal', 'America/Toronto'], + 'America/Indianapolis' => ['America/Indianapolis', 'America/Indiana/Indianapolis'], + 'Pacific/Ponape' => ['Pacific/Ponape', 'Pacific/Pohnpei'], + 'Pacific/Truk' => ['Pacific/Truk', 'Pacific/Chuuk'], + 'Asia/Ulan_Bator' => ['Asia/Ulan_Bator', 'Asia/Ulaanbaatar'], + 'Atlantic/Faeroe' => ['Atlantic/Faeroe', 'Atlantic/Faroe'], + 'Africa/Asmera' => ['Africa/Asmera', 'Africa/Nairobi'], + 'America/Buenos_Aires' => ['America/Buenos_Aires', 'America/Argentina/Buenos_Aires'], + 'Asia/Dacca' => ['Asia/Dacca', 'Asia/Dhaka'], + ]; + } + + // ========================================================================= + // Country shorthand aliases + // ========================================================================= + + #[DataProvider('countryShorthandProvider')] + public function testCountryShorthand(string $shorthand, string $expectedIana): void + { + $info = TimezoneMapper::resolve($shorthand); + $this->assertSame($expectedIana, $info->getIanaName()); + } + + public static function countryShorthandProvider(): array + { + return [ + 'Cuba' => ['Cuba', 'America/Havana'], + 'Egypt' => ['Egypt', 'Africa/Cairo'], + 'Eire' => ['Eire', 'Europe/Dublin'], + 'GB' => ['GB', 'Europe/London'], + 'GB-Eire' => ['GB-Eire', 'Europe/London'], + 'Hongkong' => ['Hongkong', 'Asia/Hong_Kong'], + 'Iceland' => ['Iceland', 'Atlantic/Reykjavik'], + 'Iran' => ['Iran', 'Asia/Tehran'], + 'Israel' => ['Israel', 'Asia/Jerusalem'], + 'Jamaica' => ['Jamaica', 'America/Jamaica'], + 'Japan' => ['Japan', 'Asia/Tokyo'], + 'Kwajalein' => ['Kwajalein', 'Pacific/Kwajalein'], + 'Libya' => ['Libya', 'Africa/Tripoli'], + 'NZ' => ['NZ', 'Pacific/Auckland'], + 'Poland' => ['Poland', 'Europe/Warsaw'], + 'Portugal' => ['Portugal', 'Europe/Lisbon'], + 'Singapore' => ['Singapore', 'Asia/Singapore'], + 'Turkey' => ['Turkey', 'Europe/Istanbul'], + ]; + } + + // ========================================================================= + // Lotus Notes aliases + // ========================================================================= + + public function testLotusNotesWEurope(): void + { + $info = TimezoneMapper::resolve('W. Europe'); + $this->assertSame('Europe/Berlin', $info->getIanaName()); + } + + public function testLotusNotesEEurope(): void + { + $info = TimezoneMapper::resolve('E. Europe'); + $this->assertSame('Asia/Nicosia', $info->getIanaName()); + } + + // ========================================================================= + // POSIX abbreviation aliases + // ========================================================================= + + #[DataProvider('posixAbbreviationProvider')] + public function testPosixAbbreviation(string $abbr, string $expectedIana): void + { + $info = TimezoneMapper::resolve($abbr); + $this->assertSame($expectedIana, $info->getIanaName()); + } + + public static function posixAbbreviationProvider(): array + { + return [ + 'CET' => ['CET', 'Europe/Berlin'], + 'CST6CDT' => ['CST6CDT', 'America/Chicago'], + 'EET' => ['EET', 'Europe/Athens'], + 'EST' => ['EST', 'America/Panama'], + 'EST5EDT' => ['EST5EDT', 'America/New_York'], + 'MET' => ['MET', 'Europe/Berlin'], + 'MST' => ['MST', 'America/Phoenix'], + 'MST7MDT' => ['MST7MDT', 'America/Denver'], + 'PST8PDT' => ['PST8PDT', 'America/Los_Angeles'], + 'WET' => ['WET', 'Europe/Lisbon'], + ]; + } + + // ========================================================================= + // Special UTC-like aliases + // ========================================================================= + + public function testZuluResolvesToUtc(): void + { + $this->assertSame('UTC', TimezoneMapper::toIana('Zulu')); + } + + public function testUniversalResolvesToUtc(): void + { + $this->assertSame('UTC', TimezoneMapper::toIana('Universal')); + } + + public function testGmt0ResolvesToEtcGmt(): void + { + $this->assertSame('Etc/GMT', TimezoneMapper::toIana('GMT0')); + } + + public function testGreenwich(): void + { + $this->assertSame('Etc/GMT', TimezoneMapper::toIana('Greenwich')); + } + + // ========================================================================= + // Abbreviation fallback via DateTimeZone::listAbbreviations() + // ========================================================================= + + public function testAbbreviationFallbackCet(): void + { + $info = TimezoneMapper::resolve('cet'); + $this->assertInstanceOf(TimezoneInfo::class, $info); + $this->assertNotEmpty($info->getIanaName()); + } + + // ========================================================================= + // Convenience methods + // ========================================================================= + + public function testToIanaReturnsString(): void + { + $this->assertSame('America/New_York', TimezoneMapper::toIana('Eastern Standard Time')); + } + + public function testIsAliasTrue(): void + { + $this->assertTrue(TimezoneMapper::isAlias('Eastern Standard Time')); + } + + public function testIsAliasFalseForIana(): void + { + $this->assertFalse(TimezoneMapper::isAlias('America/New_York')); + } + + // ========================================================================= + // Runtime aliases + // ========================================================================= + + public function testAddAliases(): void + { + TimezoneMapper::addAliases(['MyCustomTZ' => 'Europe/Berlin']); + $this->assertSame('Europe/Berlin', TimezoneMapper::toIana('MyCustomTZ')); + } + + public function testRuntimeAliasOverridesBuiltIn(): void + { + TimezoneMapper::addAliases(['Zulu' => 'Europe/Paris']); + $this->assertSame('Europe/Paris', TimezoneMapper::toIana('Zulu')); + } + + public function testGetAliasesIncludesRuntime(): void + { + TimezoneMapper::addAliases(['TestZone' => 'Asia/Tokyo']); + $all = TimezoneMapper::getAliases(); + $this->assertArrayHasKey('TestZone', $all); + $this->assertSame('Asia/Tokyo', $all['TestZone']); + } + + public function testGetAliasesIncludesBuiltIn(): void + { + $all = TimezoneMapper::getAliases(); + $this->assertArrayHasKey('Eastern Standard Time', $all); + } + + public function testResetRuntimeAliases(): void + { + TimezoneMapper::addAliases(['TestZone' => 'Asia/Tokyo']); + TimezoneMapper::resetRuntimeAliases(); + $all = TimezoneMapper::getAliases(); + $this->assertArrayNotHasKey('TestZone', $all); + } + + // ========================================================================= + // Unknown timezone passthrough + // ========================================================================= + + public function testUnknownTimezonePassthrough(): void + { + $info = TimezoneMapper::resolve('Completely/Unknown/Zone'); + $this->assertSame('Completely/Unknown/Zone', $info->getIanaName()); + $this->assertFalse($info->isAlias()); + } + + // ========================================================================= + // resolve() returns TimezoneInfo + // ========================================================================= + + public function testResolveReturnsTimezoneInfo(): void + { + $info = TimezoneMapper::resolve('Pacific Standard Time'); + $this->assertInstanceOf(TimezoneInfo::class, $info); + } +} diff --git a/test/Unit/UtilsTest.php b/test/Unit/UtilsTest.php new file mode 100644 index 0000000..0a1b3b5 --- /dev/null +++ b/test/Unit/UtilsTest.php @@ -0,0 +1,207 @@ +assertSame($expected, Utils::isLeapYear($year), "isLeapYear($year)"); + } + + public static function leapYearProvider(): array + { + return [ + '4 is leap' => [4, true], + '100 is not leap' => [100, false], + '400 is leap' => [400, true], + '1900 is not leap' => [1900, false], + '1996 is leap' => [1996, true], + '1997 is not leap' => [1997, false], + '2000 is leap (÷400)' => [2000, true], + '2024 is leap' => [2024, true], + '2025 is not leap' => [2025, false], + '2100 is not leap' => [2100, false], + '2400 is leap (÷400)' => [2400, true], + ]; + } + + // ========================================================================= + // daysInMonth() + // ========================================================================= + + #[DataProvider('daysInMonthProvider')] + public function testDaysInMonth(int $month, int $year, int $expected): void + { + $this->assertSame($expected, Utils::daysInMonth($month, $year), "daysInMonth($month, $year)"); + } + + public static function daysInMonthProvider(): array + { + return [ + 'Jan' => [1, 2026, 31], + 'Feb non-leap' => [2, 2025, 28], + 'Feb leap' => [2, 2024, 29], + 'Feb 1900' => [2, 1900, 28], + 'Feb 2000' => [2, 2000, 29], + 'Mar' => [3, 2026, 31], + 'Apr' => [4, 2026, 30], + 'May' => [5, 2026, 31], + 'Jun' => [6, 2026, 30], + 'Jul' => [7, 2026, 31], + 'Aug' => [8, 2026, 31], + 'Sep' => [9, 2026, 30], + 'Oct' => [10, 2026, 31], + 'Nov' => [11, 2026, 30], + 'Dec' => [12, 2026, 31], + ]; + } + + public function testDaysInMonthReturnsInt(): void + { + $result = Utils::daysInMonth(1, 2026); + $this->assertIsInt($result); + } + + public function testDaysInMonthInvalidThrows(): void + { + $this->expectException(DateException::class); + Utils::daysInMonth(13, 2026); + } + + // ========================================================================= + // firstDayOfWeek() + // ========================================================================= + + #[DataProvider('firstDayOfWeekProvider')] + public function testFirstDayOfWeek(int $week, int $year, string $expectedDate): void + { + $date = Utils::firstDayOfWeek($week, $year); + $this->assertSame($expectedDate, $date->format('Y-m-d'), "firstDayOfWeek($week, $year)"); + } + + public static function firstDayOfWeekProvider(): array + { + return [ + 'W01 2006' => [1, 2006, '2006-01-02'], + 'W01 2007' => [1, 2007, '2007-01-01'], + 'W01 2008' => [1, 2008, '2007-12-31'], + 'W01 2010' => [1, 2010, '2010-01-04'], + 'W01 2024' => [1, 2024, '2024-01-01'], + 'W01 2026' => [1, 2026, '2025-12-29'], + 'W53 2020' => [53, 2020, '2020-12-28'], + ]; + } + + public function testFirstDayOfWeekReturnsDate(): void + { + $this->assertInstanceOf(Date::class, Utils::firstDayOfWeek(1, 2026)); + } + + public function testFirstDayOfWeekIsAlwaysMonday(): void + { + foreach ([1, 10, 26, 40, 52] as $week) { + $date = Utils::firstDayOfWeek($week, 2026); + $this->assertSame( + Date::MONDAY, + $date->dayOfWeek(), + "Week $week of 2026 should start on Monday" + ); + } + } + + // ========================================================================= + // strftime2date() + // ========================================================================= + + #[DataProvider('strftime2dateProvider')] + public function testStrftime2date(string $strftimeFormat, string $expectedDateFormat): void + { + $this->assertSame($expectedDateFormat, Utils::strftime2date($strftimeFormat)); + } + + public static function strftime2dateProvider(): array + { + return [ + '%Y → Y' => ['%Y', 'Y'], + '%m → m' => ['%m', 'm'], + '%d → d' => ['%d', 'd'], + '%H → H' => ['%H', 'H'], + '%M → i' => ['%M', 'i'], + '%S → s' => ['%S', 's'], + '%A → l' => ['%A', 'l'], + '%a → D' => ['%a', 'D'], + '%B → F' => ['%B', 'F'], + '%b → M' => ['%b', 'M'], + '%h → M' => ['%h', 'M'], + '%e → j' => ['%e', 'j'], + '%I → h' => ['%I', 'h'], + '%p → A' => ['%p', 'A'], + '%P → a' => ['%P', 'a'], + '%F → Y-m-d' => ['%F', 'Y-m-d'], + '%T → H:i:s' => ['%T', 'H:i:s'], + '%R → H:i' => ['%R', 'H:i'], + '%V → W' => ['%V', 'W'], + '%n → newline' => ['%n', "\n"], + '%t → tab' => ['%t', "\t"], + '%% → %' => ['%%', '%'], + '%s → U' => ['%s', 'U'], + '%D → m/d/y' => ['%D', 'm/d/y'], + '%r → time12' => ['%r', 'h:i:s A'], + '%z → O' => ['%z', 'O'], + ]; + } + + public function testStrftime2dateUnsupportedDropped(): void + { + $this->assertSame('', Utils::strftime2date('%U')); + $this->assertSame('', Utils::strftime2date('%W')); + $this->assertSame('', Utils::strftime2date('%C')); + $this->assertSame('', Utils::strftime2date('%Z')); + } + + public function testStrftime2dateLiteralPreserved(): void + { + $this->assertSame('Date: Y/m/d', Utils::strftime2date('Date: %Y/%m/%d')); + } + + public function testStrftime2dateWithLocaleProvider(): void + { + $provider = function (int $constant): string { + return match ($constant) { + D_FMT => 'd/m/Y', + T_FMT => 'H:i', + default => '', + }; + }; + + $this->assertSame('d/m/Y', Utils::strftime2date('%x', $provider)); + $this->assertSame('H:i', Utils::strftime2date('%X', $provider)); + } + + public function testStrftime2dateDefaultLocale(): void + { + $this->assertSame('m/d/Y', Utils::strftime2date('%x')); + $this->assertSame('H:i:s', Utils::strftime2date('%X')); + } + + public function testStrftime2dateComposite(): void + { + $this->assertSame('Y-m-d H:i:s', Utils::strftime2date('%Y-%m-%d %H:%M:%S')); + } +} diff --git a/test/Unnamespaced/DateCalcTest.php b/test/Unnamespaced/DateCalcTest.php index 8481e17..5215e0a 100644 --- a/test/Unnamespaced/DateCalcTest.php +++ b/test/Unnamespaced/DateCalcTest.php @@ -57,11 +57,6 @@ public static function toDaysProvider(): array #[DataProvider('toDaysProvider')] public function testFromDaysRoundTrip(int $year, int $month, int $day): void { - if (function_exists('jdtogregorian')) { - $this->markTestSkipped( - 'fromDays() bug: jdtogregorian returns strings which fail the 3-arg constructor string check' - ); - } $original = new Horde_Date(['year' => $year, 'month' => $month, 'mday' => $day]); $jd = $original->toDays(); $restored = Horde_Date::fromDays($jd); @@ -71,15 +66,6 @@ public function testFromDaysRoundTrip(int $year, int $month, int $day): void $this->assertSame($day, $restored->mday, "Day mismatch for JD $jd"); } - public function testFromDaysStringYearBug(): void - { - if (!function_exists('jdtogregorian')) { - $this->markTestSkipped('Bug only manifests when jdtogregorian is available'); - } - $this->expectException(\Horde_Date_Exception::class); - Horde_Date::fromDays(gregoriantojd(1, 1, 1970)); - } - public function testToDaysConsecutiveDaysAreConsecutiveJd(): void { $d1 = new Horde_Date('2026-03-15'); @@ -130,7 +116,7 @@ public function testDayOfWeekMatchesDateTime(string $dateStr): void $nativeDate = new DateTime($dateStr, new DateTimeZone('UTC')); $this->assertSame( - (int)$nativeDate->format('w'), + (int) $nativeDate->format('w'), $hordeDate->dayOfWeek(), "dayOfWeek() mismatch for $dateStr" ); @@ -181,7 +167,7 @@ public function testDayOfWeekConstants(): void public function testWeekOfMonth(int $day, int $expectedWeek): void { $date = new Horde_Date(['year' => 2026, 'month' => 4, 'mday' => $day]); - $this->assertSame($expectedWeek, (int)$date->weekOfMonth()); + $this->assertSame($expectedWeek, (int) $date->weekOfMonth()); } public static function weekOfMonthProvider(): array @@ -204,7 +190,7 @@ public static function weekOfMonthProvider(): array public function testWeekOfMonthDay31(): void { $date = new Horde_Date(['year' => 2026, 'month' => 1, 'mday' => 31]); - $this->assertSame(5, (int)$date->weekOfMonth()); + $this->assertSame(5, (int) $date->weekOfMonth()); } // ========================================================================= @@ -217,7 +203,7 @@ public function testWeekOfYearMatchesDateTime(string $dateStr, int $expectedWeek $date = new Horde_Date($dateStr); $this->assertSame( $expectedWeek, - (int)$date->weekOfYear(), + (int) $date->weekOfYear(), "weekOfYear() mismatch for $dateStr" ); } @@ -242,7 +228,7 @@ public function testWeeksInYear(int $year, int $expectedWeeks): void { $this->assertSame( $expectedWeeks, - (int)Horde_Date::weeksInYear($year), + (int) Horde_Date::weeksInYear($year), "weeksInYear() mismatch for year $year" ); } diff --git a/test/Unnamespaced/DateCorrectionTest.php b/test/Unnamespaced/DateCorrectionTest.php index ad939b7..df7fd73 100644 --- a/test/Unnamespaced/DateCorrectionTest.php +++ b/test/Unnamespaced/DateCorrectionTest.php @@ -272,15 +272,11 @@ public function testAddMonthFromJan31ToFebNonLeapYear(): void public function testAddYearFromFeb29(): void { - // add(['year' => 1]) only triggers MASK_YEAR correction, which does not - // cascade to day. Internal state keeps month=2, mday=29 even though - // 2025 is not a leap year. format() normalizes via DateTime. $date = new Horde_Date('2024-02-29 12:00:00'); $result = $date->add(['year' => 1]); $this->assertSame(2025, $result->year); - $this->assertSame(2, $result->month); - $this->assertSame(29, $result->mday); - $this->assertSame('2025-03-01', $result->format('Y-m-d')); + $this->assertSame(3, $result->month); + $this->assertSame(1, $result->mday); } // ========================================================================= diff --git a/test/Unnamespaced/DateEjectionTest.php b/test/Unnamespaced/DateEjectionTest.php new file mode 100644 index 0000000..ce33047 --- /dev/null +++ b/test/Unnamespaced/DateEjectionTest.php @@ -0,0 +1,109 @@ +oldTimezone = date_default_timezone_get(); + date_default_timezone_set('UTC'); + } + + protected function tearDown(): void + { + date_default_timezone_set($this->oldTimezone); + } + + public function testToDateTimeImmutable(): void + { + $legacy = new Horde_Date('2026-04-17 14:30:00', 'UTC'); + $immutable = $legacy->toDateTimeImmutable(); + + $this->assertInstanceOf(DateTimeImmutable::class, $immutable); + $this->assertSame('2026', $immutable->format('Y')); + $this->assertSame('04', $immutable->format('m')); + $this->assertSame('17', $immutable->format('d')); + $this->assertSame('14', $immutable->format('H')); + $this->assertSame('30', $immutable->format('i')); + $this->assertSame('00', $immutable->format('s')); + } + + public function testToDate(): void + { + $legacy = new Horde_Date('2026-04-17 14:30:00', 'UTC'); + $modern = $legacy->toDate(); + + $this->assertInstanceOf(Date::class, $modern); + $this->assertInstanceOf(DateInterface::class, $modern); + $this->assertSame('2026', $modern->format('Y')); + $this->assertSame('04', $modern->format('m')); + $this->assertSame('17', $modern->format('d')); + $this->assertSame('14', $modern->format('H')); + $this->assertSame('30', $modern->format('i')); + $this->assertSame('00', $modern->format('s')); + } + + public function testToDatePreservesTimezone(): void + { + $legacy = new Horde_Date('2026-04-17 14:30:00', 'America/New_York'); + $modern = $legacy->toDate(); + + $this->assertSame('America/New_York', $modern->getTimezone()->getName()); + $this->assertSame('14', $modern->format('H')); + } + + public function testToDatePreservesDateTime(): void + { + $legacy = new Horde_Date('2026-12-31 23:59:59', 'Asia/Tokyo'); + $modern = $legacy->toDate(); + + $this->assertSame('2026-12-31 23:59:59', $modern->format('Y-m-d H:i:s')); + $this->assertSame('Asia/Tokyo', $modern->getTimezone()->getName()); + } + + public function testToDateRoundTrip(): void + { + $legacy = new Horde_Date('2026-04-17 14:30:45', 'Europe/Berlin'); + $modern = $legacy->toDate(); + + $this->assertSame( + $legacy->format('Y-m-d H:i:s'), + $modern->format('Y-m-d H:i:s') + ); + } + + public function testGetTimezone(): void + { + $legacy = new Horde_Date('2026-04-17 14:30:00', 'Europe/Berlin'); + $tz = $legacy->getTimezone(); + + $this->assertInstanceOf(DateTimeZone::class, $tz); + $this->assertSame('Europe/Berlin', $tz->getName()); + } + + public function testGetTimezoneUtc(): void + { + $legacy = new Horde_Date('2026-04-17 14:30:00', 'UTC'); + $tz = $legacy->getTimezone(); + + $this->assertSame('UTC', $tz->getName()); + } + + public function testToDateTimeImmutablePreservesTimezone(): void + { + $legacy = new Horde_Date('2026-04-17 14:30:00', 'Asia/Tokyo'); + $immutable = $legacy->toDateTimeImmutable(); + + $this->assertSame('Asia/Tokyo', $immutable->getTimezone()->getName()); + $this->assertSame('14', $immutable->format('H')); + } +} diff --git a/test/Unnamespaced/DateFormatTest.php b/test/Unnamespaced/DateFormatTest.php index cdb14b8..96f4081 100644 --- a/test/Unnamespaced/DateFormatTest.php +++ b/test/Unnamespaced/DateFormatTest.php @@ -16,6 +16,8 @@ use Horde_Date; use PHPUnit\Framework\TestCase; +use DateTime; +use DateTimeZone; /** * Tests for Horde_Date::format() method @@ -23,6 +25,7 @@ * @category Horde * @package Date * @subpackage UnitTests + * @coversNothing */ class DateFormatTest extends TestCase { @@ -273,11 +276,11 @@ public function testAllSingleLetterSpecifiers(): void // Just verify they all return non-empty strings (except special cases) $specifiers = ['d', 'D', 'j', 'l', 'N', 'S', 'w', 'z', - 'W', 'F', 'm', 'M', 'n', 't', - 'o', 'Y', 'y', - 'a', 'A', 'B', 'g', 'G', 'h', 'H', 'i', 's', 'u', 'v', - 'e', 'O', 'P', 'T', - 'c', 'r', 'U']; + 'W', 'F', 'm', 'M', 'n', 't', + 'o', 'Y', 'y', + 'a', 'A', 'B', 'g', 'G', 'h', 'H', 'i', 's', 'u', 'v', + 'e', 'O', 'P', 'T', + 'c', 'r', 'U']; foreach ($specifiers as $spec) { $result = $date->format($spec); @@ -328,7 +331,7 @@ public function testConsistencyWithDateTime(): void { $dateString = '2026-03-18 14:30:45'; $hordeDate = new Horde_Date($dateString, 'UTC'); - $phpDate = new \DateTime($dateString, new \DateTimeZone('UTC')); + $phpDate = new DateTime($dateString, new DateTimeZone('UTC')); // Should format identically to DateTime $this->assertEquals($phpDate->format('Y-m-d'), $hordeDate->format('Y-m-d')); diff --git a/test/Unnamespaced/DatePluggableFormatterTest.php b/test/Unnamespaced/DatePluggableFormatterTest.php index f7f81d2..58e8454 100644 --- a/test/Unnamespaced/DatePluggableFormatterTest.php +++ b/test/Unnamespaced/DatePluggableFormatterTest.php @@ -18,6 +18,9 @@ use Horde\Date\Formatter\IcuFormatter; use Horde_Date; use PHPUnit\Framework\TestCase; +use InvalidArgumentException; +use Stringable; +use stdClass; /** * Tests for Horde_Date::format() with pluggable formatters @@ -25,6 +28,7 @@ * @category Horde * @package Date * @subpackage UnitTests + * @coversNothing */ class DatePluggableFormatterTest extends TestCase { @@ -212,7 +216,7 @@ public function testTimezoneFromConstructor(): void */ public function testInvalidFormatterClassThrowsException(): void { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Formatter class not found'); $date = new Horde_Date('2026-03-18 14:30:45'); @@ -224,11 +228,11 @@ public function testInvalidFormatterClassThrowsException(): void */ public function testFormatterNotImplementingInterfaceThrowsException(): void { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Formatter must implement FormatterInterface'); $date = new Horde_Date('2026-03-18 14:30:45'); - $date->format('Y-m-d', new \stdClass()); + $date->format('Y-m-d', new stdClass()); } /** @@ -239,7 +243,7 @@ public function testStringablePattern(): void $date = new Horde_Date('2026-03-18 14:30:45'); // Create a simple Stringable - $pattern = new class () implements \Stringable { + $pattern = new class implements Stringable { public function __toString(): string { return 'yyyy-MM-dd'; diff --git a/test/Unnamespaced/DateTest.php b/test/Unnamespaced/DateTest.php index 4ba97dd..297dc20 100644 --- a/test/Unnamespaced/DateTest.php +++ b/test/Unnamespaced/DateTest.php @@ -15,16 +15,17 @@ use DateTimeZone; use Horde_Date; use Horde_Date_Span; - -use function PHP81_BC\strftime; - use PHPUnit\Framework\TestCase; use stdClass; +use Horde_Date_Exception; + +use function PHP81_BC\strftime; /** * @category Horde * @package Date * @subpackage UnitTests + * @coversNothing */ class DateTest extends TestCase { @@ -51,16 +52,16 @@ public function testConstructor() $date->min = 5; $date->sec = 6; - $this->assertEquals('2001-02-03 04:05:06', (string)new Horde_Date($date)); - $this->assertEquals('2001-02-03 04:05:06', (string)new Horde_Date((array)$date)); - $this->assertEquals('2001-02-03 04:05:06', (string)new Horde_Date(['year' => 2001, 'month' => 2, 'day' => 3, 'hour' => 4, 'minute' => 5, 'sec' => 6])); - $this->assertEquals('2001-02-03 04:05:06', (string)new Horde_Date('20010203040506')); - $this->assertEquals('2001-02-03 04:05:06', (string)new Horde_Date('20010203T040506Z')); - $this->assertEquals('2001-02-03 04:05:06', (string)new Horde_Date('2001-02-03 04:05:06')); - $this->assertEquals('2001-02-03 04:05:06', (string)new Horde_Date(981169506)); + $this->assertEquals('2001-02-03 04:05:06', (string) new Horde_Date($date)); + $this->assertEquals('2001-02-03 04:05:06', (string) new Horde_Date((array) $date)); + $this->assertEquals('2001-02-03 04:05:06', (string) new Horde_Date(['year' => 2001, 'month' => 2, 'day' => 3, 'hour' => 4, 'minute' => 5, 'sec' => 6])); + $this->assertEquals('2001-02-03 04:05:06', (string) new Horde_Date('20010203040506')); + $this->assertEquals('2001-02-03 04:05:06', (string) new Horde_Date('20010203T040506Z')); + $this->assertEquals('2001-02-03 04:05:06', (string) new Horde_Date('2001-02-03 04:05:06')); + $this->assertEquals('2001-02-03 04:05:06', (string) new Horde_Date(981169506)); $date = new Horde_Date('2011-11-08 14:54:00 +0000'); $date->setTimezone('UTC'); - $this->assertEquals('2011-11-08 14:54:00', (string)$date); + $this->assertEquals('2011-11-08 14:54:00', (string) $date); $date = new Horde_Date('20010203T040506Z'); $this->assertEquals('UTC', $date->timezone); @@ -68,22 +69,22 @@ public function testConstructor() $newDate = new Horde_Date($date); $this->assertEquals('America/New_York', $newDate->timezone); $newDate->setTimezone('UTC'); - $this->assertEquals('2001-02-03 04:05:06', (string)$newDate); + $this->assertEquals('2001-02-03 04:05:06', (string) $newDate); /* Test creating Horde_Date from DateTime with timezone explicitly set */ $dt = new DateTime('2011-12-10T04:05:06', new DateTimeZone('Europe/Berlin')); $dt->setTimezone(new DateTimeZone('UTC')); $date = new Horde_Date($dt); - $this->assertEquals('2011-12-10 03:05:06', (string)$date); + $this->assertEquals('2011-12-10 03:05:06', (string) $date); // Test creating Horde_Date from a string that will use DateTime // internally to parse the date. $date = new Horde_Date('2014-03-20 5:00PM'); - $this->assertEquals('2014-03-20 17:00:00', (string)$date); + $this->assertEquals('2014-03-20 17:00:00', (string) $date); $this->assertEquals('Europe/Berlin', $date->timezone); $date = new Horde_Date('2014-03-20 5:00PM', 'America/New_York'); - $this->assertEquals('2014-03-20 17:00:00', (string)$date); + $this->assertEquals('2014-03-20 17:00:00', (string) $date); $this->assertEquals('America/New_York', $date->timezone); } @@ -114,12 +115,12 @@ public function testTZChangeDuringTransition() // Once we pass the actual hour, it works $date = new Horde_Date('2011-11-06T07:00:00+0000'); $date->setTimezone('UTC'); - $this->assertEquals('2011-11-06 07:00:00', (string)$date); + $this->assertEquals('2011-11-06 07:00:00', (string) $date); // This one works $date = new Horde_Date('2011-03-13T07:00:00+0000'); $date->setTimezone('UTC'); - $this->assertEquals('2011-03-13 07:00:00', (string)$date); + $this->assertEquals('2011-03-13 07:00:00', (string) $date); date_default_timezone_set($oldtz); } @@ -333,27 +334,27 @@ public function testSetTimezone() date_default_timezone_set('America/New_York'); $date = new Horde_Date('20010203040506'); - $this->assertEquals('2001-02-03 04:05:06', (string)$date); + $this->assertEquals('2001-02-03 04:05:06', (string) $date); $date->setTimezone('Europe/Berlin'); - $this->assertEquals('2001-02-03 10:05:06', (string)$date); + $this->assertEquals('2001-02-03 10:05:06', (string) $date); $date = new Horde_Date('20010203040506', 'UTC'); - $this->assertEquals('2001-02-03 04:05:06', (string)$date); + $this->assertEquals('2001-02-03 04:05:06', (string) $date); $date->setTimezone('Europe/Berlin'); - $this->assertEquals('2001-02-03 05:05:06', (string)$date); + $this->assertEquals('2001-02-03 05:05:06', (string) $date); $date->setTimezone('W. Europe'); - $this->assertEquals('2001-02-03 05:05:06', (string)$date); + $this->assertEquals('2001-02-03 05:05:06', (string) $date); $date->setTimezone('CET'); - $this->assertEquals('2001-02-03 05:05:06', (string)$date); + $this->assertEquals('2001-02-03 05:05:06', (string) $date); $date = new Horde_Date('20010203040506', 'CET'); - $this->assertEquals('2001-02-03 04:05:06', (string)$date); + $this->assertEquals('2001-02-03 04:05:06', (string) $date); $date->setTimezone('Europe/Berlin'); - $this->assertEquals('2001-02-03 04:05:06', (string)$date); + $this->assertEquals('2001-02-03 04:05:06', (string) $date); date_default_timezone_set($oldTimezone); } @@ -362,12 +363,12 @@ public function testDateMath() { $d = new Horde_Date('2008-01-01 00:00:00'); - $this->assertEquals('2007-12-31 00:00:00', (string)$d->sub(['day' => 1])); - $this->assertEquals('2009-01-01 00:00:00', (string)$d->add(['year' => 1])); - $this->assertEquals('2008-01-01 04:00:00', (string)$d->add(14400)); + $this->assertEquals('2007-12-31 00:00:00', (string) $d->sub(['day' => 1])); + $this->assertEquals('2009-01-01 00:00:00', (string) $d->add(['year' => 1])); + $this->assertEquals('2008-01-01 04:00:00', (string) $d->add(14400)); $span = new Horde_Date_Span('2006-01-01 00:00:00', '2006-08-16 00:00:00'); - $this->assertEquals('2006-04-24 11:30:00', (string)$span->begin->add($span->width() / 2)); + $this->assertEquals('2006-04-24 11:30:00', (string) $span->begin->add($span->width() / 2)); } public function testSetNthWeekday() @@ -854,7 +855,7 @@ public function testInvalidYYYYMMDDPatterns(): void $date = new Horde_Date('19701399'); // If it doesn't throw, it was interpreted as something else $this->assertNotEquals(13, $date->month); // Month 13 is invalid - } catch (\Horde_Date_Exception $e) { + } catch (Horde_Date_Exception $e) { // Expected: DateTime rejects invalid month $this->assertStringContainsString('Failed to parse', $e->getMessage()); } @@ -864,7 +865,7 @@ public function testInvalidYYYYMMDDPatterns(): void try { $date = new Horde_Date('19700132'); $this->assertNotEquals(32, $date->mday); // Day 32 is invalid - } catch (\Horde_Date_Exception $e) { + } catch (Horde_Date_Exception $e) { // Expected: DateTime rejects invalid day $this->assertStringContainsString('Failed to parse', $e->getMessage()); } @@ -874,7 +875,7 @@ public function testInvalidYYYYMMDDPatterns(): void try { $date = new Horde_Date('19700001'); $this->assertNotEquals(0, $date->month); // Month 0 is invalid - } catch (\Horde_Date_Exception $e) { + } catch (Horde_Date_Exception $e) { // Expected: DateTime rejects invalid month $this->assertStringContainsString('Failed to parse', $e->getMessage()); } @@ -910,7 +911,7 @@ public function testLongNumericDateTimeStrings(): void $this->assertEquals(1, $date->month); $this->assertEquals(1, $date->mday); // Note: hour/minute may not be parsed correctly by DateTime - } catch (\Horde_Date_Exception $e) { + } catch (Horde_Date_Exception $e) { // DateTime may reject this format - that's acceptable $this->assertStringContainsString('Failed to parse', $e->getMessage()); } @@ -931,7 +932,7 @@ public function testNumericStringLengthBoundaries(): void $date = new Horde_Date('1970101'); // 7 digits // DateTime may parse this in unexpected ways or reject it $this->assertTrue(true); // Just document that it doesn't crash - } catch (\Horde_Date_Exception $e) { + } catch (Horde_Date_Exception $e) { // Rejection is acceptable $this->assertStringContainsString('Failed to parse', $e->getMessage()); } diff --git a/test/Unnamespaced/HordeLegacyDateTest.php b/test/Unnamespaced/HordeLegacyDateTest.php new file mode 100644 index 0000000..01ad7fb --- /dev/null +++ b/test/Unnamespaced/HordeLegacyDateTest.php @@ -0,0 +1,768 @@ +assertSame(2026, $d->year); + $this->assertSame(4, $d->month); + $this->assertSame(17, $d->mday); + $this->assertSame(10, $d->hour); + $this->assertSame(30, $d->min); + $this->assertSame(45, $d->sec); + } + + public function testConstructFromArray(): void + { + $d = new HordeLegacyDate(['year' => 2026, 'month' => 4, 'mday' => 17]); + $this->assertSame(2026, $d->year); + $this->assertSame(4, $d->month); + $this->assertSame(17, $d->mday); + $this->assertSame(0, $d->hour); + } + + public function testConstructFromTimestamp(): void + { + $ts = mktime(10, 30, 0, 4, 17, 2026); + $d = new HordeLegacyDate($ts); + $this->assertSame(2026, $d->year); + $this->assertSame(4, $d->month); + $this->assertSame(17, $d->mday); + } + + public function testConstructFromHordeDate(): void + { + $legacy = new Horde_Date('2026-04-17 10:30:00'); + $d = new HordeLegacyDate($legacy); + $this->assertSame(2026, $d->year); + $this->assertSame(10, $d->hour); + } + + public function testConstructFromDateTime(): void + { + $dt = new DateTime('2026-04-17 10:30:00', new DateTimeZone('UTC')); + $d = new HordeLegacyDate($dt); + $this->assertSame(2026, $d->year); + $this->assertSame('UTC', $d->timezone); + } + + public function testConstructWithTimezone(): void + { + $d = new HordeLegacyDate('2026-04-17 10:30:00', 'America/New_York'); + $this->assertSame('America/New_York', $d->timezone); + $this->assertSame(10, $d->hour); + } + + public function testConstructFromYYYYMMDD(): void + { + $d = new HordeLegacyDate('20260417'); + $this->assertSame(2026, $d->year); + $this->assertSame(4, $d->month); + $this->assertSame(17, $d->mday); + } + + public function testConstructNull(): void + { + $d = new HordeLegacyDate(null, 'UTC'); + $this->assertSame('UTC', $d->timezone); + // Matches legacy behavior: uninitialized fields format as -0001-11-30 + $legacy = new Horde_Date(null, 'UTC'); + $this->assertSame((string) $legacy, (string) $d); + } + + // ========================================================================= + // Magic properties: __get, __set, __isset + // ========================================================================= + + public function testDayAlias(): void + { + $d = new HordeLegacyDate('2026-04-17'); + $this->assertSame(17, $d->day); + $d->day = 20; + $this->assertSame(20, $d->mday); + } + + public function testIsset(): void + { + $d = new HordeLegacyDate('2026-04-17'); + $this->assertTrue(isset($d->year)); + $this->assertTrue(isset($d->month)); + $this->assertTrue(isset($d->mday)); + $this->assertTrue(isset($d->day)); + $this->assertTrue(isset($d->hour)); + $this->assertTrue(isset($d->min)); + $this->assertTrue(isset($d->sec)); + $this->assertFalse(isset($d->foo)); + } + + public function testGetUnknownProperty(): void + { + $d = new HordeLegacyDate('2026-04-17'); + $this->assertNull($d->nonexistent); + } + + public function testSetInvalidProperty(): void + { + $d = new HordeLegacyDate('2026-04-17'); + $this->expectException(InvalidArgumentException::class); + $d->foo = 42; + } + + // ========================================================================= + // Overflow normalization (replaces _correct with PHP date math) + // ========================================================================= + + public function testSecondOverflowPositive(): void + { + $d = new HordeLegacyDate('2026-04-17 10:30:00'); + $d->sec = 90; + $this->assertSame(10, $d->hour); + $this->assertSame(31, $d->min); + $this->assertSame(30, $d->sec); + } + + public function testSecondOverflowNegative(): void + { + $d = new HordeLegacyDate('2026-04-17 10:30:30'); + $d->sec = -30; + $this->assertSame(29, $d->min); + $this->assertSame(30, $d->sec); + } + + public function testMinuteOverflow(): void + { + $d = new HordeLegacyDate('2026-04-17 10:30:00'); + $d->min = 90; + $this->assertSame(11, $d->hour); + $this->assertSame(30, $d->min); + } + + public function testHourOverflow(): void + { + $d = new HordeLegacyDate('2026-04-17 10:30:00'); + $d->hour = 25; + $this->assertSame(18, $d->mday); + $this->assertSame(1, $d->hour); + $this->assertSame(30, $d->min); + } + + public function testHourUnderflow(): void + { + $d = new HordeLegacyDate('2026-04-17 10:30:00'); + $d->hour = -1; + $this->assertSame(16, $d->mday); + $this->assertSame(23, $d->hour); + $this->assertSame(30, $d->min); + } + + public function testDayOverflowEndOfMonth(): void + { + $d = new HordeLegacyDate(['year' => 2026, 'month' => 4, 'mday' => 1]); + $d->mday = 31; + $this->assertSame(5, $d->month); + $this->assertSame(1, $d->mday); + } + + public function testDayOverflowFebruary(): void + { + $d = new HordeLegacyDate(['year' => 2025, 'month' => 2, 'mday' => 1]); + $d->mday = 29; + $this->assertSame(3, $d->month); + $this->assertSame(1, $d->mday); + } + + public function testDayOverflowFebruaryLeap(): void + { + $d = new HordeLegacyDate(['year' => 2024, 'month' => 2, 'mday' => 1]); + $d->mday = 29; + $this->assertSame(2, $d->month); + $this->assertSame(29, $d->mday); + } + + public function testDayUnderflow(): void + { + $d = new HordeLegacyDate(['year' => 2026, 'month' => 4, 'mday' => 1]); + $d->mday = 0; + $this->assertSame(3, $d->month); + $this->assertSame(31, $d->mday); + } + + public function testMonthOverflow(): void + { + $d = new HordeLegacyDate(['year' => 2026, 'month' => 1, 'mday' => 15]); + $d->month = 13; + $this->assertSame(2027, $d->year); + $this->assertSame(1, $d->month); + } + + public function testMonthUnderflow(): void + { + $d = new HordeLegacyDate(['year' => 2026, 'month' => 3, 'mday' => 15]); + $d->month = -1; + $this->assertSame(2025, $d->year); + $this->assertSame(11, $d->month); + } + + public function testMonthSetToZero(): void + { + $d = new HordeLegacyDate(['year' => 2026, 'month' => 6, 'mday' => 15]); + $d->month = 0; + $this->assertSame(2025, $d->year); + $this->assertSame(12, $d->month); + } + + // ========================================================================= + // Timezone: __set vs setTimezone + // ========================================================================= + + public function testTimezonePropertyReinterprets(): void + { + $d = new HordeLegacyDate('2026-04-17 10:00:00', 'UTC'); + $d->timezone = 'America/New_York'; + $this->assertSame(10, $d->hour); + $this->assertSame('America/New_York', $d->timezone); + } + + public function testSetTimezoneConverts(): void + { + $d = new HordeLegacyDate('2026-04-17 10:00:00', 'UTC'); + $d->setTimezone('America/New_York'); + $this->assertSame(6, $d->hour); + $this->assertSame('America/New_York', $d->timezone); + } + + public function testSetTimezoneReturnsThis(): void + { + $d = new HordeLegacyDate('2026-04-17 10:00:00', 'UTC'); + $result = $d->setTimezone('Europe/Berlin'); + $this->assertSame($d, $result); + } + + public function testTimezoneAlias(): void + { + $d = new HordeLegacyDate('2026-04-17 10:00:00', 'Eastern Standard Time'); + $this->assertSame('America/New_York', $d->timezone); + } + + // ========================================================================= + // Arithmetic: add/sub + // ========================================================================= + + public function testAddSeconds(): void + { + $d = new HordeLegacyDate('2026-12-31 23:59:59'); + $r = $d->add(1); + $this->assertInstanceOf(HordeLegacyDate::class, $r); + $this->assertSame(2027, $r->year); + $this->assertSame(1, $r->month); + $this->assertSame(1, $r->mday); + $this->assertSame(0, $r->hour); + $this->assertSame(0, $r->min); + $this->assertSame(0, $r->sec); + } + + public function testSubSeconds(): void + { + $d = new HordeLegacyDate('2026-01-01 00:00:00'); + $r = $d->sub(1); + $this->assertSame(2025, $r->year); + $this->assertSame(12, $r->month); + $this->assertSame(31, $r->mday); + $this->assertSame(23, $r->hour); + $this->assertSame(59, $r->min); + $this->assertSame(59, $r->sec); + } + + public function testAddMonthArray(): void + { + $d = new HordeLegacyDate('2026-01-31 12:00:00'); + $r = $d->add(['month' => 1]); + $this->assertSame(2026, $r->year); + $this->assertSame(3, $r->month); + $this->assertSame(3, $r->mday); + } + + public function testSubMonthArray(): void + { + $d = new HordeLegacyDate('2026-03-31 12:00:00'); + $r = $d->sub(['month' => 1]); + // PHP date math: setDate(2026, 2, 31) overflows to Mar 3 + // (differs from legacy _correct which clamped to Feb 28) + $this->assertSame(2026, $r->year); + $this->assertSame(3, $r->month); + $this->assertSame(3, $r->mday); + } + + public function testAddMultipleParts(): void + { + $d = new HordeLegacyDate('2026-04-17 10:00:00'); + $r = $d->add(['year' => 1, 'month' => 2, 'mday' => 5]); + $this->assertSame(2027, $r->year); + $this->assertSame(6, $r->month); + $this->assertSame(22, $r->mday); + } + + public function testAddDoesNotMutateOriginal(): void + { + $d = new HordeLegacyDate('2026-04-17 10:00:00'); + $d->add(3600); + $this->assertSame(10, $d->hour); + } + + public function testAddManyDays(): void + { + $d = new HordeLegacyDate('2026-01-01 00:00:00'); + $r = $d->add(['mday' => 365]); + $this->assertSame(2027, $r->year); + $this->assertSame(1, $r->month); + $this->assertSame(1, $r->mday); + } + + // ========================================================================= + // Calendar calculations + // ========================================================================= + + public function testToDaysFromDaysRoundTrip(): void + { + $d = new HordeLegacyDate('2026-04-17'); + $days = $d->toDays(); + $back = HordeLegacyDate::fromDays($days); + $this->assertInstanceOf(HordeLegacyDate::class, $back); + $this->assertSame(2026, $back->year); + $this->assertSame(4, $back->month); + $this->assertSame(17, $back->mday); + } + + #[DataProvider('dayOfWeekProvider')] + public function testDayOfWeek(string $date, int $expected): void + { + $d = new HordeLegacyDate($date); + $this->assertSame($expected, $d->dayOfWeek()); + } + + public static function dayOfWeekProvider(): array + { + return [ + ['2026-04-12', Horde_Date::DATE_SUNDAY], + ['2026-04-13', Horde_Date::DATE_MONDAY], + ['2026-04-14', Horde_Date::DATE_TUESDAY], + ['2026-04-15', Horde_Date::DATE_WEDNESDAY], + ['2026-04-16', Horde_Date::DATE_THURSDAY], + ['2026-04-17', Horde_Date::DATE_FRIDAY], + ['2026-04-18', Horde_Date::DATE_SATURDAY], + ]; + } + + public function testDayOfYear(): void + { + $d = new HordeLegacyDate('2026-01-01'); + $this->assertSame(1, $d->dayOfYear()); + + $d2 = new HordeLegacyDate('2026-12-31'); + $this->assertSame(365, $d2->dayOfYear()); + } + + public function testWeekOfMonth(): void + { + $d = new HordeLegacyDate('2026-04-01'); + $this->assertSame(1, $d->weekOfMonth()); + + $d2 = new HordeLegacyDate('2026-04-28'); + $this->assertSame(4, $d2->weekOfMonth()); + } + + public function testWeekOfYear(): void + { + $d = new HordeLegacyDate('2026-01-05'); + $this->assertSame(2, $d->weekOfYear()); + } + + public function testWeeksInYear(): void + { + $this->assertSame(53, HordeLegacyDate::weeksInYear(2004)); + $this->assertSame(53, HordeLegacyDate::weeksInYear(2026)); + } + + public function testSetNthWeekday(): void + { + $d = new HordeLegacyDate('2026-04-01'); + $d->setNthWeekday(Horde_Date::DATE_SATURDAY); + $this->assertSame(4, $d->mday); + + $d2 = new HordeLegacyDate('2026-04-01'); + $d2->setNthWeekday(Horde_Date::DATE_SATURDAY, 2); + $this->assertSame(11, $d2->mday); + } + + public function testSetNthWeekdayNegative(): void + { + $d = new HordeLegacyDate('2026-04-15'); + $d->setNthWeekday(Horde_Date::DATE_FRIDAY, -1); + $this->assertSame(24, $d->mday); + } + + public function testIsValid(): void + { + $d = new HordeLegacyDate('2026-04-17'); + $this->assertTrue($d->isValid()); + } + + public function testDiff(): void + { + $a = new HordeLegacyDate('2026-01-01'); + $b = new HordeLegacyDate('2026-01-11'); + $this->assertSame(10, $a->diff($b)); + $this->assertSame(10, $b->diff($a)); + } + + // ========================================================================= + // Comparison + // ========================================================================= + + public function testCompareDate(): void + { + $a = new HordeLegacyDate('2026-04-17 10:00:00'); + $b = new HordeLegacyDate('2026-04-18 10:00:00'); + $this->assertLessThan(0, $a->compareDate($b)); + $this->assertGreaterThan(0, $b->compareDate($a)); + + $c = new HordeLegacyDate('2026-04-17 23:00:00'); + $this->assertSame(0, $a->compareDate($c)); + } + + public function testCompareTime(): void + { + $a = new HordeLegacyDate('2026-04-17 10:00:00'); + $b = new HordeLegacyDate('2026-04-17 11:00:00'); + $this->assertLessThan(0, $a->compareTime($b)); + } + + public function testCompareDateTime(): void + { + $a = new HordeLegacyDate('2026-04-17 10:00:00'); + $b = new HordeLegacyDate('2026-04-17 10:00:01'); + $this->assertLessThan(0, $a->compareDateTime($b)); + } + + public function testBeforeAfterEquals(): void + { + $a = new HordeLegacyDate('2026-04-17'); + $b = new HordeLegacyDate('2026-04-18'); + $this->assertTrue($a->before($b)); + $this->assertTrue($b->after($a)); + $this->assertTrue($a->equals(new HordeLegacyDate('2026-04-17'))); + } + + public function testCompareWithLegacyDate(): void + { + $a = new HordeLegacyDate('2026-04-17 10:00:00'); + $b = new Horde_Date('2026-04-17 10:00:00'); + $this->assertSame(0, $a->compareDateTime($b)); + } + + // ========================================================================= + // Conversion methods + // ========================================================================= + + public function testToDateTime(): void + { + $d = new HordeLegacyDate('2026-04-17 10:30:00', 'UTC'); + $dt = $d->toDateTime(); + $this->assertInstanceOf(DateTime::class, $dt); + $this->assertSame('2026-04-17 10:30:00', $dt->format('Y-m-d H:i:s')); + $this->assertSame('UTC', $dt->getTimezone()->getName()); + } + + public function testToDateTimeImmutable(): void + { + $d = new HordeLegacyDate('2026-04-17 10:30:00', 'UTC'); + $dti = $d->toDateTimeImmutable(); + $this->assertInstanceOf(DateTimeImmutable::class, $dti); + $this->assertSame('2026-04-17 10:30:00', $dti->format('Y-m-d H:i:s')); + } + + public function testToDate(): void + { + $d = new HordeLegacyDate('2026-04-17 10:30:00', 'UTC'); + $date = $d->toDate(); + $this->assertInstanceOf(Date::class, $date); + $this->assertSame('2026-04-17 10:30:00', $date->format('Y-m-d H:i:s')); + } + + public function testGetTimezone(): void + { + $d = new HordeLegacyDate('2026-04-17 10:30:00', 'Europe/Berlin'); + $tz = $d->getTimezone(); + $this->assertInstanceOf(DateTimeZone::class, $tz); + $this->assertSame('Europe/Berlin', $tz->getName()); + } + + // ========================================================================= + // Timestamps & serialization + // ========================================================================= + + public function testTimestamp(): void + { + $d = new HordeLegacyDate('2026-04-17 10:00:00', 'UTC'); + $this->assertSame( + mktime(10, 0, 0, 4, 17, 2026), + $d->timestamp(), + ); + } + + public function testDatestamp(): void + { + $d = new HordeLegacyDate('2026-04-17 10:30:00', 'UTC'); + $expected = mktime(0, 0, 0, 4, 17, 2026); + $this->assertSame($expected, $d->datestamp()); + } + + public function testDateString(): void + { + $d = new HordeLegacyDate('2026-04-17'); + $this->assertSame('20260417', $d->dateString()); + } + + public function testToJson(): void + { + $d = new HordeLegacyDate('2026-04-17 10:30:00'); + $this->assertSame('2026-04-17T10:30:00', $d->toJson()); + } + + public function testToiCalendarFloating(): void + { + $d = new HordeLegacyDate('2026-04-17 10:30:00'); + $this->assertSame('20260417T103000', $d->toiCalendar(true)); + } + + public function testToiCalendarUTC(): void + { + $d = new HordeLegacyDate('2026-04-17 10:30:00', 'UTC'); + $this->assertSame('20260417T103000Z', $d->toiCalendar(false)); + } + + public function testTzOffset(): void + { + $d = new HordeLegacyDate('2026-04-17 10:00:00', 'UTC'); + $this->assertSame('+00:00', $d->tzOffset(true)); + $this->assertSame('+0000', $d->tzOffset(false)); + } + + // ========================================================================= + // Formatting + // ========================================================================= + + public function testFormatBasic(): void + { + $d = new HordeLegacyDate('2026-04-17 10:30:45'); + $this->assertSame('2026', $d->format('Y')); + $this->assertSame('04', $d->format('m')); + $this->assertSame('17', $d->format('d')); + $this->assertSame('10:30:45', $d->format('H:i:s')); + } + + public function testFormatWithDateTimeFormatter(): void + { + $d = new HordeLegacyDate('2026-04-17 10:30:00', 'UTC'); + $result = $d->format('Y-m-d', \Horde\Date\Formatter\DateTimeFormatter::class); + $this->assertSame('2026-04-17', $result); + } + + public function testToString(): void + { + $d = new HordeLegacyDate('2026-04-17 10:30:00'); + $this->assertSame('2026-04-17 10:30:00', (string) $d); + } + + public function testSetDefaultFormat(): void + { + $d = new HordeLegacyDate('2026-04-17 10:30:00'); + $d->setDefaultFormat('d/m/Y'); + $this->assertSame('17/04/2026', (string) $d); + } + + // ========================================================================= + // strftime + // ========================================================================= + + public function testStrftimeBasic(): void + { + $d = new HordeLegacyDate('2026-04-17 10:30:45'); + $this->assertSame('2026', $d->strftime('%Y')); + $this->assertSame('04', $d->strftime('%m')); + $this->assertSame('17', $d->strftime('%d')); + $this->assertSame('10', $d->strftime('%H')); + $this->assertSame('30', $d->strftime('%M')); + $this->assertSame('45', $d->strftime('%S')); + } + + public function testStrftimeComposite(): void + { + $d = new HordeLegacyDate('2026-04-17 14:30:00'); + $this->assertSame('14:30', $d->strftime('%R')); + $this->assertSame('14:30:00', $d->strftime('%T')); + } + + public function testStrftime12Hour(): void + { + $d = new HordeLegacyDate('2026-04-17 14:00:00'); + $this->assertSame('02', $d->strftime('%I')); + + $d2 = new HordeLegacyDate('2026-04-17 00:00:00'); + $this->assertSame('12', $d2->strftime('%I')); + } + + // ========================================================================= + // Clone behavior + // ========================================================================= + + public function testCloneIsIndependent(): void + { + $a = new HordeLegacyDate('2026-04-17 10:00:00'); + $b = clone $a; + $b->hour = 20; + $this->assertSame(10, $a->hour); + $this->assertSame(20, $b->hour); + } + + // ========================================================================= + // Consistency with Horde_Date (excluding known divergences) + // ========================================================================= + + #[DataProvider('overflowConsistencyProvider')] + public function testOverflowConsistency( + string $initial, + string $property, + int $value, + ): void { + $legacy = new Horde_Date($initial); + $modern = new HordeLegacyDate($initial); + + $legacy->$property = $value; + $modern->$property = $value; + + $this->assertSame( + $legacy->format('Y-m-d H:i:s'), + $modern->format('Y-m-d H:i:s'), + "Divergence on $property=$value from $initial", + ); + } + + public static function overflowConsistencyProvider(): array + { + return [ + 'sec +90' => ['2026-04-17 10:30:00', 'sec', 90], + 'sec -30' => ['2026-04-17 10:30:30', 'sec', -30], + 'min +90' => ['2026-04-17 10:30:00', 'min', 90], + 'min -5' => ['2026-04-17 10:30:00', 'min', -5], + 'hour +25' => ['2026-04-17 10:30:00', 'hour', 25], + 'hour -1' => ['2026-04-17 10:30:00', 'hour', -1], + 'day +32 apr' => ['2026-04-01 00:00:00', 'mday', 32], + 'day 0' => ['2026-04-01 00:00:00', 'mday', 0], + 'day -27' => ['2026-03-01 00:00:00', 'mday', -27], + 'month +13' => ['2026-01-15 00:00:00', 'month', 13], + 'month -1' => ['2026-03-15 00:00:00', 'month', -1], + 'month 25' => ['2026-01-15 00:00:00', 'month', 25], + 'month 0' => ['2026-06-15 00:00:00', 'month', 0], + 'feb 29 non-leap' => ['2025-02-01 00:00:00', 'mday', 29], + 'feb 29 leap' => ['2024-02-01 00:00:00', 'mday', 29], + 'day +400' => ['2026-01-15 00:00:00', 'mday', 400], + ]; + } + + // ========================================================================= + // Full cascade: add/sub consistency + // ========================================================================= + + public function testFullCascadeViaAdd(): void + { + $d = new HordeLegacyDate('2026-04-17 23:59:59'); + $r = $d->add(1); + $this->assertSame(2026, $r->year); + $this->assertSame(4, $r->month); + $this->assertSame(18, $r->mday); + $this->assertSame(0, $r->hour); + $this->assertSame(0, $r->min); + $this->assertSame(0, $r->sec); + } + + public function testFullCascadeYearBoundary(): void + { + $d = new HordeLegacyDate('2026-12-31 23:59:59'); + $r = $d->add(1); + $this->assertSame(2027, $r->year); + $this->assertSame(1, $r->month); + $this->assertSame(1, $r->mday); + } + + // ========================================================================= + // Leap year edge cases + // ========================================================================= + + public function testAddYearFromFeb29(): void + { + $d = new HordeLegacyDate('2024-02-29 12:00:00'); + $r = $d->add(['year' => 1]); + $this->assertSame(2025, $r->year); + $this->assertSame(3, $r->month); + $this->assertSame(1, $r->mday); + } + + public function testAddMonthFromJan31(): void + { + $d = new HordeLegacyDate('2026-01-31 12:00:00'); + $r = $d->add(['month' => 1]); + $this->assertSame(3, $r->month); + $this->assertSame(3, $r->mday); + } + + // ========================================================================= + // Float handling (from DateTest) + // ========================================================================= + + public function testFloatInConstructorArray(): void + { + $d = new HordeLegacyDate([ + 'year' => 2026.7, + 'month' => 4.9, + 'mday' => 17.3, + 'hour' => 10.5, + 'min' => 30.9, + 'sec' => 45.1, + ]); + $this->assertSame(2026, $d->year); + $this->assertSame(4, $d->month); + $this->assertSame(17, $d->mday); + } + + public function testFloatInPropertySetter(): void + { + $d = new HordeLegacyDate('2026-04-17'); + $d->month = 6.7; + $this->assertSame(6, $d->month); + } +} diff --git a/test/Unnamespaced/RecurrenceFullTest.php b/test/Unnamespaced/RecurrenceFullTest.php new file mode 100644 index 0000000..d17c83c --- /dev/null +++ b/test/Unnamespaced/RecurrenceFullTest.php @@ -0,0 +1,1077 @@ +oldTimezone = date_default_timezone_get(); + date_default_timezone_set('UTC'); + } + + protected function tearDown(): void + { + date_default_timezone_set($this->oldTimezone); + } + + private function collectRecurrences(Horde_Date_Recurrence $r, string $afterDate, int $limit = 30): array + { + $dates = []; + $next = new Horde_Date($afterDate); + while ($next = $r->nextRecurrence($next)) { + if (count($dates) >= $limit) { + break; + } + $dates[] = $next->format('Y-m-d'); + $next->mday++; + } + return $dates; + } + + // ========================================================================= + // Section 1: Property Getters / Setters + // ========================================================================= + + public function testReset(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurInterval(3); + $r->setRecurCount(10); + $r->setRecurOnDay(Horde_Date::MASK_MONDAY); + $r->addException(2026, 1, 5); + $r->addCompletion(2026, 1, 6); + + $r->reset(); + + $this->assertSame(Horde_Date_Recurrence::RECUR_NONE, $r->getRecurType()); + $this->assertSame(1, $r->getRecurInterval()); + $this->assertNull($r->getRecurCount()); + $this->assertNull($r->getRecurEnd()); + $this->assertNull($r->recurData); + $this->assertSame([], $r->getExceptions()); + $this->assertSame([], $r->getCompletions()); + } + + public function testHasRecurType(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_WEEKLY); + + $this->assertTrue($r->hasRecurType(Horde_Date_Recurrence::RECUR_WEEKLY)); + $this->assertFalse($r->hasRecurType(Horde_Date_Recurrence::RECUR_DAILY)); + $this->assertFalse($r->hasRecurType(Horde_Date_Recurrence::RECUR_NONE)); + } + + #[DataProvider('recurTypeProvider')] + public function testSetGetRecurType(int $type): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType($type); + $this->assertSame($type, $r->getRecurType()); + } + + public static function recurTypeProvider(): array + { + return [ + 'NONE' => [Horde_Date_Recurrence::RECUR_NONE], + 'DAILY' => [Horde_Date_Recurrence::RECUR_DAILY], + 'WEEKLY' => [Horde_Date_Recurrence::RECUR_WEEKLY], + 'MONTHLY_DATE' => [Horde_Date_Recurrence::RECUR_MONTHLY_DATE], + 'MONTHLY_WEEKDAY' => [Horde_Date_Recurrence::RECUR_MONTHLY_WEEKDAY], + 'YEARLY_DATE' => [Horde_Date_Recurrence::RECUR_YEARLY_DATE], + 'YEARLY_DAY' => [Horde_Date_Recurrence::RECUR_YEARLY_DAY], + 'YEARLY_WEEKDAY' => [Horde_Date_Recurrence::RECUR_YEARLY_WEEKDAY], + 'MONTHLY_LAST_WEEKDAY' => [Horde_Date_Recurrence::RECUR_MONTHLY_LAST_WEEKDAY], + ]; + } + + #[DataProvider('intervalProvider')] + public function testSetGetRecurInterval(int $interval): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurInterval($interval); + $this->assertSame($interval, $r->getRecurInterval()); + } + + public static function intervalProvider(): array + { + return [ + '1' => [1], + '2' => [2], + '7' => [7], + '30' => [30], + ]; + } + + public function testSetRecurIntervalZeroIgnored(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurInterval(5); + $r->setRecurInterval(0); + $this->assertSame(5, $r->getRecurInterval()); + } + + public function testSetRecurIntervalNegativeIgnored(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurInterval(3); + $r->setRecurInterval(-1); + $this->assertSame(3, $r->getRecurInterval()); + } + + public function testSetGetRecurCount(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurCount(10); + $this->assertSame(10, $r->getRecurCount()); + $this->assertTrue($r->hasRecurCount()); + } + + public function testSetRecurCountNegativeSetsNull(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurCount(5); + $r->setRecurCount(-1); + $this->assertNull($r->getRecurCount()); + $this->assertFalse($r->hasRecurCount()); + } + + public function testSetRecurCountZeroSetsNull(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurCount(5); + $r->setRecurCount(0); + $this->assertNull($r->getRecurCount()); + } + + public function testCountAndEndMutuallyExclusive(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + + $r->setRecurCount(10); + $this->assertSame(10, $r->getRecurCount()); + $this->assertNull($r->getRecurEnd()); + + $r->setRecurEnd(new Horde_Date('2026-12-31')); + $this->assertNull($r->getRecurCount()); + $this->assertNotNull($r->getRecurEnd()); + + $r->setRecurCount(5); + $this->assertSame(5, $r->getRecurCount()); + $this->assertNull($r->getRecurEnd()); + } + + public function testRecurOnDay(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurOnDay(Horde_Date::MASK_MONDAY | Horde_Date::MASK_WEDNESDAY); + + $this->assertTrue((bool) $r->recurOnDay(Horde_Date::MASK_MONDAY)); + $this->assertTrue((bool) $r->recurOnDay(Horde_Date::MASK_WEDNESDAY)); + $this->assertFalse((bool) $r->recurOnDay(Horde_Date::MASK_TUESDAY)); + $this->assertFalse((bool) $r->recurOnDay(Horde_Date::MASK_FRIDAY)); + } + + public function testSetGetRecurOnDays(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $mask = Horde_Date::MASK_MONDAY | Horde_Date::MASK_FRIDAY; + $r->setRecurOnDay($mask); + $this->assertSame($mask, $r->getRecurOnDays()); + } + + public function testHasRecurEndSentinelYear9999(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurEnd(new Horde_Date('9999-12-31 23:59:59')); + $this->assertFalse($r->hasRecurEnd()); + } + + public function testHasRecurEndNullReturnsFalse(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $this->assertFalse($r->hasRecurEnd()); + } + + public function testHasRecurEndValid(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurEnd(new Horde_Date('2026-12-31')); + $this->assertTrue($r->hasRecurEnd()); + } + + public function testSetRecurStartClones(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $original = new Horde_Date('2026-06-15 12:00:00'); + $r->setRecurStart($original); + $original->year = 2099; + $this->assertSame('2026', $r->getRecurStart()->format('Y')); + } + + public function testSetRecurEndClones(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $end = new Horde_Date('2026-12-31 23:59:59'); + $r->setRecurEnd($end); + $end->year = 2099; + $this->assertSame('2026', $r->getRecurEnd()->format('Y')); + } + + // ========================================================================= + // Section 2: getRecurName() + // ========================================================================= + + #[DataProvider('recurNameProvider')] + public function testGetRecurName(int $type, string $expectedSubstring): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType($type); + $name = $r->getRecurName(); + $this->assertNotEmpty($name); + $this->assertStringContainsString($expectedSubstring, $name); + } + + public static function recurNameProvider(): array + { + return [ + 'NONE' => [Horde_Date_Recurrence::RECUR_NONE, 'No recurrence'], + 'DAILY' => [Horde_Date_Recurrence::RECUR_DAILY, 'Daily'], + 'WEEKLY' => [Horde_Date_Recurrence::RECUR_WEEKLY, 'Weekly'], + 'MONTHLY_DATE' => [Horde_Date_Recurrence::RECUR_MONTHLY_DATE, 'Monthly'], + 'MONTHLY_WEEKDAY' => [Horde_Date_Recurrence::RECUR_MONTHLY_WEEKDAY, 'Monthly'], + 'MONTHLY_LAST_WEEKDAY' => [Horde_Date_Recurrence::RECUR_MONTHLY_LAST_WEEKDAY, 'Monthly'], + 'YEARLY_DATE' => [Horde_Date_Recurrence::RECUR_YEARLY_DATE, 'Yearly'], + 'YEARLY_DAY' => [Horde_Date_Recurrence::RECUR_YEARLY_DAY, 'Yearly'], + 'YEARLY_WEEKDAY' => [Horde_Date_Recurrence::RECUR_YEARLY_WEEKDAY, 'Yearly'], + ]; + } + + // ========================================================================= + // Section 3: Exception / Completion Management + // ========================================================================= + + public function testAddExceptionIdempotent(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->addException(2026, 3, 15); + $r->addException(2026, 3, 15); + $this->assertCount(1, $r->getExceptions()); + } + + public function testDeleteNonexistentExceptionNoOp(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->addException(2026, 3, 15); + $r->deleteException(2026, 6, 20); + $this->assertCount(1, $r->getExceptions()); + } + + public function testExceptionsFormat(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->addException(2026, 1, 5); + $r->addException(2026, 12, 25); + $this->assertSame(['20260105', '20261225'], $r->getExceptions()); + } + + public function testHasException(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->addException(2026, 3, 15); + $this->assertTrue($r->hasException(2026, 3, 15)); + $this->assertFalse($r->hasException(2026, 3, 16)); + } + + public function testDeleteException(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->addException(2026, 3, 15); + $r->addException(2026, 3, 16); + $r->deleteException(2026, 3, 15); + $this->assertFalse($r->hasException(2026, 3, 15)); + $this->assertTrue($r->hasException(2026, 3, 16)); + } + + public function testCompletionAllowsDuplicates(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->addCompletion(2026, 3, 15); + $r->addCompletion(2026, 3, 15); + $this->assertCount(2, $r->getCompletions()); + } + + public function testHasExceptionFalseForCompletion(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->addCompletion(2026, 3, 15); + $this->assertFalse($r->hasException(2026, 3, 15)); + $this->assertTrue($r->hasCompletion(2026, 3, 15)); + } + + public function testNextActiveRecurrenceSkipsExceptions(): void + { + $r = new Horde_Date_Recurrence('2026-01-05 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurInterval(1); + $r->setRecurEnd(new Horde_Date('2026-01-15 23:59:59')); + + $r->addException(2026, 1, 5); + $r->addException(2026, 1, 6); + + $next = $r->nextActiveRecurrence(new Horde_Date('2026-01-05')); + $this->assertInstanceOf(Horde_Date::class, $next); + $this->assertSame('2026-01-07', $next->format('Y-m-d')); + } + + public function testNextActiveRecurrenceSkipsCompletions(): void + { + $r = new Horde_Date_Recurrence('2026-01-05 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurInterval(1); + $r->setRecurEnd(new Horde_Date('2026-01-15 23:59:59')); + + $r->addCompletion(2026, 1, 5); + + $next = $r->nextActiveRecurrence(new Horde_Date('2026-01-05')); + $this->assertInstanceOf(Horde_Date::class, $next); + $this->assertSame('2026-01-06', $next->format('Y-m-d')); + } + + public function testHasActiveRecurrenceWithAllExcepted(): void + { + $r = new Horde_Date_Recurrence('2026-01-05 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurInterval(1); + $r->setRecurEnd(new Horde_Date('2026-01-07 23:59:59')); + + $r->addException(2026, 1, 5); + $r->addException(2026, 1, 6); + $r->addException(2026, 1, 7); + + $this->assertFalse($r->hasActiveRecurrence()); + } + + public function testHasActiveRecurrenceWithSomeActive(): void + { + $r = new Horde_Date_Recurrence('2026-01-05 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurInterval(1); + $r->setRecurEnd(new Horde_Date('2026-01-07 23:59:59')); + + $r->addException(2026, 1, 5); + + $this->assertTrue($r->hasActiveRecurrence()); + } + + // ========================================================================= + // Section 4: nextRecurrence() Edge Cases + // ========================================================================= + + public function testNextRecurrenceIntervalZero(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->recurInterval = 0; + + $result = $r->nextRecurrence(new Horde_Date('2026-01-02')); + $this->assertFalse($result); + } + + public function testNextRecurrenceUnknownType(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->recurType = 99; + $r->recurInterval = 1; + + $result = $r->nextRecurrence(new Horde_Date('2026-01-02')); + $this->assertFalse($result); + } + + public function testNextRecurrenceReturnsStartWhenAfterIsBeforeStart(): void + { + $r = new Horde_Date_Recurrence('2026-06-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurInterval(1); + + $result = $r->nextRecurrence(new Horde_Date('2026-01-01')); + $this->assertInstanceOf(Horde_Date::class, $result); + $this->assertSame('2026-06-01', $result->format('Y-m-d')); + } + + public function testNextRecurrenceWeeklyNoDataReturnsFalse(): void + { + $r = new Horde_Date_Recurrence('2026-01-05 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_WEEKLY); + $r->setRecurInterval(1); + + $result = $r->nextRecurrence(new Horde_Date('2026-01-06')); + $this->assertFalse($result); + } + + public function testNextRecurrenceDailyPreservesTime(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 14:30:45'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurInterval(1); + + $next = $r->nextRecurrence(new Horde_Date('2026-01-02')); + $this->assertSame('14', $next->format('H')); + $this->assertSame('30', $next->format('i')); + $this->assertSame('45', $next->format('s')); + } + + public function testNextRecurrenceReturnsClone(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurInterval(1); + + $next1 = $r->nextRecurrence(new Horde_Date('2026-01-02')); + $next1->year = 2099; + + $next2 = $r->nextRecurrence(new Horde_Date('2026-01-02')); + $this->assertSame('2026', $next2->format('Y')); + } + + public function testNextRecurrenceAtExactEnd(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurInterval(1); + $r->setRecurEnd(new Horde_Date('2026-01-03 10:00:00')); + + $next = $r->nextRecurrence(new Horde_Date('2026-01-03')); + $this->assertInstanceOf(Horde_Date::class, $next); + $this->assertSame('2026-01-03', $next->format('Y-m-d')); + + $after = $r->nextRecurrence(new Horde_Date('2026-01-04')); + $this->assertFalse($after); + } + + public function testNextRecurrenceTimezoneNormalization(): void + { + date_default_timezone_set('America/New_York'); + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurInterval(1); + + $after = new Horde_Date('2026-01-02 00:00:00'); + $after->setTimezone('Europe/Berlin'); + + $next = $r->nextRecurrence($after); + $this->assertInstanceOf(Horde_Date::class, $next); + } + + // ========================================================================= + // Section 5: Monthly Last Weekday Extended + // ========================================================================= + + public function testMonthlyLastWeekdayCount(): void + { + // Last Thursday of month, starting 2026-01-29 (last Thu of Jan 2026) + $r = new Horde_Date_Recurrence('2026-01-29 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_MONTHLY_LAST_WEEKDAY); + $r->setRecurInterval(1); + $r->setRecurCount(6); + + $dates = $this->collectRecurrences($r, '2026-01-01'); + $this->assertCount(6, $dates); + $this->assertSame('2026-01-29', $dates[0]); + $this->assertSame('2026-02-26', $dates[1]); + $this->assertSame('2026-03-26', $dates[2]); + } + + public function testMonthlyLastWeekdayEnd(): void + { + $r = new Horde_Date_Recurrence('2026-01-29 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_MONTHLY_LAST_WEEKDAY); + $r->setRecurInterval(1); + $r->setRecurEnd(new Horde_Date('2026-04-30 23:59:59')); + + $dates = $this->collectRecurrences($r, '2026-01-01'); + $this->assertGreaterThanOrEqual(4, count($dates)); + foreach ($dates as $date) { + $hd = new Horde_Date($date); + $this->assertLessThanOrEqual(4, (int) $hd->format('m')); + } + } + + public function testMonthlyLastWeekdayInterval2(): void + { + $r = new Horde_Date_Recurrence('2026-01-29 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_MONTHLY_LAST_WEEKDAY); + $r->setRecurInterval(2); + $r->setRecurCount(4); + + $dates = $this->collectRecurrences($r, '2026-01-01'); + $this->assertCount(4, $dates); + + $months = array_map(fn($d) => (int) (new Horde_Date($d))->format('m'), $dates); + $this->assertSame(1, $months[0]); + $this->assertSame(3, $months[1]); + $this->assertSame(5, $months[2]); + $this->assertSame(7, $months[3]); + } + + public function testMonthlyLastWeekdayFriday(): void + { + // Last Friday of Jan 2026 = Jan 30 + $r = new Horde_Date_Recurrence('2026-01-30 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_MONTHLY_LAST_WEEKDAY); + $r->setRecurInterval(1); + $r->setRecurCount(3); + + $dates = $this->collectRecurrences($r, '2026-01-01'); + $this->assertCount(3, $dates); + + foreach ($dates as $date) { + $hd = new Horde_Date($date); + $this->assertSame( + Horde_Date::DATE_FRIDAY, + $hd->dayOfWeek(), + "$date should be a Friday" + ); + } + } + + // ========================================================================= + // Section 6: Yearly Types Extended + // ========================================================================= + + public function testYearlyDateFeb29LeapSkip(): void + { + // Count check uses year offset, not occurrence count. + // With count=10 and interval=1, offsets 0,4,8 all pass < 10. + $r = new Horde_Date_Recurrence('2024-02-29 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_YEARLY_DATE); + $r->setRecurInterval(1); + $r->setRecurCount(10); + + $dates = $this->collectRecurrences($r, '2024-01-01'); + $this->assertSame('2024-02-29', $dates[0]); + $this->assertSame('2028-02-29', $dates[1]); + $this->assertSame('2032-02-29', $dates[2]); + } + + public function testYearlyDateInterval2(): void + { + // Count check uses offset/interval. With interval=2 and count=4, + // offsets 0, 2 pass (< 4), but offset 4 fails (>= 4). + $r = new Horde_Date_Recurrence('2026-06-15 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_YEARLY_DATE); + $r->setRecurInterval(2); + $r->setRecurCount(6); + + $dates = $this->collectRecurrences($r, '2026-01-01'); + $this->assertSame('2026-06-15', $dates[0]); + $this->assertSame('2028-06-15', $dates[1]); + $this->assertSame('2030-06-15', $dates[2]); + } + + public function testYearlyDayInterval3(): void + { + // Day 100 of 2026 = April 10 + $r = new Horde_Date_Recurrence('2026-04-10 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_YEARLY_DAY); + $r->setRecurInterval(3); + $r->setRecurCount(3); + + $dates = $this->collectRecurrences($r, '2026-01-01'); + $this->assertCount(3, $dates); + $this->assertSame('2026-04-10', $dates[0]); + + $year2 = (int) (new Horde_Date($dates[1]))->format('Y'); + $year3 = (int) (new Horde_Date($dates[2]))->format('Y'); + $this->assertSame(2029, $year2); + $this->assertSame(2032, $year3); + } + + public function testYearlyWeekdayCount6(): void + { + // 1st Thursday of March 2026 = March 5 + $r = new Horde_Date_Recurrence('2026-03-05 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_YEARLY_WEEKDAY); + $r->setRecurInterval(1); + $r->setRecurCount(6); + + $dates = $this->collectRecurrences($r, '2026-01-01'); + $this->assertCount(6, $dates); + + foreach ($dates as $date) { + $hd = new Horde_Date($date); + $this->assertSame( + Horde_Date::DATE_THURSDAY, + $hd->dayOfWeek(), + "$date should be Thursday" + ); + $this->assertSame('03', $hd->format('m'), "$date should be in March"); + } + } + + public function testYearlyWeekdayInterval2(): void + { + // 1st Thursday of March 2026 = March 5 + $r = new Horde_Date_Recurrence('2026-03-05 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_YEARLY_WEEKDAY); + $r->setRecurInterval(2); + $r->setRecurCount(3); + + $dates = $this->collectRecurrences($r, '2026-01-01'); + $this->assertCount(3, $dates); + + $years = array_map(fn($d) => (int) (new Horde_Date($d))->format('Y'), $dates); + $this->assertSame(2026, $years[0]); + $this->assertSame(2028, $years[1]); + $this->assertSame(2030, $years[2]); + } + + public function testYearlyDateLeapYearWithCount(): void + { + // With count=10 and interval=1, offsets 0 and 4 pass, giving 2 results + $r = new Horde_Date_Recurrence('2024-02-29 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_YEARLY_DATE); + $r->setRecurInterval(1); + $r->setRecurCount(5); + + $dates = $this->collectRecurrences($r, '2024-01-01'); + $this->assertCount(2, $dates); + $this->assertSame('2024-02-29', $dates[0]); + $this->assertSame('2028-02-29', $dates[1]); + } + + // ========================================================================= + // Section 7: toJson() + // ========================================================================= + + public function testToJsonStructure(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurInterval(2); + + $json = $r->toJson(); + $this->assertInstanceOf(stdClass::class, $json); + $this->assertObjectHasProperty('t', $json); + $this->assertObjectHasProperty('i', $json); + $this->assertSame(Horde_Date_Recurrence::RECUR_DAILY, $json->t); + $this->assertSame(2, $json->i); + } + + public function testToJsonWithEnd(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurInterval(1); + $r->setRecurEnd(new Horde_Date('2026-06-30')); + + $json = $r->toJson(); + $this->assertObjectHasProperty('e', $json); + } + + public function testToJsonWithCount(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurInterval(1); + $r->setRecurCount(5); + + $json = $r->toJson(); + $this->assertObjectHasProperty('c', $json); + $this->assertSame(5, $json->c); + } + + public function testToJsonWeeklyWithExtras(): void + { + $r = new Horde_Date_Recurrence('2026-01-05 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_WEEKLY); + $r->setRecurInterval(1); + $r->setRecurOnDay(Horde_Date::MASK_MONDAY | Horde_Date::MASK_FRIDAY); + $r->addException(2026, 1, 12); + $r->addCompletion(2026, 1, 9); + + $json = $r->toJson(); + $this->assertObjectHasProperty('d', $json); + $this->assertObjectHasProperty('ex', $json); + $this->assertObjectHasProperty('co', $json); + $this->assertSame(Horde_Date::MASK_MONDAY | Horde_Date::MASK_FRIDAY, $json->d); + } + + // ========================================================================= + // Section 8: isEqual() + // ========================================================================= + + public function testIsEqualSame(): void + { + $r1 = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r1->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r1->setRecurInterval(2); + $r1->setRecurCount(10); + + $r2 = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r2->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r2->setRecurInterval(2); + $r2->setRecurCount(10); + + $this->assertTrue($r1->isEqual($r2)); + } + + public function testIsEqualDifferentType(): void + { + $r1 = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r1->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + + $r2 = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r2->setRecurType(Horde_Date_Recurrence::RECUR_WEEKLY); + + $this->assertFalse($r1->isEqual($r2)); + } + + public function testIsEqualDifferentInterval(): void + { + $r1 = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r1->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r1->setRecurInterval(1); + + $r2 = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r2->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r2->setRecurInterval(3); + + $this->assertFalse($r1->isEqual($r2)); + } + + public function testIsEqualIgnoresExceptions(): void + { + $r1 = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r1->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r1->setRecurInterval(1); + + $r2 = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r2->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r2->setRecurInterval(1); + $r2->addException(2026, 3, 15); + + $this->assertTrue($r1->isEqual($r2)); + } + + public function testIsEqualDifferentDayMask(): void + { + $r1 = new Horde_Date_Recurrence('2026-01-05 10:00:00'); + $r1->setRecurType(Horde_Date_Recurrence::RECUR_WEEKLY); + $r1->setRecurOnDay(Horde_Date::MASK_MONDAY); + + $r2 = new Horde_Date_Recurrence('2026-01-05 10:00:00'); + $r2->setRecurType(Horde_Date_Recurrence::RECUR_WEEKLY); + $r2->setRecurOnDay(Horde_Date::MASK_FRIDAY); + + $this->assertFalse($r1->isEqual($r2)); + } + + // ========================================================================= + // Section 9: RRULE Round-Trip Consistency + // ========================================================================= + + public function testRRule20RoundTripDaily(): void + { + $ical = new Horde_Icalendar(); + + $r = new Horde_Date_Recurrence('2026-03-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurInterval(3); + $r->setRecurCount(10); + + $rrule = $r->toRRule20($ical); + $this->assertStringContainsString('FREQ=DAILY', $rrule); + $this->assertStringContainsString('INTERVAL=3', $rrule); + $this->assertStringContainsString('COUNT=10', $rrule); + + $r2 = new Horde_Date_Recurrence('2026-03-01 10:00:00'); + $r2->fromRRule20($rrule); + + $this->assertSame($r->getRecurType(), $r2->getRecurType()); + $this->assertEquals($r->getRecurInterval(), $r2->getRecurInterval()); + $this->assertEquals($r->getRecurCount(), $r2->getRecurCount()); + } + + public function testRRule20RoundTripWeekly(): void + { + $ical = new Horde_Icalendar(); + + $r = new Horde_Date_Recurrence('2026-03-02 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_WEEKLY); + $r->setRecurInterval(2); + $r->setRecurOnDay(Horde_Date::MASK_MONDAY | Horde_Date::MASK_WEDNESDAY | Horde_Date::MASK_FRIDAY); + $r->setRecurCount(12); + + $rrule = $r->toRRule20($ical); + $this->assertStringContainsString('FREQ=WEEKLY', $rrule); + $this->assertStringContainsString('BYDAY=', $rrule); + + $r2 = new Horde_Date_Recurrence('2026-03-02 10:00:00'); + $r2->fromRRule20($rrule); + + $this->assertSame($r->getRecurType(), $r2->getRecurType()); + $this->assertEquals($r->getRecurInterval(), $r2->getRecurInterval()); + $this->assertSame($r->getRecurOnDays(), $r2->getRecurOnDays()); + } + + public function testRRule20RoundTripMonthlyDate(): void + { + $ical = new Horde_Icalendar(); + + $r = new Horde_Date_Recurrence('2026-03-15 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_MONTHLY_DATE); + $r->setRecurInterval(1); + $r->setRecurCount(6); + + $rrule = $r->toRRule20($ical); + + $r2 = new Horde_Date_Recurrence('2026-03-15 10:00:00'); + $r2->fromRRule20($rrule); + + $this->assertSame(Horde_Date_Recurrence::RECUR_MONTHLY_DATE, $r2->getRecurType()); + $this->assertEquals(1, $r2->getRecurInterval()); + } + + public function testRRule20RoundTripMonthlyLastWeekday(): void + { + $ical = new Horde_Icalendar(); + + // Last Thursday of month, starting Jan 29 2026 + $r = new Horde_Date_Recurrence('2026-01-29 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_MONTHLY_LAST_WEEKDAY); + $r->setRecurInterval(1); + $r->setRecurCount(5); + + $rrule = $r->toRRule20($ical); + $this->assertStringContainsString('BYDAY=-1', $rrule); + + $r2 = new Horde_Date_Recurrence('2026-01-29 10:00:00'); + $r2->fromRRule20($rrule); + + $this->assertSame(Horde_Date_Recurrence::RECUR_MONTHLY_LAST_WEEKDAY, $r2->getRecurType()); + } + + public function testRRule20RoundTripYearlyDate(): void + { + $ical = new Horde_Icalendar(); + + $r = new Horde_Date_Recurrence('2026-06-15 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_YEARLY_DATE); + $r->setRecurInterval(1); + $r->setRecurCount(5); + + $rrule = $r->toRRule20($ical); + + $r2 = new Horde_Date_Recurrence('2026-06-15 10:00:00'); + $r2->fromRRule20($rrule); + + $this->assertSame(Horde_Date_Recurrence::RECUR_YEARLY_DATE, $r2->getRecurType()); + $this->assertEquals(1, $r2->getRecurInterval()); + } + + public function testRRule20RoundTripYearlyDay(): void + { + $ical = new Horde_Icalendar(); + + $r = new Horde_Date_Recurrence('2026-04-10 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_YEARLY_DAY); + $r->setRecurInterval(1); + $r->setRecurCount(3); + + $rrule = $r->toRRule20($ical); + $this->assertStringContainsString('BYYEARDAY=', $rrule); + + $r2 = new Horde_Date_Recurrence('2026-04-10 10:00:00'); + $r2->fromRRule20($rrule); + + $this->assertSame(Horde_Date_Recurrence::RECUR_YEARLY_DAY, $r2->getRecurType()); + } + + public function testRRule20RoundTripYearlyWeekday(): void + { + $ical = new Horde_Icalendar(); + + // 1st Thursday of March 2026 = March 5 + $r = new Horde_Date_Recurrence('2026-03-05 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_YEARLY_WEEKDAY); + $r->setRecurInterval(1); + $r->setRecurCount(4); + + $rrule = $r->toRRule20($ical); + $this->assertStringContainsString('FREQ=YEARLY', $rrule); + $this->assertStringContainsString('BYDAY=', $rrule); + $this->assertStringContainsString('BYMONTH=', $rrule); + + $r2 = new Horde_Date_Recurrence('2026-03-05 10:00:00'); + $r2->fromRRule20($rrule); + + $this->assertSame(Horde_Date_Recurrence::RECUR_YEARLY_WEEKDAY, $r2->getRecurType()); + } + + public function testRRule10RoundTripDaily(): void + { + $ical = new Horde_Icalendar(); + + $r = new Horde_Date_Recurrence('2026-03-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurInterval(2); + $r->setRecurCount(5); + + $rrule10 = $r->toRRule10($ical); + $this->assertStringStartsWith('D2', $rrule10); + + $r2 = new Horde_Date_Recurrence('2026-03-01 10:00:00'); + $r2->fromRRule10($rrule10); + + $this->assertSame(Horde_Date_Recurrence::RECUR_DAILY, $r2->getRecurType()); + $this->assertEquals(2, $r2->getRecurInterval()); + } + + public function testRRule10RoundTripWeekly(): void + { + $ical = new Horde_Icalendar(); + + $r = new Horde_Date_Recurrence('2026-03-02 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_WEEKLY); + $r->setRecurInterval(1); + $r->setRecurOnDay(Horde_Date::MASK_MONDAY | Horde_Date::MASK_FRIDAY); + $r->setRecurCount(8); + + $rrule10 = $r->toRRule10($ical); + $this->assertStringStartsWith('W1', $rrule10); + $this->assertStringContainsString('MO', $rrule10); + $this->assertStringContainsString('FR', $rrule10); + + $r2 = new Horde_Date_Recurrence('2026-03-02 10:00:00'); + $r2->fromRRule10($rrule10); + + $this->assertSame(Horde_Date_Recurrence::RECUR_WEEKLY, $r2->getRecurType()); + $this->assertTrue((bool) $r2->recurOnDay(Horde_Date::MASK_MONDAY)); + $this->assertTrue((bool) $r2->recurOnDay(Horde_Date::MASK_FRIDAY)); + } + + public function testRRule10MonthlyLastWeekday(): void + { + $ical = new Horde_Icalendar(); + + $r = new Horde_Date_Recurrence('2026-01-29 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_MONTHLY_LAST_WEEKDAY); + $r->setRecurInterval(1); + $r->setRecurCount(3); + + $rrule10 = $r->toRRule10($ical); + $this->assertStringContainsString('MP', $rrule10); + $this->assertStringContainsString('1-', $rrule10); + + // The modern class correctly detects the minus sign in "1-", + // fixing the legacy trim() bug. Now returns MONTHLY_LAST_WEEKDAY. + $r2 = new Horde_Date_Recurrence('2026-01-29 10:00:00'); + $r2->fromRRule10($rrule10); + + // The modern class correctly detects the minus sign in "1-", + // fixing the legacy trim() bug. Now returns MONTHLY_LAST_WEEKDAY. + $this->assertSame(Horde_Date_Recurrence::RECUR_MONTHLY_LAST_WEEKDAY, $r2->getRecurType()); + } + + public function testFromRRule20DailyWithByday(): void + { + $r = new Horde_Date_Recurrence('2026-03-02 10:00:00'); + $r->fromRRule20('FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR'); + + $this->assertSame( + Horde_Date_Recurrence::RECUR_WEEKLY, + $r->getRecurType(), + 'DAILY with BYDAY should be converted to WEEKLY (Thunderbird workaround)' + ); + $this->assertTrue((bool) $r->recurOnDay(Horde_Date::MASK_MONDAY)); + $this->assertTrue((bool) $r->recurOnDay(Horde_Date::MASK_FRIDAY)); + $this->assertFalse((bool) $r->recurOnDay(Horde_Date::MASK_SATURDAY)); + } + + public function testRRule20NoneReturnsEmpty(): void + { + $ical = new Horde_Icalendar(); + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_NONE); + $this->assertSame('', $r->toRRule20($ical)); + $this->assertSame('', $r->toRRule10($ical)); + } + + public function testFromRRule20NoFreqSetsNone(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->fromRRule20('INVALID=RULE'); + $this->assertSame(Horde_Date_Recurrence::RECUR_NONE, $r->getRecurType()); + } + + public function testFromRRule10EmptyNoOp(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->fromRRule10(''); + $this->assertSame(Horde_Date_Recurrence::RECUR_NONE, $r->getRecurType()); + } + + // ========================================================================= + // Section 10: toHash() / fromHash() (supplement existing tests) + // ========================================================================= + + public function testToHashContainsAllKeys(): void + { + $r = new Horde_Date_Recurrence('2026-01-05 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_WEEKLY); + $r->setRecurInterval(2); + $r->setRecurOnDay(Horde_Date::MASK_MONDAY); + $r->setRecurCount(10); + $r->addException(2026, 1, 12); + $r->addCompletion(2026, 1, 19); + + $hash = $r->toHash(); + + $this->assertArrayHasKey('start', $hash); + $this->assertArrayHasKey('end', $hash); + $this->assertArrayHasKey('count', $hash); + $this->assertArrayHasKey('type', $hash); + $this->assertArrayHasKey('interval', $hash); + $this->assertArrayHasKey('data', $hash); + $this->assertArrayHasKey('exceptions', $hash); + $this->assertArrayHasKey('completions', $hash); + + $this->assertSame(Horde_Date_Recurrence::RECUR_WEEKLY, $hash['type']); + $this->assertSame(2, $hash['interval']); + $this->assertSame(10, $hash['count']); + } + + public function testFromHashRoundTrip(): void + { + $r = new Horde_Date_Recurrence('2026-03-15 14:30:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurInterval(3); + $r->setRecurCount(7); + $r->addException(2026, 3, 18); + + $hash = $r->toHash(); + $r2 = Horde_Date_Recurrence::fromHash($hash); + + $this->assertSame($r->getRecurType(), $r2->getRecurType()); + $this->assertSame($r->getRecurInterval(), $r2->getRecurInterval()); + $this->assertSame($r->getRecurCount(), $r2->getRecurCount()); + $this->assertSame($r->getExceptions(), $r2->getExceptions()); + } +} diff --git a/test/Unnamespaced/RecurrenceTest.php b/test/Unnamespaced/RecurrenceTest.php index 01bb02c..517a0b4 100644 --- a/test/Unnamespaced/RecurrenceTest.php +++ b/test/Unnamespaced/RecurrenceTest.php @@ -21,6 +21,7 @@ * @category Horde * @package Date * @subpackage UnitTests + * @coversNothing */ class RecurrenceTest extends TestCase { @@ -49,7 +50,7 @@ private function _getRecurrences($r) if (++$protect > 20) { return 'Infinite loop'; } - $recurrences[] = (string)$next; + $recurrences[] = (string) $next; $next->mday++; } return $recurrences; @@ -65,9 +66,9 @@ public function testDailyEnd() $this->assertEquals('FREQ=DAILY;INTERVAL=2;UNTIL=20070307T090000Z', $r->toRRule20($this->ical)); $this->assertEquals( ['2007-03-01 10:00:00', - '2007-03-03 10:00:00', - '2007-03-05 10:00:00', - '2007-03-07 10:00:00'], + '2007-03-03 10:00:00', + '2007-03-05 10:00:00', + '2007-03-07 10:00:00'], $this->_getRecurrences($r) ); $r->setRecurCount(4); @@ -83,9 +84,9 @@ public function testDailyCount() $this->assertEquals('FREQ=DAILY;INTERVAL=2;COUNT=4', $r->toRRule20($this->ical)); $this->assertEquals( ['2007-03-01 10:00:00', - '2007-03-03 10:00:00', - '2007-03-05 10:00:00', - '2007-03-07 10:00:00'], + '2007-03-03 10:00:00', + '2007-03-05 10:00:00', + '2007-03-07 10:00:00'], $this->_getRecurrences($r) ); } @@ -101,10 +102,10 @@ public function testWeeklyEnd() $this->assertEquals('FREQ=WEEKLY;INTERVAL=1;BYDAY=TH;UNTIL=20070329T080000Z', $r->toRRule20($this->ical)); $this->assertEquals( ['2007-03-01 10:00:00', - '2007-03-08 10:00:00', - '2007-03-15 10:00:00', - '2007-03-22 10:00:00', - '2007-03-29 10:00:00'], + '2007-03-08 10:00:00', + '2007-03-15 10:00:00', + '2007-03-22 10:00:00', + '2007-03-29 10:00:00'], $this->_getRecurrences($r) ); @@ -115,22 +116,22 @@ public function testWeeklyEnd() $r->setRecurEnd(new Horde_Date('2010-02-05 00:00:00')); $this->assertEquals( [ - '2009-09-28 08:00:00', - '2009-09-29 08:00:00', - '2009-09-30 08:00:00', - '2009-10-01 08:00:00', - '2009-10-02 08:00:00', - '2009-11-16 08:00:00', - '2009-11-17 08:00:00', - '2009-11-18 08:00:00', - '2009-11-19 08:00:00', - '2009-11-20 08:00:00', - '2010-01-04 08:00:00', - '2010-01-05 08:00:00', - '2010-01-06 08:00:00', - '2010-01-07 08:00:00', - '2010-01-08 08:00:00', - ], + '2009-09-28 08:00:00', + '2009-09-29 08:00:00', + '2009-09-30 08:00:00', + '2009-10-01 08:00:00', + '2009-10-02 08:00:00', + '2009-11-16 08:00:00', + '2009-11-17 08:00:00', + '2009-11-18 08:00:00', + '2009-11-19 08:00:00', + '2009-11-20 08:00:00', + '2010-01-04 08:00:00', + '2010-01-05 08:00:00', + '2010-01-06 08:00:00', + '2010-01-07 08:00:00', + '2010-01-08 08:00:00', + ], $this->_getRecurrences($r), 'Test for bug #8546' ); @@ -147,9 +148,9 @@ public function testWeeklyCount() $this->assertEquals('FREQ=WEEKLY;INTERVAL=1;BYDAY=TH;COUNT=4', $r->toRRule20($this->ical)); $this->assertEquals( ['2007-03-01 10:00:00', - '2007-03-08 10:00:00', - '2007-03-15 10:00:00', - '2007-03-22 10:00:00'], + '2007-03-08 10:00:00', + '2007-03-15 10:00:00', + '2007-03-22 10:00:00'], $this->_getRecurrences($r) ); $r->setRecurInterval(2); @@ -157,9 +158,9 @@ public function testWeeklyCount() $this->assertEquals('FREQ=WEEKLY;INTERVAL=2;BYDAY=TH;COUNT=4', $r->toRRule20($this->ical)); $this->assertEquals( ['2007-03-01 10:00:00', - '2007-03-15 10:00:00', - '2007-03-29 10:00:00', - '2007-04-12 10:00:00'], + '2007-03-15 10:00:00', + '2007-03-29 10:00:00', + '2007-04-12 10:00:00'], $this->_getRecurrences($r) ); } @@ -175,8 +176,8 @@ public function testWeeklyCountWithMultipleIncidencesPerWeekIfTheFirstIncidenceI $this->assertEquals('FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,SA;COUNT=3', $r->toRRule20($this->ical)); $this->assertEquals( ['2007-03-03 10:00:00', - '2007-03-05 10:00:00', - '2007-03-10 10:00:00',], + '2007-03-05 10:00:00', + '2007-03-10 10:00:00',], $this->_getRecurrences($r) ); } @@ -192,8 +193,8 @@ public function testWeeklyCountWithMultipleIncidencesPerWeek() $this->assertEquals('FREQ=WEEKLY;INTERVAL=1;BYDAY=TH,SA;COUNT=3', $r->toRRule20($this->ical)); $this->assertEquals( ['2007-03-01 10:00:00', - '2007-03-03 10:00:00', - '2007-03-08 10:00:00',], + '2007-03-03 10:00:00', + '2007-03-08 10:00:00',], $this->_getRecurrences($r) ); } @@ -209,8 +210,8 @@ public function testWeeklyCountWithMultipleIncidencesPerWeekAndIntervalLargerOne $this->assertEquals('FREQ=WEEKLY;INTERVAL=2;BYDAY=TH,SA;COUNT=3', $r->toRRule20($this->ical)); $this->assertEquals( ['2007-03-01 10:00:00', - '2007-03-03 10:00:00', - '2007-03-15 10:00:00',], + '2007-03-03 10:00:00', + '2007-03-15 10:00:00',], $this->_getRecurrences($r) ); } @@ -226,9 +227,9 @@ public function testWeeklyCountWithMultipleIncidencesPerWeekAndLastWeekIsComplet $this->assertEquals('FREQ=WEEKLY;INTERVAL=1;BYDAY=TH,SA;COUNT=4', $r->toRRule20($this->ical)); $this->assertEquals( ['2007-03-01 10:00:00', - '2007-03-03 10:00:00', - '2007-03-08 10:00:00', - '2007-03-10 10:00:00'], + '2007-03-03 10:00:00', + '2007-03-08 10:00:00', + '2007-03-10 10:00:00'], $this->_getRecurrences($r) ); } @@ -244,11 +245,11 @@ public function testWeeklyCountWithMultipleIncidencesPerWeekIfNextIncidenceIsNex $this->assertEquals('FREQ=WEEKLY;INTERVAL=1;BYDAY=WE,TH;COUNT=6', $r->toRRule20($this->ical)); $this->assertEquals( ['2009-11-11 06:00:00', - '2009-11-12 06:00:00', - '2009-11-18 06:00:00', - '2009-11-19 06:00:00', - '2009-11-25 06:00:00', - '2009-11-26 06:00:00'], + '2009-11-12 06:00:00', + '2009-11-18 06:00:00', + '2009-11-19 06:00:00', + '2009-11-25 06:00:00', + '2009-11-26 06:00:00'], $this->_getRecurrences($r) ); } @@ -258,11 +259,11 @@ public function testWeeklyCountWithMultipleIncidencesPerWeekIfNextIncidenceIsBeg $r = new Horde_Date_Recurrence('2009-11-09 06:00:00'); $r->setRecurType(Horde_Date_Recurrence::RECUR_WEEKLY); $r->setRecurOnDay( - Horde_Date::MASK_MONDAY | - Horde_Date::MASK_TUESDAY | - Horde_Date::MASK_WEDNESDAY | - Horde_Date::MASK_THURSDAY | - Horde_Date::MASK_FRIDAY + Horde_Date::MASK_MONDAY + | Horde_Date::MASK_TUESDAY + | Horde_Date::MASK_WEDNESDAY + | Horde_Date::MASK_THURSDAY + | Horde_Date::MASK_FRIDAY ); $r->setRecurInterval(1); $r->setRecurCount(6); @@ -270,11 +271,11 @@ public function testWeeklyCountWithMultipleIncidencesPerWeekIfNextIncidenceIsBeg $this->assertEquals('FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,TU,WE,TH,FR;COUNT=6', $r->toRRule20($this->ical)); $this->assertEquals( ['2009-11-09 06:00:00', - '2009-11-10 06:00:00', - '2009-11-11 06:00:00', - '2009-11-12 06:00:00', - '2009-11-13 06:00:00', - '2009-11-16 06:00:00'], + '2009-11-10 06:00:00', + '2009-11-11 06:00:00', + '2009-11-12 06:00:00', + '2009-11-13 06:00:00', + '2009-11-16 06:00:00'], $this->_getRecurrences($r) ); } @@ -305,8 +306,8 @@ public function testBiweeklySundayEvent() $this->assertEquals('FREQ=WEEKLY;INTERVAL=2;BYDAY=SU;COUNT=3', $r->toRRule20($this->ical)); $this->assertEquals( ['2009-11-29 06:00:00', - '2009-12-13 06:00:00', - '2009-12-27 06:00:00'], + '2009-12-13 06:00:00', + '2009-12-27 06:00:00'], $this->_getRecurrences($r) ); } @@ -323,16 +324,16 @@ public function testBug8799WeeklyISOWeek52() $after = new Horde_Date('12/21/2010'); for ($i = 0; $i <= 5; $i++) { $after = $r->nextRecurrence($after); - $recurrences[] = (string)$after; + $recurrences[] = (string) $after; $after->mday++; } $this->assertEquals( ['2010-12-24 10:00:00', - '2010-12-31 10:00:00', - '2011-01-07 10:00:00', - '2011-01-14 10:00:00', - '2011-01-21 10:00:00', - '2011-01-28 10:00:00'], + '2010-12-31 10:00:00', + '2011-01-07 10:00:00', + '2011-01-14 10:00:00', + '2011-01-21 10:00:00', + '2011-01-28 10:00:00'], $recurrences ); @@ -341,16 +342,16 @@ public function testBug8799WeeklyISOWeek52() $recurrences = []; for ($i = 0; $i <= 5; $i++) { $after = $r->nextRecurrence($after); - $recurrences[] = (string)$after; + $recurrences[] = (string) $after; $after->mday++; } $this->assertEquals( ['2012-01-06 10:00:00', - '2012-01-13 10:00:00', - '2012-01-20 10:00:00', - '2012-01-27 10:00:00', - '2012-02-03 10:00:00', - '2012-02-10 10:00:00'], + '2012-01-13 10:00:00', + '2012-01-20 10:00:00', + '2012-01-27 10:00:00', + '2012-02-03 10:00:00', + '2012-02-10 10:00:00'], $recurrences ); } @@ -364,7 +365,7 @@ public function testWeeklyISOWeek53() $recurrences = []; $after = new Horde_Date('1/1/2010'); - $after = (string)$r->nextRecurrence($after); + $after = (string) $r->nextRecurrence($after); $this->assertEquals('2010-01-05 10:00:00', $after); } @@ -378,8 +379,8 @@ public function testMonthlyEnd() $this->assertEquals('FREQ=MONTHLY;INTERVAL=1;UNTIL=20070501T080000Z', $r->toRRule20($this->ical)); $this->assertEquals( ['2007-03-01 10:00:00', - '2007-04-01 10:00:00', - '2007-05-01 10:00:00'], + '2007-04-01 10:00:00', + '2007-05-01 10:00:00'], $this->_getRecurrences($r) ); } @@ -394,9 +395,9 @@ public function testMonthlyCount() $this->assertEquals('FREQ=MONTHLY;INTERVAL=1;COUNT=4', $r->toRRule20($this->ical)); $this->assertEquals( ['2007-03-01 10:00:00', - '2007-04-01 10:00:00', - '2007-05-01 10:00:00', - '2007-06-01 10:00:00'], + '2007-04-01 10:00:00', + '2007-05-01 10:00:00', + '2007-06-01 10:00:00'], $this->_getRecurrences($r) ); $r->setRecurInterval(2); @@ -404,9 +405,9 @@ public function testMonthlyCount() $this->assertEquals('FREQ=MONTHLY;INTERVAL=2;COUNT=4', $r->toRRule20($this->ical)); $this->assertEquals( ['2007-03-01 10:00:00', - '2007-05-01 10:00:00', - '2007-07-01 10:00:00', - '2007-09-01 10:00:00'], + '2007-05-01 10:00:00', + '2007-07-01 10:00:00', + '2007-09-01 10:00:00'], $this->_getRecurrences($r) ); } @@ -421,7 +422,7 @@ public function testMonthlyDayEnd() $this->assertEquals('FREQ=MONTHLY;INTERVAL=1;BYDAY=1TH;UNTIL=20070501T080000Z', $r->toRRule20($this->ical)); $this->assertEquals( ['2007-03-01 10:00:00', - '2007-04-05 10:00:00'], + '2007-04-05 10:00:00'], $this->_getRecurrences($r) ); } @@ -436,9 +437,9 @@ public function testMonthlyDayCount() $this->assertEquals('FREQ=MONTHLY;INTERVAL=1;BYDAY=1TH;COUNT=4', $r->toRRule20($this->ical)); $this->assertEquals( ['2007-03-01 10:00:00', - '2007-04-05 10:00:00', - '2007-05-03 10:00:00', - '2007-06-07 10:00:00'], + '2007-04-05 10:00:00', + '2007-05-03 10:00:00', + '2007-06-07 10:00:00'], $this->_getRecurrences($r) ); @@ -461,13 +462,13 @@ public function testMonthlyWeekdayFifth() $this->assertEquals('FREQ=MONTHLY;INTERVAL=1;BYDAY=5TH', $r->toRRule20($this->ical)); $next = new Horde_Date('2012-06-01 00:00:00'); $next = $r->nextRecurrence($next); - $this->assertEquals('2012-08-30 10:00:00', (string)$next); + $this->assertEquals('2012-08-30 10:00:00', (string) $next); $next->mday++; $next = $r->nextRecurrence($next); - $this->assertEquals('2012-11-29 10:00:00', (string)$next); + $this->assertEquals('2012-11-29 10:00:00', (string) $next); $next->mday++; $next = $r->nextRecurrence($next); - $this->assertEquals('2013-01-31 10:00:00', (string)$next); + $this->assertEquals('2013-01-31 10:00:00', (string) $next); } public function testMonthlyLastWeekday() @@ -479,16 +480,16 @@ public function testMonthlyLastWeekday() $this->assertEquals('FREQ=MONTHLY;INTERVAL=1;BYDAY=-1TH', $r->toRRule20($this->ical)); $next = new Horde_Date('2012-06-01 00:00:00'); $next = $r->nextRecurrence($next); - $this->assertEquals('2012-06-28 10:00:00', (string)$next); + $this->assertEquals('2012-06-28 10:00:00', (string) $next); $next->mday++; $next = $r->nextRecurrence($next); - $this->assertEquals('2012-07-26 10:00:00', (string)$next); + $this->assertEquals('2012-07-26 10:00:00', (string) $next); $next->mday++; $next = $r->nextRecurrence($next); - $this->assertEquals('2012-08-30 10:00:00', (string)$next); + $this->assertEquals('2012-08-30 10:00:00', (string) $next); $next->mday++; $next = $r->nextRecurrence($next); - $this->assertEquals('2012-09-27 10:00:00', (string)$next); + $this->assertEquals('2012-09-27 10:00:00', (string) $next); } public function testYearlyDateNoEnd() @@ -508,8 +509,8 @@ public function testYearlyDateEnd() $this->assertEquals('FREQ=YEARLY;INTERVAL=1;UNTIL=20090301T090000Z', $r->toRRule20($this->ical)); $this->assertEquals( ['2007-03-01 10:00:00', - '2008-03-01 10:00:00', - '2009-03-01 10:00:00'], + '2008-03-01 10:00:00', + '2009-03-01 10:00:00'], $this->_getRecurrences($r) ); } @@ -523,9 +524,9 @@ public function testYearlyDateCount() $this->assertEquals('FREQ=YEARLY;INTERVAL=1;COUNT=4', $r->toRRule20($this->ical)); $this->assertEquals( ['2007-03-01 10:00:00', - '2008-03-01 10:00:00', - '2009-03-01 10:00:00', - '2010-03-01 10:00:00'], + '2008-03-01 10:00:00', + '2009-03-01 10:00:00', + '2010-03-01 10:00:00'], $this->_getRecurrences($r) ); @@ -533,12 +534,12 @@ public function testYearlyDateCount() $r->setRecurType(Horde_Date_Recurrence::RECUR_YEARLY_DATE); $r->setRecurEnd(new Horde_Date('2011-04-25 23:00:00')); $r->setRecurInterval(2); - $this->assertEquals('2009-04-25 12:00:00', (string)$r->nextRecurrence(new Horde_Date('2009-03-30 00:00:00'))); + $this->assertEquals('2009-04-25 12:00:00', (string) $r->nextRecurrence(new Horde_Date('2009-03-30 00:00:00'))); $r = new Horde_Date_Recurrence('2008-02-29 00:00:00'); $r->setRecurType(Horde_Date_Recurrence::RECUR_YEARLY_DATE); $r->setRecurInterval(1); - $this->assertEquals('2012-02-29 00:00:00', (string)$r->nextRecurrence(new Horde_Date('2008-03-01 00:00:00'))); + $this->assertEquals('2012-02-29 00:00:00', (string) $r->nextRecurrence(new Horde_Date('2008-03-01 00:00:00'))); } public function testYearlyDayEnd() @@ -550,8 +551,8 @@ public function testYearlyDayEnd() $this->assertEquals('FREQ=YEARLY;INTERVAL=1;BYYEARDAY=60;UNTIL=20090301T090000Z', $r->toRRule20($this->ical)); $this->assertEquals( ['2007-03-01 10:00:00', - '2008-02-29 10:00:00', - '2009-03-01 10:00:00'], + '2008-02-29 10:00:00', + '2009-03-01 10:00:00'], $this->_getRecurrences($r) ); } @@ -565,9 +566,9 @@ public function testYearlyDayCount() $this->assertEquals('FREQ=YEARLY;INTERVAL=1;BYYEARDAY=60;COUNT=4', $r->toRRule20($this->ical)); $this->assertEquals( ['2007-03-01 10:00:00', - '2008-02-29 10:00:00', - '2009-03-01 10:00:00', - '2010-03-01 10:00:00'], + '2008-02-29 10:00:00', + '2009-03-01 10:00:00', + '2010-03-01 10:00:00'], $this->_getRecurrences($r) ); } @@ -581,7 +582,7 @@ public function testYearlyWeekEnd() $this->assertEquals('FREQ=YEARLY;INTERVAL=1;BYDAY=1TH;BYMONTH=3;UNTIL=20090301T090000Z', $r->toRRule20($this->ical)); $this->assertEquals( ['2007-03-01 10:00:00', - '2008-03-06 10:00:00'], + '2008-03-06 10:00:00'], $this->_getRecurrences($r) ); } @@ -595,9 +596,9 @@ public function testYearlyWeekCount() $this->assertEquals('FREQ=YEARLY;INTERVAL=1;BYDAY=1TH;BYMONTH=3;COUNT=4', $r->toRRule20($this->ical)); $this->assertEquals( ['2007-03-01 10:00:00', - '2008-03-06 10:00:00', - '2009-03-05 10:00:00', - '2010-03-04 10:00:00'], + '2008-03-06 10:00:00', + '2009-03-05 10:00:00', + '2010-03-04 10:00:00'], $this->_getRecurrences($r) ); @@ -615,14 +616,14 @@ public function testParseDaily() $this->assertEquals(2, $r->getRecurInterval()); $this->assertNull($r->getRecurOnDays()); $this->assertNull($r->getRecurCount()); - $this->assertEquals('2007-03-07 00:00:00', (string)$r->recurEnd); + $this->assertEquals('2007-03-07 00:00:00', (string) $r->recurEnd); $r->fromRRule10('D2 20070308T090000Z'); $this->assertEquals(Horde_Date_Recurrence::RECUR_DAILY, $r->getRecurType()); $this->assertEquals(2, $r->getRecurInterval()); $this->assertNull($r->getRecurOnDays()); $this->assertNull($r->getRecurCount()); - $this->assertEquals('2007-03-08 09:00:00', (string)$r->recurEnd->setTimezone('UTC')); + $this->assertEquals('2007-03-08 09:00:00', (string) $r->recurEnd->setTimezone('UTC')); $r->fromRRule10('D2 #4'); $this->assertEquals(Horde_Date_Recurrence::RECUR_DAILY, $r->getRecurType()); @@ -635,14 +636,14 @@ public function testParseDaily() $this->assertEquals(2, $r->getRecurInterval()); $this->assertNull($r->getRecurOnDays()); $this->assertNull($r->getRecurCount()); - $this->assertEquals('2007-03-08 00:00:00', (string)$r->recurEnd); + $this->assertEquals('2007-03-08 00:00:00', (string) $r->recurEnd); $r->fromRRule20('FREQ=DAILY;INTERVAL=2;UNTIL=20070308T090000Z'); $this->assertEquals(Horde_Date_Recurrence::RECUR_DAILY, $r->getRecurType()); $this->assertEquals(2, $r->getRecurInterval()); $this->assertNull($r->getRecurOnDays()); $this->assertNull($r->getRecurCount()); - $this->assertEquals('2007-03-08 09:00:00', (string)$r->recurEnd->setTimezone('UTC')); + $this->assertEquals('2007-03-08 09:00:00', (string) $r->recurEnd->setTimezone('UTC')); $r->fromRRule20('FREQ=DAILY;INTERVAL=2;COUNT=4'); $this->assertEquals(Horde_Date_Recurrence::RECUR_DAILY, $r->getRecurType()); @@ -659,21 +660,21 @@ public function testParseWeekly() $this->assertEquals(1, $r->getRecurInterval()); $this->assertEquals(Horde_Date::MASK_THURSDAY, $r->getRecurOnDays()); $this->assertNull($r->getRecurCount()); - $this->assertEquals('2007-03-29 00:00:00', (string)$r->recurEnd); + $this->assertEquals('2007-03-29 00:00:00', (string) $r->recurEnd); $r->fromRRule10('W1 TH 20070330T080000Z'); $this->assertEquals(Horde_Date_Recurrence::RECUR_WEEKLY, $r->getRecurType()); $this->assertEquals(1, $r->getRecurInterval()); $this->assertEquals(Horde_Date::MASK_THURSDAY, $r->getRecurOnDays()); $this->assertNull($r->getRecurCount()); - $this->assertEquals('2007-03-30 08:00:00', (string)$r->recurEnd->setTimezone('UTC')); + $this->assertEquals('2007-03-30 08:00:00', (string) $r->recurEnd->setTimezone('UTC')); $r->fromRRule10('W1 SU MO TU WE TH FR SA 20070603T235959'); $this->assertEquals(Horde_Date_Recurrence::RECUR_WEEKLY, $r->getRecurType()); $this->assertEquals(1, $r->getRecurInterval()); $this->assertEquals(Horde_Date::MASK_ALLDAYS, $r->getRecurOnDays()); $this->assertNull($r->getRecurCount()); - $this->assertEquals('2007-06-03 23:59:59', (string)$r->recurEnd); + $this->assertEquals('2007-06-03 23:59:59', (string) $r->recurEnd); $r->fromRRule10('W1 TH #4'); $this->assertEquals(Horde_Date_Recurrence::RECUR_WEEKLY, $r->getRecurType()); @@ -692,14 +693,14 @@ public function testParseWeekly() $this->assertEquals(1, $r->getRecurInterval()); $this->assertEquals(Horde_Date::MASK_THURSDAY, $r->getRecurOnDays()); $this->assertNull($r->getRecurCount()); - $this->assertEquals('2007-03-30 00:00:00', (string)$r->recurEnd); + $this->assertEquals('2007-03-30 00:00:00', (string) $r->recurEnd); $r->fromRRule20('FREQ=WEEKLY;INTERVAL=1;BYDAY=TH;UNTIL=20070330T080000Z'); $this->assertEquals(Horde_Date_Recurrence::RECUR_WEEKLY, $r->getRecurType()); $this->assertEquals(1, $r->getRecurInterval()); $this->assertEquals(Horde_Date::MASK_THURSDAY, $r->getRecurOnDays()); $this->assertNull($r->getRecurCount()); - $this->assertEquals('2007-03-30 08:00:00', (string)$r->recurEnd->setTimezone('UTC')); + $this->assertEquals('2007-03-30 08:00:00', (string) $r->recurEnd->setTimezone('UTC')); $r->fromRRule20('FREQ=WEEKLY;INTERVAL=1;BYDAY=TH;COUNT=4'); $this->assertEquals(Horde_Date_Recurrence::RECUR_WEEKLY, $r->getRecurType()); @@ -723,7 +724,7 @@ public function testParseWeeklyWithBrokenRule() $this->assertEquals(Horde_Date_Recurrence::RECUR_WEEKLY, $r->getRecurType()); $this->assertEquals(1, $r->getRecurInterval()); $this->assertEquals(Horde_Date::MASK_WEDNESDAY, $r->getRecurOnDays()); - $this->assertEquals('2010-11-03 08:00:00', (string)$r->recurEnd); + $this->assertEquals('2010-11-03 08:00:00', (string) $r->recurEnd); } public function testParseMonthlyDate() @@ -734,14 +735,14 @@ public function testParseMonthlyDate() $this->assertEquals(1, $r->getRecurInterval()); $this->assertNull($r->getRecurOnDays()); $this->assertNull($r->getRecurCount()); - $this->assertEquals('2007-05-01 00:00:00', (string)$r->recurEnd); + $this->assertEquals('2007-05-01 00:00:00', (string) $r->recurEnd); $r->fromRRule10('MD1 1 20070502T080000Z'); $this->assertEquals(Horde_Date_Recurrence::RECUR_MONTHLY_DATE, $r->getRecurType()); $this->assertEquals(1, $r->getRecurInterval()); $this->assertNull($r->getRecurOnDays()); $this->assertNull($r->getRecurCount()); - $this->assertEquals('2007-05-02 08:00:00', (string)$r->recurEnd->setTimezone('UTC')); + $this->assertEquals('2007-05-02 08:00:00', (string) $r->recurEnd->setTimezone('UTC')); $r->fromRRule10('MD1 1 #4'); $this->assertEquals(Horde_Date_Recurrence::RECUR_MONTHLY_DATE, $r->getRecurType()); @@ -760,14 +761,14 @@ public function testParseMonthlyDate() $this->assertEquals(1, $r->getRecurInterval()); $this->assertNull($r->getRecurOnDays()); $this->assertNull($r->getRecurCount()); - $this->assertEquals('2007-05-02 00:00:00', (string)$r->recurEnd); + $this->assertEquals('2007-05-02 00:00:00', (string) $r->recurEnd); $r->fromRRule20('FREQ=MONTHLY;INTERVAL=1;UNTIL=20070502T080000Z'); $this->assertEquals(Horde_Date_Recurrence::RECUR_MONTHLY_DATE, $r->getRecurType()); $this->assertEquals(1, $r->getRecurInterval()); $this->assertNull($r->getRecurOnDays()); $this->assertNull($r->getRecurCount()); - $this->assertEquals('2007-05-02 08:00:00', (string)$r->recurEnd->setTimezone('UTC')); + $this->assertEquals('2007-05-02 08:00:00', (string) $r->recurEnd->setTimezone('UTC')); $r->fromRRule20('FREQ=MONTHLY;INTERVAL=1;COUNT=4'); $this->assertEquals(Horde_Date_Recurrence::RECUR_MONTHLY_DATE, $r->getRecurType()); @@ -790,14 +791,14 @@ public function testParseMonthlyWeekday() $this->assertEquals(1, $r->getRecurInterval()); $this->assertNull($r->getRecurOnDays()); $this->assertNull($r->getRecurCount()); - $this->assertEquals('2007-05-01 00:00:00', (string)$r->recurEnd); + $this->assertEquals('2007-05-01 00:00:00', (string) $r->recurEnd); $r->fromRRule10('MP1 1+ TH 20070502T080000Z'); $this->assertEquals(Horde_Date_Recurrence::RECUR_MONTHLY_WEEKDAY, $r->getRecurType()); $this->assertEquals(1, $r->getRecurInterval()); $this->assertNull($r->getRecurOnDays()); $this->assertNull($r->getRecurCount()); - $this->assertEquals('2007-05-02 08:00:00', (string)$r->recurEnd->setTimezone('UTC')); + $this->assertEquals('2007-05-02 08:00:00', (string) $r->recurEnd->setTimezone('UTC')); $r->fromRRule10('MP1 1+ TH #4'); $this->assertEquals(Horde_Date_Recurrence::RECUR_MONTHLY_WEEKDAY, $r->getRecurType()); @@ -810,14 +811,14 @@ public function testParseMonthlyWeekday() $this->assertEquals(1, $r->getRecurInterval()); $this->assertNull($r->getRecurOnDays()); $this->assertNull($r->getRecurCount()); - $this->assertEquals('2007-05-02 00:00:00', (string)$r->recurEnd); + $this->assertEquals('2007-05-02 00:00:00', (string) $r->recurEnd); $r->fromRRule20('FREQ=MONTHLY;INTERVAL=1;BYDAY=1TH;UNTIL=20070502T080000Z'); $this->assertEquals(Horde_Date_Recurrence::RECUR_MONTHLY_WEEKDAY, $r->getRecurType()); $this->assertEquals(1, $r->getRecurInterval()); $this->assertNull($r->getRecurOnDays()); $this->assertNull($r->getRecurCount()); - $this->assertEquals('2007-05-02 08:00:00', (string)$r->recurEnd->setTimezone('UTC')); + $this->assertEquals('2007-05-02 08:00:00', (string) $r->recurEnd->setTimezone('UTC')); $r->fromRRule20('FREQ=MONTHLY;INTERVAL=1;BYDAY=1TH;COUNT=4'); $this->assertEquals(Horde_Date_Recurrence::RECUR_MONTHLY_WEEKDAY, $r->getRecurType()); @@ -834,14 +835,14 @@ public function testParseYearlyDate() $this->assertEquals(1, $r->getRecurInterval()); $this->assertNull($r->getRecurOnDays()); $this->assertNull($r->getRecurCount()); - $this->assertEquals('2009-03-01 00:00:00', (string)$r->recurEnd); + $this->assertEquals('2009-03-01 00:00:00', (string) $r->recurEnd); $r->fromRRule10('YM1 3 20090302T090000Z'); $this->assertEquals(Horde_Date_Recurrence::RECUR_YEARLY_DATE, $r->getRecurType()); $this->assertEquals(1, $r->getRecurInterval()); $this->assertNull($r->getRecurOnDays()); $this->assertNull($r->getRecurCount()); - $this->assertEquals('2009-03-02 09:00:00', (string)$r->recurEnd->setTimezone('UTC')); + $this->assertEquals('2009-03-02 09:00:00', (string) $r->recurEnd->setTimezone('UTC')); $r->fromRRule10('YM1 3 #4'); $this->assertEquals(Horde_Date_Recurrence::RECUR_YEARLY_DATE, $r->getRecurType()); @@ -854,14 +855,14 @@ public function testParseYearlyDate() $this->assertEquals(1, $r->getRecurInterval()); $this->assertNull($r->getRecurOnDays()); $this->assertNull($r->getRecurCount()); - $this->assertEquals('2009-03-02 00:00:00', (string)$r->recurEnd); + $this->assertEquals('2009-03-02 00:00:00', (string) $r->recurEnd); $r->fromRRule20('FREQ=YEARLY;INTERVAL=1;UNTIL=20090302T090000Z'); $this->assertEquals(Horde_Date_Recurrence::RECUR_YEARLY_DATE, $r->getRecurType()); $this->assertEquals(1, $r->getRecurInterval()); $this->assertNull($r->getRecurOnDays()); $this->assertNull($r->getRecurCount()); - $this->assertEquals('2009-03-02 09:00:00', (string)$r->recurEnd->setTimezone('UTC')); + $this->assertEquals('2009-03-02 09:00:00', (string) $r->recurEnd->setTimezone('UTC')); $r->fromRRule20('FREQ=YEARLY;INTERVAL=1;COUNT=4'); $this->assertEquals(Horde_Date_Recurrence::RECUR_YEARLY_DATE, $r->getRecurType()); @@ -878,14 +879,14 @@ public function testParseYearlyDay() $this->assertEquals(1, $r->getRecurInterval()); $this->assertNull($r->getRecurOnDays()); $this->assertNull($r->getRecurCount()); - $this->assertEquals('2009-03-01 00:00:00', (string)$r->recurEnd); + $this->assertEquals('2009-03-01 00:00:00', (string) $r->recurEnd); $r->fromRRule10('YD1 60 20090302T090000Z'); $this->assertEquals(Horde_Date_Recurrence::RECUR_YEARLY_DAY, $r->getRecurType()); $this->assertEquals(1, $r->getRecurInterval()); $this->assertNull($r->getRecurOnDays()); $this->assertNull($r->getRecurCount()); - $this->assertEquals('2009-03-02 09:00:00', (string)$r->recurEnd->setTimezone('UTC')); + $this->assertEquals('2009-03-02 09:00:00', (string) $r->recurEnd->setTimezone('UTC')); $r->fromRRule10('YD1 60 #4'); $this->assertEquals(Horde_Date_Recurrence::RECUR_YEARLY_DAY, $r->getRecurType()); @@ -898,14 +899,14 @@ public function testParseYearlyDay() $this->assertEquals(1, $r->getRecurInterval()); $this->assertNull($r->getRecurOnDays()); $this->assertNull($r->getRecurCount()); - $this->assertEquals('2009-03-02 00:00:00', (string)$r->recurEnd); + $this->assertEquals('2009-03-02 00:00:00', (string) $r->recurEnd); $r->fromRRule20('FREQ=YEARLY;INTERVAL=1;BYYEARDAY=60;UNTIL=20090302T090000Z'); $this->assertEquals(Horde_Date_Recurrence::RECUR_YEARLY_DAY, $r->getRecurType()); $this->assertEquals(1, $r->getRecurInterval()); $this->assertNull($r->getRecurOnDays()); $this->assertNull($r->getRecurCount()); - $this->assertEquals('2009-03-02 09:00:00', (string)$r->recurEnd->setTimezone('UTC')); + $this->assertEquals('2009-03-02 09:00:00', (string) $r->recurEnd->setTimezone('UTC')); $r->fromRRule20('FREQ=YEARLY;INTERVAL=1;BYYEARDAY=60;COUNT=4'); $this->assertEquals(Horde_Date_Recurrence::RECUR_YEARLY_DAY, $r->getRecurType()); @@ -922,14 +923,14 @@ public function testParseYearlyWeekday() $this->assertEquals(1, $r->getRecurInterval()); $this->assertNull($r->getRecurOnDays()); $this->assertNull($r->getRecurCount()); - $this->assertEquals('2009-03-02 00:00:00', (string)$r->recurEnd); + $this->assertEquals('2009-03-02 00:00:00', (string) $r->recurEnd); $r->fromRRule20('FREQ=YEARLY;INTERVAL=1;BYDAY=1TH;BYMONTH=3;UNTIL=20090302T090000Z'); $this->assertEquals(Horde_Date_Recurrence::RECUR_YEARLY_WEEKDAY, $r->getRecurType()); $this->assertEquals(1, $r->getRecurInterval()); $this->assertNull($r->getRecurOnDays()); $this->assertNull($r->getRecurCount()); - $this->assertEquals('2009-03-02 09:00:00', (string)$r->recurEnd->setTimezone('UTC')); + $this->assertEquals('2009-03-02 09:00:00', (string) $r->recurEnd->setTimezone('UTC')); $r->fromRRule20('FREQ=YEARLY;INTERVAL=1;BYDAY=1TH;BYMONTH=3;COUNT=4'); $this->assertEquals(Horde_Date_Recurrence::RECUR_YEARLY_WEEKDAY, $r->getRecurType()); @@ -1083,13 +1084,13 @@ public function testBug2813RecurrenceEndFromIcalendar() $after = ['year' => 2006, 'month' => 6]; $after['mday'] = 16; - $this->assertEquals('2006-06-16 18:00:00', (string)$recurrence->nextRecurrence($after)); + $this->assertEquals('2006-06-16 18:00:00', (string) $recurrence->nextRecurrence($after)); $after['mday'] = 17; - $this->assertEquals('2006-06-17 18:00:00', (string)$recurrence->nextRecurrence($after)); + $this->assertEquals('2006-06-17 18:00:00', (string) $recurrence->nextRecurrence($after)); $after['mday'] = 18; - $this->assertEquals('', (string)$recurrence->nextRecurrence($after)); + $this->assertEquals('', (string) $recurrence->nextRecurrence($after)); } public function testBug4626MonthlyByDayRRule() @@ -1133,7 +1134,7 @@ public function testRecurrenceObjectWithNonDefaultTimezones() $next = $rrule->nextRecurrence($after); $this->assertInstanceOf('Horde_Date', $next); - $this->assertEquals('2011-10-29 15:00:00', (string)$next); + $this->assertEquals('2011-10-29 15:00:00', (string) $next); $this->assertEquals('America/New_York', $next->timezone); } @@ -1157,13 +1158,13 @@ public function testBug12869RecurrenceEndFromIcalendar() $after = ['year' => 2013, 'month' => 12]; $after['mday'] = 11; - $this->assertEquals('2013-12-12 13:45:00', (string)$recurrence->nextRecurrence($after)); + $this->assertEquals('2013-12-12 13:45:00', (string) $recurrence->nextRecurrence($after)); $after['mday'] = 18; - $this->assertEquals('2013-12-19 13:45:00', (string)$recurrence->nextRecurrence($after)); + $this->assertEquals('2013-12-19 13:45:00', (string) $recurrence->nextRecurrence($after)); $after['mday'] = 20; - $this->assertEquals('', (string)$recurrence->nextRecurrence($after)); + $this->assertEquals('', (string) $recurrence->nextRecurrence($after)); date_default_timezone_set('Europe/Berlin'); } @@ -1186,28 +1187,28 @@ public function testBug15054ThunderbirdWorkday() // Recurrence must not include weekend // Thursday, checking for thursday - $dtInput = new \Horde_Date('20210318T080000', 'Europe/Berlin'); - $dtExpected = new \Horde_Date('20210318T090000', 'Europe/Berlin'); + $dtInput = new Horde_Date('20210318T080000', 'Europe/Berlin'); + $dtExpected = new Horde_Date('20210318T090000', 'Europe/Berlin'); $this->assertEquals($dtExpected->timestamp(), $recurrence->nextRecurrence($dtInput)->timestamp()); // Friday, checking for friday - $dtInput = new \Horde_Date('20210319T080000', 'Europe/Berlin'); - $dtExpected = new \Horde_Date('20210319T090000', 'Europe/Berlin'); + $dtInput = new Horde_Date('20210319T080000', 'Europe/Berlin'); + $dtExpected = new Horde_Date('20210319T090000', 'Europe/Berlin'); $this->assertEquals($dtExpected->timestamp(), $recurrence->nextRecurrence($dtInput)->timestamp()); // Saturday, checking for monday - $dtInput = new \Horde_Date('20210320T080000', 'Europe/Berlin'); - $dtExpected = new \Horde_Date('20210322T090000', 'Europe/Berlin'); + $dtInput = new Horde_Date('20210320T080000', 'Europe/Berlin'); + $dtExpected = new Horde_Date('20210322T090000', 'Europe/Berlin'); $this->assertEquals($dtExpected->toJSON(), $recurrence->nextRecurrence($dtInput)->toJSON()); // Sunday, checking for monday - $dtInput = new \Horde_Date('20210321T080000', 'Europe/Berlin'); - $dtExpected = new \Horde_Date('20210322T090000', 'Europe/Berlin'); + $dtInput = new Horde_Date('20210321T080000', 'Europe/Berlin'); + $dtExpected = new Horde_Date('20210322T090000', 'Europe/Berlin'); $this->assertEquals($dtExpected->timestamp(), $recurrence->nextRecurrence($dtInput)->timestamp()); // monday, checking for monday - $dtInput = new \Horde_Date('20210322T080000', 'Europe/Berlin'); - $dtExpected = new \Horde_Date('20210322T090000', 'Europe/Berlin'); + $dtInput = new Horde_Date('20210322T080000', 'Europe/Berlin'); + $dtExpected = new Horde_Date('20210322T090000', 'Europe/Berlin'); $this->assertEquals($dtExpected->timestamp(), $recurrence->nextRecurrence($dtInput)->timestamp()); // tuesday, checking for tuesday - $dtInput = new \Horde_Date('20210323T080000', 'Europe/Berlin'); - $dtExpected = new \Horde_Date('20210323T090000', 'Europe/Berlin'); + $dtInput = new Horde_Date('20210323T080000', 'Europe/Berlin'); + $dtExpected = new Horde_Date('20210323T090000', 'Europe/Berlin'); $this->assertEquals($dtExpected->timestamp(), $recurrence->nextRecurrence($dtInput)->timestamp()); } } diff --git a/test/Unnamespaced/RecurrenceWrapperReadyTest.php b/test/Unnamespaced/RecurrenceWrapperReadyTest.php new file mode 100644 index 0000000..ec4b806 --- /dev/null +++ b/test/Unnamespaced/RecurrenceWrapperReadyTest.php @@ -0,0 +1,967 @@ +oldTimezone = date_default_timezone_get(); + date_default_timezone_set('UTC'); + } + + protected function tearDown(): void + { + date_default_timezone_set($this->oldTimezone); + } + + // ========================================================================= + // Section 1: Direct property read — types and values + // ========================================================================= + + public function testStartPropertyIsHordeDate(): void + { + $r = new Horde_Date_Recurrence('2026-06-15 14:30:00'); + $this->assertInstanceOf(Horde_Date::class, $r->start); + $this->assertSame('2026-06-15', $r->start->format('Y-m-d')); + $this->assertSame('14:30:00', $r->start->format('H:i:s')); + } + + public function testStartPropertyClonedOnConstruction(): void + { + $orig = new Horde_Date('2026-01-01'); + $r = new Horde_Date_Recurrence($orig); + $orig->year = 2099; + $this->assertSame('2026-01-01', $r->start->format('Y-m-d')); + } + + public function testRecurTypeDefaultNone(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $this->assertSame(Horde_Date_Recurrence::RECUR_NONE, $r->recurType); + } + + public function testRecurIntervalDefault1(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $this->assertSame(1, $r->recurInterval); + } + + public function testRecurEndDefaultNull(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $this->assertNull($r->recurEnd); + } + + public function testRecurCountDefaultNull(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $this->assertNull($r->recurCount); + } + + public function testRecurDataDefaultNull(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $this->assertNull($r->recurData); + } + + public function testExceptionsDefaultEmpty(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $this->assertSame([], $r->exceptions); + } + + public function testCompletionsDefaultEmpty(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $this->assertSame([], $r->completions); + } + + // ========================================================================= + // Section 2: Direct property write → read round-trip + // ========================================================================= + + public function testWriteRecurType(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $r->recurType = Horde_Date_Recurrence::RECUR_WEEKLY; + $this->assertSame(Horde_Date_Recurrence::RECUR_WEEKLY, $r->recurType); + $this->assertSame(Horde_Date_Recurrence::RECUR_WEEKLY, $r->getRecurType()); + } + + public function testWriteRecurInterval(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $r->recurInterval = 5; + $this->assertSame(5, $r->recurInterval); + $this->assertSame(5, $r->getRecurInterval()); + } + + public function testWriteRecurEnd(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $r->recurEnd = new Horde_Date('2026-12-31'); + $this->assertInstanceOf(Horde_Date::class, $r->recurEnd); + $this->assertSame('2026-12-31', $r->recurEnd->format('Y-m-d')); + } + + public function testWriteRecurCount(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $r->recurCount = 7; + $this->assertSame(7, $r->recurCount); + } + + public function testWriteRecurData(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $r->recurData = Horde_Date::MASK_MONDAY | Horde_Date::MASK_FRIDAY; + $this->assertSame(Horde_Date::MASK_MONDAY | Horde_Date::MASK_FRIDAY, $r->recurData); + } + + public function testWriteExceptions(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $r->exceptions = ['20260115', '20260220']; + $this->assertSame(['20260115', '20260220'], $r->exceptions); + } + + public function testWriteCompletions(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $r->completions = ['20260115']; + $this->assertSame(['20260115'], $r->completions); + } + + // ========================================================================= + // Section 3: Setter/getter consistency with property access + // ========================================================================= + + public function testSetRecurTypeReflectedInProperty(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $this->assertSame(Horde_Date_Recurrence::RECUR_DAILY, $r->recurType); + } + + public function testSetRecurIntervalReflectedInProperty(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $r->setRecurInterval(3); + $this->assertSame(3, $r->recurInterval); + } + + public function testSetRecurEndReflectedInProperty(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $r->setRecurEnd(new Horde_Date('2026-06-30 23:59:59')); + $this->assertInstanceOf(Horde_Date::class, $r->recurEnd); + $this->assertSame('2026-06-30', $r->recurEnd->format('Y-m-d')); + } + + public function testSetRecurCountReflectedInProperty(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $r->setRecurCount(10); + $this->assertSame(10, $r->recurCount); + } + + public function testSetRecurOnDayReflectedInRecurData(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $mask = Horde_Date::MASK_MONDAY | Horde_Date::MASK_WEDNESDAY; + $r->setRecurOnDay($mask); + $this->assertSame($mask, $r->recurData); + } + + public function testSetRecurStartReflectedInStartProperty(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $r->setRecurStart(new Horde_Date('2026-07-04 12:00:00')); + $this->assertSame('2026-07-04', $r->start->format('Y-m-d')); + } + + // ========================================================================= + // Section 4: Property write → method behavior + // (set via property, then call a method that depends on it) + // ========================================================================= + + public function testPropertyWriteThenNextRecurrence(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->recurType = Horde_Date_Recurrence::RECUR_DAILY; + $r->recurInterval = 2; + $next = $r->nextRecurrence(new Horde_Date('2026-01-02')); + $this->assertInstanceOf(Horde_Date::class, $next); + $this->assertSame('2026-01-03', $next->format('Y-m-d')); + } + + public function testPropertyWriteRecurDataThenWeeklyRecurrence(): void + { + $r = new Horde_Date_Recurrence('2026-01-05 10:00:00'); + $r->recurType = Horde_Date_Recurrence::RECUR_WEEKLY; + $r->recurInterval = 1; + $r->recurData = Horde_Date::MASK_MONDAY | Horde_Date::MASK_FRIDAY; + $next = $r->nextRecurrence(new Horde_Date('2026-01-06')); + $this->assertInstanceOf(Horde_Date::class, $next); + $this->assertSame('2026-01-09', $next->format('Y-m-d')); + } + + public function testPropertyWriteRecurCountThenDailyLimited(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->recurType = Horde_Date_Recurrence::RECUR_DAILY; + $r->recurInterval = 1; + $r->recurCount = 3; + $dates = []; + $after = new Horde_Date('2026-01-01'); + while ($next = $r->nextRecurrence($after)) { + if (count($dates) >= 10) { + break; + } + $dates[] = $next->format('Y-m-d'); + $after = clone $next; + $after->mday++; + } + $this->assertSame(['2026-01-01', '2026-01-02', '2026-01-03'], $dates); + } + + public function testPropertyWriteRecurEndThenDailyLimited(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->recurType = Horde_Date_Recurrence::RECUR_DAILY; + $r->recurInterval = 1; + $r->recurEnd = new Horde_Date('2026-01-03 23:59:59'); + $dates = []; + $after = new Horde_Date('2026-01-01'); + while ($next = $r->nextRecurrence($after)) { + if (count($dates) >= 10) { + break; + } + $dates[] = $next->format('Y-m-d'); + $after = clone $next; + $after->mday++; + } + $this->assertSame(['2026-01-01', '2026-01-02', '2026-01-03'], $dates); + } + + // ========================================================================= + // Section 5: nextRecurrence return types + // ========================================================================= + + public function testNextRecurrenceReturnsFalseWhenNoMatch(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurEnd(new Horde_Date('2026-01-01 23:59:59')); + $result = $r->nextRecurrence(new Horde_Date('2026-01-02')); + $this->assertFalse($result); + } + + public function testNextRecurrenceReturnsHordeDate(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $next = $r->nextRecurrence(new Horde_Date('2026-01-01')); + $this->assertInstanceOf(Horde_Date::class, $next); + } + + public function testNextRecurrenceNoneReturnsFalse(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $result = $r->nextRecurrence(new Horde_Date('2026-01-02')); + $this->assertFalse($result); + } + + public function testNextActiveRecurrenceReturnsFalseWhenAllExcepted(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurEnd(new Horde_Date('2026-01-03 23:59:59')); + $r->addException(2026, 1, 1); + $r->addException(2026, 1, 2); + $r->addException(2026, 1, 3); + $result = $r->nextActiveRecurrence(new Horde_Date('2026-01-01')); + $this->assertFalse($result); + } + + public function testNextActiveRecurrenceReturnsHordeDate(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->addException(2026, 1, 1); + $next = $r->nextActiveRecurrence(new Horde_Date('2026-01-01')); + $this->assertInstanceOf(Horde_Date::class, $next); + $this->assertSame('2026-01-02', $next->format('Y-m-d')); + } + + // ========================================================================= + // Section 6: toString() + // ========================================================================= + + public function testToStringDaily(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurInterval(2); + $str = $r->toString('%Y-%m-%d'); + $this->assertStringContainsString('Daily', $str); + $this->assertStringContainsString('2', $str); + $this->assertStringContainsString('day(s)', $str); + } + + public function testToStringWeeklyWithDays(): void + { + $r = new Horde_Date_Recurrence('2026-01-05 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_WEEKLY); + $r->setRecurInterval(1); + $r->setRecurOnDay(Horde_Date::MASK_MONDAY | Horde_Date::MASK_FRIDAY); + $str = $r->toString('%Y-%m-%d'); + $this->assertStringContainsString('Weekly', $str); + $this->assertStringContainsString('Monday', $str); + $this->assertStringContainsString('Friday', $str); + } + + public function testToStringMonthlyDate(): void + { + $r = new Horde_Date_Recurrence('2026-01-15 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_MONTHLY_DATE); + $r->setRecurInterval(1); + $str = $r->toString('%Y-%m-%d'); + $this->assertStringContainsString('Monthly', $str); + $this->assertStringContainsString('same date', $str); + } + + public function testToStringMonthlyWeekday(): void + { + $r = new Horde_Date_Recurrence('2026-01-13 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_MONTHLY_WEEKDAY); + $r->setRecurInterval(1); + $str = $r->toString('%Y-%m-%d'); + $this->assertStringContainsString('Monthly', $str); + $this->assertStringContainsString('same weekday', $str); + } + + public function testToStringMonthlyLastWeekday(): void + { + $r = new Horde_Date_Recurrence('2026-01-30 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_MONTHLY_LAST_WEEKDAY); + $r->setRecurInterval(1); + $str = $r->toString('%Y-%m-%d'); + $this->assertStringContainsString('Monthly', $str); + $this->assertStringContainsString('last weekday', $str); + } + + public function testToStringYearlyDate(): void + { + $r = new Horde_Date_Recurrence('2026-03-15 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_YEARLY_DATE); + $r->setRecurInterval(1); + $str = $r->toString('%Y-%m-%d'); + $this->assertStringContainsString('Yearly', $str); + $this->assertStringContainsString('same date', $str); + } + + public function testToStringYearlyDay(): void + { + $r = new Horde_Date_Recurrence('2026-03-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_YEARLY_DAY); + $r->setRecurInterval(1); + $str = $r->toString('%Y-%m-%d'); + $this->assertStringContainsString('Yearly', $str); + $this->assertStringContainsString('same day of the year', $str); + } + + public function testToStringYearlyWeekday(): void + { + $r = new Horde_Date_Recurrence('2026-01-13 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_YEARLY_WEEKDAY); + $r->setRecurInterval(1); + $str = $r->toString('%Y-%m-%d'); + $this->assertStringContainsString('Yearly', $str); + $this->assertStringContainsString('same weekday and month', $str); + } + + public function testToStringWithEndDate(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurEnd(new Horde_Date('2026-12-31 23:59:59')); + $str = $r->toString('%Y-%m-%d'); + $this->assertStringContainsString('2026-12-31', $str); + $this->assertStringNotContainsString('No end date', $str); + } + + public function testToStringWithCount(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurCount(10); + $str = $r->toString('%Y-%m-%d'); + $this->assertStringContainsString('10 times', $str); + } + + public function testToStringNoEnd(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $str = $r->toString('%Y-%m-%d'); + $this->assertStringContainsString('No end date', $str); + } + + public function testToStringWithExceptions(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->addException(2026, 1, 5); + $r->addException(2026, 1, 10); + $str = $r->toString('%Y-%m-%d'); + $this->assertStringContainsString('Exceptions', $str); + $this->assertStringContainsString('2026-01-05', $str); + $this->assertStringContainsString('2026-01-10', $str); + } + + public function testToStringReturnsString(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_NONE); + $str = $r->toString('%Y-%m-%d'); + $this->assertIsString($str); + } + + public function testToStringEndDateWithTime(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurEnd(new Horde_Date('2026-12-31 14:30:00')); + $str = $r->toString('%Y-%m-%d', '%H:%M'); + $this->assertStringContainsString('2026-12-31', $str); + $this->assertStringContainsString('14:30', $str); + } + + public function testToStringEndDateMidnightOmitsTime(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurEnd(new Horde_Date('2026-12-31 23:59:00')); + $str = $r->toString('%Y-%m-%d', '%H:%M'); + $this->assertStringContainsString('2026-12-31', $str); + $this->assertStringNotContainsString('23:59', $str); + } + + // ========================================================================= + // Section 7: toRRule20 / toRRule10 with Horde_Icalendar + // ========================================================================= + + public function testToRRule20WithCalendarParameter(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurInterval(1); + $r->setRecurEnd(new Horde_Date('2026-12-31 23:59:59')); + $cal = new Horde_Icalendar(); + $rrule = $r->toRRule20($cal); + $this->assertStringContainsString('FREQ=DAILY', $rrule); + $this->assertStringContainsString('UNTIL=', $rrule); + } + + public function testToRRule10WithCalendarParameter(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurInterval(2); + $cal = new Horde_Icalendar(); + $rrule = $r->toRRule10($cal); + $this->assertStringStartsWith('D2', $rrule); + } + + public function testToRRule20NoneReturnsEmpty(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $cal = new Horde_Icalendar(); + $this->assertSame('', $r->toRRule20($cal)); + } + + public function testToRRule10NoneReturnsEmpty(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $cal = new Horde_Icalendar(); + $this->assertSame('', $r->toRRule10($cal)); + } + + public function testToRRule20WeeklyByday(): void + { + $r = new Horde_Date_Recurrence('2026-01-05 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_WEEKLY); + $r->setRecurInterval(1); + $r->setRecurOnDay(Horde_Date::MASK_MONDAY | Horde_Date::MASK_FRIDAY); + $cal = new Horde_Icalendar(); + $rrule = $r->toRRule20($cal); + $this->assertStringContainsString('BYDAY=', $rrule); + $this->assertStringContainsString('MO', $rrule); + $this->assertStringContainsString('FR', $rrule); + } + + public function testToRRule20MonthlyWeekday(): void + { + $r = new Horde_Date_Recurrence('2026-01-13 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_MONTHLY_WEEKDAY); + $r->setRecurInterval(1); + $cal = new Horde_Icalendar(); + $rrule = $r->toRRule20($cal); + $this->assertStringContainsString('FREQ=MONTHLY', $rrule); + $this->assertStringContainsString('BYDAY=2TU', $rrule); + } + + public function testToRRule20MonthlyLastWeekday(): void + { + $r = new Horde_Date_Recurrence('2026-01-30 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_MONTHLY_LAST_WEEKDAY); + $r->setRecurInterval(1); + $cal = new Horde_Icalendar(); + $rrule = $r->toRRule20($cal); + $this->assertStringContainsString('BYDAY=-1FR', $rrule); + } + + public function testToRRule20YearlyWeekday(): void + { + $r = new Horde_Date_Recurrence('2026-01-13 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_YEARLY_WEEKDAY); + $r->setRecurInterval(1); + $cal = new Horde_Icalendar(); + $rrule = $r->toRRule20($cal); + $this->assertStringContainsString('FREQ=YEARLY', $rrule); + $this->assertStringContainsString('BYDAY=2TU', $rrule); + $this->assertStringContainsString('BYMONTH=1', $rrule); + } + + public function testToRRule20WithCount(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurInterval(1); + $r->setRecurCount(5); + $cal = new Horde_Icalendar(); + $rrule = $r->toRRule20($cal); + $this->assertStringContainsString('COUNT=5', $rrule); + } + + public function testToRRule10WeeklyDays(): void + { + $r = new Horde_Date_Recurrence('2026-01-05 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_WEEKLY); + $r->setRecurInterval(1); + $r->setRecurOnDay(Horde_Date::MASK_MONDAY | Horde_Date::MASK_FRIDAY); + $cal = new Horde_Icalendar(); + $rrule = $r->toRRule10($cal); + $this->assertStringStartsWith('W1', $rrule); + $this->assertStringContainsString('MO', $rrule); + $this->assertStringContainsString('FR', $rrule); + } + + public function testToRRule10YearlyDay(): void + { + $r = new Horde_Date_Recurrence('2026-03-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_YEARLY_DAY); + $r->setRecurInterval(1); + $cal = new Horde_Icalendar(); + $rrule = $r->toRRule10($cal); + $this->assertStringStartsWith('YD1', $rrule); + } + + // ========================================================================= + // Section 8: fromRRule20 / fromRRule10 → property access + // (parse RRULE then verify properties match) + // ========================================================================= + + public function testFromRRule20SetsProperties(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->fromRRule20('FREQ=DAILY;INTERVAL=3;COUNT=10'); + $this->assertSame(Horde_Date_Recurrence::RECUR_DAILY, $r->recurType); + $this->assertEquals(3, $r->recurInterval); + $this->assertSame(10, $r->recurCount); + } + + public function testFromRRule20WeeklySetsRecurData(): void + { + $r = new Horde_Date_Recurrence('2026-01-05 10:00:00'); + $r->fromRRule20('FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE,FR'); + $this->assertSame(Horde_Date_Recurrence::RECUR_WEEKLY, $r->recurType); + $expected = Horde_Date::MASK_MONDAY | Horde_Date::MASK_WEDNESDAY | Horde_Date::MASK_FRIDAY; + $this->assertSame($expected, $r->recurData); + } + + public function testFromRRule20WithUntilSetsRecurEnd(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $r->fromRRule20('FREQ=DAILY;INTERVAL=1;UNTIL=20261231T235959Z'); + $this->assertInstanceOf(Horde_Date::class, $r->recurEnd); + $this->assertSame('2026-12-31', $r->recurEnd->format('Y-m-d')); + } + + public function testFromRRule10SetsProperties(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $r->fromRRule10('D3 #5'); + $this->assertSame(Horde_Date_Recurrence::RECUR_DAILY, $r->recurType); + $this->assertEquals(3, $r->recurInterval); + $this->assertSame(5, $r->recurCount); + } + + // ========================================================================= + // Section 9: Constants + // ========================================================================= + + #[DataProvider('constantProvider')] + public function testConstants(string $name, int $value): void + { + $this->assertSame($value, constant('Horde_Date_Recurrence::' . $name)); + } + + public static function constantProvider(): array + { + return [ + 'RECUR_NONE' => ['RECUR_NONE', 0], + 'RECUR_DAILY' => ['RECUR_DAILY', 1], + 'RECUR_WEEKLY' => ['RECUR_WEEKLY', 2], + 'RECUR_MONTHLY_DATE' => ['RECUR_MONTHLY_DATE', 3], + 'RECUR_MONTHLY_WEEKDAY' => ['RECUR_MONTHLY_WEEKDAY', 4], + 'RECUR_YEARLY_DATE' => ['RECUR_YEARLY_DATE', 5], + 'RECUR_YEARLY_DAY' => ['RECUR_YEARLY_DAY', 6], + 'RECUR_YEARLY_WEEKDAY' => ['RECUR_YEARLY_WEEKDAY', 7], + 'RECUR_MONTHLY_LAST_WEEKDAY' => ['RECUR_MONTHLY_LAST_WEEKDAY', 8], + ]; + } + + // ========================================================================= + // Section 10: Kolab round-trip (already tested elsewhere, pin specific details) + // ========================================================================= + + public function testKolabRoundTripDaily(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurInterval(2); + $r->setRecurCount(5); + + $kolab = $r->toKolab(); + $this->assertIsArray($kolab); + $this->assertSame('daily', $kolab['cycle']); + $this->assertSame(2, $kolab['interval']); + + $r2 = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r2->fromKolab($kolab); + $this->assertSame(Horde_Date_Recurrence::RECUR_DAILY, $r2->getRecurType()); + $this->assertSame(2, $r2->getRecurInterval()); + $this->assertSame(5, $r2->getRecurCount()); + } + + public function testKolabRoundTripWeekly(): void + { + $r = new Horde_Date_Recurrence('2026-01-05 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_WEEKLY); + $r->setRecurInterval(1); + $r->setRecurOnDay(Horde_Date::MASK_MONDAY | Horde_Date::MASK_FRIDAY); + + $kolab = $r->toKolab(); + $this->assertSame('weekly', $kolab['cycle']); + + $r2 = new Horde_Date_Recurrence('2026-01-05 10:00:00'); + $r2->fromKolab($kolab); + $this->assertSame(Horde_Date_Recurrence::RECUR_WEEKLY, $r2->getRecurType()); + $this->assertTrue((bool) $r2->recurOnDay(Horde_Date::MASK_MONDAY)); + $this->assertTrue((bool) $r2->recurOnDay(Horde_Date::MASK_FRIDAY)); + } + + public function testKolabRoundTripMonthlyDate(): void + { + $r = new Horde_Date_Recurrence('2026-01-15 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_MONTHLY_DATE); + $r->setRecurInterval(1); + + $kolab = $r->toKolab(); + $this->assertSame('monthly', $kolab['cycle']); + $this->assertSame('daynumber', $kolab['type']); + + $r2 = new Horde_Date_Recurrence('2026-01-15 10:00:00'); + $r2->fromKolab($kolab); + $this->assertSame(Horde_Date_Recurrence::RECUR_MONTHLY_DATE, $r2->getRecurType()); + } + + public function testKolabRoundTripMonthlyWeekday(): void + { + $r = new Horde_Date_Recurrence('2026-01-13 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_MONTHLY_WEEKDAY); + $r->setRecurInterval(1); + + $kolab = $r->toKolab(); + $this->assertSame('monthly', $kolab['cycle']); + $this->assertSame('weekday', $kolab['type']); + + $r2 = new Horde_Date_Recurrence('2026-01-13 10:00:00'); + $r2->fromKolab($kolab); + $this->assertSame(Horde_Date_Recurrence::RECUR_MONTHLY_WEEKDAY, $r2->getRecurType()); + } + + public function testKolabRoundTripYearlyDate(): void + { + $r = new Horde_Date_Recurrence('2026-03-15 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_YEARLY_DATE); + $r->setRecurInterval(1); + + $kolab = $r->toKolab(); + $this->assertSame('yearly', $kolab['cycle']); + $this->assertSame('monthday', $kolab['type']); + + $r2 = new Horde_Date_Recurrence('2026-03-15 10:00:00'); + $r2->fromKolab($kolab); + $this->assertSame(Horde_Date_Recurrence::RECUR_YEARLY_DATE, $r2->getRecurType()); + } + + public function testKolabRoundTripYearlyWeekday(): void + { + $r = new Horde_Date_Recurrence('2026-01-13 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_YEARLY_WEEKDAY); + $r->setRecurInterval(1); + + $kolab = $r->toKolab(); + $this->assertSame('yearly', $kolab['cycle']); + $this->assertSame('weekday', $kolab['type']); + + $r2 = new Horde_Date_Recurrence('2026-01-13 10:00:00'); + $r2->fromKolab($kolab); + $this->assertSame(Horde_Date_Recurrence::RECUR_YEARLY_WEEKDAY, $r2->getRecurType()); + } + + public function testKolabWithEndDate(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurInterval(1); + $r->setRecurEnd(new Horde_Date('2026-12-31 23:59:59')); + + $kolab = $r->toKolab(); + $this->assertSame('date', $kolab['range-type']); + + $r2 = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r2->fromKolab($kolab); + $this->assertTrue($r2->hasRecurEnd()); + } + + // ========================================================================= + // Section 11: Exception/completion with (year, month, day) signature + // ========================================================================= + + public function testAddExceptionThreeArgs(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $r->addException(2026, 3, 15); + $this->assertTrue($r->hasException(2026, 3, 15)); + $this->assertFalse($r->hasException(2026, 3, 16)); + $this->assertSame(['20260315'], $r->getExceptions()); + } + + public function testDeleteExceptionThreeArgs(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $r->addException(2026, 3, 15); + $r->deleteException(2026, 3, 15); + $this->assertFalse($r->hasException(2026, 3, 15)); + $this->assertSame([], $r->getExceptions()); + } + + public function testAddCompletionThreeArgs(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $r->addCompletion(2026, 3, 15); + $this->assertTrue($r->hasCompletion(2026, 3, 15)); + $this->assertSame(['20260315'], $r->getCompletions()); + } + + public function testDeleteCompletionThreeArgs(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $r->addCompletion(2026, 3, 15); + $r->deleteCompletion(2026, 3, 15); + $this->assertFalse($r->hasCompletion(2026, 3, 15)); + } + + public function testExceptionReflectedInExceptionsProperty(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $r->addException(2026, 1, 5); + $r->addException(2026, 2, 10); + $this->assertSame(['20260105', '20260210'], $r->exceptions); + } + + public function testCompletionReflectedInCompletionsProperty(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $r->addCompletion(2026, 1, 5); + $this->assertSame(['20260105'], $r->completions); + } + + // ========================================================================= + // Section 12: Cross-method interactions + // ========================================================================= + + public function testResetClearsEverything(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_WEEKLY); + $r->setRecurInterval(3); + $r->setRecurCount(10); + $r->setRecurOnDay(Horde_Date::MASK_MONDAY); + $r->addException(2026, 1, 5); + $r->addCompletion(2026, 1, 6); + $r->reset(); + + $this->assertSame(Horde_Date_Recurrence::RECUR_NONE, $r->recurType); + $this->assertSame(1, $r->recurInterval); + $this->assertNull($r->recurCount); + $this->assertNull($r->recurEnd); + $this->assertNull($r->recurData); + $this->assertSame([], $r->exceptions); + $this->assertSame([], $r->completions); + } + + public function testSetEndClearsCount(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $r->setRecurCount(10); + $r->setRecurEnd(new Horde_Date('2026-12-31')); + $this->assertNull($r->recurCount); + $this->assertNotNull($r->recurEnd); + } + + public function testSetCountClearsEnd(): void + { + $r = new Horde_Date_Recurrence('2026-01-01'); + $r->setRecurEnd(new Horde_Date('2026-12-31')); + $r->setRecurCount(10); + $this->assertNull($r->recurEnd); + $this->assertSame(10, $r->recurCount); + } + + public function testFromRRule20ThenPropertyAccess(): void + { + $r = new Horde_Date_Recurrence('2026-01-05 10:00:00'); + $r->fromRRule20('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR;COUNT=8'); + $this->assertSame(Horde_Date_Recurrence::RECUR_WEEKLY, $r->recurType); + $this->assertEquals(2, $r->recurInterval); + $expected = Horde_Date::MASK_MONDAY | Horde_Date::MASK_WEDNESDAY | Horde_Date::MASK_FRIDAY; + $this->assertSame($expected, $r->recurData); + $this->assertSame(8, $r->recurCount); + $this->assertNull($r->recurEnd); + } + + public function testFromRRule20ThenNextRecurrence(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->fromRRule20('FREQ=DAILY;INTERVAL=1;COUNT=3'); + $next = $r->nextRecurrence(new Horde_Date('2026-01-01')); + $this->assertInstanceOf(Horde_Date::class, $next); + $this->assertSame('2026-01-01', $next->format('Y-m-d')); + } + + public function testToHashFromHashThenPropertyAccess(): void + { + $r = new Horde_Date_Recurrence('2026-01-05 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_WEEKLY); + $r->setRecurInterval(2); + $r->setRecurOnDay(Horde_Date::MASK_MONDAY); + $r->setRecurCount(10); + $r->addException(2026, 1, 12); + + $hash = $r->toHash(); + $r2 = Horde_Date_Recurrence::fromHash($hash); + + $this->assertSame(Horde_Date_Recurrence::RECUR_WEEKLY, $r2->recurType); + $this->assertSame(2, $r2->recurInterval); + $this->assertSame(Horde_Date::MASK_MONDAY, $r2->recurData); + $this->assertSame(10, $r2->recurCount); + $this->assertNull($r2->recurEnd); + $this->assertSame(['20260112'], $r2->exceptions); + } + + public function testFromHashThenNextRecurrence(): void + { + $r = new Horde_Date_Recurrence('2026-01-01 10:00:00'); + $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + $r->setRecurInterval(1); + + $hash = $r->toHash(); + $r2 = Horde_Date_Recurrence::fromHash($hash); + + $next = $r2->nextRecurrence(new Horde_Date('2026-01-02')); + $this->assertInstanceOf(Horde_Date::class, $next); + $this->assertSame('2026-01-02', $next->format('Y-m-d')); + } + + // ========================================================================= + // Section 13: Specific recurrence algorithms via property setup + // (tests that set up via properties instead of setters, ensuring + // wrapper __set delegates properly) + // ========================================================================= + + public function testMonthlyDateViaProperties(): void + { + $r = new Horde_Date_Recurrence('2026-01-15 10:00:00'); + $r->recurType = Horde_Date_Recurrence::RECUR_MONTHLY_DATE; + $r->recurInterval = 1; + $dates = []; + $after = new Horde_Date('2026-01-01'); + while ($next = $r->nextRecurrence($after)) { + if (count($dates) >= 4) { + break; + } + $dates[] = $next->format('Y-m-d'); + $after = clone $next; + $after->mday++; + } + $this->assertSame([ + '2026-01-15', '2026-02-15', '2026-03-15', '2026-04-15', + ], $dates); + } + + public function testYearlyWeekdayViaProperties(): void + { + // 2026-01-13 is the 2nd Tuesday of January + $r = new Horde_Date_Recurrence('2026-01-13 10:00:00'); + $r->recurType = Horde_Date_Recurrence::RECUR_YEARLY_WEEKDAY; + $r->recurInterval = 1; + $dates = []; + $after = new Horde_Date('2026-01-01'); + while ($next = $r->nextRecurrence($after)) { + if (count($dates) >= 3) { + break; + } + $dates[] = $next->format('Y-m-d'); + $after = clone $next; + $after->mday++; + } + $this->assertSame([ + '2026-01-13', '2027-01-12', '2028-01-11', + ], $dates); + } +} diff --git a/test/Unnamespaced/Repeater/DayNameTest.php b/test/Unnamespaced/Repeater/DayNameTest.php index 03cb6ef..2426d3d 100644 --- a/test/Unnamespaced/Repeater/DayNameTest.php +++ b/test/Unnamespaced/Repeater/DayNameTest.php @@ -17,6 +17,7 @@ * @category Horde * @package Date * @subpackage UnitTests + * @coversNothing */ class DayNameTest extends TestCase { diff --git a/test/Unnamespaced/Repeater/DayTest.php b/test/Unnamespaced/Repeater/DayTest.php index 9d2fc42..3f83eae 100644 --- a/test/Unnamespaced/Repeater/DayTest.php +++ b/test/Unnamespaced/Repeater/DayTest.php @@ -17,6 +17,7 @@ * @category Horde * @package Date * @subpackage UnitTests + * @coversNothing */ class DayTest extends TestCase { @@ -24,14 +25,14 @@ public function testNextFuture() { $repeater = new Horde_Date_Repeater_Day(); $repeater->now = new Horde_Date('2009-01-01'); - $this->assertEquals('(2009-01-02 00:00:00..2009-01-03 00:00:00)', (string)$repeater->next('future')); + $this->assertEquals('(2009-01-02 00:00:00..2009-01-03 00:00:00)', (string) $repeater->next('future')); } public function testNextPast() { $repeater = new Horde_Date_Repeater_Day(); $repeater->now = new Horde_Date('2009-01-01'); - $this->assertEquals('(2008-12-31 00:00:00..2009-01-01 00:00:00)', (string)$repeater->next('past')); + $this->assertEquals('(2008-12-31 00:00:00..2009-01-01 00:00:00)', (string) $repeater->next('past')); } } diff --git a/test/Unnamespaced/Repeater/EdgeCaseTest.php b/test/Unnamespaced/Repeater/EdgeCaseTest.php index d691d83..4afdb78 100644 --- a/test/Unnamespaced/Repeater/EdgeCaseTest.php +++ b/test/Unnamespaced/Repeater/EdgeCaseTest.php @@ -12,8 +12,9 @@ use Horde_Date_Span; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use Horde_Date_Repeater; -#[CoversClass(\Horde_Date_Repeater::class)] +#[CoversClass(Horde_Date_Repeater::class)] #[CoversClass(Horde_Date_Repeater_Minute::class)] #[CoversClass(Horde_Date_Repeater_Second::class)] #[CoversClass(Horde_Date_Repeater_Fortnight::class)] @@ -69,12 +70,12 @@ public function testMinuteNextFuture(): void $minutes->now = new Horde_Date('2026-04-17 14:30:00'); $span = $minutes->next('future'); - $this->assertSame('2026-04-17 14:31:00', (string)$span->begin); - $this->assertSame('2026-04-17 14:32:00', (string)$span->end); + $this->assertSame('2026-04-17 14:31:00', (string) $span->begin); + $this->assertSame('2026-04-17 14:32:00', (string) $span->end); $span2 = $minutes->next('future'); - $this->assertSame('2026-04-17 14:32:00', (string)$span2->begin); - $this->assertSame('2026-04-17 14:33:00', (string)$span2->end); + $this->assertSame('2026-04-17 14:32:00', (string) $span2->begin); + $this->assertSame('2026-04-17 14:33:00', (string) $span2->end); } public function testMinuteNextPast(): void @@ -83,8 +84,8 @@ public function testMinuteNextPast(): void $minutes->now = new Horde_Date('2026-04-17 14:30:00'); $span = $minutes->next('past'); - $this->assertSame('2026-04-17 14:29:00', (string)$span->begin); - $this->assertSame('2026-04-17 14:30:00', (string)$span->end); + $this->assertSame('2026-04-17 14:29:00', (string) $span->begin); + $this->assertSame('2026-04-17 14:30:00', (string) $span->end); } public function testMinuteNextFutureAcrossHourBoundary(): void @@ -93,8 +94,8 @@ public function testMinuteNextFutureAcrossHourBoundary(): void $minutes->now = new Horde_Date('2026-04-17 14:59:00'); $span = $minutes->next('future'); - $this->assertSame('2026-04-17 15:00:00', (string)$span->begin); - $this->assertSame('2026-04-17 15:01:00', (string)$span->end); + $this->assertSame('2026-04-17 15:00:00', (string) $span->begin); + $this->assertSame('2026-04-17 15:01:00', (string) $span->end); } public function testMinuteThisFuture(): void @@ -103,8 +104,8 @@ public function testMinuteThisFuture(): void $minutes->now = new Horde_Date('2026-04-17 14:30:30'); $span = $minutes->this('future'); - $this->assertSame('2026-04-17 14:30:30', (string)$span->begin); - $this->assertSame('2026-04-17 14:30:00', (string)$span->end); + $this->assertSame('2026-04-17 14:30:30', (string) $span->begin); + $this->assertSame('2026-04-17 14:30:00', (string) $span->end); } public function testMinuteThisPast(): void @@ -113,8 +114,8 @@ public function testMinuteThisPast(): void $minutes->now = new Horde_Date('2026-04-17 14:30:30'); $span = $minutes->this('past'); - $this->assertSame('2026-04-17 14:30:00', (string)$span->begin); - $this->assertSame('2026-04-17 14:30:30', (string)$span->end); + $this->assertSame('2026-04-17 14:30:00', (string) $span->begin); + $this->assertSame('2026-04-17 14:30:30', (string) $span->end); } public function testMinuteThisNone(): void @@ -123,8 +124,8 @@ public function testMinuteThisNone(): void $minutes->now = new Horde_Date('2026-04-17 14:30:00'); $span = $minutes->this('none'); - $this->assertSame('2026-04-17 14:30:00', (string)$span->begin); - $this->assertSame('2026-04-17 14:31:00', (string)$span->end); + $this->assertSame('2026-04-17 14:30:00', (string) $span->begin); + $this->assertSame('2026-04-17 14:31:00', (string) $span->end); } public function testMinuteOffset(): void @@ -136,16 +137,16 @@ public function testMinuteOffset(): void ); $offsetSpan = $minutes->offset($span, 5, 'future'); - $this->assertSame('2026-04-17 14:05:00', (string)$offsetSpan->begin); + $this->assertSame('2026-04-17 14:05:00', (string) $offsetSpan->begin); $offsetSpan = $minutes->offset($span, 90, 'past'); - $this->assertSame('2026-04-17 12:30:00', (string)$offsetSpan->begin); + $this->assertSame('2026-04-17 12:30:00', (string) $offsetSpan->begin); } public function testMinuteToString(): void { $minutes = new Horde_Date_Repeater_Minute(); - $this->assertSame('repeater-minute', (string)$minutes); + $this->assertSame('repeater-minute', (string) $minutes); } // ========================================================================= @@ -164,8 +165,8 @@ public function testSecondNextFuture(): void $seconds->now = new Horde_Date('2026-04-17 14:30:00'); $span = $seconds->next('future'); - $this->assertSame('2026-04-17 14:30:01', (string)$span->begin); - $this->assertSame('2026-04-17 14:30:02', (string)$span->end); + $this->assertSame('2026-04-17 14:30:01', (string) $span->begin); + $this->assertSame('2026-04-17 14:30:02', (string) $span->end); } public function testSecondNextPast(): void @@ -174,8 +175,8 @@ public function testSecondNextPast(): void $seconds->now = new Horde_Date('2026-04-17 14:30:30'); $span = $seconds->next('past'); - $this->assertSame('2026-04-17 14:30:29', (string)$span->begin); - $this->assertSame('2026-04-17 14:30:30', (string)$span->end); + $this->assertSame('2026-04-17 14:30:29', (string) $span->begin); + $this->assertSame('2026-04-17 14:30:30', (string) $span->end); } public function testSecondThis(): void @@ -184,8 +185,8 @@ public function testSecondThis(): void $seconds->now = new Horde_Date('2026-04-17 14:30:00'); $span = $seconds->this('future'); - $this->assertSame('2026-04-17 14:30:00', (string)$span->begin); - $this->assertSame('2026-04-17 14:30:01', (string)$span->end); + $this->assertSame('2026-04-17 14:30:00', (string) $span->begin); + $this->assertSame('2026-04-17 14:30:01', (string) $span->end); } public function testSecondOffset(): void @@ -197,14 +198,14 @@ public function testSecondOffset(): void ); $offsetSpan = $seconds->offset($span, 30, 'future'); - $this->assertSame('2026-04-17 14:00:30', (string)$offsetSpan->begin); - $this->assertSame('2026-04-17 14:00:31', (string)$offsetSpan->end); + $this->assertSame('2026-04-17 14:00:30', (string) $offsetSpan->begin); + $this->assertSame('2026-04-17 14:00:31', (string) $offsetSpan->end); } public function testSecondToString(): void { $seconds = new Horde_Date_Repeater_Second(); - $this->assertSame('repeater-second', (string)$seconds); + $this->assertSame('repeater-second', (string) $seconds); } // ========================================================================= @@ -247,13 +248,13 @@ public function testFortnightOffset(): void ); $offsetSpan = $fortnight->offset($span, 1, 'future'); - $this->assertSame('2026-05-01 14:00:00', (string)$offsetSpan->begin); + $this->assertSame('2026-05-01 14:00:00', (string) $offsetSpan->begin); } public function testFortnightToString(): void { $fortnight = new Horde_Date_Repeater_Fortnight(); - $this->assertSame('repeater-fortnight', (string)$fortnight); + $this->assertSame('repeater-fortnight', (string) $fortnight); } // ========================================================================= @@ -266,12 +267,12 @@ public function testMinuteConsecutiveNextProducesIncreasingSpans(): void $minutes->now = new Horde_Date('2026-04-17 14:00:00'); $span1 = $minutes->next('future'); - $begin1 = (string)$span1->begin; - $end1 = (string)$span1->end; + $begin1 = (string) $span1->begin; + $end1 = (string) $span1->end; $span2 = $minutes->next('future'); - $begin2 = (string)$span2->begin; - $end2 = (string)$span2->end; + $begin2 = (string) $span2->begin; + $end2 = (string) $span2->end; $this->assertSame('2026-04-17 14:01:00', $begin1); $this->assertSame('2026-04-17 14:02:00', $end1); @@ -279,27 +280,28 @@ public function testMinuteConsecutiveNextProducesIncreasingSpans(): void $this->assertSame('2026-04-17 14:03:00', $end2); } - public function testMinuteSpansMutateAfterSubsequentCalls(): void + public function testMinuteSpansStableAfterSubsequentCalls(): void { $minutes = new Horde_Date_Repeater_Minute(); $minutes->now = new Horde_Date('2026-04-17 14:00:00'); $span1 = $minutes->next('future'); - $this->assertSame('2026-04-17 14:01:00', (string)$span1->begin); + $this->assertSame('2026-04-17 14:01:00', (string) $span1->begin); $minutes->next('future'); - // span1.begin mutates because it shares the currentMinuteStart reference - $this->assertSame('2026-04-17 14:02:00', (string)$span1->begin); + $this->assertSame('2026-04-17 14:01:00', (string) $span1->begin); } public function testSecondConsecutiveNextNonOverlapping(): void { - $this->expectException(\TypeError::class); $seconds = new Horde_Date_Repeater_Second(); $seconds->now = new Horde_Date('2026-04-17 14:00:00'); - $seconds->next('future'); - $seconds->next('future'); + $span1 = $seconds->next('future'); + $span1End = (string) $span1->end; + $span2 = $seconds->next('future'); + + $this->assertSame($span1End, (string) $span2->begin); } // ========================================================================= @@ -312,7 +314,7 @@ public function testMinuteNextAcrossMidnight(): void $minutes->now = new Horde_Date('2026-04-17 23:59:00'); $span = $minutes->next('future'); - $this->assertSame('2026-04-18 00:00:00', (string)$span->begin); + $this->assertSame('2026-04-18 00:00:00', (string) $span->begin); } public function testSecondNextAcrossMidnight(): void @@ -321,7 +323,7 @@ public function testSecondNextAcrossMidnight(): void $seconds->now = new Horde_Date('2026-04-17 23:59:59'); $span = $seconds->next('future'); - $this->assertSame('2026-04-18 00:00:00', (string)$span->begin); + $this->assertSame('2026-04-18 00:00:00', (string) $span->begin); } // ========================================================================= @@ -334,6 +336,6 @@ public function testMinuteNextAcrossYearBoundary(): void $minutes->now = new Horde_Date('2026-12-31 23:59:00'); $span = $minutes->next('future'); - $this->assertSame('2027-01-01 00:00:00', (string)$span->begin); + $this->assertSame('2027-01-01 00:00:00', (string) $span->begin); } } diff --git a/test/Unnamespaced/Repeater/HourTest.php b/test/Unnamespaced/Repeater/HourTest.php index 0c6eeb9..4b6512a 100644 --- a/test/Unnamespaced/Repeater/HourTest.php +++ b/test/Unnamespaced/Repeater/HourTest.php @@ -18,6 +18,7 @@ * @category Horde * @package Date * @subpackage UnitTests + * @coversNothing */ class HourTest extends TestCase { @@ -34,12 +35,12 @@ public function testNextFuture() $hours->now = $this->now; $nextHour = $hours->next('future'); - $this->assertEquals('2006-08-16 15:00:00', (string)$nextHour->begin); - $this->assertEquals('2006-08-16 16:00:00', (string)$nextHour->end); + $this->assertEquals('2006-08-16 15:00:00', (string) $nextHour->begin); + $this->assertEquals('2006-08-16 16:00:00', (string) $nextHour->end); $nextNextHour = $hours->next('future'); - $this->assertEquals('2006-08-16 16:00:00', (string)$nextNextHour->begin); - $this->assertEquals('2006-08-16 17:00:00', (string)$nextNextHour->end); + $this->assertEquals('2006-08-16 16:00:00', (string) $nextNextHour->begin); + $this->assertEquals('2006-08-16 17:00:00', (string) $nextNextHour->end); } public function testNextPast() @@ -48,12 +49,12 @@ public function testNextPast() $hours->now = $this->now; $pastHour = $hours->next('past'); - $this->assertEquals('2006-08-16 13:00:00', (string)$pastHour->begin); - $this->assertEquals('2006-08-16 14:00:00', (string)$pastHour->end); + $this->assertEquals('2006-08-16 13:00:00', (string) $pastHour->begin); + $this->assertEquals('2006-08-16 14:00:00', (string) $pastHour->end); $pastPastHour = $hours->next('past'); - $this->assertEquals('2006-08-16 12:00:00', (string)$pastPastHour->begin); - $this->assertEquals('2006-08-16 13:00:00', (string)$pastPastHour->end); + $this->assertEquals('2006-08-16 12:00:00', (string) $pastPastHour->begin); + $this->assertEquals('2006-08-16 13:00:00', (string) $pastPastHour->end); } public function testThis() @@ -62,12 +63,12 @@ public function testThis() $hours->now = new Horde_Date('2006-08-16 14:30:00'); $thisHour = $hours->this('future'); - $this->assertEquals('2006-08-16 14:31:00', (string)$thisHour->begin); - $this->assertEquals('2006-08-16 15:00:00', (string)$thisHour->end); + $this->assertEquals('2006-08-16 14:31:00', (string) $thisHour->begin); + $this->assertEquals('2006-08-16 15:00:00', (string) $thisHour->end); $thisHour = $hours->this('past'); - $this->assertEquals('2006-08-16 14:00:00', (string)$thisHour->begin); - $this->assertEquals('2006-08-16 14:30:00', (string)$thisHour->end); + $this->assertEquals('2006-08-16 14:00:00', (string) $thisHour->begin); + $this->assertEquals('2006-08-16 14:30:00', (string) $thisHour->end); } public function testOffset() @@ -76,12 +77,12 @@ public function testOffset() $hours = new Horde_Date_Repeater_Hour(); $offsetSpan = $hours->offset($span, 3, 'future'); - $this->assertEquals('2006-08-16 17:00:00', (string)$offsetSpan->begin); - $this->assertEquals('2006-08-16 17:00:01', (string)$offsetSpan->end); + $this->assertEquals('2006-08-16 17:00:00', (string) $offsetSpan->begin); + $this->assertEquals('2006-08-16 17:00:01', (string) $offsetSpan->end); $offsetSpan = $hours->offset($span, 24, 'past'); - $this->assertEquals('2006-08-15 14:00:00', (string)$offsetSpan->begin); - $this->assertEquals('2006-08-15 14:00:01', (string)$offsetSpan->end); + $this->assertEquals('2006-08-15 14:00:00', (string) $offsetSpan->begin); + $this->assertEquals('2006-08-15 14:00:01', (string) $offsetSpan->end); } } diff --git a/test/Unnamespaced/Repeater/MonthNameTest.php b/test/Unnamespaced/Repeater/MonthNameTest.php index 27b7e43..818a75f 100644 --- a/test/Unnamespaced/Repeater/MonthNameTest.php +++ b/test/Unnamespaced/Repeater/MonthNameTest.php @@ -17,6 +17,7 @@ * @category Horde * @package Date * @subpackage UnitTests + * @coversNothing */ class MonthNameTest extends TestCase { @@ -33,19 +34,19 @@ public function testNextFuture() $mays->now = $this->now; $nextMay = $mays->next('future'); - $this->assertEquals('2007-05-01 00:00:00', (string)$nextMay->begin); - $this->assertEquals('2007-06-01 00:00:00', (string)$nextMay->end); + $this->assertEquals('2007-05-01 00:00:00', (string) $nextMay->begin); + $this->assertEquals('2007-06-01 00:00:00', (string) $nextMay->end); $nextNextMay = $mays->next('future'); - $this->assertEquals('2008-05-01 00:00:00', (string)$nextNextMay->begin); - $this->assertEquals('2008-06-01 00:00:00', (string)$nextNextMay->end); + $this->assertEquals('2008-05-01 00:00:00', (string) $nextNextMay->begin); + $this->assertEquals('2008-06-01 00:00:00', (string) $nextNextMay->end); $decembers = new Horde_Date_Repeater_MonthName('december'); $decembers->now = $this->now; $nextDecember = $decembers->next('future'); - $this->assertEquals('2006-12-01 00:00:00', (string)$nextDecember->begin); - $this->assertEquals('2007-01-01 00:00:00', (string)$nextDecember->end); + $this->assertEquals('2006-12-01 00:00:00', (string) $nextDecember->begin); + $this->assertEquals('2007-01-01 00:00:00', (string) $nextDecember->end); } public function testNextPast() @@ -53,8 +54,8 @@ public function testNextPast() $mays = new Horde_Date_Repeater_MonthName('may'); $mays->now = $this->now; - $this->assertEquals('2006-05-01 00:00:00', (string)$mays->next('past')->begin); - $this->assertEquals('2005-05-01 00:00:00', (string)$mays->next('past')->begin); + $this->assertEquals('2006-05-01 00:00:00', (string) $mays->next('past')->begin); + $this->assertEquals('2005-05-01 00:00:00', (string) $mays->next('past')->begin); } public function testThis() @@ -63,15 +64,15 @@ public function testThis() $octobers->now = $this->now; $thisOctober = $octobers->this('future'); - $this->assertEquals('2006-10-01 00:00:00', (string)$thisOctober->begin); - $this->assertEquals('2006-11-01 00:00:00', (string)$thisOctober->end); + $this->assertEquals('2006-10-01 00:00:00', (string) $thisOctober->begin); + $this->assertEquals('2006-11-01 00:00:00', (string) $thisOctober->end); $aprils = new Horde_Date_Repeater_MonthName('april'); $aprils->now = $this->now; $thisApril = $aprils->this('past'); - $this->assertEquals('2006-04-01 00:00:00', (string)$thisApril->begin); - $this->assertEquals('2006-05-01 00:00:00', (string)$thisApril->end); + $this->assertEquals('2006-04-01 00:00:00', (string) $thisApril->begin); + $this->assertEquals('2006-05-01 00:00:00', (string) $thisApril->end); } } diff --git a/test/Unnamespaced/Repeater/MonthTest.php b/test/Unnamespaced/Repeater/MonthTest.php index 3b4d913..ff8b308 100644 --- a/test/Unnamespaced/Repeater/MonthTest.php +++ b/test/Unnamespaced/Repeater/MonthTest.php @@ -18,6 +18,7 @@ * @category Horde * @package Date * @subpackage UnitTests + * @coversNothing */ class MonthTest extends TestCase { @@ -34,8 +35,8 @@ public function testOffsetFuture() $repeater = new Horde_Date_Repeater_Month(); $offsetSpan = $repeater->offset($span, 1, 'future'); - $this->assertEquals('2006-09-16 14:00:00', (string)$offsetSpan->begin); - $this->assertEquals('2006-09-16 14:01:00', (string)$offsetSpan->end); + $this->assertEquals('2006-09-16 14:00:00', (string) $offsetSpan->begin); + $this->assertEquals('2006-09-16 14:01:00', (string) $offsetSpan->end); } public function testOffsetPast() @@ -44,8 +45,8 @@ public function testOffsetPast() $repeater = new Horde_Date_Repeater_Month(); $offsetSpan = $repeater->offset($span, 1, 'past'); - $this->assertEquals('2006-07-16 14:00:00', (string)$offsetSpan->begin); - $this->assertEquals('2006-07-16 14:01:00', (string)$offsetSpan->end); + $this->assertEquals('2006-07-16 14:00:00', (string) $offsetSpan->begin); + $this->assertEquals('2006-07-16 14:01:00', (string) $offsetSpan->end); } } diff --git a/test/Unnamespaced/Repeater/TimeTest.php b/test/Unnamespaced/Repeater/TimeTest.php index 5ddeb9d..cbf1a39 100644 --- a/test/Unnamespaced/Repeater/TimeTest.php +++ b/test/Unnamespaced/Repeater/TimeTest.php @@ -17,6 +17,7 @@ * @category Horde * @package Date * @subpackage UnitTests + * @coversNothing */ class TimeTest extends TestCase { @@ -32,33 +33,33 @@ public function testNextFuture() $t = new Horde_Date_Repeater_Time('4:00'); $t->now = $this->now; - $this->assertEquals('2006-08-16 16:00:00', (string)$t->next('future')->begin); - $this->assertEquals('2006-08-17 04:00:00', (string)$t->next('future')->begin); + $this->assertEquals('2006-08-16 16:00:00', (string) $t->next('future')->begin); + $this->assertEquals('2006-08-17 04:00:00', (string) $t->next('future')->begin); $t = new Horde_Date_Repeater_Time('13:00'); $t->now = $this->now; - $this->assertEquals('2006-08-17 13:00:00', (string)$t->next('future')->begin); - $this->assertEquals('2006-08-18 13:00:00', (string)$t->next('future')->begin); + $this->assertEquals('2006-08-17 13:00:00', (string) $t->next('future')->begin); + $this->assertEquals('2006-08-18 13:00:00', (string) $t->next('future')->begin); $t = new Horde_Date_Repeater_Time('0400'); $t->now = $this->now; - $this->assertEquals('2006-08-17 04:00:00', (string)$t->next('future')->begin); - $this->assertEquals('2006-08-18 04:00:00', (string)$t->next('future')->begin); + $this->assertEquals('2006-08-17 04:00:00', (string) $t->next('future')->begin); + $this->assertEquals('2006-08-18 04:00:00', (string) $t->next('future')->begin); } public function testNextPast() { $t = new Horde_Date_Repeater_Time('4:00'); $t->now = $this->now; - $this->assertEquals('2006-08-16 04:00:00', (string)$t->next('past')->begin); - $this->assertEquals('2006-08-15 16:00:00', (string)$t->next('past')->begin); + $this->assertEquals('2006-08-16 04:00:00', (string) $t->next('past')->begin); + $this->assertEquals('2006-08-15 16:00:00', (string) $t->next('past')->begin); $t = new Horde_Date_Repeater_Time('13:00'); $t->now = $this->now; - $this->assertEquals('2006-08-16 13:00:00', (string)$t->next('past')->begin); - $this->assertEquals('2006-08-15 13:00:00', (string)$t->next('past')->begin); + $this->assertEquals('2006-08-16 13:00:00', (string) $t->next('past')->begin); + $this->assertEquals('2006-08-15 13:00:00', (string) $t->next('past')->begin); } public function testType() diff --git a/test/Unnamespaced/Repeater/WeekTest.php b/test/Unnamespaced/Repeater/WeekTest.php index f90fc45..aec5453 100644 --- a/test/Unnamespaced/Repeater/WeekTest.php +++ b/test/Unnamespaced/Repeater/WeekTest.php @@ -18,6 +18,7 @@ * @category Horde * @package Date * @subpackage UnitTests + * @coversNothing */ class WeekTest extends TestCase { @@ -34,12 +35,12 @@ public function testNextFuture() $weeks->now = $this->now; $nextWeek = $weeks->next('future'); - $this->assertEquals('2006-08-20 00:00:00', (string)$nextWeek->begin); - $this->assertEquals('2006-08-27 00:00:00', (string)$nextWeek->end); + $this->assertEquals('2006-08-20 00:00:00', (string) $nextWeek->begin); + $this->assertEquals('2006-08-27 00:00:00', (string) $nextWeek->end); $nextNextWeek = $weeks->next('future'); - $this->assertEquals('2006-08-27 00:00:00', (string)$nextNextWeek->begin); - $this->assertEquals('2006-09-03 00:00:00', (string)$nextNextWeek->end); + $this->assertEquals('2006-08-27 00:00:00', (string) $nextNextWeek->begin); + $this->assertEquals('2006-09-03 00:00:00', (string) $nextNextWeek->end); } public function testNextPast() @@ -48,12 +49,12 @@ public function testNextPast() $weeks->now = $this->now; $lastWeek = $weeks->next('past'); - $this->assertEquals('2006-08-06 00:00:00', (string)$lastWeek->begin); - $this->assertEquals('2006-08-13 00:00:00', (string)$lastWeek->end); + $this->assertEquals('2006-08-06 00:00:00', (string) $lastWeek->begin); + $this->assertEquals('2006-08-13 00:00:00', (string) $lastWeek->end); $lastLastWeek = $weeks->next('past'); - $this->assertEquals('2006-07-30 00:00:00', (string)$lastLastWeek->begin); - $this->assertEquals('2006-08-06 00:00:00', (string)$lastLastWeek->end); + $this->assertEquals('2006-07-30 00:00:00', (string) $lastLastWeek->begin); + $this->assertEquals('2006-08-06 00:00:00', (string) $lastLastWeek->end); } public function testThisFuture() @@ -62,8 +63,8 @@ public function testThisFuture() $weeks->now = $this->now; $thisWeek = $weeks->this('future'); - $this->assertEquals('2006-08-16 15:00:00', (string)$thisWeek->begin); - $this->assertEquals('2006-08-20 00:00:00', (string)$thisWeek->end); + $this->assertEquals('2006-08-16 15:00:00', (string) $thisWeek->begin); + $this->assertEquals('2006-08-20 00:00:00', (string) $thisWeek->end); } public function testThisPast() @@ -72,8 +73,8 @@ public function testThisPast() $weeks->now = $this->now; $thisWeek = $weeks->this('past'); - $this->assertEquals('2006-08-13 00:00:00', (string)$thisWeek->begin); - $this->assertEquals('2006-08-16 14:00:00', (string)$thisWeek->end); + $this->assertEquals('2006-08-13 00:00:00', (string) $thisWeek->begin); + $this->assertEquals('2006-08-16 14:00:00', (string) $thisWeek->end); } public function testOffset() @@ -82,8 +83,8 @@ public function testOffset() $span = new Horde_Date_Span($this->now, $this->now->add(1)); $offsetSpan = $weeks->offset($span, 3, 'future'); - $this->assertEquals('2006-09-06 14:00:00', (string)$offsetSpan->begin); - $this->assertEquals('2006-09-06 14:00:01', (string)$offsetSpan->end); + $this->assertEquals('2006-09-06 14:00:00', (string) $offsetSpan->begin); + $this->assertEquals('2006-09-06 14:00:01', (string) $offsetSpan->end); } } diff --git a/test/Unnamespaced/Repeater/WeekendTest.php b/test/Unnamespaced/Repeater/WeekendTest.php index 6e24eaf..99bcb2f 100644 --- a/test/Unnamespaced/Repeater/WeekendTest.php +++ b/test/Unnamespaced/Repeater/WeekendTest.php @@ -18,6 +18,7 @@ * @category Horde * @package Date * @subpackage UnitTests + * @coversNothing */ class WeekendTest extends TestCase { @@ -34,8 +35,8 @@ public function testNextFuture() $weekend->now = $this->now; $nextWeekend = $weekend->next('future'); - $this->assertEquals('2006-08-19 00:00:00', (string)$nextWeekend->begin); - $this->assertEquals('2006-08-21 00:00:00', (string)$nextWeekend->end); + $this->assertEquals('2006-08-19 00:00:00', (string) $nextWeekend->begin); + $this->assertEquals('2006-08-21 00:00:00', (string) $nextWeekend->end); } public function testNextPast() @@ -44,8 +45,8 @@ public function testNextPast() $weekend->now = $this->now; $lastWeekend = $weekend->next('past'); - $this->assertEquals('2006-08-12 00:00:00', (string)$lastWeekend->begin); - $this->assertEquals('2006-08-14 00:00:00', (string)$lastWeekend->end); + $this->assertEquals('2006-08-12 00:00:00', (string) $lastWeekend->begin); + $this->assertEquals('2006-08-14 00:00:00', (string) $lastWeekend->end); } public function testThisFuture() @@ -54,8 +55,8 @@ public function testThisFuture() $weekend->now = $this->now; $thisWeekend = $weekend->this('future'); - $this->assertEquals('2006-08-19 00:00:00', (string)$thisWeekend->begin); - $this->assertEquals('2006-08-21 00:00:00', (string)$thisWeekend->end); + $this->assertEquals('2006-08-19 00:00:00', (string) $thisWeekend->begin); + $this->assertEquals('2006-08-21 00:00:00', (string) $thisWeekend->end); } public function testThisPast() @@ -64,8 +65,8 @@ public function testThisPast() $weekend->now = $this->now; $thisWeekend = $weekend->this('past'); - $this->assertEquals('2006-08-12 00:00:00', (string)$thisWeekend->begin); - $this->assertEquals('2006-08-14 00:00:00', (string)$thisWeekend->end); + $this->assertEquals('2006-08-12 00:00:00', (string) $thisWeekend->begin); + $this->assertEquals('2006-08-14 00:00:00', (string) $thisWeekend->end); } public function testThisNone() @@ -74,8 +75,8 @@ public function testThisNone() $weekend->now = $this->now; $thisWeekend = $weekend->this('none'); - $this->assertEquals('2006-08-19 00:00:00', (string)$thisWeekend->begin); - $this->assertEquals('2006-08-21 00:00:00', (string)$thisWeekend->end); + $this->assertEquals('2006-08-19 00:00:00', (string) $thisWeekend->begin); + $this->assertEquals('2006-08-21 00:00:00', (string) $thisWeekend->end); } public function testOffset() @@ -84,16 +85,16 @@ public function testOffset() $span = new Horde_Date_Span($this->now, $this->now->add(1)); $offsetSpan = $weekend->offset($span, 3, 'future'); - $this->assertEquals('2006-09-02 00:00:00', (string)$offsetSpan->begin); - $this->assertEquals('2006-09-02 00:00:01', (string)$offsetSpan->end); + $this->assertEquals('2006-09-02 00:00:00', (string) $offsetSpan->begin); + $this->assertEquals('2006-09-02 00:00:01', (string) $offsetSpan->end); $offsetSpan = $weekend->offset($span, 1, 'past'); - $this->assertEquals('2006-08-12 00:00:00', (string)$offsetSpan->begin); - $this->assertEquals('2006-08-12 00:00:01', (string)$offsetSpan->end); + $this->assertEquals('2006-08-12 00:00:00', (string) $offsetSpan->begin); + $this->assertEquals('2006-08-12 00:00:01', (string) $offsetSpan->end); $offsetSpan = $weekend->offset($span, 0, 'future'); - $this->assertEquals('2006-08-12 00:00:00', (string)$offsetSpan->begin); - $this->assertEquals('2006-08-12 00:00:01', (string)$offsetSpan->end); + $this->assertEquals('2006-08-12 00:00:00', (string) $offsetSpan->begin); + $this->assertEquals('2006-08-12 00:00:01', (string) $offsetSpan->end); } } diff --git a/test/Unnamespaced/Repeater/YearTest.php b/test/Unnamespaced/Repeater/YearTest.php index dbe26b2..edad771 100644 --- a/test/Unnamespaced/Repeater/YearTest.php +++ b/test/Unnamespaced/Repeater/YearTest.php @@ -18,6 +18,7 @@ * @category Horde * @package Date * @subpackage UnitTests + * @coversNothing */ class YearTest extends TestCase { @@ -76,12 +77,12 @@ public function testOffset() $years = new Horde_Date_Repeater_Year(); $offsetSpan = $years->offset($span, 3, 'future'); - $this->assertEquals('2009-08-16 14:00:00', (string)$offsetSpan->begin); - $this->assertEquals('2009-08-16 14:00:01', (string)$offsetSpan->end); + $this->assertEquals('2009-08-16 14:00:00', (string) $offsetSpan->begin); + $this->assertEquals('2009-08-16 14:00:01', (string) $offsetSpan->end); $offsetSpan = $years->offset($span, 10, 'past'); - $this->assertEquals('1996-08-16 14:00:00', (string)$offsetSpan->begin); - $this->assertEquals('1996-08-16 14:00:01', (string)$offsetSpan->end); + $this->assertEquals('1996-08-16 14:00:00', (string) $offsetSpan->begin); + $this->assertEquals('1996-08-16 14:00:01', (string) $offsetSpan->end); } } diff --git a/test/Unnamespaced/SpanEdgeCaseTest.php b/test/Unnamespaced/SpanEdgeCaseTest.php index 506d5d5..10c9792 100644 --- a/test/Unnamespaced/SpanEdgeCaseTest.php +++ b/test/Unnamespaced/SpanEdgeCaseTest.php @@ -227,7 +227,7 @@ public function testToStringFormat(): void '2026-04-17 10:00:00', '2026-04-17 12:00:00' ); - $str = (string)$span; + $str = (string) $span; $this->assertStringStartsWith('(', $str); $this->assertStringEndsWith(')', $str); $this->assertStringContainsString('..', $str); diff --git a/test/Unnamespaced/SpanTest.php b/test/Unnamespaced/SpanTest.php index a69ac51..0efacbc 100644 --- a/test/Unnamespaced/SpanTest.php +++ b/test/Unnamespaced/SpanTest.php @@ -17,6 +17,7 @@ * @category Horde * @package Date * @subpackage UnitTests + * @coversNothing */ class SpanTest extends TestCase { diff --git a/test/Unnamespaced/TimezoneAliasTest.php b/test/Unnamespaced/TimezoneAliasTest.php index 9360867..7f9a6b4 100644 --- a/test/Unnamespaced/TimezoneAliasTest.php +++ b/test/Unnamespaced/TimezoneAliasTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use DateTimeZone; #[CoversClass(Horde_Date::class)] class TimezoneAliasTest extends TestCase @@ -226,7 +227,7 @@ public function testTimezoneAbbreviationLookup(): void $result = Horde_Date::getTimezoneAlias('CET'); $this->assertNotEmpty($result); // It should be a valid timezone - $tz = new \DateTimeZone($result); - $this->assertInstanceOf(\DateTimeZone::class, $tz); + $tz = new DateTimeZone($result); + $this->assertInstanceOf(DateTimeZone::class, $tz); } } diff --git a/test/Unnamespaced/UtilsFullTest.php b/test/Unnamespaced/UtilsFullTest.php index e917291..27053c6 100644 --- a/test/Unnamespaced/UtilsFullTest.php +++ b/test/Unnamespaced/UtilsFullTest.php @@ -6,9 +6,13 @@ use Horde_Date; use Horde_Date_Utils; +use Horde\Date\Date; +use Horde\Date\Utils; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use DateTime; +use DateTimeZone; #[CoversClass(Horde_Date_Utils::class)] class UtilsFullTest extends TestCase @@ -53,7 +57,7 @@ public function testDaysInMonth(int $month, int $year, int $expected): void { $this->assertSame( $expected, - (int)Horde_Date_Utils::daysInMonth($month, $year), + (int) Horde_Date_Utils::daysInMonth($month, $year), "daysInMonth($month, $year) mismatch" ); } @@ -223,7 +227,7 @@ public function testLegacyDateFormatterWithTimestamp(): void public function testLegacyDateFormatterWithDateTimeInterface(): void { - $dt = new \DateTime('2026-04-17 10:00:00', new \DateTimeZone('UTC')); + $dt = new DateTime('2026-04-17 10:00:00', new DateTimeZone('UTC')); $result = Horde_Date_Utils::legacyDateFormatter('%Y-%m-%d', $dt); $this->assertSame('2026-04-17', $result); } @@ -239,4 +243,81 @@ public function testLegacyDateFormatterNullDateDefaultsToNow(): void $result = Horde_Date_Utils::legacyDateFormatter('%Y', null); $this->assertSame(date('Y'), $result); } + + // ========================================================================= + // Cross-validation: legacy wrapper vs modern Utils + // ========================================================================= + + #[DataProvider('leapYearProvider')] + public function testIsLeapYearMatchesModern(int $year, bool $expected): void + { + $this->assertSame( + Utils::isLeapYear($year), + Horde_Date_Utils::isLeapYear($year), + "isLeapYear($year): legacy and modern must agree" + ); + } + + #[DataProvider('daysInMonthProvider')] + public function testDaysInMonthMatchesModern(int $month, int $year, int $expected): void + { + $this->assertSame( + Utils::daysInMonth($month, $year), + (int) Horde_Date_Utils::daysInMonth($month, $year), + "daysInMonth($month, $year): legacy and modern must agree" + ); + } + + #[DataProvider('firstDayOfWeekProvider')] + public function testFirstDayOfWeekMatchesModern(int $week, int $year, string $expectedDate): void + { + $legacy = Horde_Date_Utils::firstDayOfWeek($week, $year); + $modern = Utils::firstDayOfWeek($week, $year); + + $this->assertSame( + $modern->format('Y-m-d'), + $legacy->format('Y-m-d'), + "firstDayOfWeek($week, $year): legacy and modern must agree" + ); + } + + public function testFirstDayOfWeekLegacyReturnsHordeDate(): void + { + $legacy = Horde_Date_Utils::firstDayOfWeek(1, 2026); + $this->assertInstanceOf(Horde_Date::class, $legacy); + + $modern = Utils::firstDayOfWeek(1, 2026); + $this->assertInstanceOf(Date::class, $modern); + } + + #[DataProvider('strftime2dateSimpleProvider')] + public function testStrftime2dateMatchesModernForSimpleFormats(string $strftimeFormat, string $expected): void + { + $this->assertSame( + Utils::strftime2date($strftimeFormat), + Horde_Date_Utils::strftime2date($strftimeFormat), + "strftime2date('$strftimeFormat'): legacy and modern must agree" + ); + } + + public static function strftime2dateSimpleProvider(): array + { + return [ + '%Y → Y' => ['%Y', 'Y'], + '%m → m' => ['%m', 'm'], + '%d → d' => ['%d', 'd'], + '%H → H' => ['%H', 'H'], + '%M → i' => ['%M', 'i'], + '%S → s' => ['%S', 's'], + '%A → l' => ['%A', 'l'], + '%a → D' => ['%a', 'D'], + '%B → F' => ['%B', 'F'], + '%b → M' => ['%b', 'M'], + '%F → Y-m-d' => ['%F', 'Y-m-d'], + '%T → H:i:s' => ['%T', 'H:i:s'], + '%R → H:i' => ['%R', 'H:i'], + '%% → %' => ['%%', '%'], + 'composite' => ['%Y-%m-%d %H:%M:%S', 'Y-m-d H:i:s'], + ]; + } } diff --git a/test/Unnamespaced/UtilsTest.php b/test/Unnamespaced/UtilsTest.php index aca8a89..87c8326 100644 --- a/test/Unnamespaced/UtilsTest.php +++ b/test/Unnamespaced/UtilsTest.php @@ -16,6 +16,7 @@ * @category Horde * @package Date * @subpackage UnitTests + * @coversNothing */ class UtilsTest extends TestCase { diff --git a/test/bootstrap.php b/test/bootstrap.php index 2e8bb9e..bd1d431 100644 --- a/test/bootstrap.php +++ b/test/bootstrap.php @@ -13,4 +13,4 @@ break; } } -\Horde_Test_Bootstrap::bootstrap(dirname(__FILE__)); +Horde_Test_Bootstrap::bootstrap(dirname(__FILE__));