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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eed23fa6963893d67a2cc6042f1f977f4fb0012013d53775e142bf92e1333a6d
4
- data.tar.gz: 97c4f82fa65c4d46af8babc966005d8a2db81b85b26987cf5fe3444f14ef6a25
3
+ metadata.gz: 66ca7f8e6eb1fb3e7fa80907957f37af41fd04b678258199c0f1abfe784aa839
4
+ data.tar.gz: b25bc8c4da4bb7db33478cf7fda77e7119bf0fce94fdfd8310ab8d7c79f8638c
5
5
  SHA512:
6
- metadata.gz: 271a0262764b04a77af4d149485395a1cf4d617c62e65dc6fab2388ad5873f3092ce08a9c273e836780623122599603a2a365c199f51ba20a2cda0f6a94f4867
7
- data.tar.gz: baa9876cb229024e218d1ee07d4913805f9b64eaa49fab23e76b01d2fdab1f05ba4b952920cb127a3e3fe8c71990a7653d7513828e68faa056632ce0619854de
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
@@ -4,288 +4,152 @@
4
4
  [![Yard documentation](https://img.shields.io/badge/Yard-documentation-blue.svg?logo=github)](https://rubydoc.info/github/fixrb/matchi/main)
5
5
  [![License](https://img.shields.io/github/license/fixrb/matchi?label=License&logo=github)](https://github.com/fixrb/matchi/raw/main/LICENSE.md)
6
6
 
7
- Matchi is a lightweight, framework-agnostic Ruby library that provides a comprehensive set of expectation matchers for elegant and secure testing. Its design focuses on simplicity, security, and extensibility.
7
+ Framework-agnostic matchers for Ruby. Small, explicit, and written so that the object under test cannot forge a match.
8
8
 
9
9
  ![A Rubyist juggling between Matchi letters](https://github.com/fixrb/matchi/raw/main/img/matchi.png)
10
10
 
11
- ## Key Features
12
-
13
- - **Framework Agnostic**: Easily integrate with any Ruby testing framework
14
- - **Security-Focused Design**: Built with robust type checking for most matchers
15
- - **Simple Integration**: Minimal setup required to get started
16
- - **Extensible**: Create custom matchers with just a few lines of code
17
- - **Comprehensive**: Rich set of built-in matchers for common testing scenarios
18
- - **Well Documented**: Extensive documentation with clear examples and implementation details
19
- - **Thread Safe**: Immutable matchers design ensures thread safety in concurrent environments
20
-
21
- ### Security Considerations for Predicate Matchers
22
-
23
- While most Matchi matchers are designed to resist type spoofing, predicate matchers (`Matchi::Predicate`) rely on Ruby's dynamic method dispatch system and can be vulnerable to method overriding:
24
-
25
- ```ruby
26
- # Example of predicate matcher vulnerability:
27
- matcher = Matchi::Predicate.new(:be_empty)
28
- array = []
29
-
30
- # Method overriding can defeat the matcher
31
- def array.empty?
32
- false
33
- end
34
-
35
- matcher.match? { array } # => false (Even though array is empty!)
36
- ```
37
-
38
- This limitation is inherent to Ruby's dynamic nature when working with predicate methods. If your tests require strict security guarantees, consider using direct state verification matchers instead of predicate matchers.
39
-
40
- ## What is a Matchi Matcher?
41
-
42
- A Matchi matcher is a simple Ruby object that follows a specific contract:
43
-
44
- 1. **Core Interface**: Every matcher must implement a `match?` method that:
45
- - Accepts a block as its only parameter
46
- - Executes that block to get the actual value
47
- - Returns a boolean indicating if the actual value matches the expected criteria
48
-
49
- 2. **Optional Description**: Matchers can implement a `to_s` method that returns a human-readable description of the match criteria
50
-
51
- Here's the simplest possible matcher:
52
-
53
- ```ruby
54
- module Matchi
55
- class SimpleEqual
56
- def initialize(expected)
57
- @expected = expected
58
- end
59
-
60
- def match?
61
- raise ArgumentError, "a block must be provided" unless block_given?
62
-
63
- @expected == yield
64
- end
65
-
66
- def to_s
67
- "equal #{@expected.inspect}"
68
- end
69
- end
70
- end
71
-
72
- # Usage:
73
- matcher = Matchi::SimpleEqual.new(42)
74
- matcher.match? { 42 } # => true
75
- matcher.match? { "42" } # => false
76
- matcher.to_s # => "equal 42"
77
- ```
78
-
79
- This design provides several benefits:
80
- - **Lazy Evaluation**: The actual value is only computed when needed via the block
81
- - **Encapsulation**: Each matcher is a self-contained object with clear responsibilities
82
- - **Composability**: Matchers can be easily combined and reused
83
- - **Testability**: The contract is simple and easy to verify
84
-
85
11
  ## Installation
86
12
 
87
- Add to your Gemfile:
88
-
89
13
  ```ruby
14
+ # Gemfile
90
15
  gem "matchi"
91
16
  ```
92
17
 
93
- Or install directly:
18
+ Matchi requires Ruby 3.3 or later and has no runtime dependency.
94
19
 
95
- ```ruby
96
- gem install matchi
97
- ```
20
+ ## Usage
98
21
 
99
- ## Quick Start
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`.
100
23
 
101
24
  ```ruby
102
25
  require "matchi"
103
26
 
104
- # Basic equality matching
105
- Matchi::Eq.new("hello").match? { "hello" } # => true
106
-
107
- # Type checking
108
- Matchi::BeAKindOf.new(Numeric).match? { 42 } # => true
109
- Matchi::BeAKindOf.new(String).match? { 42 } # => false
110
-
111
- # State change verification
112
- array = []
113
- Matchi::Change.new(array, :length).by(2).match? { array.push(1, 2) } # => true
27
+ matcher = Matchi::Eq.new(42)
28
+ matcher.match? { 42 } # => true
29
+ matcher.match? { 41 } # => false
30
+ matcher.to_s # => "eq 42"
114
31
  ```
115
32
 
116
- ## Core Matchers
117
-
118
- ### Value Comparison
33
+ Every `to_s` completes the sentence "expected ACTUAL to …", which is all a test framework needs:
119
34
 
120
35
  ```ruby
121
- # Exact equality (eql?)
122
- Matchi::Eq.new("test").match? { "test" } # => true
123
- Matchi::Eq.new([1, 2, 3]).match? { [1, 2, 3] } # => true
124
-
125
- # Object identity (equal?)
126
- symbol = :test
127
- Matchi::Be.new(symbol).match? { symbol } # => true
128
- string = "test"
129
- Matchi::Be.new(string).match? { string.dup } # => false
130
- ```
131
-
132
- ### Type Checking
133
-
134
- ```ruby
135
- # Inheritance-aware type checking
136
- Matchi::BeAKindOf.new(Numeric).match? { 42.0 } # => true
137
- Matchi::BeAKindOf.new(Integer).match? { 42.0 } # => false
138
-
139
- # Exact type matching
140
- Matchi::BeAnInstanceOf.new(Float).match? { 42.0 } # => true
141
- Matchi::BeAnInstanceOf.new(Numeric).match? { 42.0 } # => false
142
-
143
- # Using class names as strings
144
- Matchi::BeAKindOf.new("Numeric").match? { 42.0 } # => true
145
- Matchi::BeAnInstanceOf.new("Float").match? { 42.0 } # => true
146
- ```
147
-
148
- ### State Changes
149
-
150
- ```ruby
151
- # Verify exact changes
152
- counter = 0
153
- Matchi::Change.new(counter, :to_i).by(5).match? { counter += 5 } # => true
154
-
155
- # Verify minimum changes
156
- Matchi::Change.new(counter, :to_i).by_at_least(2).match? { counter += 3 } # => true
157
-
158
- # Verify maximum changes
159
- Matchi::Change.new(counter, :to_i).by_at_most(5).match? { counter += 3 } # => true
160
-
161
- # Track value transitions
162
- string = "hello"
163
- Matchi::Change.new(string, :to_s).from("hello").to("HELLO").match? { string.upcase! } # => true
36
+ # Minitest
37
+ def assert_matches(matcher, actual)
38
+ assert matcher.match? { actual }, "expected #{actual.inspect} to #{matcher}"
39
+ end
164
40
 
165
- # Simple change detection
166
- array = []
167
- Matchi::Change.new(array, :length).match? { array << 1 } # => true
41
+ def refute_matches(matcher, actual)
42
+ refute matcher.match? { actual }, "expected #{actual.inspect} not to #{matcher}"
43
+ end
168
44
 
169
- # Check final state only
170
- counter = 0
171
- Matchi::Change.new(counter, :to_i).to(5).match? { counter = 5 } # => true
45
+ assert_matches Matchi::BeAKindOf.new(Numeric), 42
46
+ refute_matches Matchi::Match.new(/^foo/), "bar"
172
47
  ```
173
48
 
174
- ### Pattern Matching
175
-
176
- ```ruby
177
- # Regular expressions
178
- Matchi::Match.new(/^test/).match? { "test_string" } # => true
179
- Matchi::Match.new(/^\d{3}-\d{2}$/).match? { "123-45" } # => true
180
-
181
- # Custom predicates with Satisfy
182
- Matchi::Satisfy.new { |x| x.positive? && x < 10 }.match? { 5 } # => true
183
- Matchi::Satisfy.new { |arr| arr.all?(&:even?) }.match? { [2, 4, 6] } # => true
184
-
185
- # Built-in predicates
186
- Matchi::Predicate.new(:be_empty).match? { [] } # => true
187
- Matchi::Predicate.new(:have_key, :name).match? { { name: "Alice" } } # => true
188
- ```
49
+ Negation is the framework's job: Matchi has no `not_to`, it answers a boolean.
189
50
 
190
- ### Exception Handling
51
+ ## Matchers
191
52
 
192
53
  ```ruby
193
- # Verify raised exceptions
194
- Matchi::RaiseException.new(ArgumentError).match? { raise ArgumentError } # => true
54
+ require "matchi"
195
55
 
196
- # Works with inheritance
197
- Matchi::RaiseException.new(StandardError).match? { raise ArgumentError } # => true
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
198
60
 
199
- # Using exception class names
200
- Matchi::RaiseException.new("ArgumentError").match? { raise ArgumentError } # => true
201
- ```
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
202
65
 
203
- ### Numeric Comparisons
66
+ # Numeric proximity; a non-Numeric actual value fails
67
+ Matchi::BeWithin.new(0.01).of(0.3).match? { 0.1 + 0.2 } # => true
204
68
 
205
- ```ruby
206
- # Delta comparisons
207
- Matchi::BeWithin.new(0.5).of(3.0).match? { 3.2 } # => true
208
- Matchi::BeWithin.new(2).of(10).match? { 9 } # => true
209
- ```
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))
210
74
 
211
- ## Creating Custom Matchers
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
212
79
 
213
- Creating custom matchers is straightforward:
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
214
86
 
215
- ```ruby
216
- module Matchi
217
- class BePositive
218
- def match?
219
- yield.positive?
220
- end
221
-
222
- def to_s
223
- "be positive"
224
- end
225
- end
226
- end
227
-
228
- matcher = Matchi::BePositive.new
229
- matcher.match? { 42 } # => true
230
- matcher.match? { -1 } # => false
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
231
90
  ```
232
91
 
233
- ## Security Best Practices
92
+ `BeWithin.new(delta)` and `Change#from(value)` are builders, not matchers: they do not respond to `match?` until `of` or `to` completes them.
234
93
 
235
- ### Proper Value Comparison Order
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.
236
95
 
237
- 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:
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.
238
97
 
239
- ```ruby
240
- # GOOD: Expected value controls the comparison
241
- expected_value.eql?(actual_value)
242
- # BAD: Actual value controls the comparison
243
- actual_value.eql?(expected_value)
244
- ```
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`.
245
99
 
246
- #### Why This Matters
100
+ ## Writing a matcher
247
101
 
248
- 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:
102
+ Any object that responds to `match?` with a block and to `to_s` is a matcher:
249
103
 
250
104
  ```ruby
251
- # Example of how comparison can be compromised
252
- class MaliciousString
253
- def eql?(other)
254
- true # Always returns true regardless of actual equality
105
+ class BePositive
106
+ def match?
107
+ yield.positive?
255
108
  end
256
109
 
257
- def ==(other)
258
- true # Always returns true regardless of actual equality
110
+ def to_s
111
+ "be positive"
259
112
  end
260
113
  end
261
114
 
262
- actual = MaliciousString.new
263
- expected = "expected string"
264
- actual.eql?(expected) # => true (incorrect result!)
265
- expected.eql?(actual) # => false (correct result)
115
+ BePositive.new.match? { 42 } # => true
266
116
  ```
267
117
 
268
- This is why Matchi's built-in matchers are implemented with this security consideration in mind. For example, the `Eq` matcher:
118
+ Every matcher in Matchi follows two rules, which are worth keeping in your own:
269
119
 
270
- ```ruby
271
- # Implementation in Matchi::Eq
272
- def match?
273
- @expected.eql?(yield) # Expected value controls the comparison
274
- end
275
- ```
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.
276
122
 
277
- ## Extensions
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.
278
124
 
279
- ### matchi-fix
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.
280
126
 
281
- The [matchi-fix gem](https://rubygems.org/gems/matchi-fix) extends Matchi with support for testing against [Fix](https://github.com/fixrb/fix) specifications. It provides a seamless integration between Matchi's matcher interface and Fix's powerful specification system.
127
+ ## Differences from RSpec
282
128
 
283
- ```ruby
284
- # Add to your Gemfile
285
- gem "matchi-fix"
286
- ```
129
+ If you come from RSpec or Minitest, these will surprise you:
130
+
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.
137
+
138
+ ## Changes in 5.0
287
139
 
288
- This extension adds a `Fix` matcher that allows you to verify implementation conformance to Fix test specifications across different testing frameworks like Minitest and RSpec.
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.
289
153
 
290
154
  ## Versioning
291
155
 
data/lib/matchi/be.rb CHANGED
@@ -1,83 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Matchi
4
- # Identity matcher that checks if two objects are the exact same instance.
4
+ # Identity matcher: passes when the actual value is the very same object as
5
+ # the expected one (`equal?`).
5
6
  #
6
- # This matcher verifies object identity using Ruby's Object#equal? method, which
7
- # compares object IDs to determine if two references point to the exact same object
8
- # in memory. This is different from equality comparison (==) which compares values.
9
- #
10
- # @example Basic usage with symbols
11
- # matcher = Matchi::Be.new(:foo)
12
- # matcher.match? { :foo } # => true
13
- # matcher.match? { :bar } # => false
14
- #
15
- # @example Identity comparison with strings
16
- # string = "test"
17
- # matcher = Matchi::Be.new(string)
18
- # matcher.match? { string } # => true
19
- # matcher.match? { string.dup } # => false
20
- # matcher.match? { "test" } # => false
21
- #
22
- # @example With mutable objects
23
- # array = [1, 2, 3]
24
- # matcher = Matchi::Be.new(array)
25
- # matcher.match? { array } # => true
26
- # matcher.match? { array.dup } # => false
27
- # matcher.match? { [1, 2, 3] } # => false
28
- #
29
- # @see https://ruby-doc.org/core/Object.html#method-i-equal-3F
30
- # @see Matchi::Eq
7
+ # @example
8
+ # Matchi::Be.new(:foo).match? { :foo } # => true
9
+ # Matchi::Be.new("a").match? { "a".dup } # => false (another object)
31
10
  class Be
32
- # Initialize the matcher with a reference object.
33
- #
34
- # @api public
35
- #
36
- # @param expected [#equal?] The expected identical object
37
- #
38
- # @return [Be] a new instance of the matcher
39
- #
40
- # @example
41
- # string = "test"
42
- # Be.new(string) # Match only the same string instance
11
+ # @param expected [Object] the expected object
43
12
  def initialize(expected)
44
13
  @expected = expected
45
14
  end
46
15
 
47
- # Checks if the yielded object is the same instance as the expected object.
48
- #
49
- # This method uses Ruby's Object#equal? method, which performs identity comparison
50
- # by comparing object IDs. Two objects are considered identical only if they are
51
- # the exact same instance in memory.
52
- #
53
- # @api public
54
- #
55
- # @yield [] Block that returns the object to check
56
- # @yieldreturn [Object] The object to verify identity with
57
- #
58
- # @return [Boolean] true if both objects are the same instance
59
- #
60
- # @raise [ArgumentError] if no block is provided
61
- #
62
- # @example
63
- # obj = "test"
64
- # matcher = Be.new(obj)
65
- # matcher.match? { obj } # => true
66
- # matcher.match? { obj.dup } # => false
16
+ # @yieldreturn [Object] the actual value
17
+ # @return [Boolean]
18
+ # @raise [ArgumentError] when no block is given
67
19
  def match?
68
20
  raise ::ArgumentError, "a block must be provided" unless block_given?
69
21
 
70
22
  @expected.equal?(yield)
71
23
  end
72
24
 
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
- # Be.new("test").to_s # => 'be "test"'
25
+ # @return [String]
81
26
  def to_s
82
27
  "be #{@expected.inspect}"
83
28
  end
@@ -1,134 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "constant"
4
+
3
5
  module Matchi
4
- # Type matcher that checks if an object is an instance of a class or one of its subclasses.
5
- #
6
- # This matcher provides a reliable way to verify object types while respecting Ruby's
7
- # inheritance hierarchy. It uses the case equality operator (===) which is Ruby's
8
- # built-in mechanism for type checking, ensuring consistent behavior with Ruby's
9
- # own type system.
10
- #
11
- # @example Basic usage with simple types
12
- # matcher = Matchi::BeAKindOf.new(Numeric)
13
- # matcher.match? { 42 } # => true
14
- # matcher.match? { 42.0 } # => true
15
- # matcher.match? { "42" } # => false
16
- #
17
- # @example Working with inheritance hierarchies
18
- # class Animal; end
19
- # class Dog < Animal; end
20
- # class GermanShepherd < Dog; end
21
- #
22
- # matcher = Matchi::BeAKindOf.new(Animal)
23
- # matcher.match? { Dog.new } # => true
24
- # matcher.match? { GermanShepherd.new } # => true
25
- # matcher.match? { Object.new } # => false
26
- #
27
- # @example Using with modules and interfaces
28
- # module Swimmable
29
- # def swim; end
30
- # end
31
- #
32
- # class Duck
33
- # include Swimmable
34
- # end
35
- #
36
- # matcher = Matchi::BeAKindOf.new(Swimmable)
37
- # matcher.match? { Duck.new } # => true
38
- # matcher.match? { Object.new } # => false
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).
39
9
  #
40
- # @example Different ways to specify the class
41
- # # Using class directly
42
- # Matchi::BeAKindOf.new(String)
10
+ # The expected module can be given directly, or by name; see
11
+ # {Matchi::Constant}.
43
12
  #
44
- # # Using class name as string
45
- # Matchi::BeAKindOf.new("String")
46
- #
47
- # # Using class name as symbol
48
- # Matchi::BeAKindOf.new(:String)
49
- #
50
- # # Using namespaced class
51
- # Matchi::BeAKindOf.new("MyModule::MyClass")
52
- #
53
- # @see Matchi::BeAnInstanceOf
54
- # @see https://ruby-doc.org/core/Module.html#method-i-3D-3D-3D
13
+ # @example
14
+ # Matchi::BeAKindOf.new(Numeric).match? { 42 } # => true
15
+ # Matchi::BeAKindOf.new(:Numeric).match? { "42" } # => false
55
16
  class BeAKindOf
56
- # Creates a new type matcher for the specified class.
57
- #
58
- # @api public
59
- #
60
- # @param expected [Class, #to_s] The expected class or its name
61
- # Can be provided as a Class object, String, or Symbol
62
- #
63
- # @raise [ArgumentError] if the class name doesn't start with an uppercase letter
64
- #
65
- # @return [BeAKindOf] a new instance of the matcher
66
- #
67
- # @example
68
- # BeAKindOf.new(String) # Using class
69
- # BeAKindOf.new("String") # Using string
70
- # BeAKindOf.new(:String) # Using symbol
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
71
19
  def initialize(expected)
72
- @expected = String(expected)
73
- return if /\A[A-Z]/.match?(@expected)
74
-
75
- raise ArgumentError,
76
- "expected must start with an uppercase letter (got: #{@expected})"
20
+ @expected = Constant.normalize(expected, ::Module)
77
21
  end
78
22
 
79
- # Checks if the yielded object is an instance of the expected class or its subclasses.
80
- #
81
- # This method leverages Ruby's case equality operator (===) which provides a reliable
82
- # way to check class hierarchy relationships. When a class is the receiver of ===,
83
- # it returns true if the argument is an instance of that class or one of its subclasses.
84
- #
85
- # @api public
86
- #
87
- # @yield [] Block that returns the object to check
88
- # @yieldreturn [Object] The object to verify the type of
89
- #
90
- # @return [Boolean] true if the object is an instance of the expected class or one of its subclasses
91
- #
92
- # @raise [ArgumentError] if no block is provided
93
- # @raise [NameError] if the expected class cannot be found
94
- #
95
- # @example Simple type check
96
- # matcher = BeAKindOf.new(Numeric)
97
- # matcher.match? { 42 } # => true
98
- #
99
- # @example With inheritance
100
- # matcher = BeAKindOf.new(Animal)
101
- # matcher.match? { Dog.new } # => true
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
102
28
  def match?
103
29
  raise ::ArgumentError, "a block must be provided" unless block_given?
104
30
 
105
- expected_class === yield # rubocop:disable Style/CaseEquality
31
+ Constant.resolve(@expected, ::Module) === yield
106
32
  end
107
33
 
108
- # Returns a human-readable description of the matcher.
109
- #
110
- # @api public
111
- #
112
- # @return [String] A string describing what this matcher verifies
113
- #
114
- # @example
115
- # BeAKindOf.new(String).to_s # => "be a kind of String"
34
+ # @return [String]
116
35
  def to_s
117
- "be a kind of #{@expected}"
118
- end
119
-
120
- private
121
-
122
- # Resolves the expected class name to an actual Class object.
123
- #
124
- # @api private
125
- #
126
- # @return [Class] The resolved class object
127
- # @raise [NameError] If the class name cannot be resolved to an actual class
128
- #
129
- # @note This method handles both string and symbol class names through constant resolution
130
- def expected_class
131
- ::Object.const_get(@expected)
36
+ "be a kind of #{Constant.label(@expected)}"
132
37
  end
133
38
  end
134
39
  end