matchi 4.2.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,131 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "constant"
4
+
3
5
  module Matchi
4
- # Type matcher that checks if an object is an exact instance of a specific class.
5
- #
6
- # This matcher provides a secure way to verify an object's exact type, ensuring it
7
- # matches a specific class without including subclasses. It uses Ruby's method binding
8
- # mechanism to bypass potential method overrides, providing better protection against
9
- # type check spoofing than standard instance_of? checks.
10
- #
11
- # @example Basic usage
12
- # matcher = Matchi::BeAnInstanceOf.new(String)
13
- # matcher.match? { "test" } # => true
14
- # matcher.match? { :test } # => false
15
- #
16
- # @example Inheritance behavior
17
- # class Animal; end
18
- # class Dog < Animal; end
19
- #
20
- # matcher = Matchi::BeAnInstanceOf.new(Animal)
21
- # matcher.match? { Animal.new } # => true
22
- # matcher.match? { Dog.new } # => false # Subclass doesn't match
23
- #
24
- # @example Secure type checking
25
- # # Consider a class that attempts to masquerade as String:
26
- # class MaliciousString
27
- # def class; String; end
28
- # def instance_of?(klass); true; end
29
- # end
30
- #
31
- # obj = MaliciousString.new
32
- # obj.instance_of?(String) # => true (spoofed)
33
- #
34
- # matcher = Matchi::BeAnInstanceOf.new(String)
35
- # matcher.match? { obj } # => false (secure)
6
+ # Exact type matcher: passes when the class of the actual value is the
7
+ # expected class itself, not a descendant.
36
8
  #
37
- # @example Different ways to specify the class
38
- # # Using class directly
39
- # Matchi::BeAnInstanceOf.new(String)
9
+ # The class of the actual value is read with `Kernel#class` bound to it, so
10
+ # an object that redefines `class` cannot lie, and a `BasicObject` is
11
+ # handled. The expected class can be given directly, or by name; see
12
+ # {Matchi::Constant}.
40
13
  #
41
- # # Using class name as string
42
- # Matchi::BeAnInstanceOf.new("String")
43
- #
44
- # # Using class name as symbol
45
- # Matchi::BeAnInstanceOf.new(:String)
46
- #
47
- # # Using namespaced class
48
- # Matchi::BeAnInstanceOf.new("MyModule::MyClass")
49
- #
50
- # @see Matchi::BeAKindOf
51
- # @see https://ruby-doc.org/core/Object.html#method-i-instance_of-3F
52
- # @see https://ruby-doc.org/core/Module.html#method-i-bind_call
14
+ # @example
15
+ # Matchi::BeAnInstanceOf.new(Integer).match? { 42 } # => true
16
+ # Matchi::BeAnInstanceOf.new(:Numeric).match? { 42 } # => false
53
17
  class BeAnInstanceOf
54
- # Initialize the matcher with (the name of) a class or module.
55
- #
56
- # @api public
57
- #
58
- # @param expected [Class, #to_s] The expected class or its name
59
- # Can be provided as a Class object, String, or Symbol
60
- #
61
- # @raise [ArgumentError] if the class name doesn't start with an uppercase letter
62
- #
63
- # @return [BeAnInstanceOf] a new instance of the matcher
64
- #
65
- # @example
66
- # BeAnInstanceOf.new(String) # Using class
67
- # BeAnInstanceOf.new("String") # Using string
68
- # BeAnInstanceOf.new(:String) # Using symbol
18
+ # @param expected [Class, String, Symbol] the expected class, or its name
19
+ # @raise [ArgumentError] when expected is neither a Class nor a constant name
69
20
  def initialize(expected)
70
- @expected = String(expected)
71
- return if /\A[A-Z]/.match?(@expected)
72
-
73
- raise ::ArgumentError,
74
- "expected must start with an uppercase letter (got: #{@expected})"
21
+ @expected = Constant.normalize(expected, ::Class)
75
22
  end
76
23
 
77
- # Securely checks if the yielded object is an instance of the expected class.
78
- #
79
- # This method uses Ruby's method binding mechanism to get the true class of an object,
80
- # bypassing potential method overrides. While not completely foolproof, it provides
81
- # better protection against type check spoofing than using regular method calls which
82
- # can be overridden.
83
- #
84
- # @api public
85
- #
86
- # @yield [] Block that returns the object to check
87
- # @yieldreturn [Object] The object to verify the type of
88
- #
89
- # @return [Boolean] true if the object's actual class is exactly the expected class
90
- #
91
- # @raise [ArgumentError] if no block is provided
92
- # @raise [NameError] if the expected class cannot be found
93
- #
94
- # @example Simple type check
95
- # matcher = BeAnInstanceOf.new(String)
96
- # matcher.match? { "test" } # => true
97
- # matcher.match? { StringIO.new } # => false
24
+ # @yieldreturn [Object] the actual value
25
+ # @return [Boolean]
26
+ # @raise [ArgumentError] when no block is given
27
+ # @raise [NameError] when the name is not defined
28
+ # @raise [TypeError] when the name does not resolve to a Class
98
29
  def match?
