protocol-caldav 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "bundler/setup"
4
- require "scampi"
5
3
  require "date"
6
4
 
7
5
  module Protocol
@@ -24,55 +22,88 @@ module Protocol
24
22
  def expand(dtstart:, rrule_value:, range_start:, range_end:, exdates: [], max_count: 10000)
25
23
  parts = parse_rrule(rrule_value)
26
24
  freq = parts['FREQ']
27
- return [dtstart] unless freq
28
-
29
- interval = (parts['INTERVAL'] || '1').to_i
30
- count = parts['COUNT']&.to_i
31
- until_time = parts['UNTIL'] ? parse_dt(parts['UNTIL']) : nil
32
- byday = parts['BYDAY']&.split(',')
33
- bymonthday = parts['BYMONTHDAY']&.split(',')&.map(&:to_i)
34
- bymonth = parts['BYMONTH']&.split(',')&.map(&:to_i)
35
-
36
- occurrences = []
37
- current = dtstart
38
- generated = 0
39
-
40
- loop do
41
- # Stop conditions
42
- break if until_time && current > until_time
43
- break if current > range_end
44
- break if count && generated >= count
45
- break if occurrences.length >= max_count
46
-
47
- candidates = expand_freq(freq, current, interval, byday, bymonthday, bymonth, dtstart)
48
-
49
- candidates.each do |candidate|
50
- break if until_time && candidate > until_time
51
- break if count && generated >= count
52
- break if occurrences.length >= max_count
53
-
54
- generated += 1
25
+ if freq
26
+ interval = (parts['INTERVAL'] || '1').to_i
27
+ count = parts['COUNT']&.to_i
28
+ if parts['UNTIL']
29
+ until_time = parse_dt(parts['UNTIL'])
30
+ else
31
+ until_time = nil
32
+ end
33
+ byday = parts['BYDAY']&.split(',')
34
+ bymonthday = parts['BYMONTHDAY']&.split(',')&.map(&:to_i)
35
+ bymonth = parts['BYMONTH']&.split(',')&.map(&:to_i)
36
+ occurrences = []
37
+ current = dtstart
38
+ generated = 0
39
+ loop do
40
+ # Stop conditions
41
+ if until_time && current > until_time
42
+ break
43
+ end
44
+ if current > range_end
45
+ break
46
+ end
47
+ if count && generated >= count
48
+ break
49
+ end
50
+ if occurrences.length >= max_count
51
+ break
52
+ end
55
53
 
56
- next if candidate < range_start && candidate < dtstart
57
- next if candidate >= range_end
58
- next if exdates.any? { |ex| times_equal?(candidate, ex) }
54
+ candidates = expand_freq(
55
+ freq,
56
+ current,
57
+ interval,
58
+ byday,
59
+ bymonthday,
60
+ bymonth,
61
+ dtstart,
62
+ )
63
+
64
+ candidates.each do |candidate|
65
+ if until_time && candidate > until_time
66
+ break
67
+ end
68
+ if count && generated >= count
69
+ break
70
+ end
71
+ if occurrences.length >= max_count
72
+ break
73
+ end
74
+
75
+ generated += 1
76
+
77
+ if candidate < range_start && candidate < dtstart
78
+ next
79
+ end
80
+ if candidate >= range_end
81
+ next
82
+ end
83
+ if exdates.any? { |ex| times_equal?(candidate, ex) }
84
+ next
85
+ end
86
+
87
+ if candidate >= range_start
88
+ occurrences << candidate
89
+ end
90
+ end
59
91
 
60
- occurrences << candidate if candidate >= range_start
92
+ current = advance(freq, current, interval)
61
93
  end
62
-
63
- current = advance(freq, current, interval)
94
+ occurrences.sort
95
+ else
96
+ [dtstart]
64
97
  end
65
-
66
- occurrences.sort
67
98
  end
68
99
 
69
100
  def parse_rrule(value)
