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,13 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "bundler/setup"
4
- require "scampi"
5
3
  require "protocol/caldav"
6
4
 
7
5
  module Protocol
8
6
  module Caldav
9
7
  module Ical
10
- Component = Struct.new(:name, :properties, :components, keyword_init: true) do
8
+ Component = Struct.new(
9
+ :name,
10
+ :properties,
11
+ :components,
12
+ keyword_init: true,
13
+ ) do
11
14
  def initialize(name:, properties: [], components: [])
12
15
  super(name: name, properties: properties, components: components)
13
16
  end
@@ -28,57 +31,57 @@ module Protocol
28
31
  end
29
32
  end
30
33
 
31
- test do
32
- describe "Protocol::Caldav::Ical::Component" do
33
- def prop(name, value, params: {})
34
- Protocol::Caldav::Ical::Property.new(name: name, params: params, value: value)
35
- end
34
+ __END__
36
35
 
37
- def comp(name, properties: [], components: [])
38
- Protocol::Caldav::Ical::Component.new(name: name, properties: properties, components: components)
39
- end
36
+ describe "Protocol::Caldav::Ical::Component" do
37
+ def prop(name, value, params: {})
38
+ Protocol::Caldav::Ical::Property.new(name: name, params: params, value: value)
39
+ end
40
40
 
41
- it "find_property returns the first property with that name" do
42
- c = comp("VEVENT", properties: [prop("SUMMARY", "First"), prop("SUMMARY", "Second")])
43
- c.find_property("SUMMARY").value.should.equal "First"
44
- end
41
+ def comp(name, properties: [], components: [])
42
+ Protocol::Caldav::Ical::Component.new(name: name, properties: properties, components: components)
43
+ end
45
44
 
46
- it "find_property is case-insensitive" do
47
- c = comp("VEVENT", properties: [prop("SUMMARY", "Hello")])
48
- c.find_property("summary").value.should.equal "Hello"
49
- end
45
+ it "find_property returns the first property with that name" do
46
+ c = comp("VEVENT", properties: [prop("SUMMARY", "First"), prop("SUMMARY", "Second")])
47
+ c.find_property("SUMMARY").value.should.equal "First"
48
+ end
50
49
 
51
- it "find_property returns nil when absent" do
52
- c = comp("VEVENT", properties: [])
53
- c.find_property("SUMMARY").should.be.nil
54
- end
50
+ it "find_property is case-insensitive" do
51
+ c = comp("VEVENT", properties: [prop("SUMMARY", "Hello")])
52
+ c.find_property("summary").value.should.equal "Hello"
53
+ end
55
54
 
56
- it "find_components returns all matching children" do
57
- c = comp("VCALENDAR", components: [comp("VEVENT"), comp("VTODO"), comp("VEVENT")])
58
- c.find_components("VEVENT").length.should.equal 2
59
- end
55
+ it "find_property returns nil when absent" do
56
+ c = comp("VEVENT", properties: [])
57
+ c.find_property("SUMMARY").should.be.nil
58
+ end
60
59
 
61
- it "find_components returns empty array when none match" do
62
- c = comp("VCALENDAR", components: [comp("VEVENT")])
63
- c.find_components("VTODO").should.equal []
64
- end
60
+ it "find_components returns all matching children" do
61
+ c = comp("VCALENDAR", components: [comp("VEVENT"), comp("VTODO"), comp("VEVENT")])
62
+ c.find_components("VEVENT").length.should.equal 2
63
+ end
65
64
 
66
- it "find_components does not recurse into grandchildren" do
67
- inner = comp("VALARM")
68
- vevent = comp("VEVENT", components: [inner])
69
- vcal = comp("VCALENDAR", components: [vevent])
70
- vcal.find_components("VALARM").should.equal []
71
- end
65
+ it "find_components returns empty array when none match" do
66
+ c = comp("VCALENDAR", components: [comp("VEVENT")])
67
+ c.find_components("VTODO").should.equal []
68
+ end
72
69
 