99
30
  raise ::ArgumentError, "a block must be provided" unless block_given?
100
31
 
101
- actual_class = ::Object.instance_method(:class).bind_call(yield)
102
- expected_class == actual_class
103
- end
32
+ actual_class = ::Kernel.instance_method(:class).bind_call(yield)
104
33
 
105
- # Returns a human-readable description of the matcher.
106
- #
107
- # @api public
108
- #
109
- # @return [String] A string describing what this matcher verifies
110
- #
111
- # @example
112
- # BeAnInstanceOf.new(String).to_s # => "be an instance of String"
113
- def to_s
114
- "be an instance of #{@expected}"
34
+ Constant.resolve(@expected, ::Class).equal?(actual_class)
115
35
  end
116
36
 
117
- private
118
-
119
- # Resolves the expected class name to an actual Class object.
120
- #
121
- # @api private
122
- #
123
- # @return [Class] The resolved class object
124
- # @raise [NameError] If the class name cannot be resolved to an actual class
125
- #
126
- # @note This method handles both string and symbol class names through constant resolution
127
- def expected_class
128
- ::Object.const_get(@expected)
37
+ # @return [String]
38
+ def to_s
39
+ "be an instance of #{Constant.label(@expected)}"
129
40
  end
130
41
  end
131
42
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../number"
4
+
5
+ module Matchi
6
+ class BeWithin
7
+ # Numeric proximity matcher: passes when the actual value is a core number
8
+ # whose distance to the expected value is at most delta.
9
+ #
10
+ # The actual value must be an `Integer`, a `Float`, a `Rational` or a
11
+ # `BigDecimal` (its class is read with `Kernel#class` bound to it). Any
12
+ # other value fails, including a user-defined `Numeric` subclass: such an
13
+ # object would control the subtraction through `coerce`.
14
+ #
15
+ # @example
16
+ # Matchi::BeWithin::Of.new(1, 42).match? { 41 } # => true
17
+ # Matchi::BeWithin::Of.new(1, 42).match? { "42" } # => false
18
+ class Of
19
+ # @param delta [Numeric] the tolerance: a finite, real, non-negative number
20
+ # @param expected [Numeric] the center of the interval: a finite, real number
21
+ # @raise [ArgumentError] when an argument is not such a number
22
+ def initialize(delta, expected)
23
+ @delta = Number.non_negative!(delta, "delta")
24
+ @expected = Number.finite_real!(expected, "expected")
25
+ end
26
+
27
+ # @yieldreturn [Object] the actual value
28
+ # @return [Boolean]
29
+ # @raise [ArgumentError] when no block is given
30
+ def match?
31
+ raise ::ArgumentError, "a block must be provided" unless block_given?
32
+
33
+ actual = yield
34
+ return false unless core_number?(actual)
35
+
36
+ (@expected - actual).abs <= @delta
37
+ end
38
+
39
+ # @return [String]
40
+ def to_s
41
+ "be within #{@delta.inspect} of #{@expected.inspect}"
42
+ end
43
+
44
+ private
45
+
46
+ CORE = [::Integer, ::Float, ::Rational].freeze
47
+ private_constant :CORE
48
+
49
+ def core_number?(actual)
50
+ klass = ::Kernel.instance_method(:class).bind_call(actual)
51
+ return true if CORE.include?(klass)
52
+
53
+ defined?(::BigDecimal) ? klass.equal?(::BigDecimal) : false
54
+ end
55
+ end
56
+ end
57
+ end
@@ -1,149 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "be_within/of"
4
+ require_relative "number"
5
+
3
6
  module Matchi
