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,56 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../eq"
4
+
3
5
  module Matchi
4
6
  class Change
5
- # Final state matcher that verifies if a method returns an expected value after a change.
6
- #
7
- # This matcher focuses on the final state of an object, verifying that a method call
8
- # returns an expected value after executing a block of code. Unlike the full from/to
9
- # matcher, it only cares about the end result, not the initial state.
10
- #
11
- # @example Basic string transformation
12
- # text = "hello"
13
- # matcher = Matchi::Change::To.new("HELLO") { text.to_s }
14
- # matcher.match? { text.upcase! } # => true
15
- # matcher.match? { text.reverse! } # => false
16
- #
17
- # @example Number calculations
18
- # counter = 0
19
- # matcher = Matchi::Change::To.new(5) { counter }
20
- # matcher.match? { counter = 5 } # => true
21
- # matcher.match? { counter += 1 } # => false
22
- #
23
- # @example With object attributes
24
- # class User
25
- # attr_accessor :status
26
- # def initialize(status)
27
- # @status = status
28
- # end
29
- # end
30
- #
31
- # user = User.new(:pending)
32
- # matcher = Matchi::Change::To.new(:active) { user.status }
33
- # matcher.match? { user.status = :active } # => true
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.
34
10
  #
35
- # @see Matchi::Change::From::To For checking both initial and final states
36
- # @see Matchi::Change::By For checking numeric changes
11
+ # @example
12
+ # s = +"a"
13
+ # Matchi::Change.new(s, :to_s).to("b").match? { s.replace("b") } # => true
37
14
  class To
38
- # Initialize the matcher with an expected new value and a state block.
39
- #
40
- # @api public
41
- #
42
- # @param expected [#eql?] The expected final value
43
- # @param state [Proc] Block that retrieves the value to check
44
- #
45
- # @raise [ArgumentError] if no state block is provided
46
- #
47
- # @return [To] a new instance of the matcher
48
- #
49
- # @example With simple value
50
- # To.new("test") { object.value }
51
- #
52
- # @example With complex calculation
53
- # To.new(100) { object.items.count }
15
+ # @param expected [#eql?] the expected value after the block
16
+ # @yieldreturn [Object] the observed value
17
+ # @raise [ArgumentError] when no block is given
54
18
  def initialize(expected, &state)
55
19
  raise ::ArgumentError, "a block must be provided" unless block_given?
56
20
 
@@ -58,47 +22,19 @@ module Matchi
58
22
  @state = state
59
23
  end
60
24
 
61
- # Checks if the state block returns the expected value after executing the provided block.
62
- #
63
- # This method executes the provided block and then checks if the state block
64
- # returns the expected value. It only cares about the final state, not any
65
- # intermediate values or the initial state.
66
- #
67
- # @api public
68
- #
69
- # @yield [] Block that should cause the state change
70
- # @yieldreturn [Object] The result of the block (not used)
71
- #
72
- # @return [Boolean] true if the final state matches the expected value
73
- #
74
- # @raise [ArgumentError] if no block is provided
75
- #
76
- # @example Basic usage
77
- # text = "hello"
78
- # matcher = To.new("HELLO") { text.to_s }
79
- # matcher.match? { text.upcase! } # => true
80
- #
81
- # @example With method chaining
82
- # array = [1, 2, 3]
83
- # matcher = To.new(6) { array.sum }
84
- # matcher.match? { array.map! { |x| x * 2 } } # => false
25
+ # @yieldreturn [Object] ignored, the block is evaluated for its effect
26
+ # @return [Boolean]
27
+ # @raise [ArgumentError] when no block is given
85
28
  def match?
86
29
  raise ::ArgumentError, "a block must be provided" unless block_given?
87
30
 
31
+ already = Eq.eql?(@expected, @state.call)
88
32
  yield
89
- value_after = @state.call
90
33
 
