working_hours 1.3.0 → 1.4.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4bdb747e39a652368403adb9ad97f3776a8d8ab28f841d70912bcab6141118ea
4
- data.tar.gz: 74e1447b590b829f95e52fe3c5bac18e7a690dcc8b21683402982f5810b94d8c
3
+ metadata.gz: 9d51f59c3456e35117b42605450d911595a331ef6a72303aba33a6eedcdabd6f
4
+ data.tar.gz: '0527249d73eb1da76205e5b02bd7d67226a105c91f2a14240ddfd39abc0daeb6'
5
5
  SHA512:
6
- metadata.gz: ee953af27c8d6622d8089feec9cd5b966613baff8239c11e488668ad07a48023efbd98d28599f9723c913b7e295ced2ac2b4cb5ef70711c4950ed8afea2aa107
7
- data.tar.gz: 5b632e842d20ff83173c83a3e34c4bf166237f3689ba130db2cfd3031e027078f1efa1acbddac588198d78b07f3775c94e88f6d83fba328a9400629e92fab107
6
+ metadata.gz: b276429238c21dbb526c53d4be5082450c99b5c5414d9b21786ec4278ef83ff07c6e2a952cdf6bbb9125f0b32b013f7d2b009cadb020b062a780dcf119d733aa
7
+ data.tar.gz: 77524f554091fccfdd9b96ea0ff64295fb55a8358f010adac5f008455428fd91c3b8ef80111f515529f2059b4e3db6081177b3563b2c16c83ee5d6e829e27789
data/.gitignore CHANGED
@@ -6,6 +6,7 @@
6
6
  .ruby-gemset
7
7
  .ruby-version
8
8
  Gemfile.lock
