finite_machine 0.0.1 → 0.1.0
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.
- checksums.yaml +4 -4
- data/.rspec +2 -0
- data/.travis.yml +18 -0
- data/README.md +467 -11
- data/Rakefile +40 -0
- data/lib/finite_machine.rb +44 -2
- data/lib/finite_machine/callable.rb +51 -0
- data/lib/finite_machine/dsl.rb +134 -0
- data/lib/finite_machine/event.rb +66 -0
- data/lib/finite_machine/observer.rb +136 -0
- data/lib/finite_machine/state_machine.rb +188 -0
- data/lib/finite_machine/subscribers.rb +41 -0
- data/lib/finite_machine/threadable.rb +44 -0
- data/lib/finite_machine/transition.rb +69 -0
- data/lib/finite_machine/version.rb +3 -1
- data/spec/spec_helper.rb +15 -0
- data/spec/unit/callbacks_spec.rb +391 -0
- data/spec/unit/can_spec.rb +50 -0
- data/spec/unit/define_spec.rb +32 -0
- data/spec/unit/events_spec.rb +256 -0
- data/spec/unit/finished_spec.rb +51 -0
- data/spec/unit/if_unless_spec.rb +196 -0
- data/spec/unit/initialize_spec.rb +59 -0
- data/spec/unit/is_spec.rb +33 -0
- data/spec/unit/states_spec.rb +21 -0
- data/spec/unit/transition/parse_states_spec.rb +34 -0
- metadata +35 -3
data/Rakefile
CHANGED
@@ -1 +1,41 @@
|
|
1
1
|
require "bundler/gem_tasks"
|
2
|
+
|
3
|
+
begin
|
4
|
+
require 'rspec/core/rake_task'
|
5
|
+
|
6
|
+
desc 'Run all specs'
|
7
|
+
RSpec::Core::RakeTask.new(:spec) do |task|
|
8
|
+
task.pattern = 'spec/{unit,integration}{,/*/**}/*_spec.rb'
|
9
|
+
end
|
10
|
+
|
11
|
+
namespace :spec do
|
12
|
+
desc 'Run unit specs'
|
13
|
+
RSpec::Core::RakeTask.new(:unit) do |task|
|
14
|
+
task.pattern = 'spec/unit{,/*/**}/*_spec.rb'
|
15
|
+
end
|
16
|
+
|
17
|
+
desc 'Run integration specs'
|
18
|
+
RSpec::Core::RakeTask.new(:integration) do |task|
|
19
|
+
task.pattern = 'spec/integration{,/*/**}/*_spec.rb'
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
rescue LoadError
|
24
|
+
%w[spec spec:unit spec:integration].each do |name|
|
25
|
+
task name do
|
26
|
+
$stderr.puts "In order to run #{name}, do `gem install rspec`"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
desc 'Run all specs'
|
32
|
+
task ci: %w[ spec ]
|
33
|
+
|
34
|
+
desc 'Load gem inside irb console'
|
35
|
+
task :console do
|
36
|
+
require 'irb'
|
37
|
+
require 'irb/completion'
|
38
|
+
require File.join(__FILE__, '../lib/finite_machine')
|
39
|
+
ARGV.clear
|
40
|
+
IRB.start
|
41
|
+
end
|
data/lib/finite_machine.rb
CHANGED
@@ -1,9 +1,51 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require "thread"
|
4
|
+
require "sync"
|
5
|
+
|
1
6
|
require "finite_machine/version"
|
7
|
+
require "finite_machine/threadable"
|
8
|
+
require "finite_machine/callable"
|
9
|
+
require "finite_machine/event"
|
10
|
+
require "finite_machine/transition"
|
11
|
+
require "finite_machine/dsl"
|
12
|
+
require "finite_machine/state_machine"
|
13
|
+
require "finite_machine/subscribers"
|
14
|
+
require "finite_machine/observer"
|
2
15
|
|
3
16
|
module FiniteMachine
|
4
17
|
|
5
|
-
|
6
|
-
|
18
|
+
DEFAULT_STATE = :none
|
19
|
+
|
20
|
+
DEFAULT_EVENT_NAME = :init
|
21
|
+
|
22
|
+
ANY_STATE = :any
|
23
|
+
|
24
|
+
ANY_EVENT = :any
|
25
|
+
|
26
|
+
# Returned when transition has successfully performed
|
27
|
+
SUCCEEDED = 1
|
28
|
+
|
29
|
+
# Returned when transition is cancelled in callback
|
30
|
+
CANCELLED = 2
|
31
|
+
|
32
|
+
# Returned when transition has not changed the state
|
33
|
+
NOTRANSITION = 3
|
34
|
+
|
35
|
+
# When transition between states is invalid
|
36
|
+
TransitionError = Class.new(::StandardError)
|
37
|
+
|
38
|
+
InvalidEventError = Class.new(::NoMethodError)
|
39
|
+
|
40
|
+
# Raised when a callback is defined with invalid name
|
41
|
+
InvalidCallbackNameError = Class.new(::StandardError)
|
42
|
+
|
43
|
+
Environment = Struct.new(:target)
|
44
|
+
|
45
|
+
# TODO: this should instantiate system not the state machine
|
46
|
+
# and then delegate calls to StateMachine instance etc...
|
47
|
+
def self.define(*args, &block)
|
48
|
+
StateMachine.new(*args, &block)
|
7
49
|
end
|
8
50
|
|
9
51
|
end # FiniteMachine
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module FiniteMachine
|
4
|
+
|
5
|
+
# A generic interface for executing strings, symbol methods or procs.
|
6
|
+
class Callable
|
7
|
+
|
8
|
+
attr_reader :object
|
9
|
+
|
10
|
+
# Initialize a Callable
|
11
|
+
#
|
12
|
+
# @param [Symbol, String, Proc] object
|
13
|
+
# the callable object
|
14
|
+
#
|
15
|
+
# @api public
|
16
|
+
def initialize(object)
|
17
|
+
@object = object
|
18
|
+
end
|
19
|
+
|
20
|
+
# Invert callable
|
21
|
+
#
|
22
|
+
# @api public
|
23
|
+
def invert
|
24
|
+
lambda { |*args, &block| !self.call(*args, &block) }
|
25
|
+
end
|
26
|
+
|
27
|
+
# Execute action
|
28
|
+
#
|
29
|
+
# @param [Object] target
|
30
|
+
#
|
31
|
+
# @api public
|
32
|
+
def call(env, *args, &block)
|
33
|
+
target = env.target
|
34
|
+
case object
|
35
|
+
when Symbol
|
36
|
+
target.__send__(@object.to_sym)
|
37
|
+
when String
|
38
|
+
value = eval "lambda { #{@object} }"
|
39
|
+
target.instance_exec(&value)
|
40
|
+
when ::Proc
|
41
|
+
if object.arity >= 1
|
42
|
+
object.call(target, *args)
|
43
|
+
else
|
44
|
+
object.call
|
45
|
+
end
|
46
|
+
else
|
47
|
+
raise ArgumentError, "Unknown callable #{@object}"
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end # Callable
|
51
|
+
end # FiniteMachine
|
@@ -0,0 +1,134 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module FiniteMachine
|
4
|
+
|
5
|
+
class GenericDSL
|
6
|
+
class << self
|
7
|
+
# @api private
|
8
|
+
attr_accessor :top_level
|
9
|
+
end
|
10
|
+
|
11
|
+
def initialize(machine)
|
12
|
+
@machine = machine
|
13
|
+
end
|
14
|
+
|
15
|
+
def method_missing(method_name, *args, &block)
|
16
|
+
if @machine.respond_to?(method_name)
|
17
|
+
@machine.send(method_name, *args, &block)
|
18
|
+
else
|
19
|
+
super
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def call(&block)
|
24
|
+
instance_eval(&block)
|
25
|
+
# top_level.instance_eval(&block)
|
26
|
+
end
|
27
|
+
end # GenericDSL
|
28
|
+
|
29
|
+
class DSL < GenericDSL
|
30
|
+
|
31
|
+
attr_reader :machine
|
32
|
+
|
33
|
+
attr_reader :defer
|
34
|
+
|
35
|
+
attr_reader :initial_event
|
36
|
+
|
37
|
+
def initialize(machine)
|
38
|
+
super(machine)
|
39
|
+
machine.state = FiniteMachine::DEFAULT_STATE
|
40
|
+
@defer = true
|
41
|
+
end
|
42
|
+
|
43
|
+
# Define initial state
|
44
|
+
#
|
45
|
+
# @params [String, Hash] value
|
46
|
+
#
|
47
|
+
# @api public
|
48
|
+
def initial(value)
|
49
|
+
if value.is_a?(String) || value.is_a?(Symbol)
|
50
|
+
state, name = value, FiniteMachine::DEFAULT_EVENT_NAME
|
51
|
+
@defer = false
|
52
|
+
else
|
53
|
+
state = value[:state]
|
54
|
+
name = value.has_key?(:event) ? value[:event] : FiniteMachine::DEFAULT_EVENT_NAME
|
55
|
+
@defer = value[:defer] || true
|
56
|
+
end
|
57
|
+
@initial_event = name
|
58
|
+
event = proc { event name, from: FiniteMachine::DEFAULT_STATE, to: state }
|
59
|
+
machine.events.call(&event)
|
60
|
+
end
|
61
|
+
|
62
|
+
def target(value)
|
63
|
+
machine.env.target = value
|
64
|
+
end
|
65
|
+
|
66
|
+
# Define terminal state
|
67
|
+
#
|
68
|
+
# @api public
|
69
|
+
def terminal(value)
|
70
|
+
machine.final_state = value
|
71
|
+
end
|
72
|
+
|
73
|
+
# Define state machine events
|
74
|
+
#
|
75
|
+
# @api public
|
76
|
+
def events(&block)
|
77
|
+
machine.events.call(&block)
|
78
|
+
end
|
79
|
+
|
80
|
+
# Define state machine callbacks
|
81
|
+
#
|
82
|
+
# @api public
|
83
|
+
def callbacks(&block)
|
84
|
+
machine.observer.call(&block)
|
85
|
+
end
|
86
|
+
|
87
|
+
# Error handler that throws exception when machine is in illegal state
|
88
|
+
#
|
89
|
+
# @api public
|
90
|
+
def error
|
91
|
+
end
|
92
|
+
end # DSL
|
93
|
+
|
94
|
+
class EventsDSL < GenericDSL
|
95
|
+
|
96
|
+
attr_reader :machine
|
97
|
+
|
98
|
+
def initialize(machine)
|
99
|
+
super(machine)
|
100
|
+
end
|
101
|
+
|
102
|
+
# Create event and associate transition
|
103
|
+
#
|
104
|
+
# @api public
|
105
|
+
def event(name, attrs = {}, &block)
|
106
|
+
_transition = Transition.new(machine, attrs.merge!(name: name))
|
107
|
+
add_transition(_transition)
|
108
|
+
define_event(_transition)
|
109
|
+
end
|
110
|
+
|
111
|
+
# Add transition
|
112
|
+
#
|
113
|
+
# @param [Transition] _transition
|
114
|
+
#
|
115
|
+
# @api private
|
116
|
+
def add_transition(_transition)
|
117
|
+
_transition.from.each do |from|
|
118
|
+
machine.transitions[_transition.name][from] = _transition.to || from
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
# Define event
|
123
|
+
#
|
124
|
+
# @param [String] name
|
125
|
+
# @param [Transition] _transition
|
126
|
+
#
|
127
|
+
# @api private
|
128
|
+
def define_event(_transition)
|
129
|
+
machine.class.__send__ :define_method, _transition.name do |*args, &block|
|
130
|
+
transition(_transition, *args, &block)
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end # EventsDSL
|
134
|
+
end # FiniteMachine
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module FiniteMachine
|
4
|
+
|
5
|
+
# A class responsible for event notification
|
6
|
+
class Event
|
7
|
+
include Threadable
|
8
|
+
|
9
|
+
MESSAGE = :trigger
|
10
|
+
|
11
|
+
# Event state
|
12
|
+
attr_threadsafe :state
|
13
|
+
|
14
|
+
# Event type
|
15
|
+
attr_threadsafe :type
|
16
|
+
|
17
|
+
# Data associated with the event
|
18
|
+
attr_threadsafe :data
|
19
|
+
|
20
|
+
# Transition associated with the event
|
21
|
+
attr_threadsafe :transition
|
22
|
+
|
23
|
+
def initialize(state, transition, *data, &block)
|
24
|
+
@state = state
|
25
|
+
@transition = transition
|
26
|
+
@data = *data
|
27
|
+
@type = self.class.event_name
|
28
|
+
end
|
29
|
+
|
30
|
+
def notify(subscriber, *args, &block)
|
31
|
+
if subscriber.respond_to? MESSAGE
|
32
|
+
subscriber.__send__(MESSAGE, self, *args, &block)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
class Anystate < Event; end
|
37
|
+
|
38
|
+
class Enterstate < Anystate; end
|
39
|
+
|
40
|
+
class Transitionstate < Anystate; end
|
41
|
+
|
42
|
+
class Exitstate < Anystate; end
|
43
|
+
|
44
|
+
class Anyaction < Event; end
|
45
|
+
|
46
|
+
class Enteraction < Anyaction; end
|
47
|
+
|
48
|
+
class Transitionaction < Anyaction; end
|
49
|
+
|
50
|
+
class Exitaction < Anyaction; end
|
51
|
+
|
52
|
+
EVENTS = Anystate, Enterstate, Transitionstate, Exitstate,
|
53
|
+
Anyaction, Enteraction, Transitionaction, Exitaction
|
54
|
+
|
55
|
+
def self.event_name
|
56
|
+
name.split('::').last.downcase.to_sym
|
57
|
+
end
|
58
|
+
|
59
|
+
EVENTS.each do |event|
|
60
|
+
(class << self; self; end).class_eval do
|
61
|
+
define_method(event.event_name) { event.event_name }
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
end # Event
|
66
|
+
end # FiniteMachine
|
@@ -0,0 +1,136 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module FiniteMachine
|
4
|
+
|
5
|
+
# A class responsible for observing state changes
|
6
|
+
class Observer
|
7
|
+
include Threadable
|
8
|
+
|
9
|
+
# The current state machine
|
10
|
+
attr_threadsafe :machine
|
11
|
+
|
12
|
+
# The hooks to trigger around the transition lifecycle.
|
13
|
+
attr_threadsafe :hooks
|
14
|
+
|
15
|
+
# Initialize an Observer
|
16
|
+
#
|
17
|
+
# @api public
|
18
|
+
def initialize(machine)
|
19
|
+
@machine = machine
|
20
|
+
@machine.subscribe(self)
|
21
|
+
|
22
|
+
@hooks = Hash.new { |events_hash, event_type|
|
23
|
+
events_hash[event_type] = Hash.new { |state_hash, name|
|
24
|
+
state_hash[name] = []
|
25
|
+
}
|
26
|
+
}
|
27
|
+
end
|
28
|
+
|
29
|
+
# Evaluate in current context
|
30
|
+
#
|
31
|
+
# @api private
|
32
|
+
def call(&block)
|
33
|
+
instance_eval(&block)
|
34
|
+
end
|
35
|
+
|
36
|
+
# Register callback for a given event.
|
37
|
+
#
|
38
|
+
# @param [Symbol] event_type
|
39
|
+
# @param [Symbol] name
|
40
|
+
# @param [Proc] callback
|
41
|
+
#
|
42
|
+
# @api public
|
43
|
+
def on(event_type = ANY_EVENT, name = ANY_STATE, &callback)
|
44
|
+
ensure_valid_callback_name!(name)
|
45
|
+
hooks[event_type][name] << callback
|
46
|
+
end
|
47
|
+
|
48
|
+
def on_enter(*args, &callback)
|
49
|
+
if machine.states.any? { |state| state == args.first }
|
50
|
+
on :enterstate, *args, &callback
|
51
|
+
elsif machine.event_names.any? { |name| name == args.first }
|
52
|
+
on :enteraction, *args, &callback
|
53
|
+
else
|
54
|
+
on :enterstate, *args, &callback
|
55
|
+
on :enteraction, *args, &callback
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def on_transition(*args, &callback)
|
60
|
+
if machine.states.any? { |state| state == args.first }
|
61
|
+
on :transitionstate, *args, &callback
|
62
|
+
elsif machine.event_names.any? { |name| name == args.first }
|
63
|
+
on :transitionaction, *args, &callback
|
64
|
+
else
|
65
|
+
on :transitionstate, *args, &callback
|
66
|
+
on :transitionaction, *args, &callback
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def on_exit(*args, &callback)
|
71
|
+
if machine.states.any? { |state| state == args.first }
|
72
|
+
on :exitstate, *args, &callback
|
73
|
+
elsif machine.event_names.any? { |name| name == args.first }
|
74
|
+
on :exitaction, *args, &callback
|
75
|
+
else
|
76
|
+
on :exitstate, *args, &callback
|
77
|
+
on :exitaction, *args, &callback
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def method_missing(method_name, *args, &block)
|
82
|
+
_, event_name, callback_name = *method_name.to_s.match(/^(on_\w+?)_(\w+)$/)
|
83
|
+
if callback_names.include?(callback_name.to_sym)
|
84
|
+
send(event_name, callback_name.to_sym, *args, &block)
|
85
|
+
else
|
86
|
+
super
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def respond_to_missing?(method_name, include_private = false)
|
91
|
+
_, callback_name = *method_name.to_s.match(/^(on_\w+?)_(\w+)$/)
|
92
|
+
callback_names.include?(callback_name.to_sym)
|
93
|
+
end
|
94
|
+
|
95
|
+
TransitionEvent = Struct.new(:from, :to, :name) do
|
96
|
+
def build(_transition)
|
97
|
+
self.from = _transition.from.first
|
98
|
+
self.to = _transition.to
|
99
|
+
self.name = _transition.name
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def run_callback(hook, event)
|
104
|
+
trans_event = TransitionEvent.new
|
105
|
+
trans_event.build(event.transition)
|
106
|
+
hook.call(trans_event, *event.data)
|
107
|
+
end
|
108
|
+
|
109
|
+
def trigger(event)
|
110
|
+
[event.type, ANY_EVENT].each do |event_type|
|
111
|
+
[event.state, ANY_STATE].each do |event_state|
|
112
|
+
hooks[event_type][event_state].each do |hook|
|
113
|
+
run_callback hook, event
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
private
|
120
|
+
|
121
|
+
def callback_names
|
122
|
+
@callback_names = Set.new
|
123
|
+
@callback_names.merge machine.event_names
|
124
|
+
@callback_names.merge machine.states
|
125
|
+
@callback_names.merge [ANY_STATE, ANY_EVENT]
|
126
|
+
end
|
127
|
+
|
128
|
+
def ensure_valid_callback_name!(name)
|
129
|
+
unless callback_names.include?(name)
|
130
|
+
raise InvalidCallbackNameError, "#{name} is not a valid callback name." +
|
131
|
+
" Valid callback names are #{callback_names.to_a.inspect}"
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
end # Observer
|
136
|
+
end # FiniteMachine
|