Add spec helpers with a transition matcher.
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