working_hours 1.1.4 → 1.4.0

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
- SHA1:
3
- metadata.gz: f00a9f2b6cd9d293631464feb64a41d6c738b03f
4
- data.tar.gz: c18fa540c8033e1d8c1f42cad501420f16c6c7d7
2
+ SHA256:
3
+ metadata.gz: d2f3a760854612e389ff263f670c2ddbd3206c5895f13a753267e44a92ca12f1
4
+ data.tar.gz: f9f30c0aaf3c7004a68b7f18ef39767e30c497bedc14361c2ad450894272997e
5
5
  SHA512:
6
- metadata.gz: fb32d4257bb17d0c0dc5d7595e19f808ec76970d38bb46ab3b4bf0893ed3d05e9f1734db7f49ec09a89eb495bc21e45a98bd08ce40979edd89efbbfd039f8539
7
- data.tar.gz: f9874b5d95e6b47846d194347de5e78c14fe56d95a36071a1215e016dfe9312c3a57f0343aabdb4a4280fc89900e100e5c5598975954683d32a08cb18cfd92bd
6
+ metadata.gz: c63b351486621dca69deb0b827dc51024f74b4304e6639c9272b01d5a5fd9a58013e1d9874ed13466ce6f5fb60ccab67c9f2712a0af2b4d5e4a94bbf214ac996
7
+ data.tar.gz: 8662b6a40f4ee6bc734cd2f87eecf1e6b51a474638dc5b9a867525cbdc0c7a4b329a34054fff395e71e36b5b93e26d01d3205b2965fd0573c70bbe764c1e450b
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,29 +1,27 @@
1
1
  language: ruby
2
+ dist: bionic
2
3
  rvm:
3
- - 2.0.0
4
- - 2.1.8
5
- - 2.2.4
6
- - 2.3.0
7
- - 2.4.0
8
- - ruby-head
9
- - jruby-1.7.23
10
- - jruby-9.0.4.0
11
- - jruby-head
12
- before_install:
13
- - bundler --version || gem install bundler
14
- env: JRUBY_OPTS=--2.0
4
+ - 2.4.10
5
+ - 2.5.8
6
+ - 2.6.6
7
+ - 2.7.2
8
+ - 3.0.0
9
+ - jruby-9.2.14.0
15
10
  gemfile:
16
- - gemfiles/Gemfile.activesupport-3.2.x
17
- - gemfiles/Gemfile.activesupport-4.0.x
18
- - gemfiles/Gemfile.activesupport-4.1.x
19
- - gemfiles/Gemfile.activesupport-4.2.x
20
- - gemfiles/Gemfile.activesupport-edge
21
- matrix:
11
+ - gemfiles/Gemfile.activesupport-4.x
12
+ - gemfiles/Gemfile.activesupport-5.x
13
+ - gemfiles/Gemfile.activesupport-6.x
14
+ jobs:
22
15
  exclude:
23
- - rvm: 2.4.0
24
- env: JRUBY_OPTS=--2.0
25
- gemfile: gemfiles/Gemfile.activesupport-3.2.x
16
+ - rvm: 2.4.10
17
+ gemfile: gemfiles/Gemfile.activesupport-6.x
18
+ - rvm: 2.7.2
19
+ gemfile: gemfiles/Gemfile.activesupport-4.x
20
+ - rvm: 3.0.0
21
+ gemfile: gemfiles/Gemfile.activesupport-4.x
22
+ include:
23
+ - rvm: ruby-head
24
+ gemfile: gemfiles/Gemfile.activesupport-edge
26
25
  allow_failures:
27
26
  - gemfile: gemfiles/Gemfile.activesupport-edge
28
- - rvm: ruby-head
29
- - rvm: jruby-head
27
+ fast_finish: true
data/CHANGELOG.md CHANGED
@@ -1,6 +1,31 @@
1
1
  # Unreleased
2
2
 