91
- @expected.eql?(value_after)
34
+ !already && Eq.eql?(@expected, @state.call)
92
35
  end
93
36
 
94
- # Returns a human-readable description of the matcher.
95
- #
96
- # @api public
97
- #
98
- # @return [String] A string describing what this matcher verifies
99
- #
100
- # @example
101
- # To.new("test").to_s # => 'change to "test"'
37
+ # @return [String]
102
38
  def to_s
103
39
  "change to #{@expected.inspect}"
104
40
  end
data/lib/matchi/change.rb CHANGED
@@ -1,85 +1,54 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative File.join("change", "by_at_least")
4
- require_relative File.join("change", "by_at_most")
5
- require_relative File.join("change", "by")
6
- require_relative File.join("change", "from")
7
- require_relative File.join("change", "to")
3
+ require_relative "change/delta"
4
+ require_relative "change/from"
5
+ require_relative "change/to"
6
+ require_relative "constant"
8
7
 
9
8
  module Matchi
10
- # Wraps the target of a change matcher.
9
+ # State change matcher: passes when evaluating the block changes the value
10
+ # returned by a public method of an object.
11
+ #
12
+ # The observed method is called with `public_send` before and after the
13
+ # block, and the two values are compared with `eql?`. The observed method
14
+ # must return a value, not the mutated object itself: when it returns the
15
+ # very same unfrozen object twice, the comparison cannot tell whether that
16
+ # object changed, and `match?` raises instead of answering `false`.
17
+ # `attr_reader :items` with `items << x` is that case; observe
18
+ # `items.length` or use {#to}, which compares with the expected value. A
19
+ # frozen object returned twice counts as an unchanged value.
20
+ #
21
+ # The refinements {#by}, {#by_at_least}, {#by_at_most}, {#from} and {#to}
22
+ # build more specific matchers. `Change`, `by`, `by_at_least` and
23
+ # `by_at_most` compare values produced by the observed object, so they
24
+ # depend on its honesty, like {Matchi::Predicate}. `to` and `from … to`
25
+ # compare with the expected value, like {Matchi::Eq}. Whatever the
26
+ # observed method raises propagates.
27
+ #
28
+ # @example
29
+ # list = []
30
+ # Matchi::Change.new(list, :length).match? { list << 1 } # => true
31
+ # Matchi::Change.new(list, :length).by(1).match? { list << 1 } # => true
11
32
  class Change
12
- # Initialize a wrapper of the change matcher with an object and the name of
13
- # one of its methods.
14
- #
15
- # @api public
16
- #
17
- # @param object [#object_id] The object whose state will be monitored
18
- # @param method [Symbol] The name of the method to track
19
- # @param args [Array] Additional positional arguments to pass to the method
20
- # @param kwargs [Hash] Additional keyword arguments to pass to the method
21
- # @param block [Proc] Optional block to pass to the method
22
- #
23
- # @raise [ArgumentError] if method is not a Symbol
24
- # @raise [ArgumentError] if object doesn't respond to method
25
- #
26
- # @return [Change] a new instance of the change wrapper
27
- #
28
- # @example Basic initialization
29
- # array = []
30
- # Change.new(array, :length) # Track array length changes
31
- #
32
- # @example With positional arguments
33
- # hash = { key: "value" }
34
- # Change.new(hash, :fetch, :key) # Track specific key value
35
- #
36
- # @example With keyword arguments
37
- # hash = { a: 1, b: 2 }
38
- # Change.new(hash, :fetch, default: 0) # Track with default value
39
- #
40
- # @example With block
41
- # hash = { a: 1 }
42
- # Change.new(hash, :fetch, :b) { |k| k.to_s } # Track with block default
33
+ # @param object [Object] the object to observe
34
+ # @param method [Symbol, String] a public method of that object
35
+ # @param args [Array] positional arguments for the method
36
+ # @param kwargs [Hash] keyword arguments for the method
37
+ # @param block [Proc, nil] a block for the method
38
+ # @raise [ArgumentError] when method is neither a Symbol nor a String
43
39
  def initialize(object, method, *args, **kwargs, &block)