73
- it "a component with no properties or sub-components is valid" do
74
- c = comp("VEVENT")
75
- c.properties.should.equal []
76
- c.components.should.equal []
77
- end
70
+ it "find_components does not recurse into grandchildren" do
71
+ inner = comp("VALARM")
72
+ vevent = comp("VEVENT", components: [inner])
73
+ vcal = comp("VCALENDAR", components: [vevent])
74
+ vcal.find_components("VALARM").should.equal []
75
+ end
78
76
 
79
- it "find_all_properties returns all matching" do
80
- c = comp("VEVENT", properties: [prop("ATTENDEE", "a"), prop("SUMMARY", "x"), prop("ATTENDEE", "b")])
81
- c.find_all_properties("ATTENDEE").length.should.equal 2
82
- end
77
+ it "a component with no properties or sub-components is valid" do
78
+ c = comp("VEVENT")
79
+ c.properties.should.equal []
80
+ c.components.should.equal []
81
+ end
82
+
83
+ it "find_all_properties returns all matching" do
84
+ c = comp("VEVENT", properties: [prop("ATTENDEE", "a"), prop("SUMMARY", "x"), prop("ATTENDEE", "b")])
85
+ c.find_all_properties("ATTENDEE").length.should.equal 2
83
86
  end
84
87
  end
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "bundler/setup"
4
- require "scampi"
5
-
6
3
  require "protocol/caldav"
7
4
 
8
5
  module Protocol
@@ -15,62 +12,69 @@ module Protocol
15
12
  # Returns a serialized VCALENDAR string with RRULE removed
16
13
  # and each occurrence as a separate VEVENT with RECURRENCE-ID.
17
14
  def expand(component, range_start:, range_end:, max_occurrences: 10000)
18
- return serialize_component(component) unless component.name.casecmp?('VCALENDAR')
19
-
20
- # Find the base VEVENT with RRULE
21
- base = component.find_components('VEVENT').find { |v| v.find_property('RRULE') }
22
- return serialize_component(component) unless base
23
-
24
- # Collect override VEVENTs (same UID, has RECURRENCE-ID)
25
- uid = base.find_property('UID')&.value
26
- overrides = {}
27
- component.find_components('VEVENT').each do |v|
28
- rid = v.find_property('RECURRENCE-ID')
29
- if rid && v.find_property('UID')&.value == uid
30
- rid_time = Filter::Match.send(:parse_datetime_string, rid.value.strip)
31
- overrides[rid_time] = v if rid_time
32
- end
33
- end
34
-
35
- dtstart_prop = base.find_property('DTSTART')
36
- dtstart = Filter::Match.send(:parse_datetime_string, dtstart_prop.value.strip)
37
- return serialize_component(component) unless dtstart
38
-
39
- dtend = Filter::Match.send(:parse_ical_datetime, base, 'DTEND')
40
- duration = dtend ? (dtend - dtstart).to_i : 3600
41
-
42
- rrule = base.find_property('RRULE').value.strip
43
- exdates = base.find_all_properties('EXDATE').filter_map do |ex|
44
- Filter::Match.send(:parse_datetime_string, ex.value.strip)
45
- end
46
-
47
- occurrences = Rrule.expand(
48
- dtstart: dtstart,
49
- rrule_value: rrule,
50
- range_start: range_start,
51
- range_end: range_end,
52
- exdates: exdates,
53
- max_count: max_occurrences
54
- )
55
-
56
- instances = occurrences.map do |occ_start|
57
- override = overrides[occ_start]
58
- if override
59
- serialize_vevent_instance(override, occ_start)
15
+ if component.name.casecmp?('VCALENDAR')
16
+ base = component.find_components('VEVENT').find { |v| v.find_property('RRULE') }
17
+ if base
18
+ uid = base.find_property('UID')&.value
19
+ overrides = {}
20
+ component.find_components('VEVENT').each do |v|
21
+ rid = v.find_property('RECURRENCE-ID')
22
+ if rid && v.find_property('UID')&.value == uid
23
+ rid_time = Filter::Match.send(:parse_datetime_string, rid.value.strip)
24
+ if rid_time
25
+ overrides[rid_time] = v
26
+ end
27
+ end
28
+ end
29
+ dtstart_prop = base.find_property('DTSTART')
30
+ dtstart = Filter::Match.send(:parse_datetime_string, dtstart_prop.value.strip)
31
+ if dtstart
32
+ dtend = Filter::Match.send(:parse_ical_datetime, base, 'DTEND')
33
+ if dtend
34
+ duration = (dtend - dtstart).to_i
35
+ else
36
+ duration = 3600
37
+ end
38
+ rrule = base.find_property('RRULE').value.strip
39
+ exdates = base.find_all_properties('EXDATE').filter_map do |ex|
40
+ Filter::Match.send(:parse_datetime_string, ex.value.strip)
41
+ end
42
+ occurrences = Rrule.expand(
43
+ dtstart: dtstart,
44
+ rrule_value: rrule,
45
+ range_start: range_start,
46
+ range_end: range_end,
47
+ exdates: exdates,
48
+ max_count: max_occurrences,
49
+ )
50
+ instances = occurrences.map do |occ_start|
51
+ override = overrides[occ_start]
52
+ if override
53
+ serialize_vevent_instance(override, occ_start)
54
+ else
55
+ serialize_expanded_instance(base, occ_start, duration)
56
+ end
57
+ end
58
+ overrides.each do |rid_time, override_vevent|
59
+ if occurrences.any? { |o| Rrule.send(:times_equal?, o, rid_time) }
60
+ next
61
+ end
62
+ odt = Filter::Match.send(:parse_ical_datetime, override_vevent, 'DTSTART')
63
+ unless odt && odt >= range_start && odt < range_end
64
+ next
65
+ end
66
+ instances << serialize_vevent_instance(override_vevent, rid_time)
67
+ end
68
+ "BEGIN:VCALENDAR\r\n#{instances.join}END:VCALENDAR\r\n"
69
+ else
70
+ serialize_component(component)
71
+ end
60
72
  else