70
- parts = {}
71
- value.split(';').each do |part|
72
- key, val = part.split('=', 2)
73
- parts[key.upcase] = val
101
+ {}.tap do |parts|
102
+ value.split(';').each do |part|
103
+ key, val = part.split('=', 2)
104
+ parts[key.upcase] = val
105
+ end
74
106
  end
75
- parts
76
107
  end
77
108
 
78
109
  def parse_dt(str)
@@ -81,7 +112,14 @@ module Protocol
81
112
  Time.utc(str[0..3].to_i, str[4..5].to_i, str[6..7].to_i)
82
113
  else
83
114
  s = str.chomp('Z')
84
- Time.utc(s[0..3].to_i, s[4..5].to_i, s[6..7].to_i, s[9..10].to_i, s[11..12].to_i, s[13..14].to_i)
115
+ Time.utc(
116
+ s[0..3].to_i,
117
+ s[4..5].to_i,
118
+ s[6..7].to_i,
119
+ s[9..10].to_i,
120
+ s[11..12].to_i,
121
+ s[13..14].to_i,
122
+ )
85
123
  end
86
124
  rescue ArgumentError
87
125
  nil
@@ -102,10 +140,19 @@ module Protocol
102
140
  week_start = current - (current.wday * 86400)
103
141
  byday.map do |day_str|
104
142
  wday = DAYS[day_str.gsub(/[^A-Z]/, '')]
105
- next nil unless wday
143
+ unless wday
144
+ next nil
145
+ end
106
146
  candidate = week_start + (wday * 86400)
107
147
  # Preserve time of day from dtstart
108
- Time.utc(candidate.year, candidate.month, candidate.day, dtstart.hour, dtstart.min, dtstart.sec)
148
+ Time.utc(
149
+ candidate.year,
150
+ candidate.month,
151
+ candidate.day,
152
+ dtstart.hour,
153
+ dtstart.min,
154
+ dtstart.sec,
155
+ )
109
156
  end.compact.select { |c| c >= current }.sort
110
157
  else
111
158
  [current]
@@ -114,9 +161,22 @@ module Protocol
114
161
  if bymonthday
115
162
  bymonthday.filter_map do |day|
116
163
  days_in_month = Date.new(current.year, current.month, -1).day
117
- actual_day = day > 0 ? day : days_in_month + day + 1
118
- next nil if actual_day < 1 || actual_day > days_in_month
119
- Time.utc(current.year, current.month, actual_day, dtstart.hour, dtstart.min, dtstart.sec)
164
+ if day > 0
165
+ actual_day = day
166
+ else
167
+ actual_day = days_in_month + day + 1
168
+ end
169
+ if actual_day < 1 || actual_day > days_in_month
170
+ next nil
171
+ end
172
+ Time.utc(
173
+ current.year,
174
+ current.month,
175
+ actual_day,
176
+ dtstart.hour,
177
+ dtstart.min,
178
+ dtstart.sec,
179
+ )
120
180
  end.sort
121
181
  elsif byday
122
182
  expand_byday_monthly(current, byday, dtstart)
@@ -126,7 +186,14 @@ module Protocol
126
186
  when 'YEARLY'
127
187
  if bymonth
128
188
  bymonth.map do |month|
129
- Time.utc(current.year, month, dtstart.day, dtstart.hour, dtstart.min, dtstart.sec)
189
+ Time.utc(
190
+ current.year,
191
+ month,
192
+ dtstart.day,
193
+ dtstart.hour,
194
+ dtstart.min,
195
+ dtstart.sec,
196
+ )
130
197
  end.sort
131
198
  else
132
199
  [current]
@@ -140,20 +207,52 @@ module Protocol
140
207
  results = []
141
208
  byday.each do |day_str|
142
209
  match = day_str.match(/^(-?\d+)?([A-Z]{2})$/)
143
- next unless match
210
+ unless match
211
+ next
212
+ end
144
213
  ordinal = match[1]&.to_i
145
214
  wday = DAYS[match[2]]
146
- next unless wday
215
+ unless wday
216
+ next
217
+ end
147
218
 
148
219
  if ordinal
