matchi 3.3.1 → 4.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 +151 -101
- data/lib/matchi/be.rb +7 -13
- data/lib/matchi/be_a_kind_of.rb +81 -0
- data/lib/matchi/be_an_instance_of.rb +91 -21
- data/lib/matchi/be_within/of.rb +11 -13
- data/lib/matchi/be_within.rb +4 -1
- data/lib/matchi/change/by.rb +11 -14
- data/lib/matchi/change/by_at_least.rb +12 -14
- data/lib/matchi/change/by_at_most.rb +12 -14
- data/lib/matchi/change/from/to.rb +10 -14
- data/lib/matchi/change/from.rb +3 -1
- data/lib/matchi/change/to.rb +10 -14
- data/lib/matchi/change.rb +10 -9
- data/lib/matchi/eq.rb +7 -13
- data/lib/matchi/match.rb +9 -13
- data/lib/matchi/predicate.rb +8 -19
- data/lib/matchi/raise_exception.rb +30 -17
- data/lib/matchi/satisfy.rb +8 -12
- data/lib/matchi.rb +1 -1
- metadata +6 -120
    
        checksums.yaml
    CHANGED
    
    | @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            ---
         | 
| 2 2 | 
             
            SHA256:
         | 
| 3 | 
            -
              metadata.gz:  | 
| 4 | 
            -
              data.tar.gz:  | 
| 3 | 
            +
              metadata.gz: e8454c54085d68ded31ba34e1ff0ab03cdc3dab452316ef82a15967f3de3ee4c
         | 
| 4 | 
            +
              data.tar.gz: ac11c5ab16b7e93f16189b1fa62f0d0502e0d94e67627f81dfab40cd7db48d61
         | 
| 5 5 | 
             
            SHA512:
         | 
| 6 | 
            -
              metadata.gz:  | 
| 7 | 
            -
              data.tar.gz:  | 
| 6 | 
            +
              metadata.gz: b984f3a46f4eb2f0743b4407518261ca0539efd780b28ab0b0f476a687925bedeec2d88249160d7c148617809080593d9472957dc8571bf92295007dfaf941b7
         | 
| 7 | 
            +
              data.tar.gz: 96f609b1bc8af4ddf6a5b7b839ed0b981018d7aac53f1d26b5ea05c1629318f3cdbef64e9776dd2224c3fdd48b56e148e8e3a94df4de5d07e93164941da0fa25
         | 
    
        data/LICENSE.md
    CHANGED
    
    
    
        data/README.md
    CHANGED
    
    | @@ -1,12 +1,13 @@ | |
| 1 1 | 
             
            # Matchi
         | 