61
- serialize_expanded_instance(base, occ_start, duration)
73
+ serialize_component(component)
62
74
  end
75
+ else
76
+ serialize_component(component)
63
77
  end
64
-
65
- # Also include overrides that aren't in the base RRULE expansion
66
- overrides.each do |rid_time, override_vevent|
67
- next if occurrences.any? { |o| Rrule.send(:times_equal?, o, rid_time) }
68
- odt = Filter::Match.send(:parse_ical_datetime, override_vevent, 'DTSTART')
69
- next unless odt && odt >= range_start && odt < range_end
70
- instances << serialize_vevent_instance(override_vevent, rid_time)
71
- end
72
-
73
- "BEGIN:VCALENDAR\r\n#{instances.join}END:VCALENDAR\r\n"
74
78
  end
75
79
 
76
80
  def serialize_expanded_instance(base, occ_start, duration)
@@ -82,7 +86,9 @@ module Protocol
82
86
  lines << "DTEND:#{format_utc(occ_end)}"
83
87
 
84
88
  base.properties.each do |prop|
85
- next if %w[DTSTART DTEND RRULE EXDATE RDATE EXRULE RECURRENCE-ID DURATION].include?(prop.name.upcase)
89
+ if %w[DTSTART DTEND RRULE EXDATE RDATE EXRULE RECURRENCE-ID DURATION].include?(prop.name.upcase)
90
+ next
91
+ end
86
92
  lines << "#{prop.name}:#{prop.value}"
87
93
  end
88
94
 
@@ -95,11 +101,17 @@ module Protocol
95
101
  lines << "BEGIN:VEVENT"
96
102
  has_rid = false
97
103
  vevent.properties.each do |prop|
98
- next if %w[RRULE EXDATE RDATE EXRULE].include?(prop.name.upcase)
99
- has_rid = true if prop.name.casecmp?('RECURRENCE-ID')
104
+ if %w[RRULE EXDATE RDATE EXRULE].include?(prop.name.upcase)
105
+ next
106
+ end
107
+ if prop.name.casecmp?('RECURRENCE-ID')
108
+ has_rid = true
109
+ end
100
110
  lines << "#{prop.name}:#{prop.value}"
