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,66 +1,101 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "constant"
4
+
3
5
  module Matchi
4
- # *Expecting errors* matcher.
6
+ # Exception matcher: passes when evaluating the block raises an exception
7
+ # of the expected class or of one of its descendants (`Module#===`, so the
8
+ # exception cannot lie about its class), optionally with an expected
9
+ # message: a String the message must be `eql?` to, or a Regexp it must
10
+ # match.
11
+ #
12
+ # Every `Exception` is caught, including `SystemExit` and `Interrupt`: a
13
+ # specification must be able to assert on any of them, so an unexpected
14
+ # `exit` or Ctrl-C inside the block is reported as a failure, not
15
+ # propagated. The expected class can be given directly, or by name; see
16
+ # {Matchi::Constant}.
17
+ #
18
+ # @example
19
+ # Matchi::RaiseException.new(ZeroDivisionError).match? { 1 / 0 } # => true
20
+ # Matchi::RaiseException.new(:StandardError).match? { 1 / 0 } # => true
21
+ # Matchi::RaiseException.new(ZeroDivisionError, /by 0/).match? { 1 / 0 } # => true
22
+ # Matchi::RaiseException.new(ZeroDivisionError, "nope").match? { 1 / 0 } # => false
23
+ # Matchi::RaiseException.new(IOError).match? { 1 / 0 } # => false
5
24
  class RaiseException
6
- # Initialize the matcher with a descendant of class Exception.
7
- #
8
- # @example
9
- # require "matchi/raise_exception"
10
- #
11
- # Matchi::RaiseException.new(NameError)
12
- #
13
- # @param expected [Exception, #to_s] The expected exception name.
14
- def initialize(expected)
15
- @expected = String(expected)
16
- return if /\A[A-Z]/.match?(@expected)
17
-
18
- raise ::ArgumentError,
19
- "expected must start with an uppercase letter (got: #{@expected})"
25
+ # @param expected [Class, String, Symbol] the expected exception class, or its name
26
+ # @param message [String, Regexp, nil] the expected message, exact or as a pattern
27
+ # @raise [ArgumentError] when expected is neither an Exception class nor a constant name,
28
+ # or message is neither a String nor a Regexp
29
+ def initialize(expected, message = nil)
30
+ @expected = Constant.normalize(expected, ::Class)
31
+ @message = message
32
+ validate_class!
33
+ validate_message!
20
34
  end
21
35
 
22
- # Boolean comparison between the actual value and the expected value.
23
- #
24
- # @example
25
- # require "matchi/raise_exception"
26
- #
27
- # matcher = Matchi::RaiseException.new(NameError)
28
- # matcher.match? { Boom } # => true
29
- #
30
- # @yieldreturn [#object_id] The actual value to compare to the expected
31
- # one.
32
- #
33
- # @return [Boolean] Comparison between actual and expected values.
36
+ # @yieldreturn [Object] ignored, the block is evaluated for its exception
37
+ # @return [Boolean]
38
+ # @raise [ArgumentError] when no block is given
39
+ # @raise [NameError] when the name is not defined
40
+ # @raise [TypeError] when the name does not resolve to an Exception class
34
41
  def match?
35
42
  raise ::ArgumentError, "a block must be provided" unless block_given?
36
43
 
44
+ # Resolved before the block, so that a NameError from the name is not
45
+ # mistaken for an exception raised by the block.
37
46
  klass = expected_class
38
- raise ::ArgumentError, "expected exception class must inherit from Exception" unless klass <= ::Exception
39
47
 
40
48
  begin
41
49
  yield
42
50
  false
43
- rescue Exception => e # rubocop:disable Lint/RescueException
44
- e.class <= klass # Checks if the class of the thrown exception is klass or one of its subclasses
51
+ rescue ::Exception => e # rubocop:disable Lint/RescueException
52
+ klass === e && message?(e)
45
53
  end
46
54
  end
47
55
 
48
- # Returns a string representing the matcher.
49
- #
50
- # @return [String] a human-readable description of the matcher
56
+ # @return [String]
51
57
  def to_s
