module Remarkable 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 # It sets the arguments your matcher receives on initialization. # # == Options # # * :collection - if a collection is expected. # * :as - how each item of the collection will be available. # * :block - 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 arguments: # # 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 if collection = options.delete(:collection) @matcher_arguments[:collection] = collection 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 args << 'options={}' get_options = 'options' set_collection = '' end 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 # Declare the assertions that are runned for each element in the collection. # It must be used with arguments methods in order to work properly. # # == Examples # # The example given in assertions 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 assertions 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 # 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 @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 arguments, assertions # and collection_assertions. # # 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 assertions = self.class.matcher_single_assertions unless assertions.empty? value = send_methods_and_generate_message(assertions) return negative? if positive? == !value end matches_collection_assertions? end protected # You can overwrite this instance method to provide default options on # initialization. # def default_options {} end # Overwrites default_i18n_options to provide arguments and optionals # to interpolation options. # # If you still need to provide more other interpolation options, you can # do that in two ways: # # 1. Overwrite interpolation_options: # # def interpolation_options # { :real_value => real_value } # end # # 2. Return a hash from your assertion method: # # def my_assertion # return true if real_value == expected_value # return false, :real_value => real_value # end # # In both cases, :real_value will be available as interpolation option. # 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 # Method responsible to add collection as interpolation. # 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 # Send the assertion methods given and create a expectation message # if any of those methods returns false. # # 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) if positive? == !bool parent_scope = matcher_i18n_scope.split('.') matcher_name = parent_scope.pop method_name = method.to_s.gsub(/(\?|\!)$/, '') lookup = [] lookup << :"#{matcher_name}.negative_expectations.#{method_name}" if negative? lookup << :"#{matcher_name}.expectations.#{method_name}" lookup << :"negative_expectations.#{method_name}" if negative? lookup << :"expectations.#{method_name}" hash = { :scope => parent_scope, :default => lookup }.merge(hash || {}) @expectation ||= Remarkable.t lookup.shift, default_i18n_options.merge(hash) return negative? end end return positive? end def matches_single_assertions? #:nodoc: assertions = self.class.matcher_single_assertions send_methods_and_generate_message(assertions) end def matches_collection_assertions? #:nodoc: arguments = self.class.matcher_arguments assertions = self.class.matcher_collection_assertions collection = instance_variable_get("@#{self.class.matcher_arguments[:collection]}") || [] assert_collection(nil, collection) do |value| instance_variable_set("@#{arguments[:as]}", value) send_methods_and_generate_message(assertions) end end end end end