149
- candidate = nth_weekday(current.year, current.month, wday, ordinal)
150
- results << Time.utc(candidate.year, candidate.month, candidate.day, dtstart.hour, dtstart.min, dtstart.sec) if candidate
220
+ candidate = nth_weekday(
221
+ current.year,
222
+ current.month,
223
+ wday,
224
+ ordinal,
225
+ )
226
+ if candidate
227
+ results << Time.utc(
228
+ candidate.year,
229
+ candidate.month,
230
+ candidate.day,
231
+ dtstart.hour,
232
+ dtstart.min,
233
+ dtstart.sec,
234
+ )
235
+ end
151
236
  else
152
237
  # All matching weekdays in the month
153
238
  (1..5).each do |n|
154
- candidate = nth_weekday(current.year, current.month, wday, n)
155
- break unless candidate
156
- results << Time.utc(candidate.year, candidate.month, candidate.day, dtstart.hour, dtstart.min, dtstart.sec)
239
+ candidate = nth_weekday(
240
+ current.year,
241
+ current.month,
242
+ wday,
243
+ n,
244
+ )
245
+ unless candidate
246
+ break
247
+ end
248
+ results << Time.utc(
249
+ candidate.year,
250
+ candidate.month,
251
+ candidate.day,
252
+ dtstart.hour,
253
+ dtstart.min,
254
+ dtstart.sec,
255
+ )
157
256
  end
158
257
  end
159
258
  end
@@ -195,205 +294,217 @@ module Protocol
195
294
  month = ((month - 1) % 12) + 1
196
295
  max_day = Date.new(year, month, -1).day
197
296
  day = [time.day, max_day].min
198
- Time.utc(year, month, day, time.hour, time.min, time.sec)
297
+ Time.utc(
298
+ year,
299
+ month,
300
+ day,
301
+ time.hour,
302
+ time.min,
303
+ time.sec,
304
+ )
199
305
  end
200
306
 
201
- private_class_method :parse_rrule, :parse_dt, :times_equal?, :expand_freq,
202
- :expand_byday_monthly, :nth_weekday, :advance, :advance_months
307
+ private_class_method :parse_rrule,
308
+ :parse_dt,
309
+ :times_equal?,
310
+ :expand_freq,
311
+ :expand_byday_monthly,
312
+ :nth_weekday,
313
+ :advance,
314
+ :advance_months
203
315
  end
204
316
  end
205
317
  end
206
318
  end
207
319
 
320
+ __END__
208
321
 
209
- test do
210
- describe "Protocol::Caldav::Ical::Rrule" do
211
- def expand(**kwargs)
212
- Protocol::Caldav::Ical::Rrule.expand(**kwargs)
213
- end
322
+ describe "Protocol::Caldav::Ical::Rrule" do
323
+ def expand(**kwargs)
324
+ Protocol::Caldav::Ical::Rrule.expand(**kwargs)
325
+ end
214
326
 
215
- it "FREQ=DAILY expands daily occurrences" do
216
- dtstart = Time.utc(2026, 1, 1, 9, 0, 0)
217
- result = expand(
218
- dtstart: dtstart,
219
- rrule_value: "FREQ=DAILY",
220
- range_start: Time.utc(2026, 1, 1),
221
- range_end: Time.utc(2026, 1, 4)
222
- )
223
- result.length.should.equal 3
224
- result[0].should.equal Time.utc(2026, 1, 1, 9, 0, 0)
225
- result[1].should.equal Time.utc(2026, 1, 2, 9, 0, 0)
226
- result[2].should.equal Time.utc(2026, 1, 3, 9, 0, 0)
227
- end
327
+ it "FREQ=DAILY expands daily occurrences" do
328
+ dtstart = Time.utc(2026, 1, 1, 9, 0, 0)
329
+ result = expand(
330
+ dtstart: dtstart,
331
+ rrule_value: "FREQ=DAILY",
332
+ range_start: Time.utc(2026, 1, 1),
333
+ range_end: Time.utc(2026, 1, 4)
334
+ )
335
+ result.length.should.equal 3
336
+ result[0].should.equal Time.utc(2026, 1, 1, 9, 0, 0)
337
+ result[1].should.equal Time.utc(2026, 1, 2, 9, 0, 0)
338
+ result[2].should.equal Time.utc(2026, 1, 3, 9, 0, 0)
339
+ end
228
340
 
