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("2020-01-01T00:00")..utc("2020-01-02T00:00"),
256 Zone::Utc,
257 );
258 assert!(out.truncated);
259}
260