//! Recurrence expansion, checked against known-good outputs. //! //! Most of these are table-driven and deliberately boring: a rule, a window, //! and the exact list of local times it should produce. The interesting ones //! are the boundaries — daylight saving, leap day, the difference between an //! excluded occurrence and a replaced one — because those are where the //! previous iteration's hand-written expander went wrong, and it had no tests //! at all to say so. #![allow(clippy::unwrap_used, clippy::expect_used)] use chrono::{DateTime, TimeDelta, Utc}; use chrono_tz::Tz; use pretty_assertions::assert_eq; use runway_core::ical; use runway_core::model::*; use runway_core::recurrence::{RecurrenceError, Window, ZoneSource, Zones, expand}; use std::path::Path; // ------------------------------------------------------------------ helpers -- fn denver() -> Tz { Tz::America__Denver } fn instant(text: &str) -> DateTime { text.parse::>() .unwrap_or_else(|e| panic!("{text}: {e}")) } fn window(from: &str, to: &str) -> Window { Window::new(instant(from), instant(to)) } /// Wraps event bodies in a `VCALENDAR` and parses them. fn calendar(events: &str) -> VCalendar { let ics = format!( "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//test//EN\r\n{}END:VCALENDAR\r\n", events.replace('\n', "\r\n"), ); ical::parse(&ics).unwrap_or_else(|e| panic!("fixture did not parse: {e}\n{ics}")) } /// A recurring event in Denver, 09:00–09:30, starting on the given date. fn denver_series(start: &str, rrule: &str, extra: &str) -> VCalendar { calendar(&format!( "BEGIN:VEVENT UID:series@test DTSTAMP:20260101T000000Z DTSTART;TZID=America/Denver:{start}T090000 DTEND;TZID=America/Denver:{start}T093000 SUMMARY:Series RRULE:{rrule} {extra}END:VEVENT " )) } fn occurrences(cal: &VCalendar, window: Window, tz: Tz) -> Vec { expand(cal, window, Zones::new(tz)).unwrap_or_else(|e| panic!("expansion failed: {e}")) } /// The local wall-clock start of each occurrence, as written. fn local_starts(cal: &VCalendar, window: Window, tz: Tz) -> Vec { occurrences(cal, window, tz) .iter() .map(|o| match &o.start { CalendarDateTime::Date { date } => date.to_string(), other => other.naive_local().to_string(), }) .collect() } /// The UTC instant of each occurrence, which is what shows a zone bug. fn utc_starts(cal: &VCalendar, window: Window, tz: Tz) -> Vec { occurrences(cal, window, tz) .iter() .map(|o| o.start_utc.to_rfc3339()) .collect() } // -------------------------------------------------------------- basic rules -- #[test] fn a_weekly_rule_produces_one_occurrence_a_week() { let cal = denver_series("20260105", "FREQ=WEEKLY;BYDAY=MO", ""); assert_eq!( local_starts( &cal, window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), denver() ), vec![ "2026-01-05 09:00:00", "2026-01-12 09:00:00", "2026-01-19 09:00:00", "2026-01-26 09:00:00", ], ); } #[test] fn count_and_until_bound_a_series_the_same_way() { let by_count = denver_series("20260105", "FREQ=DAILY;COUNT=3", ""); let by_until = denver_series("20260105", "FREQ=DAILY;UNTIL=20260107T163000Z", ""); let span = window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"); let expected = vec![ "2026-01-05 09:00:00", "2026-01-06 09:00:00", "2026-01-07 09:00:00", ]; assert_eq!(local_starts(&by_count, span, denver()), expected); assert_eq!( local_starts(&by_until, span, denver()), expected, "UNTIL is a UTC instant while the occurrences are local; comparing the \ two in the wrong frame is how a series gains or loses its last event", ); } #[test] fn a_monthly_nth_weekday_rule_lands_on_the_right_days() { // The case the previous iteration built a form for and then dropped on the // way to the API, so it never reached the server at all. let cal = denver_series("20260120", "FREQ=MONTHLY;BYDAY=3TU", ""); assert_eq!( local_starts( &cal, window("2026-01-01T00:00:00Z", "2026-05-01T00:00:00Z"), denver() ), vec![ "2026-01-20 09:00:00", "2026-02-17 09:00:00", "2026-03-17 09:00:00", "2026-04-21 09:00:00", ], ); } #[test] fn a_monthly_by_monthday_rule_skips_months_that_are_too_short() { let cal = denver_series("20260131", "FREQ=MONTHLY;BYMONTHDAY=31", ""); assert_eq!( local_starts( &cal, window("2026-01-01T00:00:00Z", "2026-06-01T00:00:00Z"), denver() ), vec![ "2026-01-31 09:00:00", "2026-03-31 09:00:00", "2026-05-31 09:00:00", ], "February, April and June have no 31st, and the rule simply does not \ fire -- it does not roll over into the next month", ); } #[test] fn a_leap_day_rule_only_fires_in_leap_years() { let cal = calendar( "BEGIN:VEVENT UID:leap@test DTSTAMP:20240101T000000Z DTSTART;VALUE=DATE:20240229 DTEND;VALUE=DATE:20240301 SUMMARY:Leap day RRULE:FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29 END:VEVENT ", ); assert_eq!( local_starts( &cal, window("2024-01-01T00:00:00Z", "2034-01-01T00:00:00Z"), denver() ), vec!["2024-02-29", "2028-02-29", "2032-02-29"], ); } // ------------------------------------------------------------- daylight saving #[test] fn a_weekly_meeting_keeps_its_local_time_across_spring_forward() { // Denver moves to daylight time on 2026-03-08. let cal = denver_series("20260302", "FREQ=WEEKLY;BYDAY=MO", ""); let span = window("2026-03-01T00:00:00Z", "2026-03-24T00:00:00Z"); assert_eq!( local_starts(&cal, span, denver()), vec![ "2026-03-02 09:00:00", "2026-03-09 09:00:00", "2026-03-16 09:00:00", "2026-03-23 09:00:00", ], "the meeting is at 09:00 every week, before and after the change", ); assert_eq!( utc_starts(&cal, span, denver()), vec![ "2026-03-02T16:00:00+00:00", "2026-03-09T15:00:00+00:00", "2026-03-16T15:00:00+00:00", "2026-03-23T15:00:00+00:00", ], "and the instant moves by an hour, which is the whole point. The last \ iteration sent a fixed UTC offset in place of a zone, so this series \ drifted to 08:00 for the rest of the year", ); } #[test] fn a_weekly_meeting_keeps_its_local_time_across_fall_back() { // Denver returns to standard time on 2026-11-01. let cal = denver_series("20261026", "FREQ=WEEKLY;BYDAY=MO", ""); let span = window("2026-10-20T00:00:00Z", "2026-11-17T00:00:00Z"); assert_eq!( utc_starts(&cal, span, denver()), vec![ "2026-10-26T15:00:00+00:00", "2026-11-02T16:00:00+00:00", "2026-11-09T16:00:00+00:00", "2026-11-16T16:00:00+00:00", ], ); } #[test] fn a_start_inside_the_spring_forward_gap_moves_past_it() { // 02:30 on 2026-03-08 does not exist in Denver; the clock jumps 01:59 to // 03:00. Something has to happen, and the choice is made once, explicitly: // the time slides past the gap by the gap's own length. let cal = calendar( "BEGIN:VEVENT UID:gap@test DTSTAMP:20260101T000000Z DTSTART;TZID=America/Denver:20260308T023000 DTEND;TZID=America/Denver:20260308T033000 SUMMARY:In the gap END:VEVENT ", ); let found = occurrences( &cal, window("2026-03-08T00:00:00Z", "2026-03-09T00:00:00Z"), denver(), ); assert_eq!(found.len(), 1); assert_eq!( found[0].start_utc.to_rfc3339(), "2026-03-08T09:30:00+00:00", "03:30 local -- slid past the gap, not silently dropped, not thrown \ back to the previous day, and not snapped to 03:00 alongside every \ other lost time that morning", ); } #[test] fn an_ambiguous_start_takes_the_earlier_of_the_two() { // 01:30 on 2026-11-01 happens twice in Denver. let cal = calendar( "BEGIN:VEVENT UID:ambiguous@test DTSTAMP:20260101T000000Z DTSTART;TZID=America/Denver:20261101T013000 DTEND;TZID=America/Denver:20261101T020000 SUMMARY:Twice over END:VEVENT ", ); let found = occurrences( &cal, window("2026-11-01T00:00:00Z", "2026-11-02T00:00:00Z"), denver(), ); assert_eq!( found[0].start_utc.to_rfc3339(), "2026-11-01T07:30:00+00:00", "the first 01:30, which is what a person setting that time means", ); } // ------------------------------------------------------- exclusions and extras #[test] fn exdate_removes_an_occurrence_the_rule_would_generate() { let cal = denver_series( "20260105", "FREQ=WEEKLY;BYDAY=MO", "EXDATE;TZID=America/Denver:20260112T090000,20260126T090000\n", ); assert_eq!( local_starts( &cal, window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), denver() ), vec!["2026-01-05 09:00:00", "2026-01-19 09:00:00"], "both values on the one EXDATE line have to be honoured", ); } #[test] fn rdate_adds_an_occurrence_the_rule_would_not() { let cal = denver_series( "20260105", "FREQ=WEEKLY;BYDAY=MO;COUNT=2", "RDATE;TZID=America/Denver:20260108T090000\n", ); assert_eq!( local_starts( &cal, window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), denver() ), vec![ "2026-01-05 09:00:00", "2026-01-08 09:00:00", "2026-01-12 09:00:00", ], ); } #[test] fn an_all_day_series_excludes_by_date() { let cal = calendar( "BEGIN:VEVENT UID:bins@test DTSTAMP:20260101T000000Z DTSTART;VALUE=DATE:20260105 DTEND;VALUE=DATE:20260106 SUMMARY:Bin day RRULE:FREQ=WEEKLY;BYDAY=MO EXDATE;VALUE=DATE:20260119 END:VEVENT ", ); assert_eq!( local_starts( &cal, window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), denver() ), vec!["2026-01-05", "2026-01-12", "2026-01-26"], ); } // ----------------------------------------------------------------- overrides -- #[test] fn an_override_replaces_the_occurrence_it_names_without_duplicating_it() { let cal = calendar( "BEGIN:VEVENT UID:standup@test DTSTAMP:20260101T000000Z DTSTART;TZID=America/Denver:20260105T090000 DTEND;TZID=America/Denver:20260105T093000 SUMMARY:Standup RRULE:FREQ=WEEKLY;BYDAY=MO END:VEVENT BEGIN:VEVENT UID:standup@test RECURRENCE-ID;TZID=America/Denver:20260112T090000 DTSTAMP:20260106T000000Z DTSTART;TZID=America/Denver:20260112T140000 DTEND;TZID=America/Denver:20260112T143000 SUMMARY:Standup (moved) END:VEVENT ", ); let found = occurrences( &cal, window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), denver(), ); let on_the_12th: Vec<&Occurrence> = found .iter() .filter(|o| o.start.date().to_string() == "2026-01-12") .collect(); assert_eq!( on_the_12th.len(), 1, "exactly one occurrence that day -- the master must not also generate \ its 09:00 version. Emitting both is what made a correct feed look \ like it was full of duplicates", ); assert!(on_the_12th[0].is_override); assert_eq!( on_the_12th[0].start.naive_local().to_string(), "2026-01-12 14:00:00" ); assert_eq!(found.len(), 4, "four Mondays in January 2026"); } #[test] fn an_override_moved_to_another_day_lands_on_the_new_day() { let cal = calendar( "BEGIN:VEVENT UID:moved@test DTSTAMP:20260101T000000Z DTSTART;TZID=America/Denver:20260105T090000 DTEND;TZID=America/Denver:20260105T093000 SUMMARY:Weekly RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=3 END:VEVENT BEGIN:VEVENT UID:moved@test RECURRENCE-ID;TZID=America/Denver:20260112T090000 DTSTAMP:20260106T000000Z DTSTART;TZID=America/Denver:20260114T110000 DTEND;TZID=America/Denver:20260114T113000 SUMMARY:Weekly (rescheduled) END:VEVENT ", ); assert_eq!( local_starts( &cal, window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), denver() ), vec![ "2026-01-05 09:00:00", "2026-01-14 11:00:00", "2026-01-19 09:00:00", ], "RECURRENCE-ID says which occurrence is replaced; DTSTART says when the \ replacement happens. Nothing should appear on the 12th", ); } #[test] fn an_override_carries_the_recurrence_id_needed_to_edit_it_again() { let cal = calendar( "BEGIN:VEVENT UID:edit@test DTSTAMP:20260101T000000Z DTSTART;TZID=America/Denver:20260105T090000 DTEND;TZID=America/Denver:20260105T093000 SUMMARY:Weekly RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=2 END:VEVENT ", ); let found = occurrences( &cal, window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), denver(), ); assert_eq!( found[1].recurrence_id, Some(CalendarDateTime::Zoned { local: chrono::NaiveDate::from_ymd_opt(2026, 1, 12) .unwrap() .and_hms_opt(9, 0, 0) .unwrap(), tzid: TzId::new("America/Denver").unwrap(), }), "a generated occurrence knows its own RECURRENCE-ID, so editing it \ sends back the value the server will write. The last iteration \ encoded this as \"{{uid}}-{{timestamp}}\" and split the string apart \ again at the other end", ); } #[test] fn an_override_without_a_master_is_still_an_occurrence() { let cal = calendar( "BEGIN:VEVENT UID:orphan@test RECURRENCE-ID;TZID=America/Denver:20260112T090000 DTSTAMP:20260106T000000Z DTSTART;TZID=America/Denver:20260112T140000 DTEND;TZID=America/Denver:20260112T143000 SUMMARY:Orphaned exception END:VEVENT ", ); let found = occurrences( &cal, window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), denver(), ); assert_eq!( found.len(), 1, "a published feed truncates series at its edge" ); assert!(found[0].is_override); } // ------------------------------------------------------------------- windows -- #[test] fn an_event_straddling_the_window_edge_is_included() { let cal = calendar( "BEGIN:VEVENT UID:long@test DTSTAMP:20260101T000000Z DTSTART;TZID=America/Denver:20260104T220000 DTEND;TZID=America/Denver:20260105T020000 SUMMARY:Across midnight END:VEVENT ", ); let found = occurrences( &cal, window("2026-01-05T00:00:00Z", "2026-01-06T00:00:00Z"), denver(), ); assert_eq!( found.len(), 1, "it starts before the window but runs into it" ); } #[test] fn an_event_wholly_outside_the_window_is_excluded() { let cal = denver_series("20260105", "FREQ=WEEKLY;BYDAY=MO;COUNT=2", ""); assert!( occurrences( &cal, window("2026-06-01T00:00:00Z", "2026-07-01T00:00:00Z"), denver() ) .is_empty() ); } #[test] fn results_are_ordered_and_deterministic() { let cal = calendar( "BEGIN:VEVENT UID:b@test DTSTAMP:20260101T000000Z DTSTART;TZID=America/Denver:20260105T140000 DTEND;TZID=America/Denver:20260105T150000 SUMMARY:Afternoon END:VEVENT BEGIN:VEVENT UID:a@test DTSTAMP:20260101T000000Z DTSTART;TZID=America/Denver:20260105T090000 DTEND;TZID=America/Denver:20260105T100000 SUMMARY:Morning END:VEVENT ", ); let span = window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"); let first = occurrences(&cal, span, denver()); assert_eq!( first.iter().map(Occurrence::uid).collect::>(), vec!["a@test", "b@test"], ); assert_eq!(first, occurrences(&cal, span, denver())); } // ----------------------------------------------------------------- timezones -- #[test] fn a_windows_zone_name_resolves_through_cldr() { let zones = Zones::new(denver()); for (windows, iana) in [ ("Pacific Standard Time", Tz::America__Los_Angeles), ("Eastern Standard Time", Tz::America__New_York), ("Mountain Standard Time", Tz::America__Denver), ("GTB Standard Time", Tz::Europe__Bucharest), ] { let resolved = zones.resolve(&TzId::new(windows).unwrap()); assert_eq!(resolved.tz, iana, "{windows}"); assert_eq!(resolved.source, ZoneSource::WindowsName); } } #[test] fn an_iana_identifier_is_preferred_and_reported_as_such() { let resolved = Zones::new(denver()).resolve(&TzId::new("Europe/Zurich").unwrap()); assert_eq!(resolved.tz, Tz::Europe__Zurich); assert_eq!(resolved.source, ZoneSource::Iana); } #[test] fn an_unresolvable_zone_is_reported_rather_than_hidden() { let resolved = Zones::new(denver()).resolve(&TzId::new("Customized Time Zone 3").unwrap()); assert_eq!(resolved.source, ZoneSource::Assumed); assert_eq!( resolved.tz, denver(), "something has to be assumed, but the caller is told it was assumed \ rather than being handed a silently wrong time", ); } #[test] fn an_event_in_a_windows_zone_resolves_to_the_right_instant() { let cal = calendar( "BEGIN:VEVENT UID:exchange@test DTSTAMP:20260101T000000Z DTSTART;TZID=Pacific Standard Time:20260115T090000 DTEND;TZID=Pacific Standard Time:20260115T093000 SUMMARY:From Exchange END:VEVENT ", ); let found = occurrences( &cal, window("2026-01-15T00:00:00Z", "2026-01-16T00:00:00Z"), denver(), ); assert_eq!( found[0].start_utc.to_rfc3339(), "2026-01-15T17:00:00+00:00", "09:00 Los Angeles in January is 17:00 UTC", ); } #[test] fn a_floating_time_is_read_in_the_viewers_zone() { let cal = calendar( "BEGIN:VEVENT UID:floating@test DTSTAMP:20260101T000000Z DTSTART:20260115T090000 DTEND:20260115T100000 SUMMARY:Nine, wherever you are END:VEVENT ", ); let span = window("2026-01-15T00:00:00Z", "2026-01-16T00:00:00Z"); assert_eq!( occurrences(&cal, span, denver())[0].start_utc.to_rfc3339(), "2026-01-15T16:00:00+00:00", ); assert_eq!( occurrences(&cal, span, Tz::Europe__Zurich)[0] .start_utc .to_rfc3339(), "2026-01-15T08:00:00+00:00", "the same floating value is a different instant for a different reader, \ which is exactly what floating means", ); } // -------------------------------------------------------------------- limits -- #[test] fn a_runaway_rule_is_reported_not_truncated() { let cal = denver_series("20260101", "FREQ=MINUTELY", ""); let result = expand( &cal, window("2026-01-01T00:00:00Z", "2027-01-01T00:00:00Z"), Zones::new(denver()), ); assert!( matches!(result, Err(RecurrenceError::TooManyOccurrences { .. })), "half a million occurrences is not a calendar view; failing loudly \ beats returning a list that is quietly missing most of the year", ); } #[test] fn an_unusable_rule_names_the_event_and_the_rule() { let mut cal = denver_series("20260105", "FREQ=WEEKLY", ""); cal.events[0].rrule = Some("FREQ=NONSENSE".to_owned()); match expand( &cal, window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), Zones::new(denver()), ) { Err(RecurrenceError::InvalidRule { uid, rrule, .. }) => { assert_eq!(uid, "series@test"); assert_eq!(rrule, "FREQ=NONSENSE"); } other => panic!("expected a typed error, got {other:?}"), } } // ------------------------------------------------------- against real data --- fn outlook_feed() -> VCalendar { let path = Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/golden/outlook/feed-overrides-windows-tz.ics"); ical::parse(&std::fs::read_to_string(path).unwrap()).unwrap() } #[test] fn the_outlook_feed_expands_without_duplicating_a_single_day() { let cal = outlook_feed(); let found = occurrences( &cal, window("2025-10-01T00:00:00Z", "2026-01-01T00:00:00Z"), denver(), ); assert!(!found.is_empty()); // Within one series, no two occurrences may claim the same start. This is // the assertion the old importer's title-matching heuristics were standing // in for -- and it holds here because overrides suppress the occurrence // they replace, not because anything was merged away. let mut seen: Vec<(&str, DateTime)> = found.iter().map(|o| (o.uid(), o.start_utc)).collect(); let before = seen.len(); seen.sort(); seen.dedup(); assert_eq!( before, seen.len(), "a series produced two occurrences at one instant" ); } #[test] fn every_zone_in_the_outlook_feed_resolves() { let cal = outlook_feed(); assert_eq!( runway_core::recurrence::unresolved_zones(&cal, Zones::new(denver())), Vec::::new(), "the feed names its zones in Windows form and every one of them maps", ); } #[test] fn an_exchange_series_honours_its_exclusions() { let cal = outlook_feed(); let master = cal .events .iter() .find(|e| !e.exdate.is_empty() && e.rrule.is_some()) .expect("the feed has a series with exclusions"); let excluded: Vec> = master .exdate .iter() .map(|d| Zones::new(denver()).instant(d)) .collect(); let found = occurrences( &cal, window("2025-10-01T00:00:00Z", "2027-01-01T00:00:00Z"), denver(), ); for instant in excluded { assert!( !found .iter() .any(|o| o.uid() == master.uid && o.start_utc == instant && !o.is_override), "an EXDATE'd occurrence at {instant} was still generated", ); } } #[test] fn expanding_the_whole_feed_stays_cheap() { // Not a benchmark, a guard: the old client re-expanded every rule in the // browser on every view change. let cal = outlook_feed(); let started = std::time::Instant::now(); let found = occurrences( &cal, window("2025-01-01T00:00:00Z", "2028-01-01T00:00:00Z"), denver(), ); let elapsed = started.elapsed(); assert!(!found.is_empty()); assert!( elapsed < TimeDelta::seconds(5).to_std().unwrap(), "three years of a real feed took {elapsed:?}", ); }