//! Cases the corpus oracle cannot check, and the bugs that made calcard's own //! expansion unusable. use chrono::{DateTime, Utc}; use pimdav::calcard::icalendar::ICalendar; use pimdav::expand::expand; use pimdav::zone::Zone; fn utc(s: &str) -> DateTime { format!("{s}:00Z").parse().unwrap() } fn fmt(t: DateTime) -> String { t.format("%Y-%m-%dT%H:%M").to_string() } /// `start/end` pairs in UTC of the instances overlapping `lo..hi`. fn instances(body: &str, lo: &str, hi: &str) -> Vec { let ics = format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{body}END:VCALENDAR\r\n"); let cal = ICalendar::parse(&ics).unwrap(); let out = expand(&cal, utc(lo)..utc(hi), Zone::Utc); assert!(!out.truncated); out.instances .iter() .map(|i| format!("{}/{}", fmt(i.start), fmt(i.end))) .collect() } fn event(uid: &str, props: &str) -> String { format!("BEGIN:VEVENT\r\nUID:{uid}\r\n{props}END:VEVENT\r\n") } #[test] fn utc_until_east_of_utc_keeps_last_instance() { let body = event( "a", "SEQUENCE:1\r\nDTSTART;TZID=Europe/Berlin:20190318T040000\r\nDTEND;TZID=Europe/Berlin:20190318T050000\r\nRRULE:FREQ=DAILY;UNTIL=20190320T030000Z\r\n", ) + &event( "a", "SEQUENCE:2\r\nRECURRENCE-ID;TZID=Europe/Berlin:20190319T040000\r\nDTSTART;TZID=Europe/Berlin:20190319T100000\r\nDTEND;TZID=Europe/Berlin:20190319T110000\r\n", ); // The override has a higher SEQUENCE than the master and still replaces // the instance. assert_eq!( instances(&body, "2019-03-01T00:00", "2019-04-01T00:00"), [ "2019-03-18T03:00/2019-03-18T04:00", "2019-03-19T09:00/2019-03-19T10:00", "2019-03-20T03:00/2019-03-20T04:00", ] ); } #[test] fn rdate_is_deduplicated_and_excluded() { let body = event( "a", "DTSTART:20240101T100000Z\r\nDTEND:20240101T110000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nRDATE:20240101T100000Z,20240110T100000Z\r\nEXDATE:20240110T100000Z\r\n", ); assert_eq!( instances(&body, "2024-01-01T00:00", "2024-02-01T00:00"), [ "2024-01-01T10:00/2024-01-01T11:00", "2024-01-02T10:00/2024-01-02T11:00", "2024-01-03T10:00/2024-01-03T11:00", ] ); } #[test] fn dst_gap_and_fold() { let body = event( "a", "DTSTART;TZID=Europe/Berlin:20240330T023000\r\nDURATION:PT30M\r\nRRULE:FREQ=DAILY;COUNT=2\r\n", ) + &event( "b", "DTSTART;TZID=Europe/Berlin:20241027T023000\r\nDURATION:PT30M\r\n", ); // 02:30 on 03-31 does not exist and takes the offset before the gap. // 02:30 on 10-27 happens twice and takes the first. assert_eq!( instances(&body, "2024-03-01T00:00", "2024-11-01T00:00"), [ "2024-03-30T01:30/2024-03-30T02:00", "2024-03-31T01:30/2024-03-31T02:00", "2024-10-27T00:30/2024-10-27T01:00", ] ); } #[test] fn duration_days_are_nominal_and_dtend_is_exact() { let nominal = event( "a", "DTSTART;TZID=Europe/Berlin:20240330T120000\r\nDURATION:P1D\r\n", ); let exact = event( "a", "DTSTART;TZID=Europe/Berlin:20240330T120000\r\nDTEND;TZID=Europe/Berlin:20240331T120000\r\nRRULE:FREQ=DAILY;COUNT=2\r\n", ); assert_eq!( instances(&nominal, "2024-03-01T00:00", "2024-04-01T00:00"), ["2024-03-30T11:00/2024-03-31T10:00"] ); // The second instance keeps the 23 hours of the first. assert_eq!( instances(&exact, "2024-03-01T00:00", "2024-04-02T00:00"), [ "2024-03-30T11:00/2024-03-31T10:00", "2024-03-31T10:00/2024-04-01T09:00", ] ); } #[test] fn custom_vtimezone_rules() { let body = "BEGIN:VTIMEZONE\r\nTZID:Customized Time Zone\r\nBEGIN:STANDARD\r\nDTSTART:16010101T020000\r\nTZOFFSETFROM:-0400\r\nTZOFFSETTO:-0500\r\nRRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11\r\nEND:STANDARD\r\nBEGIN:DAYLIGHT\r\nDTSTART:16010101T020000\r\nTZOFFSETFROM:-0500\r\nTZOFFSETTO:-0400\r\nRRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3\r\nEND:DAYLIGHT\r\nEND:VTIMEZONE\r\n".to_string() + &event("a", "DTSTART;TZID=Customized Time Zone:20240301T090000\r\nDURATION:PT1H\r\nRRULE:FREQ=WEEKLY;COUNT=2\r\n", ); assert_eq!( instances(&body, "2024-03-01T00:00", "2024-04-01T00:00"), [ "2024-03-01T14:00/2024-03-01T15:00", "2024-03-08T14:00/2024-03-08T15:00", ] ); let body = body.replace("20240301T090000", "20240315T090000"); assert_eq!( instances(&body, "2024-03-01T00:00", "2024-04-01T00:00"), [ "2024-03-15T13:00/2024-03-15T14:00", "2024-03-22T13:00/2024-03-22T14:00", ] ); } #[test] fn this_and_future_moves_later_instances() { let body = event( "a", "DTSTART:20240101T100000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=5\r\n", ) + &event( "a", "RECURRENCE-ID;RANGE=THISANDFUTURE:20240103T100000Z\r\nDTSTART:20240103T120000Z\r\nDURATION:PT2H\r\n", ) + &event( "a", "RECURRENCE-ID:20240105T100000Z\r\nDTSTART:20240105T150000Z\r\nDURATION:PT1H\r\n", ); assert_eq!( instances(&body, "2024-01-01T00:00", "2024-02-01T00:00"), [ "2024-01-01T10:00/2024-01-01T11:00", "2024-01-02T10:00/2024-01-02T11:00", "2024-01-03T12:00/2024-01-03T14:00", "2024-01-04T12:00/2024-01-04T14:00", "2024-01-05T15:00/2024-01-05T16:00", ] ); } #[test] fn exdate_forms() { // A DATE EXDATE removes the day of a DATE-TIME series. A floating EXDATE // is read in the series zone. An override of an excluded instance goes too. let body = event( "a", "DTSTART;TZID=Europe/Berlin:20240101T100000\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=4\r\nEXDATE;VALUE=DATE:20240102\r\nEXDATE:20240103T100000\r\n", ) + &event( "a", "RECURRENCE-ID;TZID=Europe/Berlin:20240103T100000\r\nDTSTART;TZID=Europe/Berlin:20240103T110000\r\nDURATION:PT1H\r\n", ); assert_eq!( instances(&body, "2024-01-01T00:00", "2024-02-01T00:00"), [ "2024-01-01T09:00/2024-01-01T10:00", "2024-01-04T09:00/2024-01-04T10:00", ] ); } #[test] fn window_overlap() { let body = event( "a", "DTSTART;VALUE=DATE:20240101\r\nDTEND;VALUE=DATE:20240104\r\n", ) + &event("b", "DTSTART:20240105T000000Z\r\n"); // The three-day event starts before the window and overlaps it. The // zero-length one sits exactly on the window end. assert_eq!( instances(&body, "2024-01-03T00:00", "2024-01-05T00:00"), ["2024-01-01T00:00/2024-01-04T00:00"] ); } #[test] fn todo_uses_due() { let body = "BEGIN:VTODO\r\nUID:t\r\nDTSTART:20240101T100000Z\r\nDUE:20240101T120000Z\r\nRRULE:FREQ=WEEKLY;COUNT=2\r\nEND:VTODO\r\n"; assert_eq!( instances(body, "2024-01-01T00:00", "2024-02-01T00:00"), [ "2024-01-01T10:00/2024-01-01T12:00", "2024-01-08T10:00/2024-01-08T12:00", ] ); } #[test] fn mixed_numbered_and_plain_weekdays() { let body = event( "a", "DTSTART:20240102T100000Z\r\nRRULE:FREQ=MONTHLY;COUNT=4;BYDAY=1MO,TU\r\n", ); assert_eq!( instances(&body, "2024-01-01T00:00", "2024-02-01T00:00"), [ "2024-01-02T10:00/2024-01-02T10:00", "2024-01-09T10:00/2024-01-09T10:00", "2024-01-16T10:00/2024-01-16T10:00", "2024-01-23T10:00/2024-01-23T10:00", ] ); } #[test] fn unsupported_rules_leave_dtstart_only() { for rule in [ "FREQ=MONTHLY;COUNT=3;BYMONTH=13", "FREQ=YEARLY;COUNT=3;BYMONTH=5L", "RSCALE=CHINESE;FREQ=YEARLY;COUNT=3", "RSCALE=GREGORIAN;FREQ=YEARLY;SKIP=FORWARD;COUNT=3", "FREQ=YEARLY;UNTIL=20243456T000000Z", ] { let body = event( "a", &format!("DTSTART:20240101T100000Z\r\nRRULE:{rule}\r\n"), ); assert_eq!( instances(&body, "2024-01-01T00:00", "2030-01-01T00:00"), ["2024-01-01T10:00/2024-01-01T10:00"], "{rule}" ); } } #[test] fn runaway_rule_is_truncated() { let ics = format!( "BEGIN:VCALENDAR\r\n{}END:VCALENDAR\r\n", event("a", "DTSTART:20000101T000000Z\r\nRRULE:FREQ=MINUTELY\r\n") ); let cal = ICalendar::parse(&ics).unwrap(); let out = expand( &cal, utc("2000-01-01T00:00")..utc("2020-01-01T00:00"), Zone::Utc, ); assert!(out.truncated); } #[test] fn old_sub_daily_rule_skips_ahead() { let body = event( "a", "DTSTART:20200101T000000Z\r\nDTEND:20200101T000030Z\r\nRRULE:FREQ=MINUTELY;INTERVAL=7\r\n", ); let got = instances(&body, "2026-03-01T10:00", "2026-03-01T10:20"); assert_eq!(got.len(), 3, "{got:?}"); assert!(got.iter().all(|g| { let t = utc(&g[..16]); (t - utc("2020-01-01T00:00")).num_minutes() % 7 == 0 })); } #[test] fn sub_daily_rule_keeps_its_grid_across_hour_limits() { let body = event( "a", "DTSTART:20200101T093000Z\r\nRRULE:FREQ=MINUTELY;INTERVAL=7;BYHOUR=9;BYMINUTE=0,30\r\n", ); assert_eq!( instances(&body, "2026-03-25T00:00", "2026-04-09T00:00"), [ "2026-03-25T09:30/2026-03-25T09:30", "2026-03-31T09:00/2026-03-31T09:00", "2026-04-01T09:30/2026-04-01T09:30", "2026-04-07T09:00/2026-04-07T09:00", "2026-04-08T09:30/2026-04-08T09:30", ] ); // COUNT counts the instances inside the hour limit only. let counted = event( "a", "DTSTART:20260324T095000Z\r\nRRULE:FREQ=MINUTELY;INTERVAL=7;BYHOUR=9;COUNT=3\r\n", ); assert_eq!( instances(&counted, "2026-03-24T00:00", "2026-03-27T00:00"), [ "2026-03-24T09:50/2026-03-24T09:50", "2026-03-24T09:57/2026-03-24T09:57", "2026-03-25T09:03/2026-03-25T09:03", ] ); } #[test] fn old_biweekly_rule_keeps_its_grid() { // 2026-03-02 is 321 weeks after DTSTART, so the week of 03-09 is the next one in the grid. let body = event( "a", "DTSTART:20200106T090000Z\r\nDTEND:20200106T100000Z\r\nRRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE\r\n", ); assert_eq!( instances(&body, "2026-03-01T00:00", "2026-03-20T00:00"), [ "2026-03-09T09:00/2026-03-09T10:00", "2026-03-11T09:00/2026-03-11T10:00", ] ); } #[test] fn utc_until_on_an_all_day_series_keeps_its_day() { let body = event( "a", "DTSTART;VALUE=DATE:20240108\r\nRRULE:FREQ=DAILY;UNTIL=20240110T000000Z\r\n", ); let ics = format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{body}END:VCALENDAR\r\n"); let cal = ICalendar::parse(&ics).unwrap(); let west = Zone::Iana(chrono_tz::America::New_York); let out = expand(&cal, utc("2024-01-01T00:00")..utc("2024-02-01T00:00"), west); assert_eq!(out.instances.len(), 3); } #[test] fn dtstart_is_the_first_of_count() { // A Tuesday, with a rule for Mondays. let body = event( "a", "DTSTART:20260106T100000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=3\r\n", ); let got = instances(&body, "2026-01-01T00:00", "2026-03-01T00:00"); assert_eq!(got.len(), 3, "{got:?}"); assert_eq!(got[0], "2026-01-06T10:00/2026-01-06T11:00"); } #[test] fn instance_for_finds_instances_moved_by_this_and_future() { let body = event( "a", "DTSTART:20240101T100000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=5\r\n", ) + &event( "a", "RECURRENCE-ID;RANGE=THISANDFUTURE:20240103T100000Z\r\nDTSTART:20240103T120000Z\r\nDURATION:PT2H\r\n", ); let ics = format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{body}END:VCALENDAR\r\n"); let cal = ICalendar::parse(&ics).unwrap(); let rid = Some(utc("2024-01-04T10:00")); let index = pimdav::view::component_for(&cal, rid, &Zone::Utc).unwrap(); let i = pimdav::view::instance_for(&cal, index, rid, &Zone::Utc).unwrap(); assert_eq!( (fmt(i.start), fmt(i.end)), ("2024-01-04T12:00".into(), "2024-01-04T14:00".into()) ); } #[test] fn instance_for_finds_an_instance_moved_across_dst() { let body = event( "a", "DTSTART;TZID=Europe/Berlin:20240328T100000\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=6\r\n", ) + &event( "a", "RECURRENCE-ID;RANGE=THISANDFUTURE;TZID=Europe/Berlin:20240330T100000\r\nDTSTART;TZID=Europe/Berlin:20240331T100000\r\nDURATION:PT1H\r\n", ); let ics = format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{body}END:VCALENDAR\r\n"); let cal = ICalendar::parse(&ics).unwrap(); // The instance of the 31st, 10:00, moved to the 1st, 10:00. let rid = Some(utc("2024-03-31T08:00")); let index = pimdav::view::component_for(&cal, rid, &Zone::Utc).unwrap(); let i = pimdav::view::instance_for(&cal, index, rid, &Zone::Utc).unwrap(); assert_eq!(fmt(i.start), "2024-04-01T08:00"); } #[test] fn rdate_period_on_dtstart_keeps_its_length() { let body = event( "a", "DTSTART:20260601T100000Z\r\nDTEND:20260601T110000Z\r\nRDATE;VALUE=PERIOD:20260601T100000Z/PT5H\r\n", ); assert_eq!( instances(&body, "2026-06-01T00:00", "2026-06-02T00:00"), ["2026-06-01T10:00/2026-06-01T15:00"] ); } #[test] fn a_rule_that_never_matches_stops_early() { let body = event( "a", "DTSTART:20260601T100000Z\r\nRRULE:FREQ=DAILY;BYMONTH=2;BYMONTHDAY=30\r\n", ); let t = std::time::Instant::now(); assert_eq!( instances(&body, "2026-06-01T00:00", "2026-07-01T00:00"), ["2026-06-01T10:00/2026-06-01T10:00"] ); // Without the period cap it runs on to year 9999, seconds in a debug build. assert!(t.elapsed().as_millis() < 1000, "{:?}", t.elapsed()); } #[test] fn a_this_and_future_move_across_dst_keeps_the_local_time() { // From the 30th on, one day later. The move crosses the DST change, so in // UTC it is 23 hours, but each later instance still starts at 10:00. let body = event( "a", "DTSTART;TZID=Europe/Berlin:20240328T100000\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=6\r\n", ) + &event( "a", "RECURRENCE-ID;RANGE=THISANDFUTURE;TZID=Europe/Berlin:20240330T100000\r\nDTSTART;TZID=Europe/Berlin:20240331T100000\r\nDURATION:PT1H\r\n", ); assert_eq!( instances(&body, "2024-03-28T00:00", "2024-04-10T00:00"), [ "2024-03-28T09:00/2024-03-28T10:00", "2024-03-29T09:00/2024-03-29T10:00", "2024-03-31T08:00/2024-03-31T09:00", "2024-04-01T08:00/2024-04-01T09:00", "2024-04-02T08:00/2024-04-02T09:00", "2024-04-03T08:00/2024-04-03T09:00", ] ); } #[test] fn the_rules_of_one_series_share_one_budget() { // Each rule alone stays under the cap; together they pass it. let body = event( "a", "DTSTART:20260601T000000Z\r\nRRULE:FREQ=SECONDLY\r\nRRULE:FREQ=SECONDLY;BYSECOND=0,10,20,30,40,50\r\n", ); let ics = format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{body}END:VCALENDAR\r\n"); let cal = ICalendar::parse(&ics).unwrap(); // 11 days with the slack: 950,400 plus 95,040 occurrences. let days = utc("2026-06-01T00:00")..utc("2026-06-11T00:00"); assert!(expand(&cal, days, Zone::Utc).truncated); }