business_day 3.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "business_day/calendar"
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "date"
5
+
6
+ module BusinessDay
7
+ class Calendar
8
+ VALID_KEYS = %w[holidays working_days extra_working_dates].freeze
9
+
10
+ class << self
11
+ attr_accessor :load_paths
12
+ end
13
+
14
+ def self.calendar_directories
15
+ @load_paths
16
+ end
17
+ private_class_method :calendar_directories
18
+
19
+ def self.load(calendar_name)
20
+ data = find_calendar_data(calendar_name)
21
+ raise "No such calendar '#{calendar_name}'" unless data
22
+
23
+ unless (data.keys - VALID_KEYS).empty?
24
+ raise "Only valid keys are: #{VALID_KEYS.join(', ')}"
25
+ end
26
+
27
+ new(
28
+ holidays: data["holidays"],
29
+ working_days: data["working_days"],
30
+ extra_working_dates: data["extra_working_dates"],
31
+ )
32
+ end
33
+
34
+ def self.find_calendar_data(calendar_name)
35
+ calendar_directories.detect do |path|
36
+ if path.is_a?(Hash)
37
+ break path[calendar_name] if path[calendar_name]
38
+ else
39
+ next unless File.exist?(File.join(path, "#{calendar_name}.yml"))
40
+
41
+ break YAML.load_file(File.join(path, "#{calendar_name}.yml"))
42
+ end
43
+ end
44
+ end
45
+
46
+ @lock = Mutex.new
47
+ def self.load_cached(calendar)
48
+ @lock.synchronize do
49
+ @cache ||= {}
50
+ @cache[calendar] = self.load(calendar) unless @cache.include?(calendar)
51
+ @cache[calendar]
52
+ end
53
+ end
54
+
55
+ DAY_NAMES = %( mon tue wed thu fri sat sun )
56
+
57
+ attr_reader :holidays, :working_days, :extra_working_dates
58
+
59
+ def initialize(config)
60
+ set_extra_working_dates(config[:extra_working_dates])
61
+ set_working_days(config[:working_days])
62
+ set_holidays(config[:holidays])
63
+
64
+ unless (@holidays & @extra_working_dates).none?
65
+ raise ArgumentError, "Holidays cannot be extra working dates"
66
+ end
67
+ end
68
+
69
+ # Return true if the date given is a business day (typically that means a
70
+ # non-weekend day) and not a holiday.
71
+ def business_day?(date)
72
+ date = date.to_date
73
+ working_day?(date) && !holiday?(date)
74
+ end
75
+
76
+ def working_day?(date)
77
+ date = date.to_date
78
+ extra_working_dates.include?(date) ||
79
+ working_days.include?(date.strftime("%a").downcase)
80
+ end
81
+
82
+ def holiday?(date)
83
+ holidays.include?(date.to_date)
84
+ end
85
+
86
+ # Roll forward to the next business day. If the date given is a business
87
+ # day, that day will be returned. If the day given is a holiday or
88
+ # non-working day, the next non-holiday working day will be returned.
89
+ def roll_forward(date)
90
+ date += day_interval_for(date) until business_day?(date)
91
+ date
92
+ end
93
+
94
+ # Roll backward to the previous business day. If the date given is a
95
+ # business day, that day will be returned. If the day given is a holiday or
96
+ # non-working day, the previous non-holiday working day will be returned.
97
+ def roll_backward(date)
98
+ date -= day_interval_for(date) until business_day?(date)
99
+ date
100
+ end
101
+
102
+ # Roll forward to the next business day regardless of whether the given
103
+ # date is a business day or not.
104
+ def next_business_day(date)
105
+ loop do
106
+ date += day_interval_for(date)
107
+ break date if business_day?(date)
108
+ end
109
+ end
110
+
111
+ # Roll backward to the previous business day regardless of whether the given
112
+ # date is a business day or not.
113
+ def previous_business_day(date)
114
+ loop do
115
+ date -= day_interval_for(date)
116
+ break date if business_day?(date)
117
+ end
118
+ end
119
+
120
+ # Add a number of business days to a date. If a non-business day is given,
121
+ # counting will start from the next business day. So,
122
+ # monday + 1 = tuesday
123
+ # friday + 1 = monday
124
+ # sunday + 1 = tuesday
125
+ def add_business_days(date, delta)
126
+ date = roll_forward(date)
127
+ delta.times do
128
+ loop do
129
+ date += day_interval_for(date)
130
+ break date if business_day?(date)
131
+ end
132
+ end
133
+ date
134
+ end
135
+
136
+ # Subtract a number of business days to a date. If a non-business day is
137
+ # given, counting will start from the previous business day. So,
138
+ # friday - 1 = thursday
139
+ # monday - 1 = friday
140
+ # sunday - 1 = thursday
141
+ def subtract_business_days(date, delta)
142
+ date = roll_backward(date)
143
+ delta.times do
144
+ loop do
145
+ date -= day_interval_for(date)
146
+ break date if business_day?(date)
147
+ end
148
+ end
149
+ date
150
+ end
151
+
152
+ # Count the number of business days between two dates.
153
+ # This method counts from start of date1 to start of date2. So,
154
+ # business_days_between(mon, weds) = 2 (assuming no holidays)
155
+ # rubocop:disable Metrics/AbcSize
156
+ # rubocop:disable Metrics/MethodLength
157
+ def business_days_between(date1, date2)
158
+ date1 = date1.to_date
159
+ date2 = date2.to_date
160
+
161
+ # To optimise this method we split the range into full weeks and a
162
+ # remaining period.
163
+ #
164
+ # We then calculate business days in the full weeks period by
165
+ # multiplying number of weeks by number of working days in a week and
166
+ # removing holidays one by one.
167
+
168
+ # For the remaining period, we just loop through each day and check
169
+ # whether it is a business day.
170
+
171
+ # Calculate number of full weeks and remaining days
172
+ num_full_weeks, remaining_days = (date2 - date1).to_i.divmod(7)
173
+
174
+ # First estimate for full week range based on # biz days in a week
175
+ num_biz_days = num_full_weeks * working_days.length
176
+
177
+ full_weeks_range = (date1...(date2 - remaining_days))
178
+ num_biz_days -= holidays.count do |holiday|
179
+ in_range = full_weeks_range.cover?(holiday)
180
+ # Only pick a holiday if its on a working day (e.g., not a weekend)
181
+ on_biz_day = working_days.include?(holiday.strftime("%a").downcase)
182
+ in_range && on_biz_day
183
+ end
184
+
185
+ remaining_range = (date2 - remaining_days...date2)
186
+ # Loop through each day in remaining_range and count if a business day
187
+ num_biz_days + remaining_range.count { |a| business_day?(a) }
188
+ end
189
+ # rubocop:enable Metrics/AbcSize
190
+ # rubocop:enable Metrics/MethodLength
191
+
192
+ def day_interval_for(date)
193
+ date.is_a?(Date) ? 1 : 3600 * 24
194
+ end
195
+
196
+ # Internal method for assigning working days from a calendar config.
197
+ def set_working_days(working_days)
198
+ @working_days = (working_days || default_working_days).map do |day|
199
+ day.downcase.strip[0..2].tap do |normalised_day|
200
+ raise "Invalid day #{day}" unless DAY_NAMES.include?(normalised_day)
201
+ end
202
+ end
203
+ extra_working_dates_names = @extra_working_dates.map do |d|
204
+ d.strftime("%a").downcase
205
+ end
206
+ return if (extra_working_dates_names & @working_days).none?
207
+
208
+ raise ArgumentError, "Extra working dates cannot be on working days"
209
+ end
210
+
211
+ def parse_dates(dates)
212
+ (dates || []).map { |date| date.is_a?(Date) ? date : Date.parse(date) }
213
+ end
214
+
215
+ # Internal method for assigning holidays from a calendar config.
216
+ def set_holidays(holidays)
217
+ @holidays = parse_dates(holidays)
218
+ end
219
+
220
+ def set_extra_working_dates(extra_working_dates)
221
+ @extra_working_dates = parse_dates(extra_working_dates)
222
+ end
223
+
224
+ # If no working days are provided in the calendar config, these are used.
225
+ def default_working_days
226
+ %w[mon tue wed thu fri]
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BusinessDay
4
+ VERSION = "3.1.0"
5
+ end
@@ -0,0 +1,935 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "business/calendar"
4
+ require "time"
5
+
6
+ RSpec.configure do |config|
7
+ config.mock_with(:rspec) { |mocks| mocks.verify_partial_doubles = true }
8
+ config.raise_errors_for_deprecations!
9
+ end
10
+
11
+ RSpec.describe BusinessDay::Calendar do
12
+ describe ".load" do
13
+ subject(:load_calendar) { described_class.load(calendar) }
14
+
15
+ let(:dummy_calendar) { { "working_days" => ["monday"] } }
16
+
17
+ before do
18
+ fixture_path = File.join(File.dirname(__FILE__), "../fixtures", "calendars")
19
+ described_class.load_paths = [fixture_path, { "foobar" => dummy_calendar }]
20
+ end
21
+
22
+ context "when given a calendar from a custom directory" do
23
+ let(:calendar) { "ecb" }
24
+
25
+ after { described_class.load_paths = nil }
26
+
27
+ it "loads the yaml file" do
28
+ expect(YAML).to receive(:load_file).with(/ecb\.yml$/).and_return({})
29
+
30
+ load_calendar
31
+ end
32
+
33
+ it { is_expected.to be_a described_class }
34
+
35
+ context "that also exists as a default calendar" do
36
+ let(:calendar) { "bacs" }
37
+
38
+ it "uses the custom calendar" do
39
+ expect(load_calendar.business_day?(Date.parse("25th December 2014"))).
40
+ to eq(true)
41
+ end
42
+ end
43
+ end
44
+
45
+ context "when loading a calendar as a hash" do
46
+ let(:calendar) { "foobar" }
47
+
48
+ it { is_expected.to be_a described_class }
49
+ end
50
+
51
+ context "when given a calendar that does not exist" do
52
+ let(:calendar) { "invalid-calendar" }
53
+
54
+ specify { expect { load_calendar }.to raise_error(/No such calendar/) }
55
+ end
56
+
57
+ context "when given a calendar that has invalid keys" do
58
+ let(:calendar) { "invalid-keys" }
59
+
60
+ specify do
61
+ expect { load_calendar }.
62
+ to raise_error(
63
+ "Only valid keys are: holidays, working_days, extra_working_dates",
64
+ )
65
+ end
66
+ end
67
+
68
+ context "when given real business data" do
69
+ let(:data_path) do
70
+ File.join(File.dirname(__FILE__), "..", "lib", "business", "data")
71
+ end
72
+
73
+ it "validates they are all loadable by the calendar" do
74
+ Dir.glob("#{data_path}/*").each do |filename|
75
+ calendar_name = File.basename(filename, ".yml")
76
+ calendar = described_class.load(calendar_name)
77
+
78
+ expect(calendar.working_days.length).to be >= 1
79
+ end
80
+ end
81
+ end
82
+ end
83
+
84
+ describe "#set_working_days" do
85
+ subject(:set_working_days) { calendar.set_working_days(working_days) }
86
+
87
+ let(:calendar) { described_class.new({}) }
88
+ let(:working_days) { [] }
89
+
90
+ context "when given valid working days" do
91
+ let(:working_days) { %w[mon fri] }
92
+
93
+ before { set_working_days }
94
+
95
+ it "assigns them" do
96
+ expect(calendar.working_days).to eq(working_days)
97
+ end
98
+
99
+ context "that are unnormalised" do
100
+ let(:working_days) { %w[Monday Friday] }
101
+
102
+ it "normalises them" do
103
+ expect(calendar.working_days).to eq(%w[mon fri])
104
+ end
105
+ end
106
+ end
107
+
108
+ context "when given an invalid business day" do
109
+ let(:working_days) { %w[Notaday] }
110
+
111
+ specify { expect { set_working_days }.to raise_error(/Invalid day/) }
112
+ end
113
+
114
+ context "when given nil" do
115
+ let(:working_days) { nil }
116
+
117
+ it "uses the default business days" do
118
+ expect(calendar.working_days).to eq(calendar.default_working_days)
119
+ end
120
+ end
121
+ end
122
+
123
+ describe "#set_holidays" do
124
+ subject(:holidays) { calendar.holidays }
125
+
126
+ let(:calendar) { described_class.new({}) }
127
+ let(:holiday_dates) { [] }
128
+
129
+ before { calendar.set_holidays(holiday_dates) }
130
+
131
+ context "when given valid business days" do
132
+ let(:holiday_dates) { ["1st Jan, 2013"] }
133
+
134
+ it { is_expected.to_not be_empty }
135
+
136
+ it "converts them to Date objects" do
137
+ expect(holidays).to all be_a Date
138
+ end
139
+ end
140
+
141
+ context "when given nil" do
142
+ let(:holiday_dates) { nil }
143
+
144
+ it { is_expected.to be_empty }
145
+ end
146
+ end
147
+
148
+ describe "#set_extra_working_dates" do
149
+ subject(:extra_working_dates) { calendar.extra_working_dates }
150
+
151
+ let(:calendar) { described_class.new({}) }
152
+ let(:extra_dates) { [] }
153
+
154
+ before { calendar.set_extra_working_dates(extra_dates) }
155
+
156
+ context "when given valid business days" do
157
+ let(:extra_dates) { ["1st Jan, 2013"] }
158
+
159
+ it { is_expected.to_not be_empty }
160
+
161
+ it "converts them to Date objects" do
162
+ expect(extra_working_dates).to all be_a Date
163
+ end
164
+ end
165
+
166
+ context "when given nil" do
167
+ let(:holidays) { nil }
168
+
169
+ it { is_expected.to be_empty }
170
+ end
171
+ end
172
+
173
+ context "when holiday is also a working date" do
174
+ let(:instance) do
175
+ described_class.new(holidays: ["2018-01-06"],
176
+ extra_working_dates: ["2018-01-06"])
177
+ end
178
+
179
+ it do
180
+ expect { instance }.to raise_error(ArgumentError).
181
+ with_message("Holidays cannot be extra working dates")
182
+ end
183
+ end
184
+
185
+ context "when working date on working day" do
186
+ let(:instance) do
187
+ described_class.new(working_days: ["mon"],
188
+ extra_working_dates: ["Monday 26th Mar, 2018"])
189
+ end
190
+
191
+ it do
192
+ expect { instance }.to raise_error(ArgumentError).
193
+ with_message("Extra working dates cannot be on working days")
194
+ end
195
+ end
196
+
197
+ # A set of examples that are supposed to work when given Date and Time
198
+ # objects. The implementation slightly differs, so i's worth running the
199
+ # tests for both Date *and* Time.
200
+ shared_examples "common" do
201
+ describe "#business_day?" do
202
+ subject { calendar.business_day?(day) }
203
+
204
+ let(:calendar) do
205
+ described_class.new(holidays: ["9am, Tuesday 1st Jan, 2013"],
206
+ extra_working_dates: ["9am, Sunday 6th Jan, 2013"])
207
+ end
208
+
209
+ context "when given a business day" do
210
+ let(:day) { date_class.parse("9am, Wednesday 2nd Jan, 2013") }
211
+
212
+ it { is_expected.to be_truthy }
213
+ end
214
+
215
+ context "when given a non-business day" do
216
+ let(:day) { date_class.parse("9am, Saturday 5th Jan, 2013") }
217
+
218
+ it { is_expected.to be_falsey }
219
+ end
220
+
221
+ context "when given a business day that is a holiday" do
222
+ let(:day) { date_class.parse("9am, Tuesday 1st Jan, 2013") }
223
+
224
+ it { is_expected.to be_falsey }
225
+ end
226
+
227
+ context "when given a non-business day that is a working date" do
228
+ let(:day) { date_class.parse("9am, Sunday 6th Jan, 2013") }
229
+
230
+ it { is_expected.to be_truthy }
231
+ end
232
+ end
233
+
234
+ describe "#working_day?" do
235
+ subject { calendar.working_day?(day) }
236
+
237
+ let(:calendar) do
238
+ described_class.new(holidays: ["9am, Tuesday 1st Jan, 2013"],
239
+ extra_working_dates: ["9am, Sunday 6th Jan, 2013"])
240
+ end
241
+
242
+ context "when given a working day" do
243
+ let(:day) { date_class.parse("9am, Wednesday 2nd Jan, 2013") }
244
+
245
+ it { is_expected.to be_truthy }
246
+ end
247
+
248
+ context "when given a non-working day" do
249
+ let(:day) { date_class.parse("9am, Saturday 5th Jan, 2013") }
250
+
251
+ it { is_expected.to be_falsey }
252
+ end
253
+
254
+ context "when given a working day that is a holiday" do
255
+ let(:day) { date_class.parse("9am, Tuesday 1st Jan, 2013") }
256
+
257
+ it { is_expected.to be_truthy }
258
+ end
259
+
260
+ context "when given a non-business day that is a working date" do
261
+ let(:day) { date_class.parse("9am, Sunday 6th Jan, 2013") }
262
+
263
+ it { is_expected.to be_truthy }
264
+ end
265
+ end
266
+
267
+ describe "#holiday?" do
268
+ subject { calendar.holiday?(day) }
269
+
270
+ let(:calendar) do
271
+ described_class.new(holidays: ["9am, Tuesday 1st Jan, 2013"],
272
+ extra_working_dates: ["9am, Sunday 6th Jan, 2013"])
273
+ end
274
+
275
+ context "when given a working day that is not a holiday" do
276
+ let(:day) { date_class.parse("9am, Wednesday 2nd Jan, 2013") }
277
+
278
+ it { is_expected.to be_falsey }
279
+ end
280
+
281
+ context "when given a non-working day that is not a holiday day" do
282
+ let(:day) { date_class.parse("9am, Saturday 5th Jan, 2013") }
283
+
284
+ it { is_expected.to be_falsey }
285
+ end
286
+
287
+ context "when given a day that is a holiday" do
288
+ let(:day) { date_class.parse("9am, Tuesday 1st Jan, 2013") }
289
+
290
+ it { is_expected.to be_truthy }
291
+ end
292
+
293
+ context "when given a non-business day that is no a holiday" do
294
+ let(:day) { date_class.parse("9am, Sunday 6th Jan, 2013") }
295
+
296
+ it { is_expected.to be_falsey }
297
+ end
298
+ end
299
+
300
+ describe "#roll_forward" do
301
+ subject { calendar.roll_forward(date) }
302
+
303
+ let(:calendar) do
304
+ described_class.new(holidays: ["Tuesday 1st Jan, 2013"])
305
+ end
306
+
307
+ context "given a business day" do
308
+ let(:date) { date_class.parse("Wednesday 2nd Jan, 2013") }
309
+
310
+ it { is_expected.to eq(date) }
311
+ end
312
+
313
+ context "given a non-business day" do
314
+ context "with a business day following it" do
315
+ let(:date) { date_class.parse("Tuesday 1st Jan, 2013") }
316
+
317
+ it { is_expected.to eq(date + day_interval) }
318
+ end
319
+
320
+ context "followed by another non-business day" do
321
+ let(:date) { date_class.parse("Saturday 5th Jan, 2013") }
322
+
323
+ it { is_expected.to eq(date + 2 * day_interval) }
324
+ end
325
+ end
326
+ end
327
+
328
+ describe "#roll_backward" do
329
+ subject { calendar.roll_backward(date) }
330
+
331
+ let(:calendar) do
332
+ described_class.new(holidays: ["Tuesday 1st Jan, 2013"])
333
+ end
334
+
335
+ context "given a business day" do
336
+ let(:date) { date_class.parse("Wednesday 2nd Jan, 2013") }
337
+
338
+ it { is_expected.to eq(date) }
339
+ end
340
+
341
+ context "given a non-business day" do
342
+ context "with a business day preceeding it" do
343
+ let(:date) { date_class.parse("Tuesday 1st Jan, 2013") }
344
+
345
+ it { is_expected.to eq(date - day_interval) }
346
+ end
347
+
348
+ context "preceeded by another non-business day" do
349
+ let(:date) { date_class.parse("Sunday 6th Jan, 2013") }
350
+
351
+ it { is_expected.to eq(date - 2 * day_interval) }
352
+ end
353
+ end
354
+ end
355
+
356
+ describe "#next_business_day" do
357
+ subject { calendar.next_business_day(date) }
358
+
359
+ let(:calendar) do
360
+ described_class.new(holidays: ["Tuesday 1st Jan, 2013"])
361
+ end
362
+
363
+ context "given a business day" do
364
+ let(:date) { date_class.parse("Wednesday 2nd Jan, 2013") }
365
+
366
+ it { is_expected.to eq(date + day_interval) }
367
+ end
368
+
369
+ context "given a non-business day" do
370
+ context "with a business day following it" do
371
+ let(:date) { date_class.parse("Tuesday 1st Jan, 2013") }
372
+
373
+ it { is_expected.to eq(date + day_interval) }
374
+ end
375
+
376
+ context "followed by another non-business day" do
377
+ let(:date) { date_class.parse("Saturday 5th Jan, 2013") }
378
+
379
+ it { is_expected.to eq(date + 2 * day_interval) }
380
+ end
381
+ end
382
+ end
383
+
384
+ describe "#previous_business_day" do
385
+ subject { calendar.previous_business_day(date) }
386
+
387
+ let(:calendar) do
388
+ described_class.new(holidays: ["Tuesday 1st Jan, 2013"])
389
+ end
390
+
391
+ context "given a business day" do
392
+ let(:date) { date_class.parse("Thursday 3nd Jan, 2013") }
393
+
394
+ it { is_expected.to eq(date - day_interval) }
395
+ end
396
+
397
+ context "given a non-business day" do
398
+ context "with a business day before it" do
399
+ let(:date) { date_class.parse("Tuesday 1st Jan, 2013") }
400
+
401
+ it { is_expected.to eq(date - day_interval) }
402
+ end
403
+
404
+ context "preceeded by another non-business day" do
405
+ let(:date) { date_class.parse("Sunday 6th Jan, 2013") }
406
+
407
+ it { is_expected.to eq(date - 2 * day_interval) }
408
+ end
409
+ end
410
+ end
411
+
412
+ describe "#add_business_days" do
413
+ subject { calendar.add_business_days(date, delta) }
414
+
415
+ let(:extra_working_dates) { [] }
416
+ let(:calendar) do
417
+ described_class.new(holidays: ["Tuesday 1st Jan, 2013"],
418
+ extra_working_dates: extra_working_dates)
419
+ end
420
+ let(:delta) { 2 }
421
+
422
+ context "given a business day" do
423
+ context "and a period that includes only business days" do
424
+ let(:date) { date_class.parse("Wednesday 2nd Jan, 2013") }
425
+
426
+ it { is_expected.to eq(date + delta * day_interval) }
427
+ end
428
+
429
+ context "and a period that includes a weekend" do
430
+ let(:date) { date_class.parse("Friday 4th Jan, 2013") }
431
+
432
+ it { is_expected.to eq(date + (delta + 2) * day_interval) }
433
+ end
434
+
435
+ context "and a period that includes a working date weekend" do
436
+ let(:extra_working_dates) { ["Sunday 6th Jan, 2013"] }
437
+ let(:date) { date_class.parse("Friday 4th Jan, 2013") }
438
+
439
+ it { is_expected.to eq(date + (delta + 1) * day_interval) }
440
+ end
441
+
442
+ context "and a period that includes a holiday day" do
443
+ let(:date) { date_class.parse("Monday 31st Dec, 2012") }
444
+
445
+ it { is_expected.to eq(date + (delta + 1) * day_interval) }
446
+ end
447
+ end
448
+
449
+ context "given a non-business day" do
450
+ let(:date) { date_class.parse("Tuesday 1st Jan, 2013") }
451
+
452
+ it { is_expected.to eq(date + (delta + 1) * day_interval) }
453
+ end
454
+ end
455
+
456
+ describe "#subtract_business_days" do
457
+ subject { calendar.subtract_business_days(date, delta) }
458
+
459
+ let(:extra_working_dates) { [] }
460
+ let(:calendar) do
461
+ described_class.new(holidays: ["Thursday 3rd Jan, 2013"],
462
+ extra_working_dates: extra_working_dates)
463
+ end
464
+ let(:delta) { 2 }
465
+
466
+ context "given a business day" do
467
+ context "and a period that includes only business days" do
468
+ let(:date) { date_class.parse("Wednesday 2nd Jan, 2013") }
469
+
470
+ it { is_expected.to eq(date - delta * day_interval) }
471
+ end
472
+
473
+ context "and a period that includes a weekend" do
474
+ let(:date) { date_class.parse("Monday 31st Dec, 2012") }
475
+
476
+ it { is_expected.to eq(date - (delta + 2) * day_interval) }
477
+ end
478
+
479
+ context "and a period that includes a working date weekend" do
480
+ let(:extra_working_dates) { ["Saturday 29th Dec, 2012"] }
481
+ let(:date) { date_class.parse("Monday 31st Dec, 2012") }
482
+
483
+ it { is_expected.to eq(date - (delta + 1) * day_interval) }
484
+ end
485
+
486
+ context "and a period that includes a holiday day" do
487
+ let(:date) { date_class.parse("Friday 4th Jan, 2013") }
488
+
489
+ it { is_expected.to eq(date - (delta + 1) * day_interval) }
490
+ end
491
+ end
492
+
493
+ context "given a non-business day" do
494
+ let(:date) { date_class.parse("Thursday 3rd Jan, 2013") }
495
+
496
+ it { is_expected.to eq(date - (delta + 1) * day_interval) }
497
+ end
498
+ end
499
+
500
+ describe "#business_days_between" do
501
+ subject do
502
+ calendar.business_days_between(date_class.parse(date_1),
503
+ date_class.parse(date_2))
504
+ end
505
+
506
+ let(:holidays) do
507
+ ["Wed 27/5/2014", "Thu 12/6/2014", "Wed 18/6/2014", "Fri 20/6/2014",
508
+ "Sun 22/6/2014", "Fri 27/6/2014", "Thu 3/7/2014"]
509
+ end
510
+ let(:extra_working_dates) do
511
+ ["Sun 1/6/2014", "Sat 28/6/2014", "Sat 5/7/2014"]
512
+ end
513
+ let(:calendar) do
514
+ described_class.new(holidays: holidays, extra_working_dates: extra_working_dates)
515
+ end
516
+
517
+ context "starting on a business day" do
518
+ let(:date_1) { "Mon 2/6/2014" }
519
+
520
+ context "ending on a business day" do
521
+ context "including only business days" do
522
+ let(:date_2) { "Thu 5/6/2014" }
523
+
524
+ it { is_expected.to eq(3) }
525
+ end
526
+
527
+ context "including only business days & weekend days" do
528
+ let(:date_2) { "Mon 9/6/2014" }
529
+
530
+ it { is_expected.to eq(5) }
531
+ end
532
+
533
+ context "including only business days, weekend days & working date" do
534
+ let(:date_1) { "Thu 29/5/2014" }
535
+ let(:date_2) { "The 3/6/2014" }
536
+
537
+ it { is_expected.to be(4) }
538
+ end
539
+
540
+ context "including only business days & holidays" do
541
+ let(:date_1) { "Mon 9/6/2014" }
542
+ let(:date_2) { "Fri 13/6/2014" }
543
+
544
+ it { is_expected.to eq(3) }
545
+ end
546
+
547
+ context "including business, weekend days, and holidays" do
548
+ let(:date_2) { "Fri 13/6/2014" }
549
+
550
+ it { is_expected.to eq(8) }
551
+ end
552
+
553
+ context "including business, weekend, hoilday days & working date" do
554
+ let(:date_1) { "Thu 26/6/2014" }
555
+ let(:date_2) { "The 1/7/2014" }
556
+
557
+ it { is_expected.to be(3) }
558
+ end
559
+ end
560
+
561
+ context "ending on a weekend day" do
562
+ context "including only business days & weekend days" do
563
+ let(:date_2) { "Sun 8/6/2014" }
564
+
565
+ it { is_expected.to eq(5) }
566
+ end
567
+
568
+ context "including business & weekend days & working date" do
569
+ let(:date_1) { "Thu 29/5/2014" }
570
+ let(:date_2) { "Sun 3/6/2014" }
571
+
572
+ it { is_expected.to eq(4) }
573
+ end
574
+
575
+ context "including business, weekend days, and holidays" do
576
+ let(:date_2) { "Sat 14/6/2014" }
577
+
578
+ it { is_expected.to eq(9) }
579
+ end
580
+
581
+ context "including business, weekend & holiday days & working date" do
582
+ let(:date_1) { "Thu 26/6/2014" }
583
+ let(:date_2) { "Tue 2/7/2014" }
584
+
585
+ it { is_expected.to eq(4) }
586
+ end
587
+ end
588
+
589
+ context "ending on a holiday" do
590
+ context "including only business days & holidays" do
591
+ let(:date_1) { "Mon 9/6/2014" }
592
+ let(:date_2) { "Thu 12/6/2014" }
593
+
594
+ it { is_expected.to eq(3) }
595
+ end
596
+
597
+ context "including business, weekend days, and holidays" do
598
+ let(:date_2) { "Thu 12/6/2014" }
599
+
600
+ it { is_expected.to eq(8) }
601
+ end
602
+
603
+ context "including business, weekend, holiday days & business date" do
604
+ let(:date_1) { "Wed 28/5/2014" }
605
+ let(:date_2) { "Thu 12/6/2014" }
606
+
607
+ it { is_expected.to eq(11) }
608
+ end
609
+ end
610
+
611
+ context "ending on a working date" do
612
+ let(:date_1) { "Fri 4/7/2014" }
613
+
614
+ context "including only business days & working date" do
615
+ let(:date_2) { "Sat 5/7/2014" }
616
+
617
+ it { is_expected.to eq(1) }
618
+ end
619
+
620
+ context "including business, weekend days & working date" do
621
+ let(:date_2) { "Tue 8/7/2014" }
622
+
623
+ it { is_expected.to eq(3) }
624
+ end
625
+
626
+ context "including business, weekend days, holidays & working date" do
627
+ let(:date_1) { "Wed 25/6/2014" }
628
+ let(:date_2) { "Tue 8/7/2014" }
629
+
630
+ it { is_expected.to eq(8) }
631
+ end
632
+ end
633
+ end
634
+
635
+ context "starting on a weekend" do
636
+ let(:date_1) { "Sat 7/6/2014" }
637
+
638
+ context "ending on a business day" do
639
+ context "including only business days & weekend days" do
640
+ let(:date_2) { "Mon 9/6/2014" }
641
+
642
+ it { is_expected.to eq(0) }
643
+ end
644
+
645
+ context "including business, weekend days & working date" do
646
+ let(:date_1) { "Sat 31/5/2014" }
647
+ let(:date_2) { "Tue 3/6/2014" }
648
+
649
+ it { is_expected.to eq(2) }
650
+ end
651
+
652
+ context "including business, weekend days, and holidays" do
653
+ let(:date_2) { "Fri 13/6/2014" }
654
+
655
+ it { is_expected.to eq(3) }
656
+ end
657
+
658
+ context "including business, weekend, holilday days & working date" do
659
+ let(:date_1) { "Sat 31/5/2014" }
660
+ let(:date_2) { "Fri 13/6/2014" }
661
+
662
+ it { is_expected.to eq(8) }
663
+ end
664
+ end
665
+
666
+ context "ending on a weekend day" do
667
+ context "including only business days & weekend days" do
668
+ let(:date_2) { "Sun 8/6/2014" }
669
+
670
+ it { is_expected.to eq(0) }
671
+ end
672
+
673
+ context "including business, weekend days & working date" do
674
+ let(:date_1) { "Sat 31/5/2014" }
675
+ let(:date_2) { "Sun 8/6/2014" }
676
+
677
+ it { is_expected.to be(5) }
678
+ end
679
+
680
+ context "including business, weekend days, and holidays" do
681
+ let(:date_2) { "Sat 14/6/2014" }
682
+
683
+ it { is_expected.to eq(4) }
684
+ end
685
+
686
+ context "including business, weekend, holiday days & working date" do
687
+ let(:date_1) { "Sat 31/5/2014" }
688
+ let(:date_2) { "Sun 14/6/2014" }
689
+
690
+ it { is_expected.to be(9) }
691
+ end
692
+ end
693
+
694
+ context "ending on a holiday" do
695
+ context "including business, weekend days, and holidays" do
696
+ let(:date_2) { "Thu 12/6/2014" }
697
+
698
+ it { is_expected.to eq(3) }
699
+ end
700
+
701
+ context "including business, weekend days & working date" do
702
+ let(:date_1) { "Sat 31/5/2014" }
703
+ let(:date_2) { "Thu 12/6/2014" }
704
+
705
+ it { is_expected.to eq(8) }
706
+ end
707
+ end
708
+
709
+ context "ending on a working date" do
710
+ let(:date_1) { "Sat 31/5/2014" }
711
+
712
+ context "including only weekend days & working date" do
713
+ let(:date_2) { "Sat 2/6/2014" }
714
+
715
+ it { is_expected.to eq(1) }
716
+ end
717
+
718
+ context "including business, weekend days & working date" do
719
+ let(:date_2) { "Tue 4/6/2014" }
720
+
721
+ it { is_expected.to eq(3) }
722
+ end
723
+
724
+ context "including business, weekend days, holidays & working date" do
725
+ let(:date_2) { "Tue 13/6/2014" }
726
+
727
+ it { is_expected.to eq(8) }
728
+ end
729
+ end
730
+ end
731
+
732
+ context "starting on a holiday" do
733
+ let(:date_1) { "Thu 12/6/2014" }
734
+
735
+ context "ending on a business day" do
736
+ context "including only business days & holidays" do
737
+ let(:date_2) { "Fri 13/6/2014" }
738
+
739
+ it { is_expected.to eq(0) }
740
+ end
741
+
742
+ context "including business, weekend days, and holidays" do
743
+ let(:date_2) { "Thu 19/6/2014" }
744
+
745
+ it { is_expected.to eq(3) }
746
+ end
747
+
748
+ context "including business, weekend days, holidays & working date" do
749
+ let(:date_1) { "Fri 27/6/2014" }
750
+ let(:date_2) { "Tue 1/7/2014" }
751
+
752
+ it { is_expected.to eq(2) }
753
+ end
754
+ end
755
+
756
+ context "ending on a weekend day" do
757
+ context "including business, weekend days, and holidays" do
758
+ let(:date_2) { "Sun 15/6/2014" }
759
+
760
+ it { is_expected.to eq(1) }
761
+ end
762
+
763
+ context "including business, weekend days, holidays & working date" do
764
+ let(:date_1) { "Fri 27/6/2014" }
765
+ let(:date_2) { "Sun 29/6/2014" }
766
+
767
+ it { is_expected.to eq(1) }
768
+ end
769
+ end
770
+
771
+ context "ending on a holiday" do
772
+ context "including only business days & holidays" do
773
+ let(:date_1) { "Wed 18/6/2014" }
774
+ let(:date_2) { "Fri 20/6/2014" }
775
+
776
+ it { is_expected.to eq(1) }
777
+ end
778
+
779
+ context "including business, weekend days, and holidays" do
780
+ let(:date_2) { "Wed 18/6/2014" }
781
+
782
+ it { is_expected.to eq(3) }
783
+ end
784
+
785
+ context "including business/weekend days, holidays & working date" do
786
+ let(:date_1) { "27/5/2014" }
787
+ let(:date_2) { "Thu 12/6/2014" }
788
+
789
+ it { is_expected.to eq(11) }
790
+ end
791
+ end
792
+
793
+ context "ending on a working date" do
794
+ let(:date_1) { "Sat 27/6/2014" }
795
+
796
+ context "including only holiday & working date" do
797
+ let(:date_2) { "Sat 29/6/2014" }
798
+
799
+ it { is_expected.to eq(1) }
800
+ end
801
+
802
+ context "including holiday, weekend days & working date" do
803
+ let(:date_2) { "Tue 30/6/2014" }
804
+
805
+ it { is_expected.to eq(1) }
806
+ end
807
+
808
+ context "including business, weekend days, holidays & working date" do
809
+ let(:date_2) { "Tue 2/7/2014" }
810
+
811
+ it { is_expected.to eq(3) }
812
+ end
813
+ end
814
+ end
815
+
816
+ context "starting on a working date" do
817
+ let(:date_1) { "Sun 1/6/2014" }
818
+
819
+ context "ending on a working day" do
820
+ context "including only working date & working day" do
821
+ let(:date_2) { "Wed 4/6/2014" }
822
+
823
+ it { is_expected.to eq(3) }
824
+ end
825
+
826
+ context "including working date, working & weekend days" do
827
+ let(:date_2) { "Tue 10/6/2014" }
828
+
829
+ it { is_expected.to eq(6) }
830
+ end
831
+
832
+ context "including working date, working & weekend days & holiday" do
833
+ let(:date_2) { "Tue 13/6/2014" }
834
+
835
+ it { is_expected.to eq(8) }
836
+ end
837
+ end
838
+
839
+ context "ending on a weekend day" do
840
+ let(:date_1) { "Sat 28/6/2014" }
841
+
842
+ context "including only working date & weekend day" do
843
+ let(:date_2) { "Sun 29/6/2014" }
844
+
845
+ it { is_expected.to eq(1) }
846
+ end
847
+
848
+ context "including working date, weekend & working days" do
849
+ let(:date_1) { "Sat 5/7/2014" }
850
+ let(:date_2) { "Wed 9/7/2014" }
851
+
852
+ it { is_expected.to eq(3) }
853
+ end
854
+
855
+ context "including working date, weekend & working days & holiday" do
856
+ let(:date_2) { "Fri 4/7/2014" }
857
+
858
+ it { is_expected.to eq(4) }
859
+ end
860
+ end
861
+
862
+ context "ending on a holiday" do
863
+ let(:date_1) { "Sat 28/6/2014" }
864
+
865
+ context "including only working date & holiday" do
866
+ let(:holidays) { ["Mon 2/6/2014"] }
867
+ let(:date_1) { "Sun 1/6/2014" }
868
+ let(:date_2) { "Mon 2/6/2014" }
869
+
870
+ it { is_expected.to eq(1) }
871
+ end
872
+
873
+ context "including working date, holiday & weekend day" do
874
+ let(:holidays) { ["Mon 30/6/2014"] }
875
+ let(:date_2) { "Mon 30/6/2014" }
876
+
877
+ it { is_expected.to eq(1) }
878
+ end
879
+
880
+ context "including working date, holiday, weekend & working days" do
881
+ let(:date_2) { "Thu 3/7/2014" }
882
+
883
+ it { is_expected.to eq(4) }
884
+ end
885
+ end
886
+
887
+ context "ending on a working date" do
888
+ context "including working dates, weekend & working days" do
889
+ let(:date_1) { "Sat 28/6/2014" }
890
+ let(:date_2) { "Sat 5/7/2014" }
891
+
892
+ it { is_expected.to eq(4) }
893
+ end
894
+ end
895
+ end
896
+
897
+ context "if a calendar has a holiday on a non-working (weekend) day" do
898
+ context "for a range less than a week long" do
899
+ let(:date_1) { "Thu 19/6/2014" }
900
+ let(:date_2) { "Tue 24/6/2014" }
901
+
902
+ it { is_expected.to eq(2) }
903
+ end
904
+
905
+ context "for a range more than a week long" do
906
+ let(:date_1) { "Mon 16/6/2014" }
907
+ let(:date_2) { "Tue 24/6/2014" }
908
+
909
+ it { is_expected.to eq(4) }
910
+ end
911
+ end
912
+ end
913
+ end
914
+
915
+ context "(using Date objects)" do
916
+ let(:date_class) { Date }
917
+ let(:day_interval) { 1 }
918
+
919
+ it_behaves_like "common"
920
+ end
921
+
922
+ context "(using Time objects)" do
923
+ let(:date_class) { Time }
924
+ let(:day_interval) { 3600 * 24 }
925
+
926
+ it_behaves_like "common"
927
+ end
928
+
929
+ context "(using DateTime objects)" do
930
+ let(:date_class) { DateTime }
931
+ let(:day_interval) { 1 }
932
+
933
+ it_behaves_like "common"
934
+ end
935
+ end