dotiw 5.5.0 → 5.6.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/.github/workflows/ruby.yml +37 -13
- data/.gitignore +1 -0
- data/Appraisals +13 -0
- data/CHANGELOG.md +26 -3
- data/README.markdown +53 -5
- data/RELEASING.md +71 -0
- data/benchmarks/distance_of_time_in_words_bench.rb +38 -0
- data/dotiw.gemspec +1 -1
- data/gemfiles/rails_8.0.gemfile +11 -0
- data/gemfiles/rails_8.1.gemfile +11 -0
- data/gemfiles/rails_8.1_i18n.gemfile +11 -0
- data/lib/dotiw/core.rb +45 -0
- data/lib/dotiw/methods.rb +53 -17
- data/lib/dotiw/time_hash.rb +119 -14
- data/lib/dotiw/version.rb +1 -1
- data/lib/dotiw.rb +1 -40
- data/spec/lib/dotiw_module_spec.rb +52 -0
- data/spec/lib/dotiw_spec.rb +273 -7
- data/spec/spec_helper.rb +19 -0
- metadata +22 -11
data/lib/dotiw/time_hash.rb
CHANGED
|
@@ -11,17 +11,9 @@ module DOTIW
|
|
|
11
11
|
@options = options.dup
|
|
12
12
|
@distance = distance
|
|
13
13
|
@from_time = from_time || Time.current
|
|
14
|
-
@to_time = to_time
|
|
14
|
+
@to_time = to_time || (@from_time + distance.seconds)
|
|
15
15
|
@smallest, @largest = [@from_time, @to_time].minmax
|
|
16
|
-
@
|
|
17
|
-
@to_time -= 1.hour if @to_time_not_given && !smallest.dst? && largest.dst?
|
|
18
|
-
@smallest, @largest = [@from_time, @to_time].minmax
|
|
19
|
-
@distance ||= begin
|
|
20
|
-
d = largest - smallest
|
|
21
|
-
d -= 1.hour if smallest.dst? && !largest.dst?
|
|
22
|
-
d += 1.hour if !smallest.dst? && largest.dst?
|
|
23
|
-
d
|
|
24
|
-
end
|
|
16
|
+
@distance ||= largest - smallest
|
|
25
17
|
|
|
26
18
|
build_time_hash
|
|
27
19
|
end
|
|
@@ -40,8 +32,64 @@ module DOTIW
|
|
|
40
32
|
ONE_WEEK = 7.days.freeze
|
|
41
33
|
FOUR_WEEKS = 28.days.freeze
|
|
42
34
|
|
|
35
|
+
# The distance between two Time objects computed via subtraction already
|
|
36
|
+
# reflects any change in UTC offset between them (whether from a DST
|
|
37
|
+
# transition or a permanent tzdata rule change, e.g. Pacific/Norfolk's
|
|
38
|
+
# 2015 UTC offset change), which is what we want when reporting a raw
|
|
39
|
+
# elapsed duration - this is why we never touch the top-level @distance
|
|
40
|
+
# used to pick which build_* branch to use. However, once we've split
|
|
41
|
+
# that distance into calendar fields (years/months/weeks/days, #153),
|
|
42
|
+
# we want the offset difference folded out of the *sub-day leftover*
|
|
43
|
+
# only, so it reflects actual elapsed wall-clock time rather than an
|
|
44
|
+
# artifact of an offset shift, without risking flipping the sign of the
|
|
45
|
+
# much larger top-level distance the way applying this correction
|
|
46
|
+
# globally used to (#165).
|
|
47
|
+
#
|
|
48
|
+
# This only makes sense when both times are the same clock (the same
|
|
49
|
+
# location/zone before and after a transition). If they're simply
|
|
50
|
+
# expressed with different, unrelated UTC offsets (e.g. one in UTC and
|
|
51
|
+
# one with an explicit "-08:00" offset), any difference between their
|
|
52
|
+
# offsets is an artifact of how each was represented, not a transition,
|
|
53
|
+
# and folding it in would double count what subtraction already got
|
|
54
|
+
# right (#160).
|
|
55
|
+
def offset_delta(smallest, largest)
|
|
56
|
+
return 0 unless same_clock?(smallest, largest)
|
|
57
|
+
|
|
58
|
+
largest.utc_offset - smallest.utc_offset
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Whether smallest and largest are readings of the same underlying
|
|
62
|
+
# clock, as opposed to two independently fixed offsets that simply
|
|
63
|
+
# happen to differ (e.g. one in UTC, one with an explicit "-08:00").
|
|
64
|
+
# Only in the former case does a UTC offset difference represent a
|
|
65
|
+
# real transition (DST, or a historical tzdata rule change) rather
|
|
66
|
+
# than an artifact of how each time happens to be represented.
|
|
67
|
+
#
|
|
68
|
+
# An ActiveSupport::TimeWithZone carries its zone explicitly, so two
|
|
69
|
+
# of them share a clock when their +time_zone+ matches. A plain Ruby
|
|
70
|
+
# Time normally reports its zone as a String abbreviation (e.g. "PST")
|
|
71
|
+
# or +nil+ (fixed numeric offset, which can never transition, so two
|
|
72
|
+
# such times can't share a clock). Depending on the Ruby/ActiveSupport
|
|
73
|
+
# version, however, converting an ActiveSupport::TimeWithZone via
|
|
74
|
+
# +#to_time+ can instead produce a plain Time whose +#zone+ is the
|
|
75
|
+
# ActiveSupport::TimeZone object itself (#162) - in that case we
|
|
76
|
+
# compare the zone objects directly rather than assuming any two
|
|
77
|
+
# non-String zones both refer to the process's system zone.
|
|
78
|
+
def same_clock?(smallest, largest)
|
|
79
|
+
if smallest.respond_to?(:time_zone) || largest.respond_to?(:time_zone)
|
|
80
|
+
smallest.respond_to?(:time_zone) && largest.respond_to?(:time_zone) &&
|
|
81
|
+
smallest.time_zone == largest.time_zone
|
|
82
|
+
elsif !smallest.zone.is_a?(String) || !largest.zone.is_a?(String)
|
|
83
|
+
!smallest.zone.nil? && smallest.zone == largest.zone
|
|
84
|
+
else
|
|
85
|
+
!smallest.zone.nil? && !smallest.utc? && !largest.zone.nil? && !largest.utc?
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
43
89
|
def build_time_hash
|
|
44
|
-
if
|
|
90
|
+
if (max_unit = options[:max_unit])
|
|
91
|
+
build_max_unit(max_unit.to_sym)
|
|
92
|
+
elsif accumulate_on = options[:accumulate_on]
|
|
45
93
|
accumulate_on = accumulate_on.to_sym
|
|
46
94
|
TIME_FRACTIONS.index(accumulate_on).downto(0) { |i| send("build_#{TIME_FRACTIONS[i]}") }
|
|
47
95
|
else
|
|
@@ -86,6 +134,44 @@ module DOTIW
|
|
|
86
134
|
output[:weeks], @distance = distance.divmod(ONE_WEEK.to_i) unless output[:weeks]
|
|
87
135
|
end
|
|
88
136
|
|
|
137
|
+
# Expresses the entire distance as a single number in the given unit, discarding
|
|
138
|
+
# (flooring) any remainder smaller than that unit, unlike +accumulate_on+ which keeps
|
|
139
|
+
# showing the smaller units below the target.
|
|
140
|
+
def build_max_unit(unit)
|
|
141
|
+
case unit
|
|
142
|
+
when :seconds
|
|
143
|
+
output[:seconds] = distance.to_i
|
|
144
|
+
when :minutes
|
|
145
|
+
output[:minutes] = (distance / ONE_MINUTE).floor
|
|
146
|
+
when :hours
|
|
147
|
+
output[:hours] = (distance / ONE_HOUR).floor
|
|
148
|
+
when :days
|
|
149
|
+
output[:days] = (distance / ONE_DAY).floor
|
|
150
|
+
when :weeks
|
|
151
|
+
output[:weeks] = (distance / ONE_WEEK).floor
|
|
152
|
+
when :months
|
|
153
|
+
output[:months] = whole_months_between(smallest, largest)
|
|
154
|
+
when :years
|
|
155
|
+
output[:years] = whole_years_between(smallest, largest)
|
|
156
|
+
else
|
|
157
|
+
raise ArgumentError, "unrecognized max_unit #{unit.inspect}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
@distance = 0
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def whole_months_between(from, to)
|
|
164
|
+
months = (to.year - from.year) * 12 + (to.month - from.month)
|
|
165
|
+
months -= 1 if to.advance(months: -months) < from
|
|
166
|
+
months
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def whole_years_between(from, to)
|
|
170
|
+
years = to.year - from.year
|
|
171
|
+
years -= 1 if to.advance(years: -years) < from
|
|
172
|
+
years
|
|
173
|
+
end
|
|
174
|
+
|
|
89
175
|
def build_months
|
|
90
176
|
build_years_months_weeks_days
|
|
91
177
|
|
|
@@ -145,9 +231,28 @@ module DOTIW
|
|
|
145
231
|
output[:weeks] = weeks
|
|
146
232
|
output[:days] = days
|
|
147
233
|
|
|
148
|
-
total_days
|
|
149
|
-
|
|
150
|
-
|
|
234
|
+
# total_days is discarded: years/months/weeks/days above are derived
|
|
235
|
+
# from calendar components (largest/smallest year/month/day), an
|
|
236
|
+
# entirely separate calculation from @distance. All we need from here
|
|
237
|
+
# on is the leftover below the day boundary, which is just @distance
|
|
238
|
+
# mod one day - decoupled like this (rather than reconstructed by
|
|
239
|
+
# advancing smallest by years/months/weeks/days and diffing against
|
|
240
|
+
# largest) so a calendar edge case where that reconstruction doesn't
|
|
241
|
+
# land exactly on largest (e.g. Jan 31 -> Mar 2) can never leave a
|
|
242
|
+
# leftover large enough to loop back into this same branch forever.
|
|
243
|
+
# We do fold in any UTC offset change between smallest and largest
|
|
244
|
+
# (#153) exactly once - guarded, since build_years and build_months
|
|
245
|
+
# both call this method unconditionally, and accumulate_on: :years
|
|
246
|
+
# ends up invoking it twice (once via build_years, once via
|
|
247
|
+
# build_months) - but only here, not in the top-level @distance used
|
|
248
|
+
# to pick which build_* branch to use in the first place, so a real
|
|
249
|
+
# elapsed time much smaller than the offset change (#165) still gets
|
|
250
|
+
# bucketed correctly.
|
|
251
|
+
unless @offset_applied
|
|
252
|
+
@offset_applied = true
|
|
253
|
+
@distance = distance.abs + offset_delta(smallest, largest)
|
|
254
|
+
end
|
|
255
|
+
_total_days, @distance = distance.abs.divmod(ONE_DAY.to_i)
|
|
151
256
|
end
|
|
152
257
|
end
|
|
153
258
|
end
|
data/lib/dotiw/version.rb
CHANGED
data/lib/dotiw.rb
CHANGED
|
@@ -1,45 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require 'logger'
|
|
5
|
-
require 'active_support'
|
|
6
|
-
require 'active_support/core_ext'
|
|
7
|
-
|
|
8
|
-
module DOTIW
|
|
9
|
-
extend ActiveSupport::Autoload
|
|
10
|
-
|
|
11
|
-
eager_autoload do
|
|
12
|
-
autoload :VERSION, 'dotiw/version'
|
|
13
|
-
autoload :TimeHash, 'dotiw/time_hash'
|
|
14
|
-
autoload :Methods, 'dotiw/methods'
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
extend self
|
|
18
|
-
|
|
19
|
-
DEFAULT_I18N_SCOPE = :'datetime.dotiw'
|
|
20
|
-
DEFAULT_I18N_SCOPE_COMPACT = :'datetime.dotiw_compact'
|
|
21
|
-
|
|
22
|
-
def init_i18n!
|
|
23
|
-
I18n.load_path.unshift(*locale_files)
|
|
24
|
-
I18n.reload!
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def languages
|
|
28
|
-
@languages ||= (locale_files.map { |path| path.split(%r{[/.]})[-2].to_sym })
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def locale_files
|
|
32
|
-
files 'dotiw/locale', '*.yml'
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
protected
|
|
36
|
-
|
|
37
|
-
def files(directory, ext)
|
|
38
|
-
Dir[File.join File.dirname(__FILE__), directory, ext]
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
DOTIW.init_i18n!
|
|
3
|
+
require_relative 'dotiw/core'
|
|
43
4
|
|
|
44
5
|
begin
|
|
45
6
|
require 'action_view'
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe DOTIW do
|
|
6
|
+
START_TIME_MODULE = '01-08-2009'.to_time(:utc)
|
|
7
|
+
|
|
8
|
+
before do
|
|
9
|
+
I18n.locale = :en
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
describe '.distance_of_time_in_words' do
|
|
13
|
+
it 'returns the exact distance' do
|
|
14
|
+
expect(
|
|
15
|
+
described_class.distance_of_time_in_words(START_TIME_MODULE, START_TIME_MODULE + 1.hour + 30.minutes)
|
|
16
|
+
).to eq('1 hour and 30 minutes')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'accepts options' do
|
|
20
|
+
expect(
|
|
21
|
+
described_class.distance_of_time_in_words(
|
|
22
|
+
START_TIME_MODULE, START_TIME_MODULE + 1.hour + 30.minutes, highest_measures: 1
|
|
23
|
+
)
|
|
24
|
+
).to eq('1 hour')
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
describe '.time_ago_in_words' do
|
|
29
|
+
it 'returns the distance to now' do
|
|
30
|
+
allow(Time).to receive(:now).and_return(START_TIME_MODULE)
|
|
31
|
+
allow(Time.zone).to receive(:now).and_return(START_TIME_MODULE)
|
|
32
|
+
|
|
33
|
+
expect(
|
|
34
|
+
described_class.time_ago_in_words(START_TIME_MODULE - 90, include_seconds: true)
|
|
35
|
+
).to eq('1 minute and 30 seconds')
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
describe '.distance_of_time' do
|
|
40
|
+
it 'returns the distance for a number of seconds' do
|
|
41
|
+
expect(described_class.distance_of_time(90)).to eq('1 minute and 30 seconds')
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
describe '.distance_of_time_in_words_hash' do
|
|
46
|
+
it 'returns the distance as a hash' do
|
|
47
|
+
expect(
|
|
48
|
+
described_class.distance_of_time_in_words_hash(START_TIME_MODULE, START_TIME_MODULE + 1.hour + 30.minutes)
|
|
49
|
+
).to include(hours: 1, minutes: 30)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
data/spec/lib/dotiw_spec.rb
CHANGED
|
@@ -17,7 +17,11 @@ describe 'A better distance_of_time_in_words' do
|
|
|
17
17
|
|
|
18
18
|
before do
|
|
19
19
|
I18n.locale = :en
|
|
20
|
-
|
|
20
|
+
# This config is deprecated as of Rails 8.1 (it becomes the permanent,
|
|
21
|
+
# non-configurable behavior) and is removed entirely in Rails 8.2.
|
|
22
|
+
if Gem::Version.new(ActiveSupport::VERSION::STRING) < Gem::Version.new('8.1')
|
|
23
|
+
ActiveSupport.to_time_preserves_timezone = :zone
|
|
24
|
+
end
|
|
21
25
|
|
|
22
26
|
allow(Time).to receive(:now).and_return(START_TIME)
|
|
23
27
|
allow(Time.zone).to receive(:now).and_return(START_TIME)
|
|
@@ -193,6 +197,167 @@ describe 'A better distance_of_time_in_words' do
|
|
|
193
197
|
end
|
|
194
198
|
end
|
|
195
199
|
|
|
200
|
+
context 'in timezones with an inverted DST scheme (#63)' do
|
|
201
|
+
# Real, non-mocked reproduction from #63: running the suite under
|
|
202
|
+
# certain timezones (e.g. Europe/Dublin, where IST is "standard" time
|
|
203
|
+
# and GMT is technically the DST offset) previously failed because
|
|
204
|
+
# Time#dst? disagreed between a Time built via Time.at(datetime) and
|
|
205
|
+
# one built via datetime.to_time for the exact same instant, even
|
|
206
|
+
# though their utc_offset was identical. This caused a spurious +/-1
|
|
207
|
+
# hour "DST correction" to be applied.
|
|
208
|
+
around do |example|
|
|
209
|
+
original_tz = ENV.fetch('TZ', nil)
|
|
210
|
+
ENV['TZ'] = tz
|
|
211
|
+
example.run
|
|
212
|
+
ENV['TZ'] = original_tz
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
%w[
|
|
216
|
+
Europe/Dublin
|
|
217
|
+
Africa/Casablanca
|
|
218
|
+
Asia/Tehran
|
|
219
|
+
Europe/Moscow
|
|
220
|
+
Asia/Gaza
|
|
221
|
+
].each do |timezone|
|
|
222
|
+
context "TZ=#{timezone}" do
|
|
223
|
+
let(:tz) { timezone }
|
|
224
|
+
|
|
225
|
+
it 'is 1 minute' do
|
|
226
|
+
start = Time.at(DateTime.now)
|
|
227
|
+
finish = DateTime.now + 1.minute
|
|
228
|
+
|
|
229
|
+
expect(distance_of_time_in_words(start, finish)).to eq('1 minute')
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
context 'across a historical UTC offset change (#153)' do
|
|
236
|
+
# Real, non-mocked reproduction from #153: Pacific/Norfolk permanently
|
|
237
|
+
# changed its UTC offset from +11:30 to +11:00 on 2015-10-04 (a
|
|
238
|
+
# tzdata rule change, not a recurring DST transition - both endpoints
|
|
239
|
+
# report dst? == false). The calendar distance (years/months/weeks/
|
|
240
|
+
# days) was correct, but the leftover hours/minutes leaked the 30
|
|
241
|
+
# minute offset difference as a spurious "23 hours and 30 minutes".
|
|
242
|
+
around do |example|
|
|
243
|
+
original_tz = ENV.fetch('TZ', nil)
|
|
244
|
+
ENV['TZ'] = 'Pacific/Norfolk'
|
|
245
|
+
example.run
|
|
246
|
+
ENV['TZ'] = original_tz
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
it 'is 1 year and 2 months, not 1 year, 2 months, 23 hours, and 30 minutes' do
|
|
250
|
+
start = '2015-1-15'.to_time
|
|
251
|
+
finish = '2016-3-15'.to_time
|
|
252
|
+
|
|
253
|
+
expect(distance_of_time_in_words(start, finish, true)).to eq('1 year and 2 months')
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
context 'with different UTC offsets that are not the same clock (#160)' do
|
|
258
|
+
# Real, non-mocked reproduction from #160: comparing two timestamps
|
|
259
|
+
# expressed with different, unrelated UTC offsets (not the same
|
|
260
|
+
# location before/after a real transition) incorrectly folded the
|
|
261
|
+
# full offset difference between them into the reported distance.
|
|
262
|
+
# Neither endpoint here crosses any DST or historical offset
|
|
263
|
+
# transition; only their representation differs.
|
|
264
|
+
it 'is 1 hour, not 9 hours' do
|
|
265
|
+
from = Time.new(2026, 1, 15, 11, 0, 0, '-08:00')
|
|
266
|
+
to = Time.utc(2026, 1, 15, 20)
|
|
267
|
+
|
|
268
|
+
expect(to - from).to eq(3600.0)
|
|
269
|
+
expect(distance_of_time_in_words(from, to)).to eq('1 hour')
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
it 'is still 1 hour when both timestamps are in UTC' do
|
|
273
|
+
from = Time.new(2026, 1, 15, 11, 0, 0, '-08:00').getutc
|
|
274
|
+
to = Time.utc(2026, 1, 15, 20)
|
|
275
|
+
|
|
276
|
+
expect(distance_of_time_in_words(from, to)).to eq('1 hour')
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
context 'with Time values using different zone objects (#162)' do
|
|
281
|
+
# #to_time on an ActiveSupport::TimeWithZone (with to_time_preserves_timezone
|
|
282
|
+
# set, the default since Rails 7.1) returns a plain Time whose #zone is the
|
|
283
|
+
# ActiveSupport::TimeZone object itself, not a String abbreviation. Building
|
|
284
|
+
# these directly via TimeZone#local/#to_time, rather than Ruby's Time.new(in:)
|
|
285
|
+
# keyword (only available on Ruby 3.2+), reproduces the same shape of object
|
|
286
|
+
# portably across the whole supported Ruby/Rails matrix.
|
|
287
|
+
it 'treats different TimeZone-object zones as different clocks, without folding in any offset' do
|
|
288
|
+
tokyo_time = ActiveSupport::TimeZone['Asia/Tokyo'].local(2026, 1, 16, 4, 0, 0).to_time
|
|
289
|
+
la_time = ActiveSupport::TimeZone['America/Los_Angeles'].local(2026, 1, 15, 12, 0, 0).to_time
|
|
290
|
+
|
|
291
|
+
expect(la_time - tokyo_time).to eq(3600.0)
|
|
292
|
+
expect(distance_of_time_in_words(tokyo_time, la_time)).to eq('1 hour')
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Same TimeZone-object shape as above, but far enough apart to be split
|
|
296
|
+
# into calendar fields (years/months/weeks/days, #153/#165), which is
|
|
297
|
+
# the only place same_clock? is invoked (see #165). Tokyo and LA are
|
|
298
|
+
# unrelated zones (not the same clock before/after a transition), so
|
|
299
|
+
# this exercises same_clock?'s "not a String zone" comparison finding
|
|
300
|
+
# the two TimeZone objects unequal, exactly like the #160 case does
|
|
301
|
+
# for String zones.
|
|
302
|
+
it 'treats TimeZone-object zones far enough apart to span calendar fields as different clocks' do
|
|
303
|
+
tokyo_time = ActiveSupport::TimeZone['Asia/Tokyo'].local(2025, 1, 16, 4, 0, 0).to_time
|
|
304
|
+
la_time = ActiveSupport::TimeZone['America/Los_Angeles'].local(2026, 3, 15, 12, 0, 0).to_time
|
|
305
|
+
|
|
306
|
+
expect(distance_of_time_in_words(tokyo_time, la_time, true)).to eq('1 year, 1 month, 3 weeks, and 6 days')
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
context 'with explicit named zones' do
|
|
311
|
+
# https://github.com/moment/luxon/blob/3.7.2/test/datetime/diff.test.js#L317-L330
|
|
312
|
+
it 'preserves the elapsed time between UTC and CEST' do
|
|
313
|
+
from = Time.utc(2022, 5, 5, 23, 0, 0)
|
|
314
|
+
to = Time.new(2022, 5, 10, 0, 0, 0, '+02:00')
|
|
315
|
+
|
|
316
|
+
expect(to - from).to eq(3.days + 23.hours)
|
|
317
|
+
expect(distance_of_time_in_words(from, to, true)).to eq('3 days and 23 hours')
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# https://github.com/bitwalker/timex/blob/3.7.11/test/format_duration_humanized_test.exs
|
|
321
|
+
it 'preserves one minute across the Europe/Dublin DST fall-back (#165)' do
|
|
322
|
+
# Passing an ActiveSupport::TimeWithZone straight through (rather than
|
|
323
|
+
# calling #to_time on it, as in the #162 example above) exercises the
|
|
324
|
+
# same_clock? branch that compares #time_zone directly. Prior to the
|
|
325
|
+
# #165 fix, dotiw unconditionally called #to_time on any TimeWithZone
|
|
326
|
+
# argument, which discarded the real zone entirely on Rails < 8.0 (see
|
|
327
|
+
# DOTIW::Methods#coerce_to_time, #170) - so this was only reproducible
|
|
328
|
+
# on Rails >= 8.0, and passed for the wrong reason everywhere else. Now
|
|
329
|
+
# that #coerce_to_time no longer makes that unforced conversion, and
|
|
330
|
+
# offset_delta is only ever applied to the sub-day leftover rather
|
|
331
|
+
# than the top-level distance, this passes for the right reason on
|
|
332
|
+
# every supported Rails version.
|
|
333
|
+
dublin = ActiveSupport::TimeZone['Europe/Dublin']
|
|
334
|
+
from = Time.utc(2024, 10, 27, 0, 59, 30).in_time_zone(dublin)
|
|
335
|
+
to = from + 1.minute
|
|
336
|
+
|
|
337
|
+
expect(from.utc_offset).to eq(1.hour)
|
|
338
|
+
expect(to.utc_offset).to eq(0)
|
|
339
|
+
expect(to - from).to eq(1.minute)
|
|
340
|
+
expect(distance_of_time_in_words(from, to)).to eq('1 minute')
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# https://github.com/dblock/tz_test/blob/master/ruby/test.rb
|
|
344
|
+
it 'folds the Pacific/Norfolk historical offset change into the calendar distance' do
|
|
345
|
+
# As with the Dublin example above, from/to are passed through as
|
|
346
|
+
# ActiveSupport::TimeWithZone rather than being converted with
|
|
347
|
+
# #to_time, so their real zone is preserved on every Rails version
|
|
348
|
+
# (see DOTIW::Methods#coerce_to_time, #170) and same_clock? can
|
|
349
|
+
# correctly recognize this as the same clock across Pacific/Norfolk's
|
|
350
|
+
# 2015 historical UTC offset change.
|
|
351
|
+
norfolk = ActiveSupport::TimeZone['Pacific/Norfolk']
|
|
352
|
+
from = norfolk.local(2015, 1, 15)
|
|
353
|
+
to = norfolk.local(2016, 3, 15)
|
|
354
|
+
|
|
355
|
+
expect(from.utc_offset).to eq(11.hours + 30.minutes)
|
|
356
|
+
expect(to.utc_offset).to eq(11.hours)
|
|
357
|
+
expect(distance_of_time_in_words(from, to, true)).to eq('1 year and 2 months')
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
196
361
|
describe 'accumulate_on:' do
|
|
197
362
|
[
|
|
198
363
|
[START_TIME,
|
|
@@ -228,6 +393,55 @@ describe 'A better distance_of_time_in_words' do
|
|
|
228
393
|
end
|
|
229
394
|
end
|
|
230
395
|
|
|
396
|
+
describe 'max_unit:' do
|
|
397
|
+
[
|
|
398
|
+
[START_TIME,
|
|
399
|
+
START_TIME + 10.minute,
|
|
400
|
+
:seconds,
|
|
401
|
+
{ seconds: 600 },
|
|
402
|
+
'600 seconds'],
|
|
403
|
+
[START_TIME,
|
|
404
|
+
START_TIME + 10.hour + 10.minute + 1.second,
|
|
405
|
+
:minutes,
|
|
406
|
+
{ minutes: 610 },
|
|
407
|
+
'610 minutes'],
|
|
408
|
+
[START_TIME,
|
|
409
|
+
START_TIME + 2.day + 10_000.hour + 10.second,
|
|
410
|
+
:hours,
|
|
411
|
+
{ hours: 10_048 },
|
|
412
|
+
'10048 hours'],
|
|
413
|
+
[START_TIME,
|
|
414
|
+
START_TIME + 2.day + 10_000.hour + 10.second,
|
|
415
|
+
:days,
|
|
416
|
+
{ days: 418 },
|
|
417
|
+
'418 days'],
|
|
418
|
+
[START_TIME,
|
|
419
|
+
START_TIME + 2.day + 10_000.hour + 10.second,
|
|
420
|
+
:weeks,
|
|
421
|
+
{ weeks: 59 },
|
|
422
|
+
'59 weeks'],
|
|
423
|
+
['2015-1-15'.to_time, '2016-3-15'.to_time, :months, { months: 14 }, '14 months'],
|
|
424
|
+
['2015-1-15'.to_time, '2016-3-15'.to_time, :years, { years: 1 }, '1 year']
|
|
425
|
+
].each do |start, finish, unit, hash, output|
|
|
426
|
+
it "should be #{output}" do
|
|
427
|
+
expect(distance_of_time_in_words_hash(start, finish, max_unit: unit)).to eq(hash)
|
|
428
|
+
expect(distance_of_time_in_words(start, finish, true, max_unit: unit)).to eq(output)
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
it 'floors and discards any remainder smaller than the unit' do
|
|
433
|
+
expect(
|
|
434
|
+
distance_of_time_in_words_hash(START_TIME, START_TIME + 1.year - 1.day, max_unit: :years)
|
|
435
|
+
).to eq(years: 0)
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
it 'raises for an unrecognized unit' do
|
|
439
|
+
expect do
|
|
440
|
+
distance_of_time_in_words_hash(START_TIME, START_TIME + 1.day, max_unit: :fortnights)
|
|
441
|
+
end.to raise_error(ArgumentError, /unrecognized max_unit/)
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
|
|
231
445
|
describe 'without finish time' do
|
|
232
446
|
# A missing finish argument should default to zero, essentially returning
|
|
233
447
|
# the equivalent of distance_of_time in order to be backwards-compatible
|
|
@@ -297,22 +511,28 @@ describe 'A better distance_of_time_in_words' do
|
|
|
297
511
|
[START_TIME,
|
|
298
512
|
START_TIME + 1.year + 2.months + 3.days + 4.hours + 5.minutes + 6.seconds,
|
|
299
513
|
{ except: 'minutes' },
|
|
300
|
-
|
|
514
|
+
# 5 excluded minutes are folded into seconds (5 * 60 + 6 = 306) instead of being discarded.
|
|
515
|
+
'1 year, 2 months, 3 days, 4 hours, and 306 seconds'],
|
|
301
516
|
[START_TIME,
|
|
302
517
|
START_TIME + 1.hour + 1.minute,
|
|
303
|
-
{ except: 'minutes' },
|
|
518
|
+
{ except: 'minutes' },
|
|
519
|
+
# The excluded minute is folded into seconds (1 * 60 = 60) instead of being discarded.
|
|
520
|
+
'1 hour and 60 seconds'],
|
|
304
521
|
[START_TIME,
|
|
305
522
|
START_TIME + 1.hour + 1.day + 1.minute,
|
|
306
523
|
{ except: %w[minutes hours] },
|
|
307
|
-
|
|
524
|
+
# The excluded hour and minute cascade down into seconds (1 * 24 * 60 * 60 + 1 * 60 = 3660).
|
|
525
|
+
'1 day and 3660 seconds'],
|
|
308
526
|
[START_TIME,
|
|
309
527
|
START_TIME + 1.hour + 1.day + 1.minute,
|
|
310
528
|
{ only: %w[minutes hours] },
|
|
311
|
-
|
|
529
|
+
# The excluded day is folded into hours (1 * 24 + 1 = 25) instead of being discarded.
|
|
530
|
+
'25 hours and 1 minute'],
|
|
312
531
|
[START_TIME,
|
|
313
532
|
START_TIME + 1.year + 2.months + 3.weeks + 4.days + 5.hours + 6.minutes + 7.seconds,
|
|
314
533
|
{ except: 'minutes' },
|
|
315
|
-
|
|
534
|
+
# 6 excluded minutes are folded into seconds (6 * 60 + 7 = 367) instead of being discarded.
|
|
535
|
+
'1 year, 2 months, 3 weeks, 4 days, 5 hours, and 367 seconds'],
|
|
316
536
|
[START_TIME,
|
|
317
537
|
START_TIME + 1.hour + 2.minutes + 3.seconds,
|
|
318
538
|
{ highest_measure_only: true },
|
|
@@ -395,6 +615,34 @@ describe 'A better distance_of_time_in_words' do
|
|
|
395
615
|
end.to raise_error(ArgumentError)
|
|
396
616
|
end
|
|
397
617
|
|
|
618
|
+
describe 'folding excluded fractions into included ones (regression for #77 and #120)' do
|
|
619
|
+
it 'folds excluded weeks into days rather than discarding them' do
|
|
620
|
+
from = '2019-01-02'.to_time
|
|
621
|
+
to = '2020-12-28'.to_time
|
|
622
|
+
expect(distance_of_time_in_words(from, to, false, only: %i[years months days]))
|
|
623
|
+
.to eq('1 year, 11 months, and 26 days')
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
it 'folds excluded weeks into days when using except' do
|
|
627
|
+
from = '2019-01-02'.to_time
|
|
628
|
+
to = '2020-12-28'.to_time
|
|
629
|
+
expect(distance_of_time_in_words(from, to, false, except: :weeks))
|
|
630
|
+
.to eq('1 year, 11 months, and 26 days')
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
it 'folds an excluded month into days' do
|
|
634
|
+
expect(distance_of_time_in_words(START_TIME, START_TIME + 1.month + 9.days, false, only: %i[months days]))
|
|
635
|
+
.to eq('1 month and 9 days')
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
it 'converts a negative day count into a week when the day-of-month matches but the hour rolls back' do
|
|
639
|
+
from = Time.new(2024, 5, 1, 10, 0, 0)
|
|
640
|
+
to = Time.new(2024, 6, 1, 9, 0, 0)
|
|
641
|
+
expect(distance_of_time_in_words(from, to, false, only: %i[months weeks days]))
|
|
642
|
+
.to eq('4 weeks and 2 days')
|
|
643
|
+
end
|
|
644
|
+
end
|
|
645
|
+
|
|
398
646
|
if defined?(ActionView)
|
|
399
647
|
describe 'ActionView without include seconds argument' do
|
|
400
648
|
[
|
|
@@ -440,13 +688,31 @@ describe 'A better distance_of_time_in_words' do
|
|
|
440
688
|
[START_TIME,
|
|
441
689
|
START_TIME + 1.year + 2.months + 3.weeks + 4.days + 5.hours + 6.minutes + 7.seconds,
|
|
442
690
|
{ vague: nil },
|
|
443
|
-
'1 year, 2 months, 3 weeks, 4 days, 5 hours, 6 minutes, and 7 seconds']
|
|
691
|
+
'1 year, 2 months, 3 weeks, 4 days, 5 hours, 6 minutes, and 7 seconds'],
|
|
692
|
+
[START_TIME,
|
|
693
|
+
START_TIME + 1.year + 2.months,
|
|
694
|
+
{ vague: true, locale: :en },
|
|
695
|
+
'about 1 year']
|
|
444
696
|
].each do |start, finish, options, output|
|
|
445
697
|
it "should be #{output}" do
|
|
446
698
|
expect(distance_of_time_in_words(start, finish, true, options)).to eq(output)
|
|
447
699
|
end
|
|
448
700
|
end
|
|
449
701
|
|
|
702
|
+
# requires the rails-i18n gem for non-English datetime.distance_in_words translations, see #44
|
|
703
|
+
if defined?(RailsI18n)
|
|
704
|
+
it 'should be rundt 1 år for vague: true, locale: :nb' do
|
|
705
|
+
finish = START_TIME + 1.year + 2.months
|
|
706
|
+
expect(distance_of_time_in_words(START_TIME, finish, true, vague: true, locale: :nb)).to eq('rundt 1 år')
|
|
707
|
+
end
|
|
708
|
+
else
|
|
709
|
+
it 'returns a translation missing message for vague: true, locale: :nb without the rails-i18n gem' do
|
|
710
|
+
finish = START_TIME + 1.year + 2.months
|
|
711
|
+
expect(distance_of_time_in_words(START_TIME, finish, true, vague: true, locale: :nb))
|
|
712
|
+
.to eq('Translation missing: nb.datetime.distance_in_words.about_x_years')
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
|
|
450
716
|
context 'via ActionController::Base.helpers' do
|
|
451
717
|
it '#distance_of_time_in_words' do
|
|
452
718
|
end_time = START_TIME + 1.year + 2.months + 3.weeks + 4.days + 5.hours + 6.minutes + 7.seconds
|
data/spec/spec_helper.rb
CHANGED
|
@@ -3,8 +3,27 @@
|
|
|
3
3
|
ROOT_PATH = File.join(File.dirname(__FILE__), '..')
|
|
4
4
|
$LOAD_PATH.unshift ROOT_PATH unless $LOAD_PATH.include? ROOT_PATH
|
|
5
5
|
|
|
6
|
+
require 'simplecov'
|
|
7
|
+
SimpleCov.start do
|
|
8
|
+
add_filter '/spec/'
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Ruby 3.4 dropped concurrent-ruby's implicit require of logger, which older
|
|
12
|
+
# Rails versions rely on being already loaded, see https://github.com/rails/rails/issues/54271.
|
|
13
|
+
require 'logger'
|
|
14
|
+
require 'bundler'
|
|
15
|
+
Bundler.require
|
|
16
|
+
|
|
6
17
|
require 'dotiw'
|
|
7
18
|
|
|
19
|
+
# Loads Rails' own datetime.distance_in_words translations for non-English
|
|
20
|
+
# locales, needed by the vague: true option, which falls back to Rails'
|
|
21
|
+
# native distance_of_time_in_words instead of dotiw's own translations, see #44.
|
|
22
|
+
# Only present when the rails-i18n gem is in the Gemfile (see gemfiles/rails_8.1_i18n.gemfile).
|
|
23
|
+
if defined?(RailsI18n)
|
|
24
|
+
I18n.load_path += Dir[File.join(Gem.loaded_specs['rails-i18n'].gem_dir, 'rails', 'locale', '*.yml')]
|
|
25
|
+
end
|
|
26
|
+
|
|
8
27
|
Time.zone = 'UTC'
|
|
9
28
|
|
|
10
29
|
I18n.locale = :en
|