52
- "raise exception #{@expected}"
58
+ return "raise exception #{Constant.label(@expected)}" if @message.nil?
59
+
60
+ "raise exception #{Constant.label(@expected)} with message #{@message.inspect}"
53
61
  end
54
62
 
55
63
  private
56
64
 
57
- # Resolves the expected class name to an actual Class object.
58
- # This method handles both string and symbol class names through constant resolution.
59
- #
60
- # @return [Class] the resolved class
61
- # @raise [NameError] if the class doesn't exist
65
+ def validate_class!
66
+ return unless ::Class === @expected
67
+ return if @expected <= ::Exception
68
+
69
+ raise ::ArgumentError, "expected must inherit from Exception (got: #{@expected.inspect})"
70
+ end
71
+
72
+ def validate_message!
73
+ return if @message.nil? || ::String === @message || ::Regexp === @message
74
+
75
+ raise ::ArgumentError, "message must be a String or a Regexp (got: #{@message.inspect})"
76
+ end
77
+
62
78
  def expected_class
63
- ::Object.const_get(@expected)
79
+ return @expected if ::Class === @expected
80
+
81
+ klass = Constant.resolve(@expected, ::Class)
82
+ raise ::TypeError, "#{@expected} is not an Exception class" unless klass <= ::Exception
83
+
84
+ klass
85
+ end
86
+
87
+ # The message is whatever the exception answers to `message`, which is
88
+ # its own data, as with any exception; it must be a valid String. The
89
+ # expected String or Regexp receives the comparison.
90
+ def message?(exception)
91
+ return true if @message.nil?
92
+
93
+ message = exception.message
94
+ return false unless ::String === message && message.valid_encoding?
95
+
96
+ ::String === @message ? @message.eql?(message) : @message.match?(message)
97
+ rescue ::Encoding::CompatibilityError
98
+ false
64
99
  end
65
100
  end
66
101
  end
@@ -1,43 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "constant"
4
+
3
5
  module Matchi
4
- # *Satisfy* matcher.
6
+ # Custom matcher: passes when the given block, called with the actual value,
7
+ # returns `true`. The block must return `true` or `false`; any other value
8
+ # is an error, not a truthy pass.
9
+ #
10
+ # @example
11
+ # Matchi::Satisfy.new { |value| value > 41 }.match? { 42 } # => true
5
12
  class Satisfy
6
- # Initialize the matcher with a block.
7
- #
8
- # @example
9
- # require "matchi/satisfy"
10
- #
11
- # Matchi::Satisfy.new { |value| value == 42 }
12
- #
13
- # @param block [Proc] A block of code.
13
+ # @yieldparam actual [Object] the actual value
14
+ # @yieldreturn [Boolean]
15
+ # @raise [ArgumentError] when no block is given
14
16
  def initialize(&block)
15
17
  raise ::ArgumentError, "a block must be provided" unless block_given?
16
18
 
17
- @expected = block
19
+ @predicate = block
18
20
  end
19
21
 
20
- # Boolean comparison between the actual value and the expected value.
21
- #
22
- # @example
23
- # require "matchi/satisfy"
24
- #
25
- # matcher = Matchi::Satisfy.new { |value| value == 42 }
26
- # matcher.match? { 42 } # => true
27
- #
28
- # @yieldreturn [#object_id] The actual value to compare to the expected
29
- # one.
30
- #
31
- # @return [Boolean] Comparison between actual and expected values.
22
+ # @yieldreturn [Object] the actual value
23
+ # @return [Boolean]
24
+ # @raise [ArgumentError] when no block is given
25
+ # @raise [TypeError] when the predicate returns something other than true or false
32
26
  def match?
33
27
  raise ::ArgumentError, "a block must be provided" unless block_given?
34
28
 
35
- @expected.call(yield)
29
+ result = @predicate.call(yield)
30
+ return result if true.equal?(result) || false.equal?(result)
31
+
32
+ klass = ::Kernel.instance_method(:class).bind_call(result)
33
+ raise ::TypeError, "the block must return true or false (got a #{Constant.label(klass)})"
36
34
  end
