matchi 4.1.1 → 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,119 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "constant"
4
+
3
5
  module Matchi
4
- # *Type/class* matcher with enhanced class checking.
5
- #
6
- # This matcher aims to provide a more reliable way to check if an object is an exact
7
- # instance of a specific class (not a subclass). While not foolproof, it uses a more
8
- # robust method to get the actual class of an object that helps resist common
9
- # attempts at type checking manipulation.
10
- #
11
- # @example Basic usage
12
- # require "matchi/be_an_instance_of"
13
- #
14
- # matcher = Matchi::BeAnInstanceOf.new(String)
15
- # matcher.match? { "foo" } # => true
16
- # matcher.match? { :foo } # => false
17
- #
18
- # @example Enhanced class checking in practice
19
- # # Consider a class that attempts to masquerade as String by overriding
20
- # # common type checking methods:
21
- # class MaliciousString
22
- # def class
23
- # ::String
24
- # end
25
- #
26
- # def instance_of?(klass)
27
- # self.class == klass
28
- # end
29
- #
30
- # def is_a?(klass)
31
- # "".is_a?(klass) # Delegates to a real String
32
- # end
6
+ # Exact type matcher: passes when the class of the actual value is the
7
+ # expected class itself, not a descendant.
33
8
  #
34
- # def kind_of?(klass)
35
- # is_a?(klass) # Maintains Ruby's kind_of? alias for is_a?
36
- # end
37
- # end
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}.
38
13
  #
39
- # obj = MaliciousString.new
40
- # obj.class # => String
41
- # obj.is_a?(String) # => true
42
- # obj.kind_of?(String) # => true
43
- # obj.instance_of?(String) # => true
44
- #
45
- # # Using our enhanced checking approach:
46
- # matcher = Matchi::BeAnInstanceOf.new(String)
47
- # matcher.match? { obj } # => false
14
+ # @example
15
+ # Matchi::BeAnInstanceOf.new(Integer).match? { 42 } # => true
16
+ # Matchi::BeAnInstanceOf.new(:Numeric).match? { 42 } # => false
48
17
  class BeAnInstanceOf
49
- # Initialize the matcher with (the name of) a class or module.
50
- #
51
- # @example
52
- # require "matchi/be_an_instance_of"
53
- #
54
- # Matchi::BeAnInstanceOf.new(String)
55
- # Matchi::BeAnInstanceOf.new("String")
56
- # Matchi::BeAnInstanceOf.new(:String)
57
- #
58
- # @param expected [Class, #to_s] The expected class name
59
- # @raise [ArgumentError] if the class name doesn't start with an uppercase letter
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
60
20
  def initialize(expected)
61
- @expected = String(expected)
62
- return if /\A[A-Z]/.match?(@expected)
63
-
64
- raise ::ArgumentError,
65
- "expected must start with an uppercase letter (got: #{@expected})"
21
+ @expected = Constant.normalize(expected, ::Class)
66
22
  end
67
23
 
68
- # Securely checks if the yielded object is an instance of the expected class.
69
- #
70
- # This method uses a specific Ruby reflection technique to get the true class of
71
- # an object, bypassing potential method overrides:
72
- #
73
- # 1. ::Object.instance_method(:class) retrieves the original, unoverridden 'class'
74
- # method from the Object class
75
- # 2. .bind_call(obj) binds this original method to our object and calls it,
76
- # ensuring we get the real class regardless of method overrides
77
- #
78
- # This approach is more reliable than obj.class because it uses Ruby's method
79
- # binding mechanism to call the original implementation directly. While not
80
- # completely foolproof, it provides better protection against type check spoofing
81
- # than using regular method calls which can be overridden.
82
- #
83
- # @example Basic class check
84
- # matcher = Matchi::BeAnInstanceOf.new(String)
85
- # matcher.match? { "test" } # => true
86
- # matcher.match? { StringIO.new } # => false
87
- #
88
- # @see https://ruby-doc.org/core/Method.html#method-i-bind_call
89
- # @see https://ruby-doc.org/core/UnboundMethod.html
90
- #
91
- # @yieldreturn [Object] the actual value to check
92
- # @return [Boolean] true if the object's actual class is exactly the expected class
93
- # @raise [ArgumentError] if no block is provided
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
94
29
  def match?
95
30
  raise ::ArgumentError, "a block must be provided" unless block_given?
96
31
 
97
- actual_class = ::Object.instance_method(:class).bind_call(yield)
98
- expected_class == actual_class
99
- end
32
+ actual_class = ::Kernel.instance_method(:class).bind_call(yield)
100
33
 
