finite_machine 0.1.0 → 0.2.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/.travis.yml +6 -2
- data/CHANGELOG.md +12 -0
- data/README.md +345 -105
- data/finite_machine.gemspec +2 -2
- data/lib/finite_machine.rb +12 -0
- data/lib/finite_machine/callable.rb +2 -7
- data/lib/finite_machine/catchable.rb +112 -0
- data/lib/finite_machine/dsl.rb +59 -44
- data/lib/finite_machine/hooks.rb +43 -0
- data/lib/finite_machine/observer.rb +39 -40
- data/lib/finite_machine/state_machine.rb +37 -18
- data/lib/finite_machine/subscribers.rb +1 -1
- data/lib/finite_machine/threadable.rb +8 -2
- data/lib/finite_machine/transition.rb +45 -1
- data/lib/finite_machine/version.rb +1 -1
- data/spec/spec_helper.rb +9 -0
- data/spec/unit/callable/call_spec.rb +91 -0
- data/spec/unit/callbacks_spec.rb +96 -12
- data/spec/unit/events_spec.rb +1 -1
- data/spec/unit/handlers_spec.rb +99 -0
- data/spec/unit/if_unless_spec.rb +3 -3
- data/spec/unit/initialize_spec.rb +40 -0
- data/spec/unit/target_spec.rb +137 -0
- data/spec/unit/transition/parse_states_spec.rb +16 -5
- metadata +15 -4
data/finite_machine.gemspec
CHANGED
@@ -8,9 +8,9 @@ Gem::Specification.new do |spec|
|
|
8
8
|
spec.version = FiniteMachine::VERSION
|
9
9
|
spec.authors = ["Piotr Murach"]
|
10
10
|
spec.email = [""]
|
11
|
-
spec.description = %q{A minimal finite state machine with a straightforward syntax.}
|
11
|
+
spec.description = %q{A minimal finite state machine with a straightforward syntax. You can quickly model states, add callbacks and use object-oriented techniques to integrate with ORMs.}
|
12
12
|
spec.summary = %q{A minimal finite state machine with a straightforward syntax.}
|
13
|
-
spec.homepage = ""
|
13
|
+
spec.homepage = "https://github.com/peter-murach/finite_machine"
|
14
14
|
spec.license = "MIT"
|
15
15
|
|
16
16
|
spec.files = `git ls-files`.split($/)
|
data/lib/finite_machine.rb
CHANGED
@@ -6,7 +6,9 @@ require "sync"
|
|
6
6
|
require "finite_machine/version"
|
7
7
|
require "finite_machine/threadable"
|
8
8
|
require "finite_machine/callable"
|
9
|
+
require "finite_machine/catchable"
|
9
10
|
require "finite_machine/event"
|
11
|
+
require "finite_machine/hooks"
|
10
12
|
require "finite_machine/transition"
|
11
13
|
require "finite_machine/dsl"
|
12
14
|
require "finite_machine/state_machine"
|
@@ -23,6 +25,10 @@ module FiniteMachine
|
|
23
25
|
|
24
26
|
ANY_EVENT = :any
|
25
27
|
|
28
|
+
ANY_STATE_HOOK = :state
|
29
|
+
|
30
|
+
ANY_EVENT_HOOK = :event
|
31
|
+
|
26
32
|
# Returned when transition has successfully performed
|
27
33
|
SUCCEEDED = 1
|
28
34
|
|
@@ -35,11 +41,17 @@ module FiniteMachine
|
|
35
41
|
# When transition between states is invalid
|
36
42
|
TransitionError = Class.new(::StandardError)
|
37
43
|
|
44
|
+
# Raised when transitining to invalid state
|
45
|
+
InvalidStateError = Class.new(::ArgumentError)
|
46
|
+
|
38
47
|
InvalidEventError = Class.new(::NoMethodError)
|
39
48
|
|
40
49
|
# Raised when a callback is defined with invalid name
|
41
50
|
InvalidCallbackNameError = Class.new(::StandardError)
|
42
51
|
|
52
|
+
# Raised when event has no transitions
|
53
|
+
NotEnoughTransitionsError = Class.new(::ArgumentError)
|
54
|
+
|
43
55
|
Environment = Struct.new(:target)
|
44
56
|
|
45
57
|
# TODO: this should instantiate system not the state machine
|
@@ -29,8 +29,7 @@ module FiniteMachine
|
|
29
29
|
# @param [Object] target
|
30
30
|
#
|
31
31
|
# @api public
|
32
|
-
def call(
|
33
|
-
target = env.target
|
32
|
+
def call(target, *args, &block)
|
34
33
|
case object
|
35
34
|
when Symbol
|
36
35
|
target.__send__(@object.to_sym)
|
@@ -38,11 +37,7 @@ module FiniteMachine
|
|
38
37
|
value = eval "lambda { #{@object} }"
|
39
38
|
target.instance_exec(&value)
|
40
39
|
when ::Proc
|
41
|
-
|
42
|
-
object.call(target, *args)
|
43
|
-
else
|
44
|
-
object.call
|
45
|
-
end
|
40
|
+
object.arity.zero? ? object.call : object.call(target, *args)
|
46
41
|
else
|
47
42
|
raise ArgumentError, "Unknown callable #{@object}"
|
48
43
|
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module FiniteMachine
|
4
|
+
|
5
|
+
# A mixin to allow for specifying error handlers
|
6
|
+
module Catchable
|
7
|
+
|
8
|
+
def self.included(base)
|
9
|
+
base.class_eval do
|
10
|
+
attr_threadsafe :error_handlers
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
# Rescue exception raised in state machine
|
15
|
+
#
|
16
|
+
# @param [Array[Exception]] exceptions
|
17
|
+
#
|
18
|
+
# @example
|
19
|
+
# handle TransitionError, with: :pretty_errors
|
20
|
+
#
|
21
|
+
# @example
|
22
|
+
# handle TransitionError do |exception|
|
23
|
+
# logger.info exception.message
|
24
|
+
# raise exception
|
25
|
+
# end
|
26
|
+
#
|
27
|
+
# @api public
|
28
|
+
def handle(*exceptions, &block)
|
29
|
+
options = exceptions.last.is_a?(Hash) ? exceptions.pop : {}
|
30
|
+
|
31
|
+
unless options.key?(:with)
|
32
|
+
if block_given?
|
33
|
+
options[:with] = block
|
34
|
+
else
|
35
|
+
raise ArgumentError, 'Need to provide error handler.'
|
36
|
+
end
|
37
|
+
end
|
38
|
+
evaluate_exceptions(exceptions, options)
|
39
|
+
end
|
40
|
+
|
41
|
+
# Catches error and finds a handler
|
42
|
+
#
|
43
|
+
# @param [Exception] exception
|
44
|
+
#
|
45
|
+
# @api public
|
46
|
+
def catch_error(exception)
|
47
|
+
if handler = handler_for_error(exception)
|
48
|
+
handler.arity.zero? ? handler.call : handler.call(exception)
|
49
|
+
true
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def handler_for_error(exception)
|
56
|
+
_, handler = error_handlers.reverse.find do |class_name, _|
|
57
|
+
klass = FiniteMachine.const_get(class_name) rescue nil
|
58
|
+
klass ||= extract_const(class_name)
|
59
|
+
exception <= klass
|
60
|
+
end
|
61
|
+
evaluate_handler(handler)
|
62
|
+
end
|
63
|
+
|
64
|
+
# Find constant in state machine namespace
|
65
|
+
#
|
66
|
+
# @param [String] class_name
|
67
|
+
#
|
68
|
+
# @api private
|
69
|
+
def extract_const(class_name)
|
70
|
+
class_name.split('::').reduce(FiniteMachine) do |constant, part|
|
71
|
+
constant.const_get(part)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# Executes given handler
|
76
|
+
#
|
77
|
+
# @api private
|
78
|
+
def evaluate_handler(handler)
|
79
|
+
case handler
|
80
|
+
when Symbol
|
81
|
+
method(handler)
|
82
|
+
when Proc
|
83
|
+
if handler.arity.zero?
|
84
|
+
proc { instance_exec(&handler) }
|
85
|
+
else
|
86
|
+
proc { |_exception| instance_exec(_exception, &handler) }
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
# Check if exception inherits from Exception class and add to error handlers
|
92
|
+
#
|
93
|
+
# @param [Array[Exception]] exceptions
|
94
|
+
#
|
95
|
+
# @param [Hash] options
|
96
|
+
#
|
97
|
+
# @api private
|
98
|
+
def evaluate_exceptions(exceptions, options)
|
99
|
+
exceptions.each do |exception|
|
100
|
+
key = if exception.is_a?(Class) && exception <= Exception
|
101
|
+
exception.name
|
102
|
+
elsif exception.is_a?(String)
|
103
|
+
exception
|
104
|
+
else
|
105
|
+
raise ArgumentError, "#{exception} isn't an Exception"
|
106
|
+
end
|
107
|
+
|
108
|
+
error_handlers << [key, options[:with]]
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end # Catchable
|
112
|
+
end # FiniteMachine
|
data/lib/finite_machine/dsl.rb
CHANGED
@@ -2,14 +2,19 @@
|
|
2
2
|
|
3
3
|
module FiniteMachine
|
4
4
|
|
5
|
+
# A generic DSL for describing the state machine
|
5
6
|
class GenericDSL
|
7
|
+
include Threadable
|
8
|
+
|
6
9
|
class << self
|
7
10
|
# @api private
|
8
11
|
attr_accessor :top_level
|
9
12
|
end
|
10
13
|
|
14
|
+
attr_threadsafe :machine
|
15
|
+
|
11
16
|
def initialize(machine)
|
12
|
-
|
17
|
+
self.machine = machine
|
13
18
|
end
|
14
19
|
|
15
20
|
def method_missing(method_name, *args, &block)
|
@@ -21,23 +26,24 @@ module FiniteMachine
|
|
21
26
|
end
|
22
27
|
|
23
28
|
def call(&block)
|
24
|
-
instance_eval(&block)
|
29
|
+
sync_exclusive { instance_eval(&block) }
|
25
30
|
# top_level.instance_eval(&block)
|
26
31
|
end
|
27
32
|
end # GenericDSL
|
28
33
|
|
29
34
|
class DSL < GenericDSL
|
30
35
|
|
31
|
-
|
32
|
-
|
33
|
-
attr_reader :defer
|
36
|
+
attr_threadsafe :defer
|
34
37
|
|
35
|
-
|
38
|
+
attr_threadsafe :initial_event
|
36
39
|
|
40
|
+
# Initialize top level DSL
|
41
|
+
#
|
42
|
+
# @api public
|
37
43
|
def initialize(machine)
|
38
44
|
super(machine)
|
39
45
|
machine.state = FiniteMachine::DEFAULT_STATE
|
40
|
-
|
46
|
+
self.defer = true
|
41
47
|
end
|
42
48
|
|
43
49
|
# Define initial state
|
@@ -46,15 +52,8 @@ module FiniteMachine
|
|
46
52
|
#
|
47
53
|
# @api public
|
48
54
|
def initial(value)
|
49
|
-
|
50
|
-
|
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
|
55
|
+
state, name, self.defer = parse(value)
|
56
|
+
self.initial_event = name
|
58
57
|
event = proc { event name, from: FiniteMachine::DEFAULT_STATE, to: state }
|
59
58
|
machine.events.call(&event)
|
60
59
|
end
|
@@ -87,48 +86,64 @@ module FiniteMachine
|
|
87
86
|
# Error handler that throws exception when machine is in illegal state
|
88
87
|
#
|
89
88
|
# @api public
|
90
|
-
def
|
89
|
+
def handlers(&block)
|
90
|
+
machine.errors.call(&block)
|
91
91
|
end
|
92
|
-
end # DSL
|
93
|
-
|
94
|
-
class EventsDSL < GenericDSL
|
95
92
|
|
96
|
-
|
93
|
+
private
|
97
94
|
|
98
|
-
|
99
|
-
|
95
|
+
# Parse initial options
|
96
|
+
#
|
97
|
+
# @params [String, Hash] value
|
98
|
+
#
|
99
|
+
# @return [Array[Symbol,String]]
|
100
|
+
#
|
101
|
+
# @api private
|
102
|
+
def parse(value)
|
103
|
+
if value.is_a?(String) || value.is_a?(Symbol)
|
104
|
+
[value, FiniteMachine::DEFAULT_EVENT_NAME, false]
|
105
|
+
else
|
106
|
+
[value[:state], value.fetch(:event, FiniteMachine::DEFAULT_EVENT_NAME),
|
107
|
+
!!value[:defer]]
|
108
|
+
end
|
100
109
|
end
|
110
|
+
end # DSL
|
111
|
+
|
112
|
+
class EventsDSL < GenericDSL
|
101
113
|
|
102
114
|
# Create event and associate transition
|
103
115
|
#
|
116
|
+
# @example
|
117
|
+
# event :go, :green => :yellow
|
118
|
+
# event :go, :green => :yellow, if: :lights_on?
|
119
|
+
#
|
120
|
+
# @return [Transition]
|
121
|
+
#
|
104
122
|
# @api public
|
105
123
|
def event(name, attrs = {}, &block)
|
106
|
-
|
107
|
-
|
108
|
-
|
124
|
+
sync_exclusive do
|
125
|
+
_transition = Transition.new(machine, attrs.merge!(name: name))
|
126
|
+
_transition.define
|
127
|
+
_transition.define_event
|
128
|
+
end
|
109
129
|
end
|
130
|
+
end # EventsDSL
|
110
131
|
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
def add_transition(_transition)
|
117
|
-
_transition.from.each do |from|
|
118
|
-
machine.transitions[_transition.name][from] = _transition.to || from
|
119
|
-
end
|
132
|
+
class ErrorsDSL < GenericDSL
|
133
|
+
|
134
|
+
def initialize(machine)
|
135
|
+
super(machine)
|
136
|
+
machine.error_handlers = []
|
120
137
|
end
|
121
138
|
|
122
|
-
#
|
139
|
+
# Add error handler
|
123
140
|
#
|
124
|
-
# @param [
|
125
|
-
# @param [Transition] _transition
|
141
|
+
# @param [Array] exceptions
|
126
142
|
#
|
127
|
-
# @api
|
128
|
-
def
|
129
|
-
machine.
|
130
|
-
transition(_transition, *args, &block)
|
131
|
-
end
|
143
|
+
# @api public
|
144
|
+
def handle(*exceptions, &block)
|
145
|
+
machine.handle(*exceptions, &block)
|
132
146
|
end
|
133
|
-
|
147
|
+
|
148
|
+
end # ErrorsDSL
|
134
149
|
end # FiniteMachine
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module FiniteMachine
|
4
|
+
|
5
|
+
# A class reponsible for registering callbacks
|
6
|
+
class Hooks
|
7
|
+
include Threadable
|
8
|
+
|
9
|
+
attr_threadsafe :collection
|
10
|
+
|
11
|
+
# Initialize a collection of hoooks
|
12
|
+
#
|
13
|
+
# @api public
|
14
|
+
def initialize(machine)
|
15
|
+
@collection = Hash.new do |events_hash, event_type|
|
16
|
+
events_hash[event_type] = Hash.new do |state_hash, name|
|
17
|
+
state_hash[name] = []
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# Register callback
|
23
|
+
#
|
24
|
+
# @param [String] event_type
|
25
|
+
# @param [String] name
|
26
|
+
# @param [Proc] callback
|
27
|
+
#
|
28
|
+
# @api public
|
29
|
+
def register(event_type, name, callback)
|
30
|
+
@collection[event_type][name] << callback
|
31
|
+
end
|
32
|
+
|
33
|
+
# Return all hooks matching event and state
|
34
|
+
#
|
35
|
+
# @api public
|
36
|
+
def call(event_type, event_state, event)
|
37
|
+
@collection[event_type][event_state].each do |hook|
|
38
|
+
yield hook
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
end # Hooks
|
43
|
+
end # FiniteMachine
|
@@ -18,12 +18,7 @@ module FiniteMachine
|
|
18
18
|
def initialize(machine)
|
19
19
|
@machine = machine
|
20
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
|
-
}
|
21
|
+
@hooks = FiniteMachine::Hooks.new(machine)
|
27
22
|
end
|
28
23
|
|
29
24
|
# Evaluate in current context
|
@@ -41,41 +36,35 @@ module FiniteMachine
|
|
41
36
|
#
|
42
37
|
# @api public
|
43
38
|
def on(event_type = ANY_EVENT, name = ANY_STATE, &callback)
|
44
|
-
|
45
|
-
|
39
|
+
sync_exclusive do
|
40
|
+
ensure_valid_callback_name!(name)
|
41
|
+
hooks.register event_type, name, callback
|
42
|
+
end
|
46
43
|
end
|
47
44
|
|
48
|
-
def
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
45
|
+
def listen_on(type, *args, &callback)
|
46
|
+
name = args.first
|
47
|
+
events = []
|
48
|
+
if machine.states.include?(name) || name == ANY_STATE_HOOK
|
49
|
+
events << :"#{type}state"
|
50
|
+
elsif machine.event_names.include?(name) || name == ANY_EVENT_HOOK
|
51
|
+
events << :"#{type}action"
|
53
52
|
else
|
54
|
-
|
55
|
-
on :enteraction, *args, &callback
|
53
|
+
events << :"#{type}state" << :"#{type}action"
|
56
54
|
end
|
55
|
+
events.each { |event| on event, *args, &callback }
|
56
|
+
end
|
57
|
+
|
58
|
+
def on_enter(*args, &callback)
|
59
|
+
listen_on :enter, *args, &callback
|
57
60
|
end
|
58
61
|
|
59
62
|
def on_transition(*args, &callback)
|
60
|
-
|
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
|
63
|
+
listen_on :transition, *args, &callback
|
68
64
|
end
|
69
65
|
|
70
66
|
def on_exit(*args, &callback)
|
71
|
-
|
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
|
67
|
+
listen_on :exit, *args, &callback
|
79
68
|
end
|
80
69
|
|
81
70
|
def method_missing(method_name, *args, &block)
|
@@ -94,7 +83,7 @@ module FiniteMachine
|
|
94
83
|
|
95
84
|
TransitionEvent = Struct.new(:from, :to, :name) do
|
96
85
|
def build(_transition)
|
97
|
-
self.from = _transition.
|
86
|
+
self.from = _transition.from_state
|
98
87
|
self.to = _transition.to
|
99
88
|
self.name = _transition.name
|
100
89
|
end
|
@@ -103,14 +92,21 @@ module FiniteMachine
|
|
103
92
|
def run_callback(hook, event)
|
104
93
|
trans_event = TransitionEvent.new
|
105
94
|
trans_event.build(event.transition)
|
106
|
-
|
95
|
+
data = event.data
|
96
|
+
deferred_hook = proc { |_trans_event, *_data|
|
97
|
+
machine.instance_exec(_trans_event, *_data, &hook)
|
98
|
+
}
|
99
|
+
deferred_hook.call(trans_event, *data)
|
107
100
|
end
|
108
101
|
|
109
|
-
def trigger(event)
|
110
|
-
|
111
|
-
[event.
|
112
|
-
|
113
|
-
|
102
|
+
def trigger(event, *args, &block)
|
103
|
+
sync_exclusive do
|
104
|
+
[event.type, ANY_EVENT].each do |event_type|
|
105
|
+
[event.state, ANY_STATE,
|
106
|
+
ANY_STATE_HOOK, ANY_EVENT_HOOK].each do |event_state|
|
107
|
+
hooks.call(event_type, event_state, event) do |hook|
|
108
|
+
run_callback(hook, event)
|
109
|
+
end
|
114
110
|
end
|
115
111
|
end
|
116
112
|
end
|
@@ -123,12 +119,15 @@ module FiniteMachine
|
|
123
119
|
@callback_names.merge machine.event_names
|
124
120
|
@callback_names.merge machine.states
|
125
121
|
@callback_names.merge [ANY_STATE, ANY_EVENT]
|
122
|
+
@callback_names.merge [ANY_STATE_HOOK, ANY_EVENT_HOOK]
|
126
123
|
end
|
127
124
|
|
128
125
|
def ensure_valid_callback_name!(name)
|
129
126
|
unless callback_names.include?(name)
|
130
|
-
|
131
|
-
|
127
|
+
exception = InvalidCallbackNameError
|
128
|
+
machine.catch_error(exception) ||
|
129
|
+
raise(InvalidCallbackNameError, "#{name} is not a valid callback name." +
|
130
|
+
" Valid callback names are #{callback_names.to_a.inspect}")
|
132
131
|
end
|
133
132
|
end
|
134
133
|
|