229
- it "COUNT limits the total occurrences" do
230
- dtstart = Time.utc(2026, 1, 1, 9, 0, 0)
231
- result = expand(
232
- dtstart: dtstart,
233
- rrule_value: "FREQ=DAILY;COUNT=2",
234
- range_start: Time.utc(2026, 1, 1),
235
- range_end: Time.utc(2026, 12, 31)
236
- )
237
- result.length.should.equal 2
238
- end
341
+ it "COUNT limits the total occurrences" do
342
+ dtstart = Time.utc(2026, 1, 1, 9, 0, 0)
343
+ result = expand(
344
+ dtstart: dtstart,
345
+ rrule_value: "FREQ=DAILY;COUNT=2",
346
+ range_start: Time.utc(2026, 1, 1),
347
+ range_end: Time.utc(2026, 12, 31)
348
+ )
349
+ result.length.should.equal 2
350
+ end
239
351
 
240
- it "UNTIL stops at the given time" do
241
- dtstart = Time.utc(2026, 1, 1, 9, 0, 0)
242
- result = expand(
243
- dtstart: dtstart,
244
- rrule_value: "FREQ=DAILY;UNTIL=20260103T090000Z",
245
- range_start: Time.utc(2026, 1, 1),
246
- range_end: Time.utc(2026, 12, 31)
247
- )
248
- result.length.should.equal 3
249
- result.last.should.equal Time.utc(2026, 1, 3, 9, 0, 0)
250
- end
352
+ it "UNTIL stops at the given time" do
353
+ dtstart = Time.utc(2026, 1, 1, 9, 0, 0)
354
+ result = expand(
355
+ dtstart: dtstart,
356
+ rrule_value: "FREQ=DAILY;UNTIL=20260103T090000Z",
357
+ range_start: Time.utc(2026, 1, 1),
358
+ range_end: Time.utc(2026, 12, 31)
359
+ )
360
+ result.length.should.equal 3
361
+ result.last.should.equal Time.utc(2026, 1, 3, 9, 0, 0)
362
+ end
251
363
 
252
- it "EXDATE excludes specific dates" do
253
- dtstart = Time.utc(2026, 1, 1, 9, 0, 0)
254
- exdates = [Time.utc(2026, 1, 2, 9, 0, 0)]
255
- result = expand(
256
- dtstart: dtstart,
257
- rrule_value: "FREQ=DAILY;COUNT=3",
258
- range_start: Time.utc(2026, 1, 1),
259
- range_end: Time.utc(2026, 12, 31),
260
- exdates: exdates
261
- )
262
- result.length.should.equal 2
263
- result.should.not.include Time.utc(2026, 1, 2, 9, 0, 0)
264
- end
364
+ it "EXDATE excludes specific dates" do
365
+ dtstart = Time.utc(2026, 1, 1, 9, 0, 0)
366
+ exdates = [Time.utc(2026, 1, 2, 9, 0, 0)]
367
+ result = expand(
368
+ dtstart: dtstart,
369
+ rrule_value: "FREQ=DAILY;COUNT=3",
370
+ range_start: Time.utc(2026, 1, 1),
371
+ range_end: Time.utc(2026, 12, 31),
372
+ exdates: exdates
373
+ )
374
+ result.length.should.equal 2
375
+ result.should.not.include Time.utc(2026, 1, 2, 9, 0, 0)
376
+ end
265
377
 
