lib/shoulda/matchers/active_model/validate_numericality_of_matcher.rb



module Shoulda
  module Matchers
    module ActiveModel
      # The `validate_numericality_of` matcher tests usage of the
      # `validates_numericality_of` validation.
      #
      #     class Person
      #       include ActiveModel::Model
      #       attr_accessor :gpa
      #
      #       validates_numericality_of :gpa
      #     end
      #
      #     # RSpec
      #     RSpec.describe Person, type: :model do
      #       it { should validate_numericality_of(:gpa) }
      #     end
      #
      #     # Minitest (Shoulda)
      #     class PersonTest < ActiveSupport::TestCase
      #       should validate_numericality_of(:gpa)
      #     end
      #
      # #### Qualifiers
      #
      # ##### on
      #
      # Use `on` if your validation applies only under a certain context.
      #
      #     class Person
      #       include ActiveModel::Model
      #       attr_accessor :number_of_dependents
      #
      #       validates_numericality_of :number_of_dependents, on: :create
      #     end
      #
      #     # RSpec
      #     RSpec.describe Person, type: :model do
      #       it do
      #         should validate_numericality_of(:number_of_dependents).
      #           on(:create)
      #       end
      #     end
      #
      #     # Minitest (Shoulda)
      #     class PersonTest < ActiveSupport::TestCase
      #       should validate_numericality_of(:number_of_dependents).on(:create)
      #     end
      #
      # ##### only_integer
      #
      # Use `only_integer` to test usage of the `:only_integer` option. This
      # asserts that your attribute only allows integer numbers and disallows
      # non-integer ones.
      #
      #     class Person
      #       include ActiveModel::Model
      #       attr_accessor :age
      #
      #       validates_numericality_of :age, only_integer: true
      #     end
      #
      #     # RSpec
      #     RSpec.describe Person, type: :model do
      #       it { should validate_numericality_of(:age).only_integer }
      #     end
      #
      #     # Minitest (Shoulda)
      #     class PersonTest < ActiveSupport::TestCase
      #       should validate_numericality_of(:age).only_integer
      #     end
      #
      # ##### is_less_than
      #
      # Use `is_less_than` to test usage of the the `:less_than` option. This
      # asserts that the attribute can take a number which is less than the
      # given value and cannot take a number which is greater than or equal to
      # it.
      #
      #     class Person
      #       include ActiveModel::Model
      #       attr_accessor :number_of_cars
      #
      #       validates_numericality_of :number_of_cars, less_than: 2
      #     end
      #
      #     # RSpec
      #     RSpec.describe Person, type: :model do
      #       it do
      #         should validate_numericality_of(:number_of_cars).
      #           is_less_than(2)
      #       end
      #     end
      #
      #     # Minitest (Shoulda)
      #     class PersonTest < ActiveSupport::TestCase
      #       should validate_numericality_of(:number_of_cars).
      #         is_less_than(2)
      #     end
      #
      # ##### is_less_than_or_equal_to
      #
      # Use `is_less_than_or_equal_to` to test usage of the
      # `:less_than_or_equal_to` option. This asserts that the attribute can
      # take a number which is less than or equal to the given value and cannot
      # take a number which is greater than it.
      #
      #     class Person
      #       include ActiveModel::Model
      #       attr_accessor :birth_year
      #
      #       validates_numericality_of :birth_year, less_than_or_equal_to: 1987
      #     end
      #
      #     # RSpec
      #     RSpec.describe Person, type: :model do
      #       it do
      #         should validate_numericality_of(:birth_year).
      #           is_less_than_or_equal_to(1987)
      #       end
      #     end
      #
      #     # Minitest (Shoulda)
      #     class PersonTest < ActiveSupport::TestCase
      #       should validate_numericality_of(:birth_year).
      #         is_less_than_or_equal_to(1987)
      #     end
      #
      # ##### is_equal_to
      #
      # Use `is_equal_to` to test usage of the `:equal_to` option. This asserts
      # that the attribute can take a number which is equal to the given value
      # and cannot take a number which is not equal.
      #
      #     class Person
      #       include ActiveModel::Model
      #       attr_accessor :weight
      #
      #       validates_numericality_of :weight, equal_to: 150
      #     end
      #
      #     # RSpec
      #     RSpec.describe Person, type: :model do
      #       it { should validate_numericality_of(:weight).is_equal_to(150) }
      #     end
      #
      #     # Minitest (Shoulda)
      #     class PersonTest < ActiveSupport::TestCase
      #       should validate_numericality_of(:weight).is_equal_to(150)
      #     end
      #
      # ##### is_greater_than_or_equal_to
      #
      # Use `is_greater_than_or_equal_to` to test usage of the
      # `:greater_than_or_equal_to` option. This asserts that the attribute can
      # take a number which is greater than or equal to the given value and
      # cannot take a number which is less than it.
      #
      #     class Person
      #       include ActiveModel::Model
      #       attr_accessor :height
      #
      #       validates_numericality_of :height, greater_than_or_equal_to: 55
      #     end
      #
      #     # RSpec
      #     RSpec.describe Person, type: :model do
      #       it do
      #         should validate_numericality_of(:height).
      #           is_greater_than_or_equal_to(55)
      #       end
      #     end
      #
      #     # Minitest (Shoulda)
      #     class PersonTest < ActiveSupport::TestCase
      #       should validate_numericality_of(:height).
      #         is_greater_than_or_equal_to(55)
      #     end
      #
      # ##### is_greater_than
      #
      # Use `is_greater_than` to test usage of the `:greater_than` option.
      # This asserts that the attribute can take a number which is greater than
      # the given value and cannot take a number less than or equal to it.
      #
      #     class Person
      #       include ActiveModel::Model
      #       attr_accessor :legal_age
      #
      #       validates_numericality_of :legal_age, greater_than: 21
      #     end
      #
      #     # RSpec
      #     RSpec.describe Person, type: :model do
      #       it do
      #         should validate_numericality_of(:legal_age).
      #           is_greater_than(21)
      #       end
      #     end
      #
      #     # Minitest (Shoulda)
      #     class PersonTest < ActiveSupport::TestCase
      #       should validate_numericality_of(:legal_age).
      #         is_greater_than(21)
      #     end
      #
      # ##### even
      #
      # Use `even` to test usage of the `:even` option. This asserts that the
      # attribute can take odd numbers and cannot take even ones.
      #
      #     class Person
      #       include ActiveModel::Model
      #       attr_accessor :birth_month
      #
      #       validates_numericality_of :birth_month, even: true
      #     end
      #
      #     # RSpec
      #     RSpec.describe Person, type: :model do
      #       it { should validate_numericality_of(:birth_month).even }
      #     end
      #
      #     # Minitest (Shoulda)
      #     class PersonTest < ActiveSupport::TestCase
      #       should validate_numericality_of(:birth_month).even
      #     end
      #
      # ##### odd
      #
      # Use `odd` to test usage of the `:odd` option. This asserts that the
      # attribute can take a number which is odd and cannot take a number which
      # is even.
      #
      #     class Person
      #       include ActiveModel::Model
      #       attr_accessor :birth_day
      #
      #       validates_numericality_of :birth_day, odd: true
      #     end
      #
      #     # RSpec
      #     RSpec.describe Person, type: :model do
      #       it { should validate_numericality_of(:birth_day).odd }
      #     end
      #
      #     # Minitest (Shoulda)
      #     class PersonTest < ActiveSupport::TestCase
      #       should validate_numericality_of(:birth_day).odd
      #     end
      #
      # ##### with_message
      #
      # Use `with_message` if you are using a custom validation message.
      #
      #     class Person
      #       include ActiveModel::Model
      #       attr_accessor :number_of_dependents
      #
      #       validates_numericality_of :number_of_dependents,
      #         message: 'Number of dependents must be a number'
      #     end
      #
      #     # RSpec
      #     RSpec.describe Person, type: :model do
      #       it do
      #         should validate_numericality_of(:number_of_dependents).
      #           with_message('Number of dependents must be a number')
      #       end
      #     end
      #
      #     # Minitest (Shoulda)
      #     class PersonTest < ActiveSupport::TestCase
      #       should validate_numericality_of(:number_of_dependents).
      #         with_message('Number of dependents must be a number')
      #     end
      #
      # ##### allow_nil
      #
      # Use `allow_nil` to assert that the attribute allows nil.
      #
      #     class Post
      #       include ActiveModel::Model
      #       attr_accessor :age
      #
      #       validates_numericality_of :age, allow_nil: true
      #     end
      #
      #     # RSpec
      #     RSpec.describe Post, type: :model do
      #       it { should validate_numericality_of(:age).allow_nil }
      #     end
      #
      #     # Minitest (Shoulda)
      #     class PostTest < ActiveSupport::TestCase
      #       should validate_numericality_of(:age).allow_nil
      #     end
      #
      # @return [ValidateNumericalityOfMatcher]
      #
      def validate_numericality_of(attr)
        ValidateNumericalityOfMatcher.new(attr)
      end

      # @private
      class ValidateNumericalityOfMatcher
        NUMERIC_NAME = 'number'
        NON_NUMERIC_VALUE = 'abcd'
        DEFAULT_DIFF_TO_COMPARE = 1

        include Qualifiers::IgnoringInterferenceByWriter

        attr_reader :diff_to_compare

        def initialize(attribute)
          super
          @attribute = attribute
          @submatchers = []
          @diff_to_compare = DEFAULT_DIFF_TO_COMPARE
          @expects_custom_validation_message = false
          @expects_to_allow_nil = false
          @expects_strict = false
          @allowed_type_adjective = nil
          @allowed_type_name = 'number'
          @context = nil
          @expected_message = nil
        end

        def strict
          @expects_strict = true
          self
        end

        def expects_strict?
          @expects_strict
        end

        def only_integer
          prepare_submatcher(
            NumericalityMatchers::OnlyIntegerMatcher.new(self, @attribute)
          )
          self
        end

        def allow_nil
          @expects_to_allow_nil = true
          prepare_submatcher(
            AllowValueMatcher.new(nil)
              .for(@attribute)
              .with_message(:not_a_number)
          )
          self
        end

        def expects_to_allow_nil?
          @expects_to_allow_nil
        end

        def odd
          prepare_submatcher(
            NumericalityMatchers::OddNumberMatcher.new(self, @attribute)
          )
          self
        end

        def even
          prepare_submatcher(
            NumericalityMatchers::EvenNumberMatcher.new(self, @attribute)
          )
          self
        end

        def is_greater_than(value)
          prepare_submatcher(comparison_matcher_for(value, :>).for(@attribute))
          self
        end

        def is_greater_than_or_equal_to(value)
          prepare_submatcher(comparison_matcher_for(value, :>=).for(@attribute))
          self
        end

        def is_equal_to(value)
          prepare_submatcher(comparison_matcher_for(value, :==).for(@attribute))
          self
        end

        def is_less_than(value)
          prepare_submatcher(comparison_matcher_for(value, :<).for(@attribute))
          self
        end

        def is_less_than_or_equal_to(value)
          prepare_submatcher(comparison_matcher_for(value, :<=).for(@attribute))
          self
        end

        def with_message(message)
          @expects_custom_validation_message = true
          @expected_message = message
          self
        end

        def expects_custom_validation_message?
          @expects_custom_validation_message
        end

        def on(context)
          @context = context
          self
        end

        def matches?(subject)
          matches_or_does_not_match?(subject)
          first_submatcher_that_fails_to_match.nil?
        end

        def does_not_match?(subject)
          matches_or_does_not_match?(subject)
          first_submatcher_that_fails_to_not_match.nil?
        end

        def simple_description
          description = ''

          description << "validate that :#{@attribute} looks like "
          description << Shoulda::Matchers::Util.a_or_an(full_allowed_type)

          if comparison_descriptions.present?
            description << ' ' + comparison_descriptions
          end

          description
        end

        def description
          ValidationMatcher::BuildDescription.call(self, simple_description)
        end

        def failure_message
          overall_failure_message.dup.tap do |message|
            message << "\n"
            message << failure_message_for_first_submatcher_that_fails_to_match
          end
        end

        def failure_message_when_negated
          overall_failure_message_when_negated.dup.tap do |message|
            message << "\n"
            message << failure_message_for_first_submatcher_that_fails_to_not_match
          end
        end

        def given_numeric_column?
          attribute_is_active_record_column? &&
            [:integer, :float, :decimal].include?(column_type)
        end

        private

        def matches_or_does_not_match?(subject)
          @subject = subject
          @number_of_submatchers = @submatchers.size

          add_disallow_value_matcher
          qualify_submatchers
        end

        def overall_failure_message
          Shoulda::Matchers.word_wrap(
            "Expected #{model.name} to #{description}, but this could not " +
            'be proved.'
          )
        end

        def overall_failure_message_when_negated
          Shoulda::Matchers.word_wrap(
            "Expected #{model.name} not to #{description}, but this could not " +
            'be proved.'
          )
        end

        def attribute_is_active_record_column?
          columns_hash.key?(@attribute.to_s)
        end

        def column_type
          columns_hash[@attribute.to_s].type
        end

        def columns_hash
          if @subject.class.respond_to?(:columns_hash)
            @subject.class.columns_hash
          else
            {}
          end
        end

        def add_disallow_value_matcher
          disallow_value_matcher = DisallowValueMatcher.
            new(NON_NUMERIC_VALUE).
            for(@attribute).
            with_message(:not_a_number)

          add_submatcher(disallow_value_matcher)
        end

        def prepare_submatcher(submatcher)
          add_submatcher(submatcher)
          submatcher
        end

        def comparison_matcher_for(value, operator)
          NumericalityMatchers::ComparisonMatcher.
            new(self, value, operator).
            for(@attribute)
        end

        def add_submatcher(submatcher)
          if submatcher.respond_to?(:allowed_type_name)
            @allowed_type_name = submatcher.allowed_type_name
          end

          if submatcher.respond_to?(:allowed_type_adjective)
            @allowed_type_adjective = submatcher.allowed_type_adjective
          end

          if submatcher.respond_to?(:diff_to_compare)
            @diff_to_compare = [@diff_to_compare, submatcher.diff_to_compare].max
          end

          @submatchers << submatcher
        end

        def qualify_submatchers
          @submatchers.each do |submatcher|
            if @expects_strict
              submatcher.strict(@expects_strict)
            end

            if @expected_message.present?
              submatcher.with_message(@expected_message)
            end

            if @context
              submatcher.on(@context)
            end

            submatcher.ignoring_interference_by_writer(
              ignore_interference_by_writer
            )
          end
        end

        def number_of_submatchers_for_failure_message
          if has_been_qualified?
            @submatchers.size - 1
          else
            @submatchers.size
          end
        end

        def has_been_qualified?
          @submatchers.any? do |submatcher|
            Shoulda::Matchers::RailsShim.parent_of(submatcher.class) ==
              NumericalityMatchers
          end
        end

        def first_submatcher_that_fails_to_match
          @_failing_submatchers ||= @submatchers.detect do |submatcher|
            !submatcher.matches?(@subject)
          end
        end

        def first_submatcher_that_fails_to_not_match
          @_failing_submatchers ||= @submatchers.detect do |submatcher|
            !submatcher.does_not_match?(@subject)
          end
        end

        def failure_message_for_first_submatcher_that_fails_to_match
          build_submatcher_failure_message_for(
            first_submatcher_that_fails_to_match,
            :failure_message
          )
        end

        def failure_message_for_first_submatcher_that_fails_to_not_match
          build_submatcher_failure_message_for(
            first_submatcher_that_fails_to_not_match,
            :failure_message_when_negated
          )
        end

        def build_submatcher_failure_message_for(
          submatcher,
          failure_message_method
        )
          failure_message = submatcher.public_send(failure_message_method)
          submatcher_description = submatcher.simple_description.
            sub(/\bvalidate that\b/, 'validates').
            sub(/\bdisallow\b/, 'disallows').
            sub(/\ballow\b/, 'allows')
          submatcher_message =
            if number_of_submatchers_for_failure_message > 1
              "In checking that #{model.name} #{submatcher_description}, " +
                failure_message[0].downcase +
                failure_message[1..-1]
            else
              failure_message
            end

          Shoulda::Matchers.word_wrap(submatcher_message, indent: 2)
        end

        def full_allowed_type
          "#{@allowed_type_adjective} #{@allowed_type_name}".strip
        end

        def comparison_descriptions
          description_array = submatcher_comparison_descriptions
          description_array.empty? ? '' : submatcher_comparison_descriptions.join(' and ')
        end

        def submatcher_comparison_descriptions
          @submatchers.inject([]) do |arr, submatcher|
            if submatcher.respond_to? :comparison_description
              arr << submatcher.comparison_description
            end
            arr
          end
        end

        def model
          @subject.class
        end
      end
    end
  end
end