101
111
  end
102
- lines.insert(1, "RECURRENCE-ID:#{format_utc(rid_time)}") unless has_rid
112
+ unless has_rid
113
+ lines.insert(1, "RECURRENCE-ID:#{format_utc(rid_time)}")
114
+ end
103
115
  lines << "END:VEVENT"
104
116
  lines.map { |l| "#{l}\r\n" }.join
105
117
  end
@@ -116,93 +128,95 @@ module Protocol
116
128
  time.utc.strftime('%Y%m%dT%H%M%SZ')
117
129
  end
118
130
 
119
- private_class_method :serialize_expanded_instance, :serialize_vevent_instance,
120
- :serialize_component, :format_utc
131
+ private_class_method :serialize_expanded_instance,
132
+ :serialize_vevent_instance,
133
+ :serialize_component,
134
+ :format_utc
121
135
  end
122
136
  end
123
137
  end
124
138
  end
125
139
 
126
- test do
127
- describe "Protocol::Caldav::Ical::Expand" do
128
- def parse(text)
129
- Protocol::Caldav::Ical::Parser.parse(text)
130
- end
140
+ __END__
131
141
 
132
- it "returns serialized component when not VCALENDAR" do
133
- vevent = parse("BEGIN:VEVENT\r\nSUMMARY:Hello\r\nEND:VEVENT")
134
- result = Protocol::Caldav::Ical::Expand.expand(vevent, range_start: Time.utc(2026, 1, 1), range_end: Time.utc(2026, 12, 31))
135
- result.should.include "BEGIN:VEVENT"
136
- result.should.include "SUMMARY:Hello"
137
- result.should.include "END:VEVENT"
138
- end
142
+ describe "Protocol::Caldav::Ical::Expand" do
143
+ def parse(text)
144
+ Protocol::Caldav::Ical::Parser.parse(text)
145
+ end
139
146
 
140
- it "returns serialized component when no RRULE found" do
141
- ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nSUMMARY:Once\r\nEND:VEVENT\r\nEND:VCALENDAR"
142
- component = parse(ical)
143
- result = Protocol::Caldav::Ical::Expand.expand(component, range_start: Time.utc(2026, 1, 1), range_end: Time.utc(2026, 12, 31))
144
- result.should.include "BEGIN:VCALENDAR"
145
- result.should.include "SUMMARY:Once"
146
- result.should.include "END:VCALENDAR"
147
- end
147
+ it "returns serialized component when not VCALENDAR" do
148
+ vevent = parse("BEGIN:VEVENT\r\nSUMMARY:Hello\r\nEND:VEVENT")
149
+ result = Protocol::Caldav::Ical::Expand.expand(vevent, range_start: Time.utc(2026, 1, 1), range_end: Time.utc(2026, 12, 31))
150
+ result.should.include "BEGIN:VEVENT"
151
+ result.should.include "SUMMARY:Hello"
152
+ result.should.include "END:VEVENT"
153
+ end
148
154
 
149
- it "expands daily RRULE into individual instances" do
150
- ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nUID:expand-test\r\nSUMMARY:Daily\r\nEND:VEVENT\r\nEND:VCALENDAR"
151
- component = parse(ical)
152
- result = Protocol::Caldav::Ical::Expand.expand(component, range_start: Time.utc(2026, 1, 1), range_end: Time.utc(2026, 1, 10))
153
- result.scan("BEGIN:VEVENT").length.should.equal 3
154
- result.should.include "RECURRENCE-ID"
155
- result.should.include "SUMMARY:Daily"
156
- end
155
+ it "returns serialized component when no RRULE found" do
156
+ ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nSUMMARY:Once\r\nEND:VEVENT\r\nEND:VCALENDAR"
157
+ component = parse(ical)
158
+ result = Protocol::Caldav::Ical::Expand.expand(component, range_start: Time.utc(2026, 1, 1), range_end: Time.utc(2026, 12, 31))
159
+ result.should.include "BEGIN:VCALENDAR"
160
+ result.should.include "SUMMARY:Once"
161
+ result.should.include "END:VCALENDAR"
162
+ end
157
163
 
