bundle.rs
| 1 | //! Feeds, export and import splitting. |
| 2 | |
| 3 | use pimdav::bundle::{self, Detail}; |
| 4 | use pimdav::object; |
| 5 | |
| 6 | const BERLIN: &str = "BEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:STANDARD\r\nDTSTART:19701025T030000\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n"; |
| 7 | |
| 8 | fn uids() -> impl FnMut(&str) -> String { |
| 9 | let mut n = 0; |
| 10 | move |_| { |
| 11 | n += 1; |
| 12 | format!("new-{n}") |
| 13 | } |
| 14 | } |
| 15 | |
| 16 | #[test] |
| 17 | fn feed_merges_objects_and_deduplicates_zones() { |
| 18 | let a = format!( |
| 19 | "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:x\r\n{BERLIN}BEGIN:VEVENT\r\nUID:a\r\nDTSTART;TZID=Europe/Berlin:20240101T100000\r\nSUMMARY:Long\r\n folded\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT5M\r\nEND:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" |
| 20 | ); |
| 21 | // LF endings in storage become CRLF. |
| 22 | let b = format!( |
| 23 | "BEGIN:VCALENDAR\nVERSION:2.0\n{}BEGIN:VEVENT\nUID:b\nDTSTART;TZID=Europe/Berlin:20240102T100000\nATTENDEE:mailto:x@y\nEND:VEVENT\nBEGIN:VTODO\nUID:c\nEND:VTODO\nEND:VCALENDAR\n", |
| 24 | BERLIN.replace("\r\n", "\n") |
| 25 | ); |
| 26 | let full = bundle::calendar(&[&a, &b], Some("Work; home"), Detail::All); |
| 27 | assert!(full.starts_with("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n")); |
| 28 | assert!(full.contains(r"X-WR-CALNAME:Work\; home")); |
| 29 | assert!(full.contains(r"NAME:Work\; home")); |
| 30 | let long = bundle::calendar(&[&a], Some(&"x".repeat(200)), Detail::All); |
| 31 | assert!(long.lines().all(|l| l.len() <= 75), "{long}"); |
| 32 | assert!(full.contains("REFRESH-INTERVAL;VALUE=DURATION:PT1H\r\nX-PUBLISHED-TTL:PT1H\r\n")); |
| 33 | assert_eq!(full.matches("BEGIN:VTIMEZONE").count(), 1); |
| 34 | assert!(full.contains("SUMMARY:Long\r\n folded\r\n")); |
| 35 | assert!(full.contains("BEGIN:VTODO\r\nUID:c\r\n")); |
| 36 | assert!(!full.replace("\r\n", "").contains('\n')); |
| 37 | assert!(full.ends_with("END:VCALENDAR\r\n")); |
| 38 | |
| 39 | let busy = bundle::calendar(&[&a, &b], None, Detail::Busy); |
| 40 | assert_eq!(busy.matches("SUMMARY:Busy").count(), 2); |
| 41 | assert!(!busy.contains("Long")); |
| 42 | assert!(!busy.contains("VALARM")); |
| 43 | assert!(!busy.contains("ATTENDEE")); |
| 44 | assert!(!busy.contains("VTODO")); |
| 45 | assert!(busy.contains("DTSTART;TZID=Europe/Berlin:20240102T100000\r\n")); |
| 46 | assert!(busy.contains("BEGIN:VTIMEZONE")); |
| 47 | } |
| 48 | |
| 49 | fn object(components: &str) -> String { |
| 50 | format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{components}END:VCALENDAR\r\n") |
| 51 | } |
| 52 | |
| 53 | #[test] |
| 54 | fn busy_time_hides_what_blocks_nothing() { |
| 55 | let series = object( |
| 56 | "BEGIN:VEVENT\r\nUID:series@corp.example\r\nDTSTART:20240101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nEND:VEVENT\r\n\ |
| 57 | BEGIN:VEVENT\r\nUID:series@corp.example\r\nRECURRENCE-ID;RANGE=THISANDFUTURE:20240102T100000Z\r\nDTSTART:20240102T100000Z\r\nSTATUS:CANCELLED\r\nEND:VEVENT\r\n\ |
| 58 | BEGIN:VEVENT\r\nUID:series@corp.example\r\nRECURRENCE-ID:20240103T100000Z\r\nDTSTART:20240103T120000Z\r\nEND:VEVENT\r\n", |
| 59 | ); |
| 60 | let free = object( |
| 61 | "BEGIN:VEVENT\r\nUID:free\r\nDTSTART:20240105T100000Z\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\n", |
| 62 | ); |
| 63 | let busy = bundle::calendar(&[&series, &free], None, Detail::Busy); |
| 64 | // The master and the moved instance stay; the cancelled range stays as |
| 65 | // an override, so it frees its instance and the later ones. |
| 66 | assert_eq!(busy.matches("BEGIN:VEVENT").count(), 3, "{busy}"); |
| 67 | assert!(!busy.contains("EXDATE"), "{busy}"); |
| 68 | assert!(busy.contains("RANGE=THISANDFUTURE") && busy.contains("STATUS:CANCELLED")); |
| 69 | assert!(!busy.contains("corp.example") && !busy.contains("UID:free")); |
| 70 | let uids: Vec<&str> = busy.lines().filter(|l| l.starts_with("UID:")).collect(); |
| 71 | assert_eq!(uids.len(), 3); |
| 72 | assert!( |
| 73 | uids.iter().all(|u| *u == uids[0]), |
| 74 | "master and overrides keep one UID" |
| 75 | ); |
| 76 | } |
| 77 | |
| 78 | #[test] |
| 79 | fn public_feeds_reduce_private_objects() { |
| 80 | let open = object( |
| 81 | "BEGIN:VEVENT\r\nUID:open\r\nDTSTART:20240101T100000Z\r\nSUMMARY:Standup\r\nEND:VEVENT\r\n", |
| 82 | ); |
| 83 | let private = object( |
| 84 | "BEGIN:VEVENT\r\nUID:doc\r\nDTSTART:20240101T120000Z\r\nRRULE:FREQ=WEEKLY\r\nSUMMARY:Doctor\r\nCLASS:PRIVATE\r\nEND:VEVENT\r\n\ |
| 85 | BEGIN:VEVENT\r\nUID:doc\r\nRECURRENCE-ID:20240108T120000Z\r\nDTSTART:20240108T130000Z\r\nSUMMARY:Doctor\r\nEND:VEVENT\r\n", |
| 86 | ); |
| 87 | let task = object( |
| 88 | "BEGIN:VTODO\r\nUID:t\r\nSUMMARY:Secret task\r\nCLASS:CONFIDENTIAL\r\nEND:VTODO\r\n", |
| 89 | ); |
| 90 | let public = bundle::calendar(&[&open, &private, &task], None, Detail::Public); |
| 91 | assert!(public.contains("SUMMARY:Standup")); |
| 92 | assert!( |
| 93 | !public.contains("Doctor") && !public.contains("Secret task"), |
| 94 | "{public}" |
| 95 | ); |
| 96 | // The whole object is reduced, so its override still matches its master. |
| 97 | assert_eq!(public.matches("SUMMARY:Busy").count(), 2); |
| 98 | let all = bundle::calendar(&[&open, &private, &task], None, Detail::All); |
| 99 | assert!(all.contains("Doctor") && all.contains("Secret task")); |
| 100 | } |
| 101 | |
| 102 | #[test] |
| 103 | fn import_splits_by_uid_with_overrides_and_zones() { |
| 104 | let file = format!( |
| 105 | "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:src\r\nMETHOD:PUBLISH\r\nX-WR-CALNAME:Old\r\n{BERLIN}BEGIN:VTIMEZONE\r\nTZID:Unused\r\nEND:VTIMEZONE\r\n\ |
| 106 | BEGIN:VEVENT\r\nUID:m\r\nDTSTART;TZID=Europe/Berlin:20240101T100000\r\nRRULE:FREQ=DAILY\r\nEND:VEVENT\r\n\ |
| 107 | BEGIN:VEVENT\r\nUID:plain\r\nDTSTART:20240105T100000Z\r\nEND:VEVENT\r\n\ |
| 108 | BEGIN:VEVENT\r\nUID:m\r\nRECURRENCE-ID;TZID=Europe/Berlin:20240102T100000\r\nDTSTART;TZID=Europe/Berlin:20240102T120000\r\nEND:VEVENT\r\n\ |
| 109 | BEGIN:VEVENT\r\nDTSTART:20240106T100000Z\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" |
| 110 | ); |
| 111 | let parts = bundle::split_calendar(&file, &mut uids(), usize::MAX).unwrap(); |
| 112 | assert_eq!(parts.len(), 3); |
| 113 | let master = &parts[0]; |
| 114 | assert_eq!(master.matches("BEGIN:VEVENT").count(), 2); |
| 115 | assert!(master.contains("RECURRENCE-ID")); |
| 116 | assert!(master.contains("TZID:Europe/Berlin")); |
| 117 | assert!(!master.contains("Unused")); |
| 118 | assert!(!master.contains("METHOD")); |
| 119 | assert!(!master.contains("X-WR-CALNAME")); |
| 120 | assert!(master.contains("PRODID:src\r\n")); |
| 121 | assert!(!parts[1].contains("VTIMEZONE")); |
| 122 | assert!(parts[2].contains("BEGIN:VEVENT\r\nUID:new-1\r\n")); |
| 123 | for p in &parts { |
| 124 | assert!(object::calendar(p.as_bytes(), &["VEVENT"]).is_ok(), "{p}"); |
| 125 | } |
| 126 | // With a UID from the content, as the server makes it, a file holding |
| 127 | // everything twice gives the same objects. |
| 128 | let mut by_content = |t: &str| format!("len-{}", t.len()); |
| 129 | let once = bundle::split_calendar(&file, &mut by_content, usize::MAX).unwrap(); |
| 130 | let twice = |
| 131 | bundle::split_calendar(&format!("{file}{file}"), &mut by_content, usize::MAX).unwrap(); |
| 132 | assert_eq!(twice, once); |
| 133 | } |
| 134 | |
| 135 | #[test] |
| 136 | fn escaped_tzid_finds_its_zone() { |
| 137 | let file = "BEGIN:VCALENDAR\r\nBEGIN:VTIMEZONE\r\nTZID:(UTC+02:00) Athens\\, Bucharest\r\nEND:VTIMEZONE\r\n\ |
| 138 | BEGIN:VEVENT\r\nUID:e\r\nDTSTART;TZID=\"(UTC+02:00) Athens, Bucharest\":20130412T150000\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; |
| 139 | let parts = bundle::split_calendar(file, &mut uids(), usize::MAX).unwrap(); |
| 140 | assert!(parts[0].contains("BEGIN:VTIMEZONE"), "{}", parts[0]); |
| 141 | assert_eq!( |
| 142 | bundle::calendar(&[&parts[0]], None, Detail::All) |
| 143 | .matches("BEGIN:VTIMEZONE") |
| 144 | .count(), |
| 145 | 1 |
| 146 | ); |
| 147 | } |
| 148 | |
| 149 | #[test] |
| 150 | fn import_splits_cards() { |
| 151 | let file = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:one\r\nFN:One\r\nEND:VCARD\r\n\ |
| 152 | BEGIN:VCARD\nVERSION:4.0\nFN:Two\nEND:VCARD\n"; |
| 153 | let cards = bundle::split_cards(file, &mut uids()); |
| 154 | assert_eq!(cards.len(), 2); |
| 155 | assert_eq!( |
| 156 | cards[0], |
| 157 | "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:one\r\nFN:One\r\nEND:VCARD\r\n" |
| 158 | ); |
| 159 | // Before END: vCard 4.0 wants VERSION right after BEGIN. |
| 160 | assert_eq!( |
| 161 | cards[1], |
| 162 | "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Two\r\nUID:new-1\r\nEND:VCARD\r\n" |
| 163 | ); |
| 164 | for c in &cards { |
| 165 | assert!(object::vcard(c.as_bytes()).unwrap().is_some()); |
| 166 | } |
| 167 | assert_eq!(bundle::cards(&[&cards[0], &cards[1]]), cards.concat()); |
| 168 | } |
| 169 | |
| 170 | #[test] |
| 171 | fn quoted_tzid_keeps_its_zone() { |
| 172 | let zone = "BEGIN:VTIMEZONE\r\nTZID:Work\\; late: shift\r\nBEGIN:STANDARD\r\nDTSTART:19700101T000000\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0100\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n"; |
| 173 | let file = format!( |
| 174 | "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{zone}BEGIN:VEVENT\r\nUID:a\r\nDTSTART;TZID=\"Work; late: shift\":20240101T100000\r\nRECURRENCE-ID;TZID=\"Work; late: shift\":20240101T100000\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" |
| 175 | ); |
| 176 | let objects = bundle::split_calendar(&file, &mut uids(), usize::MAX).unwrap(); |
| 177 | assert_eq!(objects.len(), 1); |
| 178 | assert!(objects[0].contains("TZID:Work\\; late: shift")); |
| 179 | } |
| 180 | |
| 181 | #[test] |
| 182 | fn calendar_names_itself() { |
| 183 | let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nX-WR-CALNAME:Ferien\\, Bayern\r\nX-APPLE-CALENDAR-COLOR:#FF2968FF\r\nBEGIN:VEVENT\r\nUID:a\r\nSUMMARY:x\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; |
| 184 | assert_eq!( |
| 185 | bundle::calendar_meta(ics), |
| 186 | (Some("Ferien, Bayern".into()), Some("#FF2968FF".into())) |
| 187 | ); |
| 188 | // RFC 7986 wins over the X- names, and a component's own NAME does not count. |
| 189 | let ics = ics.replace( |
| 190 | "VERSION:2.0\r\n", |
| 191 | "VERSION:2.0\r\nNAME:Holidays\r\nCOLOR:teal\r\n", |
| 192 | ); |
| 193 | assert_eq!( |
| 194 | bundle::calendar_meta(&ics), |
| 195 | (Some("Holidays".into()), Some("teal".into())) |
| 196 | ); |
| 197 | let bare = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:a\r\nNAME:not me\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; |
| 198 | assert_eq!(bundle::calendar_meta(bare), (None, None)); |
| 199 | } |
| 200 | |
| 201 | #[test] |
| 202 | fn byte_order_mark_is_ignored() { |
| 203 | let file = format!( |
| 204 | "\u{feff}{}", |
| 205 | object("BEGIN:VEVENT\r\nUID:a\r\nEND:VEVENT\r\n") |
| 206 | ); |
| 207 | assert_eq!( |
| 208 | bundle::split_calendar(&file, &mut uids(), usize::MAX) |
| 209 | .unwrap() |
| 210 | .len(), |
| 211 | 1 |
| 212 | ); |
| 213 | assert_eq!( |
| 214 | bundle::calendar_meta("\u{feff}BEGIN:VCALENDAR\r\nNAME:N\r\nEND:VCALENDAR\r\n") |
| 215 | .0 |
| 216 | .as_deref(), |
| 217 | Some("N") |
| 218 | ); |
| 219 | let card = "\u{feff}BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c\r\nFN:C\r\nEND:VCARD\r\n"; |
| 220 | assert_eq!(bundle::split_cards(card, &mut uids()).len(), 1); |
| 221 | // A stored object keeps its mark; feeds still show it. |
| 222 | assert!(bundle::calendar(&[&file], None, Detail::All).contains("UID:a")); |
| 223 | } |
| 224 | |
| 225 | #[test] |
| 226 | fn shared_uid_across_types_splits_and_empty_uid_is_missing() { |
| 227 | let file = object( |
| 228 | "BEGIN:VEVENT\r\nUID:x\r\nDTSTART:20240101T100000Z\r\nEND:VEVENT\r\n\ |
| 229 | BEGIN:VTODO\r\nUID:x\r\nSUMMARY:t\r\nEND:VTODO\r\n\ |
| 230 | BEGIN:VTODO\r\nUID:x\r\nRECURRENCE-ID:20240102T100000Z\r\nEND:VTODO\r\n\ |
| 231 | BEGIN:VEVENT\r\nUID:\r\nDTSTART:20240103T100000Z\r\nEND:VEVENT\r\n", |
| 232 | ); |
| 233 | let parts = bundle::split_calendar(&file, &mut uids(), usize::MAX).unwrap(); |
| 234 | assert_eq!(parts.len(), 3, "{parts:#?}"); |
| 235 | assert!(parts[0].contains("BEGIN:VEVENT\r\nUID:x\r\n")); |
| 236 | assert_eq!(parts[1].matches("UID:new-1\r\n").count(), 2, "{}", parts[1]); |
| 237 | assert!(!parts[1].contains("UID:x")); |
| 238 | assert!(parts[2].contains("UID:new-2\r\n")); |
| 239 | assert!(!parts[2].contains("UID:\r\n")); |
| 240 | for p in &parts { |
| 241 | assert!( |
| 242 | object::calendar(p.as_bytes(), &["VEVENT", "VTODO"]).is_ok(), |
| 243 | "{p}" |
| 244 | ); |
| 245 | } |
| 246 | let cards = bundle::split_cards( |
| 247 | "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:\r\nFN:C\r\nEND:VCARD\r\n", |
| 248 | &mut uids(), |
| 249 | ); |
| 250 | assert_eq!( |
| 251 | cards[0], |
| 252 | "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:C\r\nUID:new-1\r\nEND:VCARD\r\n" |
| 253 | ); |
| 254 | } |
| 255 | |
| 256 | #[test] |
| 257 | fn busy_time_writes_exdates_once_and_import_drops_cut_parts() { |
| 258 | let events: String = (0..2) |
| 259 | .map(|h| format!("BEGIN:VEVENT\r\nUID:m\r\nDTSTART:20240101T1{h}0000Z\r\nRRULE:FREQ=DAILY\r\nEND:VEVENT\r\n")) |
| 260 | .collect(); |
| 261 | let free = "BEGIN:VEVENT\r\nUID:m\r\nRECURRENCE-ID:20240102T100000Z\r\nDTSTART:20240102T100000Z\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\n"; |
| 262 | let busy = bundle::calendar(&[&object(&format!("{events}{free}"))], None, Detail::Busy); |
| 263 | assert_eq!(busy.matches("EXDATE").count(), 1, "{busy}"); |
| 264 | |
| 265 | let file = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:a\r\nDTSTART:20240101T100000Z\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:b\r\nSUMMARY:x\r\n"; |
| 266 | let parts = bundle::split_calendar(file, &mut uids(), usize::MAX).unwrap(); |
| 267 | assert_eq!(parts.len(), 1, "{parts:?}"); |
| 268 | assert!(parts[0].contains("UID:a")); |
| 269 | let cut = format!("{file}BEGIN:VALARM\r\nTRIGGER:-PT5M\r\nEND:VALARM\r\nEND:VCALENDAR\r\n"); |
| 270 | assert_eq!( |
| 271 | bundle::split_calendar(&cut, &mut uids(), usize::MAX) |
| 272 | .unwrap() |
| 273 | .len(), |
| 274 | 1 |
| 275 | ); |
| 276 | let cards = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:A\r\nEND:VCARD\r\nBEGIN:VCARD\r\nFN:B\r\n"; |
| 277 | assert_eq!(bundle::split_cards(cards, &mut uids()).len(), 1); |
| 278 | } |
| 279 | |
| 280 | #[test] |
| 281 | fn a_second_master_of_a_uid_gets_an_object_of_its_own() { |
| 282 | let file = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\ |
| 283 | BEGIN:VEVENT\r\nUID:m\r\nDTSTART:20240101T100000Z\r\nSUMMARY:one\r\nEND:VEVENT\r\n\ |
| 284 | BEGIN:VEVENT\r\nUID:m\r\nDTSTART:20240201T100000Z\r\nSUMMARY:two\r\nEND:VEVENT\r\n\ |
| 285 | BEGIN:VEVENT\r\nUID:m\r\nDTSTART:20240101T100000Z\r\nSUMMARY:one\r\nEND:VEVENT\r\n\ |
| 286 | END:VCALENDAR\r\n"; |
| 287 | let parts = bundle::split_calendar(file, &mut uids(), usize::MAX).unwrap(); |
| 288 | assert_eq!(parts.len(), 2, "{parts:?}"); |
| 289 | assert!(parts[0].contains("UID:m\r\n") && parts[0].contains("SUMMARY:one")); |
| 290 | assert!(parts[1].contains("UID:new-1\r\n") && parts[1].contains("SUMMARY:two")); |
| 291 | for p in &parts { |
| 292 | assert!(object::calendar(p.as_bytes(), &["VEVENT"]).is_ok(), "{p}"); |
| 293 | } |
| 294 | } |
| 295 | |
| 296 | #[test] |
| 297 | fn an_override_follows_the_master_that_has_its_instance() { |
| 298 | let file = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\ |
| 299 | BEGIN:VEVENT\r\nUID:m\r\nDTSTART:20240101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nSUMMARY:A\r\nEND:VEVENT\r\n\ |
| 300 | BEGIN:VEVENT\r\nUID:m\r\nRECURRENCE-ID:20240102T100000Z\r\nDTSTART:20240102T120000Z\r\nSUMMARY:A1\r\nEND:VEVENT\r\n\ |
| 301 | BEGIN:VEVENT\r\nUID:m\r\nDTSTART:20240201T090000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nSUMMARY:B\r\nEND:VEVENT\r\n\ |
| 302 | BEGIN:VEVENT\r\nUID:m\r\nRECURRENCE-ID:20240202T090000Z\r\nDTSTART:20240202T150000Z\r\nSUMMARY:B1\r\nEND:VEVENT\r\n\ |
| 303 | END:VCALENDAR\r\n"; |
| 304 | let parts = bundle::split_calendar(file, &mut uids(), usize::MAX).unwrap(); |
| 305 | assert_eq!(parts.len(), 2, "{parts:?}"); |
| 306 | assert!(parts[0].contains("SUMMARY:A1") && !parts[0].contains("SUMMARY:B")); |
| 307 | assert!(parts[1].contains("UID:new-1\r\n") && parts[1].contains("SUMMARY:B1")); |
| 308 | assert!(!parts[1].contains("UID:m\r\n"), "{}", parts[1]); |
| 309 | for p in &parts { |
| 310 | assert!(object::calendar(p.as_bytes(), &["VEVENT"]).is_ok(), "{p}"); |
| 311 | } |
| 312 | } |
| 313 | |
| 314 | #[test] |
| 315 | fn override_placement_reads_the_masters_own_time_zones() { |
| 316 | let file = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\ |
| 317 | BEGIN:VTIMEZONE\r\nTZID:My Zone\r\nBEGIN:STANDARD\r\nDTSTART:19700101T000000\r\n\ |
| 318 | TZOFFSETFROM:-1000\r\nTZOFFSETTO:-1000\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n\ |
| 319 | BEGIN:VEVENT\r\nUID:m\r\nDTSTART;TZID=My Zone:20240101T080000\r\nRRULE:FREQ=DAILY;COUNT=3\r\nSUMMARY:A\r\nEND:VEVENT\r\n\ |
| 320 | BEGIN:VEVENT\r\nUID:m\r\nDTSTART:20240101T120000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nSUMMARY:B\r\nEND:VEVENT\r\n\ |
| 321 | BEGIN:VEVENT\r\nUID:m\r\nRECURRENCE-ID:20240102T180000Z\r\nDTSTART:20240102T200000Z\r\nSUMMARY:A1\r\nEND:VEVENT\r\n\ |
| 322 | END:VCALENDAR\r\n"; |
| 323 | let parts = bundle::split_calendar(file, &mut uids(), usize::MAX).unwrap(); |
| 324 | let a = parts.iter().find(|p| p.contains("SUMMARY:A\r\n")).unwrap(); |
| 325 | assert!(a.contains("SUMMARY:A1"), "{parts:?}"); |
| 326 | } |
| 327 | |
| 328 | #[test] |
| 329 | fn a_free_this_and_future_override_frees_the_later_instances() { |
| 330 | let object = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\ |
| 331 | BEGIN:VEVENT\r\nUID:s\r\nDTSTART:20240101T100000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=5\r\nEND:VEVENT\r\n\ |
| 332 | BEGIN:VEVENT\r\nUID:s\r\nRECURRENCE-ID;RANGE=THISANDFUTURE:20240103T100000Z\r\n\ |
| 333 | DTSTART:20240103T100000Z\r\nDURATION:PT1H\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\n\ |
| 334 | END:VCALENDAR\r\n"; |
| 335 | let feed = bundle::calendar(&[object], None, Detail::Busy); |
| 336 | assert!(!feed.contains("EXDATE"), "{feed}"); |
| 337 | let cal = pimdav::calcard::icalendar::ICalendar::parse(&feed).unwrap(); |
| 338 | let range = "2024-01-01T00:00:00Z".parse().unwrap().."2024-01-10T00:00:00Z".parse().unwrap(); |
| 339 | let busy = pimdav::freebusy::busy(&cal, &range, &pimdav::zone::Zone::Utc, None); |
| 340 | assert_eq!(busy.len(), 2, "{busy:?}"); |
| 341 | } |
| 342 | |
| 343 | #[test] |
| 344 | fn feeds_skip_a_component_cut_off_before_its_end() { |
| 345 | let cut = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:cut\r\nSUMMARY:x\r\nEND:VCALENDAR\r\n"; |
| 346 | let fine = object("BEGIN:VEVENT\r\nUID:fine\r\nDTSTART:20240101T100000Z\r\nEND:VEVENT\r\n"); |
| 347 | let feed = bundle::calendar(&[cut, &fine], None, Detail::All); |
| 348 | assert!(!feed.contains("UID:cut"), "{feed}"); |
| 349 | assert!(feed.contains("UID:fine"), "{feed}"); |
| 350 | } |
| 351 | |
| 352 | #[test] |
| 353 | fn overrides_of_several_masters_are_placed_quickly() { |
| 354 | let mut file = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"); |
| 355 | for (summary, start) in [("A", "19700101T000000Z"), ("B", "19700101T000001Z")] { |
| 356 | file.push_str(&format!( |
| 357 | "BEGIN:VEVENT\r\nUID:m\r\nDTSTART:{start}\r\nRRULE:FREQ=SECONDLY;INTERVAL=2\r\nSUMMARY:{summary}\r\nEND:VEVENT\r\n" |
| 358 | )); |
| 359 | } |
| 360 | for n in 0..20 { |
| 361 | file.push_str(&format!( |
| 362 | "BEGIN:VEVENT\r\nUID:m\r\nRECURRENCE-ID:20240101T0000{:02}Z\r\nDTSTART:20240102T000000Z\r\nEND:VEVENT\r\n", |
| 363 | n * 2 + 1 |
| 364 | )); |
| 365 | } |
| 366 | file.push_str("END:VCALENDAR\r\n"); |
| 367 | let started = std::time::Instant::now(); |
| 368 | let parts = bundle::split_calendar(&file, &mut uids(), usize::MAX).unwrap(); |
| 369 | assert!( |
| 370 | started.elapsed().as_secs_f64() < 2.0, |
| 371 | "{:?}", |
| 372 | started.elapsed() |
| 373 | ); |
| 374 | // B started last before them. |
| 375 | assert_eq!(parts.len(), 2); |
| 376 | assert_eq!( |
| 377 | parts[1].matches("RECURRENCE-ID").count(), |
| 378 | 20, |
| 379 | "{}", |
| 380 | parts[1] |
| 381 | ); |
| 382 | } |
| 383 | |
| 384 | #[test] |
| 385 | fn a_busy_range_override_of_a_free_series_stays_busy() { |
| 386 | let object = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\ |
| 387 | BEGIN:VEVENT\r\nUID:s\r\nDTSTART:20240101T100000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=5\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\n\ |
| 388 | BEGIN:VEVENT\r\nUID:s\r\nRECURRENCE-ID;RANGE=THISANDFUTURE:20240103T100000Z\r\n\ |
| 389 | DTSTART:20240103T100000Z\r\nDURATION:PT1H\r\nTRANSP:OPAQUE\r\nEND:VEVENT\r\n\ |
| 390 | END:VCALENDAR\r\n"; |
| 391 | let feed = bundle::calendar(&[object], None, Detail::Busy); |
| 392 | let cal = pimdav::calcard::icalendar::ICalendar::parse(&feed).unwrap(); |
| 393 | let range = "2024-01-01T00:00:00Z".parse().unwrap().."2024-01-10T00:00:00Z".parse().unwrap(); |
| 394 | let busy = pimdav::freebusy::busy(&cal, &range, &pimdav::zone::Zone::Utc, None); |
| 395 | assert_eq!(busy.len(), 3, "{busy:?}\n{feed}"); |
| 396 | } |
| 397 |