44
- raise ::ArgumentError, "method must be a Symbol" unless method.is_a?(::Symbol)
45
- raise ::ArgumentError, "object must respond to method" unless object.respond_to?(method)
40
+ raise ::ArgumentError, "method must be a Symbol or a String (got: #{method.inspect})" unless name?(method)
46
41
 
47
- @state = -> { object.send(method, *args, **kwargs, &block) }
42
+ @object = object
43
+ @method = method.to_sym
44
+ @state = -> { ::Kernel.instance_method(:public_send).bind_call(object, @method, *args, **kwargs, &block) }
48
45
  end
49
46
 
50
- # Checks if the tracked method's return value changes when executing the block.
51
- #
52
- # This method verifies that the value changes in any way between the start and end
53
- # of the block execution. It doesn't care about the type or magnitude of the change,
54
- # only that it's different.
55
- #
56
- # @api public
57
- #
58
- # @yield [] Block during which the change should occur
59
- # @yieldreturn [Object] Result of the block execution (not used)
60
- #
61
- # @return [Boolean] true if the value changed, false otherwise
62
- #
63
- # @raise [ArgumentError] if no block is provided
64
- #
65
- # @example Basic usage with array length
66
- # array = []
67
- # matcher = Change.new(array, :length)
68
- # matcher.match? { array << "item" } # => true
69
- # matcher.match? { array.clear } # => true (from 1 to 0)
70
- # matcher.match? { array.dup } # => false (no change)
71
- #
72
- # @example With method parameters
73
- # hash = { key: "old" }
74
- # matcher = Change.new(hash, :fetch, :key)
75
- # matcher.match? { hash[:key] = "new" } # => true
76
- # matcher.match? { hash[:key] = "new" } # => false (same value)
77
- #
78
- # @example With computed values
79
- # text = "hello"
80
- # matcher = Change.new(text, :upcase)
81
- # matcher.match? { text.upcase! } # => true
82
- # matcher.match? { text.upcase! } # => false (already uppercase)
47
+ # @yieldreturn [Object] ignored, the block is evaluated for its effect
48
+ # @return [Boolean]
49
+ # @raise [ArgumentError] when no block is given
50
+ # @raise [NoMethodError] when the object has no such public method
51
+ # @raise [TypeError] when the observed method returns the same unfrozen object twice
83
52
  def match?
84
53
  raise ::ArgumentError, "a block must be provided" unless block_given?
85
54
 
@@ -87,98 +56,54 @@ module Matchi
87
56
  yield
88
57
  value_after = @state.call
89
58
 
90
- !value_before.eql?(value_after)
59
+ return !value_before.eql?(value_after) unless value_before.equal?(value_after)
60
+ return false if ::Kernel.instance_method(:frozen?).bind_call(value_before)
61
+
62
+ raise ::TypeError, "#{self} returned the same unfrozen object twice: observe a value, not the object"
91
63
  end
92
64
 
93
- # Returns a human-readable description of the matcher.
94
- #
95
- # @api public
96
- #
97
- # @return [String] A string describing what this matcher verifies
98
- #
99
- # @example
100
- # Change.new("test", :upcase).to_s # => 'eq "test"'
65
+ # @return [String] the observed class and method, such as `change Array#length`
101
66
  def to_s
102
- "change #{@state.inspect}"
67
+ "change #{Constant.label(::Kernel.instance_method(:class).bind_call(@object))}##{@method}"
103
68
  end
104
69
 
