composable_state_machine 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +15 -0
  2. data/.gitignore +17 -0
  3. data/.rspec +3 -0
  4. data/.simplecov +4 -0
  5. data/.travis.yml +8 -0
  6. data/.yardopts +4 -0
  7. data/Gemfile +4 -0
  8. data/Guardfile +5 -0
  9. data/LICENSE.txt +22 -0
  10. data/README.md +352 -0
  11. data/Rakefile +19 -0
  12. data/assets/class-diagram.yuml +24 -0
  13. data/assets/uml-class-diagram.png +0 -0
  14. data/composable_state_machine.gemspec +35 -0
  15. data/lib/composable_state_machine.rb +45 -0
  16. data/lib/composable_state_machine/behaviors.rb +48 -0
  17. data/lib/composable_state_machine/callback_runner.rb +19 -0
  18. data/lib/composable_state_machine/callbacks.rb +56 -0
  19. data/lib/composable_state_machine/default_callback_runner.rb +16 -0
  20. data/lib/composable_state_machine/invalid_event.rb +7 -0
  21. data/lib/composable_state_machine/invalid_transition.rb +7 -0
  22. data/lib/composable_state_machine/invalid_trigger.rb +7 -0
  23. data/lib/composable_state_machine/machine.rb +21 -0
  24. data/lib/composable_state_machine/machine_with_external_state.rb +41 -0
  25. data/lib/composable_state_machine/model.rb +55 -0
  26. data/lib/composable_state_machine/transitions.rb +73 -0
  27. data/lib/composable_state_machine/version.rb +3 -0
  28. data/spec/integration/auto_update_state_spec.rb +38 -0
  29. data/spec/integration/instance_callbacks_spec.rb +47 -0
  30. data/spec/integration/leave_callbacks_spec.rb +60 -0
  31. data/spec/integration/leave_callbacks_with_composition_spec.rb +68 -0
  32. data/spec/lib/composable_state_machine/behaviors_spec.rb +83 -0
  33. data/spec/lib/composable_state_machine/callback_runner_spec.rb +54 -0
  34. data/spec/lib/composable_state_machine/callbacks_spec.rb +106 -0
  35. data/spec/lib/composable_state_machine/machine_spec.rb +25 -0
  36. data/spec/lib/composable_state_machine/machine_with_external_state_spec.rb +97 -0
  37. data/spec/lib/composable_state_machine/model_spec.rb +76 -0
  38. data/spec/lib/composable_state_machine/transitions_spec.rb +77 -0
  39. data/spec/lib/composable_state_machine_spec.rb +53 -0
  40. data/spec/spec_helper.rb +14 -0
  41. data/spec/support/delegation.rb +196 -0
  42. metadata +218 -0