9
+ gemfiles/*.lock
9
10
  InstalledFiles
10
11
  _yardoc
11
12
  coverage
data/.travis.yml CHANGED
@@ -1,9 +1,10 @@
1
1
  language: ruby
2
+ dist: bionic
2
3
  rvm:
3
- - 2.4.9
4
- - 2.5.7
5
- - 2.6.5
6
- - 2.7.0
4
+ - 2.4.10
5
+ - 2.5.8
6
+ - 2.6.6
7
+ - 2.7.2
7
8
  - 3.0.0
8
9
  - jruby-9.2.14.0
9
10
  gemfile:
@@ -12,17 +13,15 @@ gemfile:
12
13
  - gemfiles/Gemfile.activesupport-6.x
13
14
  jobs:
14
15
  exclude:
15
- - rvm: 2.4.9
16
+ - rvm: 2.4.10
16
17
  gemfile: gemfiles/Gemfile.activesupport-6.x
17
- - rvm: 2.7.0
18
+ - rvm: 2.7.2
18
19
  gemfile: gemfiles/Gemfile.activesupport-4.x
19
20
  - rvm: 3.0.0
20
21
  gemfile: gemfiles/Gemfile.activesupport-4.x
21
22
  include:
22
23
  - rvm: ruby-head
23
24
  gemfile: gemfiles/Gemfile.activesupport-edge
24
- - rvm: jruby-head
25
- gemfile: gemfiles/Gemfile.activesupport-edge
26
25
  allow_failures:
27
26
  - gemfile: gemfiles/Gemfile.activesupport-edge
28
27
  fast_finish: true
data/CHANGELOG.md CHANGED
@@ -1,6 +1,20 @@
1
1
  # Unreleased
2
2
 
3
- [Compare master with v1.3.0](https://github.com/intrepidd/working_hours/compare/v1.3.0...master)
3
+ [Compare master with v1.4.1](https://github.com/intrepidd/working_hours/compare/v1.4.1...master)
4
+
5
+ # v1.4.1
6
+ * Add InvalidConfiguration error code to allow custom message or behavior - [#47](https://github.com/Intrepidd/working_hours/pull/47)
7
+
8
+ # v1.4.0
9
+ * New config option: holiday_hours - [#37](https://github.com/Intrepidd/working_hours/pull/37)
10
+
11
+ # v1.3.2
12
+ * Improve support for time shifts - [#46](https://github.com/Intrepidd/working_hours/pull/46)
13
+
14
+ # v1.3.1
15
+ * Improve computation accuracy in `advance_to_working_time` and `working_time_between` by using more exact (integer-based) time operations instead of floating point numbers - [#44](https://github.com/Intrepidd/working_hours/pull/44)
16
+ * Raise an exception when we detect an infinite loops in `advance_to_working_time` to improve resilience and make debugging easier - [#44](https://github.com/Intrepidd/working_hours/pull/44)
17
+ * Use a Rational number for the midnight value to avoid leaking sub-nanoseconds residue because of floating point accuracy - [#44](https://github.com/Intrepidd/working_hours/pull/44)
4
18
 
5
19
  # v1.3.0
6
20
  * Improve supports for fractional seconds in input times by only rounding results at the end - [#42](https://github.com/Intrepidd/working_hours/issues/42) [#43](https://github.com/Intrepidd/working_hours/pull/43)
data/README.md CHANGED
@@ -104,6 +104,32 @@ end
104
104
  - ``holidays``
105
105
  - ``time_zone``
106
106
 
107
+ ### Holiday hours
108
+ Sometimes you need to configure different working hours as a one-off, e.g. the working day might end earlier on Christmas Eve.
109
+
110
+ You can configure this with the `holiday_hours` option, either as an override on the existing working hours, or as a set of hours that *are* being worked on a holiday day.
111
+
112
+ If *any* hours are set for a calendar day in `holiday_hours`, then the `working_hours` for that day will be ignored, and only the entries in `holiday_hours` taken into consideration.
113
+
114
+ ```ruby
115
+ # Configure holiday hours
116
+ WorkingHours::Config.holiday_hours = {Date.new(2020, 12, 24) => {'09:00' => '12:00', '13:00' => '15:00'}}
117
+ ```
118
+
119
+ ### Handling errors
120
+
121
+ If the configuration is erroneous, an ``WorkingHours::InvalidConfiguration`` exception will be raised containing the appropriate error message.
122
+
123
+ You can also access the error code in case you want to implement custom behavior or changing one specific message, e.g:
124
+
125
+ ```ruby
126
+ rescue WorkingHours::InvalidConfiguration => e
127
+ if e.error_code == :empty
128
+ raise StandardError.new "Config is required"
129
+ end
130
+ raise e
131
+ end
132
+ ```
107
133
 
108
134
  ## No core extensions / monkey patching
109
135
 
@@ -2,4 +2,4 @@ source 'https://rubygems.org'
2
2
 
3
3
  gemspec :path => '..'
4
4
 
5
- gem 'activesupport', '~> 6.0'
5
+ gem 'activesupport', '~> 6.1'
@@ -2,4 +2,4 @@ source 'https://rubygems.org'
2
2
 
3
3
  gemspec :path => '..'
4
4
 
5
- gem 'activesupport', github: 'rails/rails'
5
+ gem 'activesupport', github: 'rails/rails', branch: 'main'
@@ -40,7 +40,7 @@ module WorkingHours
40
40
  time = advance_to_working_time(time, config: config)
41
41
  # look at working ranges
42
42
  time_in_day = time.seconds_since_midnight
43
- config[:working_hours][time.wday].each do |from, to|
43
+ working_hours_for(time, config: config).each do |from, to|
44
44
  if time_in_day >= from and time_in_day < to
45
45
  # take all we can
46
46
  take = [to - time_in_day, seconds].min
@@ -56,7 +56,8 @@ module WorkingHours
56
56
  time = return_to_exact_working_time(time, config: config)
57
57
  # look at working ranges
58
58
  time_in_day = time.seconds_since_midnight
59
- config[:working_hours][time.wday].reverse_each do |from, to|
59
+
60
+ working_hours_for(time, config: config).reverse_each do |from, to|
60
61
  if time_in_day > from and time_in_day <= to
61
62
  # take all we can
62
63
  take = [time_in_day - from, -seconds].min
@@ -80,9 +81,10 @@ module WorkingHours
80
81
  end
81
82
  # find first working range after time
82
83
  time_in_day = time.seconds_since_midnight
83
- config[:working_hours][time.wday].each do |from, to|
84
+
85
+ working_hours_for(time, config: config).each do |from, to|
84
86
  return time if time_in_day >= from and time_in_day < to
85
- return time + (from - time_in_day) if from >= time_in_day
87
+ return move_time_of_day(time, from) if from >= time_in_day
86
88
  end
87
89
  # if none is found, go to next day and loop
88
90
  time = (time + 1.day).beginning_of_day
@@ -99,13 +101,11 @@ module WorkingHours
99
101
  end
100
102
  # find next working range after time
101
103
  time_in_day = time.seconds_since_midnight
102
- time = time.beginning_of_day
103
- config[:working_hours][time.wday].each do |from, to|
104
- return time + to if time_in_day >= from and time_in_day < to
105
- return time + to if from >= time_in_day
104
+ working_hours_for(time, config: config).each do |from, to|
105
+ return move_time_of_day(time, to) if time_in_day < to
106
106
  end
107
107
  # if none is found, go to next day and loop
108
- time = time + 1.day
108
+ time = (time + 1.day).beginning_of_day
109
109
  end
110
110
  end
111
111
 
@@ -131,10 +131,9 @@ module WorkingHours
131
131
  end
132
132
  # find last working range before time
133
133
  time_in_day = time.seconds_since_midnight
134
- config[:working_hours][time.wday].reverse_each do |from, to|
135
- # round is used to suppress miliseconds hack from `end_of_day`
134
+ working_hours_for(time, config: config).reverse_each do |from, to|
136
135
  return time if time_in_day > from and time_in_day <= to
137
- return (time - (time_in_day - to)) if to <= time_in_day
136
+ return move_time_of_day(time, to) if to <= time_in_day
138
137
  end
139
138
  # if none is found, go to previous day and loop
140
139
  time = (time - 1.day).end_of_day
@@ -144,7 +143,9 @@ module WorkingHours
144
143
  def working_day? time, config: nil
145
144
  config ||= wh_config
146
145
  time = in_config_zone(time, config: config)
147
- config[:working_hours][time.wday].present? and not config[:holidays].include?(time.to_date)
146
+
147
+ (config[:working_hours][time.wday].present? && !config[:holidays].include?(time.to_date)) ||
148
+ config[:holiday_hours].include?(time.to_date)
148
149
  end
149
150
 
150
151
  def in_working_hours? time, config: nil
@@ -152,7 +153,7 @@ module WorkingHours
152
153
  time = in_config_zone(time, config: config)
153
154
  return false if not working_day?(time, config: config)
154
155
  time_in_day = time.seconds_since_midnight
155
- config[:working_hours][time.wday].each do |from, to|
156
+ working_hours_for(time, config: config).each do |from, to|
156
157
  return true if time_in_day >= from and time_in_day < to
157
158
  end
158
159
  false
@@ -183,20 +184,25 @@ module WorkingHours
183
184
  to = in_config_zone(to, config: config)
184
185
  distance = 0
185
186
  while from < to
187
+ from_was = from
186
188
  # look at working ranges
187
189
  time_in_day = from.seconds_since_midnight
188
- config[:working_hours][from.wday].each do |begins, ends|
190
+ working_hours_for(from, config: config).each do |begins, ends|
189
191
  if time_in_day >= begins and time_in_day < ends
190
- # take all we can
191
- take = [ends - time_in_day, to - from].min
192
- # advance time
193
- from += take
194
- # increase counter
195
- distance += take
192
+ if (to - from) > (ends - time_in_day)
193
+ # take all the range and continue
194
+ distance += (ends - time_in_day)
195
+ from = move_time_of_day(from, ends)
196
+ else
197
+ # take only what's needed and stop
198
+ distance += (to - from)
199
+ from = to
200
+ end
196
201
  end
197
202
  end
198
203
  # roll to next business period
199
204
  from = advance_to_working_time(from, config: config)
205
+ raise "Invalid loop detected in working_time_between (from=#{from.iso8601(12)}, to=#{to.iso8601(12)}, distance=#{distance}, config=#{config}), please open an issue ;)" unless from > from_was
200
206
  end
201
207
  distance.round # round up to supress miliseconds introduced by 24:00 hack
202
208
  end
@@ -204,6 +210,23 @@ module WorkingHours
204
210
 
205
211
  private
206
212
 
213
+ # Changes the time of the day to match given time (in seconds since midnight)
214
+ # preserving nanosecond prevision (rational number) and honoring time shifts
215
+ #
216
+ # This replaces the previous implementation which was:
217
+ # time.beginning_of_day + seconds
218
+ # (because this one would shift hours during time shifts days)
219
+ def move_time_of_day time, seconds
220
+ # return time.beginning_of_day + seconds
221
+ hour = (seconds / 3600).to_i
222
+ seconds %= 3600
223
+ minutes = (seconds / 60).to_i
224
+ seconds %= 60
225
+ # sec/usec separation is required for ActiveSupport <= 5.1
226
+ usec = ((seconds % 1) * 10**6)
227
+ time.change(hour: hour, min: minutes, sec: seconds.to_i, usec: usec)
228
+ end
229
+
207
230
  def wh_config
208
231
  WorkingHours::Config.precompiled
209
232
  end
@@ -227,5 +250,8 @@ module WorkingHours
227
250
  end
228
251
  end
229
252
 
253
+ def working_hours_for(time, config:)
254
+ config[:holiday_hours][time.to_date] || config[:working_hours][time.wday]
255
+ end
230
256
  end
231
257
  end
@@ -1,15 +1,42 @@
1
1
  require 'set'
2
2
 
3
3
  module WorkingHours
4
- InvalidConfiguration = Class.new StandardError
4
+ class InvalidConfiguration < StandardError
5
+ attr_reader :data, :error_code
5
6
 
6
- class Config
7
+ def initialize(error_code, data: nil)
8
+ @data = data
9
+ @error_code = error_code
10
+ super compose_message(error_code)
11
+ end
7
12
 
13
+ def compose_message(error_code)
14
+ case error_code
15
+ when :empty then "No working hours given"
16
+ when :empty_day then "No working hours given for day `#{@data[:day]}`"
17
+ when :holidays_not_array then "Invalid type for holidays: #{@data[:holidays_class]} - must act like an array"
18
+ when :holiday_not_date then "Invalid holiday: #{@data[:day]} - must be Date"
19
+ when :invalid_day_keys then "Invalid day identifier(s): #{@data[:invalid_keys]} - must be 3 letter symbols"
20
+ when :invalid_format then "Invalid time: #{@data[:time]} - must be 'HH:MM(:SS)'"
21
+ when :invalid_holiday_keys then "Invalid day identifier(s): #{@data[:invalid_keys]} - must be a Date object"
22
+ when :invalid_timezone then "Invalid time zone: #{@data[:zone]} - must be String or ActiveSupport::TimeZone"
23
+ when :invalid_type then "Invalid type for `#{@data[:day]}`: #{@data[:hours_class]} - must be Hash"
24
+ when :outside_of_day then "Invalid time: #{@data[:time]} - outside of day"
25
+ when :overlap then "Invalid range: #{@data[:start]} => #{@data[:finish]} - overlaps previous range"
26
+ when :unknown_timezone then "Unknown time zone: #{@data[:zone]}"
27
+ when :wrong_order then "Invalid range: #{@data[:start]} => #{@data[:finish]} - ends before it starts"
28
+ else "Invalid Configuration"
29
+ end
30
+ end
31
+ end
32
+
33
+
34
+ class Config
8
35
  TIME_FORMAT = /\A([0-2][0-9])\:([0-5][0-9])(?:\:([0-5][0-9]))?\z/
9
36
  DAYS_OF_WEEK = [:sun, :mon, :tue, :wed, :thu, :fri, :sat]
37
+ MIDNIGHT = Rational('86399.999999')
10
38
 
11
39
  class << self
12
-
13
40
  def working_hours
14
41
  config[:working_hours]
15
42
  end
@@ -32,9 +59,26 @@ module WorkingHours
32
59
  config.delete :precompiled
33
60
  end
34
61
 
62
+ def holiday_hours
63
+ config[:holiday_hours]
64
+ end
65
+
66
+ def holiday_hours=(val)
67
+ validate_holiday_hours! val
68
+ config[:holiday_hours] = val
69
+ global_config[:holiday_hours] = val
70
+ config.delete :precompiled
71
+ end
72
+
35
73
  # Returns an optimized for computing version
36
74
  def precompiled
37
- config_hash = [config[:working_hours], config[:holidays], config[:time_zone]].hash
75
+ config_hash = [
76
+ config[:working_hours],
77
+ config[:holiday_hours],
78
+ config[:holidays],
79
+ config[:time_zone]
80
+ ].hash
81
+
38
82
  if config_hash != config[:config_hash]
39
83
  config[:config_hash] = config_hash
40
84
  config.delete :precompiled
@@ -42,14 +86,21 @@ module WorkingHours
42
86
 
43
87
  config[:precompiled] ||= begin
44
88
  validate_working_hours! config[:working_hours]
89
+ validate_holiday_hours! config[:holiday_hours]
45
90
  validate_holidays! config[:holidays]
46
91
  validate_time_zone! config[:time_zone]
47
- compiled = { working_hours: Array.new(7) { Hash.new } }
92
+ compiled = { working_hours: Array.new(7) { Hash.new }, holiday_hours: {} }
48
93
  working_hours.each do |day, hours|
49
94
  hours.each do |start, finish|
50
95
  compiled[:working_hours][DAYS_OF_WEEK.index(day)][compile_time(start)] = compile_time(finish)
51
96
  end
52
97
  end
98
+ holiday_hours.each do |day, hours|
99
+ compiled[:holiday_hours][day] = {}
100
+ hours.each do |start, finish|
101
+ compiled[:holiday_hours][day][compile_time(start)] = compile_time(finish)
102
+ end
103
+ end
53
104
  compiled[:holidays] = Set.new(holidays)
54
105
  compiled[:time_zone] = time_zone
55
106
  compiled
@@ -71,16 +122,19 @@ module WorkingHours
71
122
  Thread.current[:working_hours] = default_config
72
123
  end
73
124
 
74
- def with_config(working_hours: nil, holidays: nil, time_zone: nil)
125
+ def with_config(working_hours: nil, holiday_hours: nil, holidays: nil, time_zone: nil)
75
126
  original_working_hours = self.working_hours
127
+ original_holiday_hours = self.holiday_hours
76
128
  original_holidays = self.holidays
77
129
  original_time_zone = self.time_zone
78
130
  self.working_hours = working_hours if working_hours
131
+ self.holiday_hours = holiday_hours if holiday_hours
79
132
  self.holidays = holidays if holidays
80
133
  self.time_zone = time_zone if time_zone
81
134
  yield
82
135
  ensure
83
136
  self.working_hours = original_working_hours
137
+ self.holiday_hours = original_holiday_hours
84
138
  self.holidays = original_holidays
85
139
  self.time_zone = original_time_zone
86
140
  end
@@ -104,6 +158,7 @@ module WorkingHours
104
158
  thu: {'09:00' => '17:00'},
105
159
  fri: {'09:00' => '17:00'}
106
160
  },
161
+ holiday_hours: {},
107
162
  holidays: [],
108
163
  time_zone: ActiveSupport::TimeZone['UTC']
109
164
  }
@@ -115,48 +170,59 @@ module WorkingHours
115
170
  sec = time[TIME_FORMAT,3].to_i
116
171
  time = hour * 3600 + min * 60 + sec
117
172
  # Converts 24:00 to 23:59:59.999999
118
- return 86399.999999 if time == 86400
173
+ return MIDNIGHT if time == 86400
119
174
  time
120
175
  end
121
176
 
122
- def validate_working_hours! week
123
- if week.empty?
124
- raise InvalidConfiguration.new "No working hours given"
125
- end
126
- if (invalid_keys = (week.keys - DAYS_OF_WEEK)).any?
127
- raise InvalidConfiguration.new "Invalid day identifier(s): #{invalid_keys.join(', ')} - must be 3 letter symbols"
128
- end
129
- week.each do |day, hours|
177
+ def validate_hours! dates
178
+ dates.each do |day, hours|
130
179
  if not hours.is_a? Hash
131
- raise InvalidConfiguration.new "Invalid type for `#{day}`: #{hours.class} - must be Hash"
180
+ raise InvalidConfiguration.new :invalid_type, data: { day: day, hours_class: hours.class }
132
181
  elsif hours.empty?
133
- raise InvalidConfiguration.new "No working hours given for day `#{day}`"
182
+ raise InvalidConfiguration.new :empty_day, data: { day: day }
134
183
  end
135
184
  last_time = nil
136
185
  hours.sort.each do |start, finish|
137
186
  if not start =~ TIME_FORMAT
138
- raise InvalidConfiguration.new "Invalid time: #{start} - must be 'HH:MM(:SS)'"
187
+ raise InvalidConfiguration.new :invalid_format, data: { time: start }
139
188
  elsif not finish =~ TIME_FORMAT
140
- raise InvalidConfiguration.new "Invalid time: #{finish} - must be 'HH:MM(:SS)'"
189
+ raise InvalidConfiguration.new :invalid_format, data: { time: finish }
141
190
  elsif compile_time(finish) >= 24 * 60 * 60
142
- raise InvalidConfiguration.new "Invalid time: #{finish} - outside of day"
191
+ raise InvalidConfiguration.new :outside_of_day, data: { time: finish }
143
192
  elsif start >= finish
144
- raise InvalidConfiguration.new "Invalid range: #{start} => #{finish} - ends before it starts"
193
+ raise InvalidConfiguration.new :wrong_order, data: { start: start, finish: finish }
145
194
  elsif last_time and start < last_time
146
- raise InvalidConfiguration.new "Invalid range: #{start} => #{finish} - overlaps previous range"
195
+ raise InvalidConfiguration.new :overlap, data: { start: start, finish: finish }
147
196
  end
148
197
  last_time = finish
149
198
  end
150
199
  end
151
200
  end
152
201
 
202
+ def validate_working_hours! week
203
+ if week.empty?
204
+ raise InvalidConfiguration.new :empty
205
+ end
206
+ if (invalid_keys = (week.keys - DAYS_OF_WEEK)).any?
207
+ raise InvalidConfiguration.new :invalid_day_keys, data: { invalid_keys: invalid_keys.join(', ') }
208
+ end
209
+ validate_hours!(week)
210
+ end
211
+
212
+ def validate_holiday_hours! days
213
+ if (invalid_keys = (days.keys.reject{ |day| day.is_a?(Date) })).any?
214
+ raise InvalidConfiguration.new :invalid_holiday_keys, data: { invalid_keys: invalid_keys.join(', ') }
215
+ end
216
+ validate_hours!(days)
217
+ end
218
+
153
219
  def validate_holidays! holidays
154
220
  if not holidays.respond_to?(:to_a)
155
- raise InvalidConfiguration.new "Invalid type for holidays: #{holidays.class} - must act like an array"
221
+ raise InvalidConfiguration.new :holidays_not_array, data: { holidays_class: holidays.class }
156
222
  end
157
223
  holidays.to_a.each do |day|
158
224
  if not day.is_a? Date
159
- raise InvalidConfiguration.new "Invalid holiday: #{day} - must be Date"
225
+ raise InvalidConfiguration.new :holiday_not_date, data: { day: day }
160
226
  end
161
227
  end
162
228
  end
@@ -165,21 +231,19 @@ module WorkingHours
165
231
  if zone.is_a? String
166
232
  res = ActiveSupport::TimeZone[zone]
167
233
  if res.nil?
168
- raise InvalidConfiguration.new "Unknown time zone: #{zone}"
234
+ raise InvalidConfiguration.new :unknown_timezone, data: { zone: zone }
169
235
  end
170
236
  elsif zone.is_a? ActiveSupport::TimeZone
171
237
  res = zone
172
238
  else
173
- raise InvalidConfiguration.new "Invalid time zone: #{zone.inspect} - must be String or ActiveSupport::TimeZone"
239
+ raise InvalidConfiguration.new :invalid_timezone, data: { zone: zone.inspect }
174
240
  end
175
241
  res
176
242
  end
177
-
178
243
  end
179
244
 
180
245
  private
181
246
 
182
- def initialize
183
- end
247
+ def initialize; end
184
248
  end
185
249
  end
@@ -1,3 +1,3 @@
1
1
  module WorkingHours
2
- VERSION = "1.3.0"
2
+ VERSION = "1.4.1"
3
3
  end
@@ -111,7 +111,7 @@ describe WorkingHours::Computation do
111
111
  expect(add_seconds(time, 120)).to eq(Time.utc(1991, 11, 18, 9, 1, 42))
112
112
  end
113
113
 
114
- it 'Calls precompiled only once' do
114
+ it 'calls precompiled only once' do
115
115
  precompiled = WorkingHours::Config.precompiled
116
116
  expect(WorkingHours::Config).to receive(:precompiled).once.and_return(precompiled) # in_config_zone and add_seconds
117
117
  time = Time.utc(1991, 11, 15, 16, 59, 42) # Friday
@@ -130,12 +130,94 @@ describe WorkingHours::Computation do
130
130
  expect(add_seconds(time, -60)).to eq(Time.utc(2014, 4, 7, 23, 59, 00))
131
131
  end
132
132
 
133
+ context 'with holiday hours' do
134
+ before do
135
+ WorkingHours::Config.working_hours = { thu: { '08:00' => '18:00' }, fri: { '08:00' => '18:00' } }
136
+ end
137
+
138
+ context 'with a later starting hour' do
139
+ before do
140
+ WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 27) => { '10:00' => '18:00' } }
141
+ end
142
+
143
+ it 'adds working seconds' do
144
+ time = Time.utc(2019, 12, 27, 9)
145
+ expect(add_seconds(time, 120)).to eq(Time.utc(2019, 12, 27, 10, 2))
146
+ end
147
+
148
+ it 'removes working seconds' do
149
+ time = Time.utc(2019, 12, 27, 9)
150
+ expect(add_seconds(time, -120)).to eq(Time.utc(2019, 12, 26, 17, 58))
151
+ end
152
+
153
+ context 'working back from working hours' do
154
+ it 'moves to the previous working day' do
155
+ time = Time.utc(2019, 12, 27, 11)
156
+ expect(add_seconds(time, -2.hours)).to eq(Time.utc(2019, 12, 26, 17))
157
+ end
158
+ end
159
+ end
160
+
161
+ context 'with an earlier ending hour' do
162
+ before do
163
+ WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 27) => { '08:00' => '17:00' } }
164
+ end
165
+
166
+ it 'adds working seconds' do
167
+ time = Time.utc(2019, 12, 27, 17, 59)
168
+ expect(add_seconds(time, 120)).to eq(Time.utc(2020, 1, 2, 8, 2))
169
+ end
170
+
171
+ it 'removes working seconds' do
172
+ time = Time.utc(2019, 12, 27, 18)
173
+ expect(add_seconds(time, -120)).to eq(Time.utc(2019, 12, 27, 16, 58))
174
+ end
175
+
176
+ context 'working forward from working hours' do
177
+ it 'moves to the next working day' do
178
+ time = Time.utc(2019, 12, 27, 16)
179
+ expect(add_seconds(time, 2.hours)).to eq(Time.utc(2020, 1, 2, 9))
180
+ end
181
+ end
182
+ end
183
+
184
+ context 'with an earlier starting time in the second set of hours within a day' do
185
+ before do
186
+ WorkingHours::Config.working_hours = { thu: { '08:00' => '18:00' }, fri: { '08:00' => '12:00', '13:00' => '18:00' } }
187
+ WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 27) => { '08:00' => '12:00', '14:00' => '18:00' } }
188
+ end
189
+
190
+ it 'adds working seconds' do
191
+ time = Time.utc(2019, 12, 27, 12, 59)
192
+ expect(add_seconds(time, 120)).to eq(Time.utc(2019, 12, 27, 14, 2))
193
+ end
194
+
195
+ it 'removes working seconds' do
196
+ time = Time.utc(2019, 12, 27, 14)
197
+ expect(add_seconds(time, -120)).to eq(Time.utc(2019, 12, 27, 11, 58))
198
+ end
199
+
200
+ context 'from morning to afternoon' do
201
+ it 'takes into account the additional hour for lunch set in `holiday_hours`' do
202
+ time = Time.utc(2019, 12, 27, 10)
203
+ expect(add_seconds(time, 4.hours)).to eq(Time.utc(2019, 12, 27, 16))
204
+ end
205
+ end
206
+
207
+ context 'from afternoon to morning' do
208
+ it 'takes into account the additional hour for lunch set in `holiday_hours`' do
209
+ time = Time.utc(2019, 12, 27, 16)
210
+ expect(add_seconds(time, -4.hours)).to eq(Time.utc(2019, 12, 27, 10))
211
+ end
212
+ end
213
+ end
214
+ end
215
+
133
216
  it 'honors miliseconds in the base time and increment (but return rounded result)' do
134
217
  # Rounding the base time or increments before the end would yield a wrong result
135
218
  time = Time.utc(1991, 11, 15, 16, 59, 42.25) # +250ms
136
219
  expect(add_seconds(time, 120.4)).to eq(Time.utc(1991, 11, 18, 9, 1, 43))
137
220
  end
138
-
139
221
  end
140
222
 
141
223
  describe '#advance_to_working_time' do
@@ -173,10 +255,45 @@ describe WorkingHours::Computation do
173
255
  WorkingHours::Config.time_zone = 'Tokyo'
174
256
  expect(advance_to_working_time(Time.new(2014, 4, 7, 0, 0, 0)).zone).to eq('JST')
175
257
  end
258
+
259
+ it 'jumps outside holiday hours' do
260
+ WorkingHours::Config.working_hours = { fri: { '08:00' => '18:00' } }
261
+ WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 27) => { '10:00' => '18:00' } }
262
+ expect(advance_to_working_time(Time.utc(2019, 12, 27, 9))).to eq(Time.utc(2019, 12, 27, 10))
263
+ end
264
+
265
+ it 'do not leak nanoseconds when advancing' do
266
+ expect(advance_to_working_time(Time.utc(2014, 4, 7, 5, 0, 0, 123456.789))).to eq(Time.utc(2014, 4, 7, 9, 0, 0, 0))
267
+ end
268
+
269
+ it 'returns correct hour during positive time shifts' do
270
+ WorkingHours::Config.working_hours = {sun: {'09:00' => '17:00'}}
271
+ WorkingHours::Config.time_zone = 'Paris'
272
+ from = Time.new(2020, 3, 29, 0, 0, 0, "+01:00")
273
+ expect(from.utc_offset).to eq(3600)
274
+ res = advance_to_working_time(from)
275
+ expect(res).to eq(Time.new(2020, 3, 29, 9, 0, 0, "+02:00"))
276
+ expect(res.utc_offset).to eq(7200)
277
+ # starting from wrong time-zone
278
+ expect(advance_to_working_time(Time.new(2020, 3, 29, 5, 0, 0, "+01:00"))).to eq(Time.new(2020, 3, 29, 9, 0, 0, "+02:00"))
279
+ expect(advance_to_working_time(Time.new(2020, 3, 29, 1, 0, 0, "+02:00"))).to eq(Time.new(2020, 3, 29, 9, 0, 0, "+02:00"))
280
+ end
281
+
282
+ it 'returns correct hour during negative time shifts' do
283
+ WorkingHours::Config.working_hours = {sun: {'09:00' => '17:00'}}
284
+ WorkingHours::Config.time_zone = 'Paris'
285
+ from = Time.new(2020, 10, 25, 0, 0, 0, "+02:00")
286
+ expect(from.utc_offset).to eq(7200)
287
+ res = advance_to_working_time(from)
288
+ expect(res).to eq(Time.new(2020, 10, 25, 9, 0, 0, "+01:00"))
289
+ expect(res.utc_offset).to eq(3600)
290
+ # starting from wrong time-zone
291
+ expect(advance_to_working_time(Time.new(2020, 10, 25, 4, 0, 0, "+02:00"))).to eq(Time.new(2020, 10, 25, 9, 0, 0, "+01:00"))
292
+ expect(advance_to_working_time(Time.new(2020, 10, 25, 1, 0, 0, "+01:00"))).to eq(Time.new(2020, 10, 25, 9, 0, 0, "+01:00"))
293
+ end
176
294
  end
177
295
 
178
296
  describe '#advance_to_closing_time' do
179
-
180
297
  it 'jumps non-working day' do
181
298
  WorkingHours::Config.holidays = [Date.new(2014, 5, 1)]
182
299
  holiday = Time.utc(2014, 5, 1, 12, 0)
@@ -240,7 +357,7 @@ describe WorkingHours::Computation do
240
357
  end
241
358
 
242
359
  let(:monday_morning) { Time.utc(2014, 4, 7, 0) }
243
- let(:monday_closing) { Time.utc(2014, 4, 7) + 86399.999999 }
360
+ let(:monday_closing) { Time.utc(2014, 4, 7) + WorkingHours::Config::MIDNIGHT }
244
361
  let(:tuesday_closing) { Time.utc(2014, 4, 8, 17) }
245
362
  let(:sunday) { Time.utc(2014, 4, 6, 17) }
246
363
 
@@ -255,6 +372,11 @@ describe WorkingHours::Computation do
255
372
  it 'moves over midnight' do
256
373
  expect(advance_to_closing_time(sunday)).to eq(monday_closing)
257
374
  end
375
+
376
+ it 'give precise computation with nothing other than miliseconds' do
377
+ pending "iso8601 is not precise enough on AS < 4" if ActiveSupport::VERSION::MAJOR <= 4
378
+ expect(advance_to_closing_time(monday_morning).iso8601(25)).to eq("2014-04-07T23:59:59.9999990000000000000000000Z")
379
+ end
258
380
  end
259
381
 
260
382
  it 'works with any input timezone (converts to config)' do
@@ -271,10 +393,55 @@ describe WorkingHours::Computation do
271
393
  WorkingHours::Config.time_zone = 'Tokyo'
272
394
  expect(advance_to_closing_time(Time.new(2014, 4, 7, 0, 0, 0)).zone).to eq('JST')
273
395
  end
396
+
397
+ context 'with holiday hours' do
398
+ before do
399
+ WorkingHours::Config.working_hours = { thu: { '08:00' => '18:00' }, fri: { '08:00' => '18:00' } }
400
+ end
401
+
402
+ it 'takes into account reduced holiday closing' do
403
+ WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 27) => { '10:00' => '17:00' } }
404
+ expect(advance_to_closing_time(Time.new(2019, 12, 26, 20))).to eq(Time.new(2019, 12, 27, 17))
405
+ end
406
+
407
+ it 'takes into account extended holiday closing' do
408
+ WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 26) => { '10:00' => '21:00' } }
409
+ expect(advance_to_closing_time(Time.new(2019, 12, 26, 20))).to eq(Time.new(2019, 12, 26, 21))
410
+ end
411
+ end
412
+
413
+ it 'do not leak nanoseconds when advancing' do
414
+ expect(advance_to_closing_time(Time.utc(2014, 4, 7, 5, 0, 0, 123456.789))).to eq(Time.utc(2014, 4, 7, 17, 0, 0, 0))
415
+ end
416
+
417
+ it 'returns correct hour during positive time shifts' do
418
+ WorkingHours::Config.working_hours = {sun: {'09:00' => '17:00'}}
419
+ WorkingHours::Config.time_zone = 'Paris'
420
+ from = Time.new(2020, 3, 29, 0, 0, 0, "+01:00")
421
+ expect(from.utc_offset).to eq(3600)
422
+ res = advance_to_closing_time(from)
423
+ expect(res).to eq(Time.new(2020, 3, 29, 17, 0, 0, "+02:00"))
424
+ expect(res.utc_offset).to eq(7200)
425
+ # starting from wrong time-zone
426
+ expect(advance_to_closing_time(Time.new(2020, 3, 29, 5, 0, 0, "+01:00"))).to eq(Time.new(2020, 3, 29, 17, 0, 0, "+02:00"))
427
+ expect(advance_to_closing_time(Time.new(2020, 3, 29, 1, 0, 0, "+02:00"))).to eq(Time.new(2020, 3, 29, 17, 0, 0, "+02:00"))
428
+ end
429
+
430
+ it 'returns correct hour during negative time shifts' do
431
+ WorkingHours::Config.working_hours = {sun: {'09:00' => '17:00'}}
432
+ WorkingHours::Config.time_zone = 'Paris'
433
+ from = Time.new(2020, 10, 25, 0, 0, 0, "+02:00")
434
+ expect(from.utc_offset).to eq(7200)
435
+ res = advance_to_closing_time(from)
436
+ expect(res).to eq(Time.new(2020, 10, 25, 17, 0, 0, "+01:00"))
437
+ expect(res.utc_offset).to eq(3600)
438
+ # starting from wrong time-zone
439
+ expect(advance_to_closing_time(Time.new(2020, 10, 25, 4, 0, 0, "+02:00"))).to eq(Time.new(2020, 10, 25, 17, 0, 0, "+01:00"))
440
+ expect(advance_to_closing_time(Time.new(2020, 10, 25, 1, 0, 0, "+01:00"))).to eq(Time.new(2020, 10, 25, 17, 0, 0, "+01:00"))
441
+ end
274
442
  end
275
443
 
276
444
  describe '#next_working_time' do
277
-
278
445
  it 'jumps non-working day' do
279
446
  WorkingHours::Config.holidays = [Date.new(2014, 5, 1)]
280
447
  holiday = Time.utc(2014, 5, 1, 12, 0)
@@ -377,6 +544,32 @@ describe WorkingHours::Computation do
377
544
  WorkingHours::Config.time_zone = 'Tokyo'
378
545
  expect(return_to_working_time(Time.new(2014, 4, 7, 1, 0, 0)).zone).to eq('JST')
379
546
  end
547
+
548
+ it 'returns correct hour during positive time shifts' do
549
+ WorkingHours::Config.working_hours = {sun: {'00:00' => '01:00'}}
550
+ WorkingHours::Config.time_zone = 'Paris'
551
+ from = Time.new(2020, 3, 29, 9, 0, 0, "+02:00")
552
+ expect(from.utc_offset).to eq(7200)
553
+ res = return_to_working_time(from)
554
+ expect(res).to eq(Time.new(2020, 3, 29, 1, 0, 0, "+01:00"))
555
+ expect(res.utc_offset).to eq(3600)
556
+ # starting from wrong time-zone
557
+ expect(return_to_working_time(Time.new(2020, 3, 29, 2, 0, 0, "+02:00"))).to eq(Time.new(2020, 3, 29, 1, 0, 0, "+01:00"))
558
+ expect(return_to_working_time(Time.new(2020, 3, 29, 9, 0, 0, "+01:00"))).to eq(Time.new(2020, 3, 29, 1, 0, 0, "+01:00"))
559
+ end
560
+
561
+ it 'returns correct hour during negative time shifts' do
562
+ WorkingHours::Config.working_hours = {sun: {'00:00' => '01:00'}}
563
+ WorkingHours::Config.time_zone = 'Paris'
564
+ from = Time.new(2020, 10, 25, 9, 0, 0, "+01:00")
565
+ expect(from.utc_offset).to eq(3600)
566
+ res = return_to_working_time(from)
567
+ expect(res).to eq(Time.new(2020, 10, 25, 1, 0, 0, "+02:00"))
568
+ expect(res.utc_offset).to eq(7200)
569
+ # starting from wrong time-zone
570
+ expect(return_to_working_time(Time.new(2020, 10, 25, 9, 0, 0, "+02:00"))).to eq(Time.new(2020, 10, 25, 1, 0, 0, "+02:00"))
571
+ expect(return_to_working_time(Time.new(2020, 10, 25, 1, 0, 0, "+01:00"))).to eq(Time.new(2020, 10, 25, 1, 0, 0, "+02:00"))
572
+ end
380
573
  end
381
574
 
382
575
  describe '#working_day?' do
@@ -424,6 +617,23 @@ describe WorkingHours::Computation do
424
617
  WorkingHours::Config.time_zone = 'Tokyo'
425
618
  expect(in_working_hours?(Time.utc(2014, 4, 7, 0, 0))).to be(true)
426
619
  end
620
+
621
+ context 'with holiday hours' do
622
+ before do
623
+ WorkingHours::Config.working_hours = { thu: { '08:00' => '18:00' }, fri: { '08:00' => '18:00' } }
624
+ WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 27) => { '10:00' => '20:00' } }
625
+ end
626
+
627
+ it 'returns true during working hours' do
628
+ expect(in_working_hours?(Time.utc(2019, 12, 26, 9))).to be(true)
629
+ expect(in_working_hours?(Time.utc(2019, 12, 27, 19))).to be(true)
630
+ end
631
+
632
+ it 'returns false outside working hours' do
633
+ expect(in_working_hours?(Time.utc(2019, 12, 26, 7))).to be(false)
634
+ expect(in_working_hours?(Time.utc(2019, 12, 27, 9))).to be(false)
635
+ end
636
+ end
427
637
  end
428
638
 
429
639
  describe '#working_days_between' do
@@ -544,6 +754,63 @@ describe WorkingHours::Computation do
544
754
  )).to eq(7.hours)
545
755
  end
546
756
 
757
+ it 'uses precise computation to avoid useless loops' do
758
+ # +200 usec on each time, using floating point would cause
759
+ # precision issues and require several iterations
760
+ expect(self).to receive(:advance_to_working_time).twice.and_call_original
761
+ expect(working_time_between(
762
+ Time.utc(2014, 4, 7, 5, 0, 0, 200),
763
+ Time.utc(2014, 4, 7, 15, 0, 0, 200),
764
+ )).to eq(6.hours)
765
+ end
766
+
767
+ it 'works across positive time shifts' do
768
+ WorkingHours::Config.working_hours = {sun: {'08:00' => '21:00'}}
769
+ WorkingHours::Config.time_zone = 'Paris'
770
+ expect(working_time_between(
771
+ Time.utc(2020, 3, 29, 1, 0),
772
+ Time.utc(2020, 3, 30, 0, 0),
773
+ )).to eq(13.hours)
774
+ end
775
+
776
+ it 'works across negative time shifts' do
777
+ WorkingHours::Config.working_hours = {sun: {'08:00' => '21:00'}}
778
+ WorkingHours::Config.time_zone = 'Paris'
779
+ expect(working_time_between(
780
+ Time.utc(2019, 10, 27, 1, 0),
781
+ Time.utc(2019, 10, 28, 0, 0),
782
+ )).to eq(13.hours)
783
+ end
784
+
785
+ it 'works across time shifts + midnight' do
786
+ WorkingHours::Config.working_hours = {sun: {'00:00' => '24:00'}}
787
+ WorkingHours::Config.time_zone = 'Paris'
788
+ expect(working_time_between(
789
+ Time.utc(2020, 10, 24, 22, 0),
790
+ Time.utc(2020, 10, 25, 23, 0),
791
+ )).to eq(24.hours)
792
+ end
793
+
794
+ it 'works across multiple time shifts' do
795
+ WorkingHours::Config.working_hours = {sun: {'08:00' => '21:00'}}
796
+ WorkingHours::Config.time_zone = 'Paris'
797
+ expect(working_time_between(
798
+ Time.utc(2002, 10, 27, 6, 0),
799
+ Time.utc(2021, 10, 30, 0, 0),
800
+ )).to eq(12896.hours)
801
+ end
802
+
803
+ it 'do not cause infinite loop if the time is not advancing properly' do
804
+ # simulate some computation/precision error
805
+ expect(self).to receive(:advance_to_working_time).twice do |time|
806
+ time.change(hour: 9) - 0.0001
807
+ end
808
+ expect { working_time_between(
809
+ Time.utc(2014, 4, 7, 5, 0, 0),
810
+ Time.utc(2014, 4, 7, 15, 0, 0),
811
+ ) }.to raise_error(RuntimeError, /Invalid loop detected in working_time_between \(from=2014-04-07T08:59:59.999/)
812
+ end
813
+
547
814
  # generates two times with +0ms, +250ms, +500ms, +750ms and +1s
548
815
  # then for each combination compare the result with a ruby diff
549
816
  context 'with precise miliseconds timings' do
@@ -558,5 +825,66 @@ describe WorkingHours::Computation do
558
825
  end
559
826
  end
560
827
  end
828
+
829
+ context 'with holiday hours' do
830
+ before do
831
+ WorkingHours::Config.working_hours = { mon: { '08:00' => '18:00' }, tue: { '08:00' => '18:00' } }
832
+ WorkingHours::Config.holiday_hours = { Date.new(2014, 4, 7) => { '10:00' => '12:00', '14:00' => '17:00' } }
833
+ end
834
+
835
+ context 'time is before the start of holiday hours' do
836
+ it 'does not count holiday hours as working time' do
837
+ expect(working_time_between(
838
+ Time.utc(2014, 4, 7, 8),
839
+ Time.utc(2014, 4, 7, 9)
840
+ )).to eq(0)
841
+ end
842
+ end
843
+
844
+ context 'time is between holiday hours' do
845
+ it 'does not count holiday hours as working time' do
846
+ expect(working_time_between(
847
+ Time.utc(2014, 4, 7, 13),
848
+ Time.utc(2014, 4, 7, 13, 30)
849
+ )).to eq(0)
850
+ end
851
+ end
852
+
853
+ context 'time is after the end of holiday hours' do
854
+ it 'does not count holiday hours as working time' do
855
+ expect(working_time_between(
856
+ Time.utc(2014, 4, 7, 19),
857
+ Time.utc(2014, 4, 7, 20)
858
+ )).to eq(0)
859
+ end
860
+ end
861
+
862
+ context 'time is before the start of the holiday hours' do
863
+ it 'does not count holiday hours as working time' do
864
+ expect(working_time_between(
865
+ Time.utc(2014, 4, 7, 9),
866
+ Time.utc(2014, 4, 7, 12)
867
+ )).to eq(7200)
868
+ end
869
+ end
870
+
871
+ context 'time crosses overridden holiday hours at midday' do
872
+ it 'does not count holiday hours as working time' do
873
+ expect(working_time_between(
874
+ Time.utc(2014, 4, 7, 9),
875
+ Time.utc(2014, 4, 7, 14)
876
+ )).to eq(7200)
877
+ end
878
+ end
879
+
880
+ context 'time crosses overridden holiday hours at midday' do
881
+ it 'does not count holiday hours as working time' do
882
+ expect(working_time_between(
883
+ Time.utc(2014, 4, 7, 12),
884
+ Time.utc(2014, 4, 7, 18)
885
+ )).to eq(10800)
886
+ end
887
+ end
888
+ end
561
889
  end
562
890
  end
@@ -3,7 +3,6 @@ require 'spec_helper'
3
3
  describe WorkingHours::Config do
4
4
 
5
5
  describe '.working_hours' do
6
-
7
6
  let(:config) { WorkingHours::Config.working_hours }
8
7
  let(:config2) { { :mon => { '08:00' => '14:00' } } }
9
8
  let(:config3) { { :tue => { '10:00' => '16:00' } } }
@@ -155,7 +154,132 @@ describe WorkingHours::Config do
155
154
  1.working.hour.ago
156
155
  }.to raise_error(WorkingHours::InvalidConfiguration, 'Invalid type for `mon`: String - must be Hash')
157
156
  end
157
+ end
158
+ end
159
+
160
+ describe '.holiday_hours' do
161
+ let(:config) { WorkingHours::Config.holiday_hours }
162
+ let(:config2) { { Date.new(2019, 12, 1) => { '08:00' => '14:00' } } }
163
+ let(:config3) { { Date.new(2019, 12, 2) => { '10:00' => '16:00' } } }
164
+
165
+ it 'has a default config' do
166
+ expect(config).to be_kind_of(Hash)
167
+ end
168
+
169
+ it 'is thread safe' do
170
+ expect(WorkingHours::Config.holiday_hours).to eq(config)
171
+
172
+ thread = Thread.new do
173
+ WorkingHours::Config.holiday_hours = config2
174
+ expect(WorkingHours::Config.holiday_hours).to eq(config2)
175
+ Thread.stop
176
+ expect(WorkingHours::Config.holiday_hours).to eq(config2)
177
+ end
178
+
179
+ expect {
180
+ sleep 0.1 # let the thread begin its execution
181
+ }.not_to change { WorkingHours::Config.holiday_hours }.from(config)
182
+
183
+ expect {
184
+ WorkingHours::Config.holiday_hours = config3
185
+ }.to change { WorkingHours::Config.holiday_hours }.from(config).to(config3)
186
+
187
+ expect {
188
+ thread.run
189
+ thread.join
190
+ }.not_to change { WorkingHours::Config.holiday_hours }.from(config3)
191
+ end
192
+
193
+ it 'is fiber safe' do
194
+ expect(WorkingHours::Config.holiday_hours).to eq(config)
195
+
196
+ fiber = Fiber.new do
197
+ WorkingHours::Config.holiday_hours = config2
198
+ expect(WorkingHours::Config.holiday_hours).to eq(config2)
199
+ Fiber.yield
200
+ expect(WorkingHours::Config.holiday_hours).to eq(config2)
201
+ end
202
+
203
+ expect {
204
+ fiber.resume
205
+ }.not_to change { WorkingHours::Config.holiday_hours }.from(config)
206
+
207
+ expect {
208
+ WorkingHours::Config.holiday_hours = config3
209
+ }.to change { WorkingHours::Config.holiday_hours }.from(config).to(config3)
210
+
211
+ expect {
212
+ fiber.resume
213
+ }.not_to change { WorkingHours::Config.holiday_hours }.from(config3)
214
+ end
215
+
216
+ it 'is initialized from last known global config' do
217
+ WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 1) => { '08:00' => '14:00' } }
218
+ Thread.new {
219
+ expect(WorkingHours::Config.holiday_hours).to match Date.new(2019, 12, 1) => {'08:00' => '14:00'}
220
+ }.join
221
+ end
222
+
223
+ it 'should support multiple timespan per day' do
224
+ time_sheet = { Date.new(2019, 12, 1) => { '08:00' => '12:00', '14:00' => '18:00' } }
225
+ WorkingHours::Config.holiday_hours = time_sheet
226
+ expect(config).to eq(time_sheet)
227
+ end
228
+
229
+ describe 'validations' do
230
+ it 'rejects invalid day' do
231
+ expect {
232
+ WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 1) => 1, 'aaaaaa' => 2 }
233
+ }.to raise_error(WorkingHours::InvalidConfiguration, "Invalid day identifier(s): aaaaaa - must be a Date object")
234
+ end
235
+
236
+ it 'rejects other type than hash' do
237
+ expect {
238
+ WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 1) => [] }
239
+ }.to raise_error(WorkingHours::InvalidConfiguration, "Invalid type for `2019-12-01`: Array - must be Hash")
240
+ end
241
+
242
+ it 'rejects empty range' do
243
+ expect {
244
+ WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 1) => {} }
245
+ }.to raise_error(WorkingHours::InvalidConfiguration, "No working hours given for day `2019-12-01`")
246
+ end
247
+
248
+ it 'rejects invalid time format' do
249
+ expect {
250
+ WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 1) => { '8:0' => '12:00' } }
251
+ }.to raise_error(WorkingHours::InvalidConfiguration, "Invalid time: 8:0 - must be 'HH:MM(:SS)'")
252
+
253
+ expect {
254
+ WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 1) => { '08:00' => '24:10' }}
255
+ }.to raise_error(WorkingHours::InvalidConfiguration, "Invalid time: 24:10 - outside of day")
256
+ end
257
+
258
+ it 'rejects invalid range' do
259
+ expect {
260
+ WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 1) => { '12:30' => '12:00' } }
261
+ }.to raise_error(WorkingHours::InvalidConfiguration, "Invalid range: 12:30 => 12:00 - ends before it starts")
262
+ end
263
+
264
+ it 'rejects overlapping range' do
265
+ expect {
266
+ WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 1) => { '08:00' => '13:00', '12:00' => '18:00' } }
267
+ }.to raise_error(WorkingHours::InvalidConfiguration, "Invalid range: 12:00 => 18:00 - overlaps previous range")
268
+ end
158
269
 
270
+ it 'does not reject out-of-order, non-overlapping ranges' do
271
+ expect {
272
+ WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 1) => { '10:00' => '11:00', '08:00' => '09:00' } }
273
+ }.not_to raise_error
274
+ end
275
+
276
+ it 'raises an error when precompiling if working hours are invalid after assignment' do
277
+ WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 1) => { '10:00' => '11:00', '08:00' => '09:00' } }
278
+ WorkingHours::Config.holiday_hours[Date.new(2019, 12, 1)] = 'Not correct'
279
+ expect {
280
+ 1.working.hour.ago
281
+ }.to raise_error(WorkingHours::InvalidConfiguration, 'Invalid type for `2019-12-01`: String - must be Hash')
282
+ end
159
283
  end
160
284
  end
161
285
 
@@ -267,6 +391,7 @@ describe WorkingHours::Config do
267
391
  it 'computes an optimized version' do
268
392
  expect(subject).to eq({
269
393
  :working_hours => [{}, {32400=>61200}, {32400=>61200}, {32400=>61200}, {32400=>61200}, {32400=>61200}, {}],
394
+ :holiday_hours => {},
270
395
  :holidays => Set.new([]),
271
396
  :time_zone => ActiveSupport::TimeZone['UTC']
272
397
  })
@@ -283,6 +408,7 @@ describe WorkingHours::Config do
283
408
  WorkingHours::Config.working_hours = {:mon => {'20:32:59' => '22:59:59'}}
284
409
  expect(subject).to eq({
285
410
  :working_hours => [{}, {73979 => 82799}, {}, {}, {}, {}, {}],
411
+ :holiday_hours => {},
286
412
  :holidays => Set.new([]),
287
413
  :time_zone => ActiveSupport::TimeZone['UTC']
288
414
  })
@@ -292,6 +418,7 @@ describe WorkingHours::Config do
292
418
  WorkingHours::Config.working_hours = {:mon => {'20:00' => '24:00'}}
293
419
  expect(subject).to eq({
294
420
  :working_hours => [{}, {72000 => 86399.999999}, {}, {}, {}, {}, {}],
421
+ :holiday_hours => {},
295
422
  :holidays => Set.new([]),
296
423
  :time_zone => ActiveSupport::TimeZone['UTC']
297
424
  })
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: working_hours
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrien Jarthon
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2021-01-31 00:00:00.000000000 Z
12
+ date: 2021-07-15 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activesupport
@@ -152,7 +152,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
152
152
  - !ruby/object:Gem::Version
153
153
  version: '0'
154
154
  requirements: []
155
- rubygems_version: 3.0.3
155
+ rubygems_version: 3.1.2
156
156
  signing_key:
157
157
  specification_version: 4
158
158
  summary: time calculation with working hours