M README.md +29 -0
@@ 56,6 56,35 @@ Thanks also to Alyssa Verkade for the in
## License
+This gem includes code from the rspec-expectations gem, used under the
+terms of the MIT License:
+
+ Copyright (c) 2012 David Chelimsky, Myron Marston
+ Copyright (c) 2006 David Chelimsky, The RSpec Development Team
+ Copyright (c) 2005 Steven Baker
+
+ Permission is hereby granted, free of charge, to any person obtaining
+ a copy of this software and associated documentation files (the
+ "Software"), to deal in the Software without restriction, including
+ without limitation the rights to use, copy, modify, merge, publish,
+ distribute, sublicense, and/or sell copies of the Software, and to
+ permit persons to whom the Software is furnished to do so, subject to
+ the following conditions:
+
+ The above copyright notice and this permission notice shall be
+ included in all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+
+Pushdown itself is:
+
Copyright (c) 2019-2021, Michael Granger
All rights reserved.
A => lib/pushdown/spec_helpers.rb +263 -0
@@ 0,0 1,263 @@
+# -*- ruby -*-
+# frozen_string_literal: true
+
+require 'rspec'
+require 'rspec/matchers'
+
+require 'loggability'
+
+require 'pushdown' unless defined?( Pushdown )
+
+
+module Pushdown::SpecHelpers
+
+
+ # RSpec matcher for matching transitions of Pushdown::States
+ class StateTransitionMatcher
+ extend Loggability
+ include RSpec::Matchers
+
+ DEFAULT_CALLBACK = [ :update ]
+
+ log_to :pushdown
+
+
+ ### Create a new matcher that expects a transition to occur.
+ def initialize
+ @expected_type = nil
+ @target_state = nil
+ @callback = nil
+ @additional_expectations = []
+ @state = nil
+ @result = nil
+ @failure_description = nil
+ end
+
+
+ attr_reader :expected_type,
+ :target_state,
+ :callback,
+ :additional_expectations,
+ :state,
+ :result,
+ :failure_description
+
+
+ ### RSpec matcher API -- returns +true+ if all expectations are met after calling
+ ### #update on the specified +state+.
+ def matches?( state )
+ @state = state
+
+ return self.update_ran_without_error? &&
+ self.update_returned_transition? &&
+ self.correct_transition_type? &&
+ self.correct_target_state? &&
+ self.matches_additional_expectations?
+ end
+
+
+ ### RSpec matcher API -- return a message describing an expectation failure.
+ def failure_message
+ return "%p: %s" % [ self.state, self.describe_failure ]
+ end
+
+
+ ### RSpec matcher API -- return a message describing an expectation being met
+ ### when the matcher was used in a negated context.
+ def failure_message_when_negated
+ return "%p: %s" % [ self.state, self.describe_negated_failure ]
+ end
+
+
+ #
+ # Mutators
+ #
+
+ ### Add an additional expectation that the transition that occurs be of the specified
+ ### +transition_type+.
+ def via_transition_type( transition_type )
+ @expected_type = transition_type
+ return self
+ end
+ alias_method :via, :via_transition_type
+
+
+ ### Add an additional expectation that the state that is transitioned to be of
+ ### the given +state_name+. This only applies to transitions that take a target
+ ### state type. Expecting a particular state_name in transitions which do not take
+ ### a state is undefined behavior.
+ def to_state( state_name )
+ @target_state = state_name
+ return self
+ end
+ alias_method :to, :to_state
+
+
+ ### Specify that the operation that should cause the transition is the #update callback.
+ ### This is the default.
+ def on_update
+ raise ScriptError, "can't specify more than one callback" if self.callback
+ @callback = [ :update ]
+ return self
+ end
+
+
+ ### Specify that the operation that should cause the transition is the #on_event callback.
+ def on_an_event( event )
+ raise ScriptError, "can't specify more than one callback" if self.callback
+ @callback = [ :on_event, event ]
+ return self
+ end
+ alias_method :on_event, :on_an_event
+
+
+ #########
+ protected
+ #########
+
+ ### Call the state's update callback and record the result, then return +true+
+ ### if no exception was raised.
+ def update_ran_without_error?
+ operation = self.callback || DEFAULT_CALLBACK
+
+ @result = begin
+ self.state.public_send( *operation )
+ rescue => err
+ err
+ end
+
+ return !@result.is_a?( Exception )
+ end
+
+
+ ### Returns +true+ if the result of calling #update was a Transition or a Symbol
+ ### that corresponds with a valid transition.
+ def update_returned_transition?
+ case self.result
+ when Pushdown::Transition
+ return true
+ when Symbol
+ return self.state.class.transitions.include?( self.result )
+ else
+ return false
+ end
+ end
+
+
+ ### Returns +true+ if a transition type was specified and the transition which
+ ### occurred was of that type.
+ def correct_transition_type?
+ type = self.expected_type or return true
+
+ case self.result
+ when Pushdown::Transition
+ self.result.type_name == type
+ when Symbol
+ self.state.class.transitions[ self.result ].first == type
+ else
+ raise "unexpected transition result type %p" % [ self.result ]
+ end
+ end
+
+
+ ### Returns +true+ if a target state was specified and the transition which
+ ### occurred was to that state.
+ def correct_target_state?
+ state_name = self.target_state or return true
+
+ case self.result
+ when Pushdown::Transition
+ return self.result.respond_to?( :state_class ) &&
+ self.result.state_class.type_name == state_name
+ when Symbol
+ self.state.class.transitions[ self.result ][ 1 ] == state_name
+ end
+ end
+
+
+ ### Build an appropriate failure messages for the matcher.
+ def describe_failure
+ desc = String.new( "expected to transition" )
+ desc << " via %s" % [ self.expected_type ] if self.expected_type
+ desc << " to %s" % [ self.target_state ] if self.target_state
+
+ if self.callback
+ methname, arg = self.callback
+ desc << " when #%s is called" % [ methname ]
+ desc << " with %p" % [ arg ] if arg
+ end
+
+ desc << ', but '
+
+ case self.result
+ when Exception
+ err = self.result
+ desc << "got %p: %s" % [ err.class, err.message ]
+ when Symbol
+ transition = self.state.class.transitions[ self.result ]
+
+ if transition
+ desc << "it returned a %s transition " % [ transition.first ]
+ desc << "to %s " % [ transition[1] ] if transition[1]
+ desc << " instead"
+ else
+ desc << "it returned an unmapped Symbol (%p)" % [ self.result ]
+ end
+ when Pushdown::Transition
+ desc << "it returned a %s transition" % [ self.result.type_name ]
+ desc << " to %s" % [ self.result.state_class.type_name ] if
+ self.result.respond_to?( :state_class )
+ desc << " instead"
+ else
+ desc << "it did not (returned: %p)" % [ self.result ]
+ end
+
+ return desc
+ end
+
+
+ ### Build an appropriate failure message for the matcher.
+ def describe_negated_failure
+ desc = String.new( "expected not to transition" )
+ desc << " via %s" % [ self.expected_type ] if self.expected_type
+ desc << " to %s" % [ self.target_state ] if self.target_state
+
+ desc << ', but it did.'
+
+ return desc
+ end
+
+
+ ### Return an Array of descriptions of the members that were expected to be included in the
+ ### state body, if any were specified. If none were specified, returns an empty
+ ### Array.
+ def describe_additional_expectations
+ return self.additional_expectations.map( &:description )
+ end
+
+
+ ### Check that any additional matchers registered via the `.and` mutator also
+ ### match the parsed state body.
+ def matches_additional_expectations?
+ return self.additional_expectations.all? do |matcher|
+ matcher.matches?( self.parsed_state_body ) or
+ fail_with( matcher.failure_message )
+ end
+ end
+
+ end # class HaveJSONBodyMatcher
+
+
+ ###############
+ module_function
+ ###############
+
+ ### Set up an expectation that a transition or valid Symbol will be returned.
+ def transition
+ return StateTransitionMatcher.new
+ end
+
+
+end # module Pushdown::SpecHelpers
+
+
M lib/pushdown/state.rb +15 -0
@@ 49,6 49,14 @@ class Pushdown::State
end
+ ### Return the transition's type as a lowercase Symbol, such as that specified
+ ### in transition declarations.
+ def self::type_name
+ class_name = self.name or return :anonymous
+ return class_name.sub( /.*::/, '' ).downcase.to_sym
+ end
+
+
#
# Stack callbacks
#
@@ 111,6 119,13 @@ class Pushdown::State
# Introspection/information
#
+ ### Return the transition's type as a lowercase Symbol, such as that specified
+ ### in transition declarations.
+ def type_name
+ return self.class.type_name
+ end
+
+
### Return a description of the State as an engine phrase.
def description
return "%#x" % [ self.class.object_id ] unless self.class.name
M lib/pushdown/transition.rb +8 -0
@@ 63,5 63,13 @@ class Pushdown::Transition
[ self.class, __method__ ]
end
+
+ ### Return the transition's type as a lowercase Symbol, such as that specified
+ ### in transition declarations.
+ def type_name
+ class_name = self.class.name or return :anonymous
+ return class_name.sub( /.*::/, '' ).downcase.to_sym
+ end
+
end # class Pushdown::Transition
A => spec/pushdown/spec_helpers_spec.rb +478 -0
@@ 0,0 1,478 @@
+# -*- ruby -*-
+# frozen_string_literal: true
+
+require_relative '../spec_helper'
+
+require 'pushdown/spec_helpers'
+
+
+RSpec.describe( Pushdown::SpecHelpers ) do
+
+ include Pushdown::SpecHelpers
+
+ #
+ # Expectation-failure Matchers (stolen from rspec-expectations)
+ # See the README for licensing information.
+ #
+
+ def fail
+ raise_error( RSpec::Expectations::ExpectationNotMetError )
+ end
+
+ def fail_with( message )
+ raise_error( RSpec::Expectations::ExpectationNotMetError, message )
+ end
+
+ def fail_matching( message )
+ if String === message
+ regexp = /#{Regexp.escape(message)}/
+ else
+ regexp = message
+ end
+ raise_error( RSpec::Expectations::ExpectationNotMetError, regexp )
+ end
+
+ def fail_including( *messages )
+ raise_error do |err|
+ expect( err ).to be_a( RSpec::Expectations::ExpectationNotMetError )
+ expect( err.message ).to include( *messages )
+ end
+ end
+
+ def dedent( string )
+ return string.gsub( /^\t+/, '' ).chomp
+ end
+
+
+ let( :state_class ) do
+ subclass = Class.new( Pushdown::State )
+ end
+
+ let( :seeking_state_class ) do
+ subclass = Class.new( Pushdown::State )
+ subclass.singleton_class.attr_accessor( :name )
+ subclass.name = 'Acme::State::Seeking'
+ return subclass
+ end
+
+
+
+ describe "transition matcher" do
+
+ it "passes if a Pushdown::Transition is returned" do
+ state_class.attr_accessor( :seeking_state_class )
+ state_class.define_method( :update ) do |*|
+ return Pushdown::Transition.create( :push, :change, self.seeking_state_class )
+ end
+
+ state = state_class.new
+ state.seeking_state_class = seeking_state_class # inject the "seeking" state class
+
+ expect {
+ expect( state ).to transition
+ }.to_not raise_error
+ end
+
+
+ it "passes if a Symbol that maps to a declared transition is returned" do
+ state_class.transition_push( :change, :other )
+ state_class.define_method( :update ) do |*|
+ return :change
+ end
+
+ state = state_class.new
+
+ expect {
+ expect( state ).to transition
+ }.to_not raise_error
+ end
+
+
+ it "fails if a Symbol that does not map to a declared transition is returned" do
+ state_class.transition_push( :change, :other )
+ state_class.define_method( :update ) do |*|
+ return :something_else
+ end
+
+ state = state_class.new
+
+ expect {
+ expect( state ).to transition
+ }.to fail_matching( /unmapped symbol/i )
+ end
+
+
+ it "fails if something other than a Transition or Symbol is returned" do
+ state_class.transition_push( :change, :other )
+
+ state = state_class.new
+
+ expect {
+ expect( state ).to transition
+ }.to fail_matching( /expected to transition.*it did not/i )
+ end
+
+ end
+
+
+ describe "transition.via mutator" do
+
+ it "passes if the state returns a Symbol that maps to the specified kind of transition" do
+ state_class.transition_push( :seek, :seeking )
+ state_class.define_method( :update ) do |*|
+ return :seek
+ end
+
+ state = state_class.new
+
+ expect {
+ expect( state ).to transition.via( :push )
+ }.to_not raise_error
+ end
+
+
+ it "passes if the state returns a Pushdown::Transition of the correct type" do
+ state_class.attr_accessor( :seeking_state_class )
+ state_class.define_method( :update ) do |*|
+ return Pushdown::Transition.create( :push, :seek, self.seeking_state_class )
+ end
+
+ state = state_class.new
+ state.seeking_state_class = seeking_state_class # inject the "seeking" state class
+
+ expect {
+ expect( state ).to transition.via( :push )
+ }.to_not raise_error
+ end
+
+
+ it "fails with a detailed failure message if the state doesn't transition" do
+ state_class.transition_push( :seek, :seeking )
+
+ state = state_class.new
+
+ expect {
+ expect( state ).to transition.via( :push )
+ }.to fail_matching( /expected to transition via push.*returned: nil/i )
+ end
+
+
+ it "fails if the state returns a different kind of Pushdown::Transition" do
+ state_class.define_method( :update ) do |*|
+ return Pushdown::Transition.create( :pop, :restart )
+ end
+
+ state = state_class.new
+
+ expect {
+ expect( state ).to transition.via( :push )
+ }.to fail_matching( /transition via push.*pop/i )
+ end
+
+
+ it "fails if the state terutns a Symbol that maps to the wrong kind of transition" do
+ state_class.transition_pop( :seek )
+ state_class.define_method( :update ) do |*|
+ return :seek
+ end
+
+ state = state_class.new
+
+ expect {
+ expect( state ).to transition.via( :push )
+ }.to fail_matching( /transition via push.*it returned a pop/i )
+ end
+
+ end
+
+
+ describe "transition.to mutator" do
+
+ it "passes if the state returns a Symbol that maps to a transition to the specified state" do
+ state_class.transition_push( :seek, :seeking )
+ state_class.define_method( :update ) do |*|
+ return :seek
+ end
+
+ state = state_class.new
+
+ expect {
+ expect( state ).to transition.to( :seeking )
+ }.to_not raise_error
+ end
+
+
+ it "passes if the state returns a Pushdown::Transition with the correct target state" do
+ state_class.attr_accessor( :seeking_state_class )
+ state_class.define_method( :update ) do |*|
+ return Pushdown::Transition.create( :push, :seek, self.seeking_state_class )
+ end
+
+ state = state_class.new
+ state.seeking_state_class = seeking_state_class # inject the "seeking" state class
+
+ expect {
+ expect( state ).to transition.to( :seeking )
+ }.to_not raise_error
+ end
+
+
+ it "fails if the state returns a Symbol that maps to a transition to a different state" do
+ state_class.transition_push( :seek, :seeking )
+ state_class.define_method( :update ) do |*|
+ return :seek
+ end
+
+ state = state_class.new
+
+ expect {
+ expect( state ).to transition.to( :broadcasting )
+ }.to fail_matching( /broadcasting.*seeking/i )
+ end
+
+
+ it "fails with a detailed failure message if the state doesn't transition" do
+ state_class.transition_push( :seek, :seeking )
+
+ state = state_class.new
+
+ expect {
+ expect( state ).to transition.to( :seeking )
+ }.to fail_matching( /to seeking.*returned: nil/i )
+ end
+
+
+ it "fails if a Pushdown::Transition with a different target state is returned" do
+ state_class.attr_accessor( :seeking_state_class )
+ state_class.define_method( :update ) do |*|
+ return Pushdown::Transition.create( :push, :seek, self.seeking_state_class )
+ end
+
+ state = state_class.new
+ state.seeking_state_class = seeking_state_class # inject the "seeking" state class
+
+ expect {
+ expect( state ).to transition.to( :other )
+ }.to fail_matching( /other.*seeking/i )
+ end
+
+ end
+
+
+ describe "composed matcher" do
+
+ it "passes if both .to and .via are specified and match" do
+ state_class.transition_push( :seek, :seeking )
+ state_class.define_method( :update ) do |*|
+ return :seek
+ end
+
+ state = state_class.new
+
+ expect {
+ expect( state ).to transition.via( :push ).to( :seeking )
+ }.to_not raise_error
+ end
+
+
+ it "fails if both .to and .via are specified and .to doesn't match" do
+ state_class.transition_push( :seek, :broadcasting )
+ state_class.define_method( :update ) do |*|
+ return :seek
+ end
+
+ state = state_class.new
+
+ expect {
+ expect( state ).to transition.via( :push ).to( :seeking )
+ }.to fail_matching( /seeking.*broadcasting/i )
+ end
+
+
+ it "fails if both .to and .via are specified and .via doesn't match" do
+ state_class.transition_push( :seek, :broadcasting )
+ state_class.define_method( :update ) do |*|
+ return :seek
+ end
+
+ state = state_class.new
+
+ expect {
+ expect( state ).to transition.via( :switch ).to( :broadcasting )
+ }.to fail_matching( /switch.*push/i )
+ end
+
+
+ it "fails if both .to and .via are specified and neither match" do
+ state_class.transition_push( :seek, :broadcasting )
+ state_class.define_method( :update ) do |*|
+ return :seek
+ end
+
+ state = state_class.new
+
+ expect {
+ expect( state ).to transition.via( :switch ).to( :seeking )
+ }.to fail_matching( /switch.*seeking.*push.*broadcasting/i )
+ end
+
+ end
+
+
+ describe "operation mutators" do
+
+ it "allows the #update operation to be explicitly specified" do
+ state_class.transition_push( :change, :other )
+ state_class.define_method( :update ) do |*|
+ return :change
+ end
+ state_class.define_method( :on_event ) do |*|
+ return nil
+ end
+
+ state = state_class.new
+
+ expect {
+ expect( state ).to transition.on_update
+ }.to_not raise_error
+ end
+
+
+ it "allows the #on_event operation to be explicitly specified" do
+ state_class.transition_push( :change, :other )
+ state_class.define_method( :on_event ) do |*|
+ return :change
+ end
+
+ state = state_class.new
+
+ expect {
+ expect( state ).to transition.on_an_event( :foo )
+ }.to_not raise_error
+ end
+
+
+ it "adds the callback to the description if on_update is specified" do
+ state_class.transition_push( :change, :other )
+
+ state = state_class.new
+
+ expect {
+ expect( state ).to transition.on_update
+ }.to fail_matching( /transition when #update is called/i )
+ end
+
+
+ it "adds the callback to the description if on_an_event is specified" do
+ state_class.transition_push( :change, :other )
+
+ state = state_class.new
+
+ expect {
+ expect( state ).to transition.on_an_event( :foo )
+ }.to fail_matching( /transition when #on_event is called with :foo/i )
+ end
+
+ end
+
+ describe "negated matcher" do
+
+ it "succeeds if a Symbol that does not map to a declared transition is returned" do
+ state_class.transition_push( :change, :other )
+ state_class.define_method( :update ) do |*|
+ return :something_else
+ end
+
+ state = state_class.new
+
+ expect {
+ expect( state ).not_to transition
+ }.to_not raise_error
+ end
+
+
+ it "succeeds if something other than a Transition or Symbol is returned" do
+ state_class.transition_push( :change, :other )
+
+ state = state_class.new
+
+ expect {
+ expect( state ).not_to transition
+ }.to_not raise_error
+ end
+
+
+ it "succeeds if a specific transition is given, but a different one is returned" do
+ state_class.transition_push( :change, :other )
+ state_class.define_method( :update ) do |*|
+ return :change
+ end
+
+ state = state_class.new
+
+ expect {
+ expect( state ).not_to transition.via( :switch )
+ }.to_not raise_error
+ end
+
+
+ it "succeeds if a target state is given, but a different one is returned" do
+ state_class.transition_push( :change, :broadcasting )
+ state_class.define_method( :update ) do |*|
+ return :change
+ end
+
+ state = state_class.new
+
+ expect {
+ expect( state ).not_to transition.via( :push ).to( :seeking )
+ }.to_not raise_error
+ end
+
+
+ it "fails if a Pushdown::Transition is returned" do
+ state_class.attr_accessor( :seeking_state_class )
+ state_class.define_method( :update ) do |*|
+ return Pushdown::Transition.create( :push, :change, self.seeking_state_class )
+ end
+
+ state = state_class.new
+ state.seeking_state_class = seeking_state_class # inject the "seeking" state class
+
+ expect {
+ expect( state ).not_to transition
+ }.to fail_matching( /not to transition, but it did/i )
+ end
+
+
+ it "fails if a Symbol that maps to a declared transition is returned" do
+ state_class.transition_push( :change, :other )
+ state_class.define_method( :update ) do |*|
+ return :change
+ end
+
+ state = state_class.new
+
+ expect {
+ expect( state ).not_to transition
+ }.to fail_matching( /not to transition, but it did/i )
+ end
+
+
+ it "fails if a type and state are specified and they describe the returned transition" do
+ state_class.transition_push( :seek, :seeking )
+ state_class.define_method( :update ) do |*|
+ return :seek
+ end
+
+ state = state_class.new
+
+ expect {
+ expect( state ).not_to transition.via( :push ).to( :seeking )
+ }.to fail_matching( /not.*via push to seeking, but it did/i )
+ end
+
+ end
+
+
+end
M spec/pushdown/state_spec.rb +18 -0
@@ 33,6 33,24 @@ RSpec.describe( Pushdown::State ) do
end
+ it "knows what the name of its type is" do
+ starting_state_class.singleton_class.attr_accessor :name
+ starting_state_class.name = 'Acme::State::Starting'
+
+ state = starting_state_class.new
+
+ expect( state.type_name ).to eq( :starting )
+ end
+
+
+ it "handles anonymous classes for #type_name" do
+ transition = starting_state_class.new
+
+ expect( transition.type_name ).to eq( :anonymous )
+ end
+
+
+
describe "transition callbacks" do
it "has a default (no-op) callback for when it is added to the stack" do
M spec/pushdown/transition_spec.rb +17 -0
@@ 69,6 69,23 @@ RSpec.describe( Pushdown::Transition ) d
expect( result.last ).to be_a( state_class )
end
+
+ it "knows what the name of its type is" do
+ subclass.singleton_class.attr_accessor :name
+ subclass.name = 'Acme::Transition::Frobnicate'
+
+ transition = subclass.new( :rejigger, state_class )
+
+ expect( transition.type_name ).to eq( :frobnicate )
+ end
+
+
+ it "handles anonymous classes for #type_name" do
+ transition = subclass.new( :rejigger, state_class )
+
+ expect( transition.type_name ).to eq( :anonymous )
+ end
+
end