From 676d6a651497b56f31e00db4e7795669de0da672 Mon Sep 17 00:00:00 2001 From: gbuesing Date: Sun, 29 Jun 2008 15:17:51 -0500 Subject: [PATCH] TimeWithZone: when crossing DST boundary, treat Durations of days, months or years as variable-length, and all other values as absolute length. A time + 24.hours will advance exactly 24 hours, but a time + 1.day will advance 23-25 hours, depending on the day. Ensure consistent behavior across all advancing methods. --- activesupport/CHANGELOG | 2 + .../lib/active_support/time_with_zone.rb | 45 +++-- .../test/core_ext/time_with_zone_test.rb | 168 ++++++++++++++++++ 3 files changed, 203 insertions(+), 12 deletions(-) diff --git a/activesupport/CHANGELOG b/activesupport/CHANGELOG index a540150329..f0c1a208a5 100644 --- a/activesupport/CHANGELOG +++ b/activesupport/CHANGELOG @@ -1,5 +1,7 @@ *Edge* +* TimeWithZone: when crossing DST boundary, treat Durations of days, months or years as variable-length, and all other values as absolute length. A time + 24.hours will advance exactly 24 hours, but a time + 1.day will advance 23-25 hours, depending on the day. Ensure consistent behavior across all advancing methods [Geoff Buesing] + * Added TimeZone #=~, to support matching zones by regex in time_zone_select. #195 [Ernie Miller] * Added Array#second through Array#tenth as aliases for Array#[1] through Array#[9] [DHH] diff --git a/activesupport/lib/active_support/time_with_zone.rb b/activesupport/lib/active_support/time_with_zone.rb index bfc5b16039..7370a45931 100644 --- a/activesupport/lib/active_support/time_with_zone.rb +++ b/activesupport/lib/active_support/time_with_zone.rb @@ -159,19 +159,24 @@ def eql?(other) utc == other end - # If wrapped +time+ is a DateTime, use DateTime#since instead of +. - # Otherwise, just pass on to +method_missing+. def +(other) - result = utc.acts_like?(:date) ? utc.since(other) : utc + other rescue utc.since(other) - result.in_time_zone(time_zone) + # If we're adding a Duration of variable length (i.e., years, months, days), move forward from #time, + # otherwise move forward from #utc, for accuracy when moving across DST boundaries + if duration_of_variable_length?(other) + method_missing(:+, other) + else + result = utc.acts_like?(:date) ? utc.since(other) : utc + other rescue utc.since(other) + result.in_time_zone(time_zone) + end end - - # If a time-like object is passed in, compare it with +utc+. - # Else if wrapped +time+ is a DateTime, use DateTime#ago instead of DateTime#-. - # Otherwise, just pass on to +method_missing+. + def -(other) + # If we're subtracting a Duration of variable length (i.e., years, months, days), move backwards from #time, + # otherwise move backwards #utc, for accuracy when moving across DST boundaries if other.acts_like?(:time) utc - other + elsif duration_of_variable_length?(other) + method_missing(:-, other) else result = utc.acts_like?(:date) ? utc.ago(other) : utc - other rescue utc.ago(other) result.in_time_zone(time_zone) @@ -179,15 +184,27 @@ def -(other) end def since(other) - utc.since(other).in_time_zone(time_zone) + # If we're adding a Duration of variable length (i.e., years, months, days), move forward from #time, + # otherwise move forward from #utc, for accuracy when moving across DST boundaries + if duration_of_variable_length?(other) + method_missing(:since, other) + else + utc.since(other).in_time_zone(time_zone) + end end def ago(other) - utc.ago(other).in_time_zone(time_zone) + since(-other) end - + def advance(options) - utc.advance(options).in_time_zone(time_zone) + # If we're advancing a value of variable length (i.e., years, months, days), advance from #time, + # otherwise advance from #utc, for accuracy when moving across DST boundaries + if options.detect {|k,v| [:years, :months, :days].include? k} + method_missing(:advance, options) + else + utc.advance(options).in_time_zone(time_zone) + end end %w(year mon month day mday hour min sec).each do |method_name| @@ -279,5 +296,9 @@ def get_period_and_ensure_valid_local_time def transfer_time_values_to_utc_constructor(time) ::Time.utc_time(time.year, time.month, time.day, time.hour, time.min, time.sec, time.respond_to?(:usec) ? time.usec : 0) end + + def duration_of_variable_length?(obj) + ActiveSupport::Duration === obj && obj.parts.flatten.detect {|p| [:years, :months, :days].include? p } + end end end diff --git a/activesupport/test/core_ext/time_with_zone_test.rb b/activesupport/test/core_ext/time_with_zone_test.rb index 0977cd8e50..ac52a1be0b 100644 --- a/activesupport/test/core_ext/time_with_zone_test.rb +++ b/activesupport/test/core_ext/time_with_zone_test.rb @@ -485,6 +485,7 @@ def test_advance_1_year_from_leap_day twz = ActiveSupport::TimeWithZone.new(nil, @time_zone, Time.utc(2004,2,29)) assert_equal "Mon, 28 Feb 2005 00:00:00 EST -05:00", twz.advance(:years => 1).inspect assert_equal "Mon, 28 Feb 2005 00:00:00 EST -05:00", twz.years_since(1).inspect + assert_equal "Mon, 28 Feb 2005 00:00:00 EST -05:00", twz.since(1.year).inspect assert_equal "Mon, 28 Feb 2005 00:00:00 EST -05:00", (twz + 1.year).inspect end @@ -492,6 +493,7 @@ def test_advance_1_month_from_last_day_of_january twz = ActiveSupport::TimeWithZone.new(nil, @time_zone, Time.utc(2005,1,31)) assert_equal "Mon, 28 Feb 2005 00:00:00 EST -05:00", twz.advance(:months => 1).inspect assert_equal "Mon, 28 Feb 2005 00:00:00 EST -05:00", twz.months_since(1).inspect + assert_equal "Mon, 28 Feb 2005 00:00:00 EST -05:00", twz.since(1.month).inspect assert_equal "Mon, 28 Feb 2005 00:00:00 EST -05:00", (twz + 1.month).inspect end @@ -499,6 +501,7 @@ def test_advance_1_month_from_last_day_of_january_during_leap_year twz = ActiveSupport::TimeWithZone.new(nil, @time_zone, Time.utc(2000,1,31)) assert_equal "Tue, 29 Feb 2000 00:00:00 EST -05:00", twz.advance(:months => 1).inspect assert_equal "Tue, 29 Feb 2000 00:00:00 EST -05:00", twz.months_since(1).inspect + assert_equal "Tue, 29 Feb 2000 00:00:00 EST -05:00", twz.since(1.month).inspect assert_equal "Tue, 29 Feb 2000 00:00:00 EST -05:00", (twz + 1.month).inspect end @@ -506,6 +509,7 @@ def test_advance_1_month_into_spring_dst_gap twz = ActiveSupport::TimeWithZone.new(nil, @time_zone, Time.utc(2006,3,2,2)) assert_equal "Sun, 02 Apr 2006 03:00:00 EDT -04:00", twz.advance(:months => 1).inspect assert_equal "Sun, 02 Apr 2006 03:00:00 EDT -04:00", twz.months_since(1).inspect + assert_equal "Sun, 02 Apr 2006 03:00:00 EDT -04:00", twz.since(1.month).inspect assert_equal "Sun, 02 Apr 2006 03:00:00 EDT -04:00", (twz + 1.month).inspect end @@ -513,8 +517,172 @@ def test_advance_1_second_into_spring_dst_gap twz = ActiveSupport::TimeWithZone.new(nil, @time_zone, Time.utc(2006,4,2,1,59,59)) assert_equal "Sun, 02 Apr 2006 03:00:00 EDT -04:00", twz.advance(:seconds => 1).inspect assert_equal "Sun, 02 Apr 2006 03:00:00 EDT -04:00", (twz + 1).inspect + assert_equal "Sun, 02 Apr 2006 03:00:00 EDT -04:00", twz.since(1).inspect + assert_equal "Sun, 02 Apr 2006 03:00:00 EDT -04:00", twz.since(1.second).inspect assert_equal "Sun, 02 Apr 2006 03:00:00 EDT -04:00", (twz + 1.second).inspect end + + def test_advance_1_day_across_spring_dst_transition + twz = ActiveSupport::TimeWithZone.new(nil, @time_zone, Time.utc(2006,4,1,10,30)) + # In 2006, spring DST transition occurred Apr 2 at 2AM; this day was only 23 hours long + # When we advance 1 day, we want to end up at the same time on the next day + assert_equal "Sun, 02 Apr 2006 10:30:00 EDT -04:00", twz.advance(:days => 1).inspect + assert_equal "Sun, 02 Apr 2006 10:30:00 EDT -04:00", twz.since(1.days).inspect + assert_equal "Sun, 02 Apr 2006 10:30:00 EDT -04:00", (twz + 1.days).inspect + assert_equal "Sun, 02 Apr 2006 10:30:01 EDT -04:00", twz.since(1.days + 1.second).inspect + assert_equal "Sun, 02 Apr 2006 10:30:01 EDT -04:00", (twz + 1.days + 1.second).inspect + end + + def test_advance_1_day_across_spring_dst_transition_backwards + twz = ActiveSupport::TimeWithZone.new(nil, @time_zone, Time.utc(2006,4,2,10,30)) + # In 2006, spring DST transition occurred Apr 2 at 2AM; this day was only 23 hours long + # When we advance back 1 day, we want to end up at the same time on the previous day + assert_equal "Sat, 01 Apr 2006 10:30:00 EST -05:00", twz.advance(:days => -1).inspect + assert_equal "Sat, 01 Apr 2006 10:30:00 EST -05:00", twz.ago(1.days).inspect + assert_equal "Sat, 01 Apr 2006 10:30:00 EST -05:00", (twz - 1.days).inspect + assert_equal "Sat, 01 Apr 2006 10:30:01 EST -05:00", twz.ago(1.days - 1.second).inspect + end + + def test_advance_1_day_expressed_as_number_of_seconds_minutes_or_hours_across_spring_dst_transition + twz = ActiveSupport::TimeWithZone.new(nil, @time_zone, Time.utc(2006,4,1,10,30)) + # In 2006, spring DST transition occurred Apr 2 at 2AM; this day was only 23 hours long + # When we advance a specific number of hours, minutes or seconds, we want to advance exactly that amount + assert_equal "Sun, 02 Apr 2006 11:30:00 EDT -04:00", (twz + 86400).inspect + assert_equal "Sun, 02 Apr 2006 11:30:00 EDT -04:00", (twz + 86400.seconds).inspect + assert_equal "Sun, 02 Apr 2006 11:30:00 EDT -04:00", twz.since(86400).inspect + assert_equal "Sun, 02 Apr 2006 11:30:00 EDT -04:00", twz.since(86400.seconds).inspect + assert_equal "Sun, 02 Apr 2006 11:30:00 EDT -04:00", twz.advance(:seconds => 86400).inspect + assert_equal "Sun, 02 Apr 2006 11:30:00 EDT -04:00", (twz + 1440.minutes).inspect + assert_equal "Sun, 02 Apr 2006 11:30:00 EDT -04:00", twz.since(1440.minutes).inspect + assert_equal "Sun, 02 Apr 2006 11:30:00 EDT -04:00", twz.advance(:minutes => 1440).inspect + assert_equal "Sun, 02 Apr 2006 11:30:00 EDT -04:00", (twz + 24.hours).inspect + assert_equal "Sun, 02 Apr 2006 11:30:00 EDT -04:00", twz.since(24.hours).inspect + assert_equal "Sun, 02 Apr 2006 11:30:00 EDT -04:00", twz.advance(:hours => 24).inspect + end + + def test_advance_1_day_expressed_as_number_of_seconds_minutes_or_hours_across_spring_dst_transition_backwards + twz = ActiveSupport::TimeWithZone.new(nil, @time_zone, Time.utc(2006,4,2,11,30)) + # In 2006, spring DST transition occurred Apr 2 at 2AM; this day was only 23 hours long + # When we advance a specific number of hours, minutes or seconds, we want to advance exactly that amount + assert_equal "Sat, 01 Apr 2006 10:30:00 EST -05:00", (twz - 86400).inspect + assert_equal "Sat, 01 Apr 2006 10:30:00 EST -05:00", (twz - 86400.seconds).inspect + assert_equal "Sat, 01 Apr 2006 10:30:00 EST -05:00", twz.ago(86400).inspect + assert_equal "Sat, 01 Apr 2006 10:30:00 EST -05:00", twz.ago(86400.seconds).inspect + assert_equal "Sat, 01 Apr 2006 10:30:00 EST -05:00", twz.advance(:seconds => -86400).inspect + assert_equal "Sat, 01 Apr 2006 10:30:00 EST -05:00", (twz - 1440.minutes).inspect + assert_equal "Sat, 01 Apr 2006 10:30:00 EST -05:00", twz.ago(1440.minutes).inspect + assert_equal "Sat, 01 Apr 2006 10:30:00 EST -05:00", twz.advance(:minutes => -1440).inspect + assert_equal "Sat, 01 Apr 2006 10:30:00 EST -05:00", (twz - 24.hours).inspect + assert_equal "Sat, 01 Apr 2006 10:30:00 EST -05:00", twz.ago(24.hours).inspect + assert_equal "Sat, 01 Apr 2006 10:30:00 EST -05:00", twz.advance(:hours => -24).inspect + end + + def test_advance_1_day_across_fall_dst_transition + twz = ActiveSupport::TimeWithZone.new(nil, @time_zone, Time.utc(2006,10,28,10,30)) + # In 2006, fall DST transition occurred Oct 29 at 2AM; this day was 25 hours long + # When we advance 1 day, we want to end up at the same time on the next day + assert_equal "Sun, 29 Oct 2006 10:30:00 EST -05:00", twz.advance(:days => 1).inspect + assert_equal "Sun, 29 Oct 2006 10:30:00 EST -05:00", twz.since(1.days).inspect + assert_equal "Sun, 29 Oct 2006 10:30:00 EST -05:00", (twz + 1.days).inspect + assert_equal "Sun, 29 Oct 2006 10:30:01 EST -05:00", twz.since(1.days + 1.second).inspect + assert_equal "Sun, 29 Oct 2006 10:30:01 EST -05:00", (twz + 1.days + 1.second).inspect + end + + def test_advance_1_day_across_fall_dst_transition_backwards + twz = ActiveSupport::TimeWithZone.new(nil, @time_zone, Time.utc(2006,10,29,10,30)) + # In 2006, fall DST transition occurred Oct 29 at 2AM; this day was 25 hours long + # When we advance backwards 1 day, we want to end up at the same time on the previous day + assert_equal "Sat, 28 Oct 2006 10:30:00 EDT -04:00", twz.advance(:days => -1).inspect + assert_equal "Sat, 28 Oct 2006 10:30:00 EDT -04:00", twz.ago(1.days).inspect + assert_equal "Sat, 28 Oct 2006 10:30:00 EDT -04:00", (twz - 1.days).inspect + assert_equal "Sat, 28 Oct 2006 10:30:01 EDT -04:00", twz.ago(1.days - 1.second).inspect + end + + def test_advance_1_day_expressed_as_number_of_seconds_minutes_or_hours_across_fall_dst_transition + twz = ActiveSupport::TimeWithZone.new(nil, @time_zone, Time.utc(2006,10,28,10,30)) + # In 2006, fall DST transition occurred Oct 29 at 2AM; this day was 25 hours long + # When we advance a specific number of hours, minutes or seconds, we want to advance exactly that amount + assert_equal "Sun, 29 Oct 2006 09:30:00 EST -05:00", (twz + 86400).inspect + assert_equal "Sun, 29 Oct 2006 09:30:00 EST -05:00", (twz + 86400.seconds).inspect + assert_equal "Sun, 29 Oct 2006 09:30:00 EST -05:00", twz.since(86400).inspect + assert_equal "Sun, 29 Oct 2006 09:30:00 EST -05:00", twz.since(86400.seconds).inspect + assert_equal "Sun, 29 Oct 2006 09:30:00 EST -05:00", twz.advance(:seconds => 86400).inspect + assert_equal "Sun, 29 Oct 2006 09:30:00 EST -05:00", (twz + 1440.minutes).inspect + assert_equal "Sun, 29 Oct 2006 09:30:00 EST -05:00", twz.since(1440.minutes).inspect + assert_equal "Sun, 29 Oct 2006 09:30:00 EST -05:00", twz.advance(:minutes => 1440).inspect + assert_equal "Sun, 29 Oct 2006 09:30:00 EST -05:00", (twz + 24.hours).inspect + assert_equal "Sun, 29 Oct 2006 09:30:00 EST -05:00", twz.since(24.hours).inspect + assert_equal "Sun, 29 Oct 2006 09:30:00 EST -05:00", twz.advance(:hours => 24).inspect + end + + def test_advance_1_day_expressed_as_number_of_seconds_minutes_or_hours_across_fall_dst_transition_backwards + twz = ActiveSupport::TimeWithZone.new(nil, @time_zone, Time.utc(2006,10,29,9,30)) + # In 2006, fall DST transition occurred Oct 29 at 2AM; this day was 25 hours long + # When we advance a specific number of hours, minutes or seconds, we want to advance exactly that amount + assert_equal "Sat, 28 Oct 2006 10:30:00 EDT -04:00", (twz - 86400).inspect + assert_equal "Sat, 28 Oct 2006 10:30:00 EDT -04:00", (twz - 86400.seconds).inspect + assert_equal "Sat, 28 Oct 2006 10:30:00 EDT -04:00", twz.ago(86400).inspect + assert_equal "Sat, 28 Oct 2006 10:30:00 EDT -04:00", twz.ago(86400.seconds).inspect + assert_equal "Sat, 28 Oct 2006 10:30:00 EDT -04:00", twz.advance(:seconds => -86400).inspect + assert_equal "Sat, 28 Oct 2006 10:30:00 EDT -04:00", (twz - 1440.minutes).inspect + assert_equal "Sat, 28 Oct 2006 10:30:00 EDT -04:00", twz.ago(1440.minutes).inspect + assert_equal "Sat, 28 Oct 2006 10:30:00 EDT -04:00", twz.advance(:minutes => -1440).inspect + assert_equal "Sat, 28 Oct 2006 10:30:00 EDT -04:00", (twz - 24.hours).inspect + assert_equal "Sat, 28 Oct 2006 10:30:00 EDT -04:00", twz.ago(24.hours).inspect + assert_equal "Sat, 28 Oct 2006 10:30:00 EDT -04:00", twz.advance(:hours => -24).inspect + end + + def test_advance_1_month_across_spring_dst_transition + twz = ActiveSupport::TimeWithZone.new(nil, @time_zone, Time.utc(2006,4,1,10,30)) + assert_equal "Mon, 01 May 2006 10:30:00 EDT -04:00", twz.advance(:months => 1).inspect + assert_equal "Mon, 01 May 2006 10:30:00 EDT -04:00", twz.months_since(1).inspect + assert_equal "Mon, 01 May 2006 10:30:00 EDT -04:00", twz.since(1.month).inspect + assert_equal "Mon, 01 May 2006 10:30:00 EDT -04:00", (twz + 1.month).inspect + end + + def test_advance_1_month_across_spring_dst_transition_backwards + twz = ActiveSupport::TimeWithZone.new(nil, @time_zone, Time.utc(2006,5,1,10,30)) + assert_equal "Sat, 01 Apr 2006 10:30:00 EST -05:00", twz.advance(:months => -1).inspect + assert_equal "Sat, 01 Apr 2006 10:30:00 EST -05:00", twz.months_ago(1).inspect + assert_equal "Sat, 01 Apr 2006 10:30:00 EST -05:00", twz.ago(1.month).inspect + assert_equal "Sat, 01 Apr 2006 10:30:00 EST -05:00", (twz - 1.month).inspect + end + + def test_advance_1_month_across_fall_dst_transition + twz = ActiveSupport::TimeWithZone.new(nil, @time_zone, Time.utc(2006,10,28,10,30)) + assert_equal "Tue, 28 Nov 2006 10:30:00 EST -05:00", twz.advance(:months => 1).inspect + assert_equal "Tue, 28 Nov 2006 10:30:00 EST -05:00", twz.months_since(1).inspect + assert_equal "Tue, 28 Nov 2006 10:30:00 EST -05:00", twz.since(1.month).inspect + assert_equal "Tue, 28 Nov 2006 10:30:00 EST -05:00", (twz + 1.month).inspect + end + + def test_advance_1_month_across_fall_dst_transition_backwards + twz = ActiveSupport::TimeWithZone.new(nil, @time_zone, Time.utc(2006,11,28,10,30)) + assert_equal "Sat, 28 Oct 2006 10:30:00 EDT -04:00", twz.advance(:months => -1).inspect + assert_equal "Sat, 28 Oct 2006 10:30:00 EDT -04:00", twz.months_ago(1).inspect + assert_equal "Sat, 28 Oct 2006 10:30:00 EDT -04:00", twz.ago(1.month).inspect + assert_equal "Sat, 28 Oct 2006 10:30:00 EDT -04:00", (twz - 1.month).inspect + end + + def test_advance_1_year + twz = ActiveSupport::TimeWithZone.new(nil, @time_zone, Time.utc(2008,2,15,10,30)) + assert_equal "Sun, 15 Feb 2009 10:30:00 EST -05:00", twz.advance(:years => 1).inspect + assert_equal "Sun, 15 Feb 2009 10:30:00 EST -05:00", twz.years_since(1).inspect + assert_equal "Sun, 15 Feb 2009 10:30:00 EST -05:00", (twz + 1.year).inspect + assert_equal "Thu, 15 Feb 2007 10:30:00 EST -05:00", twz.advance(:years => -1).inspect + assert_equal "Thu, 15 Feb 2007 10:30:00 EST -05:00", twz.years_ago(1).inspect + assert_equal "Thu, 15 Feb 2007 10:30:00 EST -05:00", (twz - 1.year).inspect + end + + def test_advance_1_year_during_dst + twz = ActiveSupport::TimeWithZone.new(nil, @time_zone, Time.utc(2008,7,15,10,30)) + assert_equal "Wed, 15 Jul 2009 10:30:00 EDT -04:00", twz.advance(:years => 1).inspect + assert_equal "Wed, 15 Jul 2009 10:30:00 EDT -04:00", twz.years_since(1).inspect + assert_equal "Wed, 15 Jul 2009 10:30:00 EDT -04:00", (twz + 1.year).inspect + assert_equal "Sun, 15 Jul 2007 10:30:00 EDT -04:00", twz.advance(:years => -1).inspect + assert_equal "Sun, 15 Jul 2007 10:30:00 EDT -04:00", twz.years_ago(1).inspect + assert_equal "Sun, 15 Jul 2007 10:30:00 EDT -04:00", (twz - 1.year).inspect + end end class TimeWithZoneMethodsForTimeAndDateTimeTest < Test::Unit::TestCase -- GitLab