# frozen_string_literal: true ## # IMPORTANT: This matcher has a dedicated end-user doc. Do NOT forget to update it when needed. # https://github.com/marian13/convenient_service_docs/blob/main/docs/api/tests/rspec/matchers/delegate_to.mdx # # TODO: Refactor into composition: # - Ability to compose when `delegate_to` is used `without_arguments`. # - Ability to compose when `delegate_to` is used `with_arguments`. # - Ability to compose when `delegate_to` is used `and_return_its_value`. # # TODO: Refactor to NOT use `expect` inside this matcher. # This way the matcher will return true or false, but never raise exceptions (descendant of Exception, not StandardError). # Then it will be easier to developer a fully comprehensive spec suite for `delegate_to`. # module ConvenientService module RSpec module Matchers module Custom ## # specify { # expect { method_class.cast(other, **options) } # .to delegate_to(ConvenientService::Service::Plugins::HasResultSteps::Entities::Method::Commands::CastMethod, :call) # .with_arguments(other: other, options: options) # .and_return_its_value # } # # { method_class.cast(other, **options) } # # => block_expectation # # ConvenientService::Service::Plugins::HasResultSteps::Entities::Method::Commands::CastMethod # # => object # # :call # #=> method # # (other: other, options: options) # # => chain[:with] # # and_return_its_value # # => chain[:and_return_its_value] # # NOTE: A similar (with different behaviour) matcher exists in `saharspec`. # https://github.com/zverok/saharspec#send_messageobject-method-matcher # class DelegateTo ## # NOTE: `include ::RSpec::Expectations`. # - https://github.com/rspec/rspec-expectations/blob/v3.11.0/lib/rspec/expectations.rb # - https://github.com/rspec/rspec-expectations/blob/main/lib/rspec/expectations.rb#L60 # include ::RSpec::Expectations ## # NOTE: `include ::RSpec::Matchers`. # - https://github.com/rspec/rspec-expectations/blob/v3.11.0/lib/rspec/matchers.rb # - https://github.com/rspec/rspec-expectations/blob/main/lib/rspec/matchers.rb # include ::RSpec::Matchers ## # NOTE: `include ::RSpec::Mocks::ExampleMethods`. # - https://github.com/rspec/rspec-mocks/blob/v3.11.1/lib/rspec/mocks/example_methods.rb # - https://github.com/rspec/rspec-mocks/blob/main/lib/rspec/mocks/example_methods.rb # include ::RSpec::Mocks::ExampleMethods ## # # def initialize(object, method) @object = object @method = method end ## # # def matches?(block_expectation) @block_expectation = block_expectation ## # TODO: Support multiple `with_arguments` calls. # if used_with_arguments? ## # NOTE: RSpec `allow(object).to receive(method).with(*args, **kwargs)` does NOT support block. # https://github.com/rspec/rspec-mocks/issues/1182#issuecomment-679820352 # # NOTE: RSpec `allow(object).to receive(method) do` does NOT support `and_call_original`. # https://github.com/rspec/rspec-mocks/issues/774#issuecomment-54245277 # # NOTE: That is why `and_wrap_original` is used. # https://relishapp.com/rspec/rspec-mocks/docs/configuring-responses/wrapping-the-original-implementation # allow(object).to receive(method).and_wrap_original do |original, *actual_args, **actual_kwargs, &actual_block| actual_arguments_collection << [actual_args, actual_kwargs, actual_block] ## # NOTE: Imitates `and_call_original`. # original.call(*actual_args, **actual_kwargs, &actual_block) end else allow(object).to receive(method).and_call_original end ## # NOTE: Calls `block_expectation` before checking expections. # value = block_expectation.call ## # NOTE: If this expectation fails, it means `delegate_to` is NOT met. # expect(object).to have_received(method).at_least(1) unless used_with_arguments? ## # IMPORTANT: `and_return_its_value` works only when `delegate_to` checks a pure function. # # For example (1): # def require_dependencies_pure # RequireDependenciesPure.call # end # # class RequireDependenciesPure # def self.call # dependencies.require! # # true # end # end # # # Works since `RequireDependenciesPure.call` always returns `true`. # specify do # expect { require_dependencies_pure } # .to delegate_to(RequireDependenciesPure, :call) # .and_return_its_value # end # # Example (2): # def require_dependencies_not_pure # RequireDependenciesNotPure.call # end # # class RequireDependenciesNotPure # def self.call # return false if dependencies.required? # # dependencies.require! # # true # end # end # # # Does NOT work since `RequireDependenciesNotPure.call` returns `true` for the first time and `false` for the subsequent call. # specify do # expect { require_dependencies_not_pure } # .to delegate_to(RequireDependenciesNotPure, :call) # .and_return_its_value # end # # NOTE: If this expectation fails, it means `and_return_its_value` is NOT met. # expect(value).to eq(object.__send__(method, *args, **kwargs, &block)) if used_and_return_its_value? ## # IMPORTANT: A matcher should always return a boolean. # https://github.com/zverok/saharspec/blob/master/lib/saharspec/matchers/send_message.rb#L59 # # NOTE: RSpec raises exception when any `expect` is NOT satisfied. # So, this `true` is returned only when all `expect` are successful. # if used_with_arguments? actual_arguments_collection.any? do |(actual_args, actual_kwargs, actual_block)| actual_args == expected_args && actual_kwargs == expected_kwargs && actual_block == expected_block end else true end end ## # NOTE: Required by RSpec. # https://relishapp.com/rspec/rspec-expectations/v/3-8/docs/custom-matchers/define-a-matcher-supporting-block-expectations # def supports_block_expectations? true end ## # # def description "delegate to `#{printable_method}`" end def failure_message if used_with_arguments? "expected `#{printable_block_expectation}` to delegate to `#{printable_method}` with expected arguments at least once, but it didn't." else "expected `#{printable_block_expectation}` to delegate to `#{printable_method}` at least once, but it didn't." end end ## # IMPORTANT: `failure_message_when_negated` is NOT supported yet. # def with_arguments(*args, **kwargs, &block) chain[:with_arguments] = {args: args, kwargs: kwargs, block: block} self end def and_return_its_value chain[:and_return_its_value] = true self end def printable_method @printable_method ||= case Utils::Object.resolve_type(object) when "class", "module" "#{object}.#{method}" when "instance" "#{object.class}##{method}" end end private attr_reader :object, :method, :block_expectation def used_with_arguments? chain.key?(:with_arguments) end def used_and_return_its_value? chain.key?(:and_return_its_value) end def chain @chain ||= {} end def args @args ||= chain.dig(:with_arguments, :args) || [] end alias_method :expected_args, :args def kwargs @kwargs ||= chain.dig(:with_arguments, :kwargs) || {} end alias_method :expected_kwargs, :kwargs ## # NOTE: `if defined?` is used in order to cache `nil` if needed. # def block return @block if defined? @block @block = chain.dig(:with_arguments, :block) end alias_method :expected_block, :block def actual_arguments_collection @actual_arguments_collection ||= [] end ## # NOTE: An example of how RSpec extracts block source, but they marked it as private. # https://github.com/rspec/rspec-expectations/blob/311aaf245f2c5493572bf683b8c441cb5f7e44c8/lib/rspec/matchers/built_in/change.rb#L437 # # TODO: `printable_block_expectation` when `method_source` is available. # https://github.com/banister/method_source # # def printable_block_expectation # @printable_block_expectation ||= block_expectation.source # end # def printable_block_expectation @printable_block_expectation ||= "{ ... }" end end end end end end