Repository: freerange/mocha (Ruby)

Issue:
Error on `expects.with` with empty hash argument
How to reproduce:

minitest (5.24.1)
mocha (2.4.2)
rails (6.1.7.8)

Ruby 3.3.0

```ruby
require "test_helper"

class MyKlass
  def self.method_accepting_hash(h)
    "whatever"
  end
end

class MochaErrorTest < ActiveSupport::TestCase
  hash_with_value1 = {"key" => "value"}
  hash_with_value2 = {"key2" => "value2"}
  empty_hash = {}

  test "mocha success, hash with values" do
    MyKlass.expects(:method_accepting_hash).once.with(hash_with_value1).returns("whatever")
    MyKlass.expects(:method_accepting_hash).once.with(hash_with_value2).returns("whatever")
    MyKlass.method_accepting_hash(hash_with_value1)
    MyKlass.method_accepting_hash(hash_with_value2)
  end
  
  test "mocha success, empty hashes" do
    MyKlass.expects(:method_accepting_hash).once.with(empty_hash).returns("whatever")
    MyKlass.expects(:method_accepting_hash).once.with(empty_hash).returns("whatever")
    MyKlass.method_accepting_hash(empty_hash)
    MyKlass.method_accepting_hash(empty_hash)
  end

  test "mocha success first empty hash, then hash with values" do
    MyKlass.expects(:method_accepting_hash).once.with(empty_hash).returns("whatever")
    MyKlass.expects(:method_accepting_hash).once.with(hash_with_value1).returns("whatever")
    MyKlass.method_accepting_hash(empty_hash)
    MyKlass.method_accepting_hash(hash_with_value1)
  end
  
  test "mocha error first hash with values, then empty hash, weird error message?" do
    MyKlass.expects(:method_accepting_hash).once.with(hash_with_value1).returns("whatever")
    MyKlass.expects(:method_accepting_hash).once.with(empty_hash).returns("whatever")
    MyKlass.method_accepting_hash(hash_with_value1)
    MyKlass.method_accepting_hash(empty_hash)
  end
end
```

Failure:

Minitest::Assertion: unexpected invocation: MyKlass.method_accepting_hash({})
unsatisfied expectations:
- expected exactly once, invoked twice: MyKlass.method_accepting_hash({})
- expected exactly once, invoked never: MyKlass.method_accepting_hash({"key" => "value"})

test/support/mocha_error_test.rb:31:in `block in <class:MochaErrorTest>'


Discussion: workaround is to use block matcher
@floehopper just in case

```ruby
module ParameterMatchers
    # @private
    class PositionalOrKeywordHash < Base
      def matches?(available_parameters)
        parameter, is_last_parameter = extract_parameter(available_parameters)

        # return false unless HasEntries.new(@value).matches?([parameter]) <---- commenting this line makes the tests pass
```
Possibly you need that for something else, but I'd love to get that fixed
@josesei Thanks for reporting this. I had independently come to the same conclusion as you as to where the problem lies. I've managed to write a couple of failing unit tests in #660 which reproduce the issue. That line of code is needed to handle nested matchers with keyword arguments (see test below), but clearly the implentation isn't quite right. My head is pretty scrambled right now, but I'll try to get it fixed as soon as I can.

https://github.com/freerange/mocha/blob/e62fa61e086199f7777d70e85371525f6c2afe03/test/unit/parameter_matchers/positional_or_keyword_hash_test.rb#L38-L41
sounds good, thx!



Fix (patch):
diff --git a/lib/mocha/parameter_matchers/has_entries.rb b/lib/mocha/parameter_matchers/has_entries.rb
index 1b6d18d81..2b90f34b8 100644
--- a/lib/mocha/parameter_matchers/has_entries.rb
+++ b/lib/mocha/parameter_matchers/has_entries.rb
@@ -30,20 +30,23 @@ def has_entries(entries) # rubocop:disable Naming/PredicateName
     # Parameter matcher which matches when actual parameter contains all expected +Hash+ entries.
     class HasEntries < Base
       # @private
-      def initialize(entries)
+      def initialize(entries, exact: false)
         @entries = entries
+        @exact = exact
       end
 
       # @private
       def matches?(available_parameters)
         parameter = available_parameters.shift
+        return false if @exact && @entries.length != parameter.length
+
         has_entry_matchers = @entries.map { |key, value| HasEntry.new(key, value) }
         AllOf.new(*has_entry_matchers).matches?([parameter])
       end
 
       # @private
       def mocha_inspect
-        "has_entries(#{@entries.mocha_inspect})"
+        @exact ? @entries.mocha_inspect : "has_entries(#{@entries.mocha_inspect})"
       end
     end
   end
diff --git a/lib/mocha/parameter_matchers/positional_or_keyword_hash.rb b/lib/mocha/parameter_matchers/positional_or_keyword_hash.rb
index d9b77c428..341d314b8 100644
--- a/lib/mocha/parameter_matchers/positional_or_keyword_hash.rb
+++ b/lib/mocha/parameter_matchers/positional_or_keyword_hash.rb
@@ -1,6 +1,7 @@
 require 'mocha/configuration'
 require 'mocha/deprecation'
 require 'mocha/parameter_matchers/base'
+require 'mocha/parameter_matchers/has_entries'
 
 module Mocha
   module ParameterMatchers
@@ -14,7 +15,7 @@ def initialize(value, expectation)
       def matches?(available_parameters)
         parameter, is_last_parameter = extract_parameter(available_parameters)
 
-        return false unless HasEntries.new(@value).matches?([parameter])
+        return false unless HasEntries.new(@value, exact: true).matches?([parameter])
 
         if is_last_parameter && !same_type_of_hash?(parameter, @value)
           return false if Mocha.configuration.strict_keyword_argument_matching?