| 2 2 |  | 
| 3 | 
            -
            [](https://github.com/fixrb/matchi/ | 
| 3 | 
            +
            [](https://github.com/fixrb/matchi/tags)
         | 
| 4 4 | 
             
            [](https://rubydoc.info/github/fixrb/matchi/main)
         | 
| 5 | 
            -
            [](https://github.com/fixrb/matchi/actions?query=workflow%3Aruby+branch%3Amain)
         | 
| 6 6 | 
             
            [](https://github.com/fixrb/matchi/actions?query=workflow%3Arubocop+branch%3Amain)
         | 
| 7 7 | 
             
            [](https://github.com/fixrb/matchi/raw/main/LICENSE.md)
         | 
| 8 8 |  | 
| 9 | 
            -
             | 
| 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.
         | 
| 10 11 |  | 
| 11 12 | 
             
            
         | 
| 12 13 |  | 
| @@ -27,7 +28,7 @@ gem "matchi" | |
| 27 28 | 
             
            And then execute:
         | 
| 28 29 |  | 
| 29 30 | 
             
            ```sh
         | 
| 30 | 
            -
            bundle
         | 
| 31 | 
            +
            bundle install
         | 
| 31 32 | 
             
            ```
         | 
| 32 33 |  | 
| 33 34 | 
             
            Or install it yourself as:
         | 
| @@ -52,148 +53,160 @@ All examples here assume that this has been done. | |
| 52 53 |  | 
| 53 54 | 
             
            ### Anatomy of a matcher
         | 
| 54 55 |  | 
| 55 | 
            -
            A  | 
| 56 | 
            +
            A **Matchi** matcher is a simple Ruby object that follows these requirements:
         | 
| 56 57 |  | 
| 57 | 
            -
             | 
| 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
         | 
| 58 65 |  | 
| 59 66 | 
             
            ### Built-in matchers
         | 
| 60 67 |  | 
| 61 | 
            -
            Here is the collection of  | 
| 68 | 
            +
            Here is the collection of generic matchers.
         | 
| 62 69 |  | 
| 63 | 
            -
             | 
| 70 | 
            +
            #### Basic Comparison Matchers
         | 
| 64 71 |  | 
| 72 | 
            +
            ##### `Be`
         | 
| 73 | 
            +
            Checks for object identity using Ruby's `equal?` method.
         | 
| 65 74 | 
             
            ```ruby
         | 
| 66 | 
            -
             | 
| 67 | 
            -
             | 
| 68 | 
            -
            matcher.expected           # => "foo"
         | 
| 69 | 
            -
            matcher.matches? { "foo" } # => true
         | 
| 75 | 
            +
            Matchi::Be.new(:foo).match? { :foo }  # => true (same object)
         | 
| 76 | 
            +
            Matchi::Be.new("test").match? { "test" }  # => false (different objects)
         | 
| 70 77 | 
             
            ```
         | 
| 71 78 |  | 
| 72 | 
            -
             | 
| 73 | 
            -
             | 
| 79 | 
            +
            ##### `Eq`
         | 
| 80 | 
            +
            Verifies object equivalence using Ruby's `eql?` method.
         | 
| 74 81 | 
             
            ```ruby
         | 
| 75 | 
            -
             | 
| 76 | 
            -
             | 
| 77 | 
            -
            matcher.expected          # => :foo
         | 
| 78 | 
            -
            matcher.matches? { :foo } # => true
         | 
| 82 | 
            +
            Matchi::Eq.new("foo").match? { "foo" }  # => true (equivalent content)
         | 
| 83 | 
            +
            Matchi::Eq.new([1, 2]).match? { [1, 2] }  # => true (equivalent arrays)
         | 
| 79 84 | 
             
            ```
         | 
| 80 85 |  | 
| 81 | 
            -
             | 
| 86 | 
            +
            #### Type and Class Matchers
         | 
| 82 87 |  | 
| 88 | 
            +
            ##### `BeAnInstanceOf`
         | 
| 89 | 
            +
            Verifies exact class matching (no inheritance).
         | 
| 83 90 | 
             
            ```ruby
         | 
| 84 | 
            -
             | 
| 85 | 
            -
             | 
| 86 | 
            -
             | 
| 87 | 
            -
            matcher.matches? { 42 } # => true
         | 
| 91 | 
            +
            Matchi::BeAnInstanceOf.new(String).match? { "test" }  # => true
         | 
| 92 | 
            +
            Matchi::BeAnInstanceOf.new(Integer).match? { 42 }     # => true
         | 
| 93 | 
            +
            Matchi::BeAnInstanceOf.new(Numeric).match? { 42 }     # => false (Integer, not Numeric)
         | 
| 88 94 | 
             
            ```
         | 
| 89 95 |  | 
| 90 | 
            -
             | 
| 91 | 
            -
             | 
| 96 | 
            +
            ##### `BeAKindOf`
         | 
| 97 | 
            +
            Verifies class inheritance and module inclusion.
         | 
| 92 98 | 
             
            ```ruby
         | 
| 93 | 
            -
             | 
| 94 | 
            -
             | 
| 95 | 
            -
            matcher.expected           # => /^foo$/
         | 
| 96 | 
            -
            matcher.matches? { "foo" } # => true
         | 
| 99 | 
            +
            Matchi::BeAKindOf.new(Numeric).match? { 42 }    # => true (Integer inherits from Numeric)
         | 
| 100 | 
            +
            Matchi::BeAKindOf.new(Numeric).match? { 42.0 }  # => true (Float inherits from Numeric)
         | 
| 97 101 | 
             
            ```
         | 
| 98 102 |  | 
| 99 | 
            -
             | 
| 103 | 
            +
            #### Pattern Matchers
         | 
| 100 104 |  | 
| 105 | 
            +
            ##### `Match`
         | 
| 106 | 
            +
            Tests string patterns against regular expressions.
         | 
| 101 107 | 
             
            ```ruby
         | 
| 102 | 
            -
             | 
| 103 | 
            -
             | 
| 104 | 
            -
             | 
| 105 | 
            -
            matcher.matches? { Boom } # => true
         | 
| 108 | 
            +
            Matchi::Match.new(/^foo/).match? { "foobar" }  # => true
         | 
| 109 | 
            +
            Matchi::Match.new(/\d+/).match? { "abc123" }   # => true
         | 
| 110 | 
            +
            Matchi::Match.new(/^foo/).match? { "barfoo" }  # => false
         | 
| 106 111 | 
             
            ```
         | 
| 107 112 |  | 
| 108 | 
            -
             | 
| 109 | 
            -
             | 
| 113 | 
            +
            ##### `Satisfy`
         | 
| 114 | 
            +
            Provides custom matching through a block.
         | 
| 110 115 | 
             
            ```ruby
         | 
| 111 | 
            -
             | 
| 112 | 
            -
             | 
| 113 | 
            -
            matcher.expected           # => "String"
         | 
| 114 | 
            -
            matcher.matches? { "foo" } # => true
         | 
| 116 | 
            +
            Matchi::Satisfy.new { |x| x > 0 && x < 10 }.match? { 5 }  # => true
         | 
| 117 | 
            +
            Matchi::Satisfy.new { |x| x.start_with?("test") }.match? { "test_file" }  # => true
         | 
| 115 118 | 
             
            ```
         | 
| 116 119 |  | 
| 117 | 
            -
             | 
| 118 | 
            -
             | 
| 119 | 
            -
            ```ruby
         | 
| 120 | 
            -
            matcher = Matchi::Predicate.new(:be_empty)
         | 
| 120 | 
            +
            #### State Change Matchers
         | 
| 121 121 |  | 
| 122 | 
            -
             | 
| 123 | 
            -
             | 
| 122 | 
            +
            ##### `Change`
         | 
| 123 | 
            +
            Verifies state changes in objects with multiple variation methods:
         | 
| 124 124 |  | 
| 125 | 
            -
             | 
| 126 | 
            -
             | 
| 127 | 
            -
             | 
| 128 | 
            -
             | 
| 125 | 
            +
            ###### Basic Change
         | 
| 126 | 
            +
            ```ruby
         | 
| 127 | 
            +
            array = []
         | 
| 128 | 
            +
            Matchi::Change.new(array, :length).by(2).match? { array.push(1, 2) }  # => true
         | 
| 129 129 | 
             
            ```
         | 
| 130 130 |  | 
| 131 | 
            -
             | 
| 132 | 
            -
             | 
| 131 | 
            +
            ###### Minimum Change
         | 
| 133 132 | 
             
            ```ruby
         | 
| 134 | 
            -
             | 
| 135 | 
            -
             | 
| 136 | 
            -
             | 
| 137 | 
            -
            matcher.expected                 # => 1
         | 
| 138 | 
            -
            matcher.matches? { object << 1 } # => true
         | 
| 139 | 
            -
             | 
| 140 | 
            -
            object = []
         | 
| 141 | 
            -
            matcher = Matchi::Change.new(object, :length).by_at_least(1)
         | 
| 133 | 
            +
            counter = 0
         | 
| 134 | 
            +
            Matchi::Change.new(counter, :to_i).by_at_least(2).match? { counter += 3 }  # => true
         | 
| 135 | 
            +
            ```
         | 
| 142 136 |  | 
| 143 | 
            -
             | 
| 144 | 
            -
             | 
| 137 | 
            +
            ###### Maximum Change
         | 
| 138 | 
            +
            ```ruby
         | 
| 139 | 
            +
            value = 10
         | 
| 140 | 
            +
            Matchi::Change.new(value, :to_i).by_at_most(5).match? { value += 3 }  # => true
         | 
| 141 | 
            +
            ```
         | 
| 145 142 |  | 
| 146 | 
            -
             | 
| 147 | 
            -
             | 
| 143 | 
            +
            ###### From-To Change
         | 
| 144 | 
            +
            ```ruby
         | 
| 145 | 
            +
            string = "hello"
         | 
| 146 | 
            +
            Matchi::Change.new(string, :upcase).from("hello").to("HELLO").match? { string.upcase! }  # => true
         | 
| 147 | 
            +
            ```
         | 
| 148 148 |  | 
| 149 | 
            -
             | 
| 150 | 
            -
             | 
| 149 | 
            +
            ###### To-Only Change
         | 
| 150 | 
            +
            ```ruby
         | 
| 151 | 
            +
            number = 1
         | 
| 152 | 
            +
            Matchi::Change.new(number, :to_i).to(5).match? { number = 5 }  # => true
         | 
| 153 | 
            +
            ```
         | 
| 151 154 |  | 
| 152 | 
            -
             | 
| 153 | 
            -
            matcher = Matchi::Change.new(object, :to_s).from("foo").to("FOO")
         | 
| 155 | 
            +
            #### Numeric Matchers
         | 
| 154 156 |  | 
| 155 | 
            -
             | 
| 156 | 
            -
             | 
| 157 | 
            +
            ##### `BeWithin`
         | 
| 158 | 
            +
            Checks if a number is within a specified range of an expected value.
         | 
| 159 | 
            +
            ```ruby
         | 
| 160 | 
            +
            Matchi::BeWithin.new(0.5).of(3.0).match? { 3.2 }  # => true
         | 
| 161 | 
            +
            Matchi::BeWithin.new(5).of(100).match? { 98 }     # => true
         | 
| 162 | 
            +
            ```
         | 
| 157 163 |  | 
| 158 | 
            -
             | 
| 159 | 
            -
            matcher = Matchi::Change.new(object, :to_s).to("FOO")
         | 
| 164 | 
            +
            #### Behavior Matchers
         | 
| 160 165 |  | 
| 161 | 
            -
             | 
| 162 | 
            -
             | 
| 166 | 
            +
            ##### `RaiseException`
         | 
| 167 | 
            +
            Verifies that code raises specific exceptions.
         | 
| 168 | 
            +
            ```ruby
         | 
| 169 | 
            +
            Matchi::RaiseException.new(ArgumentError).match? { raise ArgumentError }  # => true
         | 
| 170 | 
            +
            Matchi::RaiseException.new(NameError).match? { undefined_variable }      # => true
         | 
| 163 171 | 
             
            ```
         | 
| 164 172 |  | 
| 165 | 
            -
             | 
| 173 | 
            +
            ##### `Predicate`
         | 
| 174 | 
            +
            Creates matchers for methods ending in `?`.
         | 
| 166 175 |  | 
| 176 | 
            +
            ###### Using `be_` prefix
         | 
| 167 177 | 
             
            ```ruby
         | 
| 168 | 
            -
             | 
| 178 | 
            +
            Matchi::Predicate.new(:be_empty).match? { [] }  # => true (calls empty?)
         | 
| 179 | 
            +
            Matchi::Predicate.new(:be_nil).match? { nil }   # => true (calls nil?)
         | 
| 180 | 
            +
            ```
         | 
| 169 181 |  | 
| 170 | 
            -
             | 
| 171 | 
            -
             | 
| 182 | 
            +
            ###### Using `have_` prefix
         | 
| 183 | 
            +
            ```ruby
         | 
| 184 | 
            +
            Matchi::Predicate.new(:have_key, :foo).match? { { foo: 42 } }  # => true (calls has_key?)
         | 
| 172 185 | 
             
            ```
         | 
| 173 186 |  | 
| 174 187 | 
             
            ### Custom matchers
         | 
| 175 188 |  | 
| 176 | 
            -
            Custom matchers  | 
| 189 | 
            +
            Custom matchers could easily be added to `Matchi` module to express more specific expectations.
         | 
| 177 190 |  | 
| 178 191 | 
             
            A **Be the answer** matcher:
         | 
| 179 192 |  | 
| 180 193 | 
             
            ```ruby
         | 
| 181 194 | 
             
            module Matchi
         | 
| 182 195 | 
             
              class BeTheAnswer
         | 
| 183 | 
            -
                def  | 
| 184 | 
            -
                   | 
| 196 | 
            +
                def match?
         | 
| 197 | 
            +
                  expected.equal?(yield)
         | 
| 185 198 | 
             
                end
         | 
| 186 199 |  | 
| 187 | 
            -
                 | 
| 188 | 
            -
             | 
| 200 | 
            +
                private
         | 
| 201 | 
            +
             | 
| 202 | 
            +
                def expected
         | 
| 203 | 
            +
                  42
         | 
| 189 204 | 
             
                end
         | 
| 190 205 | 
             
              end
         | 
| 191 206 | 
             
            end
         | 
| 192 207 |  | 
| 193 208 | 
             
            matcher = Matchi::BeTheAnswer.new
         | 
| 194 | 
            -
             | 
| 195 | 
            -
            matcher.expected        # => 42
         | 
| 196 | 
            -
            matcher.matches? { 42 } # => true
         | 
| 209 | 
            +
            matcher.match? { 42 } # => true
         | 
| 197 210 | 
             
            ```
         | 
| 198 211 |  | 
| 199 212 | 
             
            A **Be prime** matcher:
         | 
| @@ -203,7 +216,7 @@ require "prime" | |
| 203 216 |  | 
| 204 217 | 
             
            module Matchi
         | 
| 205 218 | 
             
              class BePrime
         | 
| 206 | 
            -
                def  | 
| 219 | 
            +
                def match?
         | 
| 207 220 | 
             
                  Prime.prime?(yield)
         | 
| 208 221 | 
             
                end
         | 
| 209 222 | 
             
              end
         | 
| @@ -211,7 +224,7 @@ end | |
| 211 224 |  | 
| 212 225 | 
             
            matcher = Matchi::BePrime.new
         | 
| 213 226 |  | 
| 214 | 
            -
            matcher. | 
| 227 | 
            +
            matcher.match? { 42 } # => false
         | 
| 215 228 | 
             
            ```
         | 
| 216 229 |  | 
| 217 230 | 
             
            A **Start with** matcher:
         | 
| @@ -219,22 +232,64 @@ A **Start with** matcher: | |
| 219 232 | 
             
            ```ruby
         | 
| 220 233 | 
             
            module Matchi
         | 
| 221 234 | 
             
              class StartWith
         | 
| 222 | 
            -
                attr_reader :expected
         | 
| 223 | 
            -
             | 
| 224 235 | 
             
                def initialize(expected)
         | 
| 225 236 | 
             
                  @expected = expected
         | 
| 226 237 | 
             
                end
         | 
| 227 238 |  | 
| 228 | 
            -
                def  | 
| 229 | 
            -
                   | 
| 239 | 
            +
                def match?
         | 
| 240 | 
            +
                  /\A#{@expected}/.match?(yield)
         | 
| 230 241 | 
             
                end
         | 
| 231 242 | 
             
              end
         | 
| 232 243 | 
             
            end
         | 
| 233 244 |  | 
| 234 245 | 
             
            matcher = Matchi::StartWith.new("foo")
         | 
| 246 | 
            +
            matcher.match? { "foobar" } # => true
         | 
| 247 | 
            +
            ```
         | 
| 248 | 
            +
             | 
| 249 | 
            +
            ## Best Practices
         | 
| 250 | 
            +
             | 
| 251 | 
            +
            ### Proper Value Comparison Order
         | 
| 252 | 
            +
             | 
| 253 | 
            +
            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:
         | 
| 254 | 
            +
             | 
| 255 | 
            +
            ```ruby
         | 
| 256 | 
            +
            # GOOD: Expected value controls the comparison
         | 
| 257 | 
            +
            expected_value.eql?(actual_value)
         | 
| 258 | 
            +
             | 
| 259 | 
            +
            # BAD: Actual value controls the comparison
         | 
| 260 | 
            +
            actual_value.eql?(expected_value)
         | 
| 261 | 
            +
            ```
         | 
| 262 | 
            +
             | 
| 263 | 
            +
            #### Why This Matters
         | 
| 264 | 
            +
             | 
| 265 | 
            +
            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:
         | 
| 266 | 
            +
             | 
| 267 | 
            +
            ```ruby
         | 
| 268 | 
            +
            # Example of how comparison can be compromised
         | 
| 269 | 
            +
            class MaliciousString
         | 
| 270 | 
            +
              def eql?(other)
         | 
| 271 | 
            +
                true  # Always returns true regardless of actual equality
         | 
| 272 | 
            +
              end
         | 
| 235 273 |  | 
| 236 | 
            -
             | 
| 237 | 
            -
             | 
| 274 | 
            +
              def ==(other)
         | 
| 275 | 
            +
                true  # Always returns true regardless of actual equality
         | 
| 276 | 
            +
              end
         | 
| 277 | 
            +
            end
         | 
| 278 | 
            +
             | 
| 279 | 
            +
            actual = MaliciousString.new
         | 
| 280 | 
            +
            expected = "expected string"
         | 
| 281 | 
            +
             | 
| 282 | 
            +
            actual.eql?(expected)      # => true (incorrect result!)
         | 
| 283 | 
            +
            expected.eql?(actual)      # => false (correct result)
         | 
| 284 | 
            +
            ```
         | 
| 285 | 
            +
             | 
| 286 | 
            +
            This is why Matchi's built-in matchers are implemented with this security consideration in mind. For example, the `Eq` matcher:
         | 
| 287 | 
            +
             | 
| 288 | 
            +
            ```ruby
         | 
| 289 | 
            +
            # Implementation in Matchi::Eq
         | 
| 290 | 
            +
            def match?
         | 
| 291 | 
            +
              @expected.eql?(yield)  # Expected value controls the comparison
         | 
| 292 | 
            +
            end
         | 
| 238 293 | 
             
            ```
         | 
| 239 294 |  | 
| 240 295 | 
             
            ## Contact
         | 
| @@ -250,11 +305,6 @@ __Matchi__ follows [Semantic Versioning 2.0](https://semver.org/). | |
| 250 305 |  | 
| 251 306 | 
             
            The [gem](https://rubygems.org/gems/matchi) is available as open source under the terms of the [MIT License](https://github.com/fixrb/matchi/raw/main/LICENSE.md).
         | 
| 252 307 |  | 
| 253 | 
            -
             | 
| 308 | 
            +
            ## Sponsors
         | 
| 254 309 |  | 
| 255 | 
            -
             | 
| 256 | 
            -
              This project is sponsored by:<br />
         | 
| 257 | 
            -
              <a href="https://sashite.com/"><img
         | 
| 258 | 
            -
                src="https://github.com/fixrb/matchi/raw/main/img/sashite.png"
         | 
| 259 | 
            -
                alt="Sashite" /></a>
         | 
| 260 | 
            -
            </p>
         | 
| 310 | 
            +
            This project is sponsored by [Sashité](https://sashite.com/)
         | 
    
        data/lib/matchi/be.rb
    CHANGED
    
    | @@ -3,9 +3,6 @@ | |
| 3 3 | 
             
            module Matchi
         | 
| 4 4 | 
             
              # *Identity* matcher.
         | 
| 5 5 | 
             
              class Be
         | 
| 6 | 
            -
                # @return [#equal?] The expected identical object.
         | 
| 7 | 
            -
                attr_reader :expected
         | 
| 8 | 
            -
             | 
| 9 6 | 
             
                # Initialize the matcher with an object.
         | 
| 10 7 | 
             
                #
         | 
| 11 8 | 
             
                # @example
         | 
| @@ -24,26 +21,23 @@ module Matchi | |
| 24 21 | 
             
                #   require "matchi/be"
         | 
| 25 22 | 
             
                #
         | 
| 26 23 | 
             
                #   matcher = Matchi::Be.new(:foo)
         | 
| 27 | 
            -
                #
         | 
| 28 | 
            -
                #   matcher.expected          # => :foo
         | 
| 29 | 
            -
                #   matcher.matches? { :foo } # => true
         | 
| 24 | 
            +
                #   matcher.match? { :foo } # => true
         | 
| 30 25 | 
             
                #
         | 
| 31 26 | 
             
                # @yieldreturn [#object_id] The actual value to compare to the expected
         | 
| 32 27 | 
             
                #   one.
         | 
| 33 28 | 
             
                #
         | 
| 34 29 | 
             
                # @return [Boolean] Comparison between actual and expected values.
         | 
| 35 | 
            -
                def  | 
| 36 | 
            -
                   | 
| 37 | 
            -
                end
         | 
| 30 | 
            +
                def match?
         | 
| 31 | 
            +
                  raise ::ArgumentError, "a block must be provided" unless block_given?
         | 
| 38 32 |  | 
| 39 | 
            -
             | 
| 40 | 
            -
                def inspect
         | 
| 41 | 
            -
                  "#{self.class}(#{expected.inspect})"
         | 
| 33 | 
            +
                  @expected.equal?(yield)
         | 
| 42 34 | 
             
                end
         | 
| 43 35 |  | 
| 44 36 | 
             
                # Returns a string representing the matcher.
         | 
| 37 | 
            +
                #
         | 
| 38 | 
            +
                # @return [String] a human-readable description of the matcher
         | 
| 45 39 | 
             
                def to_s
         | 
| 46 | 
            -
                  "be #{expected.inspect}"
         | 
| 40 | 
            +
                  "be #{@expected.inspect}"
         | 
| 47 41 | 
             
                end
         | 
| 48 42 | 
             
              end
         | 
| 49 43 | 
             
            end
         | 
| @@ -0,0 +1,81 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            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.
         | 
| 9 | 
            +
              #
         | 
| 10 | 
            +
              # @example Basic usage
         | 
| 11 | 
            +
              #   require "matchi/be_a_kind_of"
         | 
| 12 | 
            +
              #
         | 
| 13 | 
            +
              #   matcher = Matchi::BeAKindOf.new(Numeric)
         | 
| 14 | 
            +
              #   matcher.match? { 42 }     # => true
         | 
| 15 | 
            +
              #   matcher.match? { 42.0 }   # => true
         | 
| 16 | 
            +
              #   matcher.match? { "42" }   # => false
         | 
| 17 | 
            +
              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
         | 
| 29 | 
            +
                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})"
         | 
| 35 | 
            +
                end
         | 
| 36 | 
            +
             | 
| 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
         | 
| 57 | 
            +
                def match?
         | 
| 58 | 
            +
                  raise ::ArgumentError, "a block must be provided" unless block_given?
         | 
| 59 | 
            +
             | 
| 60 | 
            +
                  expected_class === yield # rubocop:disable Style/CaseEquality
         | 
| 61 | 
            +
                end
         | 
| 62 | 
            +
             | 
| 63 | 
            +
                # Returns a string representing the matcher.
         | 
| 64 | 
            +
                #
         | 
| 65 | 
            +
                # @return [String] a human-readable description of the matcher
         | 
| 66 | 
            +
                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)
         | 
| 79 | 
            +
                end
         | 
| 80 | 
            +
              end
         | 
| 81 | 
            +
            end
         | 
| @@ -1,49 +1,119 @@ | |
| 1 1 | 
             
            # frozen_string_literal: true
         | 
| 2 2 |  | 
| 3 3 | 
             
            module Matchi
         | 
| 4 | 
            -
              # *Type/class* matcher.
         | 
| 4 | 
            +
              # *Type/class* matcher with enhanced class checking.
         | 
| 5 | 
            +
              #
         | 
| 6 | 
            +
              # This matcher aims to provide a more reliable way to check if an object is an exact
         | 
| 7 | 
            +
              # instance of a specific class (not a subclass). While not foolproof, it uses a more
         | 
| 8 | 
            +
              # robust method to get the actual class of an object that helps resist common
         | 
| 9 | 
            +
              # attempts at type checking manipulation.
         | 
| 10 | 
            +
              #
         | 
| 11 | 
            +
              # @example Basic usage
         | 
| 12 | 
            +
              #   require "matchi/be_an_instance_of"
         | 
| 13 | 
            +
              #
         | 
| 14 | 
            +
              #   matcher = Matchi::BeAnInstanceOf.new(String)
         | 
| 15 | 
            +
              #   matcher.match? { "foo" }  # => true
         | 
| 16 | 
            +
              #   matcher.match? { :foo }   # => false
         | 
| 17 | 
            +
              #
         | 
| 18 | 
            +
              # @example Enhanced class checking in practice
         | 
| 19 | 
            +
              #   # Consider a class that attempts to masquerade as String by overriding
         | 
| 20 | 
            +
              #   # common type checking methods:
         | 
| 21 | 
            +
              #   class MaliciousString
         | 
| 22 | 
            +
              #     def class
         | 
| 23 | 
            +
              #       ::String
         | 
| 24 | 
            +
              #     end
         | 
| 25 | 
            +
              #
         | 
| 26 | 
            +
              #     def instance_of?(klass)
         | 
| 27 | 
            +
              #       self.class == klass
         | 
| 28 | 
            +
              #     end
         | 
| 29 | 
            +
              #
         | 
| 30 | 
            +
              #     def is_a?(klass)
         | 
| 31 | 
            +
              #       "".is_a?(klass)  # Delegates to a real String
         | 
| 32 | 
            +
              #     end
         | 
| 33 | 
            +
              #
         | 
| 34 | 
            +
              #     def kind_of?(klass)
         | 
| 35 | 
            +
              #       is_a?(klass)     # Maintains Ruby's kind_of? alias for is_a?
         | 
| 36 | 
            +
              #     end
         | 
| 37 | 
            +
              #   end
         | 
| 38 | 
            +
              #
         | 
| 39 | 
            +
              #   obj = MaliciousString.new
         | 
| 40 | 
            +
              #   obj.class                                             # => String
         | 
| 41 | 
            +
              #   obj.is_a?(String)                                     # => true
         | 
| 42 | 
            +
              #   obj.kind_of?(String)                                  # => true
         | 
| 43 | 
            +
              #   obj.instance_of?(String)                              # => true
         | 
| 44 | 
            +
              #
         | 
| 45 | 
            +
              #   # Using our enhanced checking approach:
         | 
| 46 | 
            +
              #   matcher = Matchi::BeAnInstanceOf.new(String)
         | 
| 47 | 
            +
              #   matcher.match? { obj }                                # => false
         | 
| 5 48 | 
             
              class BeAnInstanceOf
         | 
| 6 | 
            -
                # @return [String] The expected class name.
         | 
| 7 | 
            -
                attr_reader :expected
         | 
| 8 | 
            -
             | 
| 9 49 | 
             
                # Initialize the matcher with (the name of) a class or module.
         | 
| 10 50 | 
             
                #
         | 
| 11 51 | 
             
                # @example
         | 
| 12 52 | 
             
                #   require "matchi/be_an_instance_of"
         | 
| 13 53 | 
             
                #
         | 
| 14 54 | 
             
                #   Matchi::BeAnInstanceOf.new(String)
         | 
| 55 | 
            +
                #   Matchi::BeAnInstanceOf.new("String")
         | 
| 56 | 
            +
                #   Matchi::BeAnInstanceOf.new(:String)
         | 
| 15 57 | 
             
                #
         | 
| 16 | 
            -
                # @param expected [Class, #to_s] The expected class name | 
| 58 | 
            +
                # @param expected [Class, #to_s] The expected class name
         | 
| 59 | 
            +
                # @raise [ArgumentError] if the class name doesn't start with an uppercase letter
         | 
| 17 60 | 
             
                def initialize(expected)
         | 
| 18 61 | 
             
                  @expected = String(expected)
         | 
| 62 | 
            +
                  return if /\A[A-Z]/.match?(@expected)
         | 
| 63 | 
            +
             | 
| 64 | 
            +
                  raise ::ArgumentError,
         | 
| 65 | 
            +
                        "expected must start with an uppercase letter (got: #{@expected})"
         | 
| 19 66 | 
             
                end
         | 
| 20 67 |  | 
| 21 | 
            -
                #  | 
| 22 | 
            -
                # expected class.
         | 
| 68 | 
            +
                # Securely checks if the yielded object is an instance of the expected class.
         | 
| 23 69 | 
             
                #
         | 
| 24 | 
            -
                #  | 
| 25 | 
            -
                # | 
| 70 | 
            +
                # This method uses a specific Ruby reflection technique to get the true class of
         | 
| 71 | 
            +
                # an object, bypassing potential method overrides:
         | 
| 26 72 | 
             
                #
         | 
| 27 | 
            -
                # | 
| 73 | 
            +
                # 1. ::Object.instance_method(:class) retrieves the original, unoverridden 'class'
         | 
| 74 | 
            +
                #    method from the Object class
         | 
| 75 | 
            +
                # 2. .bind_call(obj) binds this original method to our object and calls it,
         | 
| 76 | 
            +
                #    ensuring we get the real class regardless of method overrides
         | 
| 28 77 | 
             
                #
         | 
| 29 | 
            -
                # | 
| 30 | 
            -
                # | 
| 78 | 
            +
                # This approach is more reliable than obj.class because it uses Ruby's method
         | 
| 79 | 
            +
                # binding mechanism to call the original implementation directly. While not
         | 
| 80 | 
            +
                # completely foolproof, it provides better protection against type check spoofing
         | 
| 81 | 
            +
                # than using regular method calls which can be overridden.
         | 
| 31 82 | 
             
                #
         | 
| 32 | 
            -
                # @ | 
| 83 | 
            +
                # @example Basic class check
         | 
| 84 | 
            +
                #   matcher = Matchi::BeAnInstanceOf.new(String)
         | 
| 85 | 
            +
                #   matcher.match? { "test" }        # => true
         | 
| 86 | 
            +
                #   matcher.match? { StringIO.new }  # => false
         | 
| 33 87 | 
             
                #
         | 
| 34 | 
            -
                # @ | 
| 35 | 
            -
                 | 
| 36 | 
            -
             | 
| 37 | 
            -
                 | 
| 88 | 
            +
                # @see https://ruby-doc.org/core/Method.html#method-i-bind_call
         | 
| 89 | 
            +
                # @see https://ruby-doc.org/core/UnboundMethod.html
         | 
| 90 | 
            +
                #
         | 
| 91 | 
            +
                # @yieldreturn [Object] the actual value to check
         | 
| 92 | 
            +
                # @return [Boolean] true if the object's actual class is exactly the expected class
         | 
| 93 | 
            +
                # @raise [ArgumentError] if no block is provided
         | 
| 94 | 
            +
                def match?
         | 
| 95 | 
            +
                  raise ::ArgumentError, "a block must be provided" unless block_given?
         | 
| 38 96 |  | 
| 39 | 
            -
             | 
| 40 | 
            -
             | 
| 41 | 
            -
                  "#{self.class}(#{expected})"
         | 
| 97 | 
            +
                  actual_class = ::Object.instance_method(:class).bind_call(yield)
         | 
| 98 | 
            +
                  expected_class == actual_class
         | 
| 42 99 | 
             
                end
         | 
| 43 100 |  | 
| 44 101 | 
             
                # Returns a string representing the matcher.
         | 
| 102 | 
            +
                #
         | 
| 103 | 
            +
                # @return [String] a human-readable description of the matcher
         | 
| 45 104 | 
             
                def to_s
         | 
| 46 | 
            -
                  "be an instance of #{expected}"
         | 
| 105 | 
            +
                  "be an instance of #{@expected}"
         | 
| 106 | 
            +
                end
         | 
| 107 | 
            +
             | 
| 108 | 
            +
                private
         | 
| 109 | 
            +
             | 
| 110 | 
            +
                # Resolves the expected class name to an actual Class object.
         | 
| 111 | 
            +
                # This method handles both string and symbol class names through constant resolution.
         | 
| 112 | 
            +
                #
         | 
| 113 | 
            +
                # @return [Class] the resolved class
         | 
| 114 | 
            +
                # @raise [NameError] if the class doesn't exist
         | 
| 115 | 
            +
                def expected_class
         | 
| 116 | 
            +
                  ::Object.const_get(@expected)
         | 
| 47 117 | 
             
                end
         | 
| 48 118 | 
             
              end
         | 
| 49 119 | 
             
            end
         |