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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4b3579155f166f952bb90f1a6ea79c7fd99d7ec17c81782e36d0a7d79ed58596
4
- data.tar.gz: acfda4f687384be25a015fa71a709c7101f69dbb7a0246d2c994eecadc959f11
3
+ metadata.gz: 66ca7f8e6eb1fb3e7fa80907957f37af41fd04b678258199c0f1abfe784aa839
4
+ data.tar.gz: b25bc8c4da4bb7db33478cf7fda77e7119bf0fce94fdfd8310ab8d7c79f8638c
5
5
  SHA512:
6
- metadata.gz: 11593bee0850470dda340821820aba5cdf82b99752f01d4de54de0849ca935a520d1d44500abd1d4415809ea36b6a15b4603fd84d198797b4ab81e9af0185551
7
- data.tar.gz: 8bb333121816652c5b3ca015df89edf962824453274fb89be666f21f313921de65d16b7901a52250076b66c47fe393e9335c31024c90a6465678ff537bf3846f
6
+ metadata.gz: 5c4b77eb03979138e162a7040380b9b74c31b16e1995e67e537eebd3d83074913fa39d377f29759e085f35a143a15f19407f77dcd6b2788fc5e2874b28f3591f
7
+ data.tar.gz: e570be50f7b9338a24489b791b0b118b0c892fcc6c8fdc7f375b6187cf63a430642d6a87c7b7016f28b9b6742e7a8c4c07cbcf94bf340a9d590dfbeb49ef0e87
data/LICENSE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2015-2025 Cyril Kato
3
+ Copyright (c) 2015-2026 Cyril Kato
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -2,365 +2,158 @@
2
2
 