37
35
 
38
- # Returns a string representing the matcher.
39
- #
40
- # @return [String] a human-readable description of the matcher
36
+ # @return [String]
41
37
  def to_s
42
38
  "satisfy &block"
43
39
  end
data/lib/matchi.rb CHANGED
@@ -1,311 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # A collection of damn simple expectation matchers.
4
- #
5
- # The Matchi module provides two ways to create and use matchers:
6
- #
7
- # 1. Direct instantiation through classes:
8
- # ```ruby
9
- # matcher = Matchi::Eq.new("foo")
10
- # matcher.match? { "foo" } # => true
11
- # ```
12
- #
13
- # 2. Helper methods by including/extending the Matchi module:
14
- # ```ruby
15
- # # Via include in a class
16
- # class MyTestFramework
17
- # include Matchi
18
- #
19
- # def test_something
20
- # # Helpers are now available as instance methods
21
- # matcher = eq("foo")
22
- # matcher.match? { "foo" } # => true
23
- #
24
- # # Multiple helpers can be chained
25
- # change(@array, :length).by(1).match? { @array << 1 }
26
- # end
27
- # end
28
- #
29
- # # Or via extend for direct usage
30
- # class MyOtherFramework
31
- # extend Matchi
32
- #
33
- # def self.assert_equals(expected, actual)
34
- # eq(expected).match? { actual }
35
- # end
36
- # end
37
- # ```
38
- #
39
- # The helper method approach provides a more readable and fluent interface,
40
- # making tests easier to write and understand. It's particularly useful when
41
- # integrating with testing frameworks or creating custom assertions.
42
- #
43
- # Available helpers include:
44
- # - eq/eql : Equivalence matching
45
- # - be/equal : Identity matching
46
- # - be_within : Delta comparison
47
- # - match : Regular expression matching
48
- # - change : State change verification
49
- # - be_true/be_false/be_nil : State verification
50
- # - be_an_instance_of : Exact type matching
51
- # - be_a_kind_of : Type hierarchy matching
52
- # - satisfy : Custom block-based matching
53
- # - be_* & have_* : Dynamic predicate matching
54
- #
55
- # @api public
3
+ # Framework-agnostic matchers for Ruby.
4
+ #
5
+ # A matcher is any object that responds to `match?` with a block and to
6
+ # `to_s`. The block is evaluated once and yields the actual value, or, for
7
+ # `Change` and `RaiseException`, produces the effect to observe; the matcher
8
+ # returns `true` or `false`. The expected value controls the comparison
9
+ # wherever a comparison takes place, so a hostile actual value cannot forge
10
+ # a match; `Predicate` and the `Change` refinements by delta, which use
11
+ # values produced by the object under test, depend on its honesty.
12
+ #
13
+ # @example
14
+ # Matchi::Eq.new(42).match? { 42 } # => true
56
15
  module Matchi
