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 +4 -4
- data/.gitignore +1 -0
- data/.travis.yml +7 -8
- data/CHANGELOG.md +15 -1
- data/README.md +26 -0
- data/gemfiles/Gemfile.activesupport-6.x +1 -1
- data/gemfiles/Gemfile.activesupport-edge +1 -1
- data/lib/working_hours/computation.rb +47 -21
- data/lib/working_hours/config.rb +93 -29
- data/lib/working_hours/version.rb +1 -1
- data/spec/working_hours/computation_spec.rb +333 -5
- data/spec/working_hours/config_spec.rb +128 -1
- metadata +3 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 9d51f59c3456e35117b42605450d911595a331ef6a72303aba33a6eedcdabd6f
|
4
|
+
data.tar.gz: '0527249d73eb1da76205e5b02bd7d67226a105c91f2a14240ddfd39abc0daeb6'
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b276429238c21dbb526c53d4be5082450c99b5c5414d9b21786ec4278ef83ff07c6e2a952cdf6bbb9125f0b32b013f7d2b009cadb020b062a780dcf119d733aa
|
7
|
+
data.tar.gz: 77524f554091fccfdd9b96ea0ff64295fb55a8358f010adac5f008455428fd91c3b8ef80111f515529f2059b4e3db6081177b3563b2c16c83ee5d6e829e27789
|
data/.gitignore
CHANGED
data/.travis.yml
CHANGED
@@ -1,9 +1,10 @@
|
|
1
1
|
language: ruby
|
2
|
+
dist: bionic
|
2
3
|
rvm:
|
3
|
-
- 2.4.
|
4
|
-
- 2.5.
|
5
|
-
- 2.6.
|
6
|
-
- 2.7.
|
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.
|
16
|
+
- rvm: 2.4.10
|
16
17
|
gemfile: gemfiles/Gemfile.activesupport-6.x
|
17
|
-
- rvm: 2.7.
|
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
|
+
[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
|
|
@@ -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
|
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
|
-
|
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
|
-
|
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
|
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
|
103
|
-
|
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
|
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
|
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
|
-
|
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
|
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
|
190
|
+
working_hours_for(from, config: config).each do |begins, ends|
|
189
191
|
if time_in_day >= begins and time_in_day < ends
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
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
|
data/lib/working_hours/config.rb
CHANGED
@@ -1,15 +1,42 @@
|
|
1
1
|
require 'set'
|
2
2
|
|
3
3
|
module WorkingHours
|
4
|
-
InvalidConfiguration
|
4
|
+
class InvalidConfiguration < StandardError
|
5
|
+
attr_reader :data, :error_code
|
5
6
|
|
6
|
-
|
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 = [
|
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
|
173
|
+
return MIDNIGHT if time == 86400
|
119
174
|
time
|
120
175
|
end
|
121
176
|
|
122
|
-
def
|
123
|
-
|
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
|
180
|
+
raise InvalidConfiguration.new :invalid_type, data: { day: day, hours_class: hours.class }
|
132
181
|
elsif hours.empty?
|
133
|
-
raise InvalidConfiguration.new
|
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
|
187
|
+
raise InvalidConfiguration.new :invalid_format, data: { time: start }
|
139
188
|
elsif not finish =~ TIME_FORMAT
|
140
|
-
raise InvalidConfiguration.new
|
189
|
+
raise InvalidConfiguration.new :invalid_format, data: { time: finish }
|
141
190
|
elsif compile_time(finish) >= 24 * 60 * 60
|
142
|
-
raise InvalidConfiguration.new
|
191
|
+
raise InvalidConfiguration.new :outside_of_day, data: { time: finish }
|
143
192
|
elsif start >= finish
|
144
|
-
raise InvalidConfiguration.new
|
193
|
+
raise InvalidConfiguration.new :wrong_order, data: { start: start, finish: finish }
|
145
194
|
elsif last_time and start < last_time
|
146
|
-
raise InvalidConfiguration.new
|
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
|
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
|
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
|
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
|
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
|
@@ -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 '
|
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) +
|
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.
|
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-
|
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.
|
155
|
+
rubygems_version: 3.1.2
|
156
156
|
signing_key:
|
157
157
|
specification_version: 4
|
158
158
|
summary: time calculation with working hours
|