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.
@@ -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 || (@to_time_not_given = true && @from_time + distance.seconds)
14
+ @to_time = to_time || (@from_time + distance.seconds)
15
15
  @smallest, @largest = [@from_time, @to_time].minmax
16
- @to_time += 1.hour if @to_time_not_given && smallest.dst? && !largest.dst?
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 accumulate_on = options[:accumulate_on]
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, @distance = distance.abs.divmod(ONE_DAY.to_i)
149
-
150
- [total_days, @distance]
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DOTIW
4
- VERSION = '5.5.0'
4
+ VERSION = '5.6.1'
5
5
  end
data/lib/dotiw.rb CHANGED
@@ -1,45 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'i18n'
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
@@ -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
- ActiveSupport.to_time_preserves_timezone = :zone
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
- '1 year, 2 months, 3 days, 4 hours, and 6 seconds'],
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' }, '1 hour'],
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
- '1 day'],
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
- '1 hour and 1 minute'],
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
- '1 year, 2 months, 3 weeks, 4 days, 5 hours, and 7 seconds'],
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