1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155
|
require "naught/basic_object"
require "naught/null_class_builder/command"
module Naught
class NullClassBuilder
module Commands
# Build a null class that mimics an existing class or instance
#
# @api private
class Mimic < Command
# Methods that should never be mimicked as they interfere with
# other Naught features like predicates_return
# @see https://github.com/avdi/naught/issues/55
METHODS_TO_SKIP = (%i[method_missing respond_to? respond_to_missing?] + Object.instance_methods).freeze
private_constant :METHODS_TO_SKIP
# Singleton class placeholder used when no instance is provided
NULL_SINGLETON_CLASS = Object.new.singleton_class.freeze
private_constant :NULL_SINGLETON_CLASS
# The class being mimicked by the null object
# @return [Class] class being mimicked
attr_reader :class_to_mimic
# Whether to include superclass methods when mimicking
# @return [Boolean] whether to include superclass methods
attr_reader :include_super
# The singleton class being mimicked (for instance-based mimicking)
# @return [Class] singleton class being mimicked
attr_reader :singleton_class
# The example instance for dynamic method discovery
# @return [Object, nil] example instance or nil
attr_reader :example_instance
# Whether to include dynamically-defined methods
# @return [Boolean] whether to include dynamic methods
attr_reader :include_dynamic
# Create a mimic command for a class or instance
#
# @param builder [NullClassBuilder]
# @param class_to_mimic_or_options [Class, Hash]
# @param options [Hash]
# @api private
def initialize(builder, class_to_mimic_or_options, options = {})
super(builder)
parse_arguments(class_to_mimic_or_options, options)
configure_builder
end
# Install stubbed methods from the target class or instance
#
# @return [void]
# @api private
def call
defer { |subject| methods_to_stub.each { |name| builder.stub_method(subject, name) } }
end
private
# Parse the arguments to determine what to mimic
#
# @param class_to_mimic_or_options [Class, Hash] class or options hash
# @param options [Hash] additional options
# @return [void]
def parse_arguments(class_to_mimic_or_options, options)
if class_to_mimic_or_options.is_a?(Hash)
options = class_to_mimic_or_options.merge(options)
@example_instance = options.fetch(:example)
@singleton_class = @example_instance.singleton_class
@class_to_mimic = @example_instance.class
else
@example_instance = nil
@singleton_class = NULL_SINGLETON_CLASS
@class_to_mimic = class_to_mimic_or_options
end
@include_super = options.fetch(:include_super, true)
@include_dynamic = options.fetch(:include_dynamic, !@example_instance.nil?)
end
# Configure the builder with the mimicked class's properties
#
# @return [void]
def configure_builder
builder.base_class = root_class_of(class_to_mimic)
klass = class_to_mimic
builder.inspect_proc = -> { "<null:#{klass}>" }
builder.interface_defined = true
end
# Determine the root class to inherit from
#
# @param klass [Class] the class to analyze
# @return [Class] Object or Naught::BasicObject
def root_class_of(klass) = klass.ancestors.include?(Object) ? Object : Naught::BasicObject
# Compute the list of methods to stub on the null object
#
# @return [Array<Symbol>] methods to stub
def methods_to_stub
all_methods = class_to_mimic.instance_methods(include_super) | singleton_class.instance_methods(false)
all_methods |= dynamic_methods if include_dynamic
all_methods - METHODS_TO_SKIP
end
# Discover dynamically-defined methods from the example instance
#
# This handles classes like Stripe that use method_missing and
# respond_to_missing? to define methods based on instance data.
#
# @return [Array<Symbol>] dynamic method names
def dynamic_methods
return [] unless example_instance
candidates = discover_method_candidates
candidates.select { |name| example_instance.respond_to?(name) }
end
# Discover candidate method names from the example instance
#
# Tries multiple approaches to find method names:
# 1. If the instance responds to :keys (like Stripe objects), use those
# 2. If the instance responds to :attributes, use those
# 3. If the instance responds to :to_h or :to_hash, use the hash keys
#
# @return [Array<Symbol>] candidate method names
def discover_method_candidates
candidates = [] #: Array[Symbol]
# Stripe-style objects expose keys
candidates |= example_instance.keys.map(&:to_sym) if example_instance.respond_to?(:keys)
# ActiveRecord-style objects expose attribute_names
if example_instance.respond_to?(:attribute_names)
candidates |= example_instance.attribute_names.map(&:to_sym)
end
# OpenStruct-style objects can be converted to hash
if example_instance.respond_to?(:to_h) && !example_instance.is_a?(Object.const_get(:Hash))
begin
hash = example_instance.to_h
candidates |= hash.keys.map(&:to_sym) if hash.is_a?(Hash)
rescue
# Ignore errors from to_h
end
end
candidates
end
end
end
end
end
|