57
- # Equivalence matcher
58
- #
59
- # @example
60
- # matcher = eq("foo")
61
- # matcher.match? { "foo" } # => true
62
- # matcher.match? { "bar" } # => false
63
- #
64
- # @param expected [#eql?] An expected equivalent object.
65
- #
66
- # @return [#match?] An equivalence matcher.
67
- #
68
- # @api public
69
- def eq(expected)
70
- ::Matchi::Eq.new(expected)
71
- end
72
-
73
- alias eql eq
74
-
75
- # Identity matcher
76
- #
77
- # @example
78
- # object = "foo"
79
- # matcher = be(object)
80
- # matcher.match? { object } # => true
81
- # matcher.match? { "foo" } # => false
82
- #
83
- # @param expected [#equal?] The expected identical object.
84
- #
85
- # @return [#match?] An identity matcher.
86
- #
87
- # @api public
88
- def be(expected)
89
- ::Matchi::Be.new(expected)
90
- end
91
-
92
- alias equal be
93
-
94
- # Comparisons matcher
95
- #
96
- # @example
97
- # matcher = be_within(1).of(41)
98
- # matcher.match? { 42 } # => true
99
- # matcher.match? { 43 } # => false
100
- #
101
- # @param delta [Numeric] A numeric value.
102
- #
103
- # @return [#match?] A comparison matcher.
104
- #
105
- # @api public
106
- def be_within(delta)
107
- ::Matchi::BeWithin.new(delta)
108
- end
109
-
110
- # Regular expressions matcher
111
- #
112
- # @example
113
- # matcher = match(/^foo$/)
114
- # matcher.match? { "foo" } # => true
115
- # matcher.match? { "bar" } # => false
116
- #
117
- # @param expected [#match] A regular expression.
118
- #
119
- # @return [#match?] A regular expression matcher.
120
- #
121
- # @api public
122
- def match(expected)
123
- ::Matchi::Match.new(expected)
124
- end
125
-
126
- # Expecting errors matcher
127
- #
128
- # @example
129
- # matcher = raise_exception(NameError)
130
- # matcher.match? { Boom } # => true
131
- # matcher.match? { true } # => false
132
- #
133
- # @param expected [Exception, #to_s] The expected exception name.
134
- #
135
- # @return [#match?] An error matcher.
136
- #
137
- # @api public
138
- def raise_exception(expected)
139
- ::Matchi::RaiseException.new(expected)
140
- end
141
-
142
- # True matcher
143
- #
144
- # @example
145
- # matcher = be_true
146
- # matcher.match? { true } # => true
147
- # matcher.match? { false } # => false
148
- # matcher.match? { nil } # => false
149
- # matcher.match? { 4 } # => false
150
- #
151
- # @return [#match?] A `true` matcher.
152
- #
153
- # @api public
154
- def be_true
155
- be(true)
156
- end
157
-
158
- # False matcher
159
- #
160
- # @example
161
- # matcher = be_false
162
- # matcher.match? { false } # => true
163
- # matcher.match? { true } # => false
164
- # matcher.match? { nil } # => false
165
- # matcher.match? { 4 } # => false
166
- #
167
- # @return [#match?] A `false` matcher.
168
- #
169
- # @api public
170
- def be_false
171
- be(false)
172
- end
173
-
174
- # Nil matcher
175
- #
176
- # @example
177
- # matcher = be_nil
178
- # matcher.match? { nil } # => true
179
- # matcher.match? { false } # => false
180
- # matcher.match? { true } # => false
181
- # matcher.match? { 4 } # => false
182
- #
183
- # @return [#match?] A `nil` matcher.
184
- #
185
- # @api public
186
- def be_nil
187
- be(nil)
188
- end
189
-
190
- # Type/class matcher
191
- #
192
- # Verifies exact class matching (no inheritance).
193
- #
194
- # @example
195
- # matcher = be_an_instance_of(String)
196
- # matcher.match? { "foo" } # => true
197
- # matcher.match? { 4 } # => false
198
- #
199
- # @param expected [Class, #to_s] The expected class name.
200
- #
201
- # @return [#match?] A type/class matcher.
202
- #
203
- # @api public
204
- def be_an_instance_of(expected)
205
- ::Matchi::BeAnInstanceOf.new(expected)
206
- end
207
-
208
- # Type/class matcher
209
- #
210
- # Verifies class inheritance and module inclusion.
211
- #
212
- # @example
213
- # matcher = be_a_kind_of(Numeric)
214
- # matcher.match? { 42 } # => true (Integer inherits from Numeric)
215
- # matcher.match? { 42.0 } # => true (Float inherits from Numeric)
216
- #
217
- # @param expected [Class, #to_s] The expected class name.
218
- #
219
- # @return [#match?] A type/class matcher.
220
- #
221
- # @api public
222
- def be_a_kind_of(expected)
223
- ::Matchi::BeAKindOf.new(expected)
224
- end
225
-
226
- # Change matcher
227
- #
228
- # @example
229
- # object = []
230
- # matcher = change(object, :length).by(1)
231
- # matcher.match? { object << 1 } # => true
232
- #
233
- # object = []
234
- # matcher = change(object, :length).by_at_least(1)
235
- # matcher.match? { object << 1 } # => true
236
- #
237
- # object = []
238
- # matcher = change(object, :length).by_at_most(1)
239
- # matcher.match? { object << 1 } # => true
240
- #
241
- # object = "foo"
242
- # matcher = change(object, :to_s).from("foo").to("FOO")
243
- # matcher.match? { object.upcase! } # => true
244
- #
245
- # object = "foo"
246
- # matcher = change(object, :to_s).to("FOO")
247
- # matcher.match? { object.upcase! } # => true
248
- #
249
- # @param object [#object_id] An object.
250
- # @param method [Symbol] The name of a method.
251
- #
252
- # @return [#match?] A change matcher.
253
- #
254
- # @api public
255
- def change(object, method, ...)
256
- ::Matchi::Change.new(object, method, ...)
257
- end
258
-
259
- # Satisfy matcher
260
- #
261
- # @example
262
- # matcher = satisfy { |value| value == 42 }
263
- # matcher.match? { 42 } # => true
264
- #
265
- # @yield [value] A block that defines the satisfaction criteria
266
- # @yieldparam value The value to test
267
- # @yieldreturn [Boolean] true if the value satisfies the criteria
268
- #
269
- # @return [#match?] A satisfy matcher.
270
- #
271
- # @api public
272
- def satisfy(&)
273
- ::Matchi::Satisfy.new(&)
274
- end
275
-
276
- private
277
-
278
- # Predicate matcher, or default method missing behavior.
279
- #
280
- # @example Empty predicate matcher
281
- # matcher = be_empty
282
- # matcher.match? { [] } # => true
283
- # matcher.match? { [4] } # => false
284
- def method_missing(name, ...)
285
- return super unless predicate_matcher_name?(name)
286
-
287
- ::Matchi::Predicate.new(name, ...)
288
- end
289
-
290
- # :nocov:
291
-
292
- # Hook method to return whether the obj can respond to id method or not.
293
- def respond_to_missing?(name, include_private = false)
294
- predicate_matcher_name?(name) || super
295
- end
296
-
297
- # :nocov:
298
-
299
- # Predicate matcher name detector.
300
- #
301
- # @param name [Array, Symbol] The name of a potential predicate matcher.
302
- #
303
- # @return [Boolean] Indicates if it is a predicate matcher name or not.
304
- def predicate_matcher_name?(name)
305
- name.start_with?("be_", "have_") && !name.end_with?("!", "?")
306
- end
307
16
  end