158
- it "removes RRULE from expanded output" do
159
- ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=2\r\nUID:no-rrule\r\nEND:VEVENT\r\nEND:VCALENDAR"
160
- component = parse(ical)
161
- result = Protocol::Caldav::Ical::Expand.expand(component, range_start: Time.utc(2026, 1, 1), range_end: Time.utc(2026, 1, 10))
162
- result.should.not.include "RRULE"
163
- end
164
+ it "expands daily RRULE into individual instances" do
165
+ ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nUID:expand-test\r\nSUMMARY:Daily\r\nEND:VEVENT\r\nEND:VCALENDAR"
166
+ component = parse(ical)
167
+ result = Protocol::Caldav::Ical::Expand.expand(component, range_start: Time.utc(2026, 1, 1), range_end: Time.utc(2026, 1, 10))
168
+ result.scan("BEGIN:VEVENT").length.should.equal 3
169
+ result.should.include "RECURRENCE-ID"
170
+ result.should.include "SUMMARY:Daily"
171
+ end
164
172
 
165
- it "applies override VEVENT replacing base occurrence" do
166
- ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nUID:override-test\r\nSUMMARY:Base\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nDTSTART:20260102T140000Z\r\nDTEND:20260102T150000Z\r\nRECURRENCE-ID:20260102T090000Z\r\nUID:override-test\r\nSUMMARY:Override\r\nEND:VEVENT\r\nEND:VCALENDAR"
167
- component = parse(ical)
168
- result = Protocol::Caldav::Ical::Expand.expand(component, range_start: Time.utc(2026, 1, 1), range_end: Time.utc(2026, 1, 10))
169
- result.scan("BEGIN:VEVENT").length.should.equal 3
170
- result.should.include "SUMMARY:Override"
171
- result.should.include "SUMMARY:Base"
172
- end
173
+ it "removes RRULE from expanded output" do
174
+ ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=2\r\nUID:no-rrule\r\nEND:VEVENT\r\nEND:VCALENDAR"
175
+ component = parse(ical)
176
+ result = Protocol::Caldav::Ical::Expand.expand(component, range_start: Time.utc(2026, 1, 1), range_end: Time.utc(2026, 1, 10))
177
+ result.should.not.include "RRULE"
178
+ end
173
179
 
174
- it "non-recurring event passes through unchanged" do
175
- ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nUID:single\r\nSUMMARY:Once\r\nEND:VEVENT\r\nEND:VCALENDAR"
176
- component = parse(ical)
177
- result = Protocol::Caldav::Ical::Expand.expand(component, range_start: Time.utc(2026, 1, 1), range_end: Time.utc(2026, 12, 31))
178
- result.scan("BEGIN:VEVENT").length.should.equal 1
179
- result.should.include "SUMMARY:Once"
180
- result.should.not.include "RECURRENCE-ID"
181
- end
180
+ it "applies override VEVENT replacing base occurrence" do
181
+ ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nUID:override-test\r\nSUMMARY:Base\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nDTSTART:20260102T140000Z\r\nDTEND:20260102T150000Z\r\nRECURRENCE-ID:20260102T090000Z\r\nUID:override-test\r\nSUMMARY:Override\r\nEND:VEVENT\r\nEND:VCALENDAR"
182
+ component = parse(ical)
183
+ result = Protocol::Caldav::Ical::Expand.expand(component, range_start: Time.utc(2026, 1, 1), range_end: Time.utc(2026, 1, 10))
184
+ result.scan("BEGIN:VEVENT").length.should.equal 3
185
+ result.should.include "SUMMARY:Override"
186
+ result.should.include "SUMMARY:Base"
187
+ end
182
188
 
