lib/rr/injections/double_injection.rb

Summary

Maintainability
B
5 hrs
Test Coverage
module RR
  module Injections
    # RR::DoubleInjection is the binding of an subject and a method.
    # A double_injection has 0 to many Double objects. Each Double
    # has Argument Expectations and Times called Expectations.
    class DoubleInjection < Injection
      extend(Module.new do
        def find_or_create(subject_class, method_name)
          instances[subject_class][method_name.to_sym] ||= begin
            new(subject_class, method_name.to_sym).bind
          end
        end

        def find_or_create_by_subject(subject, method_name)
          find_or_create(class << subject; self; end, method_name)
        end

        def find(subject_class, method_name)
          instances[subject_class] && instances[subject_class][method_name.to_sym]
        end

        def find_by_subject(subject, method_name)
          find(class << subject; self; end, method_name)
        end

        def exists?(subject_class, method_name)
          !!find(subject_class, method_name)
        end

        def exists_by_subject?(subject, method_name)
          exists?((class << subject; self; end), method_name)
        end

        def dispatch_method(subject,
                            subject_class,
                            method_name,
                            arguments,
                            keyword_arguments,
                            block)
          subject_eigenclass = (class << subject; self; end)
          if (
            exists?(subject_class, method_name) &&
            ((subject_class == subject_eigenclass) || !subject.is_a?(Class))
          )
            find(subject_class, method_name.to_sym).dispatch_method(subject, arguments, keyword_arguments, block)
          else
            new(subject_class, method_name.to_sym).dispatch_original_method(subject, arguments, keyword_arguments, block)
          end
        end

        def reset
          instances.each do |subject_class, method_double_map|
            SingletonMethodAddedInjection.find(subject_class) && SingletonMethodAddedInjection.find(subject_class).reset
            method_double_map.keys.each do |method_name|
              reset_double(subject_class, method_name)
            end
            Injections::DoubleInjection.instances.delete(subject_class) if Injections::DoubleInjection.instances.has_key?(subject_class)
          end
        end

        def verify(*subjects)
          subject_classes = subjects.empty? ?
            Injections::DoubleInjection.instances.keys :
            subjects.map {|subject| class << subject; self; end}
          subject_classes.each do |subject_class|
            instances.include?(subject_class) &&
              instances[subject_class].keys.each do |method_name|
                verify_double(subject_class, method_name)
              end &&
              instances.delete(subject_class)
          end
        end

        # Verifies the DoubleInjection for the passed in subject and method_name.
        def verify_double(subject_class, method_name)
          Injections::DoubleInjection.find(subject_class, method_name).verify
        ensure
          reset_double subject_class, method_name
        end

        # Resets the DoubleInjection for the passed in subject and method_name.
        def reset_double(subject_class, method_name)
          double_injection = Injections::DoubleInjection.instances[subject_class].delete(method_name)
          double_injection.reset
          Injections::DoubleInjection.instances.delete(subject_class) if Injections::DoubleInjection.instances[subject_class].empty?
        end

        def instances
          @instances ||= HashWithObjectIdKey.new do |hash, subject_class|
            hash.set_with_object_id(subject_class, {})
          end
        end
      end)

      include ClassInstanceMethodDefined

      attr_reader :subject_class, :method_name, :doubles

      MethodArguments = Struct.new(:arguments,
                                   :keyword_arguments,
                                   :block)

      def initialize(subject_class, method_name)
        @subject_class = subject_class
        @method_name = method_name.to_sym
        @doubles = []
        @dispatch_method_delegates_to_dispatch_original_method = nil
      end

      # RR::DoubleInjection#register_double adds the passed in Double
      # into this DoubleInjection's list of Double objects.
      def register_double(double)
        @doubles << double
      end

      # RR::DoubleInjection#bind injects a method that acts as a dispatcher
      # that dispatches to the matching Double when the method
      # is called.
      def bind
        if subject_has_method_defined?(method_name)
          if subject_has_original_method?
            bind_method
          else
            bind_method_with_alias
          end
        else
          Injections::MethodMissingInjection.find_or_create(subject_class)
          Injections::SingletonMethodAddedInjection.find_or_create(subject_class)
          bind_method_that_self_destructs_and_delegates_to_method_missing
        end
        self
      end

      BoundObjects = {}

      def bind_method_that_self_destructs_and_delegates_to_method_missing
        id = BoundObjects.size
        BoundObjects[id] = subject_class

        subject_class.class_eval(<<-RUBY, __FILE__, __LINE__ + 1)
          def #{method_name}(*args, &block)
            ::RR::Injections::DoubleInjection::BoundObjects[#{id}].class_eval do
              remove_method(:#{method_name})
            end
            method_missing(:#{method_name}, *args, &block)
          end
          ruby2_keywords(:#{method_name}) if respond_to?(:ruby2_keywords, true)
        RUBY
        self
      end

      def bind_method
        id = BoundObjects.size
        BoundObjects[id] = subject_class

        if KeywordArguments.fully_supported?
          subject_class.class_eval(<<-RUBY, __FILE__, __LINE__ + 1)
            def #{method_name}(*args, **kwargs, &block)
              arguments = MethodArguments.new(args, kwargs, block)
              obj = ::RR::Injections::DoubleInjection::BoundObjects[#{id}]
              ::RR::Injections::DoubleInjection.dispatch_method(
                self,
                obj,
                :#{method_name},
                arguments.arguments,
                arguments.keyword_arguments,
                arguments.block
              )
            end
          RUBY
        else
          subject_class.class_eval(<<-RUBY, __FILE__, __LINE__ + 1)
            def #{method_name}(*args, &block)
              arguments = MethodArguments.new(args, {}, block)
              obj = ::RR::Injections::DoubleInjection::BoundObjects[#{id}]
              ::RR::Injections::DoubleInjection.dispatch_method(
                self,
                obj,
                :#{method_name},
                arguments.arguments,
                arguments.keyword_arguments,
                arguments.block
              )
            end
            ruby2_keywords(:#{method_name}) if respond_to?(:ruby2_keywords, true)
          RUBY
        end
        self
      end

      # RR::DoubleInjection#verify verifies each Double
      # TimesCalledExpectation are met.
      def verify
        @doubles.each do |double|
          double.verify
        end
      end

      # RR::DoubleInjection#reset removes the injected dispatcher method.

      # It binds the original method implementation on the subject
      # if one exists.
      def reset
        if subject_has_original_method?
          subject_class.__send__(:remove_method, method_name)
          subject_class.__send__(:alias_method, method_name, original_method_alias_name)
          subject_class.__send__(:remove_method, original_method_alias_name)
        else
          if subject_has_method_defined?(method_name)
            subject_class.__send__(:remove_method, method_name)
          end
        end
      end

      def dispatch_method(subject, args, kwargs, block)
        if @dispatch_method_delegates_to_dispatch_original_method
          dispatch_original_method(subject, args, kwargs, block)
        else
          dispatch = MethodDispatches::MethodDispatch.new(
            self,
            subject,
            args,
            kwargs,
            block
          )
          dispatch.call
        end
      end

      def dispatch_original_method(subject, args, kwargs, block)
        dispatch = MethodDispatches::MethodDispatch.new(
          self,
          subject,
          args,
          kwargs,
          block
        )
        dispatch.call_original_method
      end

      def subject_has_original_method_missing?
        class_instance_method_defined(subject_class, MethodDispatches::MethodMissingDispatch.original_method_missing_alias_name)
      end

      def original_method_alias_name
        "__rr__original_#{@method_name}"
      end

      def dispatch_method_delegates_to_dispatch_original_method
        @dispatch_method_delegates_to_dispatch_original_method = true
        yield
      ensure
        @dispatch_method_delegates_to_dispatch_original_method = nil
      end

    protected
      def deferred_bind_method
        if respond_to?(method_name) and
            not subject_has_method_defined?(original_method_alias_name)
          bind_method_with_alias
        end
        @performed_deferred_bind = true
      end

      def bind_method_with_alias
        subject_class.__send__(:alias_method, original_method_alias_name, method_name)
        bind_method
      end
    end
  end
end