101
- # Returns a string representing the matcher.
102
- #
103
- # @return [String] a human-readable description of the matcher
104
- def to_s
105
- "be an instance of #{@expected}"
34
+ Constant.resolve(@expected, ::Class).equal?(actual_class)
106
35
  end
107
36
 
108
- private
109
-
110
- # Resolves the expected class name to an actual Class object.
111
- # This method handles both string and symbol class names through constant resolution.
112
- #
113
- # @return [Class] the resolved class
114
- # @raise [NameError] if the class doesn't exist
115
- def expected_class
116
- ::Object.const_get(@expected)
37
+ # @return [String]
38
+ def to_s
39
+ "be an instance of #{Constant.label(@expected)}"
117
40
  end
118
41
  end
119
42
  end
@@ -1,50 +1,56 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../number"
4
+
3
5
  module Matchi
4
6
  class BeWithin
5
- # *BeWithin of* matcher.
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
6
18
  class Of
7
- # Initialize the matcher with a delta and an expected value.
8
- #
9
- # @example
10
- # require "matchi/be_within/of"
11
- #
12
- # Matchi::BeWithin::Of.new(1, 41)
13
- #
14
- # @param delta [Numeric] The accepted variation of the actual value.
15
- # @param expected [Numeric] The expected value.
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
16
22
  def initialize(delta, expected)
17
- raise ::ArgumentError, "delta must be a Numeric" unless delta.is_a?(::Numeric)
18
- raise ::ArgumentError, "expected must be a Numeric" unless expected.is_a?(::Numeric)
19
- raise ::ArgumentError, "delta must be non-negative" if delta.negative?
20
-
21
- @delta = delta
22
- @expected = expected
23
+ @delta = Number.non_negative!(delta, "delta")
24
+ @expected = Number.finite_real!(expected, "expected")
23
25
  end
24
26
 
25
- # Boolean comparison on the expected be_within by comparing the actual
26
- # value and the expected value.
27
- #
28
- # @example
29
- # require "matchi/be_within/of"
30
- #
31
- # matcher = Matchi::BeWithin::Of.new(1, 41)
32
- # matcher.match? { 42 } # => true
33
- #
34
- # @yieldreturn [Numeric] The block of code to execute.
35
- #
36
- # @return [Boolean] Comparison between the actual and the expected values.
27
+ # @yieldreturn [Object] the actual value
28
+ # @return [Boolean]
29
+ # @raise [ArgumentError] when no block is given
37
30
  def match?
38
31
  raise ::ArgumentError, "a block must be provided" unless block_given?
39
32
 
40
- (@expected - yield).abs <= @delta
33
+ actual = yield
34
+ return false unless core_number?(actual)
35
+
36
+ (@expected - actual).abs <= @delta
41
37
  end
42
38
 
43
- # Returns a string representing the matcher.
44
- #
45
- # @return [String] a human-readable description of the matcher
39
+ # @return [String]
46
40
  def to_s
47
- "be within #{@delta} of #{@expected}"
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
48
54
  end
49
55
  end
50
56
  end
@@ -1,38 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative File.join("be_within", "of")
3
+ require_relative "be_within/of"
4
+ require_relative "number"
4
5
 
5
6
  module Matchi