4
- # Delta comparison matcher that checks if a numeric value is within a specified range.
5
- #
6
- # This matcher verifies that a numeric value falls within a certain distance (delta)
7
- # of an expected value. It's particularly useful for floating-point comparisons or
8
- # when checking if a value is approximately equal to another within a given tolerance.
9
- #
10
- # @example Basic usage with integers
11
- # matcher = Matchi::BeWithin.new(1).of(10)
12
- # matcher.match? { 9 } # => true
13
- # matcher.match? { 10 } # => true
14
- # matcher.match? { 11 } # => true
15
- # matcher.match? { 12 } # => false
7
+ # Builder for the `be within DELTA of EXPECTED` matcher. It is not a matcher
8
+ # itself: it does not respond to `match?`, so a framework that checks the
9
+ # matcher interface rejects a forgotten `of`.
16
10
  #
17
- # @example Floating point comparisons
18
- # matcher = Matchi::BeWithin.new(0.1).of(3.14)
19
- # matcher.match? { 3.1 } # => true
20
- # matcher.match? { 3.2 } # => true
21
- # matcher.match? { 3.0 } # => false
22
- #
23
- # @example Working with percentages
24
- # matcher = Matchi::BeWithin.new(5).of(100) # 5% margin
25
- # matcher.match? { 95 } # => true
26
- # matcher.match? { 105 } # => true
27
- # matcher.match? { 110 } # => false
28
- #
29
- # @see https://ruby-doc.org/core/Numeric.html#method-i-abs
11
+ # @example
12
+ # Matchi::BeWithin.new(0.5).of(3).match? { 3.2 } # => true
30
13
  class BeWithin
31
- # Initialize the matcher with a delta value.
32
- #
33
- # @api public
34
- #
35
- # @param delta [Numeric] The maximum allowed difference from the expected value
36
- #
37
- # @raise [ArgumentError] if delta is not a Numeric
38
- # @raise [ArgumentError] if delta is negative
39
- #
40
- # @return [BeWithin] a new instance of the matcher
41
- #
42
- # @example
43
- # BeWithin.new(0.5) # Using float
44
- # BeWithin.new(2) # Using integer
14
+ # @param delta [Numeric] the tolerance: a finite, real, non-negative number
15
+ # @raise [ArgumentError] when delta is not such a number
45
16
  def initialize(delta)
46
- raise ::ArgumentError, "delta must be a Numeric" unless delta.is_a?(::Numeric)
47
- raise ::ArgumentError, "delta must be non-negative" if delta.negative?
48
-
49
- @delta = delta
17
+ @delta = Number.non_negative!(delta, "delta")
50
18
  end
51
19
 
52
- # Raises NotImplementedError as this is not a complete matcher.
53
- #
54
- # This class acts as a builder for the actual matcher, which is created
55
- # by calling the #of method. Direct use of #match? is not supported.
56
- #
57
- # @api public
58
- #
59
- # @raise [NotImplementedError] always, as this method should not be used
60
- #
61
- # @example
62
- # # Don't do this:
63
- # BeWithin.new(0.5).match? { 42 } # Raises NotImplementedError
64
- #
65
- # # Do this instead:
66
- # BeWithin.new(0.5).of(42).match? { 41.8 } # Works correctly
67
- def match?
68
- raise ::NotImplementedError, "BeWithin is not a complete matcher. Use BeWithin#of to create a valid matcher."
69
- end
70
-
71
- # Specifies the expected reference value.
72
- #
73
- # @api public
74
- #
75
- # @param expected [Numeric] The reference value to compare against
76
- #
77
- # @raise [ArgumentError] if expected is not a Numeric
78
- #
79
- # @return [#match?] A matcher that checks if a value is within the specified range
80
- #
81
- # @example
82
- # be_within_wrapper = BeWithin.new(0.5)
83
- # be_within_wrapper.of(3.14) # Creates matcher for values in range 2.64..3.64
20
+ # @param expected [Numeric] the center of the interval, a finite real number
21
+ # @return [Matchi::BeWithin::Of] the matcher
22
+ # @raise [ArgumentError] when expected is not a finite real Numeric
84
23
  def of(expected)
85
24
  Of.new(@delta, expected)
86
25
  end
87
26
 
