working_hours 1.1.3 → 1.3.2

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: 84b53643f4f18edb7c63446575a16d2f670d658e
4
- data.tar.gz: dff1cc9158cf632f559734fdf52b31b41b16d004
2
+ SHA256:
3
+ metadata.gz: 102b64fd640e32b4164e32d0323d3575bfd720064d02045ff65f7e320b7dd305
4
+ data.tar.gz: dea4493b517eda97438e35f3857da9b456ead0e992b6278b33d108f1c38ef7ca
5
5
  SHA512:
6
- metadata.gz: ffaa30b066b7fa1aca5627d26698b6fa54c4f594d7d5dc4a876be57ab065557d6bcd078ad2a3682d855804981e992979ed7c6b986ba9b8e1135850d2e4716ea2
7
- data.tar.gz: 124cd2067fbb66250df7bf78451a0561d37b2b3a27ff91cfd8b85155af0e806193b20fb272065fd615ac85500b6539c1080ac93c2daed37e48e00d9ae2b8d346
6
+ metadata.gz: 88e9102b1a8111aab7b0c5017c93f2eda8c99821499dfe9aa91e89572925b090a95840059a91de0e1114340ae566973a4d67844ebabee4a8f93dd6f11ae94786
7
+ data.tar.gz: 5dcdc183274c968c8cf61880870642ec1045092f9f579fb0f14c9ad1e45ffd232e37aa544c0a1c2f4c58dca4a676d1c6856fdd7cf95171e8702201ec44b550bc
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.3](https://github.com/intrepidd/working_hours/compare/v1.1.3...master)
3
+ [Compare master with v1.3.1](https://github.com/intrepidd/working_hours/compare/v1.3.1...master)
4
+
5
+ # v1.3.2
6
+ * Improve support for time shifts - [#46](https://github.com/Intrepidd/working_hours/pull/46)
7
+
8
+ # v1.3.1
9
+ * 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)
10
+ * 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)
11
+ * 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)
12
+
13
+ # v1.3.0
14
+ * 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)
15
+ * 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)
16
+
17
+ # v1.2.0
18
+ * Drop support for ruby 2.0, 2.1, 2.2 and 2.3
19
+ * Drop support for jruby 1.7 and 9.0
20
+ * Drop support for ActiveSupport 3.x
21
+ * Add support for jruby 9.2
22
+ * Add support for ruby 2.5, 2.6 and 2.7
23
+ * Add support for ActiveSupport 5.x and 6.x
24
+ * Fix day computations when origin is a holiday or a non worked day - [#39](https://github.com/Intrepidd/working_hours/pull/39)
25
+
26
+
27
+ # v1.1.4
28
+ * Fix thread safety - [#36](https://github.com/Intrepidd/working_hours/pull/36)
4
29
 
5
30
  # v1.1.3
6
31
  * Fixed warnings with Ruby 2.4.0+ - [#32](https://github.com/Intrepidd/working_hours/pull/32)
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
 
@@ -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,7 +34,7 @@ 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)
@@ -68,7 +72,7 @@ module WorkingHours
68
72
 
69
73
  def advance_to_working_time time, config: nil
70
74
  config ||= wh_config
71
- time = in_config_zone(time, config: config).round
75
+ time = in_config_zone(time, config: config)
72
76
  loop do
73
77
  # skip holidays and weekends
74
78
  while not working_day?(time, config: config)
@@ -76,9 +80,9 @@ module WorkingHours
76
80
  end
77
81
  # find first working range after time
78
82
  time_in_day = time.seconds_since_midnight
79
- (config[:working_hours][time.wday] || {}).each do |from, to|
83
+ config[:working_hours][time.wday].each do |from, to|
80
84
  return time if time_in_day >= from and time_in_day < to
81
- return time + (from - time_in_day) if from >= time_in_day
85
+ return move_time_of_day(time, from) if from >= time_in_day
82
86
  end
83
87
  # if none is found, go to next day and loop
84
88
  time = (time + 1.day).beginning_of_day
@@ -87,7 +91,7 @@ module WorkingHours
87
91
 
88
92
  def advance_to_closing_time time, config: nil
89
93
  config ||= wh_config
90
- time = in_config_zone(time, config: config).round
94
+ time = in_config_zone(time, config: config)
91
95
  loop do
92
96
  # skip holidays and weekends
93
97
  while not working_day?(time, config: config)
@@ -95,13 +99,11 @@ module WorkingHours
95
99
  end
96
100
  # find next working range after time
97
101
  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
102
+ config[:working_hours][time.wday].each do |from, to|
103
+ return move_time_of_day(time, to) if time_in_day < to
102
104
  end
103
105
  # if none is found, go to next day and loop
104
- time = time + 1.day
106
+ time = (time + 1.day).beginning_of_day
105
107
  end
106
108
  end
107
109
 
@@ -119,7 +121,7 @@ module WorkingHours
119
121
 
120
122
  def return_to_exact_working_time time, config: nil
121
123
  config ||= wh_config
122
- time = in_config_zone(time, config: config).round
124
+ time = in_config_zone(time, config: config)
123
125
  loop do
124
126
  # skip holidays and weekends
125
127
  while not working_day?(time, config: config)
@@ -127,10 +129,9 @@ module WorkingHours
127
129
  end
128
130
  # find last working range before time
129
131
  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`
132
+ config[:working_hours][time.wday].reverse_each do |from, to|
132
133
  return time if time_in_day > from and time_in_day <= to
133
- return (time - (time_in_day - to)) if to <= time_in_day
134
+ return move_time_of_day(time, to) if to <= time_in_day
134
135
  end
135
136
  # if none is found, go to previous day and loop
136
137
  time = (time - 1.day).end_of_day
@@ -176,23 +177,28 @@ module WorkingHours
176
177
  -working_time_between(to, from, config: config)
177
178
  else
178
179
  from = advance_to_working_time(in_config_zone(from, config: config))
179
- to = in_config_zone(to, config: config).round
180
+ to = in_config_zone(to, config: config)
180
181
  distance = 0
181
182
  while from < to
183
+ from_was = from
182
184
  # look at working ranges
183
185
  time_in_day = from.seconds_since_midnight
184
186
  config[:working_hours][from.wday].each do |begins, ends|
185
187
  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
188
+ if (to - from) > (ends - time_in_day)
189
+ # take all the range and continue
190
+ distance += (ends - time_in_day)
191
+ from = move_time_of_day(from, ends)
192
+ else
193
+ # take only what's needed and stop
194
+ distance += (to - from)
195
+ from = to
196
+ end
192
197
  end
193
198
  end
194
199
  # roll to next business period
195
200
  from = advance_to_working_time(from, config: config)
201
+ 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
202
  end
197
203
  distance.round # round up to supress miliseconds introduced by 24:00 hack
198
204
  end
@@ -200,6 +206,23 @@ module WorkingHours
200
206
 
201
207
  private
202
208
 
209
+ # Changes the time of the day to match given time (in seconds since midnight)
210
+ # preserving nanosecond prevision (rational number) and honoring time shifts
211
+ #
212
+ # This replaces the previous implementation which was:
213
+ # time.beginning_of_day + seconds
214
+ # (because this one would shift hours during time shifts days)
215
+ def move_time_of_day time, seconds
216
+ # return time.beginning_of_day + seconds
217
+ hour = (seconds / 3600).to_i
218
+ seconds %= 3600
219
+ minutes = (seconds / 60).to_i
220
+ seconds %= 60
221
+ # sec/usec separation is required for ActiveSupport <= 5.1
222
+ usec = ((seconds % 1) * 10**6)
223
+ time.change(hour: hour, min: minutes, sec: seconds.to_i, usec: usec)
224
+ end
225
+
203
226
  def wh_config
204
227
  WorkingHours::Config.precompiled
205
228
  end
@@ -7,6 +7,7 @@ module WorkingHours
7
7
 
8
8
  TIME_FORMAT = /\A([0-2][0-9])\:([0-5][0-9])(?:\:([0-5][0-9]))?\z/
9
9
  DAYS_OF_WEEK = [:sun, :mon, :tue, :wed, :thu, :fri, :sat]
10
+ MIDNIGHT = Rational('86399.999999')
10
11
 
11
12
  class << self
12
13
 
@@ -44,9 +45,8 @@ module WorkingHours
44
45
  validate_working_hours! config[:working_hours]
45
46
  validate_holidays! config[:holidays]
46
47
  validate_time_zone! config[:time_zone]
47
- compiled = {working_hours: []}
48
+ compiled = { working_hours: Array.new(7) { Hash.new } }
48
49
  working_hours.each do |day, hours|
49
- compiled[:working_hours][DAYS_OF_WEEK.index(day)] = {}
50
50
  hours.each do |start, finish|
51
51
  compiled[:working_hours][DAYS_OF_WEEK.index(day)][compile_time(start)] = compile_time(finish)
52
52
  end
@@ -89,7 +89,7 @@ module WorkingHours
89
89
  private
90
90
 
91
91
  def config
92
- Thread.current[:working_hours] ||= global_config
92
+ Thread.current[:working_hours] ||= global_config.dup
93
93
  end
94
94
 
95
95
  def global_config
@@ -116,7 +116,7 @@ module WorkingHours
116
116
  sec = time[TIME_FORMAT,3].to_i
117
117
  time = hour * 3600 + min * 60 + sec
118
118
  # Converts 24:00 to 23:59:59.999999
119
- return 86399.999999 if time == 86400
119
+ return MIDNIGHT if time == 86400
120
120
  time
121
121
  end
122
122
 
@@ -1,3 +1,3 @@
1
1
  module WorkingHours
2
- VERSION = "1.1.3"
2
+ VERSION = "1.3.2"
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
@@ -109,6 +129,13 @@ 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
+ it 'honors miliseconds in the base time and increment (but return rounded result)' do
134
+ # Rounding the base time or increments before the end would yield a wrong result
135
+ time = Time.utc(1991, 11, 15, 16, 59, 42.25) # +250ms
136
+ expect(add_seconds(time, 120.4)).to eq(Time.utc(1991, 11, 18, 9, 1, 43))
137
+ end
138
+
112
139
  end
113
140
 
114
141
  describe '#advance_to_working_time' do
@@ -146,6 +173,36 @@ describe WorkingHours::Computation do
146
173
  WorkingHours::Config.time_zone = 'Tokyo'
147
174
  expect(advance_to_working_time(Time.new(2014, 4, 7, 0, 0, 0)).zone).to eq('JST')
148
175
  end
176
+
177
+ it 'do not leak nanoseconds when advancing' do
178
+ 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))
179
+ end
180
+
181
+ it 'returns correct hour during positive time shifts' do
182
+ WorkingHours::Config.working_hours = {sun: {'09:00' => '17:00'}}
183
+ WorkingHours::Config.time_zone = 'Paris'
184
+ from = Time.new(2020, 3, 29, 0, 0, 0, "+01:00")
185
+ expect(from.utc_offset).to eq(3600)
186
+ res = advance_to_working_time(from)
187
+ expect(res).to eq(Time.new(2020, 3, 29, 9, 0, 0, "+02:00"))
188
+ expect(res.utc_offset).to eq(7200)
189
+ # starting from wrong time-zone
190
+ 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"))
191
+ 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"))
192
+ end
193
+
194
+ it 'returns correct hour during negative time shifts' do
195
+ WorkingHours::Config.working_hours = {sun: {'09:00' => '17:00'}}
196
+ WorkingHours::Config.time_zone = 'Paris'
197
+ from = Time.new(2020, 10, 25, 0, 0, 0, "+02:00")
198
+ expect(from.utc_offset).to eq(7200)
199
+ res = advance_to_working_time(from)
200
+ expect(res).to eq(Time.new(2020, 10, 25, 9, 0, 0, "+01:00"))
201
+ expect(res.utc_offset).to eq(3600)
202
+ # starting from wrong time-zone
203
+ 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"))
204
+ 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"))
205
+ end
149
206
  end
150
207
 
151
208
  describe '#advance_to_closing_time' do
@@ -213,7 +270,7 @@ describe WorkingHours::Computation do
213
270
  end
214
271
 
215
272
  let(:monday_morning) { Time.utc(2014, 4, 7, 0) }
216
- let(:monday_closing) { Time.utc(2014, 4, 7) + 86399.999999 }
273
+ let(:monday_closing) { Time.utc(2014, 4, 7) + WorkingHours::Config::MIDNIGHT }
217
274
  let(:tuesday_closing) { Time.utc(2014, 4, 8, 17) }
218
275
  let(:sunday) { Time.utc(2014, 4, 6, 17) }
219
276
 
@@ -228,6 +285,11 @@ describe WorkingHours::Computation do
228
285
  it 'moves over midnight' do
229
286
  expect(advance_to_closing_time(sunday)).to eq(monday_closing)
230
287
  end
288
+
289
+ it 'give precise computation with nothing other than miliseconds' do
290
+ pending "iso8601 is not precise enough on AS < 4" if ActiveSupport::VERSION::MAJOR <= 4
291
+ expect(advance_to_closing_time(monday_morning).iso8601(25)).to eq("2014-04-07T23:59:59.9999990000000000000000000Z")
292
+ end
231
293
  end
232
294
 
233
295
  it 'works with any input timezone (converts to config)' do
@@ -244,6 +306,36 @@ describe WorkingHours::Computation do
244
306
  WorkingHours::Config.time_zone = 'Tokyo'
245
307
  expect(advance_to_closing_time(Time.new(2014, 4, 7, 0, 0, 0)).zone).to eq('JST')
246
308
  end
309
+
310
+ it 'do not leak nanoseconds when advancing' do
311
+ 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))
312
+ end
313
+
314
+ it 'returns correct hour during positive time shifts' do
315
+ WorkingHours::Config.working_hours = {sun: {'09:00' => '17:00'}}
316
+ WorkingHours::Config.time_zone = 'Paris'
317
+ from = Time.new(2020, 3, 29, 0, 0, 0, "+01:00")
318
+ expect(from.utc_offset).to eq(3600)
319
+ res = advance_to_closing_time(from)
320
+ expect(res).to eq(Time.new(2020, 3, 29, 17, 0, 0, "+02:00"))
321
+ expect(res.utc_offset).to eq(7200)
322
+ # starting from wrong time-zone
323
+ 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"))
324
+ 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"))
325
+ end
326
+
327
+ it 'returns correct hour during negative time shifts' do
328
+ WorkingHours::Config.working_hours = {sun: {'09:00' => '17:00'}}
329
+ WorkingHours::Config.time_zone = 'Paris'
330
+ from = Time.new(2020, 10, 25, 0, 0, 0, "+02:00")
331
+ expect(from.utc_offset).to eq(7200)
332
+ res = advance_to_closing_time(from)
333
+ expect(res).to eq(Time.new(2020, 10, 25, 17, 0, 0, "+01:00"))
334
+ expect(res.utc_offset).to eq(3600)
335
+ # starting from wrong time-zone
336
+ 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"))
337
+ 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"))
338
+ end
247
339
  end
248
340
 
249
341
  describe '#next_working_time' do
@@ -350,6 +442,32 @@ describe WorkingHours::Computation do
350
442
  WorkingHours::Config.time_zone = 'Tokyo'
351
443
  expect(return_to_working_time(Time.new(2014, 4, 7, 1, 0, 0)).zone).to eq('JST')
352
444
  end
445
+
446
+ it 'returns correct hour during positive time shifts' do
447
+ WorkingHours::Config.working_hours = {sun: {'00:00' => '01:00'}}
448
+ WorkingHours::Config.time_zone = 'Paris'
449
+ from = Time.new(2020, 3, 29, 9, 0, 0, "+02:00")
450
+ expect(from.utc_offset).to eq(7200)
451
+ res = return_to_working_time(from)
452
+ expect(res).to eq(Time.new(2020, 3, 29, 1, 0, 0, "+01:00"))
453
+ expect(res.utc_offset).to eq(3600)
454
+ # starting from wrong time-zone
455
+ 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"))
456
+ 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"))
457
+ end
458
+
459
+ it 'returns correct hour during negative time shifts' do
460
+ WorkingHours::Config.working_hours = {sun: {'00:00' => '01:00'}}
461
+ WorkingHours::Config.time_zone = 'Paris'
462
+ from = Time.new(2020, 10, 25, 9, 0, 0, "+01:00")
463
+ expect(from.utc_offset).to eq(3600)
464
+ res = return_to_working_time(from)
465
+ expect(res).to eq(Time.new(2020, 10, 25, 1, 0, 0, "+02:00"))
466
+ expect(res.utc_offset).to eq(7200)
467
+ # starting from wrong time-zone
468
+ 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"))
469
+ 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"))
470
+ end
353
471
  end
354
472
 
355
473
  describe '#working_day?' do
@@ -516,5 +634,77 @@ describe WorkingHours::Computation do
516
634
  Time.new(2014, 4, 7, 15, 0, 0, "-04:00"), # Monday 7pm in UTC
517
635
  )).to eq(7.hours)
518
636
  end
637
+
638
+ it 'uses precise computation to avoid useless loops' do
639
+ # +200 usec on each time, using floating point would cause
640
+ # precision issues and require several iterations
641
+ expect(self).to receive(:advance_to_working_time).twice.and_call_original
642
+ expect(working_time_between(
643
+ Time.utc(2014, 4, 7, 5, 0, 0, 200),
644
+ Time.utc(2014, 4, 7, 15, 0, 0, 200),
645
+ )).to eq(6.hours)
646
+ end
647
+
648
+ it 'works across positive time shifts' do
649
+ WorkingHours::Config.working_hours = {sun: {'08:00' => '21:00'}}
650
+ WorkingHours::Config.time_zone = 'Paris'
651
+ expect(working_time_between(
652
+ Time.utc(2020, 3, 29, 1, 0),
653
+ Time.utc(2020, 3, 30, 0, 0),
654
+ )).to eq(13.hours)
655
+ end
656
+
657
+ it 'works across negative time shifts' do
658
+ WorkingHours::Config.working_hours = {sun: {'08:00' => '21:00'}}
659
+ WorkingHours::Config.time_zone = 'Paris'
660
+ expect(working_time_between(
661
+ Time.utc(2019, 10, 27, 1, 0),
662
+ Time.utc(2019, 10, 28, 0, 0),
663
+ )).to eq(13.hours)
664
+ end
665
+
666
+ it 'works across time shifts + midnight' do
667
+ WorkingHours::Config.working_hours = {sun: {'00:00' => '24:00'}}
668
+ WorkingHours::Config.time_zone = 'Paris'
669
+ expect(working_time_between(
670
+ Time.utc(2020, 10, 24, 22, 0),
671
+ Time.utc(2020, 10, 25, 23, 0),
672
+ )).to eq(24.hours)
673
+ end
674
+
675
+ it 'works across multiple time shifts' do
676
+ WorkingHours::Config.working_hours = {sun: {'08:00' => '21:00'}}
677
+ WorkingHours::Config.time_zone = 'Paris'
678
+ expect(working_time_between(
679
+ Time.utc(2002, 10, 27, 6, 0),
680
+ Time.utc(2021, 10, 30, 0, 0),
681
+ )).to eq(12896.hours)
682
+ end
683
+
684
+ it 'do not cause infinite loop if the time is not advancing properly' do
685
+ # simulate some computation/precision error
686
+ expect(self).to receive(:advance_to_working_time).twice do |time|
687
+ time.change(hour: 9) - 0.0001
688
+ end
689
+ expect { working_time_between(
690
+ Time.utc(2014, 4, 7, 5, 0, 0),
691
+ Time.utc(2014, 4, 7, 15, 0, 0),
692
+ ) }.to raise_error(RuntimeError, /Invalid loop detected in working_time_between \(from=2014-04-07T08:59:59.999/)
693
+ end
694
+
695
+ # generates two times with +0ms, +250ms, +500ms, +750ms and +1s
696
+ # then for each combination compare the result with a ruby diff
697
+ context 'with precise miliseconds timings' do
698
+ reference = Time.utc(2014, 4, 7, 10)
699
+ 0.step(1.0, 0.25) do |offset1|
700
+ 0.step(1.0, 0.25) do |offset2|
701
+ from = reference + offset1
702
+ to = reference + offset2
703
+ it "returns expected value (#{(to - from).round}) for #{offset1} — #{offset2} interval" do
704
+ expect(working_time_between(from, to)).to eq((to - from).round)
705
+ end
706
+ end
707
+ end
708
+ end
519
709
  end
520
710
  end
@@ -5,25 +5,58 @@ describe WorkingHours::Config do
5
5
  describe '.working_hours' do
6
6
 
7
7
  let(:config) { WorkingHours::Config.working_hours }
8
+ let(:config2) { { :mon => { '08:00' => '14:00' } } }
9
+ let(:config3) { { :tue => { '10:00' => '16:00' } } }
8
10
 
9
11
  it 'has a default config' do
10
12
  expect(config).to be_kind_of(Hash)
11
13
  end
12
14
 
13
15
  it 'is thread safe' do
16
+ expect(WorkingHours::Config.working_hours).to eq(config)
17
+
18
+ thread = Thread.new do
19
+ WorkingHours::Config.working_hours = config2
20
+ expect(WorkingHours::Config.working_hours).to eq(config2)
21
+ Thread.stop
22
+ expect(WorkingHours::Config.working_hours).to eq(config2)
23
+ end
24
+
25
+ expect {
26
+ sleep 0.1 # let the thread begin its execution
27
+ }.not_to change { WorkingHours::Config.working_hours }.from(config)
28
+
14
29
  expect {
15
- Thread.new {
16
- WorkingHours::Config.working_hours = {:mon => {'08:00' => '14:00'}}
17
- }.join
18
- }.not_to change { WorkingHours::Config.working_hours }
30
+ WorkingHours::Config.working_hours = config3
31
+ }.to change { WorkingHours::Config.working_hours }.from(config).to(config3)
32
+
33
+ expect {
34
+ thread.run
35
+ thread.join
36
+ }.not_to change { WorkingHours::Config.working_hours }.from(config3)
19
37
  end
20
38
 
21
39
  it 'is fiber safe' do
40
+ expect(WorkingHours::Config.working_hours).to eq(config)
41
+
42
+ fiber = Fiber.new do
43
+ WorkingHours::Config.working_hours = config2
44
+ expect(WorkingHours::Config.working_hours).to eq(config2)
45
+ Fiber.yield
46
+ expect(WorkingHours::Config.working_hours).to eq(config2)
47
+ end
48
+
49
+ expect {
50
+ fiber.resume
51
+ }.not_to change { WorkingHours::Config.working_hours }.from(config)
52
+
53
+ expect {
54
+ WorkingHours::Config.working_hours = config3
55
+ }.to change { WorkingHours::Config.working_hours }.from(config).to(config3)
56
+
22
57
  expect {
23
- Fiber.new {
24
- WorkingHours::Config.working_hours = {:mon => {'08:00' => '14:00'}}
25
- }.resume
26
- }.not_to change { WorkingHours::Config.working_hours }
58
+ fiber.resume
59
+ }.not_to change { WorkingHours::Config.working_hours }.from(config3)
27
60
  end
28
61
 
29
62
  it 'is initialized from last known global config' do
@@ -233,16 +266,23 @@ describe WorkingHours::Config do
233
266
 
234
267
  it 'computes an optimized version' do
235
268
  expect(subject).to eq({
236
- :working_hours => [nil, {32400=>61200}, {32400=>61200}, {32400=>61200}, {32400=>61200}, {32400=>61200}],
269
+ :working_hours => [{}, {32400=>61200}, {32400=>61200}, {32400=>61200}, {32400=>61200}, {32400=>61200}, {}],
237
270
  :holidays => Set.new([]),
238
271
  :time_zone => ActiveSupport::TimeZone['UTC']
239
272
  })
240
273
  end
241
274
 
275
+ it 'includes default values for each days so computation does not fail' do
276
+ WorkingHours::Config.working_hours = {:mon => {'08:00' => '14:00'}}
277
+ expect(subject[:working_hours]).to eq([{}, {28800=>50400}, {}, {}, {}, {}, {}])
278
+ expect(WorkingHours.working_time_between(Time.utc(2014, 4, 14, 0), Time.utc(2014, 4, 21, 0))).to eq(3600*6)
279
+ expect(WorkingHours.add_seconds(Time.utc(2014, 4, 14, 0), 3600*7)).to eq(Time.utc(2014, 4, 21, 9))
280
+ end
281
+
242
282
  it 'supports seconds' do
243
283
  WorkingHours::Config.working_hours = {:mon => {'20:32:59' => '22:59:59'}}
244
284
  expect(subject).to eq({
245
- :working_hours => [nil, {73979 => 82799}],
285
+ :working_hours => [{}, {73979 => 82799}, {}, {}, {}, {}, {}],
246
286
  :holidays => Set.new([]),
247
287
  :time_zone => ActiveSupport::TimeZone['UTC']
248
288
  })
@@ -251,7 +291,7 @@ describe WorkingHours::Config do
251
291
  it 'supports 24:00 (converts to 23:59:59.999999)' do
252
292
  WorkingHours::Config.working_hours = {:mon => {'20:00' => '24:00'}}
253
293
  expect(subject).to eq({
254
- :working_hours => [nil, {72000 => 86399.999999}],
294
+ :working_hours => [{}, {72000 => 86399.999999}, {}, {}, {}, {}, {}],
255
295
  :holidays => Set.new([]),
256
296
  :time_zone => ActiveSupport::TimeZone['UTC']
257
297
  })
@@ -263,9 +303,9 @@ describe WorkingHours::Config do
263
303
  }.to change {
264
304
  WorkingHours::Config.precompiled[:working_hours]
265
305
  }.from(
266
- [nil, {32400=>61200}, {32400=>61200}, {32400=>61200}, {32400=>61200}, {32400=>61200}]
306
+ [{}, {32400=>61200}, {32400=>61200}, {32400=>61200}, {32400=>61200}, {32400=>61200}, {}]
267
307
  ).to(
268
- [nil, {28800=>50400}]
308
+ [{}, {28800=>50400}, {}, {}, {}, {}, {}]
269
309
  )
270
310
  end
271
311
 
@@ -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.3
4
+ version: 1.3.2
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: 2017-04-03 00:00:00.000000000 Z
12
+ date: 2021-02-20 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.6.6
155
+ rubygems_version: 3.1.2
158
156
  signing_key:
159
157
  specification_version: 4
160
158
  summary: time calculation with working hours
@@ -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'