183
- it "respects EXDATE exclusions" do
184
- ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nEXDATE:20260102T090000Z\r\nUID:exdate-test\r\nEND:VEVENT\r\nEND:VCALENDAR"
185
- component = parse(ical)
186
- result = Protocol::Caldav::Ical::Expand.expand(component, range_start: Time.utc(2026, 1, 1), range_end: Time.utc(2026, 1, 10))
187
- result.scan("BEGIN:VEVENT").length.should.equal 2
188
- end
189
+ it "non-recurring event passes through unchanged" do
190
+ ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nUID:single\r\nSUMMARY:Once\r\nEND:VEVENT\r\nEND:VCALENDAR"
191
+ component = parse(ical)
192
+ result = Protocol::Caldav::Ical::Expand.expand(component, range_start: Time.utc(2026, 1, 1), range_end: Time.utc(2026, 12, 31))
193
+ result.scan("BEGIN:VEVENT").length.should.equal 1
194
+ result.should.include "SUMMARY:Once"
195
+ result.should.not.include "RECURRENCE-ID"
196
+ end
189
197
 
190
- it "only returns instances within range" do
191
- ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=10\r\nUID:range-test\r\nEND:VEVENT\r\nEND:VCALENDAR"
192
- component = parse(ical)
193
- result = Protocol::Caldav::Ical::Expand.expand(component, range_start: Time.utc(2026, 1, 3), range_end: Time.utc(2026, 1, 6))
194
- result.scan("BEGIN:VEVENT").length.should.equal 3
195
- result.should.include "20260103T090000Z"
196
- result.should.include "20260105T090000Z"
197
- end
198
+ it "respects EXDATE exclusions" do
199
+ ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nEXDATE:20260102T090000Z\r\nUID:exdate-test\r\nEND:VEVENT\r\nEND:VCALENDAR"
200
+ component = parse(ical)
201
+ result = Protocol::Caldav::Ical::Expand.expand(component, range_start: Time.utc(2026, 1, 1), range_end: Time.utc(2026, 1, 10))
202
+ result.scan("BEGIN:VEVENT").length.should.equal 2
203
+ end
198
204
 
199
- it "preserves non-recurrence properties in expanded instances" do
200
- ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=2\r\nUID:props-test\r\nSUMMARY:Keep Me\r\nLOCATION:Room 1\r\nEND:VEVENT\r\nEND:VCALENDAR"
201
- component = parse(ical)
202
- result = Protocol::Caldav::Ical::Expand.expand(component, range_start: Time.utc(2026, 1, 1), range_end: Time.utc(2026, 1, 10))
203
- # Both instances should have SUMMARY and LOCATION
204
- result.scan("SUMMARY:Keep Me").length.should.equal 2
205
- result.scan("LOCATION:Room 1").length.should.equal 2
206
- end
205
+ it "only returns instances within range" do
206
+ ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=10\r\nUID:range-test\r\nEND:VEVENT\r\nEND:VCALENDAR"
207
+ component = parse(ical)
208
+ result = Protocol::Caldav::Ical::Expand.expand(component, range_start: Time.utc(2026, 1, 3), range_end: Time.utc(2026, 1, 6))
209
+ result.scan("BEGIN:VEVENT").length.should.equal 3
210
+ result.should.include "20260103T090000Z"
211
+ result.should.include "20260105T090000Z"
212
+ end
213
+
214
+ it "preserves non-recurrence properties in expanded instances" do
215
+ ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=2\r\nUID:props-test\r\nSUMMARY:Keep Me\r\nLOCATION:Room 1\r\nEND:VEVENT\r\nEND:VCALENDAR"
216
+ component = parse(ical)
217
+ result = Protocol::Caldav::Ical::Expand.expand(component, range_start: Time.utc(2026, 1, 1), range_end: Time.utc(2026, 1, 10))
218
+ # Both instances should have SUMMARY and LOCATION
219
+ result.scan("SUMMARY:Keep Me").length.should.equal 2
220
+ result.scan("LOCATION:Room 1").length.should.equal 2
207
221
  end
208
222
  end