105
- # Specifies a minimum delta of the expected change.
106
- #
107
- # @api public
108
- #
109
- # @param minimum_delta [#object_id] The minimum expected change amount
110
- #
111
- # @return [#match?] A matcher that verifies the minimum change
112
- #
113
- # @example
114
- # counter = 0
115
- # matcher = Change.new(counter, :to_i).by_at_least(5)
116
- # matcher.match? { counter += 6 } # => true
117
- def by_at_least(minimum_delta)
118
- ByAtLeast.new(minimum_delta, &@state)
70
+ # @param delta [Numeric] the exact expected difference, negative allowed
71
+ # @return [Matchi::Change::Delta]
72
+ # @raise [ArgumentError] when delta is not a finite real Numeric
73
+ def by(delta)
74
+ Delta.new(:by, delta, &@state)
119
75
  end
120
76
 
121
- # Specifies a maximum delta of the expected change.
122
- #
123
- # @api public
124
- #
125
- # @param maximum_delta [#object_id] The maximum allowed change amount
126
- #
127
- # @return [#match?] A matcher that verifies the maximum change
128
- #
129
- # @example
130
- # counter = 0
131
- # matcher = Change.new(counter, :to_i).by_at_most(5)
132
- # matcher.match? { counter += 3 } # => true
133
- def by_at_most(maximum_delta)
134
- ByAtMost.new(maximum_delta, &@state)
77
+ # @param delta [Numeric] the smallest acceptable increase, zero or positive
78
+ # @return [Matchi::Change::Delta]
79
+ # @raise [ArgumentError] when delta is not a finite real non-negative Numeric
80
+ def by_at_least(delta)
81
+ Delta.new(:by_at_least, delta, &@state)
135
82
  end
136
83
 
137
- # Specifies the exact delta of the expected change.
138
- #
139
- # @api public
140
- #
141
- # @param delta [#object_id] The exact expected change amount
142
- #
143
- # @return [#match?] A matcher that verifies the exact change
144
- #
145
- # @example
146
- # counter = 0
147
- # matcher = Change.new(counter, :to_i).by(5)
148
- # matcher.match? { counter += 5 } # => true
149
- def by(delta)
150
- By.new(delta, &@state)
84
+ # @param delta [Numeric] the largest acceptable increase, zero or positive
85
+ # @return [Matchi::Change::Delta]
86
+ # @raise [ArgumentError] when delta is not a finite real non-negative Numeric
87
+ def by_at_most(delta)
88
+ Delta.new(:by_at_most, delta, &@state)
151
89
  end
152
90
 
153
- # Specifies the original value in a value transition check.
154
- #
155
- # @api public
156
- #
157
- # @param old_value [#object_id] The expected initial value
158
- #
159
- # @return [#to] A wrapper for creating a from/to matcher
160
- #
161
- # @example
162
- # string = "foo"
163
- # Change.new(string, :to_s).from("foo").to("FOO")
91
+ # @param old_value [#eql?] the expected value before the block
92
+ # @return [Matchi::Change::From] a builder; call `to` on it
164
93
  def from(old_value)
165
94
  From.new(old_value, &@state)
166
95
  end
167
96
 
168
- # Specifies the final value to expect.
169
- #
170
- # @api public
171
- #
172
- # @param new_value [#object_id] The expected final value
173
- #
174
- # @return [#match?] A matcher that verifies the final state
175
- #
176
- # @example
177
- # string = "foo"
178
- # matcher = Change.new(string, :to_s).to("FOO")
179
- # matcher.match? { string.upcase! } # => true
97
+ # @param new_value [#eql?] the expected value after the block
98
+ # @return [Matchi::Change::To]
180
99
  def to(new_value)
181
100
  To.new(new_value, &@state)
182
101
  end
102
+
103
+ private
104
+
105
+ def name?(method)
106
+ ::Symbol === method || ::String === method
107
+ end
183
108
  end
