schedulability 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+