266
- it "FREQ=WEEKLY with BYDAY expands on specified days" do
267
- dtstart = Time.utc(2026, 1, 5, 9, 0, 0) # Monday
268
- result = expand(
269
- dtstart: dtstart,
270
- rrule_value: "FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=6",
271
- range_start: Time.utc(2026, 1, 1),
272
- range_end: Time.utc(2026, 12, 31)
273
- )
274
- result.length.should.equal 6
275
- result[0].wday.should.equal 1 # Monday
276
- result[1].wday.should.equal 3 # Wednesday
277
- result[2].wday.should.equal 5 # Friday
278
- end
378
+ it "FREQ=WEEKLY with BYDAY expands on specified days" do
379
+ dtstart = Time.utc(2026, 1, 5, 9, 0, 0) # Monday
380
+ result = expand(
381
+ dtstart: dtstart,
382
+ rrule_value: "FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=6",
383
+ range_start: Time.utc(2026, 1, 1),
384
+ range_end: Time.utc(2026, 12, 31)
385
+ )
386
+ result.length.should.equal 6
387
+ result[0].wday.should.equal 1 # Monday
388
+ result[1].wday.should.equal 3 # Wednesday
389
+ result[2].wday.should.equal 5 # Friday
390
+ end
279
391
 
280
- it "FREQ=WEEKLY without BYDAY defaults to dtstart weekday" do
281
- dtstart = Time.utc(2026, 1, 7, 9, 0, 0) # Wednesday
282
- result = expand(
283
- dtstart: dtstart,
284
- rrule_value: "FREQ=WEEKLY;COUNT=3",
285
- range_start: Time.utc(2026, 1, 1),
286
- range_end: Time.utc(2026, 12, 31)
287
- )
288
- result.length.should.equal 3
289
- result.each { |t| t.wday.should.equal 3 }
290
- end
392
+ it "FREQ=WEEKLY without BYDAY defaults to dtstart weekday" do
393
+ dtstart = Time.utc(2026, 1, 7, 9, 0, 0) # Wednesday
394
+ result = expand(
395
+ dtstart: dtstart,
396
+ rrule_value: "FREQ=WEEKLY;COUNT=3",
397
+ range_start: Time.utc(2026, 1, 1),
398
+ range_end: Time.utc(2026, 12, 31)
399
+ )
400
+ result.length.should.equal 3
401
+ result.each { |t| t.wday.should.equal 3 }
402
+ end
291
403
 
292
- it "FREQ=MONTHLY with BYMONTHDAY" do
293
- dtstart = Time.utc(2026, 1, 15, 10, 0, 0)
294
- result = expand(
295
- dtstart: dtstart,
296
- rrule_value: "FREQ=MONTHLY;BYMONTHDAY=15;COUNT=3",
297
- range_start: Time.utc(2026, 1, 1),
298
- range_end: Time.utc(2026, 12, 31)
299
- )
300
- result.length.should.equal 3
301
- result[0].day.should.equal 15
302
- result[1].month.should.equal 2
303
- result[2].month.should.equal 3
304
- end
404
+ it "FREQ=MONTHLY with BYMONTHDAY" do
405
+ dtstart = Time.utc(2026, 1, 15, 10, 0, 0)
406
+ result = expand(
407
+ dtstart: dtstart,
408
+ rrule_value: "FREQ=MONTHLY;BYMONTHDAY=15;COUNT=3",
409
+ range_start: Time.utc(2026, 1, 1),
410
+ range_end: Time.utc(2026, 12, 31)
411
+ )
412
+ result.length.should.equal 3
413
+ result[0].day.should.equal 15
414
+ result[1].month.should.equal 2
415
+ result[2].month.should.equal 3
416
+ end
305
417
 
