working_hours 1.3.0 → 1.4.1

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