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 +4 -4
- data/LICENSE.md +1 -1
- data/README.md +91 -227
- data/lib/matchi/be.rb +10 -65
- data/lib/matchi/be_a_kind_of.rb +21 -116
- data/lib/matchi/be_an_instance_of.rb +24 -113
- data/lib/matchi/be_within/of.rb +57 -0
- data/lib/matchi/be_within.rb +17 -134
- data/lib/matchi/change/delta.rb +56 -0
- data/lib/matchi/change/from.rb +21 -61
- data/lib/matchi/change/from_to.rb +46 -0
- data/lib/matchi/change/to.rb +17 -81
- data/lib/matchi/change.rb +73 -148
- data/lib/matchi/constant.rb +55 -0
- data/lib/matchi/eq.rb +24 -65
- data/lib/matchi/match.rb +28 -64
- data/lib/matchi/number.rb +37 -0
- data/lib/matchi/predicate.rb +40 -139
- data/lib/matchi/raise_exception.rb +72 -104
- data/lib/matchi/satisfy.rb +21 -96
- data/lib/matchi.rb +22 -60
- metadata +17 -17
- data/lib/matchi/change/by.rb +0 -137
- data/lib/matchi/change/by_at_least.rb +0 -158
- data/lib/matchi/change/by_at_most.rb +0 -171
- data/lib/matchi/change/from/to.rb +0 -132
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 66ca7f8e6eb1fb3e7fa80907957f37af41fd04b678258199c0f1abfe784aa839
|
|
4
|
+
data.tar.gz: b25bc8c4da4bb7db33478cf7fda77e7119bf0fce94fdfd8310ab8d7c79f8638c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5c4b77eb03979138e162a7040380b9b74c31b16e1995e67e537eebd3d83074913fa39d377f29759e085f35a143a15f19407f77dcd6b2788fc5e2874b28f3591f
|
|
7
|
+
data.tar.gz: e570be50f7b9338a24489b791b0b118b0c892fcc6c8fdc7f375b6187cf63a430642d6a87c7b7016f28b9b6742e7a8c4c07cbcf94bf340a9d590dfbeb49ef0e87
|
data/LICENSE.md
CHANGED
data/README.md
CHANGED
|
@@ -4,288 +4,152 @@
|
|
|
4
4
|
[](https://rubydoc.info/github/fixrb/matchi/main)
|
|
5
5
|
[](https://github.com/fixrb/matchi/raw/main/LICENSE.md)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Framework-agnostic matchers for Ruby. Small, explicit, and written so that the object under test cannot forge a match.
|
|
8
8
|
|
|
9
9
|

|
|
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
|
-
|
|
18
|
+
Matchi requires Ruby 3.3 or later and has no runtime dependency.
|
|
94
19
|
|
|
95
|
-
|
|
96
|
-
gem install matchi
|
|
97
|
-
```
|
|
20
|
+
## Usage
|
|
98
21
|
|
|
99
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
41
|
+
def refute_matches(matcher, actual)
|
|
42
|
+
refute matcher.match? { actual }, "expected #{actual.inspect} not to #{matcher}"
|
|
43
|
+
end
|
|
168
44
|
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
+
## Matchers
|
|
191
52
|
|
|
192
53
|
```ruby
|
|
193
|
-
|
|
194
|
-
Matchi::RaiseException.new(ArgumentError).match? { raise ArgumentError } # => true
|
|
54
|
+
require "matchi"
|
|
195
55
|
|
|
196
|
-
#
|
|
197
|
-
Matchi::
|
|
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
|
-
#
|
|
200
|
-
Matchi::
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
#
|
|
207
|
-
Matchi::
|
|
208
|
-
Matchi::
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
+
## Writing a matcher
|
|
247
101
|
|
|
248
|
-
|
|
102
|
+
Any object that responds to `match?` with a block and to `to_s` is a matcher:
|
|
249
103
|
|
|
250
104
|
```ruby
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
258
|
-
|
|
110
|
+
def to_s
|
|
111
|
+
"be positive"
|
|
259
112
|
end
|
|
260
113
|
end
|
|
261
114
|
|
|
262
|
-
|
|
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
|
-
|
|
118
|
+
Every matcher in Matchi follows two rules, which are worth keeping in your own:
|
|
269
119
|
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
+
## Differences from RSpec
|
|
282
128
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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
|
|
4
|
+
# Identity matcher: passes when the actual value is the very same object as
|
|
5
|
+
# the expected one (`equal?`).
|
|
5
6
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
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
|
-
#
|
|
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
|
data/lib/matchi/be_a_kind_of.rb
CHANGED
|
@@ -1,134 +1,39 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "constant"
|
|
4
|
+
|
|
3
5
|
module Matchi
|
|
4
|
-
# Type matcher
|
|
5
|
-
#
|
|
6
|
-
#
|
|
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
|
-
#
|
|
41
|
-
#
|
|
42
|
-
# Matchi::BeAKindOf.new(String)
|
|
10
|
+
# The expected module can be given directly, or by name; see
|
|
11
|
+
# {Matchi::Constant}.
|
|
43
12
|
#
|
|
44
|
-
#
|
|
45
|
-
# Matchi::BeAKindOf.new(
|
|
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
|
-
#
|
|
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 =
|
|
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
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
#
|
|
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
|
-
|
|
31
|
+
Constant.resolve(@expected, ::Module) === yield
|
|
106
32
|
end
|
|
107
33
|
|
|
108
|
-
#
|
|
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
|