Reputation: 1964
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
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