6
- # Wraps the target of a be_within matcher.
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`.
10
+ #
11
+ # @example
12
+ # Matchi::BeWithin.new(0.5).of(3).match? { 3.2 } # => true
7
13
  class BeWithin
8
- # Initialize a wrapper of the be_within matcher with a numeric value.
9
- #
10
- # @example
11
- # require "matchi/be_within"
12
- #
13
- # Matchi::BeWithin.new(1)
14
- #
15
- # @param delta [Numeric] A numeric value.
14
+ # @param delta [Numeric] the tolerance: a finite, real, non-negative number
15
+ # @raise [ArgumentError] when delta is not such a number
16
16
  def initialize(delta)
17
- raise ::ArgumentError, "delta must be a Numeric" unless delta.is_a?(::Numeric)
18
- raise ::ArgumentError, "delta must be non-negative" if delta.negative?
19
-
20
- @delta = delta
17
+ @delta = Number.non_negative!(delta, "delta")
21
18
  end
22
19
 
23
- # Specifies an expected numeric value.
24
- #
25
- # @example
26
- # require "matchi/be_within"
27
- #
28
- # be_within_wrapper = Matchi::BeWithin.new(1)
29
- # be_within_wrapper.of(41)
30
- #
31
- # @param expected [Numeric] The expected value.
32
- #
33
- # @return [#match?] A *be_within of* matcher.
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
34
23
  def of(expected)
35
24
  Of.new(@delta, expected)
36
25
  end
26
+
27
+ # @return [String]
28
+ def to_s
29
+ "be within #{@delta.inspect}"
30
+ end
37
31
  end
38
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,45 +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
- # *Change from to* wrapper.
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.
10
+ #
11
+ # @example
12
+ # Matchi::Change.new(s, :to_s).from("a").to("b")
8
13
  class From
9
- # Initialize the wrapper with an object and a block.
10
- #
11
- # @example
12
- # require "matchi/change/from"
13
- #
14
- # object = "foo"
15
- #
16
- # Matchi::Change::From.new("foo") { object.to_s }
17
- #
18
- # @param expected [#object_id] An expected initial value.
19
- # @param state [Proc] A block of code to execute to get the
20
- # state of the object.
21
- 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)
22
18
  raise ::ArgumentError, "a block must be provided" unless block_given?
23
19
 
24
- @expected = expected
25
- @state = state
20
+ @old_value = old_value
21
+ @state = state
26
22
  end
27
23
 
28
- # Specifies the new value to expect.
29
- #
30
- # @example
31
- # require "matchi/change/from"
32
- #
33
- # object = "foo"
34
- #
35
- # change_from_wrapper = Matchi::Change::From.new("foo") { object.to_s }
36
- # change_from_wrapper.to("FOO")
37
- #
38
- # @param expected_new_value [#object_id] The new value to expect.
39
- #
40
- # @return [#match?] A *change from to* matcher.
41
- def to(expected_new_value)
42
- To.new(@expected, expected_new_value, &@state)
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)
28
+ end
29
+
30
+ # @return [String]
31
+ def to_s
32
+ "change from #{@old_value.inspect}"
43
33
  end
44
34
  end
45
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
@@ -1,55 +1,40 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../eq"
4
+
3
5
  module Matchi
4
6
  class Change
5
- # *Change to* matcher.
7
+ # Passes when the observed value is not the expected value before the
8
+ # block and is the expected value after it: the value must change to it.
9
+ # The block is always evaluated.
10
+ #
11
+ # @example
12
+ # s = +"a"
13
+ # Matchi::Change.new(s, :to_s).to("b").match? { s.replace("b") } # => true
6
14
  class To
7
- # Initialize the matcher with an object and a block.
8
- #
9
- # @example
10
- # require "matchi/change/to"
11
- #
12
- # object = "foo"
13
- #
14
- # Matchi::Change::To.new("FOO") { object.to_s }
15
- #
16
- # @param expected [#object_id] An expected new value.
17
- # @param state [Proc] A block of code to execute to get the
18
- # state of the object.
15
+ # @param expected [#eql?] the expected value after the block
16
+ # @yieldreturn [Object] the observed value
17
+ # @raise [ArgumentError] when no block is given
19
18
  def initialize(expected, &state)
20
19
  raise ::ArgumentError, "a block must be provided" unless block_given?
21
20
 
22
21
  @expected = expected
23
- @state = state
22
+ @state = state
24
23
  end
25
24
 
26
- # Boolean comparison on the expected change by comparing the value
27
- # before and after the code execution.
28
- #
29
- # @example
30
- # require "matchi/change/to"
31
- #
32
- # object = "foo"
33
- #
34
- # matcher = Matchi::Change::To.new("FOO") { object.to_s }
35
- # matcher.match? { object.upcase! } # => true
36
- #
37
- # @yieldreturn [#object_id] The block of code to execute.
38
- #
39
- # @return [Boolean] Comparison between the value before and after the
40
- # code execution.
25
+ # @yieldreturn [Object] ignored, the block is evaluated for its effect
26
+ # @return [Boolean]
27
+ # @raise [ArgumentError] when no block is given
41
28
  def match?
42
29
  raise ::ArgumentError, "a block must be provided" unless block_given?
43
30
 
31
+ already = Eq.eql?(@expected, @state.call)
44
32
  yield
45
- value_after = @state.call
46
33
 
47
- @expected == value_after
34
+ !already && Eq.eql?(@expected, @state.call)
48
35
  end
49
36
 
50
- # Returns a string representing the matcher.
51
- #
52
- # @return [String] a human-readable description of the matcher
37
+ # @return [String]
53
38
  def to_s
54
39
  "change to #{@expected.inspect}"
55
40
  end