88
- # Nested class that performs the actual comparison.
89
- #
90
- # This class implements the actual matching logic, comparing the provided value
91
- # against the expected value using the specified delta.
92
- class Of
93
- # Initialize the matcher with a delta and an expected value.
94
- #
95
- # @api private
96
- #
97
- # @param delta [Numeric] The maximum allowed difference from the expected value
98
- # @param expected [Numeric] The reference value to compare against
99
- #
100
- # @raise [ArgumentError] if delta is not a Numeric
101
- # @raise [ArgumentError] if expected is not a Numeric
102
- # @raise [ArgumentError] if delta is negative
103
- def initialize(delta, expected)
104
- raise ::ArgumentError, "delta must be a Numeric" unless delta.is_a?(::Numeric)
105
- raise ::ArgumentError, "expected must be a Numeric" unless expected.is_a?(::Numeric)
106
- raise ::ArgumentError, "delta must be non-negative" if delta.negative?
107
-
108
- @delta = delta
109
- @expected = expected
110
- end
111
-
112
- # Checks if the yielded value is within the accepted range.
113
- #
114
- # The value is considered within range if its absolute difference from the
115
- # expected value is less than or equal to the specified delta.
116
- #
117
- # @api public
118
- #
119
- # @yield [] Block that returns the value to check
120
- # @yieldreturn [Numeric] The value to verify
121
- #
122
- # @return [Boolean] true if the value is within the accepted range
123
- #
124
- # @raise [ArgumentError] if no block is provided
125
- #
126
- # @example
127
- # matcher = BeWithin.new(0.5).of(3.14)
128
- # matcher.match? { 3.0 } # => true
129
- # matcher.match? { 4.0 } # => false
130
- def match?
131
- raise ::ArgumentError, "a block must be provided" unless block_given?
132
-
133
- (@expected - yield).abs <= @delta
134
- end
135
-
136
- # Returns a human-readable description of the matcher.
137
- #
138
- # @api public
139
- #
140
- # @return [String] A string describing what this matcher verifies
141
- #
142
- # @example
143
- # BeWithin.new(0.5).of(3.14).to_s # => "be within 0.5 of 3.14"
144
- def to_s
145
- "be within #{@delta} of #{@expected}"
146
- end
27
+ # @return [String]
28
+ def to_s
29
+ "be within #{@delta.inspect}"
147
30
  end
148
31
  end
149
32
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../number"
4
+
5
+ module Matchi
6
+ class Change
7
+ # Passes when the difference between the observed value after and before
8
+ # the block, `after - before`, is exactly the expected delta (`:by`), at
9
+ # least it (`:by_at_least`) or at most it (`:by_at_most`). The expected
10
+ # delta receives the comparison; the difference is computed by the
11
+ # observed values, so this matcher depends on their honesty.
12
+ #
13
+ # @example
14
+ # list = []
15
+ # Matchi::Change::Delta.new(:by, 2) { list.length }.match? { list.push(1, 2) } # => true
16
+ class Delta
17
+ # For each kind: the comparison the delta receives, and its description.
18
+ KINDS = {
19
+ by: [:==, "by"],
20
+ by_at_least: [:<=, "by at least"],
21
+ by_at_most: [:>=, "by at most"]
22
+ }.freeze
23
+
24
+ # @param kind [Symbol] `:by`, `:by_at_least` or `:by_at_most`
25
+ # @param delta [Numeric] a finite real number, non-negative unless kind is `:by`
26
+ # @yieldreturn [Object] the observed value
27
+ # @raise [ArgumentError] when the kind is unknown, delta is not such a number, or no block is given
28
+ def initialize(kind, delta, &state)
29
+ raise ::ArgumentError, "unknown kind #{kind.inspect}" unless KINDS.key?(kind)
30
+ raise ::ArgumentError, "a block must be provided" unless block_given?
31
+
32
+ @operator, @label = KINDS.fetch(kind)
33
+ @delta = kind == :by ? Number.finite_real!(delta, "delta") : Number.non_negative!(delta, "delta")
34
+ @state = state
35
+ end
36
+
37
+ # @yieldreturn [Object] ignored, the block is evaluated for its effect
38
+ # @return [Boolean]
39
+ # @raise [ArgumentError] when no block is given
40
+ def match?
41
+ raise ::ArgumentError, "a block must be provided" unless block_given?
42
+
43
+ value_before = @state.call
44
+ yield
45
+ value_after = @state.call
46
+
47
+ @delta.public_send(@operator, value_after - value_before) ? true : false
48
+ end
49
+
50
+ # @return [String]
51
+ def to_s
52
+ "change #{@label} #{@delta.inspect}"
53
+ end
54
+ end
55
+ end
56
+ end
@@ -1,75 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative File.join("from", "to")
3
+ require_relative "from_to"
4
4
 
5
5
  module Matchi
6
6
  class Change
7
- # Initial state wrapper for building a value transition matcher.
7
+ # Builder for the `change from OLD to NEW` matcher. It is not a matcher
8
+ # itself: it does not respond to `match?`, so a forgotten `to` is rejected
9
+ # by any framework that checks the matcher interface.
8
10
  #
