occurrenceFrom(ts('2023-01-15 10:30'), 'monthly_day', false, ts('2023-01-20 00:00'), false), ts('2023-02-15 10:30'), $failures ); // 2. Drift-free clamp: the 30th -> Feb clamps to 28 -> back to 30 in March (NOT 28). check( '30th -> Feb (non-leap) clamps to 28', $calc->occurrenceFrom(ts('2023-01-30 09:00'), 'monthly_day', false, ts('2023-01-30 09:00'), false), ts('2023-02-28 09:00'), $failures ); check( '30th -> March returns to 30 (no drift)', $calc->occurrenceFrom(ts('2023-01-30 09:00'), 'monthly_day', false, ts('2023-02-28 09:00'), false), ts('2023-03-30 09:00'), $failures ); // 3. Last-day rule: last day of each month. check( 'last-day: Jan 31 -> Feb 28 (non-leap)', $calc->occurrenceFrom(ts('2023-01-31 08:00'), 'monthly_day', true, ts('2023-01-31 08:00'), false), ts('2023-02-28 08:00'), $failures ); check( 'last-day: Feb -> March 31', $calc->occurrenceFrom(ts('2023-01-31 08:00'), 'monthly_day', true, ts('2023-02-28 08:00'), false), ts('2023-03-31 08:00'), $failures ); // 4. Leap year: last day of Feb 2024 is the 29th. check( 'last-day: Feb 2024 -> 29th (leap)', $calc->occurrenceFrom(ts('2024-01-31 08:00'), 'monthly_day', true, ts('2024-02-01 00:00'), false), ts('2024-02-29 08:00'), $failures ); // 5. Inclusive vs exclusive at an exact occurrence. check( 'inclusive returns the same instant', $calc->occurrenceFrom(ts('2023-03-10 12:00'), 'monthly_day', false, ts('2023-03-10 12:00'), true), ts('2023-03-10 12:00'), $failures ); check( 'exclusive skips to next month', $calc->occurrenceFrom(ts('2023-03-10 12:00'), 'monthly_day', false, ts('2023-03-10 12:00'), false), ts('2023-04-10 12:00'), $failures ); // 6. Daily: same time every day. check( 'daily -> next day same time', $calc->occurrenceFrom(ts('2023-03-10 09:30'), 'daily', false, ts('2023-03-10 12:00'), false), ts('2023-03-11 09:30'), $failures ); // 7. Weekly: same weekday every 7 days (2023-03-07 is a Tuesday). check( 'weekly -> +7 days on the same weekday', $calc->occurrenceFrom(ts('2023-03-07 08:00'), 'weekly', false, ts('2023-03-07 08:00'), false), ts('2023-03-14 08:00'), $failures ); // 8. Monthly by weekday: 2nd Tuesday (2023-03-14) -> 2nd Tuesday of April (2023-04-11). check( 'monthly-by-weekday: 2nd Tuesday -> next month 2nd Tuesday', $calc->occurrenceFrom(ts('2023-03-14 07:00'), 'monthly_dow', false, ts('2023-03-14 07:00'), false), ts('2023-04-11 07:00'), $failures ); // 9. Monthly by weekday, last: last Tuesday of March (2023-03-28) -> last Tuesday of April (04-25). check( 'monthly-by-weekday last: last Tue -> next month last Tue', $calc->occurrenceFrom(ts('2023-03-28 07:00'), 'monthly_dow', true, ts('2023-03-28 07:00'), false), ts('2023-04-25 07:00'), $failures ); // 10. Yearly: same month/day next year. check( 'yearly -> next year same date', $calc->occurrenceFrom(ts('2023-03-15 10:00'), 'yearly', false, ts('2023-03-15 10:00'), false), ts('2024-03-15 10:00'), $failures ); // 11. Yearly last-day on Feb 29 (leap) -> next year Feb 28. check( 'yearly last-day: 2024-02-29 -> 2025-02-28', $calc->occurrenceFrom(ts('2024-02-29 08:00'), 'yearly', true, ts('2024-02-29 08:00'), false), ts('2025-02-28 08:00'), $failures ); // 12. Yearly numeric Feb 29 -> clamps to 28 in a non-leap year, back to 29 in the next leap year. check( 'yearly numeric 29-Feb -> 2025 clamps to 28', $calc->occurrenceFrom(ts('2024-02-29 08:00'), 'yearly', false, ts('2024-02-29 08:00'), false), ts('2025-02-28 08:00'), $failures ); check( 'yearly numeric 29-Feb -> 2028 back to 29 (leap)', $calc->occurrenceFrom(ts('2024-02-29 08:00'), 'yearly', false, ts('2027-03-01 00:00'), false), ts('2028-02-29 08:00'), $failures ); // 13. Unknown frequency returns null. check( 'unknown frequency -> null', $calc->occurrenceFrom(ts('2023-03-10 12:00'), 'nope', false, ts('2023-03-10 12:00'), false), null, $failures ); echo "\n".($failures === 0 ? 'ALL PASSED' : $failures.' FAILURE(S)')."\n"; exit($failures === 0 ? 0 : 1);