business_day 3.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,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