306
- it "FREQ=MONTHLY with BYDAY (second Monday)" do
307
- dtstart = Time.utc(2026, 1, 12, 9, 0, 0) # 2nd Monday of Jan 2026
308
- result = expand(
309
- dtstart: dtstart,
310
- rrule_value: "FREQ=MONTHLY;BYDAY=2MO;COUNT=3",
311
- range_start: Time.utc(2026, 1, 1),
312
- range_end: Time.utc(2026, 12, 31)
313
- )
314
- result.length.should.equal 3
315
- result.each { |t| t.wday.should.equal 1 } # all Mondays
316
- result[0].day.should.equal 12 # Jan
317
- result[1].day.should.equal 9 # Feb 2nd Monday
318
- end
418
+ it "FREQ=MONTHLY with BYDAY (second Monday)" do
419
+ dtstart = Time.utc(2026, 1, 12, 9, 0, 0) # 2nd Monday of Jan 2026
420
+ result = expand(
421
+ dtstart: dtstart,
422
+ rrule_value: "FREQ=MONTHLY;BYDAY=2MO;COUNT=3",
423
+ range_start: Time.utc(2026, 1, 1),
424
+ range_end: Time.utc(2026, 12, 31)
425
+ )
426
+ result.length.should.equal 3
427
+ result.each { |t| t.wday.should.equal 1 } # all Mondays
428
+ result[0].day.should.equal 12 # Jan
429
+ result[1].day.should.equal 9 # Feb 2nd Monday
430
+ end
319
431
 
320
- it "FREQ=YEARLY with BYMONTH" do
321
- dtstart = Time.utc(2026, 3, 15, 9, 0, 0)
322
- result = expand(
323
- dtstart: dtstart,
324
- rrule_value: "FREQ=YEARLY;BYMONTH=3,6;COUNT=4",
325
- range_start: Time.utc(2026, 1, 1),
326
- range_end: Time.utc(2028, 12, 31)
327
- )
328
- result.length.should.equal 4
329
- result[0].month.should.equal 3
330
- result[1].month.should.equal 6
331
- result[2].month.should.equal 3
332
- result[2].year.should.equal 2027
333
- end
432
+ it "FREQ=YEARLY with BYMONTH" do
433
+ dtstart = Time.utc(2026, 3, 15, 9, 0, 0)
434
+ result = expand(
435
+ dtstart: dtstart,
436
+ rrule_value: "FREQ=YEARLY;BYMONTH=3,6;COUNT=4",
437
+ range_start: Time.utc(2026, 1, 1),
438
+ range_end: Time.utc(2028, 12, 31)
439
+ )
440
+ result.length.should.equal 4
441
+ result[0].month.should.equal 3
442
+ result[1].month.should.equal 6
443
+ result[2].month.should.equal 3
444
+ result[2].year.should.equal 2027
445
+ end
334
446
 
335
- it "INTERVAL > 1 spaces occurrences" do
336
- dtstart = Time.utc(2026, 1, 1, 9, 0, 0)
337
- result = expand(
338
- dtstart: dtstart,
339
- rrule_value: "FREQ=DAILY;INTERVAL=3;COUNT=3",
340
- range_start: Time.utc(2026, 1, 1),
341
- range_end: Time.utc(2026, 12, 31)
342
- )
343
- result.length.should.equal 3
344
- result[0].day.should.equal 1
345
- result[1].day.should.equal 4
346
- result[2].day.should.equal 7
347
- end
447
+ it "INTERVAL > 1 spaces occurrences" do
448
+ dtstart = Time.utc(2026, 1, 1, 9, 0, 0)
449
+ result = expand(
450
+ dtstart: dtstart,
451
+ rrule_value: "FREQ=DAILY;INTERVAL=3;COUNT=3",
452
+ range_start: Time.utc(2026, 1, 1),
453
+ range_end: Time.utc(2026, 12, 31)
454
+ )
455
+ result.length.should.equal 3
456
+ result[0].day.should.equal 1
457
+ result[1].day.should.equal 4
458
+ result[2].day.should.equal 7
459
+ end
348
460
 
