Skip to content

Commit

Permalink
refactor transition matcher to use prism
Browse files Browse the repository at this point in the history
  • Loading branch information
ezekg committed Aug 13, 2024
1 parent d1177d0 commit 872dc48
Show file tree
Hide file tree
Showing 2 changed files with 84 additions and 23 deletions.
101 changes: 81 additions & 20 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
require 'sqlite3'
require 'logger'

require 'rspec/mocks/standalone'
require 'prism'

ActiveRecord::Base.logger = ActiveSupport::TaggedLogging.new(
ActiveSupport::Logger.new(STDOUT),
)
Expand All @@ -17,41 +20,99 @@
)

RSpec::Matchers.define_negated_matcher :not_change, :change
RSpec::Matchers.define :transition do |receiver, method_name|
supports_block_expectations

match do |expectation|
setter_method = receiver.method(:"#{method_name}=")
getter_method = receiver.method(method_name)
initial_state = getter_method.call
module TransitionThrough
##
# TransitionExpression walks a Prism AST until we find a transition expression, e.g.:
#
# expect { ... }.to transition { ... }.through [...]
#
# Returns the transition state in the transition block.
class TransitionExpression < Prism::Visitor
Result = Data.define(:receiver, :method_name)

@actual_states = [initial_state]
attr_reader :at, :result

allow(receiver).to receive(setter_method.name) do |value|
@actual_states << value
def initialize(at:) = @at = at

setter_method.call(value)
def visit_call_node(node)
case node
in name: :transition, block: Prism::BlockNode(body: Prism::Node(body: [Prism::CallNode(receiver:, name: method_name)]), location:) if location.start_line == at
@result = Result.new(receiver:, method_name:)
else
super
end
end
end

expectation.call
class Matcher
include RSpec::Matchers, RSpec::Matchers::Composable, RSpec::Mocks::ExampleMethods

@actual_states == @expected_states
end
attr_reader :state_block

chain :through do |expected_states|
@expected_states = expected_states
end
def initialize(state_block)
@state_block = state_block
@expected_states = []
@actual_states = []
end

def supports_block_expectations? = true
def matches?(expect_block)
path, start_line = state_block.source_location

# walk the ast until we find our transition expression
exp = TransitionExpression.new(at: start_line)
ast = Prism.parse_file(path)

ast.value.accept(exp)

# get the actual transitioning object from the state block's binding
receiver = state_block.binding.eval(exp.result.receiver.name.to_s)

# get the receivers method names for stubbing
setter = receiver.method(:"#{exp.result.method_name}=")
getter = receiver.method(exp.result.method_name)

# record initial state
@actual_states = [getter.call]

# stub the setter so that we can track state transitions
allow(receiver).to receive(setter.name) do |value|
@actual_states << value

failure_message do
"expected block to transition through #{@expected_states} but it transitioned through #{@actual_states}"
setter.call(value)
end

# call the expect block
expect_block.call

# assert states match
@actual_states == @expected_states
end

def through(*values)
@expected_states = values.flatten(1)

self
end

def failure_message
"expected block to transition through #{@expected_states.inspect} but it transitioned through #{@actual_states.inspect}"
end

def failure_message_when_negated
"expected block not to transition through #{@expected_states.inspect} but it did"
end
end

failure_message_when_negated do
"expected block not to transition through #{@expected_states} but it did"
module Methods
def transition(&block) = Matcher.new(block)
end
end

RSpec.configure do |config|
config.include TransitionThrough::Methods

config.expect_with(:rspec) { _1.syntax = :expect }
config.disable_monkey_patching!

Expand Down
6 changes: 3 additions & 3 deletions spec/verbose_migrations_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,15 @@
migration.verbose!(logger:)

expect { instance.migrate(:up) }.to(
transition(logger, :level).through [Logger::UNKNOWN, Logger::DEBUG, Logger::UNKNOWN]
transition { logger.level }.through [Logger::UNKNOWN, Logger::DEBUG, Logger::UNKNOWN]
)
end

it 'enables verbose logging at custom :level' do
migration.verbose!(level: Logger::INFO)

expect { instance.migrate(:up) }.to(
transition(logger, :level).through [Logger::UNKNOWN, Logger::INFO, Logger::UNKNOWN]
transition { logger.level }.through [Logger::UNKNOWN, Logger::INFO, Logger::UNKNOWN]
)
end

Expand All @@ -60,7 +60,7 @@
migration.verbose!(logger: verbose_logger)

expect { migration.new.migrate(:up) }.to(
transition(verbose_logger, :level).through([Logger::UNKNOWN, Logger::DEBUG, Logger::UNKNOWN]).and(
transition { verbose_logger.level }.through(Logger::UNKNOWN, Logger::DEBUG, Logger::UNKNOWN).and(
not_change { logger.level },
),
)
Expand Down

0 comments on commit 872dc48

Please sign in to comment.