//! Tests for [`super`]. use super::*; /// A pending task with a due date and a rule, off the shared fixture so a /// new `Task` field does not need a second edit here. fn recurring_task(due: DateTime, recurrence: Recurrence) -> crate::models::Task { let mut t = crate::models::test_task(); t.due = Some(due); t.recurrence = recurrence; t } #[test] fn next_recurring_task_advances_the_due_date_and_chains_to_the_root() { let now = Utc.with_ymd_and_hms(2026, 8, 3, 9, 0, 0).unwrap(); let mut task = recurring_task(now, Recurrence::Weekly); task.scheduled_start = Some(now); task.scheduled_duration = Some(30); let next = next_recurring_task(&task, Tz::UTC, now).expect("weekly task recurs"); assert_eq!(next.due.unwrap().day(), 10); // Chained to the root, and the time block from the closed occurrence is // not carried onto a date nobody picked. assert_eq!(next.recurrence_parent_id, Some(task.id)); assert_eq!(next.scheduled_start, None); assert_eq!(next.scheduled_duration, None); // An instance chains to the root it came from, not to its predecessor. let mut instance = task.clone(); instance.id = crate::id_types::TaskId::new(); instance.recurrence_parent_id = Some(task.id); let third = next_recurring_task(&instance, Tz::UTC, now).unwrap(); assert_eq!(third.recurrence_parent_id, Some(task.id)); } #[test] fn next_recurring_task_without_a_due_anchors_to_now() { // A floating chore: nothing to advance from, so the chain starts at the // moment it was completed rather than getting no due date at all. let now = Utc.with_ymd_and_hms(2026, 8, 3, 9, 0, 0).unwrap(); let mut task = recurring_task(now, Recurrence::Daily); task.due = None; let next = next_recurring_task(&task, Tz::UTC, now).expect("still recurs"); let due = next.due.expect("anchored to now, not left empty"); assert!( due > now, "successor is due after the completion, got {due}" ); } #[test] fn next_recurring_task_is_none_for_a_one_off() { let now = Utc.with_ymd_and_hms(2026, 8, 3, 9, 0, 0).unwrap(); let task = recurring_task(now, Recurrence::None); assert!(next_recurring_task(&task, Tz::UTC, now).is_none()); } #[test] fn test_daily_recurrence() { let now = Utc.with_ymd_and_hms(2026, 2, 4, 10, 0, 0).unwrap(); let next = calculate_next_due(Some(&now), &Recurrence::Daily).unwrap(); assert_eq!(next.day(), 5); } #[test] fn test_weekly_recurrence() { let now = Utc.with_ymd_and_hms(2026, 2, 4, 10, 0, 0).unwrap(); let next = calculate_next_due(Some(&now), &Recurrence::Weekly).unwrap(); assert_eq!(next.day(), 11); } #[test] fn test_monthly_recurrence() { let now = Utc.with_ymd_and_hms(2026, 1, 15, 10, 0, 0).unwrap(); let next = calculate_next_due(Some(&now), &Recurrence::Monthly).unwrap(); assert_eq!(next.month(), 2); assert_eq!(next.day(), 15); } #[test] fn test_monthly_end_of_month() { // Jan 31 -> Feb 28 (or 29 in leap year) let jan_31 = Utc.with_ymd_and_hms(2026, 1, 31, 10, 0, 0).unwrap(); let next = calculate_next_due(Some(&jan_31), &Recurrence::Monthly).unwrap(); assert_eq!(next.month(), 2); // 2026 is not a leap year, so Feb has 28 days assert_eq!(next.day(), 28); } /// Fold `hops` completions, returning every due date in order. /// /// Recurrence is a chain: each completion re-derives from the previous /// instance's due date. Asserting a single hop from a fixed anchor passes even /// when the heuristic is wrong on the next one, which is exactly how the /// end-of-month drift survived a green suite. fn walk(start: DateTime, recurrence: &Recurrence, hops: usize) -> Vec<(u32, u32)> { let mut out = Vec::new(); let mut cur = start; for _ in 0..hops { cur = calculate_next_due(Some(&cur), recurrence).unwrap(); out.push((cur.month(), cur.day())); } out } #[test] fn monthly_from_a_leap_february_stays_at_month_end() { // Feb 29 already snapped before the fix (29 was in range); pin it so the // two adjacent inputs cannot diverge again. let feb_29 = Utc.with_ymd_and_hms(2024, 2, 29, 10, 0, 0).unwrap(); assert_eq!( walk(feb_29, &Recurrence::Monthly, 3), vec![(3, 31), (4, 30), (5, 31)] ); } #[test] fn monthly_from_a_mid_month_day_does_not_drift_to_month_end() { // The guard must stay a month-end heuristic: day 15 is unambiguous and // must keep its day across the chain, including through February. let jan_15 = Utc.with_ymd_and_hms(2026, 1, 15, 10, 0, 0).unwrap(); assert_eq!( walk(jan_15, &Recurrence::Monthly, 3), vec![(2, 15), (3, 15), (4, 15)] ); } #[test] fn monthly_from_a_non_leap_february_28_keeps_the_28th() { // Feb 28 in a non-leap year is ambiguous — the user may have meant "the // 28th" — so it must not be promoted to month-end intent. let feb_28 = Utc.with_ymd_and_hms(2026, 2, 28, 10, 0, 0).unwrap(); assert_eq!( walk(feb_28, &Recurrence::Monthly, 2), vec![(3, 28), (4, 28)] ); } #[test] fn test_no_recurrence() { let now = Utc::now(); let next = calculate_next_due(Some(&now), &Recurrence::None); assert!(next.is_none()); } #[test] fn test_should_recur() { assert!(should_recur(&Recurrence::Daily)); assert!(should_recur(&Recurrence::Weekly)); assert!(should_recur(&Recurrence::Monthly)); assert!(!should_recur(&Recurrence::None)); } #[test] fn test_monthly_recurrence_preserves_time() { let original = Utc.with_ymd_and_hms(2026, 1, 15, 14, 30, 0).unwrap(); let next = calculate_next_due(Some(&original), &Recurrence::Monthly).unwrap(); assert_eq!(next.hour(), 14); assert_eq!(next.minute(), 30); } #[test] fn test_daily_recurrence_preserves_time() { let original = Utc.with_ymd_and_hms(2026, 2, 14, 9, 15, 30).unwrap(); let next = calculate_next_due(Some(&original), &Recurrence::Daily).unwrap(); assert_eq!(next.hour(), 9); assert_eq!(next.minute(), 15); assert_eq!(next.second(), 30); } #[test] fn test_weekly_recurrence_preserves_time() { let original = Utc.with_ymd_and_hms(2026, 3, 10, 17, 0, 0).unwrap(); let next = calculate_next_due(Some(&original), &Recurrence::Weekly).unwrap(); assert_eq!(next.hour(), 17); assert_eq!(next.minute(), 0); } #[test] fn test_monthly_december_to_january() { // Dec 15, 2026 -> Jan 15, 2027 let dec_15 = Utc.with_ymd_and_hms(2026, 12, 15, 10, 0, 0).unwrap(); let next = calculate_next_due(Some(&dec_15), &Recurrence::Monthly).unwrap(); assert_eq!(next.year(), 2027); assert_eq!(next.month(), 1); assert_eq!(next.day(), 15); } #[test] fn test_monthly_leap_year() { // Jan 31, 2028 (leap year) -> Feb 29, 2028 let jan_31 = Utc.with_ymd_and_hms(2028, 1, 31, 10, 0, 0).unwrap(); let next = calculate_next_due(Some(&jan_31), &Recurrence::Monthly).unwrap(); assert_eq!(next.month(), 2); assert_eq!(next.day(), 29); // 2028 is a leap year } #[test] fn test_monthly_feb_28_no_snap() { // Feb 28 in a non-leap year: day < 29, so no end-of-month snap. // User who chose the 28th gets Mar 28, not Mar 31. let feb_28 = Utc.with_ymd_and_hms(2026, 2, 28, 10, 0, 0).unwrap(); let next = calculate_next_due(Some(&feb_28), &Recurrence::Monthly).unwrap(); assert_eq!(next.month(), 3); assert_eq!(next.day(), 28); } #[test] fn test_monthly_feb_28_explicit_day_28() { // With explicit target day 28, Feb 28 -> Mar 28 (no end-of-month heuristic) let feb_28 = Utc.with_ymd_and_hms(2026, 2, 28, 10, 0, 0).unwrap(); let next = calculate_next_due_with_day(Some(&feb_28), &Recurrence::Monthly, Some(28)).unwrap(); assert_eq!(next.month(), 3); assert_eq!(next.day(), 28); } #[test] fn test_monthly_march_31_to_april() { // Mar 31 -> Apr 30 (April only has 30 days) let mar_31 = Utc.with_ymd_and_hms(2026, 3, 31, 10, 0, 0).unwrap(); let next = calculate_next_due(Some(&mar_31), &Recurrence::Monthly).unwrap(); assert_eq!(next.month(), 4); assert_eq!(next.day(), 30); } #[test] fn test_daily_year_boundary() { // Dec 31, 2026 -> Jan 1, 2027 let dec_31 = Utc.with_ymd_and_hms(2026, 12, 31, 10, 0, 0).unwrap(); let next = calculate_next_due(Some(&dec_31), &Recurrence::Daily).unwrap(); assert_eq!(next.year(), 2027); assert_eq!(next.month(), 1); assert_eq!(next.day(), 1); } #[test] fn test_weekly_month_boundary() { // Jan 28, 2026 -> Feb 4, 2026 let jan_28 = Utc.with_ymd_and_hms(2026, 1, 28, 10, 0, 0).unwrap(); let next = calculate_next_due(Some(&jan_28), &Recurrence::Weekly).unwrap(); assert_eq!(next.month(), 2); assert_eq!(next.day(), 4); } #[test] fn test_recurrence_with_no_due_date() { // When no due date provided, should use current time as base let next = calculate_next_due(None, &Recurrence::Daily); assert!(next.is_some()); let next_date = next.unwrap(); let now = Utc::now(); // Next due should be approximately 1 day from now let diff = next_date - now; assert!(diff.num_hours() >= 23 && diff.num_hours() <= 25); } #[test] fn test_days_in_month_helper() { assert_eq!(days_in_month(2026, 1), 31); // January assert_eq!(days_in_month(2026, 2), 28); // February (non-leap) assert_eq!(days_in_month(2028, 2), 29); // February (leap year) assert_eq!(days_in_month(2026, 4), 30); // April assert_eq!(days_in_month(2026, 12), 31); // December } #[test] fn test_recurring_task_fresh_urgency_after_completion() { use crate::models::{Priority, TaskStatus}; use crate::urgency::calculate_urgency; // Simulate an overdue recurring weekly task: // Original due date was 3 days ago, so it had high urgency from the overdue penalty. let overdue_due = Utc::now() - Duration::days(3); let old_created = Utc::now() - Duration::days(10); let tags: Vec = vec![]; let old_urgency = calculate_urgency( &Priority::Medium, &TaskStatus::Pending, Some(&overdue_due), &old_created, &tags, ); // Old task should have overdue urgency (12.0 from overdue + priority + age) assert!( old_urgency > 15.0, "Overdue task should have high urgency, got: {old_urgency}" ); // When completing and creating the next instance, we calculate next_due let next_due = calculate_next_due(Some(&overdue_due), &Recurrence::Weekly).unwrap(); let new_created = Utc::now(); let new_urgency = calculate_urgency( &Priority::Medium, &TaskStatus::Pending, Some(&next_due), &new_created, &tags, ); // The new instance should NOT be overdue (due date is in the future) // and should have much lower urgency than the old overdue one assert!( new_urgency < old_urgency, "New recurring instance should have lower urgency ({new_urgency}) than the completed overdue one ({old_urgency})" ); // Specifically, it should NOT have the overdue penalty assert!( new_urgency < 12.0, "New recurring instance should not have overdue penalty, got urgency: {new_urgency}" ); } // Rich Recurrence Tests #[test] fn test_rich_daily_interval() { let now = Utc.with_ymd_and_hms(2026, 3, 1, 9, 0, 0).unwrap(); let rule = RecurrenceRule { pattern: Recurrence::Daily, interval: 3, weekdays: vec![], monthly_spec: None, until: None, }; let next = calculate_next_due_rich(Some(&now), &rule).unwrap(); assert_eq!(next.day(), 4); // 3 days later assert_eq!(next.hour(), 9); } #[test] fn test_rich_weekly_weekdays() { // Monday, requesting Mon/Wed/Fri let mon = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); // Monday let rule = RecurrenceRule { pattern: Recurrence::Weekly, interval: 1, weekdays: vec![0, 2, 4], // Mon, Wed, Fri monthly_spec: None, until: None, }; // Next after Monday should be Wednesday let next = calculate_next_due_rich(Some(&mon), &rule).unwrap(); assert_eq!(next.weekday(), chrono::Weekday::Wed); assert_eq!(next.day(), 4); // Next after Wednesday should be Friday let next2 = calculate_next_due_rich(Some(&next), &rule).unwrap(); assert_eq!(next2.weekday(), chrono::Weekday::Fri); assert_eq!(next2.day(), 6); // Next after Friday should be Monday of next week let next3 = calculate_next_due_rich(Some(&next2), &rule).unwrap(); assert_eq!(next3.weekday(), chrono::Weekday::Mon); assert_eq!(next3.day(), 9); } #[test] fn test_rich_weekly_ignores_out_of_range_weekdays() { // A corrupt/imported weekday byte (200) must not drive a ~200-day jump; // out-of-range values are dropped, leaving the valid weekday (Wed). let mon = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); // Monday let rule = RecurrenceRule { pattern: Recurrence::Weekly, interval: 1, weekdays: vec![200, 2], // garbage + Wed monthly_spec: None, until: None, }; let next = calculate_next_due_rich(Some(&mon), &rule).unwrap(); assert_eq!(next.weekday(), chrono::Weekday::Wed); assert!( (next - mon).num_days() < 7, "must not jump far past one week" ); } #[test] fn test_rich_weekly_all_invalid_weekdays_falls_back() { // If every weekday byte is invalid, advance by the interval-week instead // of panicking on an empty sorted list. let mon = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); let rule = RecurrenceRule { pattern: Recurrence::Weekly, interval: 1, weekdays: vec![99, 200], monthly_spec: None, until: None, }; let next = calculate_next_due_rich(Some(&mon), &rule).unwrap(); assert_eq!((next - mon).num_days(), 7); } #[test] fn test_rich_interval_clamped() { // An absurd interval must not overflow the i32 month cast or civil math. let mon = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); let rule = RecurrenceRule { pattern: Recurrence::Daily, interval: u32::MAX, weekdays: vec![], monthly_spec: None, until: None, }; // Clamped to 10_000 days; just assert it produces a finite future date. let next = calculate_next_due_rich(Some(&mon), &rule).unwrap(); assert!(next > mon); } #[test] fn test_rich_weekly_interval_2() { // Friday, every 2 weeks on Mon/Fri let fri = Utc.with_ymd_and_hms(2026, 3, 6, 10, 0, 0).unwrap(); // Friday let rule = RecurrenceRule { pattern: Recurrence::Weekly, interval: 2, weekdays: vec![0, 4], // Mon, Fri monthly_spec: None, until: None, }; // Next after Friday: wrap to Mon of 2-weeks-later let next = calculate_next_due_rich(Some(&fri), &rule).unwrap(); assert_eq!(next.weekday(), chrono::Weekday::Mon); assert_eq!(next.day(), 16); // 2 weeks later, Monday } #[test] fn test_rich_monthly_day_of_month() { let jan = Utc.with_ymd_and_hms(2026, 1, 15, 10, 0, 0).unwrap(); let rule = RecurrenceRule { pattern: Recurrence::Monthly, interval: 1, weekdays: vec![], monthly_spec: Some(MonthlySpec::DayOfMonth { day: 15 }), until: None, }; let next = calculate_next_due_rich(Some(&jan), &rule).unwrap(); assert_eq!(next.month(), 2); assert_eq!(next.day(), 15); } #[test] fn test_rich_monthly_nth_weekday() { // 2nd Friday of January 2026 is Jan 9... let me compute // Jan 2026: 1=Thu, 2=Fri (1st Fri), 9=Fri (2nd Fri) let jan = Utc.with_ymd_and_hms(2026, 1, 9, 10, 0, 0).unwrap(); let rule = RecurrenceRule { pattern: Recurrence::Monthly, interval: 1, weekdays: vec![], monthly_spec: Some(MonthlySpec::NthWeekday { week: 2, weekday: 4, }), // 2nd Friday until: None, }; let next = calculate_next_due_rich(Some(&jan), &rule).unwrap(); // Feb 2026: 1=Sun, 6=Fri (1st Fri), 13=Fri (2nd Fri) assert_eq!(next.month(), 2); assert_eq!(next.day(), 13); } #[test] fn test_rich_monthly_last_weekday() { let jan = Utc.with_ymd_and_hms(2026, 1, 26, 10, 0, 0).unwrap(); let rule = RecurrenceRule { pattern: Recurrence::Monthly, interval: 1, weekdays: vec![], monthly_spec: Some(MonthlySpec::NthWeekday { week: -1, weekday: 0, }), // Last Monday until: None, }; let next = calculate_next_due_rich(Some(&jan), &rule).unwrap(); // Feb 2026: last Monday is Feb 23 assert_eq!(next.month(), 2); assert_eq!(next.day(), 23); } #[test] fn test_rich_monthly_nth_weekday_rejects_out_of_range_week() { let jan = Utc.with_ymd_and_hms(2026, 1, 9, 10, 0, 0).unwrap(); for week in [0i8, 6, 7, -2, i8::MIN, i8::MAX] { let rule = RecurrenceRule { pattern: Recurrence::Monthly, interval: 1, weekdays: vec![], monthly_spec: Some(MonthlySpec::NthWeekday { week, weekday: 4 }), until: None, }; // Previously this silently yielded the un-adjusted base (Feb 9). assert_eq!( calculate_next_due_rich(Some(&jan), &rule), None, "week {week} should be rejected, not silently ignored" ); } } #[test] fn test_rich_monthly_nth_weekday_rejects_out_of_range_weekday() { let jan = Utc.with_ymd_and_hms(2026, 1, 9, 10, 0, 0).unwrap(); let rule = RecurrenceRule { pattern: Recurrence::Monthly, interval: 1, weekdays: vec![], monthly_spec: Some(MonthlySpec::NthWeekday { week: 2, weekday: 200, }), until: None, }; assert_eq!(calculate_next_due_rich(Some(&jan), &rule), None); } #[test] fn test_rich_monthly_nth_weekday_skips_months_without_occurrence() { // 5th Monday: Mar 2026 has one (Mar 30), Apr and May 2026 do not, // Jun 2026 does (Jun 29). The gap months must be skipped, not // collapsed onto the base date. let mar = Utc.with_ymd_and_hms(2026, 3, 30, 10, 0, 0).unwrap(); let rule = RecurrenceRule { pattern: Recurrence::Monthly, interval: 1, weekdays: vec![], monthly_spec: Some(MonthlySpec::NthWeekday { week: 5, weekday: 0, }), until: None, }; let next = calculate_next_due_rich(Some(&mar), &rule).unwrap(); assert_eq!((next.month(), next.day()), (6, 29)); } /// A one-hour weekly event, optionally bounded. Written as a helper because /// the `Event` literal is 25 fields and every expansion test wants the same /// one; a new field on `Event` should not mean editing four tests. fn weekly_event(start: DateTime, until: Option>) -> Event { Event { id: crate::id_types::EventId::new(), user_id: None, project_id: None, project_name: None, contact_id: None, contact_name: None, title: "Weekly meeting".to_string(), description: String::new(), start_time: start, end_time: Some(start + Duration::hours(1)), location: None, linked_task_id: None, recurrence: Recurrence::Weekly, recurrence_rule: Some(RecurrenceRule { pattern: Recurrence::Weekly, interval: 1, weekdays: vec![], monthly_spec: None, until, }), recurrence_parent_id: None, is_recurring_instance: false, block_type: None, external_source: None, external_id: None, is_read_only: false, snoozed_until: None, reminder_offsets_seconds: Vec::new(), tz_kind: crate::models::TzKind::Absolute, timezone: None, start_local: None, end_local: None, } } #[test] fn test_expand_recurrence_weekly() { let start = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); // Monday let event = weekly_event(start, None); let range_start = Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap(); let range_end = Utc.with_ymd_and_hms(2026, 3, 31, 23, 59, 59).unwrap(); let instances = expand_recurrence(&event, range_start, range_end); // Original is March 2 (Mon). Instances: Mar 9, 16, 23, 30 = 4 expanded assert_eq!(instances.len(), 4); assert_eq!(instances[0].start_time.day(), 9); assert_eq!(instances[1].start_time.day(), 16); assert_eq!(instances[2].start_time.day(), 23); assert_eq!(instances[3].start_time.day(), 30); // All should be marked as recurring instances assert!(instances.iter().all(|e| e.is_recurring_instance)); // All should have unique deterministic IDs let ids: std::collections::HashSet<_> = instances.iter().map(|e| e.id).collect(); assert_eq!(ids.len(), 4); } #[test] fn test_expand_recurrence_far_past_start_still_renders() { // A daily event whose start_time is well over 500 occurrences before the // window used to render empty: the 500-iteration budget was spent on // occurrences long before range_start. The seek must fast-forward into // the window so today's occurrences appear. let start = Utc.with_ymd_and_hms(2022, 1, 1, 9, 0, 0).unwrap(); // ~4 years prior let event = Event { id: crate::id_types::EventId::new(), user_id: None, project_id: None, project_name: None, contact_id: None, contact_name: None, title: "Daily standup".to_string(), description: String::new(), start_time: start, end_time: Some(start + Duration::minutes(15)), location: None, linked_task_id: None, recurrence: Recurrence::Daily, recurrence_rule: Some(RecurrenceRule { pattern: Recurrence::Daily, interval: 1, weekdays: vec![], monthly_spec: None, until: None, }), recurrence_parent_id: None, is_recurring_instance: false, block_type: None, external_source: None, external_id: None, is_read_only: false, snoozed_until: None, reminder_offsets_seconds: Vec::new(), tz_kind: crate::models::TzKind::Absolute, timezone: None, start_local: None, end_local: None, }; let range_start = Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap(); let range_end = Utc.with_ymd_and_hms(2026, 3, 7, 23, 59, 59).unwrap(); let instances = expand_recurrence(&event, range_start, range_end); // Seven days in the window, each with a daily occurrence. assert_eq!( instances.len(), 7, "old daily event must still render in the current window" ); assert!( instances .iter() .all(|e| e.start_time >= range_start && e.start_time <= range_end) ); } #[test] fn test_expand_recurrence_deterministic_ids() { let start = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); let event = Event { id: crate::id_types::EventId::new(), user_id: None, project_id: None, project_name: None, contact_id: None, contact_name: None, title: "Test".to_string(), description: String::new(), start_time: start, end_time: Some(start + Duration::hours(1)), location: None, linked_task_id: None, recurrence: Recurrence::Daily, recurrence_rule: None, recurrence_parent_id: None, is_recurring_instance: false, block_type: None, external_source: None, external_id: None, is_read_only: false, snoozed_until: None, reminder_offsets_seconds: Vec::new(), tz_kind: crate::models::TzKind::Absolute, timezone: None, start_local: None, end_local: None, }; let range_start = Utc.with_ymd_and_hms(2026, 3, 3, 0, 0, 0).unwrap(); let range_end = Utc.with_ymd_and_hms(2026, 3, 5, 23, 59, 59).unwrap(); let instances1 = expand_recurrence(&event, range_start, range_end); let instances2 = expand_recurrence(&event, range_start, range_end); // Same inputs produce same IDs assert_eq!(instances1.len(), instances2.len()); for (a, b) in instances1.iter().zip(instances2.iter()) { assert_eq!(a.id, b.id); } } // DST / time-zone-aware recurrence (Run #28) #[test] fn test_daily_recurrence_holds_local_time_across_spring_forward() { use chrono_tz::America::New_York; // 2026-03-08 is US spring-forward (02:00 -> 03:00). A task at 09:00 local on // Mar 7 must land at 09:00 local on Mar 8, not 10:00 as fixed-24h-UTC would give. let start = New_York .with_ymd_and_hms(2026, 3, 7, 9, 0, 0) .single() .unwrap() .with_timezone(&Utc); let next = calculate_next_due_in_tz(Some(&start), &Recurrence::Daily, New_York).unwrap(); let next_local = next.with_timezone(&New_York); assert_eq!(next_local.day(), 8); assert_eq!( next_local.hour(), 9, "local hour must stay 09:00 across DST" ); // The UTC instant shifts by 23h (a short civil day), proving DST was honored. assert_eq!((next - start).num_hours(), 23); } #[test] fn test_daily_recurrence_holds_local_time_across_fall_back() { use chrono_tz::America::New_York; // 2026-11-01 is US fall-back (02:00 -> 01:00). 09:00 local Oct 31 -> 09:00 local Nov 1. let start = New_York .with_ymd_and_hms(2026, 10, 31, 9, 0, 0) .single() .unwrap() .with_timezone(&Utc); let next = calculate_next_due_in_tz(Some(&start), &Recurrence::Daily, New_York).unwrap(); let next_local = next.with_timezone(&New_York); assert_eq!(next_local.day(), 1); assert_eq!(next_local.hour(), 9); assert_eq!( (next - start).num_hours(), 25, "a long civil day spans the fall-back" ); } #[test] fn test_weekly_recurrence_holds_local_time_across_dst() { use chrono_tz::America::New_York; // Mar 5 (Thu) 08:00 local -> Mar 12, still 08:00 local, despite the Mar 8 transition. let start = New_York .with_ymd_and_hms(2026, 3, 5, 8, 0, 0) .single() .unwrap() .with_timezone(&Utc); let next = calculate_next_due_in_tz(Some(&start), &Recurrence::Weekly, New_York).unwrap(); let next_local = next.with_timezone(&New_York); assert_eq!(next_local.day(), 12); assert_eq!(next_local.hour(), 8); } #[test] fn test_monthly_recurrence_holds_local_time_across_dst() { use chrono_tz::America::New_York; // Feb 20 09:00 local -> Mar 20 09:00 local, crossing the Mar 8 spring-forward. let start = New_York .with_ymd_and_hms(2026, 2, 20, 9, 0, 0) .single() .unwrap() .with_timezone(&Utc); let next = calculate_next_due_in_tz(Some(&start), &Recurrence::Monthly, New_York).unwrap(); let next_local = next.with_timezone(&New_York); assert_eq!(next_local.month(), 3); assert_eq!(next_local.day(), 20); assert_eq!(next_local.hour(), 9); } // A bounded series: `until` ends it, inclusive of an occurrence landing // exactly on the boundary. #[test] fn until_includes_an_occurrence_landing_exactly_on_it() { // Mon Mar 2, weekly, bounded at Mar 23 10:00 — the fourth occurrence to // the minute. Mar 9, 16 and 23 expand; Mar 30 does not. let start = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); let until = Utc.with_ymd_and_hms(2026, 3, 23, 10, 0, 0).unwrap(); let event = weekly_event(start, Some(until)); let instances = expand_recurrence( &event, Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap(), Utc.with_ymd_and_hms(2026, 3, 31, 23, 59, 59).unwrap(), ); let days: Vec = instances.iter().map(|e| e.start_time.day()).collect(); assert_eq!(days, vec![9, 16, 23]); } #[test] fn an_occurrence_one_second_past_until_is_excluded() { let start = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); // One second before the Mar 23 occurrence, so only Mar 9 and 16 survive. let until = Utc.with_ymd_and_hms(2026, 3, 23, 9, 59, 59).unwrap(); let event = weekly_event(start, Some(until)); let instances = expand_recurrence( &event, Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap(), Utc.with_ymd_and_hms(2026, 3, 31, 23, 59, 59).unwrap(), ); let days: Vec = instances.iter().map(|e| e.start_time.day()).collect(); assert_eq!(days, vec![9, 16]); } #[test] fn until_before_the_first_occurrence_expands_to_nothing() { // A rule that ends before it begins is a caller mistake the wire layer // cannot catch (it never sees the start_time), so the honest reading is // an empty series rather than an infinite one. let start = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); let until = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap(); let event = weekly_event(start, Some(until)); let instances = expand_recurrence( &event, Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap(), Utc.with_ymd_and_hms(2026, 3, 31, 23, 59, 59).unwrap(), ); assert!(instances.is_empty()); } #[test] fn until_stops_the_seek_rather_than_burning_the_budget() { // The window opens years after a bounded series closed. The seek loop // has to notice `until` too: without the check it walks toward // range_start one occurrence at a time, up to the 100k seek cap, and // then renders empty by accident rather than on purpose. let start = Utc.with_ymd_and_hms(2020, 1, 6, 10, 0, 0).unwrap(); let until = Utc.with_ymd_and_hms(2020, 3, 2, 10, 0, 0).unwrap(); let event = weekly_event(start, Some(until)); let instances = expand_recurrence( &event, Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap(), Utc.with_ymd_and_hms(2026, 3, 31, 23, 59, 59).unwrap(), ); assert!(instances.is_empty()); } #[test] fn a_rule_with_no_until_still_repeats_forever() { // The default every rule written before this field existed reads back as. let start = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); let event = weekly_event(start, None); let instances = expand_recurrence( &event, Utc.with_ymd_and_hms(2030, 3, 1, 0, 0, 0).unwrap(), Utc.with_ymd_and_hms(2030, 3, 31, 23, 59, 59).unwrap(), ); assert_eq!(instances.len(), 4); } #[test] fn next_recurring_task_opens_no_successor_past_until() { // The final instance closes the chain: completing it writes no successor, // which is what makes a bounded series stop without anyone deleting it. let due = Utc.with_ymd_and_hms(2026, 8, 3, 9, 0, 0).unwrap(); let mut task = recurring_task(due, Recurrence::Weekly); task.recurrence_rule = Some(RecurrenceRule { pattern: Recurrence::Weekly, interval: 1, weekdays: vec![], monthly_spec: None, // The successor would fall on Aug 10, one day past this. until: Some(Utc.with_ymd_and_hms(2026, 8, 9, 9, 0, 0).unwrap()), }); assert!(next_recurring_task(&task, Tz::UTC, due).is_none()); } #[test] fn next_recurring_task_opens_a_successor_landing_on_until() { // Same inclusivity as the expansion side: the boundary occurrence is // part of the series. let due = Utc.with_ymd_and_hms(2026, 8, 3, 9, 0, 0).unwrap(); let until = Utc.with_ymd_and_hms(2026, 8, 10, 9, 0, 0).unwrap(); let mut task = recurring_task(due, Recurrence::Weekly); task.recurrence_rule = Some(RecurrenceRule { pattern: Recurrence::Weekly, interval: 1, weekdays: vec![], monthly_spec: None, until: Some(until), }); let next = next_recurring_task(&task, Tz::UTC, due).expect("successor on the boundary"); assert_eq!(next.due, Some(until)); } #[test] fn test_utc_wrapper_unaffected_by_dst_logic() { // The legacy UTC entry points must still add a fixed 24h (no zone involved), // so existing callers and instants are unchanged. let start = Utc.with_ymd_and_hms(2026, 3, 7, 9, 0, 0).unwrap(); let next = calculate_next_due(Some(&start), &Recurrence::Daily).unwrap(); assert_eq!((next - start).num_hours(), 24); assert_eq!(next.hour(), 9); }