184
109
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matchi
4
+ # Shared handling of an expected module given directly or by name.
5
+ #
6
+ # A name is a String or a Symbol holding a constant path such as
7
+ # `"Comparable"` or `:"Foo::Bar"`. It is validated when the matcher is built
8
+ # and resolved from `Object` when the matcher runs, which lets a
9
+ # specification be written before the class it describes is loaded. Since
10
+ # resolution goes through `Object.const_get`, an `autoload` or an
11
+ # `Object.const_missing` defined by the code under test takes part in it.
12
+ #
13
+ # @api private
14
+ module Constant
15
+ # A constant path: ASCII segments that start with a capital letter,
16
+ # joined by `::`, such as `"Comparable"` or `"File::Stat"`.
17
+ NAME = /\A[A-Z]\w*(::[A-Z]\w*)*\z/
18
+
19
+ # @param expected [Module, String, Symbol] a module, or its name
20
+ # @param kind [Class] `Module` or `Class`, what expected must be
21
+ # @return [Module, String] the module itself, or the validated name
22
+ # @raise [ArgumentError] when expected is neither
23
+ def self.normalize(expected, kind)
24
+ return expected if kind === expected
25
+ return String(expected) if (::String === expected || ::Symbol === expected) && NAME.match?(expected)
26
+
27
+ raise ::ArgumentError, "expected must be a #{kind} or a constant name (got: #{expected.inspect})"
28
+ end
29
+
30
+ # @param expected [Module, String] a module, or a validated name
31
+ # @return [String] the name, or a stable label for an anonymous module
32
+ #
33
+ # The name is read with `Module#name` bound to the module, so a class
34
+ # of the code under test that redefines `name` cannot break a report.
35
+ def self.label(expected)
36
+ return expected unless ::Module === expected
37
+
38
+ ::Module.instance_method(:name).bind_call(expected) || "an anonymous #{::Class === expected ? "class" : "module"}"
39
+ end
40
+
41
+ # @param expected [Module, String] a module, or a validated name
42
+ # @param kind [Class] `Module` or `Class`, what the name must resolve to
43
+ # @return [Module]
44
+ # @raise [NameError] when the name is not defined
45
+ # @raise [TypeError] when the name does not resolve to the expected kind
46
+ def self.resolve(expected, kind)
47
+ return expected if kind === expected
48
+
49
+ value = ::Object.const_get(expected)
50
+ raise ::TypeError, "#{expected} is not a #{kind}" unless kind === value
51
+
52
+ value
53
+ end
54
+ end
55
+ end
data/lib/matchi/eq.rb CHANGED
@@ -1,85 +1,44 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Matchi
4
- # Value equivalence matcher that checks if two objects have identical values.
4
+ # Equivalence matcher: passes when the actual value is of the same class as
5
+ # the expected value and `eql?` to it.
5
6
  #
6
- # This matcher verifies value equality using Ruby's Object#eql? method, which
7
- # compares the values of objects rather than their identity. This is different
8
- # from identity comparison (equal?) which checks if objects are the same instance.
7
+ # The expected value receives the `eql?` call, so the actual value cannot
8
+ # lie. The class check comes first because a few core `eql?` methods, such
9
+ # as `Hash#eql?`, hand the comparison back to an operand of another class;
10
+ # it costs nothing, since `eql?` never holds across classes for core values.
9
11
  #
10
- # @example Basic usage with strings
11
- # matcher = Matchi::Eq.new("test")
12
- # matcher.match? { "test" } # => true
13
- # matcher.match? { "test".dup } # => true
14
- # matcher.match? { "other" } # => false
15
- #
16
- # @example With numbers
17
- # matcher = Matchi::Eq.new(42)
18
- # matcher.match? { 42 } # => true
19
- # matcher.match? { 42.0 } # => false # Different types
20
- # matcher.match? { 43 } # => false
21
- #
22
- # @example With collections
23
- # array = [1, 2, 3]
24
- # matcher = Matchi::Eq.new(array)
25
- # matcher.match? { array.dup } # => true # Same values
26
- # matcher.match? { array } # => true # Same object
27
- # matcher.match? { [1, 2, 3] } # => true # Same values
28
- # matcher.match? { [1, 2] } # => false # Different values
29
- #
30
- # @see https://ruby-doc.org/core/Object.html#method-i-eql-3F
31
- # @see Matchi::Be
12
+ # @example
13
+ # Matchi::Eq.new("foo").match? { "foo" } # => true
14
+ # Matchi::Eq.new(1).match? { 1.0 } # => false (eql? is strict on type)
32
15
  class Eq
