lib/remarkable/dsl/assertions.rb in remarkable-3.0.8 vs lib/remarkable/dsl/assertions.rb in remarkable-3.0.9
- old
+ new
@@ -1,183 +1,389 @@
module Remarkable
- module DSL
- module Assertions
+ module DSL
+ # This module is responsable to create a basic matcher structure using a DSL.
+ #
+ # A matcher that checks if an element is included in an array can be done
+ # just with:
+ #
+ # class IncludedMatcher < Remarkable::Base
+ # arguments :value
+ # assertion :is_included?
+ #
+ # protected
+ # def is_included?
+ # @subject.include?(@value)
+ # end
+ # end
+ #
+ # As you have noticed, the DSL also allows you to remove the messages from
+ # matcher. Since it will look for it on I18n yml file.
+ #
+ # If you want to create a matcher that accepts multile values to be tested,
+ # you just need to do:
+ #
+ # class IncludedMatcher < Remarkable::Base
+ # arguments :collection => :values, :as => :value
+ # collection_assertion :is_included?
+ #
+ # protected
+ # def is_included?
+ # @subject.include?(@value)
+ # end
+ # end
+ #
+ # Notice that the :is_included? logic didn't have to change, because Remarkable
+ # handle this automatically for you.
+ #
+ module Assertions
+
+ def self.included(base) # :nodoc:
+ base.extend ClassMethods
+ end
+
+ module ClassMethods
- protected
+ protected
- # It sets the arguments your matcher receives on initialization.
- #
- # arguments :name, :range
- #
- # Which is roughly the same as:
- #
- # def initialize(name, range, options = {})
- # @name = name
- # @range = range
- # @options = options
- # end
- #
- # But most of the time your matchers iterates through a collection,
- # such as a collection of attributes in the case below:
- #
- # @product.should validate_presence_of(:title, :name)
- #
- # validate_presence_of is a matcher declared as:
- #
- # class ValidatePresenceOfMatcher < Remarkable::Base
- # arguments :collection => :attributes, :as => :attribute
- # end
- #
- # In this case, Remarkable provides an API that enables you to easily
- # assert each item of the collection. Let's check more examples:
- #
- # should allow_values_for(:email, "jose@valim.com", "carlos@brando.com")
- #
- # Is declared as:
- #
- # arguments :attribute, :collection => :good_values, :as => :good_value
- #
- # And this is the same as:
- #
- # class AllowValuesForMatcher < Remarkable::Base
- # def initialize(attribute, *good_values)
- # @attribute = attribute
- # @options = default_options.merge(good_values.extract_options!)
- # @good_values = good_values
- # end
- # end
- #
- # Now, the collection is @good_values. In each assertion method we will
- # have a @good_value variable (in singular) instantiated with the value
- # to assert.
- #
- # Finally, if your matcher deals with blocks, you can also set them as
- # option:
- #
- # arguments :name, :block => :builder
- #
- # It will be available under the instance variable @builder.
- #
- def arguments(*names)
- options = names.extract_options!
- args = names.dup
+ # It sets the arguments your matcher receives on initialization.
+ #
+ # == Options
+ #
+ # * <tt>:collection</tt> - if a collection is expected.
+ # * <tt>:as</tt> - how each item of the collection will be available.
+ # * <tt>:block</tt> - tell the matcher can receive blocks as argument and store
+ # them under the variable given.
+ #
+ # Note: the expected block cannot have arity 1. This is already reserved
+ # for macro configuration.
+ #
+ # == Examples
+ #
+ # Let's see for each example how the arguments declarion reflects on
+ # the matcher API:
+ #
+ # arguments :assign
+ # # Can be called as:
+ # #=> should_assign :task
+ # #=> should_assign :task, :with => Task.new
+ #
+ # This is roughly the same as:
+ #
+ # def initialize(assign, options = {})
+ # @assign = name
+ # @options = options
+ # end
+ #
+ # As you noticed, a matcher can always receive options on initialization.
+ # If you have a matcher that accepts only options, for example,
+ # have_default_scope you just need to call <tt>arguments</tt>:
+ #
+ # arguments
+ # # Can be called as:
+ # #=> should_have_default_scope :limit => 10
+ #
+ # arguments :collection => :assigns, :as => :assign
+ # # Can be called as:
+ # #=> should_assign :task1, :task2
+ # #=> should_assign :task1, :task2, :with => Task.new
+ #
+ # arguments :collection => :assigns, :as => :assign, :block => :buildeer
+ # # Can be called as:
+ # #=> should_assign :task1, :task2
+ # #=> should_assign(:task1, :task2){ Task.new }
+ #
+ # The block will be available under the instance variable @builder.
+ #
+ # == I18n
+ #
+ # All the parameters given to arguments are available for interpolation
+ # in I18n. So if you have the following declarion:
+ #
+ # class InRange < Remarkable::Base
+ # arguments :range, :collection => :names, :as => :name
+ #
+ # You will have {{range}}, {{names}} and {{name}} available for I18n
+ # messages:
+ #
+ # in_range:
+ # description: "have {{names}} to be on range {{range}}"
+ #
+ # Before a collection is sent to I18n, it's transformed to a sentence.
+ # So if the following matcher:
+ #
+ # in_range(2..20, :username, :password)
+ #
+ # Has the following description:
+ #
+ # "should have username and password in range 2..20"
+ #
+ def arguments(*names)
+ options = names.extract_options!
+ args = names.dup
- @matcher_arguments[:names] = names
+ @matcher_arguments[:names] = names
- if collection = options.delete(:collection)
- @matcher_arguments[:collection] = collection
+ if collection = options.delete(:collection)
+ @matcher_arguments[:collection] = collection
- if options[:as]
- @matcher_arguments[:as] = options.delete(:as)
+ if options[:as]
+ @matcher_arguments[:as] = options.delete(:as)
+ else
+ raise ArgumentError, 'You gave me :collection as option but have not give me :as as well'
+ end
+
+ args << "*#{collection}"
+ get_options = "#{collection}.extract_options!"
+ set_collection = "@#{collection} = #{collection}"
else
- raise ArgumentError, 'You gave me :collection as option but have not give me :as as well'
+ args << 'options={}'
+ get_options = 'options'
+ set_collection = ''
end
- args << "*#{collection}"
- get_options = "#{collection}.extract_options!"
- set_collection = "@#{collection} = #{collection}"
- else
- args << 'options={}'
- get_options = 'options'
- set_collection = ''
+ if block = options.delete(:block)
+ block = :block unless block.is_a?(Symbol)
+ @matcher_arguments[:block] = block
+ end
+
+ # Blocks are always appended. If they have arity 1, they are used for
+ # macro configuration, otherwise, they are stored in the :block variable.
+ #
+ args << "&block"
+
+ assignments = names.map do |name|
+ "@#{name} = #{name}"
+ end.join("\n ")
+
+ class_eval <<-END, __FILE__, __LINE__
+ def initialize(#{args.join(',')})
+ _builder, block = block, nil if block && block.arity == 1
+ #{assignments}
+ #{"@#{block} = block" if block}
+ @options = default_options.merge(#{get_options})
+ #{set_collection}
+ run_after_initialize_callbacks
+ _builder.call(self) if _builder
+ end
+ END
end
- if block = options.delete(:block)
- @matcher_arguments[:block] = block
- args << "&#{block}"
- names << block
+ # Declare the assertions that are runned for each element in the collection.
+ # It must be used with <tt>arguments</tt> methods in order to work properly.
+ #
+ # == Examples
+ #
+ # The example given in <tt>assertions</tt> can be transformed to
+ # accept a collection just doing:
+ #
+ # class IncludedMatcher < Remarkable::Base
+ # arguments :collection => :values, :as => :value
+ # collection_assertion :is_included?
+ #
+ # protected
+ # def is_included?
+ # @subject.include?(@value)
+ # end
+ # end
+ #
+ # All further consideration done in <tt>assertions</tt> are also valid here.
+ #
+ def collection_assertions(*methods, &block)
+ define_method methods.last, &block if block_given?
+ @matcher_collection_assertions += methods
end
+ alias :collection_assertion :collection_assertions
- assignments = names.map do |name|
- "@#{name} = #{name}"
- end.join("\n ")
+ # Declares the assertions that are run once per matcher.
+ #
+ # == Examples
+ #
+ # A matcher that checks if an element is included in an array can be done
+ # just with:
+ #
+ # class IncludedMatcher < Remarkable::Base
+ # arguments :value
+ # assertion :is_included?
+ #
+ # protected
+ # def is_included?
+ # @subject.include?(@value)
+ # end
+ # end
+ #
+ # Whenever the matcher is called, the :is_included? action is automatically
+ # triggered. Each assertion must return true or false. In case it's false
+ # it will seach for an expectation message on the I18n file. In this
+ # case, the error message would be on:
+ #
+ # included:
+ # description: "check {{value}} is included in the array"
+ # expectations:
+ # is_included: "{{value}} is included in the array"
+ #
+ # In case of failure, it will output:
+ #
+ # "Expected {{value}} is included in the array"
+ #
+ # Notice that on the yml file the question mark is removed for readability.
+ #
+ # == Shortcut declaration
+ #
+ # You can shortcut declaration by giving a name and block to assertion
+ # method:
+ #
+ # class IncludedMatcher < Remarkable::Base
+ # arguments :value
+ #
+ # assertion :is_included? do
+ # @subject.include?(@value)
+ # end
+ # end
+ #
+ def assertions(*methods, &block)
+ if block_given?
+ define_method methods.last, &block
+ protected methods.last
+ end
- class_eval <<-END, __FILE__, __LINE__
-def initialize(#{args.join(',')})
- #{assignments}
- @options = default_options.merge(#{get_options})
- #{set_collection}
- run_after_initialize_callbacks
-end
-END
+ @matcher_single_assertions += methods
+ end
+ alias :assertion :assertions
+
+ # Class method that accepts a block or a hash to set matcher's default
+ # options. It's called on matcher initialization and stores the default
+ # value in the @options instance variable.
+ #
+ # == Examples
+ #
+ # default_options do
+ # { :name => @subject.name }
+ # end
+ #
+ # default_options :message => :invalid
+ #
+ def default_options(hash = {}, &block)
+ if block_given?
+ define_method :default_options, &block
+ else
+ class_eval "def default_options; #{hash.inspect}; end"
+ end
+ end
+ end
+
+ # This method is responsable for connecting <tt>arguments</tt>, <tt>assertions</tt>
+ # and <tt>collection_assertions</tt>.
+ #
+ # It's the one that executes the assertions once, executes the collection
+ # assertions for each element in the collection and also responsable to set
+ # the I18n messages.
+ #
+ def matches?(subject)
+ @subject = subject
+
+ run_before_assert_callbacks
+
+ send_methods_and_generate_message(self.class.matcher_single_assertions) &&
+ assert_matcher_for(instance_variable_get("@#{self.class.matcher_arguments[:collection]}") || []) do |value|
+ instance_variable_set("@#{self.class.matcher_arguments[:as]}", value)
+ send_methods_and_generate_message(self.class.matcher_collection_assertions)
end
+ end
- # Call it to declare your collection assertions. Every method given will
- # iterate through the whole collection given in <tt>:arguments</tt>.
+ protected
+
+ # You can overwrite this instance method to provide default options on
+ # initialization.
#
- # For example, validate_presence_of can be written as:
+ def default_options
+ {}
+ end
+
+ # Overwrites default_i18n_options to provide arguments and optionals
+ # to interpolation options.
#
- # class ValidatePresenceOfMatcher < Remarkable::Base
- # arguments :collection => :attributes, :as => :attribute
- # collection_assertions :allow_nil?
+ # If you still need to provide more other interpolation options, you can
+ # do that in two ways:
#
- # protected
- # def allow_nil?
- # # matcher logic
- # end
+ # 1. Overwrite interpolation_options:
+ #
+ # def interpolation_options
+ # { :real_value => real_value }
# end
#
- # Then we call it as:
+ # 2. Return a hash from your assertion method:
#
- # should validate_presence_of(:email, :password)
- #
- # For each attribute given, it will call the method :allow_nil which
- # contains the matcher logic. As stated in <tt>arguments</tt>, those
- # attributes will be available under the instance variable @argument
- # and the matcher subject is available under the instance variable
- # @subject.
- #
- # If a block is given, it will create a method with the name given.
- # So we could write the same class as above just as:
- #
- # class ValidatePresenceOfMatcher < Remarkable::Base
- # arguments :collection => :attributes
- #
- # collection_assertion :allow_nil? do
- # # matcher logic
- # end
+ # def my_assertion
+ # return true if real_value == expected_value
+ # return false, :real_value => real_value
# end
#
- # Those methods should return true if it pass or false if it fails. When
- # it fails, it will use I18n API to find the proper failure message:
+ # In both cases, :real_value will be available as interpolation option.
#
- # expectations:
- # allow_nil: allowed the value to be nil
- # allow_blank: allowed the value to be blank
- #
- # Or you can set the message in the instance variable @expectation in the
- # assertion method if you don't want to rely on I18n API.
- #
- # As you might have noticed from the examples above, this method is also
- # aliased as <tt>collection_assertion</tt>.
- #
- def collection_assertions(*methods, &block)
- define_method methods.last, &block if block_given?
- @matcher_collection_assertions += methods
+ def default_i18n_options #:nodoc:
+ i18n_options = {}
+
+ @options.each do |key, value|
+ i18n_options[key] = value.inspect
+ end if @options
+
+ # Also add arguments as interpolation options.
+ self.class.matcher_arguments[:names].each do |name|
+ i18n_options[name] = instance_variable_get("@#{name}").inspect
+ end
+
+ # Add collection interpolation options.
+ i18n_options.update(collection_interpolation)
+
+ # Add default options (highest priority). They should not be overwritten.
+ i18n_options.update(super)
end
- alias :collection_assertion :collection_assertions
- # In contrast to <tt>collection_assertions</tt>, the methods given here
- # are called just once. In other words, it does not iterate through the
- # collection given in arguments.
+ # Method responsible to add collection as interpolation.
#
- # It also accepts blocks and is aliased as assertion.
- #
- def assertions(*methods, &block)
- define_method methods.last, &block if block_given?
- @matcher_single_assertions += methods
+ def collection_interpolation #:nodoc:
+ options = {}
+
+ if collection_name = self.class.matcher_arguments[:collection]
+ collection_name = collection_name.to_sym
+ collection = instance_variable_get("@#{collection_name}")
+ options[collection_name] = array_to_sentence(collection) if collection
+
+ object_name = self.class.matcher_arguments[:as].to_sym
+ object = instance_variable_get("@#{object_name}")
+ options[object_name] = object if object
+ end
+
+ options
end
- alias :assertion :assertions
- # Class method that accepts a block or a hash to set matcher's default options.
+ # Send the assertion methods given and create a expectation message
+ # if any of those methods returns false.
#
- def default_options(hash = {}, &block)
- if block_given?
- define_method :default_options, &block
- else
- class_eval "def default_options; #{hash.inspect}; end"
+ # Since most assertion methods ends with an question mark and it's not
+ # readable in yml files, we remove question and exclation marks at the
+ # end of the method name before translating it. So if you have a method
+ # called is_valid? on I18n yml file we will check for a key :is_valid.
+ #
+ def send_methods_and_generate_message(methods) #:nodoc:
+ methods.each do |method|
+ bool, hash = send(method)
+
+ unless bool
+ parent_scope = matcher_i18n_scope.split('.')
+ matcher_name = parent_scope.pop
+ lookup = :"expectations.#{method.to_s.gsub(/(\?|\!)$/, '')}"
+
+ hash = { :scope => parent_scope, :default => lookup }.merge(hash || {})
+ @expectation ||= Remarkable.t "#{matcher_name}.#{lookup}", default_i18n_options.merge(hash)
+
+ return false
+ end
end
- end
+
+ return true
+ end
+
end
end
end