expand.rs
⎇
Raw
1//! Cases the corpus oracle cannot check, and the bugs that made calcard's own
2//! expansion unusable.
3
4use chrono::{DateTime, Utc};
5use pimdav::calcard::icalendar::ICalendar;
6use pimdav::expand::expand;
7use pimdav::zone::Zone;
8
9fn utc(s: &str) -> DateTime<Utc> {
10 format!("{s}:00Z").parse().unwrap()
11}
12
13fn fmt(t: DateTime<Utc>) -> String {
14 t.format("%Y-%m-%dT%H:%M").to_string()
15}
16
17/// `start/end` pairs in UTC of the instances overlapping `lo..hi`.
18fn instances(body: &str, lo: &str, hi: &str) -> Vec<String> {
19 let ics = format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{body}END:VCALENDAR\r\n");
20 let cal = ICalendar::parse(&ics).unwrap();
21 let out = expand(&cal, utc(lo)..utc(hi), Zone::Utc);
22 assert!(!out.truncated);
23 out.instances
24 .iter()
25 .map(|i| format!("{}/{}", fmt(i.start), fmt(i.end)))
26 .collect()
27}
28
29fn event(uid: &str, props: &str) -> String {
30 format!("BEGIN:VEVENT\r\nUID:{uid}\r\n{props}END:VEVENT\r\n")
31}
32
33#[test]
34fn utc_until_east_of_utc_keeps_last_instance() {
35 let body = event(
36 "a",
37 "SEQUENCE:1\r\nDTSTART;TZID=Europe/Berlin:20190318T040000\r\nDTEND;TZID=Europe/Berlin:20190318T050000\r\nRRULE:FREQ=DAILY;UNTIL=20190320T030000Z\r\n",
38 ) + &event(
39 "a",
40 "SEQUENCE:2\r\nRECURRENCE-ID;TZID=Europe/Berlin:20190319T040000\r\nDTSTART;TZID=Europe/Berlin:20190319T100000\r\nDTEND;TZID=Europe/Berlin:20190319T110000\r\n",
41 );
42 // The override has a higher SEQUENCE than the master and still replaces
43 // the instance.
44 assert_eq!(
45 instances(&body, "2019-03-01T00:00", "2019-04-01T00:00"),
46 [
47 "2019-03-18T03:00/2019-03-18T04:00",
48 "2019-03-19T09:00/2019-03-19T10:00",
49 "2019-03-20T03:00/2019-03-20T04:00",
50 ]
51 );
52}
53
54#[test]
55fn rdate_is_deduplicated_and_excluded() {
56 let body = event(
57 "a",
58 "DTSTART:20240101T100000Z\r\nDTEND:20240101T110000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nRDATE:20240101T100000Z,20240110T100000Z\r\nEXDATE:20240110T100000Z\r\n",
59 );
60 assert_eq!(
61 instances(&body, "2024-01-01T00:00", "2024-02-01T00:00"),
62 [
63 "2024-01-01T10:00/2024-01-01T11:00",
64 "2024-01-02T10:00/2024-01-02T11:00",
65 "2024-01-03T10:00/2024-01-03T11:00",
66 ]
67 );
68}
69
70#[test]
71fn dst_gap_and_fold() {
72 let body = event(
73 "a",
74 "DTSTART;TZID=Europe/Berlin:20240330T023000\r\nDURATION:PT30M\r\nRRULE:FREQ=DAILY;COUNT=2\r\n",
75 ) + &event(
76 "b",
77 "DTSTART;TZID=Europe/Berlin:20241027T023000\r\nDURATION:PT30M\r\n",
78 );
79 // 02:30 on 03-31 does not exist and takes the offset before the gap.
80 // 02:30 on 10-27 happens twice and takes the first.
81 assert_eq!(
82 instances(&body, "2024-03-01T00:00", "2024-11-01T00:00"),
83 [
84 "2024-03-30T01:30/2024-03-30T02:00",
85 "2024-03-31T01:30/2024-03-31T02:00",
86 "2024-10-27T00:30/2024-10-27T01:00",
87 ]
88 );
89}
90
91#[test]
92fn duration_days_are_nominal_and_dtend_is_exact() {
93 let nominal = event(
94 "a",
95 "DTSTART;TZID=Europe/Berlin:20240330T120000\r\nDURATION:P1D\r\n",
96 );
97 let exact = event(
98 "a",
99 "DTSTART;TZID=Europe/Berlin:20240330T120000\r\nDTEND;TZID=Europe/Berlin:20240331T120000\r\nRRULE:FREQ=DAILY;COUNT=2\r\n",
100 );
101 assert_eq!(
102 instances(&nominal, "2024-03-01T00:00", "2024-04-01T00:00"),
103 ["2024-03-30T11:00/2024-03-31T10:00"]
104 );
105 // The second instance keeps the 23 hours of the first.
106 assert_eq!(
107 instances(&exact, "2024-03-01T00:00", "2024-04-02T00:00"),
108 [
109 "2024-03-30T11:00/2024-03-31T10:00",
110 "2024-03-31T10:00/2024-04-01T09:00",
111 ]
112 );
113}
114
115#[test]
116fn custom_vtimezone_rules() {
117 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()
118 + &event("a",
119 "DTSTART;TZID=Customized Time Zone:20240301T090000\r\nDURATION:PT1H\r\nRRULE:FREQ=WEEKLY;COUNT=2\r\n",
120 );
121 assert_eq!(
122 instances(&body, "2024-03-01T00:00", "2024-04-01T00:00"),
123 [
124 "2024-03-01T14:00/2024-03-01T15:00",
125 "2024-03-08T14:00/2024-03-08T15:00",
126 ]
127 );
128 let body = body.replace("20240301T090000", "20240315T090000");
129 assert_eq!(
130 instances(&body, "2024-03-01T00:00", "2024-04-01T00:00"),
131 [
132 "2024-03-15T13:00/2024-03-15T14:00",
133 "2024-03-22T13:00/2024-03-22T14:00",
134 ]
135 );
136}
137
138#[test]
139fn this_and_future_moves_later_instances() {
140 let body = event(
141 "a",
142 "DTSTART:20240101T100000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=5\r\n",
143 ) + &event(
144 "a",
145 "RECURRENCE-ID;RANGE=THISANDFUTURE:20240103T100000Z\r\nDTSTART:20240103T120000Z\r\nDURATION:PT2H\r\n",
146 ) + &event(
147 "a",
148 "RECURRENCE-ID:20240105T100000Z\r\nDTSTART:20240105T150000Z\r\nDURATION:PT1H\r\n",
149 );
150 assert_eq!(
151 instances(&body, "2024-01-01T00:00", "2024-02-01T00:00"),
152 [
153 "2024-01-01T10:00/2024-01-01T11:00",
154 "2024-01-02T10:00/2024-01-02T11:00",
155 "2024-01-03T12:00/2024-01-03T14:00",
156 "2024-01-04T12:00/2024-01-04T14:00",
157 "2024-01-05T15:00/2024-01-05T16:00",
158 ]
159 );
160}
161
162#[test]
163fn exdate_forms() {
164 // A DATE EXDATE removes the day of a DATE-TIME series. A floating EXDATE
165 // is read in the series zone. An override of an excluded instance goes too.
166 let body = event(
167 "a",
168 "DTSTART;TZID=Europe/Berlin:20240101T100000\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=4\r\nEXDATE;VALUE=DATE:20240102\r\nEXDATE:20240103T100000\r\n",
169 ) + &event(
170 "a",
171 "RECURRENCE-ID;TZID=Europe/Berlin:20240103T100000\r\nDTSTART;TZID=Europe/Berlin:20240103T110000\r\nDURATION:PT1H\r\n",
172 );
173 assert_eq!(
174 instances(&body, "2024-01-01T00:00", "2024-02-01T00:00"),
175 [
176 "2024-01-01T09:00/2024-01-01T10:00",
177 "2024-01-04T09:00/2024-01-04T10:00",
178 ]
179 );
180}
181
182#[test]
183fn window_overlap() {
184 let body = event(
185 "a",
186 "DTSTART;VALUE=DATE:20240101\r\nDTEND;VALUE=DATE:20240104\r\n",
187 ) + &event("b", "DTSTART:20240105T000000Z\r\n");
188 // The three-day event starts before the window and overlaps it. The
189 // zero-length one sits exactly on the window end.
190 assert_eq!(
191 instances(&body, "2024-01-03T00:00", "2024-01-05T00:00"),
192 ["2024-01-01T00:00/2024-01-04T00:00"]
193 );
194}
195
196#[test]
197fn todo_uses_due() {
198 let body = "BEGIN:VTODO\r\nUID:t\r\nDTSTART:20240101T100000Z\r\nDUE:20240101T120000Z\r\nRRULE:FREQ=WEEKLY;COUNT=2\r\nEND:VTODO\r\n";
199 assert_eq!(
200 instances(body, "2024-01-01T00:00", "2024-02-01T00:00"),
201 [
202 "2024-01-01T10:00/2024-01-01T12:00",
203 "2024-01-08T10:00/2024-01-08T12:00",
204 ]
205 );
206}
207
208#[test]
209fn mixed_numbered_and_plain_weekdays() {
210 let body = event(
211 "a",
212 "DTSTART:20240102T100000Z\r\nRRULE:FREQ=MONTHLY;COUNT=4;BYDAY=1MO,TU\r\n",
213 );
214 assert_eq!(
215 instances(&body, "2024-01-01T00:00", "2024-02-01T00:00"),
216 [
217 "2024-01-02T10:00/2024-01-02T10:00",
218 "2024-01-09T10:00/2024-01-09T10:00",
219 "2024-01-16T10:00/2024-01-16T10:00",
220 "2024-01-23T10:00/2024-01-23T10:00",
221 ]
222 );
223}
224
225#[test]
226fn unsupported_rules_leave_dtstart_only() {
227 for rule in [
228 "FREQ=MONTHLY;COUNT=3;BYMONTH=13",
229 "FREQ=YEARLY;COUNT=3;BYMONTH=5L",
230 "RSCALE=CHINESE;FREQ=YEARLY;COUNT=3",
231 "RSCALE=GREGORIAN;FREQ=YEARLY;SKIP=FORWARD;COUNT=3",
232 "FREQ=YEARLY;UNTIL=20243456T000000Z",
233 ] {
234 let body = event(
235 "a",
236 &format!("DTSTART:20240101T100000Z\r\nRRULE:{rule}\r\n"),
237 );
238 assert_eq!(
239 instances(&body, "2024-01-01T00:00", "2030-01-01T00:00"),
240 ["2024-01-01T10:00/2024-01-01T10:00"],
241 "{rule}"
242 );
243 }
244}
245
246#[test]
247fn runaway_rule_is_truncated() {
248 let ics = format!(
249 "BEGIN:VCALENDAR\r\n{}END:VCALENDAR\r\n",
250 event("a", "DTSTART:20000101T000000Z\r\nRRULE:FREQ=MINUTELY\r\n")
251 );
252 let cal = ICalendar::parse(&ics).unwrap();
253 let out = expand(
254 &cal,
255 utc("2000-01-01T00:00")..utc("2020-01-01T00:00"),
256 Zone::Utc,
257 );
258 assert!(out.truncated);
259}
260
261#[test]
262fn old_sub_daily_rule_skips_ahead() {
263 let body = event(
264 "a",
265 "DTSTART:20200101T000000Z\r\nDTEND:20200101T000030Z\r\nRRULE:FREQ=MINUTELY;INTERVAL=7\r\n",
266 );
267 let got = instances(&body, "2026-03-01T10:00", "2026-03-01T10:20");
268 assert_eq!(got.len(), 3, "{got:?}");
269 assert!(got.iter().all(|g| {
270 let t = utc(&g[..16]);
271 (t - utc("2020-01-01T00:00")).num_minutes() % 7 == 0
272 }));
273}
274
275#[test]
276fn sub_daily_rule_keeps_its_grid_across_hour_limits() {
277 let body = event(
278 "a",
279 "DTSTART:20200101T093000Z\r\nRRULE:FREQ=MINUTELY;INTERVAL=7;BYHOUR=9;BYMINUTE=0,30\r\n",
280 );
281 assert_eq!(
282 instances(&body, "2026-03-25T00:00", "2026-04-09T00:00"),
283 [
284 "2026-03-25T09:30/2026-03-25T09:30",
285 "2026-03-31T09:00/2026-03-31T09:00",
286 "2026-04-01T09:30/2026-04-01T09:30",
287 "2026-04-07T09:00/2026-04-07T09:00",
288 "2026-04-08T09:30/2026-04-08T09:30",
289 ]
290 );
291 // COUNT counts the instances inside the hour limit only.
292 let counted = event(
293 "a",
294 "DTSTART:20260324T095000Z\r\nRRULE:FREQ=MINUTELY;INTERVAL=7;BYHOUR=9;COUNT=3\r\n",
295 );
296 assert_eq!(
297 instances(&counted, "2026-03-24T00:00", "2026-03-27T00:00"),
298 [
299 "2026-03-24T09:50/2026-03-24T09:50",
300 "2026-03-24T09:57/2026-03-24T09:57",
301 "2026-03-25T09:03/2026-03-25T09:03",
302 ]
303 );
304}
305
306#[test]
307fn old_biweekly_rule_keeps_its_grid() {
308 // 2026-03-02 is 321 weeks after DTSTART, so the week of 03-09 is the next one in the grid.
309 let body = event(
310 "a",
311 "DTSTART:20200106T090000Z\r\nDTEND:20200106T100000Z\r\nRRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE\r\n",
312 );
313 assert_eq!(
314 instances(&body, "2026-03-01T00:00", "2026-03-20T00:00"),
315 [
316 "2026-03-09T09:00/2026-03-09T10:00",
317 "2026-03-11T09:00/2026-03-11T10:00",
318 ]
319 );
320}
321
322#[test]
323fn utc_until_on_an_all_day_series_keeps_its_day() {
324 let body = event(
325 "a",
326 "DTSTART;VALUE=DATE:20240108\r\nRRULE:FREQ=DAILY;UNTIL=20240110T000000Z\r\n",
327 );
328 let ics = format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{body}END:VCALENDAR\r\n");
329 let cal = ICalendar::parse(&ics).unwrap();
330 let west = Zone::Iana(chrono_tz::America::New_York);
331 let out = expand(&cal, utc("2024-01-01T00:00")..utc("2024-02-01T00:00"), west);
332 assert_eq!(out.instances.len(), 3);
333}
334
335#[test]
336fn dtstart_is_the_first_of_count() {
337 // A Tuesday, with a rule for Mondays.
338 let body = event(
339 "a",
340 "DTSTART:20260106T100000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=3\r\n",
341 );
342 let got = instances(&body, "2026-01-01T00:00", "2026-03-01T00:00");
343 assert_eq!(got.len(), 3, "{got:?}");
344 assert_eq!(got[0], "2026-01-06T10:00/2026-01-06T11:00");
345}
346
347#[test]
348fn instance_for_finds_instances_moved_by_this_and_future() {
349 let body = event(
350 "a",
351 "DTSTART:20240101T100000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=5\r\n",
352 ) + &event(
353 "a",
354 "RECURRENCE-ID;RANGE=THISANDFUTURE:20240103T100000Z\r\nDTSTART:20240103T120000Z\r\nDURATION:PT2H\r\n",
355 );
356 let ics = format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{body}END:VCALENDAR\r\n");
357 let cal = ICalendar::parse(&ics).unwrap();
358 let rid = Some(utc("2024-01-04T10:00"));
359 let index = pimdav::view::component_for(&cal, rid, &Zone::Utc).unwrap();
360 let i = pimdav::view::instance_for(&cal, index, rid, &Zone::Utc).unwrap();
361 assert_eq!(
362 (fmt(i.start), fmt(i.end)),
363 ("2024-01-04T12:00".into(), "2024-01-04T14:00".into())
364 );
365}
366
367#[test]
368fn instance_for_finds_an_instance_moved_across_dst() {
369 let body = event(
370 "a",
371 "DTSTART;TZID=Europe/Berlin:20240328T100000\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=6\r\n",
372 ) + &event(
373 "a",
374 "RECURRENCE-ID;RANGE=THISANDFUTURE;TZID=Europe/Berlin:20240330T100000\r\nDTSTART;TZID=Europe/Berlin:20240331T100000\r\nDURATION:PT1H\r\n",
375 );
376 let ics = format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{body}END:VCALENDAR\r\n");
377 let cal = ICalendar::parse(&ics).unwrap();
378 // The instance of the 31st, 10:00, moved to the 1st, 10:00.
379 let rid = Some(utc("2024-03-31T08:00"));
380 let index = pimdav::view::component_for(&cal, rid, &Zone::Utc).unwrap();
381 let i = pimdav::view::instance_for(&cal, index, rid, &Zone::Utc).unwrap();
382 assert_eq!(fmt(i.start), "2024-04-01T08:00");
383}
384
385#[test]
386fn rdate_period_on_dtstart_keeps_its_length() {
387 let body = event(
388 "a",
389 "DTSTART:20260601T100000Z\r\nDTEND:20260601T110000Z\r\nRDATE;VALUE=PERIOD:20260601T100000Z/PT5H\r\n",
390 );
391 assert_eq!(
392 instances(&body, "2026-06-01T00:00", "2026-06-02T00:00"),
393 ["2026-06-01T10:00/2026-06-01T15:00"]
394 );
395}
396
397#[test]
398fn a_rule_that_never_matches_stops_early() {
399 let body = event(
400 "a",
401 "DTSTART:20260601T100000Z\r\nRRULE:FREQ=DAILY;BYMONTH=2;BYMONTHDAY=30\r\n",
402 );
403 let t = std::time::Instant::now();
404 assert_eq!(
405 instances(&body, "2026-06-01T00:00", "2026-07-01T00:00"),
406 ["2026-06-01T10:00/2026-06-01T10:00"]
407 );
408 // Without the period cap it runs on to year 9999, seconds in a debug build.
409 assert!(t.elapsed().as_millis() < 1000, "{:?}", t.elapsed());
410}
411
412#[test]
413fn a_this_and_future_move_across_dst_keeps_the_local_time() {
414 // From the 30th on, one day later. The move crosses the DST change, so in
415 // UTC it is 23 hours, but each later instance still starts at 10:00.
416 let body = event(
417 "a",
418 "DTSTART;TZID=Europe/Berlin:20240328T100000\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=6\r\n",
419 ) + &event(
420 "a",
421 "RECURRENCE-ID;RANGE=THISANDFUTURE;TZID=Europe/Berlin:20240330T100000\r\nDTSTART;TZID=Europe/Berlin:20240331T100000\r\nDURATION:PT1H\r\n",
422 );
423 assert_eq!(
424 instances(&body, "2024-03-28T00:00", "2024-04-10T00:00"),
425 [
426 "2024-03-28T09:00/2024-03-28T10:00",
427 "2024-03-29T09:00/2024-03-29T10:00",
428 "2024-03-31T08:00/2024-03-31T09:00",
429 "2024-04-01T08:00/2024-04-01T09:00",
430 "2024-04-02T08:00/2024-04-02T09:00",
431 "2024-04-03T08:00/2024-04-03T09:00",
432 ]
433 );
434}
435
436#[test]
437fn the_rules_of_one_series_share_one_budget() {
438 // Each rule alone stays under the cap; together they pass it.
439 let body = event(
440 "a",
441 "DTSTART:20260601T000000Z\r\nRRULE:FREQ=SECONDLY\r\nRRULE:FREQ=SECONDLY;BYSECOND=0,10,20,30,40,50\r\n",
442 );
443 let ics = format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{body}END:VCALENDAR\r\n");
444 let cal = ICalendar::parse(&ics).unwrap();
445 // 11 days with the slack: 950,400 plus 95,040 occurrences.
446 let days = utc("2026-06-01T00:00")..utc("2026-06-11T00:00");
447 assert!(expand(&cal, days, Zone::Utc).truncated);
448}
449
450#[test]
451fn bysetpos_gives_the_same_instances_in_any_window() {
452 // The second of Mon, Wed, Fri in each week is the Wednesday.
453 let body = event(
454 "a",
455 "DTSTART:20240103T100000Z\r\nDTEND:20240103T110000Z\r\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;BYSETPOS=2\r\n",
456 );
457 let all = instances(&body, "2024-01-01T00:00", "2024-04-01T00:00");
458 for day in 1..=28 {
459 for hour in [0, 9, 10, 12] {
460 let lo = format!("2024-02-{day:02}T{hour:02}:00");
461 let hi = format!("2024-03-{day:02}T00:00");
462 let part = instances(&body, &lo, &hi);
463 let want: Vec<_> = all
464 .iter()
465 .filter(|i| i.split('/').nth(1).unwrap() > lo.as_str() && i.as_str() < hi.as_str())
466 .cloned()
467 .collect();
468 assert_eq!(part, want, "{lo}");
469 }
470 }
471}
472
473#[test]
474fn unused_time_zones_cost_nothing() {
475 let zone = "BEGIN:VTIMEZONE\r\nTZID:Never/{n}\r\nBEGIN:STANDARD\r\nDTSTART:19700101T000000\r\n\
476 TZOFFSETFROM:+0100\r\nTZOFFSETTO:+0100\r\nRRULE:FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=30\r\n\
477 END:STANDARD\r\nEND:VTIMEZONE\r\n";
478 let zones: String = (0..300)
479 .map(|n| zone.replace("{n}", &n.to_string()))
480 .collect();
481 let body = zones
482 + &event(
483 "a",
484 "DTSTART:20240101T100000Z\r\nDTEND:20240101T110000Z\r\n",
485 );
486 let t = std::time::Instant::now();
487 assert_eq!(
488 instances(&body, "2024-01-01T00:00", "2024-01-02T00:00"),
489 ["2024-01-01T10:00/2024-01-01T11:00"]
490 );
491 assert!(t.elapsed().as_secs() < 2, "{:?}", t.elapsed());
492}
493
494#[test]
495fn a_huge_duration_does_not_expand_an_endless_rule_to_the_cap() {
496 let body = event(
497 "a",
498 "DTSTART:20260101T100000Z\r\nDURATION:P4294967295W\r\nRRULE:FREQ=DAILY\r\n",
499 );
500 let ics = format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{body}END:VCALENDAR\r\n");
501 let cal = ICalendar::parse(&ics).unwrap();
502 let t = std::time::Instant::now();
503 let out = expand(
504 &cal,
505 utc("2026-06-01T00:00")..utc("2026-06-02T00:00"),
506 Zone::Utc,
507 );
508 assert!(!out.truncated);
509 assert!(!out.instances.is_empty());
510 assert!(t.elapsed().as_millis() < 500, "{:?}", t.elapsed());
511}
512
513#[test]
514fn instances_longer_than_a_year_are_found_inside() {
515 let window = utc("2026-03-01T00:00")..utc("2026-03-02T00:00");
516 for (props, starts) in [
517 (
518 "DTSTART;VALUE=DATE:20250101\r\nDTEND;VALUE=DATE:20270101\r\n",
519 "2025-01-01T00:00",
520 ),
521 (
522 "DTSTART;VALUE=DATE:20250101\r\nDTEND;VALUE=DATE:20270101\r\nRRULE:FREQ=YEARLY;INTERVAL=3\r\n",
523 "2025-01-01T00:00",
524 ),
525 ] {
526 let ics = format!(
527 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{}END:VCALENDAR\r\n",
528 event("a", props)
529 );
530 let out = expand(&ICalendar::parse(&ics).unwrap(), window.clone(), Zone::Utc);
531 let found: Vec<_> = out.instances.iter().map(|i| i.start).collect();
532 assert_eq!(found, [utc(starts)], "{props}");
533 }
534}
535