33
- # Initialize the matcher with a reference value.
34
- #
35
- # @api public
36
- #
37
- # @param expected [#eql?] The expected equivalent value
38
- #
39
- # @return [Eq] a new instance of the matcher
40
- #
41
- # @example
42
- # Eq.new("test") # Match strings with same value
43
- # Eq.new([1, 2, 3]) # Match arrays with same elements
16
+ # @param expected [#eql?] the expected value
44
17
  def initialize(expected)
45
18
  @expected = expected
46
19
  end
47
20
 
48
- # Checks if the yielded object has a value equivalent to the expected object.
49
- #
50
- # This method uses Ruby's Object#eql? method, which performs value comparison.
51
- # Two objects are considered equivalent if they have the same value, even if
52
- # they are different instances.
53
- #
54
- # @api public
55
- #
56
- # @yield [] Block that returns the object to check
57
- # @yieldreturn [Object] The object to verify equivalence with
58
- #
59
- # @return [Boolean] true if both objects have equivalent values
60
- #
61
- # @raise [ArgumentError] if no block is provided
62
- #
63
- # @example
64
- # matcher = Eq.new([1, 2, 3])
65
- # matcher.match? { [1, 2, 3] } # => true
66
- # matcher.match? { [1, 2, 3].dup } # => true
21
+ # @yieldreturn [Object] the actual value
22
+ # @return [Boolean]
23
+ # @raise [ArgumentError] when no block is given
67
24
  def match?
68
25
  raise ::ArgumentError, "a block must be provided" unless block_given?
69
26
 
70
- @expected.eql?(yield)
27
+ Eq.eql?(@expected, yield)
71
28
  end
72
29
 
73
- # Returns a human-readable description of the matcher.
74
- #
75
- # @api public
76
- #
77
- # @return [String] A string describing what this matcher verifies
78
- #
79
- # @example
80
- # Eq.new("test").to_s # => 'eq "test"'
30
+ # @return [String]
81
31
  def to_s
82
32
  "eq #{@expected.inspect}"
83
33
  end
34
+
35
+ # @param expected [#eql?] the value that receives the comparison
36
+ # @param actual [Object] a value from the code under test
37
+ # @return [Boolean] whether actual is of the class of expected and `eql?` to it
38
+ def self.eql?(expected, actual)
39
+ return false unless ::Kernel.instance_method(:class).bind_call(actual).equal?(expected.class)
40
+
41
+ expected.eql?(actual) ? true : false
42
+ end
84
43
  end
85
44
  end
data/lib/matchi/match.rb CHANGED
@@ -1,83 +1,47 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Matchi
4
- # Pattern matching matcher that checks if a value matches a regular expression.
4
+ # Regular expression matcher: passes when the actual value is a String or a
5
+ # Symbol that the expected Regexp matches. Any other actual value fails, as
6
+ # does a string with an invalid or incompatible encoding.
5
7
  #
6
- # This matcher verifies that a value matches a pattern using Ruby's Regexp#match? method.
7
- # It's particularly useful for string validation, pattern matching, and text analysis.
8
- # The matcher ensures secure pattern matching by requiring the pattern to respond to match?.
8
+ # Only a `Regexp` is accepted as pattern: with a `String` pattern,
9
+ # `String#match?` would turn the actual value into the regular expression,
10
+ # handing the comparison to the code under test. Use {Matchi::Satisfy} for
11
+ # other predicates.
9
12
  #