3
3
  [![Version](https://img.shields.io/github/v/tag/fixrb/matchi?label=Version&logo=github)](https://github.com/fixrb/matchi/tags)
4
4
  [![Yard documentation](https://img.shields.io/badge/Yard-documentation-blue.svg?logo=github)](https://rubydoc.info/github/fixrb/matchi/main)
5
- [![Ruby](https://github.com/fixrb/matchi/workflows/Ruby/badge.svg?branch=main)](https://github.com/fixrb/matchi/actions?query=workflow%3Aruby+branch%3Amain)
6
- [![RuboCop](https://github.com/fixrb/matchi/workflows/RuboCop/badge.svg?branch=main)](https://github.com/fixrb/matchi/actions?query=workflow%3Arubocop+branch%3Amain)
7
5
  [![License](https://img.shields.io/github/license/fixrb/matchi?label=License&logo=github)](https://github.com/fixrb/matchi/raw/main/LICENSE.md)
8
6
 
9
- This library provides a comprehensive set of matchers for testing different aspects of your code.
10
- Each matcher is designed to handle specific verification needs while maintaining a clear and expressive syntax.
7
+ Framework-agnostic matchers for Ruby. Small, explicit, and written so that the object under test cannot forge a match.
11
8
 
12
9
  ![A Rubyist juggling between Matchi letters](https://github.com/fixrb/matchi/raw/main/img/matchi.png)
13
10
 
14
- ## Project goals
15
-
16
- * Adding matchers should be as simple as possible.
17
- * Being framework agnostic and easy to integrate.
18
- * Avoid false positives/negatives due to malicious actual values.
19
-
20
11
  ## Installation
21
12
 
22
- Add this line to your application's Gemfile:
23
-
24
13
  ```ruby
14
+ # Gemfile
25
15
  gem "matchi"
26
16
  ```
27
17
 
28
- And then execute:
29
-
30
- ```sh
31
- bundle install
32
- ```
33
-
34
- Or install it yourself as:
35
-
36
- ```sh
37
- gem install matchi
38
- ```
39
-
40
- ## Overview
41
-
42
- __Matchi__ provides a collection of damn simple expectation matchers.
18
+ Matchi requires Ruby 3.3 or later and has no runtime dependency.
43
19
 
44
20
  ## Usage
45
21
 
46
- To make __Matchi__ available:
22
+ A matcher is built with its expected value, then asked whether an actual value matches. The block yields the actual value once; `match?` answers `true` or `false`.
47
23
 
48
24
  ```ruby
49
25
  require "matchi"
50
- ```
51
-
52
- All examples here assume that this has been done.
53
-
54
- ### Anatomy of a matcher
55
-
56
- A **Matchi** matcher is a simple Ruby object that follows these requirements:
57
26
 
58
- 1. It must implement a `match?` method that:
59
- - Accepts a block as its only parameter
60
- - Executes that block to get the actual value
61
- - Returns a boolean indicating if the actual value matches the expected criteria
62
-
63
- 2. Optionally, it may implement:
64
- - `to_s`: Returns a human-readable description of the match criteria
65
-
66
- ### Using Matchers
67
-
68
- There are two main ways to use Matchi matchers:
69
-
70
- #### 1. Direct Class Instantiation
71
-
72
- You can create matchers directly from their classes:
73
-
74
- ```ruby
75
- matcher = Matchi::Eq.new("foo")
76
- matcher.match? { "foo" } # => true
77
-
78
- matcher = Matchi::BeWithin.new(0.5).of(3.0)
79
- matcher.match? { 3.2 } # => true
27
+ matcher = Matchi::Eq.new(42)
28
+ matcher.match? { 42 } # => true
29
+ matcher.match? { 41 } # => false
30
+ matcher.to_s # => "eq 42"
80
31
  ```
81
32
 
82
- #### 2. Helper Methods via Module Inclusion
83
-
84
- For a more expressive and readable syntax, you can include or extend the `Matchi` module to get access to helper methods:
33
+ Every `to_s` completes the sentence "expected ACTUAL to …", which is all a test framework needs:
85
34
 
86
35
  ```ruby
87
- # Including in a class for instance methods
88
- class MyTestFramework
89
- include Matchi
90
-
91
- def test_equality
92
- # Helper methods available as instance methods
93
- matcher = eq("foo")
94
- assert(matcher.match? { "foo" })
95
-
96
- # Method chaining works too
97
- assert(change(@array, :length).by(1).match? { @array << 1 })
98
- end
36
+ # Minitest
37
+ def assert_matches(matcher, actual)
38
+ assert matcher.match? { actual }, "expected #{actual.inspect} to #{matcher}"
99
39
  end
100
40
 
101
- # Extending a class for class methods
102
- class MyAssertions
103
- extend Matchi
104
-
105
- def self.assert_equals(expected, actual)
106
- eq(expected).match? { actual }
107
- end
108
-
109
- def self.assert_within_range(expected, delta, actual)
110
- be_within(delta).of(expected).match? { actual }
111
- end
41
+ def refute_matches(matcher, actual)
42
+ refute matcher.match? { actual }, "expected #{actual.inspect} not to #{matcher}"
112
43
  end
113
- ```
114
-
115
- Available helper methods correspond to the built-in matchers:
116
- - `eq` / `eql` - For equivalence matching
117
- - `be` / `equal` - For identity matching
118
- - `be_within` - For delta comparisons
119
- - `match` - For regular expression matching
120
- - `change` - For state changes
121
- - `be_true`, `be_false`, `be_nil` - For state verification
122
- - `be_an_instance_of` - For exact type matching
123
- - `be_a_kind_of` - For type hierarchy matching
124
- - `satisfy` - For custom block-based matching
125
- - Dynamic predicate matchers (`be_*` and `have_*`)
126
-
127
- ### Built-in matchers
128
-
129
- Here is the collection of generic matchers.
130
-
131
- #### Basic Comparison Matchers
132
-
133
- ##### `Be`
134
- Checks for object identity using Ruby's `equal?` method.
135
- ```ruby
136
- Matchi::Be.new(:foo).match? { :foo } # => true (same object)
137
- Matchi::Be.new("test").match? { "test" } # => false (different objects)
138
- ```
139
-
140
- ##### `Eq`
141
- Verifies object equivalence using Ruby's `eql?` method.
142
- ```ruby
143
- Matchi::Eq.new("foo").match? { "foo" } # => true (equivalent content)
144
- Matchi::Eq.new([1, 2]).match? { [1, 2] } # => true (equivalent arrays)
145
- ```
146
44
 
147
- #### Type and Class Matchers
148
-
149
- ##### `BeAnInstanceOf`
150
- Verifies exact class matching (no inheritance).
151
- ```ruby
152
- Matchi::BeAnInstanceOf.new(String).match? { "test" } # => true
153
- Matchi::BeAnInstanceOf.new(Integer).match? { 42 } # => true
154
- Matchi::BeAnInstanceOf.new(Numeric).match? { 42 } # => false (Integer, not Numeric)
45
+ assert_matches Matchi::BeAKindOf.new(Numeric), 42
46
+ refute_matches Matchi::Match.new(/^foo/), "bar"
155
47
  ```
156
48
 
157
- ##### `BeAKindOf`
158
- Verifies class inheritance and module inclusion.
159
- ```ruby
160
- Matchi::BeAKindOf.new(Numeric).match? { 42 } # => true (Integer inherits from Numeric)
161
- Matchi::BeAKindOf.new(Numeric).match? { 42.0 } # => true (Float inherits from Numeric)
162
- ```
49
+ Negation is the framework's job: Matchi has no `not_to`, it answers a boolean.
163
50
 
164
- #### Pattern Matchers
51
+ ## Matchers
165
52
 
166
- ##### `Match`
167
- Tests string patterns against regular expressions.
168
53
  ```ruby
169
- Matchi::Match.new(/^foo/).match? { "foobar" } # => true
170
- Matchi::Match.new(/\d+/).match? { "abc123" } # => true
171
- Matchi::Match.new(/^foo/).match? { "barfoo" } # => false
172
- ```
173
-
174
- ##### `Satisfy`
175
- Provides custom matching through a block.
176
- ```ruby
177
- Matchi::Satisfy.new { |x| x.positive? && x < 10 }.match? { 5 } # => true
178
- Matchi::Satisfy.new { |x| x.start_with?("test") }.match? { "test_file" } # => true
179
- ```
180
-
181
- #### State Change Matchers
182
-
183
- ##### `Change`
184
- Verifies state changes in objects with multiple variation methods:
185
-
186
- ###### Basic Change
187
- ```ruby
188
- array = []
189
- Matchi::Change.new(array, :length).by(2).match? { array.push(1, 2) } # => true
190
- ```
191
-
192
- ###### Minimum Change
193
- ```ruby
194
- counter = 0
195
- Matchi::Change.new(counter, :to_i).by_at_least(2).match? { counter += 3 } # => true
196
- ```
197
-
198
- ###### Maximum Change
199
- ```ruby
200
- value = 10
201
- Matchi::Change.new(value, :to_i).by_at_most(5).match? { value += 3 } # => true
202
- ```
203
-
204
- ###### From-To Change
205
- ```ruby
206
- string = "hello"
207
- Matchi::Change.new(string, :upcase).from("hello").to("HELLO").match? { string.upcase! } # => true
208
- ```
209
-
210
- ###### To-Only Change
211
- ```ruby
212
- number = 1
213
- Matchi::Change.new(number, :to_i).to(5).match? { number = 5 } # => true
214
- ```
54
+ require "matchi"
215
55
 
216
- #### Numeric Matchers
56
+ # Identity (equal?) and equivalence (eql?)
57
+ Matchi::Be.new(:foo).match? { :foo } # => true
58
+ Matchi::Eq.new("foo").match? { "foo" } # => true
59
+ Matchi::Eq.new(1).match? { 1.0 } # => false
217
60
 
218
- ##### `BeWithin`
219
- Checks if a number is within a specified range of an expected value.
220
- ```ruby
221
- Matchi::BeWithin.new(0.5).of(3.0).match? { 3.2 } # => true
222
- Matchi::BeWithin.new(5).of(100).match? { 98 } # => true
223
- ```
61
+ # Types: a module, or its name resolved when the matcher runs
62
+ Matchi::BeAKindOf.new(Numeric).match? { 42 } # => true
63
+ Matchi::BeAnInstanceOf.new(:Integer).match? { 42 } # => true
64
+ Matchi::BeAnInstanceOf.new(Numeric).match? { 42 } # => false
224
65
 
225
- #### Behavior Matchers
66
+ # Numeric proximity; a non-Numeric actual value fails
67
+ Matchi::BeWithin.new(0.01).of(0.3).match? { 0.1 + 0.2 } # => true
226
68
 
227
- ##### `RaiseException`
228
- Verifies that code raises specific exceptions.
229
- ```ruby
230
- Matchi::RaiseException.new(ArgumentError).match? { raise ArgumentError } # => true
231
- Matchi::RaiseException.new(NameError).match? { undefined_variable } # => true
232
- ```
69
+ # Patterns and predicates
70
+ Matchi::Match.new(/^foo/).match? { "foobar" } # => true
71
+ Matchi::Satisfy.new { |v| v > 41 }.match? { 42 } # => true
72
+ Matchi::Predicate.new(:be_empty).match? { [] } # => true (calls empty?)
73
+ Matchi::Predicate.new(:have_key, :a).match? { { a: 1 } } # => true (calls has_key?(:a))
233
74
 
234
- ##### `Predicate`
235
- Creates matchers for methods ending in `?`.
75
+ # Exceptions, by class or by name, optionally with a message (String or Regexp)
76
+ Matchi::RaiseException.new(ZeroDivisionError).match? { 1 / 0 } # => true
77
+ Matchi::RaiseException.new("Errno::ENOENT").match? { File.read("/missing") } # => true
78
+ Matchi::RaiseException.new(ZeroDivisionError, /by 0/).match? { 1 / 0 } # => true
236
79
 
237
- ###### Using `be_` prefix
238
- ```ruby
239
- Matchi::Predicate.new(:be_empty).match? { [] } # => true (calls empty?)
240
- Matchi::Predicate.new(:be_nil).match? { nil } # => true (calls nil?)
241
- ```
80
+ # State changes, observed through a public method
81
+ list = []
82
+ Matchi::Change.new(list, :length).match? { list << 1 } # => true
83
+ Matchi::Change.new(list, :length).by(2).match? { list.push(1, 2) } # => true
84
+ Matchi::Change.new(list, :length).by_at_least(1).match? { list << 1 } # => true
85
+ Matchi::Change.new(list, :length).by_at_most(1).match? { list << 1 } # => true
242
86
 
243
- ###### Using `have_` prefix
244
- ```ruby
245
- Matchi::Predicate.new(:have_key, :foo).match? { { foo: 42 } } # => true (calls has_key?)
87
+ list = []
88
+ Matchi::Change.new(list, :length).to(1).match? { list << 1 } # => true
89
+ Matchi::Change.new(list, :length).from(1).to(2).match? { list << 1 } # => true
246
90
  ```
247
91
 
248
- ### Custom matchers
249
-
250
- Custom matchers could easily be added to `Matchi` module to express more specific expectations.
92
+ `BeWithin.new(delta)` and `Change#from(value)` are builders, not matchers: they do not respond to `match?` until `of` or `to` completes them.
251
93
 
252
- A **Be the answer** matcher:
94
+ Arguments are validated when the matcher is built (`ArgumentError`); a name is resolved when the matcher runs (`NameError`, or `TypeError` when the constant is not a module). A block is always required by `match?`. Whatever the actual value or an observed method raises propagates; `RaiseException` catches what its block raises, not what its message filter or the resolution of its name raises.
253
95
 
254
- ```ruby
255
- module Matchi
256
- class BeTheAnswer
257
- def match?
258
- expected.equal?(yield)
259
- end
260
-
261
- private
96
+ `RaiseException` catches every `Exception`, including `SystemExit` and `Interrupt`, so that a specification can assert on them; an unexpected `exit` inside the block is a failure, not the end of the process.
262
97
 
263
- def expected
264
- 42
265
- end
266
- end
267
- end
98
+ `Change` observes a value: when the observed method returns the same unfrozen object twice (an `attr_reader` on a mutated array, say), `match?` raises instead of reporting no change. Observe `items.length`, or use `to`.
268
99
 
269
- matcher = Matchi::BeTheAnswer.new
270
- matcher.match? { 42 } # => true
271
- ```
100
+ ## Writing a matcher
272
101
 
273
- A **Be prime** matcher:
102
+ Any object that responds to `match?` with a block and to `to_s` is a matcher:
274
103
 
275
104
  ```ruby
276
- require "prime"
277
-
278
- module Matchi
279
- class BePrime
280
- def match?
281
- Prime.prime?(yield)
282
- end
105
+ class BePositive
106
+ def match?
107
+ yield.positive?
283
108
  end
284
- end
285
-
286
- matcher = Matchi::BePrime.new
287
109
 
288
- matcher.match? { 42 } # => false
289
- ```
290
-
291
- A **Start with** matcher:
292
-
293
- ```ruby
294
- module Matchi
295
- class StartWith
296
- def initialize(expected)
297
- @expected = expected
298
- end
299
-
300
- def match?
301
- /\A#{@expected}/.match?(yield)
302
- end
110
+ def to_s
111
+ "be positive"
303
112
  end
304
113
  end
305
114
 
306
- matcher = Matchi::StartWith.new("foo")
307
- matcher.match? { "foobar" } # => true
308
- ```
309
-
310
- ## Best Practices
311
-
312
- ### Proper Value Comparison Order
313
-
314
- One of the most critical aspects when implementing matchers is the order of comparison between expected and actual values. Always compare values in this order:
315
-
316
- ```ruby
317
- # GOOD: Expected value controls the comparison
318
- expected_value.eql?(actual_value)
319
-
320
- # BAD: Actual value controls the comparison
321
- actual_value.eql?(expected_value)
115
+ BePositive.new.match? { 42 } # => true
322
116
  ```
323
117
 
324
- #### Why This Matters
118
+ Every matcher in Matchi follows two rules, which are worth keeping in your own:
325
119
 
326
- The order is crucial because the object receiving the comparison method controls how the comparison is performed. When testing, the actual value might come from untrusted or malicious code that could override comparison methods:
120
+ - **The expected value receives the comparison.** `Matchi::Eq.new(a).match? { b }` evaluates `a.eql?(b)`, never `b.eql?(a)`, so an actual value that redefines `eql?` cannot pass; since `Hash#eql?` hands the comparison back to an operand of another class, `Eq` also requires the two values to be of the same class. Classes are compared with `Module#===`, the class of the actual value is read with `Kernel#class` bound to it, and `BeWithin` only subtracts core numbers, for the same reason.
121
+ - **A predicate answers `true` or `false`.** `Satisfy` and `Predicate` raise a `TypeError` on any other value instead of treating it as truthy.
327
122
 
328
- ```ruby
329
- # Example of how comparison can be compromised
330
- class MaliciousString
331
- def eql?(other)
332
- true # Always returns true regardless of actual equality
333
- end
123
+ Two matchers depend on the honesty of the object under test, because they use values it produces: `Predicate` calls one of its methods, and `Change` (with `by`, `by_at_least`, `by_at_most`) compares two values returned by the observed method, including the subtraction between them. So does the message filter of `RaiseException`, which reads the exception's own message. `Change#to` and `from … to` compare with the expected value and do not.
334
124
 
335
- def ==(other)
336
- true # Always returns true regardless of actual equality
337
- end
338
- end
339
-
340
- actual = MaliciousString.new
341
- expected = "expected string"
125
+ A class or an exception given by name (`:Duck`, `"Errno::ENOENT"`) is resolved from `Object` when the matcher runs, so a specification can be written before the class it describes is loaded. An `autoload` or an `Object.const_missing` defined by the code under test takes part in that resolution, so a name does not carry the guarantee a module does. Names are ASCII constant paths; `"::Integer"` is not accepted.
342
126
 
343
- actual.eql?(expected) # => true (incorrect result!)
344
- expected.eql?(actual) # => false (correct result)
345
- ```
127
+ ## Differences from RSpec
346
128
 
347
- This is why Matchi's built-in matchers are implemented with this security consideration in mind. For example, the `Eq` matcher:
129
+ If you come from RSpec or Minitest, these will surprise you:
348
130
 
349
- ```ruby
350
- # Implementation in Matchi::Eq
351
- def match?
352
- @expected.eql?(yield) # Expected value controls the comparison
353
- end
354
- ```
131
+ - `Eq` is RSpec's `eql`, not its `eq`: it compares with `eql?`, so `1` is not `1.0`, and a value object that only defines `==` never matches. Define `eql?` and `hash` on it, or use `Satisfy`. `Integer#==` hands the comparison to the other operand, which is why Matchi does not use it.
132
+ - `Match` takes a `Regexp` only. For a literal substring, `Matchi::Match.new(Regexp.new(Regexp.escape("a.b")))`.
133
+ - `RaiseException` is `raise_error`; it needs an exception class, or its name, and takes an optional message.
134
+ - `Change#to` demands a change: a value already equal to the target does not pass. `Change` without a refinement needs the observed method to return a value, not the mutated object.
135
+ - `Predicate` only calls a method of the actual value: `be_nil` calls `nil?`, but `be_truthy`, `be_falsey` and `be_a` are not predicates and do not exist. Use `Be.new(nil)`, `Satisfy`, `BeAKindOf`.
136
+ - There is no `and`, `or`, `not_to` or `all`: compose in your framework, with plain booleans.
355
137
 
356
- ## Contact
138
+ ## Changes in 5.0
357
139
 
358
- * Home page: https://github.com/fixrb/matchi
359
- * Bugs/issues: https://github.com/fixrb/matchi/issues
140
+ - `BeAKindOf`, `BeAnInstanceOf` and `RaiseException` accept a module directly, so anonymous classes work. A name is still accepted; it must be a constant path (`"Foo::bar"` is rejected at construction) and resolve to a module, or a class for `BeAnInstanceOf` and an exception class for `RaiseException`. `BeAnInstanceOf` rejects a module that is not a class.
141
+ - `RaiseException` compares with `Module#===` and always answers `true` or `false`; `Module#<=` could answer `nil` and let the exception control the comparison. A non-exception class is rejected at construction. An expected message, `String` or `Regexp`, can be given.
142
+ - `Match` accepts a `Regexp` only, and fails on an actual value that is neither a `String` nor a `Symbol`, or whose encoding is invalid or incompatible. With a `String` pattern, the actual value would have become the regular expression.
143
+ - `Eq`, `Change#to` and `from … to` require the actual value to be of the class of the expected value, so that `Hash#eql?` cannot hand the comparison to the object under test.
144
+ - `Satisfy` requires a `true` or `false` answer (`TypeError`), as `Predicate` already did. The error names the class of the answer, never its `inspect`.
145
+ - `Predicate` and `Change` reach the object under test through `Kernel#public_send`: a private or protected predicate is now a `NoMethodError`. `Change` accepts a `String` method name and no longer checks `respond_to?` at construction; a missing method is a `NoMethodError` when the matcher runs.
146
+ - `BeWithin::Of` only subtracts an `Integer`, a `Float`, a `Rational` or a `BigDecimal`; any other actual value fails, including a user-defined `Numeric` that could control the subtraction through `coerce`. Deltas and expected values, there and in `by`, `by_at_least`, `by_at_most`, must be finite real numbers (`NaN`, infinities and `Complex` are rejected).
147
+ - `BeWithin` without `of` and `Change#from` without `to` no longer respond to `match?`.
148
+ - `from … to` compares with `eql?`, like `to` and `Eq` already did; `to` requires a change; both always evaluate the block. `Change` raises a `TypeError` when the observed method returns the same unfrozen object twice: in particular, asserting that an attribute holding an unfrozen object (a `Time`, an `Array`) did not change now raises instead of answering `false`. The three `by` refinements are one class, `Change::Delta`; `Change::From::To` is `Change::FromTo`.
149
+ - Descriptions are stable: `Change#to_s` names the observed class and method (`change Array#length`), an anonymous class reads `an anonymous class`, and no `to_s` calls `inspect` on an object under test.
150
+ - Error messages are rewritten and all read `X must be … (got: …)`; `Predicate::PREFIXES` is gone.
151
+ - The `Matchi` helper module removed in 4.2 is not back: `Matchi` is a namespace. Frameworks define their own helpers.
152
+ - Ruby 3.3 or later.
360
153
 
361
154
  ## Versioning
362
155
 
363
- __Matchi__ follows [Semantic Versioning 2.0](https://semver.org/).
156
+ Matchi follows [Semantic Versioning 2.0](https://semver.org/).
364
157
 
365
158
  ## License
366
159
 
data/lib/matchi/be.rb CHANGED
@@ -1,41 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Matchi
4
- # *Identity* matcher.
4
+ # Identity matcher: passes when the actual value is the very same object as
5
+ # the expected one (`equal?`).
6
+ #
7
+ # @example
8
+ # Matchi::Be.new(:foo).match? { :foo } # => true
9
+ # Matchi::Be.new("a").match? { "a".dup } # => false (another object)
5
10
  class Be
6
- # Initialize the matcher with an object.
7
- #
8
- # @example
9
- # require "matchi/be"
10
- #
11
- # Matchi::Be.new(:foo)
12
- #
13
- # @param expected [#equal?] The expected identical object.
11
+ # @param expected [Object] the expected object
14
12
  def initialize(expected)
15
13
  @expected = expected
16
14
  end
17
15
 
18
- # Boolean comparison between the actual value and the expected value.
19
- #
20
- # @example
21
- # require "matchi/be"
22
- #
23
- # matcher = Matchi::Be.new(:foo)
24
- # matcher.match? { :foo } # => true
25
- #
26
- # @yieldreturn [#object_id] The actual value to compare to the expected
27
- # one.
28
- #
29
- # @return [Boolean] Comparison between actual and expected values.
16
+ # @yieldreturn [Object] the actual value
17
+ # @return [Boolean]
18
+ # @raise [ArgumentError] when no block is given
30
19
  def match?
31
20
  raise ::ArgumentError, "a block must be provided" unless block_given?
32
21
 
33
22
  @expected.equal?(yield)
34
23
  end
35
24
 
36
- # Returns a string representing the matcher.
37
- #
38
- # @return [String] a human-readable description of the matcher
25
+ # @return [String]
39
26
  def to_s
40
27
  "be #{@expected.inspect}"
41
28
  end
@@ -1,81 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "constant"
4
+
3
5
  module Matchi
4
- # *Type/class* matcher for inheritance-aware type checking.
5
- #
6
- # This matcher provides a clear way to check if an object is an instance of a
7
- # specific class or one of its subclasses. It leverages Ruby's native === operator
8
- # which reliably handles class hierarchy relationships.
6
+ # Type matcher: passes when the actual value is an instance of the expected
7
+ # module or of one of its descendants (`Module#===`, so a `BasicObject` is
8
+ # handled and an object redefining `is_a?` cannot lie).
9
9
  #
10
- # @example Basic usage
11
- # require "matchi/be_a_kind_of"
10
+ # The expected module can be given directly, or by name; see
11
+ # {Matchi::Constant}.
12
12
  #
13
- # matcher = Matchi::BeAKindOf.new(Numeric)
14
- # matcher.match? { 42 } # => true
15
- # matcher.match? { 42.0 } # => true
16
- # matcher.match? { "42" } # => false
13
+ # @example
14
+ # Matchi::BeAKindOf.new(Numeric).match? { 42 } # => true
15
+ # Matchi::BeAKindOf.new(:Numeric).match? { "42" } # => false
17
16
  class BeAKindOf
18
- # Initialize the matcher with (the name of) a class or module.
19
- #
20
- # @example
21
- # require "matchi/be_a_kind_of"
22
- #
23
- # Matchi::BeAKindOf.new(String)
24
- # Matchi::BeAKindOf.new("String")
25
- # Matchi::BeAKindOf.new(:String)
26
- #
27
- # @param expected [Class, #to_s] The expected class name
28
- # @raise [ArgumentError] if the class name doesn't start with an uppercase letter
17
+ # @param expected [Module, String, Symbol] the expected module, or its name
18
+ # @raise [ArgumentError] when expected is neither a Module nor a constant name
29
19
  def initialize(expected)
30
- @expected = String(expected)
31
- return if /\A[A-Z]/.match?(@expected)
32
-
33
- raise ::ArgumentError,
34
- "expected must start with an uppercase letter (got: #{@expected})"
20
+ @expected = Constant.normalize(expected, ::Module)
35
21
  end
36
22
 
37
- # Checks if the yielded object is an instance of the expected class
38
- # or one of its subclasses.
39
- #
40
- # This method uses the case equality operator (===) which provides a reliable
41
- # way to check class hierarchy relationships in Ruby. When a class is the
42
- # receiver of ===, it returns true if the argument is an instance of that
43
- # class or one of its subclasses.
44
- #
45
- # @example Class hierarchy check
46
- # class Animal; end
47
- # class Dog < Animal; end
48
- #
49
- # matcher = Matchi::BeAKindOf.new(Animal)
50
- # matcher.match? { Dog.new } # => true
51
- # matcher.match? { Animal.new } # => true
52
- # matcher.match? { Object.new } # => false
53
- #
54
- # @yieldreturn [Object] the actual value to check
55
- # @return [Boolean] true if the object is an instance of the expected class or one of its subclasses
56
- # @raise [ArgumentError] if no block is provided
23
+ # @yieldreturn [Object] the actual value
24
+ # @return [Boolean]
25
+ # @raise [ArgumentError] when no block is given
26
+ # @raise [NameError] when the name is not defined
27
+ # @raise [TypeError] when the name does not resolve to a Module
57
28
  def match?
58
29
  raise ::ArgumentError, "a block must be provided" unless block_given?
59
30
 
60
- expected_class === yield # rubocop:disable Style/CaseEquality
31
+ Constant.resolve(@expected, ::Module) === yield
61
32
  end
62
33
 
63
- # Returns a string representing the matcher.
64
- #
65
- # @return [String] a human-readable description of the matcher
34
+ # @return [String]
66
35
  def to_s
67
- "be a kind of #{@expected}"
68
- end
69
-
70
- private
71
-
72
- # Resolves the expected class name to an actual Class object.
73
- # This method handles both string and symbol class names through constant resolution.
74
- #
75
- # @return [Class] the resolved class
76
- # @raise [NameError] if the class doesn't exist
77
- def expected_class
78
- ::Object.const_get(@expected)
36
+ "be a kind of #{Constant.label(@expected)}"
79
37
  end
80
38
  end
81
39
  end