schedulability 0.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.
@@ -0,0 +1,214 @@
1
+ # -*- ruby -*-
2
+ #encoding: utf-8
3
+
4
+ require 'strscan'
5
+
6
+ require 'loggability'
7
+ require 'schedulability' unless defined?( Schedulability )
8
+ require 'schedulability/exceptions'
9
+
10
+
11
+ # A schedule object representing one or more abstract ranges of times.
12
+ class Schedulability::Schedule
13
+ extend Loggability
14
+
15
+
16
+ # Schedulability API -- Log to the Schedulability logger
17
+ log_to :schedulability
18
+
19
+
20
+ ### Parse one or more periods from the specified +expression+ and return a Schedule
21
+ ### created with them.
22
+ def self::parse( expression )
23
+ positive, negative = Schedulability::Parser.extract_periods( expression )
24
+ return new( positive, negative )
25
+ end
26
+
27
+
28
+ ### Create a new Schedule using the specified +periods+.
29
+ def initialize( positive_periods=[], negative_periods=[] )
30
+ positive_periods ||= []
31
+ negative_periods ||= []
32
+
33
+ @positive_periods = positive_periods.flatten.uniq
34
+ @positive_periods.freeze
35
+ @negative_periods = negative_periods.flatten.uniq
36
+ @negative_periods.freeze
37
+ end
38
+
39
+
40
+ # The periods that express which times are in the schedule
41
+ attr_reader :positive_periods
42
+
43
+ # The periods that express which times are *not* in the schedule
44
+ attr_reader :negative_periods
45
+
46
+
47
+ ### Returns +true+ if the schedule doesn't have any time periods.
48
+ def empty?
49
+ return self.positive_periods.empty?
50
+ end
51
+
52
+
53
+ ### Returns +true+ if the current time is within one of the Schedule's periods.
54
+ def now?
55
+ return self.include?( Time.now )
56
+ end
57
+
58
+
59
+ ### Returns +true+ if the specified +time+ is in the schedule.
60
+ def include?( time )
61
+ time_obj = if time.respond_to?( :to_time )
62
+ time.to_time
63
+ else
64
+ time_obj = Time.parse( time.to_s )
65
+ self.log.debug "Parsed %p to time %p" % [ time, time_obj ]
66
+ time_obj
67
+ end
68
+
69
+ return ! self.negative_periods_include?( time_obj ) &&
70
+ self.positive_periods_include?( time_obj )
71
+ end
72
+
73
+
74
+ ### Returns +true+ if any of the schedule's positive periods include the
75
+ ### specified +time+.
76
+ def positive_periods_include?( time )
77
+ return self.positive_periods.empty? ||
78
+ find_matching_period_for( time, self.positive_periods )
79
+ end
80
+
81
+
82
+ ### Returns +true+ if any of the schedule's negative periods include the
83
+ ### specified +time+.
84
+ def negative_periods_include?( time )
85
+ return find_matching_period_for( time, self.negative_periods )
86
+ end
87
+
88
+
89
+ ### Returns +true+ if the time periods for +other_schedule+ are the same as those for the
90
+ ### receiver.
91
+ def ==( other_schedule )
92
+ other_schedule.is_a?( self.class ) &&
93
+ self.positive_periods.all? {|period| other_schedule.positive_periods.include?(period) } &&
94
+ other_schedule.positive_periods.all? {|period| self.positive_periods.include?(period) } &&
95
+ self.negative_periods.all? {|period| other_schedule.negative_periods.include?(period) } &&
96
+ other_schedule.negative_periods.all? {|period| self.negative_periods.include?(period) }
97
+ end
98
+
99
+
100
+ ### Return a new Schedulability::Schedule object that is the union of the receiver and
101
+ ### +other_schedule+.
102
+ def |( other_schedule )
103
+ positive = self.positive_periods + other_schedule.positive_periods
104
+ negative = intersect_periods( self.negative_periods, other_schedule.negative_periods )
105
+
106
+ return self.class.new( positive, negative )
107
+ end
108
+ alias_method :+, :|
109
+
110
+
111
+ ### Return a new Schedulability::Schedule object that is the intersection of the receiver and
112
+ ### +other_schedule+.
113
+ def &( other_schedule )
114
+ positive = intersect_periods( self.positive_periods, other_schedule.positive_periods )
115
+ negative = self.negative_periods + other_schedule.negative_periods
116
+
117
+ return self.class.new( positive, negative )
118
+ end
119
+
120
+
121
+ ### Return a new Schedulability::Schedule object that inverts the positive and negative
122
+ ### period criteria.
123
+ def ~@
124
+ return self.class.new( self.negative_periods, self.positive_periods )
125
+ end
126
+
127
+
128
+ #######
129
+ private
130
+ #######
131
+
132
+ ### Returns true if any of the specified +periods+ contains the specified +time+.
133
+ def find_matching_period_for( time, periods )
134
+ periods.any? do |period|
135
+ period.all? do |scale, ranges|
136
+ val = value_for_scale( time, scale )
137
+ ranges.any? {|rng| rng.cover?(val) }
138
+ end
139
+ end
140
+ end
141
+
142
+
143
+ ### Return the appropriate numeric value for the specified +scale+ from the
144
+ ### given +time+.
145
+ def value_for_scale( time, scale )
146
+ case scale
147
+ when :mo
148
+ return time.mon
149
+ when :md
150
+ return time.day
151
+ when :wd
152
+ return time.wday
153
+ when :hr
154
+ return time.hour
155
+ when :min
156
+ return time.min
157
+ when :sec
158
+ return time.sec
159
+ when :yd
160
+ return time.yday
161
+ when :wk
162
+ return ( time.day / 7.0 ).ceil
163
+ when :yr
164
+ self.log.debug "Year match: %p" % [ time.year ]
165
+ return time.year
166
+ else
167
+ # If this happens, it's likely a bug in the parser.
168
+ raise ScriptError, "unknown scale %p" % [ scale ]
169
+ end
170
+ end
171
+
172
+
173
+ ### Return the specified +periods+ exploded into integer arrays instead of Ranges.
174
+ def explode( periods )
175
+ return periods.map do |per|
176
+ per.each_with_object({}) do |(scale,ranges), hash|
177
+ hash[ scale ] = ranges.flat_map( &:to_a )
178
+ end
179
+ end
180
+ end
181
+
182
+
183
+ ### Return the intelligent merge of the +left+ and +right+ period hashes, only retaining
184
+ ### values that exist on both sides.
185
+ def intersect_periods( left, right )
186
+ new_periods = []
187
+ explode( left ).product( explode(right) ) do |p1, p2|
188
+ new_period = {}
189
+ common_scales = p1.keys & p2.keys
190
+
191
+ # Keys exist on both sides, diff+merge identical values
192
+ common_scales.each do |scale|
193
+ vals = p1[ scale ] & p2[ scale ]
194
+ new_period[ scale ] = Schedulability::Parser.coalesce_ranges( vals, scale )
195
+ end
196
+ next if new_period.values.any?( &:empty? )
197
+
198
+ # Keys exist only on one side, sync between sides because
199
+ # the other side is implicitly infinite.
200
+ (p1.keys - common_scales).each do |scale|
201
+ new_period[ scale ] = Schedulability::Parser.coalesce_ranges( p1[scale], scale )
202
+ end
203
+ (p2.keys - common_scales).each do |scale|
204
+ new_period[ scale ] = Schedulability::Parser.coalesce_ranges( p2[scale], scale )
205
+ end
206
+
207
+ new_periods << new_period
208
+ end
209
+
210
+ return new_periods
211
+ end
212
+
213
+
214
+ end # class Schedulability::Schedule
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env ruby
2
+ # vim: set nosta noet ts=4 sw=4:
3
+ # encoding: utf-8
4
+
5
+ BEGIN {
6
+ require 'pathname'
7
+ basedir = Pathname.new( __FILE__ ).dirname.parent
8
+
9
+ libdir = basedir + "lib"
10
+
11
+ $LOAD_PATH.unshift( libdir.to_s ) unless $LOAD_PATH.include?( libdir.to_s )
12
+ }
13
+
14
+ # SimpleCov test coverage reporting; enable this using the :coverage rake task
15
+ if ENV['COVERAGE']
16
+ $stderr.puts "\n\n>>> Enabling coverage report.\n\n"
17
+ require 'simplecov'
18
+ SimpleCov.start do
19
+ add_filter 'spec'
20
+ end
21
+ end
22
+
23
+ require 'schedulability'
24
+ require 'loggability/spechelpers'
25
+
26
+
27
+ # Helpers specific to Schedulability specs
28
+ module Schedulability::SpecHelpers
29
+ end # module Schedulability::SpecHelpers
30
+
31
+
32
+ ### Mock with RSpec
33
+ RSpec.configure do |c|
34
+ c.run_all_when_everything_filtered = true
35
+ c.filter_run :focus
36
+ c.order = 'random'
37
+
38
+ c.mock_with( :rspec ) do |mock|
39
+ mock.syntax = :expect
40
+ end
41
+
42
+ c.include( Loggability::SpecHelpers )
43
+ c.include( Schedulability::SpecHelpers )
44
+ end
45
+
@@ -0,0 +1,828 @@
1
+ #!/usr/bin/env rspec -cfd
2
+
3
+ require_relative '../helpers'
4
+
5
+ require 'time'
6
+ require 'timecop'
7
+ require 'schedulability/schedule'
8
+ require 'schedulability/mixins'
9
+
10
+
11
+ using Schedulability::TimeRefinements
12
+
13
+ describe Schedulability::Schedule do
14
+
15
+ before( :all ) do
16
+ @actual_zone = ENV['TZ']
17
+ ENV['TZ'] = 'GMT'
18
+ end
19
+
20
+ after( :all ) do
21
+ ENV['TZ'] = @actual_zone
22
+ end
23
+
24
+
25
+ let( :testing_time ) { Time.iso8601('2015-12-15T12:00:00-00:00') }
26
+
27
+
28
+ context "with no periods" do
29
+
30
+ let( :schedule ) { described_class.new }
31
+
32
+
33
+ it "is empty" do
34
+ expect( schedule ).to be_empty
35
+ end
36
+
37
+
38
+ it "is always 'now'" do
39
+ Timecop.freeze( testing_time ) do
40
+ expect( schedule ).to be_now
41
+ end
42
+ end
43
+
44
+
45
+ it "includes every time" do
46
+ expect( schedule ).to include( testing_time )
47
+ end
48
+
49
+
50
+ it "is equal to other empty schedules" do
51
+ expect( schedule ).to be == described_class.new
52
+ end
53
+
54
+
55
+ it "is not equal to any other non-empty schedules" do
56
+ expect( schedule ).to_not be == described_class.new( md: 10..10 )
57
+ end
58
+
59
+ end
60
+
61
+
62
+ context "with one simple time period" do
63
+
64
+ let( :schedule ) { described_class.parse("wd {Mon-Fri}") }
65
+
66
+
67
+ it "isn't empty" do
68
+ expect( schedule ).to_not be_empty
69
+ end
70
+
71
+
72
+ it "includes the current time if it's within the period" do
73
+ Timecop.freeze( testing_time ) do
74
+ expect( schedule ).to be_now
75
+ end
76
+ end
77
+
78
+
79
+ it "includes a particular time within its period" do
80
+ expect( schedule ).to include( testing_time )
81
+ end
82
+
83
+
84
+ it "doesn't include a time outside of its period" do
85
+ expect( schedule ).to_not include( 'Tue Dec 13 12:00:00 2015' )
86
+ end
87
+
88
+
89
+ it "is equal to another schedule with the same period" do
90
+ expect( schedule ).to be == described_class.parse( 'wd {Mon Tue Wed Thu Fri}' )
91
+ end
92
+
93
+
94
+ it "is not equal to another schedule if it doesn't have the same time periods" do
95
+ expect( schedule ).to_not be == described_class.parse( 'wd {Mon-Sat}' )
96
+ expect( schedule ).to_not be == described_class.parse( 'wd {Mon-Thu}' )
97
+ expect( schedule ).to_not be == described_class.parse( 'wd {Mon-Fri} hour {6am-8am}' )
98
+ end
99
+
100
+ end
101
+
102
+
103
+ context "with one time period with multiple scales" do
104
+
105
+ let( :schedule ) { described_class.parse("wd {Sun Tue} hr {8am-4pm}") }
106
+
107
+
108
+ it "isn't empty" do
109
+ expect( schedule ).to_not be_empty
110
+ end
111
+
112
+
113
+ it "includes the current time if both scales match" do
114
+ Timecop.freeze( testing_time ) do
115
+ expect( schedule ).to be_now
116
+ end
117
+ end
118
+
119
+
120
+ it "includes a particular time within its period" do
121
+ expect( schedule ).to include( 'Tue Dec 15 12:00:00 2015' )
122
+ end
123
+
124
+
125
+ it "doesn't include a time outside of its period" do
126
+ expect( schedule ).to_not include( 'Tue Dec 15 17:00:00 2015' )
127
+ end
128
+
129
+ end
130
+
131
+
132
+ context "with multiple time periods" do
133
+
134
+ let( :schedule ) do
135
+ described_class.parse( "wd {Mon Wed Fri} hr {8am-4pm}, wd {Tue Thu} hr {9am-5pm}" )
136
+ end
137
+
138
+
139
+ it "isn't empty" do
140
+ expect( schedule ).to_not be_empty
141
+ end
142
+
143
+
144
+ it "includes the current time if all scales of one of its periods match" do
145
+ expect( schedule ).to include( 'Tue Dec 15 12:00:00 2015' )
146
+ expect( schedule ).to include( 'Wed Dec 16 12:00:00 2015' )
147
+ end
148
+
149
+
150
+ it "doesn't include a time outside of all of its periods" do
151
+ expect( schedule ).to_not include( 'Tue Dec 15 8:00:00 2015' )
152
+ expect( schedule ).to_not include( 'Wed Dec 16 17:00:00 2015' )
153
+ expect( schedule ).to_not include( 'Sat Dec 19 12:00:00 2015' )
154
+ end
155
+
156
+
157
+ it "respects negations" do
158
+ schedule = described_class.
159
+ parse( "wd {Mon Wed Fri} hr {8am-4pm}, wd {Tue Thu} hr {9am-5pm}, not hour { 3pm }" )
160
+ expect( schedule ).to include( 'Tue Dec 15 12:00:00 2015' )
161
+ expect( schedule ).to include( 'Wed Dec 16 12:00:00 2015' )
162
+ expect( schedule ).to_not include( 'Wed Dec 16 15:05:00 2015' )
163
+ end
164
+ end
165
+
166
+
167
+ describe "period parsing" do
168
+
169
+ it "matches single second values only during that second of every minute" do
170
+ schedule = described_class.parse( "sec {18}" )
171
+ time = Time.iso8601( '2015-12-15T12:00:18-00:00' )
172
+
173
+ expect( schedule ).to_not include( time - 2.seconds )
174
+ expect( schedule ).to_not include( time - 1.second )
175
+ expect( schedule ).to include( time )
176
+ expect( schedule ).to_not include( time + 1.second )
177
+ expect( schedule ).to_not include( time + 2.seconds )
178
+ end
179
+
180
+
181
+ it "matches negated single second values during every other second of every minute" do
182
+ schedule = described_class.parse( "except sec {18}" )
183
+ time = Time.iso8601( '2015-12-15T12:00:18-00:00' )
184
+
185
+ expect( schedule ).to include( time - 2.seconds )
186
+ expect( schedule ).to include( time - 1.second )
187
+ expect( schedule ).to_not include( time )
188
+ expect( schedule ).to include( time + 1.second )
189
+ expect( schedule ).to include( time + 2.seconds )
190
+ end
191
+
192
+
193
+ it "matches second range values as multi-second exclusive ranges" do
194
+ schedule = described_class.parse( "sec {10-20}" )
195
+ time = Time.iso8601( '2015-12-15T12:00:10-00:00' )
196
+
197
+ expect( schedule ).to_not include( time - 2.seconds )
198
+ expect( schedule ).to_not include( time - 1.second )
199
+ expect( schedule ).to include( time )
200
+ expect( schedule ).to include( time + 1.second )
201
+ expect( schedule ).to include( time + 9.seconds )
202
+ expect( schedule ).to_not include( time + 10.seconds )
203
+ end
204
+
205
+
206
+ it "matches wrapped second range values as two ranges covering the upper and lower parts" do
207
+ schedule = described_class.parse( "sec {45-15}" )
208
+ time = Time.iso8601( '2015-12-15T12:00:45-00:00' )
209
+
210
+ expect( schedule ).to_not include( time - 2.seconds )
211
+ expect( schedule ).to_not include( time - 1.second )
212
+ expect( schedule ).to include( time )
213
+ expect( schedule ).to include( time + 1.second )
214
+ expect( schedule ).to include( time + 14.seconds )
215
+ expect( schedule ).to include( time + 15.seconds )
216
+ expect( schedule ).to include( time + 20.seconds )
217
+ expect( schedule ).to include( time + 29.seconds )
218
+ expect( schedule ).to_not include( time + 30.seconds )
219
+ end
220
+
221
+
222
+ it "matches single minute values as a 60-second exclusive range" do
223
+ schedule = described_class.parse( "min {28}" )
224
+ time = Time.iso8601( '2015-12-15T12:28:00-00:00' )
225
+
226
+ expect( schedule ).to_not include( time - 15.seconds )
227
+ expect( schedule ).to_not include( time - 1.second )
228
+ expect( schedule ).to include( time )
229
+ expect( schedule ).to include( time + 38.seconds )
230
+ expect( schedule ).to include( time + 59.seconds )
231
+ expect( schedule ).to_not include( time + 1.minute )
232
+ expect( schedule ).to_not include( time + 2.minutes )
233
+ end
234
+
235
+
236
+ it "matches minute range values as multi-minute exclusive ranges" do
237
+ schedule = described_class.parse( "min {25-35}" )
238
+ time = Time.iso8601( '2015-12-15T12:25:00-00:00' )
239
+
240
+ expect( schedule ).to_not include( time - 2.minutes )
241
+ expect( schedule ).to_not include( time - 1.second )
242
+ expect( schedule ).to include( time )
243
+ expect( schedule ).to include( time + 1.minute )
244
+ expect( schedule ).to include( time + 9.minutes )
245
+ expect( schedule ).to include( time + 9.minutes + 59.seconds )
246
+ expect( schedule ).to_not include( time + 10.minutes )
247
+ end
248
+
249
+
250
+ it "matches wrapped minute range values as two ranges covering the upper and lower parts" do
251
+ schedule = described_class.parse( "min {50-15}" )
252
+ time = Time.iso8601( '2015-12-15T12:50:00-00:00' )
253
+
254
+ expect( schedule ).to_not include( time - 1.minute )
255
+ expect( schedule ).to_not include( time - 1.second )
256
+ expect( schedule ).to include( time )
257
+ expect( schedule ).to include( time + 1.second )
258
+ expect( schedule ).to include( time + 1.minute )
259
+ expect( schedule ).to include( time + 9.minutes )
260
+ expect( schedule ).to include( time + 9.minutes + 59.seconds )
261
+ expect( schedule ).to include( time + 10.minutes )
262
+ expect( schedule ).to include( time + 20.minutes )
263
+ expect( schedule ).to include( time + 24.minutes )
264
+ expect( schedule ).to include( time + 24.minutes + 59.seconds )
265
+ expect( schedule ).to_not include( time + 25.minutes )
266
+ end
267
+
268
+
269
+ it "matches single hour values as a 3600-second exclusive range" do
270
+ schedule = described_class.parse( "hr {8}" )
271
+ time = Time.iso8601( '2015-12-15T08:00:00-00:00' )
272
+
273
+ expect( schedule ).to_not include( time - 1 )
274
+ expect( schedule ).to include( time )
275
+ expect( schedule ).to include( time + 1.minute )
276
+ expect( schedule ).to include( time + 20.minutes )
277
+ expect( schedule ).to include( time + (1.hour - 1.minute) )
278
+ expect( schedule ).to_not include( time + 1.hour )
279
+ expect( schedule ).to_not include( time + 3.hours )
280
+ end
281
+
282
+
283
+ it "matches hour range values as multi-hour exclusive ranges" do
284
+ schedule = described_class.parse( "hr {9am-5pm}" )
285
+ time = Time.iso8601( '2015-12-15T09:00:00-00:00' )
286
+
287
+ expect( schedule ).to_not include( time - 12.hours )
288
+ expect( schedule ).to_not include( time - 10.minutes )
289
+ expect( schedule ).to_not include( time - 1.second )
290
+ expect( schedule ).to include( time )
291
+ expect( schedule ).to include( time + 1.minute )
292
+ expect( schedule ).to include( time + 1.hour )
293
+ expect( schedule ).to include( time + 7.hours )
294
+ expect( schedule ).to include( time + (8.hours - 1.second) )
295
+ expect( schedule ).to_not include( time + 8.hours )
296
+ end
297
+
298
+
299
+ it "matches wrapped hour range values as two ranges covering the upper and lower parts" do
300
+ schedule = described_class.parse( "hr {5pm-9am}" )
301
+ time = Time.iso8601( '2015-12-15T17:00:00-00:00' )
302
+
303
+ expect( schedule ).to_not include( time - 1.hour )
304
+ expect( schedule ).to_not include( time - 1.second )
305
+ expect( schedule ).to include( time )
306
+ expect( schedule ).to include( time + 1.second )
307
+ expect( schedule ).to include( time + 1.minute )
308
+ expect( schedule ).to include( time + 10.minutes )
309
+ expect( schedule ).to include( time + 2.hours )
310
+ expect( schedule ).to include( time + (7.hours - 1.second) )
311
+ expect( schedule ).to include( time + 7.hours )
312
+ expect( schedule ).to include( time + 12.hours )
313
+ expect( schedule ).to include( time + (16.hours - 1.second) )
314
+ expect( schedule ).to include( time + 24.hours )
315
+ expect( schedule ).to_not include( time + (24.hours - 1.second) )
316
+ expect( schedule ).to_not include( time + 16.hours )
317
+ expect( schedule ).to_not include( time + 18.hours )
318
+ end
319
+
320
+
321
+ it "handles 12pm correctly" do
322
+ schedule = described_class.parse( "hr {12pm}" )
323
+ time = Time.iso8601( '2015-12-15T12:00:00-00:00' )
324
+
325
+ expect( schedule ).to_not include( time - 1 )
326
+ expect( schedule ).to include( time )
327
+ expect( schedule ).to include( time + 1.minute )
328
+ expect( schedule ).to include( time + 20.minutes )
329
+ expect( schedule ).to include( time + (1.hour - 1.minute) )
330
+ expect( schedule ).to_not include( time + 1.hour )
331
+ expect( schedule ).to_not include( time + 3.hours )
332
+ end
333
+
334
+
335
+ it "matches single day number values as a 86400-second exclusive range" do
336
+ schedule = described_class.parse( "md {11}" )
337
+
338
+ expect( schedule ).to_not include( '2014-06-10T23:00:00-00:00' )
339
+ expect( schedule ).to_not include( '2014-06-10T23:59:59-00:00' )
340
+ expect( schedule ).to include( '2014-06-11T00:00:00-00:00' )
341
+ expect( schedule ).to include( '2014-06-11T00:01:00-00:00' )
342
+ expect( schedule ).to include( '2014-06-11T01:00:00-00:00' )
343
+ expect( schedule ).to include( '2014-06-11T12:00:00-00:00' )
344
+ expect( schedule ).to include( '2014-06-11T23:59:59-00:00' )
345
+ expect( schedule ).to_not include( '2014-06-12T00:00:00-00:00' )
346
+ expect( schedule ).to_not include( '2014-06-12T02:00:00-00:00' )
347
+ end
348
+
349
+
350
+ it "matches day number range values as multi-day inclusive ranges" do
351
+ schedule = described_class.parse( "md {13-15}" )
352
+
353
+ expect( schedule ).to_not include( '2015-12-12T23:59:59:00:00-00:00' )
354
+ expect( schedule ).to include( '2015-12-13T00:00:00-00:00' )
355
+ expect( schedule ).to include( '2015-12-14T00:00:00-00:00' )
356
+ expect( schedule ).to include( '2015-12-15T00:00:00-00:00' )
357
+ expect( schedule ).to include( '2015-12-15T23:59:59-00:00' )
358
+ expect( schedule ).to_not include( '2015-12-16T00:00:00-00:00' )
359
+ end
360
+
361
+
362
+ it "matches wrapped day number range values as two ranges covering the upper and lower parts" do
363
+ schedule = described_class.parse( "md {28-2}" )
364
+
365
+ expect( schedule ).to_not include( '2015-12-27T23:59:59-00:00' )
366
+ expect( schedule ).to include( '2015-12-28T00:00:00-00:00' )
367
+ expect( schedule ).to include( '2015-12-30T00:00:00-00:00' )
368
+ expect( schedule ).to include( '2015-12-31T00:00:00-00:00' )
369
+ expect( schedule ).to include( '2016-01-01T00:00:00-00:00' )
370
+ expect( schedule ).to include( '2016-01-02T23:59:59-00:00' )
371
+ expect( schedule ).to include( '2016-02-29T00:00:00-00:00' )
372
+ expect( schedule ).to_not include( '2016-01-03T00:00:00-00:00' )
373
+ end
374
+
375
+
376
+ it "matches single week number values against the counted week of the month" do
377
+ schedule = described_class.parse( "wk {2}" )
378
+
379
+ expect( schedule ).to_not include( '2016-04-01T00:00:00-00:00' )
380
+ expect( schedule ).to_not include( '2016-04-02T00:00:00-00:00' )
381
+ expect( schedule ).to_not include( '2016-04-02T23:59:59-00:00' )
382
+ expect( schedule ).to_not include( '2016-04-03T00:00:00-00:00' )
383
+ expect( schedule ).to_not include( '2016-04-04T03:00:00-00:00' )
384
+ expect( schedule ).to_not include( '2016-04-05T06:00:00-00:00' )
385
+ expect( schedule ).to_not include( '2016-04-06T09:00:00-00:00' )
386
+ expect( schedule ).to_not include( '2016-04-07T23:59:59-00:00' )
387
+
388
+ expect( schedule ).to include( '2016-04-08T15:00:00-00:00' )
389
+ expect( schedule ).to include( '2016-04-09T19:00:00-00:00' )
390
+ expect( schedule ).to include( '2016-04-10T00:00:00-00:00' )
391
+ expect( schedule ).to include( '2016-04-11T00:00:00-00:00' )
392
+ expect( schedule ).to include( '2016-04-12T00:00:00-00:00' )
393
+ expect( schedule ).to include( '2016-04-13T00:00:00-00:00' )
394
+ expect( schedule ).to include( '2016-04-14T23:59:59-00:00' )
395
+
396
+ expect( schedule ).to_not include( '2016-04-15T00:00:00-00:00' )
397
+ expect( schedule ).to_not include( '2016-04-28T00:00:00-00:00' )
398
+ end
399
+
400
+
401
+ it "matches week number range values as multi-day inclusive ranges" do
402
+ schedule = described_class.parse( "wk {2-4}" )
403
+
404
+ expect( schedule ).to_not include( '2016-04-01T00:00:00-00:00' )
405
+ expect( schedule ).to_not include( '2016-04-02T00:00:00-00:00' )
406
+ expect( schedule ).to_not include( '2016-04-07T23:59:59-00:00' )
407
+
408
+ expect( schedule ).to include( '2016-04-08T00:00:00-00:00' )
409
+ expect( schedule ).to include( '2016-04-10T00:00:00-00:00' )
410
+ expect( schedule ).to include( '2016-04-17T00:00:00-00:00' )
411
+ expect( schedule ).to include( '2016-04-28T23:59:59-00:00' )
412
+
413
+ expect( schedule ).to_not include( '2016-04-29T00:00:00-00:00' )
414
+ expect( schedule ).to_not include( '2016-04-30T00:00:00-00:00' )
415
+ end
416
+
417
+
418
+ it "matches wrapped week number range values as two ranges covering the upper and lower parts" do
419
+ schedule = described_class.parse( "wk {4-1}" )
420
+
421
+ expect( schedule ).to include( '2016-04-01T00:00:00-00:00' )
422
+ expect( schedule ).to include( '2016-04-02T00:00:00-00:00' )
423
+ expect( schedule ).to include( '2016-04-07T23:59:59-00:00' )
424
+
425
+ expect( schedule ).to_not include( '2016-04-08T00:00:00-00:00' )
426
+ expect( schedule ).to_not include( '2016-04-10T00:00:00-00:00' )
427
+ expect( schedule ).to_not include( '2016-04-20T23:59:59-00:00' )
428
+
429
+ expect( schedule ).to include( '2016-04-28T00:00:00-00:00' )
430
+ expect( schedule ).to include( '2016-04-29T00:00:00-00:00' )
431
+ expect( schedule ).to include( '2016-04-30T23:59:59-00:00' )
432
+ end
433
+
434
+
435
+ it "matches single day of week values as a 86400-second exclusive range" do
436
+ schedule = described_class.parse( "wd {Wed}" )
437
+
438
+ expect( schedule ).to_not include( 'Tue, 01 Dec 2015 00:00:00 GMT' )
439
+ expect( schedule ).to_not include( 'Tue, 01 Dec 2015 23:59:59 GMT' )
440
+ expect( schedule ).to include( 'Wed, 02 Dec 2015 00:00:00 GMT' )
441
+ expect( schedule ).to include( 'Wed, 02 Dec 2015 12:00:00 GMT' )
442
+ expect( schedule ).to include( 'Wed, 02 Dec 2015 23:59:59 GMT' )
443
+ expect( schedule ).to_not include( 'Thu, 03 Dec 2015 00:00:00 GMT' )
444
+ end
445
+
446
+
447
+ it "matches single numeric day of week value as a 86400-second exclusive range" do
448
+ schedule = described_class.parse( "wd {6}" )
449
+
450
+ expect( schedule ).to_not include( 'Fri, 01 Jan 2016 00:00:00 GMT' )
451
+ expect( schedule ).to_not include( 'Fri, 01 Jan 2016 23:59:59 GMT' )
452
+ expect( schedule ).to include( 'Sat, 02 Jan 2016 00:00:00 GMT' )
453
+ expect( schedule ).to include( 'Sat, 02 Jan 2016 12:00:00 GMT' )
454
+ expect( schedule ).to include( 'Sat, 02 Jan 2016 23:59:59 GMT' )
455
+ expect( schedule ).to_not include( 'Sun, 03 Jan 2016 00:00:00 GMT' )
456
+ end
457
+
458
+
459
+ it "matches day of week name ranges as an inclusive range" do
460
+ schedule = described_class.parse( "wd {Mon-Fri}" )
461
+
462
+ expect( schedule ).to_not include( 'Sun, 03 Jan 2016 23:59:59 GMT' )
463
+ expect( schedule ).to include( 'Mon, 04 Jan 2016 00:00:00 GMT' )
464
+ expect( schedule ).to include( 'Wed, 06 Jan 2016 00:00:00 GMT' )
465
+ expect( schedule ).to include( 'Fri, 08 Jan 2016 23:59:59 GMT' )
466
+ expect( schedule ).to_not include( 'Sat, 09 Jan 2016 00:00:00 GMT' )
467
+ expect( schedule ).to_not include( 'Sat, 09 Jan 2016 23:59:59 GMT' )
468
+ end
469
+
470
+
471
+ it "matches day of week wrapped name ranges as a set of two ranges of the included days" do
472
+ schedule = described_class.parse( "wd {Fri-Sun}" )
473
+
474
+ expect( schedule ).to_not include( 'Mon, 04 Jan 2016 00:00:00 GMT' )
475
+ expect( schedule ).to_not include( 'Thu, 07 Jan 2016 23:59:59 GMT' )
476
+ expect( schedule ).to include( 'Fri, 08 Jan 2016 00:00:00 GMT' )
477
+ expect( schedule ).to include( 'Sat, 09 Jan 2016 12:00:00 GMT' )
478
+ expect( schedule ).to include( 'Sun, 10 Jan 2016 23:59:59 GMT' )
479
+ expect( schedule ).to_not include( 'Mon, 11 Jan 2016 00:00:00 GMT' )
480
+ expect( schedule ).to_not include( 'Mon, 11 Jan 2016 23:59:59 GMT' )
481
+ end
482
+
483
+
484
+ it "matches single month name values as a single-month exclusive range" do
485
+ schedule = described_class.parse( "mo {Dec}" )
486
+
487
+ expect( schedule ).to_not include( 'Mon, 30 Nov 2015 23:59:59 GMT' )
488
+ expect( schedule ).to include( 'Tue, 01 Dec 2015 00:00:00 GMT' )
489
+ expect( schedule ).to include( 'Tue, 15 Dec 2015 00:00:00 GMT' )
490
+ expect( schedule ).to include( 'Thu, 31 Dec 2015 23:59:59 GMT' )
491
+ expect( schedule ).to_not include( 'Fri, 01 Jan 2016 00:00:00 GMT' )
492
+ end
493
+
494
+
495
+ it "matches single month number values as a single-month exclusive range" do
496
+ schedule = described_class.parse( "mo {7}" )
497
+
498
+ expect( schedule ).to_not include( '2015-06-30T23:59:59-00:00' )
499
+ expect( schedule ).to include( '2015-07-01T00:00:00-00:00' )
500
+ expect( schedule ).to include( '2015-07-15T23:59:59-00:00' )
501
+ expect( schedule ).to include( '2015-07-31T23:59:59-00:00' )
502
+ expect( schedule ).to_not include( '2015-08-01T00:00:00-00:00' )
503
+ end
504
+
505
+
506
+ it "matches a range of month name values as a inclusive range" do
507
+ schedule = described_class.parse( "mo {Aug-Nov}" )
508
+
509
+ expect( schedule ).to_not include( 'Fri, 31 Jul 2015 23:59:59 GMT' )
510
+ expect( schedule ).to include( 'Sat, 01 Aug 2015 00:00:00 GMT' )
511
+ expect( schedule ).to include( 'Thu, 15 Oct 2015 00:00:00 GMT' )
512
+ expect( schedule ).to include( 'Mon, 30 Nov 2015 23:59:59 GMT' )
513
+ expect( schedule ).to_not include( 'Tue, 01 Dec 2015 00:00:00 GMT' )
514
+ end
515
+
516
+
517
+ it "matches every month other than those in a negated range of month names" do
518
+ schedule = described_class.parse( "not mo {Aug-Nov}" )
519
+
520
+ expect( schedule ).to include( 'Fri, 31 Jul 2015 23:59:59 GMT' )
521
+ expect( schedule ).to_not include( 'Sat, 01 Aug 2015 00:00:00 GMT' )
522
+ expect( schedule ).to_not include( 'Thu, 15 Oct 2015 00:00:00 GMT' )
523
+ expect( schedule ).to_not include( 'Mon, 30 Nov 2015 23:59:59 GMT' )
524
+ expect( schedule ).to include( 'Tue, 01 Dec 2015 00:00:00 GMT' )
525
+ end
526
+
527
+
528
+ it "matches a wrapped range of month name values as two inclusive ranges" do
529
+ schedule = described_class.parse( "mo {Sep-Mar}" )
530
+
531
+ expect( schedule ).to_not include( 'Mon, 31 Aug 2015 23:59:59 GMT' )
532
+ expect( schedule ).to include( 'Tue, 01 Sep 2015 00:00:00 GMT' )
533
+ expect( schedule ).to include( 'Thu, 15 Oct 2015 00:00:00 GMT' )
534
+ expect( schedule ).to include( 'Mon, 30 Nov 2015 23:59:59 GMT' )
535
+ expect( schedule ).to include( 'Thu, 31 Dec 2015 23:59:59 GMT' )
536
+ expect( schedule ).to include( 'Fri, 01 Jan 2016 00:00:00 GMT' )
537
+ expect( schedule ).to include( 'Thu, 31 Mar 2016 23:59:59 GMT' )
538
+ expect( schedule ).to_not include( 'Fri, 01 Apr 2016 00:00:00 GMT' )
539
+ end
540
+
541
+
542
+ it "matches single day-of-year values as a single 24-hour period" do
543
+ schedule = described_class.parse( "yd {362}" )
544
+
545
+ expect( schedule ).to_not include( '2015-12-27T23:59:59-00:00' )
546
+ expect( schedule ).to include( '2015-12-28T00:00:00-00:00' )
547
+ expect( schedule ).to include( '2015-12-28T12:00:00-00:00' )
548
+ expect( schedule ).to include( '2015-12-28T23:59:59-00:00' )
549
+ expect( schedule ).to_not include( '2015-12-29T00:00:00-00:00' )
550
+ end
551
+
552
+
553
+ it "matches a range of day-of-year values as an inclusive range" do
554
+ schedule = described_class.parse( "yd {362-365}" )
555
+
556
+ expect( schedule ).to_not include( '2015-12-27T23:59:59-00:00' )
557
+ expect( schedule ).to include( '2015-12-28T00:00:00-00:00' )
558
+ expect( schedule ).to include( '2015-12-29T12:00:00-00:00' )
559
+ expect( schedule ).to include( '2015-12-30T18:00:00-00:00' )
560
+ expect( schedule ).to include( '2015-12-31T23:59:59-00:00' )
561
+ expect( schedule ).to_not include( '2016-01-01T00:00:00-00:00' )
562
+ end
563
+
564
+
565
+ it "matches a wrapped range of day-of-year values as two inclusive ranges" do
566
+ schedule = described_class.parse( "yd {362-15}" )
567
+
568
+ expect( schedule ).to_not include( '2015-12-27T23:59:59-00:00' )
569
+ expect( schedule ).to include( '2015-12-28T00:00:00-00:00' )
570
+ expect( schedule ).to include( '2015-12-29T12:00:00-00:00' )
571
+ expect( schedule ).to include( '2015-12-30T18:00:00-00:00' )
572
+ expect( schedule ).to include( '2015-12-31T23:59:59-00:00' )
573
+ expect( schedule ).to include( '2016-01-01T00:00:00-00:00' )
574
+ expect( schedule ).to include( '2016-01-11T00:00:00-00:00' )
575
+ expect( schedule ).to include( '2016-01-15T23:59:59-00:00' )
576
+ expect( schedule ).to_not include( '2016-01-16T00:00:00-00:00' )
577
+ end
578
+
579
+
580
+ it "handles day-of-year values during a leap-year correctly" do
581
+ schedule = described_class.parse( "yd {366}" )
582
+
583
+ expect( schedule ).to_not include( '2016-12-30T23:59:59-00:00' )
584
+ expect( schedule ).to include( '2016-12-31T00:00:00-00:00' )
585
+ expect( schedule ).to include( '2016-12-31T23:59:59-00:00' )
586
+ expect( schedule ).to_not include( '2017-01-01T00:00:00-00:00' )
587
+ end
588
+
589
+
590
+ it "matches single year values as a single-year exclusive range" do
591
+ schedule = described_class.parse( "yr {2019}" )
592
+
593
+ expect( schedule ).to_not include( '2018-12-31T23:59:59-00:00' )
594
+ expect( schedule ).to include( '2019-01-01T00:00:00-00:00' )
595
+ expect( schedule ).to include( '2019-06-15T00:00:00-00:00' )
596
+ expect( schedule ).to include( '2019-12-31T23:59:59-00:00' )
597
+ expect( schedule ).to_not include( '2020-01-01T00:00:00-00:00' )
598
+ end
599
+
600
+
601
+ it "matches year range values as multi-year inclusive ranges" do
602
+ schedule = described_class.parse( "yr {2009-2015}" )
603
+
604
+ expect( schedule ).to_not include( '2008-12-31T23:59:59-00:00' )
605
+ expect( schedule ).to include( '2009-01-01T00:00:00-00:00' )
606
+ expect( schedule ).to include( '2011-06-15T00:00:00-00:00' )
607
+ expect( schedule ).to include( '2015-12-31T23:59:59-00:00' )
608
+ expect( schedule ).to_not include( '2016-01-01T00:00:00-00:00' )
609
+ end
610
+
611
+
612
+ it "matches negative year range values as multi-year inclusive ranges" do
613
+ schedule = described_class.parse( "! yr {2009-2015}" )
614
+
615
+ expect( schedule ).to include( '2008-12-31T23:59:59-00:00' )
616
+ expect( schedule ).to_not include( '2009-01-01T00:00:00-00:00' )
617
+ expect( schedule ).to_not include( '2011-06-15T00:00:00-00:00' )
618
+ expect( schedule ).to_not include( '2015-12-31T23:59:59-00:00' )
619
+ expect( schedule ).to include( '2016-01-01T00:00:00-00:00' )
620
+ end
621
+
622
+
623
+ it "raises an error for wrapped year ranges" do
624
+ expect {
625
+ described_class.parse( "yr {2015-2013}" )
626
+ }.to raise_error( Schedulability::ParseError, /wrapped year range/i )
627
+ end
628
+
629
+
630
+ it "raises an error for invalid years" do
631
+ expect {
632
+ described_class.parse( "yr {76}" )
633
+ }.to raise_error( Schedulability::ParseError, /invalid year value: 76/i )
634
+ end
635
+
636
+
637
+ it "raises a parse error for invalid scales" do
638
+ expect {
639
+ described_class.parse( 'mil {2}' )
640
+ }.to raise_error( Schedulability::ParseError, /malformed schedule/i )
641
+ end
642
+
643
+
644
+ it "raises a parse error for invalid hour periods" do
645
+ expect {
646
+ described_class.parse( 'hr {2yt}' )
647
+ }.to raise_error( Schedulability::ParseError, /invalid hour range: "2yt"/i )
648
+ expect {
649
+ described_class.parse( 'hr {14pm}' )
650
+ }.to raise_error( Schedulability::ParseError, /invalid hour value: "14pm"/i )
651
+ expect {
652
+ described_class.parse( 'hr {14am}' )
653
+ }.to raise_error( Schedulability::ParseError, /invalid hour value: "14am"/i )
654
+ expect {
655
+ described_class.parse( 'hr {28}' )
656
+ }.to raise_error( Schedulability::ParseError, /invalid hour value: "28"/i )
657
+ end
658
+
659
+
660
+ it "raises a parse error for day of month values greater than 31" do
661
+ expect {
662
+ described_class.parse( 'md {11 21 88}' )
663
+ }.to raise_error( Schedulability::ParseError, /invalid mday value: 88/i )
664
+ end
665
+
666
+
667
+ it "raises a parse error for day of month ranges greater than 31" do
668
+ expect {
669
+ described_class.parse( 'md {28-35}' )
670
+ }.to raise_error( Schedulability::ParseError, /invalid mday value: 35/i )
671
+ end
672
+
673
+
674
+ it "raises a parse error for day of week numbers greater than 6" do
675
+ expect {
676
+ described_class.parse( 'wd {2 5 7}' )
677
+ }.to raise_error( Schedulability::ParseError, /invalid wday value: 7/i )
678
+ end
679
+
680
+
681
+ it "raises a parse error for day of week ranges which include a value greater than 6" do
682
+ expect {
683
+ described_class.parse( 'wd {8-2}' )
684
+ }.to raise_error( Schedulability::ParseError, /invalid wday value: 8/i )
685
+ end
686
+
687
+
688
+ it "raises a parse error for non-existant month names" do
689
+ expect {
690
+ described_class.parse( 'mo {Fit}' )
691
+ }.to raise_error( Schedulability::ParseError, /invalid month value: "Fit"/i )
692
+ end
693
+
694
+
695
+ it "raises a parse error for second values greater than 59" do
696
+ expect {
697
+ described_class.parse( 'sec {74 18}' )
698
+ }.to raise_error( Schedulability::ParseError, /invalid second value: 74/i )
699
+ expect {
700
+ described_class.parse( 'sec {60}' )
701
+ }.to raise_error( Schedulability::ParseError, /invalid second value: 60/i )
702
+ end
703
+
704
+
705
+ it "raises a parse error for second ranges with invalid values" do
706
+ expect {
707
+ described_class.parse( 'sec {55-60}' )
708
+ }.to raise_error( Schedulability::ParseError, /invalid second value: 60/i )
709
+ end
710
+
711
+
712
+ it "raises a parse error for minute values greater than 59" do
713
+ expect {
714
+ described_class.parse( 'min {09 28 68}' )
715
+ }.to raise_error( Schedulability::ParseError, /invalid minute value: 68/i )
716
+ expect {
717
+ described_class.parse( 'min {60}' )
718
+ }.to raise_error( Schedulability::ParseError, /invalid minute value: 60/i )
719
+ end
720
+
721
+
722
+ it "raises a parse error for minute ranges with invalid values" do
723
+ expect {
724
+ described_class.parse( 'min {120-15}' )
725
+ }.to raise_error( Schedulability::ParseError, /invalid minute value: 120/i )
726
+ end
727
+
728
+
729
+ it "raises a parse error for week values greater than 5" do
730
+ expect {
731
+ described_class.parse( 'wk {7}' )
732
+ }.to raise_error( Schedulability::ParseError, /invalid week value: 7/i )
733
+ end
734
+
735
+
736
+ it "raises a parse error for week ranges that have a value greater than 5" do
737
+ expect {
738
+ described_class.parse( 'wk {2-11}' )
739
+ }.to raise_error( Schedulability::ParseError, /invalid week value: 11/i )
740
+ end
741
+
742
+
743
+ it "supports pluralization syntactic sugar" do
744
+ expect(
745
+ described_class.parse("years {2001 2011 2021} months {Jul Sep}")
746
+ ).to be_a( described_class )
747
+ end
748
+
749
+ it "ignores whitespace in range values" do
750
+ schedule = described_class.parse( "sec { 18 - 55 }" )
751
+ expect( schedule ).to be_a( described_class )
752
+ end
753
+
754
+ end
755
+
756
+
757
+ describe "mutators" do
758
+
759
+
760
+ it "can calculate the union of two schedules" do
761
+ schedule1 = described_class.parse( 'md {1-15}' )
762
+ schedule2 = described_class.parse( 'month {Feb-Jul}' )
763
+ schedule3 = schedule1 | schedule2
764
+
765
+ expect( schedule3 ).to be == described_class.parse( 'md {1-15}, month {Feb-Jul}' )
766
+ end
767
+
768
+
769
+ it "can calculate the intersection of two schedules" do
770
+ schedule1 = described_class.parse( 'md {1-15} month {Mar-Jun}' )
771
+ schedule2 = described_class.parse( 'md {10-20} month {Feb-Jul}' )
772
+ schedule3 = schedule1 & schedule2
773
+
774
+ expect( schedule3 ).to be == described_class.parse( 'md {10-15} month {Mar-Jun}' )
775
+ end
776
+
777
+
778
+ it "returns an empty schedule as the intersection of two non-overlapping schedules" do
779
+ schedule1 = described_class.parse( 'hr {6am-8am} wday {Mon Wed Fri}' )
780
+ schedule2 = described_class.parse( 'wday {Thu Sat}' )
781
+ schedule3 = schedule1 & schedule2
782
+
783
+ expect( schedule3 ).to be_empty
784
+ end
785
+
786
+
787
+ it "can calculate unions of schedules with negated periods" do
788
+ schedule1 = described_class.parse( '! wday { Mon-Fri }' )
789
+ schedule2 = described_class.parse( '! wday { Thu }' )
790
+ schedule3 = schedule1 | schedule2
791
+
792
+ expect( schedule3 ).to be == schedule2
793
+ end
794
+
795
+
796
+ it "can calculate unions of schedules with negated periods that don't overlap" do
797
+ schedule1 = described_class.parse( '! wday { Wed }' )
798
+ schedule2 = described_class.parse( '! wday { Thu }' )
799
+ schedule3 = schedule1 | schedule2
800
+
801
+ expect( schedule3 ).to be_empty
802
+ end
803
+
804
+
805
+ it "can calculate intersections of schedules with negated periods" do
806
+ schedule1 = described_class.parse( '! wday { Wed }' )
807
+ schedule2 = described_class.parse( '! wday { Thu }' )
808
+ schedule3 = schedule1 & schedule2
809
+
810
+ expect( schedule3 ).to be == described_class.parse( '! wday {Wed}, ! wday {Thu}' )
811
+ end
812
+
813
+
814
+ it "can calculate the inverse of a schedule" do
815
+ schedule1 = described_class.parse( 'hr {8am-4pm} md {10-15}' )
816
+ schedule2 = ~schedule1
817
+
818
+ expect( schedule2 ).to include( '2015-01-09T08:00:00-00:00' )
819
+ expect( schedule2 ).to include( '2015-01-10T07:59:59-00:00' )
820
+ expect( schedule2 ).to_not include( '2015-01-10T08:00:00-00:00' )
821
+ expect( schedule2 ).to_not include( '2015-01-15T15:59:59-00:00' )
822
+ expect( schedule2 ).to include( '2015-01-15T16:00:00-00:00' )
823
+ end
824
+
825
+ end
826
+
827
+ end
828
+