308
17
 
309
- Dir[File.join(File.dirname(__FILE__), "matchi", "*.rb")].each do |fname|
310
- require_relative fname
311
- end
18
+ require_relative "matchi/be"
19
+ require_relative "matchi/be_a_kind_of"
20
+ require_relative "matchi/be_an_instance_of"
21
+ require_relative "matchi/be_within"
22
+ require_relative "matchi/change"
23
+ require_relative "matchi/eq"
24
+ require_relative "matchi/match"
25
+ require_relative "matchi/predicate"
26
+ require_relative "matchi/raise_exception"
27
+ require_relative "matchi/satisfy"
metadata CHANGED
@@ -1,16 +1,20 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: matchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.1.1
4
+ version: 5.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cyril Kato
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-12-31 00:00:00.000000000 Z
11
+ date: 2026-10-18 00:00:00.000000000 Z
12
12
  dependencies: []
13
- description: "Collection of expectation matchers for Rubyists \U0001F939"
13
+ description: 'Matchi provides small, explicit expectation matchers for any Ruby testing
14
+ framework. The expected value always controls the comparison, so the object under
15
+ test cannot forge a match.
16
+
17
+ '
14
18
  email: contact@cyril.email
15
19
  executables: []
16
20
  extensions: []
@@ -25,22 +29,25 @@ files:
25
29
  - lib/matchi/be_within.rb