10
- # @example Basic usage
11
- # matcher = Matchi::Match.new(/^test/)
12
- # matcher.match? { "test_string" } # => true
13
- # matcher.match? { "other_string" } # => false
14
- #
15
- # @example Case sensitivity
16
- # matcher = Matchi::Match.new(/^test$/i)
17
- # matcher.match? { "TEST" } # => true
18
- # matcher.match? { "Test" } # => true
19
- # matcher.match? { "testing" } # => false
20
- #
21
- # @example Multiline patterns
22
- # matcher = Matchi::Match.new(/\A\d+\Z/m)
23
- # matcher.match? { "123" } # => true
24
- # matcher.match? { "12.3" } # => false
25
- #
26
- # @see https://ruby-doc.org/core/Regexp.html#method-i-match-3F
13
+ # @example
14
+ # Matchi::Match.new(/^foo/).match? { "foobar" } # => true
15
+ # Matchi::Match.new(/^foo/).match? { 42 } # => false
27
16
  class Match
28
- # Initialize the matcher with a pattern.
29
- #
30
- # @api public
31
- #
32
- # @param expected [#match?] A pattern that responds to match?
33
- #
34
- # @raise [ArgumentError] if the pattern doesn't respond to match?
35
- #
36
- # @return [Match] a new instance of the matcher
37
- #
38
- # @example
39
- # Match.new(/\d+/) # Match digits
40
- # Match.new(/^test$/i) # Case-insensitive match
41
- # Match.new(/\A\w+\Z/) # Full string word characters
17
+ # @param expected [Regexp] the pattern
18
+ # @raise [ArgumentError] when expected is not a Regexp
42
19
  def initialize(expected)
43
- raise ::ArgumentError, "expected must respond to match?" unless expected.respond_to?(:match?)
20
+ raise ::ArgumentError, "expected must be a Regexp (got: #{expected.inspect})" unless ::Regexp === expected
44
21
 
45
22
  @expected = expected
46
23
  end
47
24
 
48
- # Checks if the yielded value matches the expected pattern.
49
- #
50
- # This method uses the pattern's match? method to perform the comparison.
51
- # The match is performed on the entire string unless the pattern specifically
52
- # allows partial matches.
53
- #
54
- # @api public
55
- #
56
- # @yield [] Block that returns the value to check
57
- # @yieldreturn [#to_s] The value to match against the pattern
58
- #
59
- # @return [Boolean] true if the value matches the pattern
60
- #
61
- # @raise [ArgumentError] if no block is provided
62
- #
63
- # @example
64
- # matcher = Match.new(/^\d{3}-\d{2}-\d{4}$/)
65
- # matcher.match? { "123-45-6789" } # => true
66
- # matcher.match? { "123456789" } # => false
25
+ # @yieldreturn [Object] the actual value
26
+ # @return [Boolean]
27
+ # @raise [ArgumentError] when no block is given
67
28
  def match?
68
29
  raise ::ArgumentError, "a block must be provided" unless block_given?
69
30
 
70
- @expected.match?(yield)
31
+ actual = yield
32
+ return false unless ::String === actual || ::Symbol === actual
33
+
34
+ string = ::Symbol === actual ? actual.name : actual
35
+ return false unless string.valid_encoding?
36
+
37
+ begin
38
+ @expected.match?(string)
39
+ rescue ::Encoding::CompatibilityError
40
+ false
41
+ end
71
42
  end
72
43
 
73
- # Returns a human-readable description of the matcher.
74
- #
75
- # @api public
76
- #
77
- # @return [String] A string describing what this matcher verifies
78
- #
79
- # @example
80
- # Match.new(/^test/).to_s # => "match /^test/"
44
+ # @return [String]
81
45
  def to_s
82
46
  "match #{@expected.inspect}"
83
47
  end