Paté
Paté

Reputation: 1964

Time difference in rspec

I'm writing a simple class to parse strings into relative dates.

module RelativeDate
  class InvalidString < StandardError; end
  class Parser
    REGEX = /([0-9]+)_(day|week|month|year)_(ago|from_now)/

    def self.to_time(value)
      if captures = REGEX.match(value)
        captures[1].to_i.send(captures[2]).send(captures[3])
      else
        raise InvalidString, "#{value} could not be parsed"
      end
    end

  end
end

The code seems to work fine.

Now when I try my specs I get a time difference only in year and month

require 'spec_helper'
describe RelativeDate::Parser do
  describe "#to_time" do

    before do
      Timecop.freeze
    end

    ['day','week','month','year'].each do |type|
      it "should parse #{type} correctly" do
        RelativeDate::Parser.to_time("2_#{type}_ago").should == 2.send(type).ago
        RelativeDate::Parser.to_time("2_#{type}_from_now").should == 2.send(type).from_now
      end
    end

    after do
      Timecop.return
    end

  end
end

Output

..FF

Failures:

  1) RelativeDate::Parser#to_time should parse year correctly
     Failure/Error: RelativeDate::Parser.to_time("2_#{type}_ago").should == 2.send(type).ago
       expected: Wed, 29 Aug 2012 22:40:14 UTC +00:00
            got: Wed, 29 Aug 2012 10:40:14 UTC +00:00 (using ==)
       Diff:
       @@ -1,2 +1,2 @@
       -Wed, 29 Aug 2012 22:40:14 UTC +00:00
       +Wed, 29 Aug 2012 10:40:14 UTC +00:00

     # ./spec/lib/relative_date/parser_spec.rb:11:in `(root)'

  2) RelativeDate::Parser#to_time should parse month correctly
     Failure/Error: RelativeDate::Parser.to_time("2_#{type}_ago").should == 2.send(type).ago
       expected: Sun, 29 Jun 2014 22:40:14 UTC +00:00
            got: Mon, 30 Jun 2014 22:40:14 UTC +00:00 (using ==)
       Diff:
       @@ -1,2 +1,2 @@
       -Sun, 29 Jun 2014 22:40:14 UTC +00:00
       +Mon, 30 Jun 2014 22:40:14 UTC +00:00

     # ./spec/lib/relative_date/parser_spec.rb:11:in `(root)'

Finished in 0.146 seconds
4 examples, 2 failures

Failed examples:

rspec ./spec/lib/relative_date/parser_spec.rb:10 # RelativeDate::Parser#to_time should parse year correctly
rspec ./spec/lib/relative_date/parser_spec.rb:10 # RelativeDate::Parser#to_time should parse month correctly

The first one seems like a time zone issue but the other one is even a day apart? I'm really clueless on this one.

Upvotes: 1

Views: 280

Answers (1)

Peter Alfvin
Peter Alfvin

Reputation: 29419

This is a fascinating problem!

First, this has nothing to do with Timecop or RSpec. The problem can be reproduced in the Rails console, as follows:

2.0.0-p247 :001 > 2.months.ago
 => Mon, 30 Jun 2014 20:46:19 UTC +00:00 
2.0.0-p247 :002 > 2.months.send(:ago)
DEPRECATION WARNING: Calling #ago or #until on a number (e.g. 5.ago) is deprecated and will be removed in the future, use 5.seconds.ago instead. (called from irb_binding at (irb):2)
 => Wed, 02 Jul 2014 20:46:27 UTC +00:00

[Note: This answer uses the example of months, but the same is true for the alias month as well as year and years.]

Rails adds the month method to the Integer class, returning an ActiveSupport::Duration object, which is a "proxy object" containing a method_missing method which redirects any calls to the method_missing method of the "value" it is serving as a proxy for.

When you call ago directly, it's handled by the ago method in the Duration class itself. When you try to invoke ago via send, however, send is not defined in Duration and is not defined in the BasicObject that all proxy objects inherit from, so the method_missing method of Rails' Duration is invoked which in turn calls send on the integer "value" of the proxy, resulting in the invocation of ago in Numeric. In your case, this results in a change of date equal to 2*30 days.

The only methods you have to work with are those defined by Duration itself and those defined by BasicObject. The latter are as follows:

2.0.0-p247 :023 > BasicObject.instance_methods
 => [:==, :equal?, :!, :!=, :instance_eval, :instance_exec, :__send__, :__id__]

In addition to the instance_eval you discovered, you can use __send__.

Here's the definition of method_missing from duration.rb

  def method_missing(method, *args, &block) #:nodoc:
    value.send(method, *args, &block)
  end

value in this case refers to the number of seconds in the Duration object. If you redefine method_missing to special case ago, you can get your test to pass. Or you can alias send to __send__ as follows:

class ActiveSupport::Duration
  alias send __send__
end

Here's another example of how this method_missing method from Duration works:

macbookair1:so1 palfvin$ rails c
Loading development environment (Rails 4.1.1)
irb: warn: can't alias context from irb_context.
2.0.0-p247 :001 > class ActiveSupport::Duration
2.0.0-p247 :002?>   def foo
2.0.0-p247 :003?>     'foobar'
2.0.0-p247 :004?>     end
2.0.0-p247 :005?>   end
 => nil 
2.0.0-p247 :006 > 2.months.foo
 => "foobar" 
2.0.0-p247 :007 > 2.months.respond_to?(:foo)
 => false 
2.0.0-p247 :008 > 

You can call the newly defined foo directly, but because BasicObject doesn't implement respond_to?, you can't "test" that the method is defined there. For the same reason, method(:ago) on a Duration object returns #<Method: Fixnum(Numeric)#ago> because that's the ago method defined on value.

Upvotes: 2

Related Questions