diff --git a/lib/shoulda/matchers/active_model.rb b/lib/shoulda/matchers/active_model.rb index e4a0ad7b..c9f20d49 100644 --- a/lib/shoulda/matchers/active_model.rb +++ b/lib/shoulda/matchers/active_model.rb @@ -26,6 +26,8 @@ require 'shoulda/matchers/active_model/numericality_matchers/comparison_matcher' require 'shoulda/matchers/active_model/numericality_matchers/odd_number_matcher' require 'shoulda/matchers/active_model/numericality_matchers/even_number_matcher' require 'shoulda/matchers/active_model/numericality_matchers/only_integer_matcher' +require 'shoulda/matchers/active_model/numericality_matchers/range_matcher' +require 'shoulda/matchers/active_model/numericality_matchers/submatchers' require 'shoulda/matchers/active_model/errors' require 'shoulda/matchers/active_model/have_secure_password_matcher' diff --git a/lib/shoulda/matchers/active_model/numericality_matchers/comparison_matcher.rb b/lib/shoulda/matchers/active_model/numericality_matchers/comparison_matcher.rb index 8d41b92d..2bea2de5 100644 --- a/lib/shoulda/matchers/active_model/numericality_matchers/comparison_matcher.rb +++ b/lib/shoulda/matchers/active_model/numericality_matchers/comparison_matcher.rb @@ -1,3 +1,5 @@ +require 'active_support/core_ext/module/delegation' + module Shoulda module Matchers module ActiveModel @@ -31,6 +33,8 @@ module Shoulda }, }.freeze + delegate :failure_message, :failure_message_when_negated, to: :submatchers + def initialize(numericality_matcher, value, operator) super(nil) unless numericality_matcher.respond_to? :diff_to_compare @@ -72,49 +76,24 @@ module Shoulda def matches?(subject) @subject = subject - all_bounds_correct? - end - - def failure_message - last_failing_submatcher.failure_message - end - - def failure_message_when_negated - last_failing_submatcher.failure_message_when_negated + submatchers.matches?(subject) end def comparison_description "#{comparison_expectation} #{@value}" end + def submatchers + @_submatchers ||= NumericalityMatchers::Submatchers.new(build_submatchers) + end + private - def all_bounds_correct? - failing_submatchers.empty? - end - - def failing_submatchers - submatchers_and_results. - select { |x| !x[:matched] }. - map { |x| x[:matcher] } - end - - def last_failing_submatcher - failing_submatchers.last - end - - def submatchers - @_submatchers ||= - comparison_combos.map do |diff, submatcher_method_name| - matcher = __send__(submatcher_method_name, diff, nil) - matcher.with_message(@message, values: { count: @value }) - matcher - end - end - - def submatchers_and_results - @_submatchers_and_results ||= submatchers.map do |matcher| - { matcher: matcher, matched: matcher.matches?(@subject) } + def build_submatchers + comparison_combos.map do |diff, submatcher_method_name| + matcher = __send__(submatcher_method_name, diff, nil) + matcher.with_message(@message, values: { count: @value }) + matcher end end diff --git a/lib/shoulda/matchers/active_model/numericality_matchers/range_matcher.rb b/lib/shoulda/matchers/active_model/numericality_matchers/range_matcher.rb new file mode 100644 index 00000000..03d823cd --- /dev/null +++ b/lib/shoulda/matchers/active_model/numericality_matchers/range_matcher.rb @@ -0,0 +1,71 @@ +require 'active_support/core_ext/module/delegation' + +module Shoulda + module Matchers + module ActiveModel + module NumericalityMatchers + # @private + class RangeMatcher < ValidationMatcher + OPERATORS = [:>=, :<=].freeze + + delegate :failure_message, to: :submatchers + + def initialize(numericality_matcher, attribute, range) + super(attribute) + unless numericality_matcher.respond_to? :diff_to_compare + raise ArgumentError, 'numericality_matcher is invalid' + end + + @numericality_matcher = numericality_matcher + @range = range + @attribute = attribute + end + + def matches?(subject) + @subject = subject + submatchers.matches?(subject) + end + + def simple_description + description = '' + + if expects_strict? + description << ' strictly' + end + + description + + "disallow :#{attribute} from being a number that is not " + + range_description + end + + def range_description + "from #{Shoulda::Matchers::Util.inspect_range(@range)}" + end + + def submatchers + @_submatchers ||= NumericalityMatchers::Submatchers.new(build_submatchers) + end + + private + + def build_submatchers + submatcher_combos.map do |value, operator| + build_comparison_submatcher(value, operator) + end + end + + def submatcher_combos + @range.minmax.zip(OPERATORS) + end + + def build_comparison_submatcher(value, operator) + NumericalityMatchers::ComparisonMatcher.new(@numericality_matcher, value, operator). + for(@attribute). + with_message(@message). + on(@context) + end + end + end + end + end +end diff --git a/lib/shoulda/matchers/active_model/numericality_matchers/submatchers.rb b/lib/shoulda/matchers/active_model/numericality_matchers/submatchers.rb new file mode 100644 index 00000000..c7d0a9d6 --- /dev/null +++ b/lib/shoulda/matchers/active_model/numericality_matchers/submatchers.rb @@ -0,0 +1,43 @@ +module Shoulda + module Matchers + module ActiveModel + module NumericalityMatchers + # @private + class Submatchers + def initialize(submatchers) + @submatchers = submatchers + end + + def matches?(subject) + @subject = subject + failing_submatchers.empty? + end + + def failure_message + last_failing_submatcher.failure_message + end + + def failure_message_when_negated + last_failing_submatcher.failure_message_when_negated + end + + def add(submatcher) + @submatchers << submatcher + end + + def last_failing_submatcher + failing_submatchers.last + end + + private + + def failing_submatchers + @_failing_submatchers ||= @submatchers.reject do |submatcher| + submatcher.matches?(@subject) + end + end + end + end + end + end +end diff --git a/lib/shoulda/matchers/active_model/validate_numericality_of_matcher.rb b/lib/shoulda/matchers/active_model/validate_numericality_of_matcher.rb index 1b2c446f..f10834e5 100644 --- a/lib/shoulda/matchers/active_model/validate_numericality_of_matcher.rb +++ b/lib/shoulda/matchers/active_model/validate_numericality_of_matcher.rb @@ -276,6 +276,33 @@ module Shoulda # should validate_numericality_of(:birth_day).odd # end # + # ##### is_in + # + # Use `is_in` to test usage of the `:in` option. + # This asserts that the attribute can take a number which is contained + # in the given range. + # + # class Person + # include ActiveModel::Model + # attr_accessor :legal_age + # + # validates_numericality_of :birth_month, in: 1..12 + # end + # + # # RSpec + # RSpec.describe Person, type: :model do + # it do + # should validate_numericality_of(:birth_month). + # is_in(1..12) + # end + # end + # + # # Minitest (Shoulda) + # class PersonTest < ActiveSupport::TestCase + # should validate_numericality_of(:birth_month). + # is_in(1..12) + # end + # # ##### with_message # # Use `with_message` if you are using a custom validation message. @@ -426,6 +453,13 @@ module Shoulda self end + def is_in(range) + prepare_submatcher( + NumericalityMatchers::RangeMatcher.new(self, @attribute, range), + ) + self + end + def with_message(message) @expects_custom_validation_message = true @expected_message = message @@ -457,6 +491,10 @@ module Shoulda description << "validate that :#{@attribute} looks like " description << Shoulda::Matchers::Util.a_or_an(full_allowed_type) + if range_description.present? + description << " #{range_description}" + end + if comparison_descriptions.present? description << " #{comparison_descriptions}" end @@ -673,6 +711,14 @@ module Shoulda end end + def range_description + range_submatcher = @submatchers.detect do |submatcher| + submatcher.respond_to? :range_description + end + + range_submatcher&.range_description + end + def model @subject.class end diff --git a/spec/support/unit/helpers/rails_versions.rb b/spec/support/unit/helpers/rails_versions.rb index e7831c24..44ac3f59 100644 --- a/spec/support/unit/helpers/rails_versions.rb +++ b/spec/support/unit/helpers/rails_versions.rb @@ -10,5 +10,9 @@ module UnitTests def rails_version Tests::Version.new(Rails::VERSION::STRING) end + + def rails_oldest_version_supported + 5.2 + end end end diff --git a/spec/unit/shoulda/matchers/active_model/validate_numericality_of_matcher_spec.rb b/spec/unit/shoulda/matchers/active_model/validate_numericality_of_matcher_spec.rb index d1d5c6d4..92dfb2db 100644 --- a/spec/unit/shoulda/matchers/active_model/validate_numericality_of_matcher_spec.rb +++ b/spec/unit/shoulda/matchers/active_model/validate_numericality_of_matcher_spec.rb @@ -69,26 +69,34 @@ describe Shoulda::Matchers::ActiveModel::ValidateNumericalityOfMatcher, type: :m validation_name: :on, validation_value: :customizable, }, + { + category: :range, + name: :is_in, + argument: 1..10, + validation_name: :in, + validation_value: 1..10, + rails_version: 7.0, + }, ] end def qualifiers_under(category) - all_qualifiers.select do |qualifier| + all_available_qualifiers.select do |qualifier| qualifier[:category] == category end end def mutually_exclusive_qualifiers - qualifiers_under(:cardinality) + qualifiers_under(:comparison) + qualifiers_under(:cardinality) + qualifiers_under(:comparison) + qualifiers_under(:range) end def non_mutually_exclusive_qualifiers - all_qualifiers - mutually_exclusive_qualifiers + all_available_qualifiers - mutually_exclusive_qualifiers end - def validations_by_qualifier - all_qualifiers.each_with_object({}) do |qualifier, hash| - hash[qualifier[:name]] = qualifier[:validation_name] + def all_available_qualifiers + all_qualifiers.filter do |qualifier| + rails_version >= qualifier.fetch(:rails_version, rails_oldest_version_supported) end end @@ -2065,6 +2073,144 @@ could not be proved. end end + if rails_version >= 7.0 + context 'qualified with in' do + context 'validating with in' do + it 'accepts' do + record = build_record_validating_numericality( + in: 1..10, + ) + expect(record).to validate_numericality.is_in(1..10) + end + + it 'rejects when used in the negative' do + record = build_record_validating_numericality( + in: 1..10, + ) + + assertion = lambda do + expect(record).not_to validate_numericality.is_in(1..10) + end + + expect(&assertion).to fail_with_message(<<~MESSAGE) + Expected Example not to validate that :attr looks like a number from ‹1› + to ‹10›, but this could not be proved. + After setting :attr to ‹"abcd"›, the matcher expected the Example to + be valid, but it was invalid instead, producing these validation + errors: + + * attr: ["is not a number"] + MESSAGE + end + + it_supports( + 'ignoring_interference_by_writer', + tests: { + reject_if_qualified_but_changing_value_interferes: { + model_name: 'Example', + attribute_name: :attr, + changing_values_with: :next_value, + expected_message: <<-MESSAGE.strip, +Expected Example to validate that :attr looks like a number from ‹1› to +‹10›, but this could not be proved. + After setting :attr to ‹"10"› -- which was read back as ‹"11"› -- the + matcher expected the Example to be valid, but it was invalid instead, + producing these validation errors: + + * attr: ["must be in 1..10"] + + As indicated in the message above, :attr seems to be changing certain + values as they are set, and this could have something to do with why + this test is failing. If you've overridden the writer method for this + attribute, then you may need to change it to make this test pass, or + do something else entirely. + MESSAGE + }, + }, + ) do + def validation_matcher_scenario_args + super.deep_merge( + validation_options: { in: 1..10 }, + ) + end + + def configure_validation_matcher(matcher) + matcher.is_in(1..10) + end + end + + context 'when the attribute is a virtual attribute in an ActiveRecord model' do + it 'accepts' do + record = build_record_validating_numericality_of_virtual_attribute( + in: 1..10, + ) + expect(record).to validate_numericality. + is_in(1..10) + end + end + + context 'when the column is an integer column' do + it 'accepts (and does not raise an error)' do + record = build_record_validating_numericality( + column_type: :integer, + in: 1..10, + ) + + expect(record). + to validate_numericality. + is_in(1..10) + end + end + + context 'when the column is a float column' do + it 'accepts (and does not raise an error)' do + record = build_record_validating_numericality( + column_type: :float, + in: 1..10, + ) + + expect(record). + to validate_numericality. + is_in(1..10) + end + end + + context 'when the column is a decimal column' do + it 'accepts (and does not raise an error)' do + record = build_record_validating_numericality( + column_type: :decimal, + in: 1..10, + ) + + expect(record). + to validate_numericality. + is_in(1..10) + end + end + end + + context 'not validating with in' do + it 'rejects since it does not disallow numbers that are not in the range specified' do + record = build_record_validating_numericality + + assertion = lambda do + expect(record).to validate_numericality. + is_in(1..10) + end + + message = <<-MESSAGE +Expected Example to validate that :attr looks like a number from ‹1› to +‹10›, but this could not be proved. + After setting :attr to ‹"11"›, the matcher expected the Example to be + invalid, but it was valid instead. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + end + end + end + def build_validation_options(args) combination = args.fetch(:for) @@ -2080,7 +2226,11 @@ could not be proved. combination.each do |qualifier| args = self.class.default_qualifier_arguments.fetch(qualifier[:name]) - matcher.__send__(qualifier[:name], *args) + if args + matcher.__send__(qualifier[:name], args) + else + matcher.__send__(qualifier[:name]) + end end end