9
- # This class acts as a wrapper that captures the expected initial state and
10
- # provides methods to build a complete transition matcher. When combined with
11
- # the 'to' method, it creates a matcher that verifies both the starting and
12
- # ending values of a change operation. This is useful when you need to ensure
13
- # not only the final state but also the initial state of a value.
14
- #
15
- # @example Basic string transformation
16
- # text = "hello"
17
- # Change.new(text, :to_s).from("hello").to("HELLO").match? { text.upcase! } # => true
18
- #
19
- # @example Object state transition
20
- # class User
21
- # attr_accessor :status
22
- # def initialize
23
- # @status = "pending"
24
- # end
25
- # end
26
- #
27
- # user = User.new
28
- # Change.new(user, :status).from("pending").to("active").match? {
29
- # user.status = "active"
30
- # } # => true
31
- #
32
- # @see Matchi::Change::From::To For the complete transition matcher
11
+ # @example
12
+ # Matchi::Change.new(s, :to_s).from("a").to("b")
33
13
  class From
34
- # Initialize the wrapper with an object and a block.
35
- #
36
- # @example
37
- # require "matchi/change/from"
38
- #
39
- # object = "foo"
40
- #
41
- # Matchi::Change::From.new("foo") { object.to_s }
42
- #
43
- # @param expected [#object_id] An expected initial value.
44
- # @param state [Proc] A block of code to execute to get the
45
- # state of the object.
46
- def initialize(expected, &state)
14
+ # @param old_value [#eql?] the expected value before the block
15
+ # @yieldreturn [Object] the observed value
16
+ # @raise [ArgumentError] when no block is given
17
+ def initialize(old_value, &state)
47
18
  raise ::ArgumentError, "a block must be provided" unless block_given?
48
19
 
49
- @expected = expected
50
- @state = state
20
+ @old_value = old_value
21
+ @state = state
22
+ end
23
+
24
+ # @param new_value [#eql?] the expected value after the block
25
+ # @return [Matchi::Change::FromTo]
26
+ def to(new_value)
27
+ FromTo.new(@old_value, new_value, &@state)
51
28
  end
52
29
 
53
- # Specifies the new value to expect.
54
- #
55
- # Creates a complete transition matcher that verifies both the initial
56
- # and final states of a value. The matcher will succeed only if the
57
- # value starts at the expected initial state and changes to the specified
58
- # new value after executing the test block.
59
- #
60
- # @example
61
- # require "matchi/change/from"
62
- #
63
- # object = "foo"
64
- #
65
- # change_from_wrapper = Matchi::Change::From.new("foo") { object.to_s }
66
- # change_from_wrapper.to("FOO")
67
- #
68
- # @param expected_new_value [#object_id] The new value to expect.
69
- #
70
- # @return [#match?] A *change from to* matcher.
71
- def to(expected_new_value)
72
- To.new(@expected, expected_new_value, &@state)
30
+ # @return [String]
31
+ def to_s
32
+ "change from #{@old_value.inspect}"
73
33
  end
74
34
  end
75
35
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../eq"
4
+
5
+ module Matchi
6
+ class Change
7
+ # Passes when the observed value is the old value before the block and
8
+ # the new value after it, both compared like {Matchi::Eq}. The block is
9
+ # always evaluated; a wrong initial value is a failure of the
10
+ # expectation, like a wrong final value.
11
+ #
12
+ # @example
13
+ # s = +"a"
14
+ # Matchi::Change.new(s, :to_s).from("a").to("b").match? { s.replace("b") } # => true
15
+ class FromTo
16
+ # @param old_value [#eql?] the expected value before the block
17
+ # @param new_value [#eql?] the expected value after the block
18
+ # @yieldreturn [Object] the observed value
19
+ # @raise [ArgumentError] when no block is given
20
+ def initialize(old_value, new_value, &state)
21
+ raise ::ArgumentError, "a block must be provided" unless block_given?
22
+
23
+ @old_value = old_value
24
+ @new_value = new_value
25
+ @state = state
26
+ end
27
+
28
+ # @yieldreturn [Object] ignored, the block is evaluated for its effect
29
+ # @return [Boolean]
30
+ # @raise [ArgumentError] when no block is given
31
+ def match?
32
+ raise ::ArgumentError, "a block must be provided" unless block_given?
33
+
34
+ initial = Eq.eql?(@old_value, @state.call)
35
+ yield
36
+
37
+ initial && Eq.eql?(@new_value, @state.call)
38
+ end
39
+
40
+ # @return [String]
41
+ def to_s
42
+ "change from #{@old_value.inspect} to #{@new_value.inspect}"
43
+ end
44
+ end
45
+ end
46
+ end