3
- [Compare master with v1.1.4](https://github.com/intrepidd/working_hours/compare/v1.1.4...master)
3
+ [Compare master with v1.4.0](https://github.com/intrepidd/working_hours/compare/v1.4.0...master)
4
+
5
+ # v1.4.0
6
+ * New config option: holiday_hours - [#37](https://github.com/Intrepidd/working_hours/pull/37)
7
+
8
+ # v1.3.2
9
+ * Improve support for time shifts - [#46](https://github.com/Intrepidd/working_hours/pull/46)
10
+
11
+ # v1.3.1
12
+ * 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)
13
+ * 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)
14
+ * 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)
15
+
16
+ # v1.3.0
17
+ * 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)
18
+ * Increase code safety by always initializing an empty hash for each day of the week in the precompiled config (inspired by [#35](https://github.com/Intrepidd/working_hours/pull/35)
19
+
20
+ # v1.2.0
21
+ * Drop support for ruby 2.0, 2.1, 2.2 and 2.3
22
+ * Drop support for jruby 1.7 and 9.0
23
+ * Drop support for ActiveSupport 3.x
24
+ * Add support for jruby 9.2
25
+ * Add support for ruby 2.5, 2.6 and 2.7
26
+ * Add support for ActiveSupport 5.x and 6.x
27
+ * Fix day computations when origin is a holiday or a non worked day - [#39](https://github.com/Intrepidd/working_hours/pull/39)
28
+
4
29
 
5
30
  # v1.1.4
6
31
  * Fix thread safety - [#36](https://github.com/Intrepidd/working_hours/pull/36)
data/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # WorkingHours
2
2
 
3
- [![Build Status](https://travis-ci.org/Intrepidd/working_hours.svg?branch=master)](https://travis-ci.org/Intrepidd/working_hours)
3
+ [![Build Status](https://travis-ci.com/Intrepidd/working_hours.svg?branch=master)](https://travis-ci.com/Intrepidd/working_hours)
4
4
 
5
5
  A modern ruby gem allowing to do time calculation with working hours.
6
6
 
7
7
  Compatible and tested with:
8
- - Ruby `2.x`, JRuby `9.0.x`, JRuby `1.7.x` ( with 2.0 syntax: `--2.0` )
9
- - ActiveSupport `3.2.x`, `4.x`
8
+ - Ruby `2.4`, `2.5`, `2.6`, `2.7`, `3.0`, JRuby `9.2`
9
+ - ActiveSupport `4.x`, `5.x`, `6.x`
10
10
 
11
11
  ## Installation
12
12
 
@@ -104,6 +104,17 @@ 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
+ ```
107
118
 
108
119
  ## No core extensions / monkey patching
109
120
 
@@ -2,4 +2,4 @@ source 'https://rubygems.org'
2
2
 
3
3
  gemspec :path => '..'
4
4
 
5
- gem 'activesupport', '~> 3.2'
5
+ gem 'activesupport', '~> 5.2'
@@ -2,4 +2,4 @@ source 'https://rubygems.org'
2
2
 
3
3
  gemspec :path => '..'
4
4
 
5
- gem 'activesupport', '~> 4.0'
5
+ gem 'activesupport', '~> 6.1'
@@ -0,0 +1,5 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec :path => '..'
4
+
5
+ gem 'activesupport', github: 'rails/rails', branch: 'main'
@@ -5,8 +5,12 @@ module WorkingHours
5
5
  module Computation
6
6
 
7
7
  def add_days origin, days, config: nil
8
+ return origin if days.zero?
9
+
8
10
  config ||= wh_config
9
11
  time = in_config_zone(origin, config: config)
12
+ time += (days <=> 0).day until working_day?(time, config: config)
13
+
10
14
  while days > 0
11
15
  time += 1.day
12
16
  days -= 1 if working_day?(time, config: config)
@@ -30,13 +34,13 @@ module WorkingHours
30
34
 
31
35
  def add_seconds origin, seconds, config: nil
32
36
  config ||= wh_config
33
- time = in_config_zone(origin, config: config).round
37
+ time = in_config_zone(origin, config: config)
34
38
  while seconds > 0
35
39
  # roll to next business period
36
40
  time = advance_to_working_time(time, config: config)
37
41
  # look at working ranges
38
42
  time_in_day = time.seconds_since_midnight
39
- config[:working_hours][time.wday].each do |from, to|
43
+ working_hours_for(time, config: config).each do |from, to|
40
44
  if time_in_day >= from and time_in_day < to
41
45
  # take all we can
42
46
  take = [to - time_in_day, seconds].min
@@ -52,7 +56,8 @@ module WorkingHours
52
56
  time = return_to_exact_working_time(time, config: config)
53
57
  # look at working ranges
54
58
  time_in_day = time.seconds_since_midnight
55
- config[:working_hours][time.wday].reverse_each do |from, to|
59
+
60
+ working_hours_for(time, config: config).reverse_each do |from, to|
56
61
  if time_in_day > from and time_in_day <= to
57
62
  # take all we can
58
63
  take = [time_in_day - from, -seconds].min
@@ -68,7 +73,7 @@ module WorkingHours
68
73
 
69
74
  def advance_to_working_time time, config: nil
70
75
  config ||= wh_config
71
- time = in_config_zone(time, config: config).round
76
+ time = in_config_zone(time, config: config)
72
77
  loop do
73
78
  # skip holidays and weekends
74
79
  while not working_day?(time, config: config)
@@ -76,9 +81,10 @@ module WorkingHours
76
81
  end
77
82
  # find first working range after time
78
83
  time_in_day = time.seconds_since_midnight
79
- (config[:working_hours][time.wday] || {}).each do |from, to|
84
+
85
+ working_hours_for(time, config: config).each do |from, to|
80
86
  return time if time_in_day >= from and time_in_day < to
81
- return time + (from - time_in_day) if from >= time_in_day
87
+ return move_time_of_day(time, from) if from >= time_in_day
82
88
  end
83
89
  # if none is found, go to next day and loop
84
90
  time = (time + 1.day).beginning_of_day
@@ -87,7 +93,7 @@ module WorkingHours
87
93
 
88
94
  def advance_to_closing_time time, config: nil
89
95
  config ||= wh_config
90
- time = in_config_zone(time, config: config).round
96
+ time = in_config_zone(time, config: config)
91
97
  loop do
92
98
  # skip holidays and weekends
93
99
  while not working_day?(time, config: config)
@@ -95,13 +101,11 @@ module WorkingHours
95
101
  end
96
102
  # find next working range after time
97
103
  time_in_day = time.seconds_since_midnight
98
- time = time.beginning_of_day
99
- (config[:working_hours][time.wday] || {}).each do |from, to|
100
- return time + to if time_in_day >= from and time_in_day < to
101
- 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
102
106
  end
103
107
  # if none is found, go to next day and loop
104
- time = time + 1.day
108
+ time = (time + 1.day).beginning_of_day
105
109
  end
106
110
  end
107
111
 
@@ -119,7 +123,7 @@ module WorkingHours
119
123
 
120
124
  def return_to_exact_working_time time, config: nil
121
125
  config ||= wh_config
122
- time = in_config_zone(time, config: config).round
126
+ time = in_config_zone(time, config: config)
123
127
  loop do
124
128
  # skip holidays and weekends
125
129
  while not working_day?(time, config: config)
@@ -127,10 +131,9 @@ module WorkingHours
127
131
  end
128
132
  # find last working range before time
129
133
  time_in_day = time.seconds_since_midnight
130
- (config[:working_hours][time.wday] || {}).reverse_each do |from, to|
131
- # round is used to suppress miliseconds hack from `end_of_day`
134
+ working_hours_for(time, config: config).reverse_each do |from, to|
132
135
  return time if time_in_day > from and time_in_day <= to
133
- return (time - (time_in_day - to)) if to <= time_in_day
136
+ return move_time_of_day(time, to) if to <= time_in_day
134
137
  end
135
138
  # if none is found, go to previous day and loop
136
139
  time = (time - 1.day).end_of_day
@@ -140,7 +143,9 @@ module WorkingHours
140
143
  def working_day? time, config: nil
141
144
  config ||= wh_config
142
145
  time = in_config_zone(time, config: config)
143
- 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)
144
149
  end
145
150
 
146
151
  def in_working_hours? time, config: nil
@@ -148,7 +153,7 @@ module WorkingHours
148
153
  time = in_config_zone(time, config: config)
149
154
  return false if not working_day?(time, config: config)
150
155
  time_in_day = time.seconds_since_midnight
151
- config[:working_hours][time.wday].each do |from, to|
156
+ working_hours_for(time, config: config).each do |from, to|
152
157
  return true if time_in_day >= from and time_in_day < to
153
158
  end
154
159
  false
@@ -176,23 +181,28 @@ module WorkingHours
176
181
  -working_time_between(to, from, config: config)
177
182
  else
178
183
  from = advance_to_working_time(in_config_zone(from, config: config))
179
- to = in_config_zone(to, config: config).round
184
+ to = in_config_zone(to, config: config)
180
185
  distance = 0
181
186
  while from < to
187
+ from_was = from
182
188
  # look at working ranges
183
189
  time_in_day = from.seconds_since_midnight
184
- config[:working_hours][from.wday].each do |begins, ends|
190
+ working_hours_for(from, config: config).each do |begins, ends|
185
191
  if time_in_day >= begins and time_in_day < ends
186
- # take all we can
187
- take = [ends - time_in_day, to - from].min
188
- # advance time
189
- from += take
190
- # increase counter
191
- 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
192
201
  end
193
202
  end
194
203
  # roll to next business period
195
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
196
206
  end
197
207
  distance.round # round up to supress miliseconds introduced by 24:00 hack
198
208
  end
@@ -200,6 +210,23 @@ module WorkingHours
200
210
 
201
211
  private
202
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
+
203
230
  def wh_config
204
231
  WorkingHours::Config.precompiled
205
232
  end
@@ -223,5 +250,8 @@ module WorkingHours
223
250
  end
224
251
  end
225
252
 
253
+ def working_hours_for(time, config:)
254
+ config[:holiday_hours][time.to_date] || config[:working_hours][time.wday]
255
+ end
226
256
  end
227
257
  end
@@ -4,12 +4,11 @@ module WorkingHours
4
4
  InvalidConfiguration = Class.new StandardError
5
5
 
6
6
  class Config
7
-
8
7
  TIME_FORMAT = /\A([0-2][0-9])\:([0-5][0-9])(?:\:([0-5][0-9]))?\z/
9
8
  DAYS_OF_WEEK = [:sun, :mon, :tue, :wed, :thu, :fri, :sat]
9
+ MIDNIGHT = Rational('86399.999999')
10
10
 
11
11
  class << self
12
-
13
12
  def working_hours
14
13
  config[:working_hours]
15
14
  end
@@ -32,9 +31,26 @@ module WorkingHours
32
31
  config.delete :precompiled
33
32
  end
34
33
 
34
+ def holiday_hours
35
+ config[:holiday_hours]
36
+ end
37
+
38
+ def holiday_hours=(val)
39
+ validate_holiday_hours! val
40
+ config[:holiday_hours] = val
41
+ global_config[:holiday_hours] = val
42
+ config.delete :precompiled
43
+ end
44
+
35
45
  # Returns an optimized for computing version
36
46
  def precompiled
37
- config_hash = [config[:working_hours], config[:holidays], config[:time_zone]].hash
47
+ config_hash = [
48
+ config[:working_hours],
49
+ config[:holiday_hours],
50
+ config[:holidays],
51
+ config[:time_zone]
52
+ ].hash
53
+
38
54
  if config_hash != config[:config_hash]
39
55
  config[:config_hash] = config_hash
40
56
  config.delete :precompiled
@@ -42,15 +58,21 @@ module WorkingHours
42
58
 
43
59
  config[:precompiled] ||= begin
44
60
  validate_working_hours! config[:working_hours]
61
+ validate_holiday_hours! config[:holiday_hours]
45
62
  validate_holidays! config[:holidays]
46
63
  validate_time_zone! config[:time_zone]
47
- compiled = {working_hours: []}
64
+ compiled = { working_hours: Array.new(7) { Hash.new }, holiday_hours: {} }
48
65
  working_hours.each do |day, hours|
49
- compiled[:working_hours][DAYS_OF_WEEK.index(day)] = {}
50
66
  hours.each do |start, finish|
51
67
  compiled[:working_hours][DAYS_OF_WEEK.index(day)][compile_time(start)] = compile_time(finish)
52
68
  end
53
69
  end
70
+ holiday_hours.each do |day, hours|
71
+ compiled[:holiday_hours][day] = {}
72
+ hours.each do |start, finish|
73
+ compiled[:holiday_hours][day][compile_time(start)] = compile_time(finish)
74
+ end
75
+ end
54
76
  compiled[:holidays] = Set.new(holidays)
55
77
  compiled[:time_zone] = time_zone
56
78
  compiled
@@ -72,16 +94,19 @@ module WorkingHours
72
94
  Thread.current[:working_hours] = default_config
73
95
  end
74
96
 
75
- def with_config(working_hours: nil, holidays: nil, time_zone: nil)
97
+ def with_config(working_hours: nil, holiday_hours: nil, holidays: nil, time_zone: nil)
76
98
  original_working_hours = self.working_hours
99
+ original_holiday_hours = self.holiday_hours
77
100
  original_holidays = self.holidays
78
101
  original_time_zone = self.time_zone
79
102
  self.working_hours = working_hours if working_hours
103
+ self.holiday_hours = holiday_hours if holiday_hours
80
104
  self.holidays = holidays if holidays
81
105
  self.time_zone = time_zone if time_zone
82
106
  yield
83
107
  ensure
84
108
  self.working_hours = original_working_hours
109
+ self.holiday_hours = original_holiday_hours
85
110
  self.holidays = original_holidays
86
111
  self.time_zone = original_time_zone
87
112
  end
@@ -105,6 +130,7 @@ module WorkingHours
105
130
  thu: {'09:00' => '17:00'},
106
131
  fri: {'09:00' => '17:00'}
107
132
  },
133
+ holiday_hours: {},
108
134
  holidays: [],
109
135
  time_zone: ActiveSupport::TimeZone['UTC']
110
136
  }
@@ -116,18 +142,12 @@ module WorkingHours
116
142
  sec = time[TIME_FORMAT,3].to_i
117
143
  time = hour * 3600 + min * 60 + sec
118
144
  # Converts 24:00 to 23:59:59.999999
119
- return 86399.999999 if time == 86400
145
+ return MIDNIGHT if time == 86400
120
146
  time
121
147
  end
122
148
 
123
- def validate_working_hours! week
124
- if week.empty?
125
- raise InvalidConfiguration.new "No working hours given"
126
- end
127
- if (invalid_keys = (week.keys - DAYS_OF_WEEK)).any?
128
- raise InvalidConfiguration.new "Invalid day identifier(s): #{invalid_keys.join(', ')} - must be 3 letter symbols"
129
- end
130
- week.each do |day, hours|
149
+ def validate_hours! dates
150
+ dates.each do |day, hours|
131
151
  if not hours.is_a? Hash
132
152
  raise InvalidConfiguration.new "Invalid type for `#{day}`: #{hours.class} - must be Hash"
133
153
  elsif hours.empty?
@@ -151,6 +171,23 @@ module WorkingHours
151
171
  end
152
172
  end
153
173
 
174
+ def validate_working_hours! week
175
+ if week.empty?
176
+ raise InvalidConfiguration.new "No working hours given"
177
+ end
178
+ if (invalid_keys = (week.keys - DAYS_OF_WEEK)).any?
179
+ raise InvalidConfiguration.new "Invalid day identifier(s): #{invalid_keys.join(', ')} - must be 3 letter symbols"
180
+ end
181
+ validate_hours!(week)
182
+ end
183
+
184
+ def validate_holiday_hours! days
185
+ if (invalid_keys = (days.keys.reject{ |day| day.is_a?(Date) })).any?
186
+ raise InvalidConfiguration.new "Invalid day identifier(s): #{invalid_keys.join(', ')} - must be a Date object"
187
+ end
188
+ validate_hours!(days)
189
+ end
190
+
154
191
  def validate_holidays! holidays
155
192
  if not holidays.respond_to?(:to_a)
156
193
  raise InvalidConfiguration.new "Invalid type for holidays: #{holidays.class} - must act like an array"
@@ -175,12 +212,10 @@ module WorkingHours
175
212
  end
176
213
  res
177
214
  end
178
-
179
215
  end
180
216
 
181
217
  private
182
218
 
183
- def initialize
184
- end
219
+ def initialize; end
185
220
  end
186
221
  end
@@ -1,3 +1,3 @@
1
1
  module WorkingHours
2
- VERSION = "1.1.4"
2
+ VERSION = "1.4.0"
3
3
  end
@@ -32,12 +32,26 @@ describe WorkingHours::Computation do
32
32
  expect(add_days(time, 1)).to eq(Date.new(2014, 4, 9)) # Wednesday
33
33
  end
34
34
 
35
+ it 'skips non worked days when origin is not worked' do
36
+ time = Date.new(2014, 4, 8) # Tuesday
37
+ WorkingHours::Config.working_hours = {mon: {'09:00' => '17:00'}, wed: {'09:00' => '17:00'}, thu: {'09:00' => '17:00'}, sun: {'09:00' => '17:00'}}
38
+ expect(add_days(time, 1)).to eq(Date.new(2014, 4, 10)) # Thursday
39
+ expect(add_days(time, -1)).to eq(Date.new(2014, 4, 6)) # Sunday
40
+ end
41
+
35
42
  it 'skips holidays' do
36
43
  time = Date.new(2014, 4, 7) # Monday
37
44
  WorkingHours::Config.holidays = [Date.new(2014, 4, 8)] # Tuesday
38
45
  expect(add_days(time, 1)).to eq(Date.new(2014, 4, 9)) # Wednesday
39
46
  end
40
47
 
48
+ it 'skips holidays when origin is holiday' do
49
+ time = Date.new(2014, 4, 9) # Wednesday
50
+ WorkingHours::Config.holidays = [time] # Wednesday
51
+ expect(add_days(time, 1)).to eq(Date.new(2014, 4, 11)) # Friday
52
+ expect(add_days(time, -1)).to eq(Date.new(2014, 4, 7)) # Monday
53
+ end
54
+
41
55
  it 'skips holidays and non worked days' do
42
56
  time = Date.new(2014, 4, 7) # Monday
43
57
  WorkingHours::Config.holidays = [Date.new(2014, 4, 9)] # Wednesday
@@ -45,6 +59,12 @@ describe WorkingHours::Computation do
45
59
  expect(add_days(time, 3)).to eq(Date.new(2014, 4, 21))
46
60
  end
47
61
 
62
+ it 'returns the original value when adding 0 days' do
63
+ time = Date.new(2014, 4, 7)
64
+ WorkingHours::Config.holidays = [time]
65
+ expect(add_days(time, 0)).to eq(time)
66
+ end
67
+
48
68
  it 'accepts time given from any time zone' do
49
69
  time = Time.utc(1991, 11, 14, 21, 0, 0) # Thursday 21 pm UTC
50
70
  WorkingHours::Config.time_zone = 'Tokyo' # But we are at tokyo, so it's already Friday 6 am
@@ -91,7 +111,7 @@ describe WorkingHours::Computation do
91
111
  expect(add_seconds(time, 120)).to eq(Time.utc(1991, 11, 18, 9, 1, 42))
92
112
  end
93
113
 
94
- it 'Calls precompiled only once' do
114
+ it 'calls precompiled only once' do
95
115
  precompiled = WorkingHours::Config.precompiled
96
116
  expect(WorkingHours::Config).to receive(:precompiled).once.and_return(precompiled) # in_config_zone and add_seconds
97
117
  time = Time.utc(1991, 11, 15, 16, 59, 42) # Friday
@@ -109,6 +129,95 @@ describe WorkingHours::Computation do
109
129
  time = Time.utc(2014, 4, 8, 0, 0, 30) # Tuesday
110
130
  expect(add_seconds(time, -60)).to eq(Time.utc(2014, 4, 7, 23, 59, 00))
111
131
  end
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
+
216
+ it 'honors miliseconds in the base time and increment (but return rounded result)' do
217
+ # Rounding the base time or increments before the end would yield a wrong result
218
+ time = Time.utc(1991, 11, 15, 16, 59, 42.25) # +250ms
219
+ expect(add_seconds(time, 120.4)).to eq(Time.utc(1991, 11, 18, 9, 1, 43))
220
+ end
112
221
  end
113
222
 
114
223
  describe '#advance_to_working_time' do
@@ -146,10 +255,45 @@ describe WorkingHours::Computation do
146
255
  WorkingHours::Config.time_zone = 'Tokyo'
147
256
  expect(advance_to_working_time(Time.new(2014, 4, 7, 0, 0, 0)).zone).to eq('JST')
148
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
149
294
  end
150
295
 
151
296
  describe '#advance_to_closing_time' do
152
-
153
297
  it 'jumps non-working day' do
154
298
  WorkingHours::Config.holidays = [Date.new(2014, 5, 1)]
155
299
  holiday = Time.utc(2014, 5, 1, 12, 0)
@@ -213,7 +357,7 @@ describe WorkingHours::Computation do
213
357
  end
214
358
 
215
359
  let(:monday_morning) { Time.utc(2014, 4, 7, 0) }
216
- let(:monday_closing) { Time.utc(2014, 4, 7) + 86399.999999 }
360
+ let(:monday_closing) { Time.utc(2014, 4, 7) + WorkingHours::Config::MIDNIGHT }
217
361
  let(:tuesday_closing) { Time.utc(2014, 4, 8, 17) }
218
362
  let(:sunday) { Time.utc(2014, 4, 6, 17) }
219
363
 
@@ -228,6 +372,11 @@ describe WorkingHours::Computation do
228
372
  it 'moves over midnight' do
229
373
  expect(advance_to_closing_time(sunday)).to eq(monday_closing)
230
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
231
380
  end
232
381
 
233
382
  it 'works with any input timezone (converts to config)' do
@@ -244,10 +393,55 @@ describe WorkingHours::Computation do
244
393
  WorkingHours::Config.time_zone = 'Tokyo'
245
394
  expect(advance_to_closing_time(Time.new(2014, 4, 7, 0, 0, 0)).zone).to eq('JST')
246
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
247
442
  end
248
443
 
249
444
  describe '#next_working_time' do
250
-
251
445
  it 'jumps non-working day' do
252
446
  WorkingHours::Config.holidays = [Date.new(2014, 5, 1)]
253
447
  holiday = Time.utc(2014, 5, 1, 12, 0)
@@ -350,6 +544,32 @@ describe WorkingHours::Computation do
350
544
  WorkingHours::Config.time_zone = 'Tokyo'
351
545
  expect(return_to_working_time(Time.new(2014, 4, 7, 1, 0, 0)).zone).to eq('JST')
352
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
353
573
  end
354
574
 
355
575
  describe '#working_day?' do
@@ -397,6 +617,23 @@ describe WorkingHours::Computation do
397
617
  WorkingHours::Config.time_zone = 'Tokyo'
398
618
  expect(in_working_hours?(Time.utc(2014, 4, 7, 0, 0))).to be(true)
399
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
400
637
  end
401
638
 
402
639
  describe '#working_days_between' do
@@ -516,5 +753,138 @@ describe WorkingHours::Computation do
516
753
  Time.new(2014, 4, 7, 15, 0, 0, "-04:00"), # Monday 7pm in UTC
517
754
  )).to eq(7.hours)
518
755
  end
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
+
814
+ # generates two times with +0ms, +250ms, +500ms, +750ms and +1s
815
+ # then for each combination compare the result with a ruby diff
816
+ context 'with precise miliseconds timings' do
817
+ reference = Time.utc(2014, 4, 7, 10)
818
+ 0.step(1.0, 0.25) do |offset1|
819
+ 0.step(1.0, 0.25) do |offset2|
820
+ from = reference + offset1
821
+ to = reference + offset2
822
+ it "returns expected value (#{(to - from).round}) for #{offset1} — #{offset2} interval" do
823
+ expect(working_time_between(from, to)).to eq((to - from).round)
824
+ end
825
+ end
826
+ end
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
519
889
  end
520
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)
158
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
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
 
@@ -266,16 +390,25 @@ describe WorkingHours::Config do
266
390
 
267
391
  it 'computes an optimized version' do
268
392
  expect(subject).to eq({
269
- :working_hours => [nil, {32400=>61200}, {32400=>61200}, {32400=>61200}, {32400=>61200}, {32400=>61200}],
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
  })
273
398
  end
274
399
 
400
+ it 'includes default values for each days so computation does not fail' do
401
+ WorkingHours::Config.working_hours = {:mon => {'08:00' => '14:00'}}
402
+ expect(subject[:working_hours]).to eq([{}, {28800=>50400}, {}, {}, {}, {}, {}])
403
+ expect(WorkingHours.working_time_between(Time.utc(2014, 4, 14, 0), Time.utc(2014, 4, 21, 0))).to eq(3600*6)
404
+ expect(WorkingHours.add_seconds(Time.utc(2014, 4, 14, 0), 3600*7)).to eq(Time.utc(2014, 4, 21, 9))
405
+ end
406
+
275
407
  it 'supports seconds' do
276
408
  WorkingHours::Config.working_hours = {:mon => {'20:32:59' => '22:59:59'}}
277
409
  expect(subject).to eq({
278
- :working_hours => [nil, {73979 => 82799}],
410
+ :working_hours => [{}, {73979 => 82799}, {}, {}, {}, {}, {}],
411
+ :holiday_hours => {},
279
412
  :holidays => Set.new([]),
280
413
  :time_zone => ActiveSupport::TimeZone['UTC']
281
414
  })
@@ -284,7 +417,8 @@ describe WorkingHours::Config do
284
417
  it 'supports 24:00 (converts to 23:59:59.999999)' do
285
418
  WorkingHours::Config.working_hours = {:mon => {'20:00' => '24:00'}}
286
419
  expect(subject).to eq({
287
- :working_hours => [nil, {72000 => 86399.999999}],
420
+ :working_hours => [{}, {72000 => 86399.999999}, {}, {}, {}, {}, {}],
421
+ :holiday_hours => {},
288
422
  :holidays => Set.new([]),
289
423
  :time_zone => ActiveSupport::TimeZone['UTC']
290
424
  })
@@ -296,9 +430,9 @@ describe WorkingHours::Config do
296
430
  }.to change {
297
431
  WorkingHours::Config.precompiled[:working_hours]
298
432
  }.from(
299
- [nil, {32400=>61200}, {32400=>61200}, {32400=>61200}, {32400=>61200}, {32400=>61200}]
433
+ [{}, {32400=>61200}, {32400=>61200}, {32400=>61200}, {32400=>61200}, {32400=>61200}, {}]
300
434
  ).to(
301
- [nil, {28800=>50400}]
435
+ [{}, {28800=>50400}, {}, {}, {}, {}, {}]
302
436
  )
303
437
  end
304
438
 
@@ -21,7 +21,7 @@ Gem::Specification.new do |spec|
21
21
  spec.add_dependency 'activesupport', '>= 3.2'
22
22
  spec.add_dependency 'tzinfo'
23
23
 
24
- spec.add_development_dependency 'bundler', '~> 1.5'
24
+ spec.add_development_dependency 'bundler', '>= 1.5'
25
25
  spec.add_development_dependency 'rake'
26
26
  spec.add_development_dependency 'rspec', '~> 3.2'
27
27
  spec.add_development_dependency 'timecop'
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.1.4
4
+ version: 1.4.0
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: 2018-11-30 00:00:00.000000000 Z
12
+ date: 2021-07-05 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activesupport
@@ -43,14 +43,14 @@ dependencies:
43
43
  name: bundler
44
44
  requirement: !ruby/object:Gem::Requirement
45
45
  requirements:
46
- - - "~>"
46
+ - - ">="
47
47
  - !ruby/object:Gem::Version
48
48
  version: '1.5'
49
49
  type: :development
50
50
  prerelease: false
51
51
  version_requirements: !ruby/object:Gem::Requirement
52
52
  requirements:
53
- - - "~>"
53
+ - - ">="
54
54
  - !ruby/object:Gem::Version
55
55
  version: '1.5'
56
56
  - !ruby/object:Gem::Dependency
@@ -111,11 +111,10 @@ files:
111
111
  - LICENSE.txt
112
112
  - README.md
113
113
  - Rakefile
114
- - gemfiles/Gemfile.activesupport-3.2.x
115
- - gemfiles/Gemfile.activesupport-4.0.x
116
- - gemfiles/Gemfile.activesupport-4.1.x
117
- - gemfiles/Gemfile.activesupport-4.2.x
118
- - gemfiles/Gemfile.activesupport-head
114
+ - gemfiles/Gemfile.activesupport-4.x
115
+ - gemfiles/Gemfile.activesupport-5.x
116
+ - gemfiles/Gemfile.activesupport-6.x
117
+ - gemfiles/Gemfile.activesupport-edge
119
118
  - lib/working_hours.rb
120
119
  - lib/working_hours/computation.rb
121
120
  - lib/working_hours/config.rb
@@ -153,8 +152,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
153
152
  - !ruby/object:Gem::Version
154
153
  version: '0'
155
154
  requirements: []
156
- rubyforge_project:
157
- rubygems_version: 2.5.1
155
+ rubygems_version: 3.1.2
158
156
  signing_key:
159
157
  specification_version: 4
160
158
  summary: time calculation with working hours
@@ -167,4 +165,3 @@ test_files:
167
165
  - spec/working_hours/duration_proxy_spec.rb
168
166
  - spec/working_hours/duration_spec.rb
169
167
  - spec/working_hours_spec.rb
170
- has_rdoc:
@@ -1,5 +0,0 @@
1
- source 'https://rubygems.org'
2
-
3
- gemspec :path => '..'
4
-
5
- gem 'activesupport', '~> 4.1'
@@ -1,5 +0,0 @@
1
- source 'https://rubygems.org'
2
-
3
- gemspec :path => '..'
4
-
5
- gem 'activesupport', github: 'rails/rails'