Skip to content

Commit

Permalink
Refactor ExampleConstants to use stub_const.
Browse files Browse the repository at this point in the history
  • Loading branch information
sleepingkingstudios committed Sep 19, 2024
1 parent d7b428d commit f14b0b0
Show file tree
Hide file tree
Showing 3 changed files with 211 additions and 236 deletions.
177 changes: 98 additions & 79 deletions lib/rspec/sleeping_king_studios/concerns/example_constants.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,40 @@

module RSpec::SleepingKingStudios::Concerns
# Methods for defining example-scoped classes and constants.
module ExampleConstants # rubocop:disable Metrics/ModuleLength
module ExampleConstants
DEFAULT_VALUE = Object.new.freeze
private_constant :DEFAULT_VALUE

# @api private
ExampleConstant = Struct.new(:name, :value)

# @api private
module InstanceMethods
# @api private
def apply_example_constants(example_constants) # rubocop:disable Metrics/MethodLength
return if example_constants_applied?

example_constants.each do |example_constant|
resolved_value =
if example_constant.value.is_a?(Proc)
instance_exec(&example_constant.value)
else
example_constant.value
end

stub_const(example_constant.name, resolved_value)
end

@example_constants_applied = true
end

private

def example_constants_applied?
@example_constants_applied
end
end

class << self
# @api private
def define_class(class_name:, example:, base_class: nil, &block)
Expand All @@ -23,66 +53,14 @@ def define_class(class_name:, example:, base_class: nil, &block)
end

# @api private
def with_constant( # rubocop:disable Metrics/MethodLength
constant_name:,
constant_value:,
namespace:,
force: false
)
guard_existing_constant!(namespace, constant_name) unless force

prior_value = DEFAULT_VALUE

if namespace.const_defined?(constant_name)
prior_value = namespace.const_get(constant_name)
end

namespace.const_set(constant_name, constant_value)
def extended(other)
super

yield
ensure
if prior_value == DEFAULT_VALUE
namespace.send :remove_const, constant_name
else
namespace.const_set(constant_name, prior_value)
end
end

# @api private
def with_namespace(module_names) # rubocop:disable Metrics/MethodLength
last_defined = nil

resolved =
module_names.reduce(Object) do |namespace, module_name|
if namespace.const_defined?(module_name)
next namespace.const_get(module_name)
end

last_defined ||= { namespace:, module_name: }

namespace.const_set(module_name, Module.new)
end

yield resolved
ensure
if last_defined
last_defined[:namespace]
.send(:remove_const, last_defined[:module_name])
end
other.include InstanceMethods
end

private

def guard_existing_constant!(namespace, constant_name)
return unless namespace.const_defined?(constant_name)

message =
"constant #{constant_name} is already defined with value " \
"#{namespace.const_get(constant_name).inspect}"

raise NameError, message
end

def resolve_base_class(value)
value = value.fetch(:base_class, nil) if value.is_a?(Hash)

Expand All @@ -94,6 +72,29 @@ def resolve_base_class(value)
end
end

# @api private
def each_example_constant(&)
return enum_for(:each_example_constant) unless block_given?

ancestors.reverse_each do |ancestor|
next unless ancestor.respond_to?(:defined_example_constants, true)

ancestor.defined_example_constants.each(&)
end
end

# Defines a temporary class scoped to the current example.
#
# @param class_name [String] the qualified name of the class.
# @param base_class [Class, String] the base class or name of the base
# class. This can be the name of another example class, as long as the
# base class is defined earlier in the example group or in a parent
# example group.
#
# @yield definitions for the temporary class. This block is evaluated in
# the context of the example, meaning that methods or memoized helpers
# can be referenced.
# @yieldparam klass [Class] the temporary class.
def example_class(class_name, base_class = nil, &block)
class_name = class_name.to_s if class_name.is_a?(Symbol)

Expand All @@ -107,34 +108,52 @@ def example_class(class_name, base_class = nil, &block)
end
end

# @overload example_constant(constant_name, constant_value)
# Defines a temporary constant scoped to the current example.
#
# @param constant_name [String] the qualified name of the constant.
# @param constant_value [Object] the value of the constant.
#
# @overload example_constant(constant_name, &block)
#
# @param constant_name [String] the qualified name of the constant.
#
# @yield generates the constant value. This block is evaluated in the
# context of the example, meaning that methods or memoized helpers can
# be referenced.
# @yieldreturn the value of the constant.
#
# @deprecate 2.8.0 with force: true parameter.
def example_constant( # rubocop:disable Metrics/MethodLength
qualified_name,
constant_value = DEFAULT_VALUE,
constant_name,
constant_value = nil,
force: false,
&block
)
around(:example) do |wrapped_example|
resolved_value =
if constant_value == DEFAULT_VALUE && block_given?
wrapped_example.example.instance_exec(&block)
else
constant_value
end

*module_names, constant_name = qualified_name.to_s.split('::')

ExampleConstants.with_namespace(module_names) do |namespace|
ExampleConstants.with_constant(
constant_name:,
constant_value: resolved_value,
namespace:,
force:
) \
do
wrapped_example.call
end
end
if force
SleepingKingStudios::Tools::Toolbelt
.instance
.core_tools
.deprecate(
'ExampleConstants.example_constant with force: true',
message: 'The :force parameter is no longer required.'
)
end

defined_example_constants << ExampleConstant.new(
constant_name,
constant_value || block
)

prepend_before(:example) do
apply_example_constants(self.class.each_example_constant)
end
end

protected

def defined_example_constants
@defined_example_constants ||= []
end
end
end
40 changes: 38 additions & 2 deletions spec/integration/concerns/example_constants_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
RSpec.describe RSpec::SleepingKingStudios::Concerns::ExampleConstants do
extend RSpec::SleepingKingStudios::Concerns::ExampleConstants # rubocop:disable RSpec/DescribedClass

describe '#example_class' do
describe '.example_class' do
let(:described_class) { ExampleClass }

describe 'with a class name' do
Expand Down Expand Up @@ -69,9 +69,45 @@

it { expect(instance.ok).to be true }
end

describe 'hooks' do
let(:ref) { Struct.new(:value).new }

example_class 'ExampleClass'

before(:example) do
ref.value = ExampleClass
end

it { expect(ref.value).to be_a Class }
end

describe 'inheritance' do
example_class 'Spec::Grandparent'

it { expect(Spec::Grandparent).to be_a Class }

describe 'with a child example group' do
example_class 'Spec::Parent', 'Spec::Grandparent'

it { expect(Spec::Grandparent).to be_a Class }

it { expect(Spec::Parent).to be_a Class }

describe 'with a grandchild example group' do
example_class 'Spec::Child', 'Spec::Parent'

it { expect(Spec::Grandparent).to be_a Class }

it { expect(Spec::Parent).to be_a Class }

it { expect(Spec::Child).to be_a Class }
end
end
end
end

describe '#example_constant' do
describe '.example_constant' do
describe 'with a constant name and a block' do
let(:the_answer) { 42 }

Expand Down
Loading

0 comments on commit f14b0b0

Please sign in to comment.