From 8c5479159b7ccd9e1d964a8384112254f7b2c42a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:01:12 +0000 Subject: [PATCH 1/7] Initial plan From 22577f0ab08d037915e1a8465827de7a8d4352cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:12:10 +0000 Subject: [PATCH 2/7] Update plan to handle more general simplification logic Co-authored-by: jkrrv <1238391+jkrrv@users.noreply.github.com> --- .../Utilities/Scheduling/Schedule.php | 135 ++++ .../Utilities/Scheduling/ScheduleSet.php | 589 +++++++++++++++++- 2 files changed, 722 insertions(+), 2 deletions(-) diff --git a/src/TouchPoint-WP/Utilities/Scheduling/Schedule.php b/src/TouchPoint-WP/Utilities/Scheduling/Schedule.php index 12795d6..493f9cf 100644 --- a/src/TouchPoint-WP/Utilities/Scheduling/Schedule.php +++ b/src/TouchPoint-WP/Utilities/Scheduling/Schedule.php @@ -6,5 +6,140 @@ class Schedule extends RRule { + /** + * Get the frequency of the rule. + * + * @return string|null The frequency (e.g., 'WEEKLY', 'MONTHLY', 'YEARLY') + */ + public function getFreq(): ?string + { + return $this->rule['FREQ']; + } + /** + * Get the count of occurrences. + * + * @return int|null The count + */ + public function getCount(): ?int + { + return $this->rule['COUNT']; + } + + /** + * Get the UNTIL date. + * + * @return string|null The UNTIL date in RFC format + */ + public function getUntil(): ?string + { + return $this->rule['UNTIL']; + } + + /** + * Get the interval. + * + * @return int The interval + */ + public function getInterval(): int + { + return $this->rule['INTERVAL'] ?? 1; + } + + /** + * Get the BYDAY values as an array. + * + * @return array|null The BYDAY values (e.g., ['MO', 'WE', 'FR']) + */ + public function getByDay(): ?array + { + $byday = $this->rule['BYDAY']; + if ($byday === null || $byday === '') { + return null; + } + if (is_string($byday)) { + return explode(',', $byday); + } + return $byday; + } + + /** + * Get the BYMONTH values as an array. + * + * @return array|null The BYMONTH values (e.g., [1, 3, 9, 11]) + */ + public function getByMonth(): ?array + { + $bymonth = $this->rule['BYMONTH']; + if ($bymonth === null || $bymonth === '') { + return null; + } + if (is_string($bymonth)) { + return array_map('intval', explode(',', $bymonth)); + } + if (is_array($bymonth)) { + return array_map('intval', $bymonth); + } + return [(int)$bymonth]; + } + + /** + * Get the BYHOUR values as an array. + * + * @return array|null The BYHOUR values (e.g., [9, 11]) + */ + public function getByHour(): ?array + { + $byhour = $this->rule['BYHOUR']; + if ($byhour === null || $byhour === '') { + return null; + } + if (is_string($byhour)) { + return array_map('intval', explode(',', $byhour)); + } + if (is_array($byhour)) { + return array_map('intval', $byhour); + } + return [(int)$byhour]; + } + + /** + * Get the BYMINUTE values as an array. + * + * @return array|null The BYMINUTE values + */ + public function getByMinute(): ?array + { + $byminute = $this->rule['BYMINUTE']; + if ($byminute === null || $byminute === '') { + return null; + } + if (is_string($byminute)) { + return array_map('intval', explode(',', $byminute)); + } + if (is_array($byminute)) { + return array_map('intval', $byminute); + } + return [(int)$byminute]; + } + + /** + * Get the BYSECOND values as an array. + * + * @return array|null The BYSECOND values + */ + public function getBySecond(): ?array + { + $bysecond = $this->rule['BYSECOND']; + if ($bysecond === null || $bysecond === '') { + return null; + } + if (is_string($bysecond)) { + return array_map('intval', explode(',', $bysecond)); + } + if (is_array($bysecond)) { + return array_map('intval', $bysecond); + } + return [(int)$bysecond]; + } } \ No newline at end of file diff --git a/src/TouchPoint-WP/Utilities/Scheduling/ScheduleSet.php b/src/TouchPoint-WP/Utilities/Scheduling/ScheduleSet.php index 6ccad23..f57ffc4 100644 --- a/src/TouchPoint-WP/Utilities/Scheduling/ScheduleSet.php +++ b/src/TouchPoint-WP/Utilities/Scheduling/ScheduleSet.php @@ -3,10 +3,595 @@ namespace tp\TouchPointWP\Utilities\Scheduling; use RRule\RSet; +use RRule\RRule; class ScheduleSet extends RSet { - public function mergeIfPossible() { - // TODO : Implement merging logic. + /** + * Merge compatible RRules when possible. + * This method attempts to combine multiple RRules into fewer rules when they share compatible properties. + */ + public function mergeIfPossible(): void + { + $rules = $this->getRRules(); + + if (count($rules) <= 1) { + return; // Nothing to merge + } + + // Group rules by frequency + $groupedByFreq = []; + foreach ($rules as $rule) { + $ruleData = $rule->getRule(); + $freq = $ruleData['FREQ']; + if (!isset($groupedByFreq[$freq])) { + $groupedByFreq[$freq] = []; + } + $groupedByFreq[$freq][] = $rule; + } + + // Process each frequency group + $newRules = []; + foreach ($groupedByFreq as $freq => $freqRules) { + if ($freq === 'WEEKLY') { + $merged = $this->mergeWeeklyRules($freqRules); + $newRules = array_merge($newRules, $merged); + } elseif ($freq === 'YEARLY') { + $merged = $this->mergeYearlyRules($freqRules); + $newRules = array_merge($newRules, $merged); + } elseif ($freq === 'MONTHLY') { + $merged = $this->mergeMonthlyRules($freqRules); + $newRules = array_merge($newRules, $merged); + } else { + // Keep rules we can't merge + $newRules = array_merge($newRules, $freqRules); + } + } + + // Replace the rules in this set + $this->rrules = $newRules; + $this->clearCache(); + } + + /** + * Merge weekly rules if they have compatible properties. + * + * @param array $rules Array of RRule objects + * @return array Merged rules + */ + private function mergeWeeklyRules(array $rules): array + { + if (count($rules) <= 1) { + return $rules; + } + + // Group by compatible properties + $groups = []; + foreach ($rules as $rule) { + $ruleData = $rule->getRule(); + + // Create a signature for grouping + $signature = [ + 'interval' => $ruleData['INTERVAL'] ?? 1, + 'byhour' => $ruleData['BYHOUR'] ?? null, + 'byminute' => $ruleData['BYMINUTE'] ?? null, + 'bysecond' => $ruleData['BYSECOND'] ?? null, + 'wkst' => $ruleData['WKST'] ?? 'MO', + ]; + + // Handle COUNT vs UNTIL separately + if ($ruleData['COUNT'] !== null && $ruleData['COUNT'] !== '') { + $signature['count'] = $ruleData['COUNT']; + $signature['until'] = null; + } elseif ($ruleData['UNTIL'] !== null && $ruleData['UNTIL'] !== '') { + $signature['count'] = null; + $signature['until'] = $this->getWeekOfDate($ruleData['UNTIL']); + } else { + // Open-ended rules + $signature['count'] = null; + $signature['until'] = null; + } + + $key = serialize($signature); + if (!isset($groups[$key])) { + $groups[$key] = [ + 'signature' => $signature, + 'rules' => [], + ]; + } + $groups[$key]['rules'][] = $rule; + } + + // Merge within each group + $mergedRules = []; + foreach ($groups as $group) { + $groupRules = $group['rules']; + if (count($groupRules) === 1) { + $mergedRules[] = $groupRules[0]; + continue; + } + + $signature = $group['signature']; + + // For UNTIL-based rules, check if they end in the same week + if ($signature['until'] !== null) { + // Merge only if all UNTIL dates are in the same week + $allSameWeek = true; + $weekRef = null; + $latestUntil = null; + + foreach ($groupRules as $rule) { + $ruleData = $rule->getRule(); + $until = $ruleData['UNTIL']; + $week = $this->getWeekOfDate($until); + + if ($weekRef === null) { + $weekRef = $week; + $latestUntil = $until; + } else { + if ($week !== $weekRef) { + $allSameWeek = false; + break; + } + // Keep the latest UNTIL date + if ($until > $latestUntil) { + $latestUntil = $until; + } + } + } + + if (!$allSameWeek) { + // Can't merge, keep them separate + $mergedRules = array_merge($mergedRules, $groupRules); + continue; + } + + // Merge BYDAY + $mergedByDay = $this->mergeByDayValues($groupRules); + + // Create merged rule + $mergedRule = $this->createWeeklyRule( + $mergedByDay, + $signature['interval'], + null, + $latestUntil, + $signature['byhour'], + $signature['byminute'], + $signature['bysecond'], + $signature['wkst'] + ); + + $mergedRules[] = $mergedRule; + } else { + // For COUNT-based or open-ended rules, merge BYDAY + $mergedByDay = $this->mergeByDayValues($groupRules); + + // For COUNT-based rules, sum the counts + $totalCount = null; + if ($signature['count'] !== null) { + $totalCount = 0; + foreach ($groupRules as $rule) { + $ruleData = $rule->getRule(); + $totalCount += $ruleData['COUNT']; + } + } + + // Create merged rule + $mergedRule = $this->createWeeklyRule( + $mergedByDay, + $signature['interval'], + $totalCount, + null, + $signature['byhour'], + $signature['byminute'], + $signature['bysecond'], + $signature['wkst'] + ); + + $mergedRules[] = $mergedRule; + } + } + + return $mergedRules; + } + + /** + * Merge yearly rules if they have compatible properties. + * + * @param array $rules Array of RRule objects + * @return array Merged rules + */ + private function mergeYearlyRules(array $rules): array + { + if (count($rules) <= 1) { + return $rules; + } + + // Group by compatible properties (excluding BYMONTH) + $groups = []; + foreach ($rules as $rule) { + $ruleData = $rule->getRule(); + + $signature = [ + 'interval' => $ruleData['INTERVAL'] ?? 1, + 'byday' => $ruleData['BYDAY'] ?? null, + 'byhour' => $ruleData['BYHOUR'] ?? null, + 'byminute' => $ruleData['BYMINUTE'] ?? null, + 'bysecond' => $ruleData['BYSECOND'] ?? null, + 'count' => $ruleData['COUNT'] ?? null, + 'until' => $ruleData['UNTIL'] ?? null, + ]; + + $key = serialize($signature); + if (!isset($groups[$key])) { + $groups[$key] = [ + 'signature' => $signature, + 'rules' => [], + ]; + } + $groups[$key]['rules'][] = $rule; + } + + // Merge within each group + $mergedRules = []; + foreach ($groups as $group) { + $groupRules = $group['rules']; + if (count($groupRules) === 1) { + $mergedRules[] = $groupRules[0]; + continue; + } + + // Merge BYMONTH + $allMonths = []; + foreach ($groupRules as $rule) { + $ruleData = $rule->getRule(); + $bymonth = $ruleData['BYMONTH']; + if ($bymonth !== null && $bymonth !== '') { + if (is_string($bymonth)) { + $months = explode(',', $bymonth); + } elseif (is_array($bymonth)) { + $months = $bymonth; + } else { + $months = [$bymonth]; + } + foreach ($months as $month) { + $allMonths[] = (int)$month; + } + } + } + + $allMonths = array_unique($allMonths); + sort($allMonths); + + // Create merged rule + $signature = $group['signature']; + $mergedRule = $this->createYearlyRule( + $allMonths, + $signature['byday'], + $signature['interval'], + $signature['count'], + $signature['until'], + $signature['byhour'], + $signature['byminute'], + $signature['bysecond'] + ); + + $mergedRules[] = $mergedRule; + } + + return $mergedRules; + } + + /** + * Merge monthly rules and potentially convert to weekly if applicable. + * + * @param array $rules Array of RRule objects + * @return array Merged rules + */ + private function mergeMonthlyRules(array $rules): array + { + if (count($rules) <= 1) { + return $rules; + } + + // Check if all rules can be simplified to a weekly rule + // This happens when we have multiple monthly rules that cover all Nth occurrences of a weekday + + // Group by compatible properties (excluding BYDAY) + $groups = []; + foreach ($rules as $rule) { + $ruleData = $rule->getRule(); + + $signature = [ + 'interval' => $ruleData['INTERVAL'] ?? 1, + 'bymonth' => $ruleData['BYMONTH'] ?? null, + 'count' => $ruleData['COUNT'] ?? null, + 'until' => $ruleData['UNTIL'] ?? null, + ]; + + $key = serialize($signature); + if (!isset($groups[$key])) { + $groups[$key] = [ + 'signature' => $signature, + 'rules' => [], + 'byhours' => [], + ]; + } + $groups[$key]['rules'][] = $rule; + + // Collect BYHOUR values + $byhour = $ruleData['BYHOUR']; + if ($byhour !== null && $byhour !== '') { + if (is_string($byhour)) { + $hours = explode(',', $byhour); + } elseif (is_array($byhour)) { + $hours = $byhour; + } else { + $hours = [$byhour]; + } + foreach ($hours as $hour) { + $groups[$key]['byhours'][] = (int)$hour; + } + } + } + + // Process each group + $mergedRules = []; + foreach ($groups as $group) { + $groupRules = $group['rules']; + $signature = $group['signature']; + + // Extract BYDAY patterns to see if they can be simplified + $allByDays = []; + $weekdayPattern = null; + $canSimplify = true; + + foreach ($groupRules as $rule) { + $ruleData = $rule->getRule(); + $byday = $ruleData['BYDAY']; + + if ($byday !== null && $byday !== '') { + if (is_string($byday)) { + $days = explode(',', $byday); + } elseif (is_array($byday)) { + $days = $byday; + } else { + $days = [$byday]; + } + + foreach ($days as $day) { + // Parse Nth weekday (e.g., "1SU", "2MO", "-1FR") + if (preg_match('/^(-?\d+)([A-Z]{2})$/', $day, $matches)) { + $nth = $matches[1]; + $weekday = $matches[2]; + + if ($weekdayPattern === null) { + $weekdayPattern = $weekday; + } elseif ($weekdayPattern !== $weekday) { + $canSimplify = false; + } + + $allByDays[] = $day; + } else { + $canSimplify = false; + } + } + } + } + + // Check if we have all Nth occurrences (1-5 and -1 to -5) for the same weekday + if ($canSimplify && $weekdayPattern !== null && count($groupRules) >= 5) { + // Count unique Nth values + $nthValues = []; + foreach ($allByDays as $day) { + if (preg_match('/^(-?\d+)([A-Z]{2})$/', $day, $matches)) { + $nthValues[] = (int)$matches[1]; + } + } + $nthValues = array_unique($nthValues); + + // If we have enough different Nth values, it's effectively "every weekday" + if (count($nthValues) >= 5) { + // Convert to weekly rule + $allHours = array_unique($group['byhours']); + sort($allHours); + + $byminute = null; + $bysecond = null; + // Get byminute and bysecond from first rule (assuming they're the same) + if (count($groupRules) > 0) { + $firstRuleData = $groupRules[0]->getRule(); + $byminute = $firstRuleData['BYMINUTE'] ?? null; + $bysecond = $firstRuleData['BYSECOND'] ?? null; + } + + $mergedRule = $this->createWeeklyRule( + [$weekdayPattern], + 1, // weekly interval + $signature['count'], + $signature['until'], + count($allHours) > 0 ? implode(',', $allHours) : null, + $byminute, + $bysecond, + 'MO' + ); + + $mergedRules[] = $mergedRule; + continue; + } + } + + // Can't simplify, keep original rules + $mergedRules = array_merge($mergedRules, $groupRules); + } + + return $mergedRules; + } + + /** + * Get the week identifier for a date string (year-week format). + * + * @param string $dateStr Date string in RFC format + * @return string Week identifier (e.g., "2025-52") + */ + private function getWeekOfDate(string $dateStr): string + { + // Parse the date string (e.g., "20251227T000000Z") + if (preg_match('/^(\d{4})(\d{2})(\d{2})/', $dateStr, $matches)) { + $year = $matches[1]; + $month = $matches[2]; + $day = $matches[3]; + + $date = new \DateTime("{$year}-{$month}-{$day}"); + return $date->format('o-W'); // ISO-8601 year and week number + } + + return $dateStr; // Fallback + } + + /** + * Merge BYDAY values from multiple rules. + * + * @param array $rules Array of RRule objects + * @return array Merged BYDAY values + */ + private function mergeByDayValues(array $rules): array + { + $allDays = []; + foreach ($rules as $rule) { + $ruleData = $rule->getRule(); + $byday = $ruleData['BYDAY']; + + if ($byday !== null && $byday !== '') { + if (is_string($byday)) { + $days = explode(',', $byday); + } elseif (is_array($byday)) { + $days = $byday; + } else { + $days = [$byday]; + } + + foreach ($days as $day) { + $allDays[] = $day; + } + } + } + + $allDays = array_unique($allDays); + + // Sort by weekday order + $dayOrder = ['MO' => 1, 'TU' => 2, 'WE' => 3, 'TH' => 4, 'FR' => 5, 'SA' => 6, 'SU' => 7]; + usort($allDays, function($a, $b) use ($dayOrder) { + // Extract the weekday part (last 2 characters) + $weekdayA = substr($a, -2); + $weekdayB = substr($b, -2); + + $orderA = $dayOrder[$weekdayA] ?? 999; + $orderB = $dayOrder[$weekdayB] ?? 999; + + return $orderA <=> $orderB; + }); + + return $allDays; + } + + /** + * Create a new weekly RRule. + * + * @param array $byday Array of weekday values + * @param int $interval Interval + * @param int|null $count Count + * @param string|null $until Until date + * @param string|null $byhour Hour constraint + * @param string|null $byminute Minute constraint + * @param string|null $bysecond Second constraint + * @param string $wkst Week start + * @return RRule + */ + private function createWeeklyRule( + array $byday, + int $interval, + ?int $count, + ?string $until, + ?string $byhour, + ?string $byminute, + ?string $bysecond, + string $wkst + ): RRule { + $parts = [ + 'FREQ' => 'WEEKLY', + 'BYDAY' => implode(',', $byday), + 'INTERVAL' => $interval, + 'WKST' => $wkst, + ]; + + if ($count !== null) { + $parts['COUNT'] = $count; + } + if ($until !== null) { + $parts['UNTIL'] = $until; + } + if ($byhour !== null && $byhour !== '') { + $parts['BYHOUR'] = $byhour; + } + if ($byminute !== null && $byminute !== '') { + $parts['BYMINUTE'] = $byminute; + } + if ($bysecond !== null && $bysecond !== '') { + $parts['BYSECOND'] = $bysecond; + } + + return new RRule($parts); + } + + /** + * Create a new yearly RRule. + * + * @param array $bymonth Array of month values + * @param string|null $byday Day constraint + * @param int $interval Interval + * @param int|null $count Count + * @param string|null $until Until date + * @param string|null $byhour Hour constraint + * @param string|null $byminute Minute constraint + * @param string|null $bysecond Second constraint + * @return RRule + */ + private function createYearlyRule( + array $bymonth, + ?string $byday, + int $interval, + ?int $count, + ?string $until, + ?string $byhour, + ?string $byminute, + ?string $bysecond + ): RRule { + $parts = [ + 'FREQ' => 'YEARLY', + 'BYMONTH' => implode(',', $bymonth), + 'INTERVAL' => $interval, + ]; + + if ($byday !== null && $byday !== '') { + $parts['BYDAY'] = $byday; + } + if ($count !== null) { + $parts['COUNT'] = $count; + } + if ($until !== null) { + $parts['UNTIL'] = $until; + } + if ($byhour !== null && $byhour !== '') { + $parts['BYHOUR'] = $byhour; + } + if ($byminute !== null && $byminute !== '') { + $parts['BYMINUTE'] = $byminute; + } + if ($bysecond !== null && $bysecond !== '') { + $parts['BYSECOND'] = $bysecond; + } + + return new RRule($parts); } } \ No newline at end of file From 640332e8c600622457403472b9ef673e8312d25c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:16:46 +0000 Subject: [PATCH 3/7] Implement mergeIfPossible() logic for ScheduleSet - all tests passing Co-authored-by: jkrrv <1238391+jkrrv@users.noreply.github.com> --- .../Utilities/Scheduling/Schedule.php | 14 ++- .../Utilities/Scheduling/ScheduleSet.php | 108 ++++++++++++++---- 2 files changed, 99 insertions(+), 23 deletions(-) diff --git a/src/TouchPoint-WP/Utilities/Scheduling/Schedule.php b/src/TouchPoint-WP/Utilities/Scheduling/Schedule.php index 493f9cf..f499c70 100644 --- a/src/TouchPoint-WP/Utilities/Scheduling/Schedule.php +++ b/src/TouchPoint-WP/Utilities/Scheduling/Schedule.php @@ -29,11 +29,19 @@ public function getCount(): ?int /** * Get the UNTIL date. * - * @return string|null The UNTIL date in RFC format + * @return string|\DateTime|null The UNTIL date (can be DateTime object or string) */ - public function getUntil(): ?string + public function getUntil() { - return $this->rule['UNTIL']; + $until = $this->rule['UNTIL']; + + // If it's a DateTime, format it as RFC string for compatibility + if ($until instanceof \DateTime) { + // Format as RFC 5545 format (YmdTHisZ) + return $until->format('Ymd\THis\Z'); + } + + return $until; } /** diff --git a/src/TouchPoint-WP/Utilities/Scheduling/ScheduleSet.php b/src/TouchPoint-WP/Utilities/Scheduling/ScheduleSet.php index f57ffc4..68715b0 100644 --- a/src/TouchPoint-WP/Utilities/Scheduling/ScheduleSet.php +++ b/src/TouchPoint-WP/Utilities/Scheduling/ScheduleSet.php @@ -7,6 +7,25 @@ class ScheduleSet extends RSet { + /** + * Constructor that extends RSet to support arrays of Schedule objects. + * + * @param string|array|null $input RFC string or array of Schedule/RRule objects + * @param \DateTime|null $default_dtstart Default start date + */ + public function __construct($input = null, $default_dtstart = null) + { + // If input is an array, handle it specially + if (is_array($input)) { + parent::__construct(null, $default_dtstart); + foreach ($input as $rule) { + $this->addRRule($rule); + } + } else { + parent::__construct($input, $default_dtstart); + } + } + /** * Merge compatible RRules when possible. * This method attempts to combine multiple RRules into fewer rules when they share compatible properties. @@ -134,7 +153,8 @@ private function mergeWeeklyRules(array $rules): array break; } // Keep the latest UNTIL date - if ($until > $latestUntil) { + $untilCompare = $this->compareDates($until, $latestUntil); + if ($untilCompare > 0) { $latestUntil = $until; } } @@ -429,24 +449,72 @@ private function mergeMonthlyRules(array $rules): array } /** - * Get the week identifier for a date string (year-week format). + * Get the week identifier for a date (year-week format). * - * @param string $dateStr Date string in RFC format + * @param string|\DateTime $date Date string in RFC format or DateTime object * @return string Week identifier (e.g., "2025-52") */ - private function getWeekOfDate(string $dateStr): string + private function getWeekOfDate($date): string { + if ($date instanceof \DateTime) { + return $date->format('o-W'); // ISO-8601 year and week number + } + // Parse the date string (e.g., "20251227T000000Z") - if (preg_match('/^(\d{4})(\d{2})(\d{2})/', $dateStr, $matches)) { + if (is_string($date) && preg_match('/^(\d{4})(\d{2})(\d{2})/', $date, $matches)) { $year = $matches[1]; $month = $matches[2]; $day = $matches[3]; - $date = new \DateTime("{$year}-{$month}-{$day}"); - return $date->format('o-W'); // ISO-8601 year and week number + $dateObj = new \DateTime("{$year}-{$month}-{$day}"); + return $dateObj->format('o-W'); // ISO-8601 year and week number + } + + return (string)$date; // Fallback + } + + /** + * Compare two dates (can be DateTime objects or strings). + * + * @param string|\DateTime $date1 First date + * @param string|\DateTime $date2 Second date + * @return int -1 if date1 < date2, 0 if equal, 1 if date1 > date2 + */ + private function compareDates($date1, $date2): int + { + $dt1 = $date1 instanceof \DateTime ? $date1 : $this->parseDateString($date1); + $dt2 = $date2 instanceof \DateTime ? $date2 : $this->parseDateString($date2); + + if ($dt1 < $dt2) { + return -1; + } elseif ($dt1 > $dt2) { + return 1; + } + return 0; + } + + /** + * Parse a date string to DateTime. + * + * @param string $dateStr Date string + * @return \DateTime + */ + private function parseDateString(string $dateStr): \DateTime + { + // Try to parse RFC format (e.g., "20251227T000000Z") + if (preg_match('/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/', $dateStr, $matches)) { + $year = $matches[1]; + $month = $matches[2]; + $day = $matches[3]; + $hour = $matches[4]; + $minute = $matches[5]; + $second = $matches[6]; + + return new \DateTime("{$year}-{$month}-{$day} {$hour}:{$minute}:{$second}"); } - return $dateStr; // Fallback + // Fallback to standard parsing + return new \DateTime($dateStr); } /** @@ -496,28 +564,28 @@ private function mergeByDayValues(array $rules): array } /** - * Create a new weekly RRule. + * Create a new weekly Schedule (extends RRule). * * @param array $byday Array of weekday values * @param int $interval Interval * @param int|null $count Count - * @param string|null $until Until date + * @param string|\DateTime|null $until Until date * @param string|null $byhour Hour constraint * @param string|null $byminute Minute constraint * @param string|null $bysecond Second constraint * @param string $wkst Week start - * @return RRule + * @return Schedule */ private function createWeeklyRule( array $byday, int $interval, ?int $count, - ?string $until, + $until, ?string $byhour, ?string $byminute, ?string $bysecond, string $wkst - ): RRule { + ): Schedule { $parts = [ 'FREQ' => 'WEEKLY', 'BYDAY' => implode(',', $byday), @@ -541,32 +609,32 @@ private function createWeeklyRule( $parts['BYSECOND'] = $bysecond; } - return new RRule($parts); + return new Schedule($parts); } /** - * Create a new yearly RRule. + * Create a new yearly Schedule (extends RRule). * * @param array $bymonth Array of month values * @param string|null $byday Day constraint * @param int $interval Interval * @param int|null $count Count - * @param string|null $until Until date + * @param string|\DateTime|null $until Until date * @param string|null $byhour Hour constraint * @param string|null $byminute Minute constraint * @param string|null $bysecond Second constraint - * @return RRule + * @return Schedule */ private function createYearlyRule( array $bymonth, ?string $byday, int $interval, ?int $count, - ?string $until, + $until, ?string $byhour, ?string $byminute, ?string $bysecond - ): RRule { + ): Schedule { $parts = [ 'FREQ' => 'YEARLY', 'BYMONTH' => implode(',', $bymonth), @@ -592,6 +660,6 @@ private function createYearlyRule( $parts['BYSECOND'] = $bysecond; } - return new RRule($parts); + return new Schedule($parts); } } \ No newline at end of file From b22a42e3ad502985e129dd775052b3e78adfffd3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:18:57 +0000 Subject: [PATCH 4/7] Address code review feedback - add constants and improve encapsulation Co-authored-by: jkrrv <1238391+jkrrv@users.noreply.github.com> --- .../Utilities/Scheduling/ScheduleSet.php | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/TouchPoint-WP/Utilities/Scheduling/ScheduleSet.php b/src/TouchPoint-WP/Utilities/Scheduling/ScheduleSet.php index 68715b0..fd62e33 100644 --- a/src/TouchPoint-WP/Utilities/Scheduling/ScheduleSet.php +++ b/src/TouchPoint-WP/Utilities/Scheduling/ScheduleSet.php @@ -7,6 +7,11 @@ class ScheduleSet extends RSet { + /** + * Minimum number of Nth values required to simplify monthly rules to weekly. + */ + private const MIN_NTH_VALUES_FOR_WEEKLY_SIMPLIFICATION = 5; + /** * Constructor that extends RSet to support arrays of Schedule objects. * @@ -67,8 +72,11 @@ public function mergeIfPossible(): void } } - // Replace the rules in this set - $this->rrules = $newRules; + // Replace the rules in this set by clearing and re-adding + $this->rrules = []; + foreach ($newRules as $rule) { + $this->addRRule($rule); + } $this->clearCache(); } @@ -99,10 +107,10 @@ private function mergeWeeklyRules(array $rules): array ]; // Handle COUNT vs UNTIL separately - if ($ruleData['COUNT'] !== null && $ruleData['COUNT'] !== '') { + if (!empty($ruleData['COUNT'])) { $signature['count'] = $ruleData['COUNT']; $signature['until'] = null; - } elseif ($ruleData['UNTIL'] !== null && $ruleData['UNTIL'] !== '') { + } elseif (!empty($ruleData['UNTIL'])) { $signature['count'] = null; $signature['until'] = $this->getWeekOfDate($ruleData['UNTIL']); } else { @@ -400,7 +408,7 @@ private function mergeMonthlyRules(array $rules): array } // Check if we have all Nth occurrences (1-5 and -1 to -5) for the same weekday - if ($canSimplify && $weekdayPattern !== null && count($groupRules) >= 5) { + if ($canSimplify && $weekdayPattern !== null && count($groupRules) >= self::MIN_NTH_VALUES_FOR_WEEKLY_SIMPLIFICATION) { // Count unique Nth values $nthValues = []; foreach ($allByDays as $day) { @@ -411,7 +419,7 @@ private function mergeMonthlyRules(array $rules): array $nthValues = array_unique($nthValues); // If we have enough different Nth values, it's effectively "every weekday" - if (count($nthValues) >= 5) { + if (count($nthValues) >= self::MIN_NTH_VALUES_FOR_WEEKLY_SIMPLIFICATION) { // Convert to weekly rule $allHours = array_unique($group['byhours']); sort($allHours); From fabb97046432f46c7e69046440aad4f87fe62824 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 21:40:14 +0000 Subject: [PATCH 5/7] Implement iterative reduction and fix end date compatibility logic Co-authored-by: jkrrv <1238391+jkrrv@users.noreply.github.com> --- .../Utilities/Scheduling/ScheduleSet.php | 139 +++++++++++++----- 1 file changed, 100 insertions(+), 39 deletions(-) diff --git a/src/TouchPoint-WP/Utilities/Scheduling/ScheduleSet.php b/src/TouchPoint-WP/Utilities/Scheduling/ScheduleSet.php index fd62e33..f16591d 100644 --- a/src/TouchPoint-WP/Utilities/Scheduling/ScheduleSet.php +++ b/src/TouchPoint-WP/Utilities/Scheduling/ScheduleSet.php @@ -34,6 +34,7 @@ public function __construct($input = null, $default_dtstart = null) /** * Merge compatible RRules when possible. * This method attempts to combine multiple RRules into fewer rules when they share compatible properties. + * It applies iteratively to reduce rules as far as possible (e.g., Yearly -> Monthly -> Weekly). */ public function mergeIfPossible(): void { @@ -43,38 +44,51 @@ public function mergeIfPossible(): void return; // Nothing to merge } - // Group rules by frequency - $groupedByFreq = []; - foreach ($rules as $rule) { - $ruleData = $rule->getRule(); - $freq = $ruleData['FREQ']; - if (!isset($groupedByFreq[$freq])) { - $groupedByFreq[$freq] = []; + $maxIterations = 10; // Prevent infinite loops + $iteration = 0; + $previousCount = count($rules); + + do { + $iteration++; + + // Group rules by frequency + $groupedByFreq = []; + foreach ($rules as $rule) { + $ruleData = $rule->getRule(); + $freq = $ruleData['FREQ']; + if (!isset($groupedByFreq[$freq])) { + $groupedByFreq[$freq] = []; + } + $groupedByFreq[$freq][] = $rule; } - $groupedByFreq[$freq][] = $rule; - } - // Process each frequency group - $newRules = []; - foreach ($groupedByFreq as $freq => $freqRules) { - if ($freq === 'WEEKLY') { - $merged = $this->mergeWeeklyRules($freqRules); - $newRules = array_merge($newRules, $merged); - } elseif ($freq === 'YEARLY') { - $merged = $this->mergeYearlyRules($freqRules); - $newRules = array_merge($newRules, $merged); - } elseif ($freq === 'MONTHLY') { - $merged = $this->mergeMonthlyRules($freqRules); - $newRules = array_merge($newRules, $merged); - } else { - // Keep rules we can't merge - $newRules = array_merge($newRules, $freqRules); + // Process each frequency group + $newRules = []; + foreach ($groupedByFreq as $freq => $freqRules) { + if ($freq === 'WEEKLY') { + $merged = $this->mergeWeeklyRules($freqRules); + $newRules = array_merge($newRules, $merged); + } elseif ($freq === 'YEARLY') { + $merged = $this->mergeYearlyRules($freqRules); + $newRules = array_merge($newRules, $merged); + } elseif ($freq === 'MONTHLY') { + $merged = $this->mergeMonthlyRules($freqRules); + $newRules = array_merge($newRules, $merged); + } else { + // Keep rules we can't merge + $newRules = array_merge($newRules, $freqRules); + } } - } + + $rules = $newRules; + $currentCount = count($rules); + + // Continue if we made progress and haven't hit max iterations + } while ($currentCount < $previousCount && $iteration < $maxIterations && $currentCount = $previousCount = $currentCount); // Replace the rules in this set by clearing and re-adding $this->rrules = []; - foreach ($newRules as $rule) { + foreach ($rules as $rule) { $this->addRRule($rule); } $this->clearCache(); @@ -140,27 +154,20 @@ private function mergeWeeklyRules(array $rules): array $signature = $group['signature']; - // For UNTIL-based rules, check if they end in the same week + // For UNTIL-based rules, check if they can share an end date if ($signature['until'] !== null) { - // Merge only if all UNTIL dates are in the same week - $allSameWeek = true; - $weekRef = null; + // Check if all rules can be merged with a shared end date + $canMerge = true; $latestUntil = null; + // Find the latest UNTIL date foreach ($groupRules as $rule) { $ruleData = $rule->getRule(); $until = $ruleData['UNTIL']; - $week = $this->getWeekOfDate($until); - if ($weekRef === null) { - $weekRef = $week; + if ($latestUntil === null) { $latestUntil = $until; } else { - if ($week !== $weekRef) { - $allSameWeek = false; - break; - } - // Keep the latest UNTIL date $untilCompare = $this->compareDates($until, $latestUntil); if ($untilCompare > 0) { $latestUntil = $until; @@ -168,7 +175,34 @@ private function mergeWeeklyRules(array $rules): array } } - if (!$allSameWeek) { + // Check if each rule's pattern is compatible with using the latest end date + // A rule is compatible if it doesn't have occurrences between its own end and the latest end + // that would conflict with another rule's pattern + foreach ($groupRules as $rule) { + $ruleData = $rule->getRule(); + $ruleUntil = $ruleData['UNTIL']; + + // If this rule ends before the latest, check if its pattern would have + // any occurrences between its end and the latest end + if ($this->compareDates($ruleUntil, $latestUntil) < 0) { + // For weekly rules, check if this rule's days would occur + // between its end and the latest end + $ruleByday = $ruleData['BYDAY']; + if (!empty($ruleByday)) { + $ruleDays = is_string($ruleByday) ? explode(',', $ruleByday) : $ruleByday; + + // Check if any occurrence would fall between ruleUntil and latestUntil + $hasConflict = $this->hasOccurrencesBetween($rule, $ruleUntil, $latestUntil); + + if ($hasConflict) { + $canMerge = false; + break; + } + } + } + } + + if (!$canMerge) { // Can't merge, keep them separate $mergedRules = array_merge($mergedRules, $groupRules); continue; @@ -525,6 +559,33 @@ private function parseDateString(string $dateStr): \DateTime return new \DateTime($dateStr); } + /** + * Check if a rule would have any occurrences between two dates. + * + * @param \RRule\RRule $rule The rule to check + * @param string|\DateTime $startDate Start of the range (exclusive) + * @param string|\DateTime $endDate End of the range (inclusive) + * @return bool True if the rule has occurrences in the range + */ + private function hasOccurrencesBetween($rule, $startDate, $endDate): bool + { + try { + $start = $startDate instanceof \DateTime ? clone $startDate : $this->parseDateString($startDate); + $end = $endDate instanceof \DateTime ? clone $endDate : $this->parseDateString($endDate); + + // Add one day to start to make it exclusive + $start->modify('+1 day'); + + // Get occurrences between the dates + $occurrences = $rule->getOccurrencesBetween($start, $end, 1); + + return count($occurrences) > 0; + } catch (\Exception $e) { + // If we can't determine, assume there might be occurrences (safer) + return true; + } + } + /** * Merge BYDAY values from multiple rules. * From 81d47912e9c59944a99769544ee0bab5bae746e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 21:42:29 +0000 Subject: [PATCH 6/7] Fix infinite loop bug in iterative merge logic Co-authored-by: jkrrv <1238391+jkrrv@users.noreply.github.com> --- src/TouchPoint-WP/Utilities/Scheduling/ScheduleSet.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/TouchPoint-WP/Utilities/Scheduling/ScheduleSet.php b/src/TouchPoint-WP/Utilities/Scheduling/ScheduleSet.php index f16591d..33be81e 100644 --- a/src/TouchPoint-WP/Utilities/Scheduling/ScheduleSet.php +++ b/src/TouchPoint-WP/Utilities/Scheduling/ScheduleSet.php @@ -84,7 +84,10 @@ public function mergeIfPossible(): void $currentCount = count($rules); // Continue if we made progress and haven't hit max iterations - } while ($currentCount < $previousCount && $iteration < $maxIterations && $currentCount = $previousCount = $currentCount); + $madeProgress = $currentCount < $previousCount; + $previousCount = $currentCount; + + } while ($madeProgress && $iteration < $maxIterations); // Replace the rules in this set by clearing and re-adding $this->rrules = []; From 576faae33c9430f61c66f96b52107c9bcc419ba0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 22:02:59 +0000 Subject: [PATCH 7/7] Add comprehensive unit tests for Schedule and ScheduleSet classes Co-authored-by: jkrrv <1238391+jkrrv@users.noreply.github.com> --- .../Utilities/Scheduling/ScheduleSet_Test.php | 371 ++++++++++++++++++ .../Utilities/Scheduling/Schedule_Test.php | 185 ++++++++- 2 files changed, 555 insertions(+), 1 deletion(-) diff --git a/tests/Unit/Utilities/Scheduling/ScheduleSet_Test.php b/tests/Unit/Utilities/Scheduling/ScheduleSet_Test.php index c95ebed..4b1ec08 100644 --- a/tests/Unit/Utilities/Scheduling/ScheduleSet_Test.php +++ b/tests/Unit/Utilities/Scheduling/ScheduleSet_Test.php @@ -125,6 +125,377 @@ public function test_merge_monthlyNthSundayMultipleTimes(): void $this->assertEquals(['SU'], $mergedSchedule->getByDay()); $this->assertEquals([9, 11], $mergedSchedule->getByHour()); } + + /** + * Test end date compatibility: rules that CAN merge because earlier rule has no occurrences between its end and later end + */ + public function test_merge_endDateCompatible_noConflict(): void + { + // MO ending Dec 22, TU ending Dec 30 + // Monday on Dec 22, next Monday would be Dec 29 + // So Monday rule ending Dec 22 WOULD conflict if extended to Dec 30 + $schedule1 = new Schedule("FREQ=WEEKLY;BYDAY=TU;UNTIL=20251223T000000Z"); // Tuesday Dec 23 + $schedule2 = new Schedule("FREQ=WEEKLY;BYDAY=WE;UNTIL=20251227T000000Z"); // Wednesday Dec 27 + + $set = new ScheduleSet([$schedule1, $schedule2]); + $set->mergeIfPossible(); + + // Should merge because Tuesday (Dec 23) doesn't occur again before Dec 27 + $this->assertCount(1, $set->getRRules()); + $merged = $set->getRRules()[0]; + $this->assertEquals(['TU', 'WE'], $merged->getByDay()); + $this->assertEquals('20251227T000000Z', $merged->getUntil()); + } + + /** + * Test end date compatibility: rules that CANNOT merge because earlier rule would have occurrences before later end + */ + public function test_merge_endDateIncompatible_withConflict(): void + { + // Monday ending Dec 22, Tuesday ending Dec 30 + // If we extend Monday to Dec 30, there would be a Monday on Dec 29 + $schedule1 = new Schedule("FREQ=WEEKLY;BYDAY=MO;UNTIL=20251222T000000Z"); // Monday Dec 22 + $schedule2 = new Schedule("FREQ=WEEKLY;BYDAY=TU;UNTIL=20251230T000000Z"); // Tuesday Dec 30 + + $set = new ScheduleSet([$schedule1, $schedule2]); + $set->mergeIfPossible(); + + // Should NOT merge because Monday rule would have occurrence on Dec 29 + $this->assertCount(2, $set->getRRules()); + } + + /** + * Test merging rules with same end date + */ + public function test_merge_sameEndDate(): void + { + $schedule1 = new Schedule("FREQ=WEEKLY;BYDAY=MO,WE;UNTIL=20251230T000000Z"); + $schedule2 = new Schedule("FREQ=WEEKLY;BYDAY=FR;UNTIL=20251230T000000Z"); + + $set = new ScheduleSet([$schedule1, $schedule2]); + $set->mergeIfPossible(); + + $this->assertCount(1, $set->getRRules()); + $merged = $set->getRRules()[0]; + $this->assertEquals(['MO', 'WE', 'FR'], $merged->getByDay()); + $this->assertEquals('20251230T000000Z', $merged->getUntil()); + } + + /** + * Test constructor with array of rules + */ + public function test_constructor_withArray(): void + { + $schedule1 = new Schedule("FREQ=WEEKLY;BYDAY=MO"); + $schedule2 = new Schedule("FREQ=WEEKLY;BYDAY=TU"); + + $set = new ScheduleSet([$schedule1, $schedule2]); + + $this->assertCount(2, $set->getRRules()); + } + + /** + * Test constructor with null + */ + public function test_constructor_withNull(): void + { + $set = new ScheduleSet(); + $this->assertCount(0, $set->getRRules()); + } + + /** + * Test mergeIfPossible with single rule (should not change) + */ + public function test_merge_singleRule(): void + { + $set = new ScheduleSet("RRULE:FREQ=WEEKLY;BYDAY=MO"); + $set->mergeIfPossible(); + + $this->assertCount(1, $set->getRRules()); + } + + /** + * Test merging yearly rules with different BYMONTH + */ + public function test_merge_yearlyDifferentMonths(): void + { + $set = new ScheduleSet(); + $set->addRRule("FREQ=YEARLY;BYMONTH=1;BYDAY=1MO;BYHOUR=10"); + $set->addRRule("FREQ=YEARLY;BYMONTH=6;BYDAY=1MO;BYHOUR=10"); + + $set->mergeIfPossible(); + + $this->assertCount(1, $set->getRRules()); + $merged = $set->getRRules()[0]; + $this->assertEquals("YEARLY", $merged->getFreq()); + $this->assertEquals([1, 6], $merged->getByMonth()); + } + + /** + * Test merging monthly rules that don't simplify to weekly + */ + public function test_merge_monthlyNotSimplifiedToWeekly(): void + { + $set = new ScheduleSet(); + // Only 3 Nth values, not enough to simplify to weekly + for ($n = 1; $n <= 3; $n++) { + $set->addRRule("FREQ=MONTHLY;BYDAY={$n}SU;BYHOUR=9"); + } + + $set->mergeIfPossible(); + + // Should stay as MONTHLY rules (not enough coverage for WEEKLY) + $this->assertCount(3, $set->getRRules()); + foreach ($set->getRRules() as $rule) { + $ruleData = $rule->getRule(); + $this->assertEquals("MONTHLY", $ruleData['FREQ']); + } + } + + /** + * Test merging with different intervals (should not merge) + */ + public function test_merge_differentIntervals(): void + { + $set = new ScheduleSet(); + $set->addRRule("FREQ=WEEKLY;BYDAY=MO;INTERVAL=1;COUNT=10"); + $set->addRRule("FREQ=WEEKLY;BYDAY=TU;INTERVAL=2;COUNT=10"); + + $set->mergeIfPossible(); + + $this->assertCount(2, $set->getRRules()); + } + + /** + * Test merging with different BYHOUR (should not merge) + */ + public function test_merge_differentByHour(): void + { + $set = new ScheduleSet(); + $set->addRRule("FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;COUNT=10"); + $set->addRRule("FREQ=WEEKLY;BYDAY=TU;BYHOUR=10;COUNT=10"); + + $set->mergeIfPossible(); + + $this->assertCount(2, $set->getRRules()); + } + + /** + * Test iterative reduction: Yearly -> Monthly -> Weekly (if applicable) + */ + public function test_iterativeReduction_multiLevel(): void + { + $set = new ScheduleSet(); + + // Start with monthly rules that cover all 5 Sundays + for ($n = 1; $n <= 5; $n++) { + $set->addRRule("FREQ=MONTHLY;BYDAY={$n}SU;BYHOUR=9"); + } + + $set->mergeIfPossible(); + + // Should be reduced to a single WEEKLY rule + $this->assertCount(1, $set->getRRules()); + $this->assertEquals("WEEKLY", $set->getRRules()[0]->getFreq()); + } + + /** + * Test merging rules with open-ended (no COUNT or UNTIL) + */ + public function test_merge_openEnded(): void + { + $set = new ScheduleSet(); + $set->addRRule("FREQ=WEEKLY;BYDAY=MO"); + $set->addRRule("FREQ=WEEKLY;BYDAY=TU"); + + $set->mergeIfPossible(); + + $this->assertCount(1, $set->getRRules()); + $merged = $set->getRRules()[0]; + $this->assertEquals(['MO', 'TU'], $merged->getByDay()); + $this->assertNull($merged->getCount()); + $this->assertNull($merged->getUntil()); + } + + /** + * Test merging yearly rules with incompatible BYDAY + */ + public function test_merge_yearlyIncompatibleByDay(): void + { + $set = new ScheduleSet(); + $set->addRRule("FREQ=YEARLY;BYMONTH=1;BYDAY=1MO;BYHOUR=10"); + $set->addRRule("FREQ=YEARLY;BYMONTH=6;BYDAY=2MO;BYHOUR=10"); + + $set->mergeIfPossible(); + + // Should NOT merge because BYDAY is different + $this->assertCount(2, $set->getRRules()); + } + + /** + * Test merging with COUNT and UNTIL mixed (should not merge) + */ + public function test_merge_mixedCountAndUntil(): void + { + $set = new ScheduleSet(); + $set->addRRule("FREQ=WEEKLY;BYDAY=MO;COUNT=10"); + $set->addRRule("FREQ=WEEKLY;BYDAY=TU;UNTIL=20251230T000000Z"); + + $set->mergeIfPossible(); + + // Should NOT merge because one has COUNT and one has UNTIL + $this->assertCount(2, $set->getRRules()); + } + + /** + * Test constructor with RFC string + */ + public function test_constructor_withRFCString(): void + { + $set = new ScheduleSet("RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=10"); + $this->assertCount(1, $set->getRRules()); + } + + /** + * Test merging with BYMINUTE and BYSECOND + */ + public function test_merge_withByMinuteAndBySecond(): void + { + $set = new ScheduleSet(); + $set->addRRule("FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0;BYSECOND=0;COUNT=10"); + $set->addRRule("FREQ=WEEKLY;BYDAY=TU;BYHOUR=9;BYMINUTE=0;BYSECOND=0;COUNT=10"); + + $set->mergeIfPossible(); + + $this->assertCount(1, $set->getRRules()); + $merged = $set->getRRules()[0]; + $this->assertEquals(['MO', 'TU'], $merged->getByDay()); + } + + /** + * Test merging yearly rules with COUNT + */ + public function test_merge_yearlyWithCount(): void + { + $set = new ScheduleSet(); + $set->addRRule("FREQ=YEARLY;BYMONTH=1;BYDAY=1MO;COUNT=10"); + $set->addRRule("FREQ=YEARLY;BYMONTH=6;BYDAY=1MO;COUNT=10"); + + $set->mergeIfPossible(); + + $this->assertCount(1, $set->getRRules()); + $merged = $set->getRRules()[0]; + $this->assertEquals([1, 6], $merged->getByMonth()); + $this->assertEquals(10, $merged->getCount()); + } + + /** + * Test merging yearly rules with UNTIL + */ + public function test_merge_yearlyWithUntil(): void + { + $set = new ScheduleSet(); + $set->addRRule("FREQ=YEARLY;BYMONTH=1;BYDAY=1MO;UNTIL=20251231T000000Z"); + $set->addRRule("FREQ=YEARLY;BYMONTH=6;BYDAY=1MO;UNTIL=20251231T000000Z"); + + $set->mergeIfPossible(); + + $this->assertCount(1, $set->getRRules()); + $merged = $set->getRRules()[0]; + $this->assertEquals([1, 6], $merged->getByMonth()); + } + + /** + * Test monthly rules with different weekdays stay separate + */ + public function test_merge_monthlyDifferentWeekdays(): void + { + $set = new ScheduleSet(); + for ($n = 1; $n <= 5; $n++) { + $set->addRRule("FREQ=MONTHLY;BYDAY={$n}SU;BYHOUR=9"); + } + for ($n = 1; $n <= 5; $n++) { + $set->addRRule("FREQ=MONTHLY;BYDAY={$n}MO;BYHOUR=9"); + } + + $set->mergeIfPossible(); + + // Each set of 5 with same weekday should stay as monthly (different Nth patterns per weekday) + // They don't simplify because while each has 5 Nth values, they're for different weekdays + $this->assertCount(10, $set->getRRules()); + } + + /** + * Test merging with WKST parameter + */ + public function test_merge_withWKST(): void + { + $set = new ScheduleSet(); + $set->addRRule("FREQ=WEEKLY;BYDAY=MO;WKST=SU;COUNT=10"); + $set->addRRule("FREQ=WEEKLY;BYDAY=TU;WKST=SU;COUNT=10"); + + $set->mergeIfPossible(); + + $this->assertCount(1, $set->getRRules()); + } + + /** + * Test merging fails when WKST is different + */ + public function test_merge_differentWKST(): void + { + $set = new ScheduleSet(); + $set->addRRule("FREQ=WEEKLY;BYDAY=MO;WKST=MO;COUNT=10"); + $set->addRRule("FREQ=WEEKLY;BYDAY=TU;WKST=SU;COUNT=10"); + + $set->mergeIfPossible(); + + // Should NOT merge because WKST is different + $this->assertCount(2, $set->getRRules()); + } + + /** + * Test handling of non-standard frequencies + */ + public function test_merge_hourlyFrequency(): void + { + $set = new ScheduleSet(); + $set->addRRule("FREQ=HOURLY;INTERVAL=2;COUNT=10"); + $set->addRRule("FREQ=HOURLY;INTERVAL=2;COUNT=5"); + + $set->mergeIfPossible(); + + // Should keep separate as we don't have special handling for HOURLY + $this->assertCount(2, $set->getRRules()); + } + + /** + * Test constructor with dtstart parameter + */ + public function test_constructor_withDtstart(): void + { + $dtstart = new \DateTime('2025-01-01 00:00:00'); + $set = new ScheduleSet("RRULE:FREQ=WEEKLY;BYDAY=MO", $dtstart); + + $this->assertCount(1, $set->getRRules()); + } + + /** + * Test merging when max iterations would be reached (edge case) + */ + public function test_merge_maxIterationsProtection(): void + { + // This is a theoretical test - in practice we shouldn't hit max iterations + // Just verify that the method completes without infinite loop + $set = new ScheduleSet(); + $set->addRRule("FREQ=WEEKLY;BYDAY=MO;COUNT=10"); + $set->addRRule("FREQ=WEEKLY;BYDAY=TU;COUNT=10"); + + $set->mergeIfPossible(); + + // Should complete successfully + $this->assertCount(1, $set->getRRules()); + } } diff --git a/tests/Unit/Utilities/Scheduling/Schedule_Test.php b/tests/Unit/Utilities/Scheduling/Schedule_Test.php index 65401b7..a47a856 100644 --- a/tests/Unit/Utilities/Scheduling/Schedule_Test.php +++ b/tests/Unit/Utilities/Scheduling/Schedule_Test.php @@ -11,7 +11,190 @@ class Schedule_Test extends TestCase { - + /** + * Test getFreq() method + */ + public function test_getFreq(): void + { + $schedule = new Schedule("FREQ=WEEKLY;BYDAY=MO"); + $this->assertEquals("WEEKLY", $schedule->getFreq()); + + $schedule2 = new Schedule("FREQ=MONTHLY;BYDAY=1MO"); + $this->assertEquals("MONTHLY", $schedule2->getFreq()); + + $schedule3 = new Schedule("FREQ=YEARLY;BYMONTH=1"); + $this->assertEquals("YEARLY", $schedule3->getFreq()); + } + + /** + * Test getCount() method + */ + public function test_getCount(): void + { + $schedule = new Schedule("FREQ=WEEKLY;BYDAY=MO;COUNT=10"); + $this->assertEquals(10, $schedule->getCount()); + + $scheduleNoCount = new Schedule("FREQ=WEEKLY;BYDAY=MO"); + $this->assertNull($scheduleNoCount->getCount()); + } + + /** + * Test getUntil() method with string date + */ + public function test_getUntil_withString(): void + { + $schedule = new Schedule("FREQ=WEEKLY;BYDAY=MO;UNTIL=20251231T000000Z"); + $until = $schedule->getUntil(); + $this->assertIsString($until); + $this->assertEquals("20251231T000000Z", $until); + } + + /** + * Test getUntil() method with DateTime object + */ + public function test_getUntil_withDateTime(): void + { + $schedule = new Schedule([ + 'FREQ' => 'WEEKLY', + 'BYDAY' => 'MO', + 'UNTIL' => new \DateTime('2025-12-31 00:00:00') + ]); + $until = $schedule->getUntil(); + $this->assertIsString($until); + $this->assertRegExp('/^\d{8}T\d{6}Z$/', $until); + } + + /** + * Test getUntil() method with no UNTIL + */ + public function test_getUntil_withNull(): void + { + $schedule = new Schedule("FREQ=WEEKLY;BYDAY=MO"); + $this->assertNull($schedule->getUntil()); + } + + /** + * Test getInterval() method + */ + public function test_getInterval(): void + { + $schedule = new Schedule("FREQ=WEEKLY;BYDAY=MO;INTERVAL=2"); + $this->assertEquals(2, $schedule->getInterval()); + + $scheduleDefault = new Schedule("FREQ=WEEKLY;BYDAY=MO"); + $this->assertEquals(1, $scheduleDefault->getInterval()); + } + + /** + * Test getByDay() method with string + */ + public function test_getByDay_withString(): void + { + $schedule = new Schedule("FREQ=WEEKLY;BYDAY=MO,WE,FR"); + $this->assertEquals(['MO', 'WE', 'FR'], $schedule->getByDay()); + + $singleDay = new Schedule("FREQ=WEEKLY;BYDAY=MO"); + $this->assertEquals(['MO'], $singleDay->getByDay()); + } + + /** + * Test getByDay() method with null + */ + public function test_getByDay_withNull(): void + { + $schedule = new Schedule("FREQ=DAILY;COUNT=10"); + $this->assertNull($schedule->getByDay()); + } + + /** + * Test getByMonth() method with string + */ + public function test_getByMonth_withString(): void + { + $schedule = new Schedule("FREQ=YEARLY;BYMONTH=1,3,9,11"); + $this->assertEquals([1, 3, 9, 11], $schedule->getByMonth()); + } + + /** + * Test getByMonth() method with single value + */ + public function test_getByMonth_withSingleValue(): void + { + $schedule = new Schedule("FREQ=YEARLY;BYMONTH=6"); + $this->assertEquals([6], $schedule->getByMonth()); + } + + /** + * Test getByMonth() method with null + */ + public function test_getByMonth_withNull(): void + { + $schedule = new Schedule("FREQ=WEEKLY;BYDAY=MO"); + $this->assertNull($schedule->getByMonth()); + } + + /** + * Test getByHour() method with string + */ + public function test_getByHour_withString(): void + { + $schedule = new Schedule("FREQ=WEEKLY;BYDAY=MO;BYHOUR=9,11"); + $this->assertEquals([9, 11], $schedule->getByHour()); + } + + /** + * Test getByHour() method with single value + */ + public function test_getByHour_withSingleValue(): void + { + $schedule = new Schedule("FREQ=WEEKLY;BYDAY=MO;BYHOUR=10"); + $this->assertEquals([10], $schedule->getByHour()); + } + + /** + * Test getByHour() method with null + */ + public function test_getByHour_withNull(): void + { + $schedule = new Schedule("FREQ=WEEKLY;BYDAY=MO"); + $this->assertNull($schedule->getByHour()); + } + + /** + * Test getByMinute() method with string + */ + public function test_getByMinute_withString(): void + { + $schedule = new Schedule("FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0,30"); + $this->assertEquals([0, 30], $schedule->getByMinute()); + } + + /** + * Test getByMinute() method with null + */ + public function test_getByMinute_withNull(): void + { + $schedule = new Schedule("FREQ=WEEKLY;BYDAY=MO"); + $this->assertNull($schedule->getByMinute()); + } + + /** + * Test getBySecond() method with string + */ + public function test_getBySecond_withString(): void + { + $schedule = new Schedule("FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0;BYSECOND=0,30"); + $this->assertEquals([0, 30], $schedule->getBySecond()); + } + + /** + * Test getBySecond() method with null + */ + public function test_getBySecond_withNull(): void + { + $schedule = new Schedule("FREQ=WEEKLY;BYDAY=MO"); + $this->assertNull($schedule->getBySecond()); + } }