349
- it "negative BYMONTHDAY (-1 = last day of month)" do
350
- dtstart = Time.utc(2026, 1, 31, 9, 0, 0)
351
- result = expand(
352
- dtstart: dtstart,
353
- rrule_value: "FREQ=MONTHLY;BYMONTHDAY=-1;COUNT=3",
354
- range_start: Time.utc(2026, 1, 1),
355
- range_end: Time.utc(2026, 12, 31)
356
- )
357
- result.length.should.equal 3
358
- result[0].day.should.equal 31 # Jan
359
- result[1].day.should.equal 28 # Feb
360
- result[2].day.should.equal 31 # Mar
361
- end
461
+ it "negative BYMONTHDAY (-1 = last day of month)" do
462
+ dtstart = Time.utc(2026, 1, 31, 9, 0, 0)
463
+ result = expand(
464
+ dtstart: dtstart,
465
+ rrule_value: "FREQ=MONTHLY;BYMONTHDAY=-1;COUNT=3",
466
+ range_start: Time.utc(2026, 1, 1),
467
+ range_end: Time.utc(2026, 12, 31)
468
+ )
469
+ result.length.should.equal 3
470
+ result[0].day.should.equal 31 # Jan
471
+ result[1].day.should.equal 28 # Feb
472
+ result[2].day.should.equal 31 # Mar
473
+ end
362
474
 
363
- it "UNTIL with DATE-only format" do
364
- dtstart = Time.utc(2026, 1, 1, 9, 0, 0)
365
- result = expand(
366
- dtstart: dtstart,
367
- rrule_value: "FREQ=DAILY;UNTIL=20260104",
368
- range_start: Time.utc(2026, 1, 1),
369
- range_end: Time.utc(2026, 12, 31)
370
- )
371
- result.length.should.equal 3
372
- result.last.day.should.equal 3
373
- end
475
+ it "UNTIL with DATE-only format" do
476
+ dtstart = Time.utc(2026, 1, 1, 9, 0, 0)
477
+ result = expand(
478
+ dtstart: dtstart,
479
+ rrule_value: "FREQ=DAILY;UNTIL=20260104",
480
+ range_start: Time.utc(2026, 1, 1),
481
+ range_end: Time.utc(2026, 12, 31)
482
+ )
483
+ result.length.should.equal 3
484
+ result.last.day.should.equal 3
485
+ end
374
486
 
375
- it "COUNT + INTERVAL combined" do
376
- dtstart = Time.utc(2026, 1, 1, 9, 0, 0)
377
- result = expand(
378
- dtstart: dtstart,
379
- rrule_value: "FREQ=WEEKLY;INTERVAL=2;COUNT=3",
380
- range_start: Time.utc(2026, 1, 1),
381
- range_end: Time.utc(2026, 12, 31)
382
- )
383
- result.length.should.equal 3
384
- # Every 2 weeks: Jan 1, Jan 15, Jan 29
385
- (result[1] - result[0]).should.equal 14 * 86400
386
- end
487
+ it "COUNT + INTERVAL combined" do
488
+ dtstart = Time.utc(2026, 1, 1, 9, 0, 0)
489
+ result = expand(
490
+ dtstart: dtstart,
491
+ rrule_value: "FREQ=WEEKLY;INTERVAL=2;COUNT=3",
492
+ range_start: Time.utc(2026, 1, 1),
493
+ range_end: Time.utc(2026, 12, 31)
494
+ )
495
+ result.length.should.equal 3
496
+ # Every 2 weeks: Jan 1, Jan 15, Jan 29
497
+ (result[1] - result[0]).should.equal 14 * 86400
498
+ end
387
499
 
388
- it "returns just dtstart when no valid FREQ" do
389
- dtstart = Time.utc(2026, 6, 15, 10, 0, 0)
390
- result = expand(
391
- dtstart: dtstart,
392
- rrule_value: "BOGUS=STUFF",
393
- range_start: Time.utc(2026, 1, 1),
394
- range_end: Time.utc(2026, 12, 31)
395
- )
396
- result.should.equal [dtstart]
397
- end
500
+ it "returns just dtstart when no valid FREQ" do
501
+ dtstart = Time.utc(2026, 6, 15, 10, 0, 0)
502
+ result = expand(
503
+ dtstart: dtstart,
504
+ rrule_value: "BOGUS=STUFF",
505
+ range_start: Time.utc(2026, 1, 1),
506
+ range_end: Time.utc(2026, 12, 31)
507
+ )
508
+ result.should.equal [dtstart]
398
509
  end
399
510
  end