26
30
  - lib/matchi/be_within/of.rb
27
31
  - lib/matchi/change.rb
28
- - lib/matchi/change/by.rb
29
- - lib/matchi/change/by_at_least.rb
30
- - lib/matchi/change/by_at_most.rb
32
+ - lib/matchi/change/delta.rb
31
33
  - lib/matchi/change/from.rb
32
- - lib/matchi/change/from/to.rb
34
+ - lib/matchi/change/from_to.rb
33
35
  - lib/matchi/change/to.rb
36
+ - lib/matchi/constant.rb
34
37
  - lib/matchi/eq.rb
35
38
  - lib/matchi/match.rb
39
+ - lib/matchi/number.rb
36
40
  - lib/matchi/predicate.rb
37
41
  - lib/matchi/raise_exception.rb
38
42
  - lib/matchi/satisfy.rb
39
- homepage: https://github.com/fixrb/matchi
43
+ homepage: https://fixrb.dev/gems/matchi/
40
44
  licenses:
41
45
  - MIT
42
46
  metadata:
47
+ bug_tracker_uri: https://github.com/fixrb/matchi/issues
48
+ documentation_uri: https://rubydoc.info/gems/matchi
43
49
  rubygems_mfa_required: 'true'
50
+ source_code_uri: https://github.com/fixrb/matchi
44
51
  post_install_message:
45
52
  rdoc_options: []
46
53
  require_paths:
@@ -49,15 +56,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
49
56
  requirements:
50
57
  - - ">="
51
58
  - !ruby/object:Gem::Version
52
- version: 3.1.0
59
+ version: 3.3.0
53
60
  required_rubygems_version: !ruby/object:Gem::Requirement
54
61
  requirements:
55
62
  - - ">="
56
63
  - !ruby/object:Gem::Version
57
64
  version: '0'
58
65
  requirements: []
59
- rubygems_version: 3.3.27
66
+ rubygems_version: 3.5.22
60
67
  signing_key:
61
68
  specification_version: 4
62
- summary: "Collection of expectation matchers for Rubyists \U0001F939"
69
+ summary: Framework-agnostic matchers for secure Ruby testing
63
70
  test_files: []
@@ -1,60 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Matchi
4
- class Change
5
- # *Change by* matcher.
6
- class By
7
- # Initialize the matcher with an object and a block.
8
- #
9
- # @example
10
- # require "matchi/change/by"
11
- #
12
- # object = []
13
- #
14
- # Matchi::Change::By.new(1) { object.length }
15
- #
16
- # @param expected [#object_id] An expected delta.
17
- # @param state [Proc] A block of code to execute to get the
18
- # state of the object.
19
- def initialize(expected, &state)
20
- raise ::ArgumentError, "expected must be a Numeric" unless expected.is_a?(::Numeric)
21
- raise ::ArgumentError, "a block must be provided" unless block_given?
22
-
23
- @expected = expected
24
- @state = state
25
- end
26
-
27
- # Boolean comparison on the expected change by comparing the value
28
- # before and after the code execution.
29
- #
30
- # @example
31
- # require "matchi/change/by"
32
- #
33
- # object = []
34
- #
35
- # matcher = Matchi::Change::By.new(1) { object.length }
36
- # matcher.match? { object << "foo" } # => true
37
- #
38
- # @yieldreturn [#object_id] The block of code to execute.
39
- #
40
- # @return [Boolean] Comparison between the value before and after the
41
- # code execution.
42
- def match?
43
- raise ::ArgumentError, "a block must be provided" unless block_given?
44
-
45
- value_before = @state.call
46
- yield
47
- value_after = @state.call
48
-
49
- @expected == (value_after - value_before)
50
- end
51
-
52
- # Returns a string representing the matcher.
53
- #
54
- # @return [String] a human-readable description of the matcher
55
- def to_s
56
- "change by #{@expected.inspect}"
57
- end
58
- end
59
- end
60
- end