data/Rakefile ADDED
@@ -0,0 +1,19 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+ require 'yard'
4
+
5
+ desc 'Default: run the specs'
6
+ task :default do
7
+ system('bundle exec rspec')
8
+ end
9
+
10
+ desc 'Run the specs'
11
+ task :spec => :default
12
+
13
+ desc 'Open an irb session preloaded with this library'
14
+ task :console do
15
+ exec 'irb -rubygems -I lib -r composable_state_machine.rb'
16
+ end
17
+
18
+ YARD::Rake::YardocTask.new do |_|
19
+ end
@@ -0,0 +1,24 @@
1
+ // composable_state_machine class diagram
2
+ [≺≺interface≻≻;ICallbackRunner||run_state_machine_callback(callback){bg:green}]
3
+ [≺≺interface≻≻;ICallable||call(*)]
4
+ [≺≺interface≻≻;IMachine||trigger(event);==(other_state){bg:orange}]
5
+ [Machine|state|{bg:orange}]
6
+ [Model|initial_state|transition(event...);run_callbacks();run_callbacks_for(){bg:red}]
7
+ [Transitions||on(event...);transition(event...){bg:blue}]
8
+ [Behaviors||on(behavior...){bg:green}]
9
+ [Callbacks||on(trigger...){bg:green}]
10
+ [≺≺interface≻≻;ICallable]^-.-[Behaviors]
11
+ [≺≺interface≻≻;ICallable]^-.-[Callbacks]
12
+ [≺≺interface≻≻;ICallbackRunner]-.-> called[≺≺interface≻≻;ICallable]
13
+ [≺≺interface≻≻;ICallbackRunner]^-.-[≺≺mixin≻≻;CallbackRunner{bg:green}]
14
+ [≺≺interface≻≻;ICallbackRunner]^-.-[DefaultCallbackRunner{bg:green}]
15
+ [≺≺interface≻≻;IMachine]^-.-[MachineWithExternalState]
16
+ [Model]-1> default runner[≺≺interface≻≻;ICallbackRunner]
17
+ [Model]-1>[Transitions]
18
+ [Model]-1>[Behaviors]
19
+ [Behaviors]-*> by behavior[Callbacks]
20
+ [Callbacks]-*> by trigger[≺≺interface≻≻;ICallable]
21
+ [MachineWithExternalState{bg:orange}]^[Machine]
22
+ [MachineWithExternalState]-1>[Model]
23
+ [MachineWithExternalState]-1>[≺≺interface≻≻;ICallbackRunner]
24
+ [MachineWithExternalState]-2> state reader/writer[≺≺interface≻≻;ICallable]
Binary file
@@ -0,0 +1,35 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'composable_state_machine/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'composable_state_machine'
8
+ spec.version = ComposableStateMachine::VERSION
9
+ spec.authors = ['Simeon Simeonov']
10
+ spec.email = ['sim@swoop.com']
11
+ spec.description = %q{Small, fast and flexible state machines using composition.}
12
+ spec.summary = %q{The composition patterns in this implementation make it easy to circumvent the limitations of other state machine gems. A single state machine model can be shared across thousands of machine instances without the usual overhead. An object can have more than one state machine. States and events can be any objects, not just strings or symbols. Events can take optional parameters. Different state machine models can fire different types of callbacks. Adding new types of callbacks takes a couple of lines of code. Explicit callback runners enable easy decoration for logging, caching or other purposes. No external dependencies and 100% code coverage.}
13
+ spec.homepage = 'https://github.com/swoop-inc/composable_state_machine'
14
+ spec.license = 'MIT'
15
+
16
+ spec.files = `git ls-files`.split($/)
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = %w(lib)
20
+
21
+ spec.add_development_dependency 'bundler', '~> 1.3'
22
+ spec.add_development_dependency 'rake'
23
+ spec.add_development_dependency 'rspec'
24
+ spec.add_development_dependency 'simplecov'
25
+ spec.add_development_dependency 'coveralls'
26
+ spec.add_development_dependency 'awesome_print'
27
+ spec.add_development_dependency 'yard'
28
+ spec.add_development_dependency 'redcarpet'
29
+
30
+ if RUBY_PLATFORM =~ /darwin/ && RUBY_VERSION =~ /^2/
31
+ spec.add_development_dependency 'guard'
32
+ spec.add_development_dependency 'guard-rspec'
33
+ spec.add_development_dependency 'growl'
34
+ end
35
+ end
@@ -0,0 +1,45 @@
1
+ Dir['lib/composable_state_machine/**/*.rb'].each { |f| require File.expand_path(f) }
2
+
3
+ # @author {https://github.com/ssimeonov Simeon Simeonov}, {http://swoop.com Swoop, Inc.}
4
+ #
5
+ # For examples, see the {file:README.html README}.
6
+ module ComposableStateMachine
7
+
8
+ # Creates a state machine model.
9
+ #
10
+ # State machine models are immutable and can be shared across many state machine instances.
11
+ #
12
+ # @param [Hash] options the options to create a model with.
13
+ # @option options [Hash, Transitions] :transitions State machine transitions. A {Transitions} object will be created if a Hash is provided.
14
+ # @option options [Hash, Behaviors] :behaviors State machine behaviors. A {Behaviors} object will be created if a Hash is provided. If omitted, a high-performance behaviors stub will be used.
15
+ # @option options [Object] :callback_runner (DefaultCallbackRunner) Object whose #run_state_machine_callback method will be used to execute behavior callbacks. {DefaultCallbackRunner} simply calls a Proc's #call method.
16
+ # @option options [Object] :initial_state (nil) Default initial state for the machine. This can be overriden by a machine instance.
17
+ # @option options [Object] :model_factory (Model) The object whose #new method will be called to create a model.
18
+ def self.model(options)
19
+ options = options.dup
20
+ unless options[:transitions].respond_to?(:transition)
21
+ options[:transitions] = Transitions.new(options[:transitions] || {})
22
+ end
23
+ unless options[:behaviors].respond_to?(:call)
24
+ options[:behaviors] = Behaviors.new(options[:behaviors] || {})
25
+ end
26
+ (options[:model_factory] || Model).new(options)
27
+ end
28
+
29
+ # Creates a state machine from a model.
30
+ #
31
+ # The variable number of arguments is driven by the difference in initialization APIs between different machines.
32
+ #
33
+ # The last argument must be an options Hash. It may have at least the following options:
34
+ #
35
+ # - :callback_runner [Object] (DefaultCallbackRunner) Object whose #run_state_machine_callback method will be used to execute behavior callbacks. {DefaultCallbackRunner} simply calls a Proc's #call method. When you want to execute callbacks in the context of an object, include the {CallbackRunner} mixin in the object and then pass the object as the callback runner here.
36
+ # - :state [Object] (model#initial_state) State the machine is in.
37
+ # - :model_factory [Object] ({Machine}) The object whose #new method will be called to create the machine.
38
+ #
39
+ # @param [Model, ...] model the state machine model.
40
+ # @param [any] args additional arguments passed to the model.
41
+ def self.machine(model, *args)
42
+ options = args.last
43
+ (options[:machine_factory] || Machine).new(model, *args)
44
+ end
45
+ end
@@ -0,0 +1,48 @@
1
+ module ComposableStateMachine
2
+
3
+ # Defines the behaviors of a state machine.
4
+ class Behaviors
5
+ # Creates a {Behaviors} object.
6
+ #
7
+ # @param [Hash<behavior, Hash<trigger, callback>>] behaviors triggers and callbacks per behavior. The triggers and callbacks can be provided as an object the responds to #call or they can be provided as a Hash<trigger, callback>. In the latter case, the callbacks factory will be used to create an object that would manage the callbacks for the behavior.
8
+ # @param [Object] callbacks_factory object whose #new method will be called with a Hash<trigger, callback>.
9
+ def initialize(behaviors = {}, callbacks_factory = Callbacks)
10
+ @callbacks_factory = callbacks_factory
11
+ @behaviors = behaviors.reduce({}) do |memo, (behavior, value)|
12
+ handler = value.respond_to?(:call) ? value : @callbacks_factory.new(value)
13
+ memo[behavior] = handler
14
+ memo
15
+ end
16
+ end
17
+
18
+ # Adds callbacks for a behavior.
19
+ #
20
+ # Selects the callback manager for the behavior and forwards to its #on method.
21
+ #
22
+ # @param [Object] behavior the behavior
23
+ # @param [Array<Object>] args parameters to pass to the callback manager's #on method.
24
+ #
25
+ # @return [self] for chaining
26
+ def on(behavior, *args, &block)
27
+ (@behaviors[behavior] ||= @callbacks_factory.new).on(*args, &block)
28
+ self
29
+ end
30
+
31
+ # Runs callbacks for a behavior with a runner.
32
+ #
33
+ # Selects the callback manager for the behavior and forwards to its #call method.
34
+ #
35
+ # @param [Object] runner the runner
36
+ # @param [Object] behavior the behavior
37
+ # @param [Array<Object>] args parameters to pass to the callback manager's #call method.
38
+ #
39
+ # @return [self] for chaining
40
+ def call(runner, behavior, *args)
41
+ @behaviors[behavior].tap do |handler|
42
+ handler.call(runner, *args) if handler
43
+ end
44
+ self
45
+ end
46
+ end
47
+
48
+ end
@@ -0,0 +1,19 @@
1
+ module ComposableStateMachine
2
+
3
+ # Mixin module that runs callbacks with self pointing to the object the module is included in.
4
+ module CallbackRunner
5
+ # Runs a callback with self pointing to the object the module is included in.
6
+ #
7
+ # @param [Proc, Method, UnboundMethod, ...] callback the callback. Unbound methods will be bound to the object the mixin is included in.
8
+ # @param [Array<Object>] args parameters to pass to the callback.
9
+ #
10
+ # @return [Object] the result of the callback
11
+ def run_state_machine_callback(callback, *args)
12
+ if callback.respond_to?(:bind)
13
+ callback = callback.bind(self)
14
+ end
15
+ instance_exec(*args, &callback)
16
+ end
17
+ end
18
+
19
+ end
@@ -0,0 +1,56 @@
1
+ module ComposableStateMachine
2
+
3
+ # Manages callbacks for a behavior.
4
+ class Callbacks
5
+ # Creates a {Callbacks} object.
6
+ #
7
+ # @param [Hash<trigger, callback(s)>] callbacks maps triggers to zero or more callbacks.
8
+ def initialize(callbacks = {})
9
+ @callbacks = Hash.new { |hash, key| hash[key] = [] }
10
+ callbacks.each_pair do |trigger, proc|
11
+ if proc.respond_to?(:each)
12
+ proc.each do |callback|
13
+ on(trigger, callback)
14
+ end
15
+ else
16
+ on(trigger, proc)
17
+ end
18
+ end
19
+ end
20
+
21
+ # Adds a callback for a trigger.
22
+ #
23
+ # @param [Object] trigger the callback trigger
24
+ # @param [Proc, Method, ...] proc an object responding to #call. If non-nil, it will be given precedence to #block
25
+ # @param [block] block an optional block implementing the callback
26
+ #
27
+ # @return [self] for chaining
28
+ def on(trigger, proc = nil, &block)
29
+ @callbacks[trigger] << (proc || block)
30
+ self
31
+ end
32
+
33
+ # Runs the callbacks for a trigger with a runner.
34
+ #
35
+ # Runs the callbacks for the :any trigger for every trigger.
36
+ #
37
+ # @param [Object] runner the runner
38
+ # @param [Object] trigger the callback trigger
39
+ # @param [Array<Object>] args parameters to pass to the callbacks' #call methods.
40
+ #
41
+ # @return [self] for chaining
42
+ def call(runner, trigger, *args)
43
+ if trigger == :any
44
+ raise InvalidTrigger.new(':any is not a valid trigger')
45
+ end
46
+ @callbacks[trigger].each do |callback|
47
+ runner.run_state_machine_callback(callback, *args)
48
+ end
49
+ @callbacks[:any].each do |callback|
50
+ runner.run_state_machine_callback(callback, *args)
51
+ end
52
+ self
53
+ end
54
+ end
55
+
56
+ end
@@ -0,0 +1,16 @@
1
+ module ComposableStateMachine
2
+
3
+ # Default callback runner that executes callbacks in their current binding.
4
+ class DefaultCallbackRunner
5
+ # Runs a callback in its current binding.
6
+ #
7
+ # @param [Proc, Method, ...] callback the callback, which must respond to #call.
8
+ # @param [Array<Object>] args parameters to pass to the callback.
9
+ #
10
+ # @return [Object] the result of the callback
11
+ def self.run_state_machine_callback(callback, *args)
12
+ callback.call(*args)
13
+ end
14
+ end
15
+
16
+ end
@@ -0,0 +1,7 @@
1
+ module ComposableStateMachine
2
+
3
+ # Raised when an invalid event is passed into {Transitions#transition}.
4
+ class InvalidEvent < NoMethodError
5
+ end
6
+
7
+ end
@@ -0,0 +1,7 @@
1
+ module ComposableStateMachine
2
+
3
+ # Raised in {Transitions} when nil is a state to transition to.
4
+ class InvalidTransition < StandardError
5
+ end
6
+
7
+ end
@@ -0,0 +1,7 @@
1
+ module ComposableStateMachine
2
+
3
+ # Raised in {Callbacks} when :any is received as an explicit trigger.
4
+ class InvalidTrigger < StandardError
5
+ end
6
+
7
+ end
@@ -0,0 +1,21 @@
1
+ require_relative 'machine_with_external_state'
2
+
3
+ module ComposableStateMachine
4
+
5
+ # Machine with its own state.
6
+ class Machine < MachineWithExternalState
7
+ attr_reader :state
8
+
9
+ # Creates a machine. Delegates to {MachineWithExternalState#initialize} passing method(:state) & method(:state=) as the state reader and writer.
10
+ def initialize(model, options = {})
11
+ super(model, method(:state), method(:state=), options)
12
+ end
13
+
14
+ private
15
+
16
+ def state=(new_state)
17
+ @state = new_state
18
+ end
19
+ end
20
+
21
+ end
@@ -0,0 +1,41 @@
1
+ module ComposableStateMachine
2
+
3
+ # State machine instance that manages its state via a reader and writer objects.
4
+ class MachineWithExternalState
5
+ # Creates an instance.
6
+ #
7
+ # @param [Model, ...] model the model for the machine
8
+ # @param [Proc, Method, ...] state_reader object whose #call method will return the current state
9
+ # @param [Proc, Method, ...] state_writer object whose #call method will be called with the new state after a transition
10
+ # @param [Hash] options the options to create the machine with.
11
+ # @option options [Object] :state (model#initial_state) State of the machine.
12
+ # @option options [Object] :callback_runner (model#callback_runner) Object whose #run_state_machine_callback method will be used to execute behavior callbacks.
13
+ def initialize(model, state_reader, state_writer, options = {})
14
+ @model = model
15
+ @state_reader = state_reader
16
+ @state_writer = state_writer
17
+ @callback_runner = options[:callback_runner] || model.callback_runner
18
+ initial_state = options[:state] || model.initial_state
19
+ @state_writer.call(initial_state)
20
+ end
21
+
22
+ # Executes the transition and behaviors associated with an event.
23
+ #
24
+ # @param [Object] event the event
25
+ # @param [Array<Object>] args event arguments
26
+ #
27
+ # @return [Object, nil] the result of model#transition
28
+ def trigger(event, *args)
29
+ current_state = @state_reader.call
30
+ @model.transition(current_state, event, args, @callback_runner, &@state_writer)
31
+ end
32
+
33
+ # Checks whether the state of the machine is equal to another state
34
+ #
35
+ # @return [TrueClass, FalseClass]
36
+ def ==(other_state)
37
+ @state_reader.call == other_state
38
+ end
39
+ end
40
+
41
+ end
@@ -0,0 +1,55 @@
1
+ module ComposableStateMachine
2
+
3
+ # An immutable state machine model that can be shared across many instances.
4
+ class Model
5
+ attr_reader :initial_state
6
+ attr_reader :callback_runner
7
+
8
+ # Creates a state machine model.
9
+ #
10
+ # @param [Hash] options the options to create a model with.
11
+ # @option options [Hash, Transitions] :transitions State machine transitions. A {Transitions} object will be created if a Hash is provided.
12
+ # @option options [Hash, Behaviors] :behaviors State machine behaviors. A {Behaviors} object will be created if a Hash is provided. If omitted, a high-performance behaviors stub will be used.
13
+ # @option options [Object] :initial_state (nil) Default initial state for the machine. This can be overriden by a machine instance.
14
+ # @option options [Object] :callback_runner (DefaultCallbackRunner) Object whose #run_state_machine_callback method will be used to execute behavior callbacks. {DefaultCallbackRunner} simply calls a Proc's #call method.
15
+ def initialize(options = {})
16
+ @initial_state = options[:initial_state]
17
+ @transitions = options[:transitions]
18
+ @behaviors = options[:behaviors] || proc {}
19
+ @callback_runner = options[:callback_runner] || DefaultCallbackRunner
20
+ end
21
+
22
+ # Performs a transition of the machine, executing behaviors as needed.
23
+ #
24
+ # @param current_state [Object] the current state of the machine
25
+ # @param event [Object] the event the machine has received
26
+ # @param arguments [Enumerable] ([]) any arguments related to the event.
27
+ # @param callback_runner [Object] ({Model#callback_runner}) the runner with which to execute callbacks
28
+ #
29
+ # @yield [Object] the new state of the machine, if a transition happened
30
+ #
31
+ # @return [Object] the new state of the machine, if a transition happened
32
+ # @return [nil] if no transition happened
33
+ def transition(current_state, event, arguments = [], callback_runner = nil)
34
+ @transitions.transition(current_state, event).tap do |new_state|
35
+ if new_state && new_state != current_state
36
+ callback_runner ||= @callback_runner
37
+ run_callbacks(callback_runner, current_state, event, new_state, arguments)
38
+ yield new_state if block_given?
39
+ end
40
+ end
41
+ end
42
+
43
+ # Runs the callbacks for all behaviors for a state transition
44
+ def run_callbacks(callback_runner, current_state, event, new_state, arguments, &block)
45
+ run_callbacks_for(callback_runner, :enter, new_state,
46
+ current_state, event, new_state, *arguments)
47
+ end
48
+
49
+ # Runs the callbacks for one behavior for a start transition
50
+ def run_callbacks_for(callback_runner, behavior, *args)
51
+ @behaviors.call(callback_runner, behavior, *args)
52
+ end
53
+ end
54
+
55
+ end
@@ -0,0 +1,73 @@
1
+ module ComposableStateMachine
2
+
3
+ # Defines the transitions a state machine can make.
4
+ class Transitions
5
+ # Creates a {Transitions} object.
6
+ #
7
+ # @note While nil is a valid state to transition from, it is not a valid state to transition to.
8
+ #
9
+ # @param (Hash<event, Hash<from_state, to_state>>) transitions transitions Hash mapping events to a Hash from -> to state transitions
10
+ def initialize(transitions = {})
11
+ @transitions_for = transitions
12
+ validate_transitions
13
+ end
14
+
15
+ # Adds to the transitions for an event.
16
+ #
17
+ # @param [Object] event event causing the transition
18
+ # @param [Hash<from_state, to_state>] transitions transitions to the added to the transitions for the event.
19
+ #
20
+ # @return [self] for chaining
21
+ def on(event, transitions)
22
+ (@transitions_for[event] ||= {}).tap do |transitions_for_event|
23
+ transitions_for_event.merge!(transitions)
24
+ validate_transitions_for_event(event, transitions_for_event)
25
+ end
26
+ self
27
+ end
28
+
29
+ # Checks the transition map for a valid transition from a state given an event
30
+ #
31
+ # @param [Object] state the state the machine is in
32
+ # @param [Object] event event causing the transition
33
+ #
34
+ # @return [Object] new state for the machine, if a transition can occur
35
+ # @return [nil] if a transition cannot happen with this event
36
+ #
37
+ # @raise [InvalidEvent] if an unknown event is provided
38
+ def transition(state, event)
39
+ transitions_for_event = @transitions_for[event]
40
+ unless transitions_for_event
41
+ raise InvalidEvent.new("invalid event", event, state)
42
+ end
43
+ transitions_for_event[state]
44
+ end
45
+
46
+ # @return [Array<Object>] events for the machine
47
+ def events
48
+ @transitions_for.keys
49
+ end
50
+
51
+ # @return [Array<Object>] the states of the machine
52
+ def states
53
+ events.map { |e| @transitions_for[e].to_a }.flatten.uniq
54
+ end
55
+
56
+ private
57
+
58
+ def validate_transitions
59
+ @transitions_for.each_pair do |event, transitions|
60
+ validate_transitions_for_event(event, transitions)
61
+ end
62
+ end
63
+
64
+ def validate_transitions_for_event(event, transitions)
65
+ transitions.each_pair do |from, to|
66
+ if to.nil?
67
+ raise InvalidTransition.new("transition to nil from #{from.inspect} for #{event.inspect} event")
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ end