finite_machine 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,188 @@
1
+ # encoding: utf-8
2
+
3
+ module FiniteMachine
4
+
5
+ # Base class for state machine
6
+ class StateMachine
7
+ include Threadable
8
+
9
+ # Initial state, defaults to :none
10
+ attr_threadsafe :initial_state
11
+
12
+ # Final state, defaults to :none
13
+ attr_threadsafe :final_state
14
+
15
+ # Current state
16
+ attr_threadsafe :state
17
+
18
+ # Events DSL
19
+ attr_threadsafe :events
20
+
21
+ # The prefix used to name events.
22
+ attr_threadsafe :namespace
23
+
24
+ # The events and their transitions.
25
+ attr_threadsafe :transitions
26
+
27
+ # The state machine observer
28
+ attr_threadsafe :observer
29
+
30
+ # The state machine subscribers
31
+ attr_threadsafe :subscribers
32
+
33
+ # The state machine environment
34
+ attr_threadsafe :env
35
+
36
+ # Initialize state machine
37
+ #
38
+ # @api private
39
+ def initialize(*args, &block)
40
+ @subscribers = Subscribers.new(self)
41
+ @events = EventsDSL.new(self)
42
+ @observer = Observer.new(self)
43
+ @transitions = Hash.new { |hash, name| hash[name] = Hash.new }
44
+ @env = Environment.new(target: self)
45
+
46
+ @dsl = DSL.new self
47
+ @dsl.call(&block) if block_given?
48
+ send(:"#{@dsl.initial_event}") unless @dsl.defer
49
+ end
50
+
51
+ def subscribe(*observers)
52
+ @subscribers.subscribe(*observers)
53
+ end
54
+
55
+ # TODO: use trigger to actually fire state machine events!
56
+ # Notify about event
57
+ #
58
+ # @api public
59
+ def notify(event_type, _transition, *data)
60
+ event_class = Event.const_get(event_type.capitalize.to_s)
61
+ state_or_action = event_class < Event::Anystate ? state : _transition.name
62
+ _event = event_class.new(state_or_action, _transition, *data)
63
+ subscribers.visit(_event)
64
+ end
65
+
66
+ # Get current state
67
+ #
68
+ # @return [String]
69
+ #
70
+ # @api public
71
+ def current
72
+ state
73
+ end
74
+
75
+ # Check if current state machtes provided state
76
+ #
77
+ # @param [String, Array[String]] state
78
+ #
79
+ # @return [Boolean]
80
+ #
81
+ # @api public
82
+ def is?(state)
83
+ if state.is_a?(Array)
84
+ state.include? current
85
+ else
86
+ state == current
87
+ end
88
+ end
89
+
90
+ # Retrieve all states
91
+ #
92
+ # @return [Array[Symbol]]
93
+ #
94
+ # @api public
95
+ def states
96
+ event_names.map { |event| transitions[event].to_a }.flatten.uniq
97
+ end
98
+
99
+ # Retireve all event names
100
+ #
101
+ # @return [Array[Symbol]]
102
+ #
103
+ # @api public
104
+ def event_names
105
+ transitions.keys
106
+ end
107
+
108
+ # Checks if event can be triggered
109
+ #
110
+ # @param [String] event
111
+ #
112
+ # @return [Boolean]
113
+ #
114
+ # @api public
115
+ def can?(event)
116
+ transitions[event].key?(current) || transitions[event].key?(ANY_STATE)
117
+ end
118
+
119
+ # Checks if event cannot be triggered
120
+ #
121
+ # @param [String] event
122
+ #
123
+ # @return [Boolean]
124
+ #
125
+ # @api public
126
+ def cannot?(event)
127
+ !can?(event)
128
+ end
129
+
130
+ # Checks if terminal state has been reached
131
+ #
132
+ # @return [Boolean]
133
+ #
134
+ # @api public
135
+ def finished?
136
+ is?(final_state)
137
+ end
138
+
139
+ #
140
+ #
141
+ # @api public
142
+ def errors
143
+ end
144
+
145
+ private
146
+
147
+ # Check if state is reachable
148
+ #
149
+ # @api private
150
+ def validate_state(_transition)
151
+ current_states = transitions[_transition.name].keys
152
+ if !current_states.include?(state) && !current_states.include?(ANY_STATE)
153
+ raise TransitionError, "inappropriate current state '#{state}'"
154
+ end
155
+ end
156
+
157
+ # Performs transition
158
+ #
159
+ # @api private
160
+ def transition(_transition, *args, &block)
161
+ validate_state(_transition)
162
+
163
+ return CANCELLED unless _transition.conditions.all? { |c| c.call(env) }
164
+ return NOTRANSITION if state == _transition.to
165
+
166
+ sync_exclusive do
167
+ notify :exitstate, _transition, *args
168
+ notify :enteraction, _transition, *args
169
+
170
+ begin
171
+ _transition.call
172
+
173
+ notify :transitionstate, _transition, *args
174
+ notify :transitionaction, _transition, *args
175
+ rescue StandardError => e
176
+ raise TransitionError, "#(#{e.class}): #{e.message}\n" +
177
+ "occured at #{e.backtrace.join("\n")}"
178
+ end
179
+
180
+ notify :enterstate, _transition, *args
181
+ notify :exitaction, _transition, *args
182
+ end
183
+
184
+ SUCCEEDED
185
+ end
186
+
187
+ end # StateMachine
188
+ end # FiniteMachine
@@ -0,0 +1,41 @@
1
+ # encoding: utf-8
2
+
3
+ module FiniteMachine
4
+
5
+ # A class responsibile for storage of event subscribers
6
+ class Subscribers
7
+ include Enumerable
8
+
9
+ def initialize(machine)
10
+ @machine = machine
11
+ @subscribers = []
12
+ @mutex = Mutex.new
13
+ end
14
+
15
+ def each(&block)
16
+ @subscribers.each(&block)
17
+ end
18
+
19
+ def index(subscriber)
20
+ @subscribers.index(subscriber)
21
+ end
22
+
23
+ def empty?
24
+ @subscribers.empty?
25
+ end
26
+
27
+ def subscribe(*observers)
28
+ observers.each { |observer| @subscribers << observer }
29
+ end
30
+
31
+ def visit(event)
32
+ each { |subscriber| @mutex.synchronize { event.notify subscriber } }
33
+ end
34
+
35
+ def reset
36
+ @subscribers.clear
37
+ self
38
+ end
39
+
40
+ end # Subscribers
41
+ end # FiniteMachine
@@ -0,0 +1,44 @@
1
+ # encoding: utf-8
2
+
3
+ module FiniteMachine
4
+ module Threadable
5
+ module InstanceMethods
6
+ @@sync = Sync.new
7
+
8
+ def sync_exclusive(&block)
9
+ @@sync.synchronize(:EX, &block)
10
+ end
11
+
12
+ def sync_shared(&block)
13
+ @@sync.synchronize(:SH, &block)
14
+ end
15
+ end
16
+
17
+ def self.included(base)
18
+ base.extend ClassMethods
19
+ base.module_eval do
20
+ include InstanceMethods
21
+ end
22
+ end
23
+
24
+ module ClassMethods
25
+ include InstanceMethods
26
+
27
+ def attr_threadsafe(*attrs)
28
+ attrs.flatten.each do |attr|
29
+ class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
30
+ def #{attr}
31
+ sync_shared { @#{attr} }
32
+ end
33
+ alias_method '#{attr}?', '#{attr}'
34
+
35
+ def #{attr}=(value)
36
+ sync_exclusive { @#{attr} = value }
37
+ end
38
+ RUBY_EVAL
39
+ end
40
+ end
41
+ end
42
+
43
+ end # Threadable
44
+ end # FiniteMachine
@@ -0,0 +1,69 @@
1
+ # encoding: utf-8
2
+
3
+ module FiniteMachine
4
+
5
+ # Class describing a transition associated with a given event
6
+ class Transition
7
+ include Threadable
8
+
9
+ attr_threadsafe :name
10
+
11
+ # State transitioning from
12
+ attr_threadsafe :from
13
+
14
+ # State transitioning to
15
+ attr_threadsafe :to
16
+
17
+ # Predicates before transitioning
18
+ attr_threadsafe :conditions
19
+
20
+ # The current state machine
21
+ attr_threadsafe :machine
22
+
23
+ # Initialize a Transition
24
+ #
25
+ # @api public
26
+ def initialize(machine, attrs = {})
27
+ @machine = machine
28
+ @name = attrs.fetch(:name, DEFAULT_STATE)
29
+ @from, @to = *parse_states(attrs)
30
+ @if = Array(attrs.fetch(:if, []))
31
+ @unless = Array(attrs.fetch(:unless, []))
32
+ @conditions = make_conditions
33
+ end
34
+
35
+ def make_conditions
36
+ @if.map { |c| Callable.new(c) } +
37
+ @unless.map { |c| Callable.new(c).invert }
38
+ end
39
+
40
+ # Extract states from attributes
41
+ #
42
+ # @api private
43
+ def parse_states(attrs)
44
+ _attrs = attrs.dup
45
+ [:name, :if, :unless].each { |key| _attrs.delete(key) }
46
+
47
+ if [:from, :to].any? { |key| attrs.keys.include?(key) }
48
+ [Array(_attrs[:from] || ANY_STATE), _attrs[:to]]
49
+ else
50
+ [(keys = _attrs.keys).flatten, _attrs[keys.first]]
51
+ end
52
+ end
53
+
54
+ # Execute current transition
55
+ #
56
+ # @api private
57
+ def call
58
+ sync_exclusive do
59
+ transitions = machine.transitions[name]
60
+ machine.state = transitions[machine.state] || transitions[ANY_STATE] || name
61
+ end
62
+ end
63
+
64
+ def inspect
65
+ [@name, @from, @to, @conditions].inspect
66
+ end
67
+
68
+ end # Transition
69
+ end # FiniteMachine
@@ -1,3 +1,5 @@
1
+ # encoding: utf-8
2
+
1
3
  module FiniteMachine
2
- VERSION = "0.0.1"
4
+ VERSION = "0.1.0"
3
5
  end
@@ -0,0 +1,15 @@
1
+ # encoding: utf-8
2
+
3
+ require 'finite_machine'
4
+
5
+ RSpec.configure do |config|
6
+ config.treat_symbols_as_metadata_keys_with_true_values = true
7
+ config.run_all_when_everything_filtered = true
8
+ config.filter_run :focus
9
+
10
+ # Run specs in random order to surface order dependencies. If you find an
11
+ # order dependency and want to debug it, you can fix the order by providing
12
+ # the seed, which is printed after each run.
13
+ # --seed 1234
14
+ config.order = 'random'
15
+ end
@@ -0,0 +1,391 @@
1
+ # encoding: utf-8
2
+
3
+ require 'spec_helper'
4
+
5
+ describe FiniteMachine, 'callbacks' do
6
+
7
+ it "triggers default init event" do
8
+ called = []
9
+ fsm = FiniteMachine.define do
10
+ initial :green
11
+
12
+ events {
13
+ event :slow, :green => :yellow
14
+ event :stop, :yellow => :red
15
+ event :ready, :red => :yellow
16
+ event :go, :yellow => :green
17
+ }
18
+
19
+ callbacks {
20
+ # generic callbacks
21
+ on_enter do |event| called << 'on_enter' end
22
+ on_transition do |event| called << 'on_transition' end
23
+ on_exit do |event| called << 'on_exit' end
24
+
25
+ # state callbacks
26
+ on_enter :none do |event| called << 'on_enter_none' end
27
+ on_enter :green do |event| called << 'on_enter_green' end
28
+
29
+ on_transition :none do |event| called << 'on_transition_none' end
30
+ on_transition :green do |event| called << 'on_transition_green' end
31
+
32
+ on_exit :none do |event| called << 'on_exit_none' end
33
+ on_exit :green do |event| called << 'on_exit_green' end
34
+
35
+ # event callbacks
36
+ on_enter :init do |event| called << 'on_enter_init' end
37
+ on_transition :init do |event| called << 'on_transition_init' end
38
+ on_exit :init do |event| called << 'on_exit_init' end
39
+ }
40
+ end
41
+
42
+ expect(called).to eql([
43
+ 'on_exit_none',
44
+ 'on_exit',
45
+ 'on_enter_init',
46
+ 'on_enter',
47
+ 'on_transition_green',
48
+ 'on_transition',
49
+ 'on_transition_init',
50
+ 'on_transition',
51
+ 'on_enter_green',
52
+ 'on_enter',
53
+ 'on_exit_init',
54
+ 'on_exit'
55
+ ])
56
+ end
57
+
58
+ it "executes callbacks in order" do
59
+ called = []
60
+ fsm = FiniteMachine.define do
61
+ initial :green
62
+
63
+ events {
64
+ event :slow, :green => :yellow
65
+ event :stop, :yellow => :red
66
+ event :ready, :red => :yellow
67
+ event :go, :yellow => :green
68
+ }
69
+
70
+ callbacks {
71
+ # generic callbacks
72
+ on_enter do |event| called << 'on_enter' end
73
+ on_transition do |event| called << 'on_transition' end
74
+ on_exit do |event| called << 'on_exit' end
75
+
76
+ # state callbacks
77
+ on_enter :green do |event| called << 'on_enter_green' end
78
+ on_enter :yellow do |event| called << "on_enter_yellow" end
79
+ on_enter :red do |event| called << "on_enter_red" end
80
+
81
+ on_transition :green do |event| called << 'on_transition_green' end
82
+ on_transition :yellow do |event| called << "on_transition_yellow" end
83
+ on_transition :red do |event| called << "on_transition_red" end
84
+
85
+ on_exit :green do |event| called << 'on_exit_green' end
86
+ on_exit :yellow do |event| called << "on_exit_yellow" end
87
+ on_exit :red do |event| called << "on_exit_red" end
88
+
89
+ # event callbacks
90
+ on_enter :slow do |event| called << 'on_enter_slow' end
91
+ on_enter :stop do |event| called << "on_enter_stop" end
92
+ on_enter :ready do |event| called << "on_enter_ready" end
93
+ on_enter :go do |event| called << "on_enter_go" end
94
+
95
+ on_transition :slow do |event| called << 'on_transition_slow' end
96
+ on_transition :stop do |event| called << "on_transition_stop" end
97
+ on_transition :ready do |event| called << "on_transition_ready" end
98
+ on_transition :go do |event| called << "on_transition_go" end
99
+
100
+ on_exit :slow do |event| called << 'on_exit_slow' end
101
+ on_exit :stop do |event| called << "on_exit_stop" end
102
+ on_exit :ready do |event| called << "on_exit_ready" end
103
+ on_exit :go do |event| called << "on_exit_go" end
104
+ }
105
+ end
106
+
107
+ called = []
108
+ fsm.slow
109
+ expect(called).to eql([
110
+ 'on_exit_green',
111
+ 'on_exit',
112
+ 'on_enter_slow',
113
+ 'on_enter',
114
+ 'on_transition_yellow',
115
+ 'on_transition',
116
+ 'on_transition_slow',
117
+ 'on_transition',
118
+ 'on_enter_yellow',
119
+ 'on_enter',
120
+ 'on_exit_slow',
121
+ 'on_exit'
122
+ ])
123
+
124
+ called = []
125
+ fsm.stop
126
+ expect(called).to eql([
127
+ 'on_exit_yellow',
128
+ 'on_exit',
129
+ 'on_enter_stop',
130
+ 'on_enter',
131
+ 'on_transition_red',
132
+ 'on_transition',
133
+ 'on_transition_stop',
134
+ 'on_transition',
135
+ 'on_enter_red',
136
+ 'on_enter',
137
+ 'on_exit_stop',
138
+ 'on_exit'
139
+ ])
140
+
141
+ called = []
142
+ fsm.ready
143
+ expect(called).to eql([
144
+ 'on_exit_red',
145
+ 'on_exit',
146
+ 'on_enter_ready',
147
+ 'on_enter',
148
+ 'on_transition_yellow',
149
+ 'on_transition',
150
+ 'on_transition_ready',
151
+ 'on_transition',
152
+ 'on_enter_yellow',
153
+ 'on_enter',
154
+ 'on_exit_ready',
155
+ 'on_exit'
156
+ ])
157
+
158
+ called = []
159
+ fsm.go
160
+ expect(called).to eql([
161
+ 'on_exit_yellow',
162
+ 'on_exit',
163
+ 'on_enter_go',
164
+ 'on_enter',
165
+ 'on_transition_green',
166
+ 'on_transition',
167
+ 'on_transition_go',
168
+ 'on_transition',
169
+ 'on_enter_green',
170
+ 'on_enter',
171
+ 'on_exit_go',
172
+ 'on_exit'
173
+ ])
174
+ end
175
+
176
+ it "allows multiple callbacks for the same state" do
177
+ called = []
178
+ fsm = FiniteMachine.define do
179
+ initial :green
180
+
181
+ events {
182
+ event :slow, :green => :yellow
183
+ event :stop, :yellow => :red
184
+ event :ready, :red => :yellow
185
+ event :go, :yellow => :green
186
+ }
187
+
188
+ callbacks {
189
+ # generic callbacks
190
+ on_enter do |event| called << 'on_enter' end
191
+ on_transition do |event| called << 'on_transition' end
192
+ on_exit do |event| called << 'on_exit' end
193
+
194
+ # state callbacks
195
+ on_exit :green do |event| called << 'on_exit_green_1' end
196
+ on_exit :green do |event| called << 'on_exit_green_2' end
197
+ on_enter :yellow do |event| called << 'on_enter_yellow_1' end
198
+ on_enter :yellow do |event| called << 'on_enter_yellow_2' end
199
+ on_transition :yellow do |event| called << 'on_transition_yellow_1' end
200
+ on_transition :yellow do |event| called << 'on_transition_yellow_2' end
201
+
202
+ # event callbacks
203
+ on_enter :slow do |event| called << 'on_enter_slow_1' end
204
+ on_enter :slow do |event| called << 'on_enter_slow_2' end
205
+ on_transition :slow do |event| called << 'on_transition_slow_1' end
206
+ on_transition :slow do |event| called << 'on_transition_slow_2' end
207
+ on_exit :slow do |event| called << 'on_exit_slow_1' end
208
+ on_exit :slow do |event| called << 'on_exit_slow_2' end
209
+ }
210
+ end
211
+
212
+ called = []
213
+ fsm.slow
214
+ expect(fsm.current).to eql(:yellow)
215
+ expect(called).to eql([
216
+ 'on_exit_green_1',
217
+ 'on_exit_green_2',
218
+ 'on_exit',
219
+ 'on_enter_slow_1',
220
+ 'on_enter_slow_2',
221
+ 'on_enter',
222
+ 'on_transition_yellow_1',
223
+ 'on_transition_yellow_2',
224
+ 'on_transition',
225
+ 'on_transition_slow_1',
226
+ 'on_transition_slow_2',
227
+ 'on_transition',
228
+ 'on_enter_yellow_1',
229
+ 'on_enter_yellow_2',
230
+ 'on_enter',
231
+ 'on_exit_slow_1',
232
+ 'on_exit_slow_2',
233
+ 'on_exit'
234
+ ])
235
+ end
236
+
237
+ it "allows for fluid callback definition" do
238
+ called = []
239
+ fsm = FiniteMachine.define do
240
+ initial :green
241
+
242
+ events {
243
+ event :slow, :green => :yellow
244
+ event :stop, :yellow => :red
245
+ event :ready, :red => :yellow
246
+ event :go, :yellow => :green
247
+ }
248
+
249
+ callbacks {
250
+ # state callbacks
251
+ on_exit_green do |event| called << 'on_exit_green' end
252
+ on_enter_yellow do |event| called << 'on_enter_yellow' end
253
+ on_transition_yellow do |event| called << 'on_transition_yellow' end
254
+
255
+ # event callbacks
256
+ on_enter_slow do |event| called << 'on_enter_slow' end
257
+ on_transition_slow do |event| called << 'on_transition_slow' end
258
+ on_exit_slow do |event| called << 'on_exit_slow' end
259
+ }
260
+ end
261
+
262
+ called = []
263
+ fsm.slow
264
+ expect(fsm.current).to eql(:yellow)
265
+ expect(called).to eql([
266
+ 'on_exit_green',
267
+ 'on_enter_slow',
268
+ 'on_transition_yellow',
269
+ 'on_transition_slow',
270
+ 'on_enter_yellow',
271
+ 'on_exit_slow'
272
+ ])
273
+ end
274
+
275
+ it "passes event object to callback" do
276
+ evt = nil
277
+
278
+ fsm = FiniteMachine.define do
279
+ initial :green
280
+
281
+ events {
282
+ event :slow, :green => :yellow
283
+ }
284
+
285
+ callbacks {
286
+ on_enter(:yellow) { |e| evt = e }
287
+ }
288
+ end
289
+
290
+ expect(fsm.current).to eql(:green)
291
+ fsm.slow
292
+ expect(fsm.current).to eql(:yellow)
293
+
294
+ expect(evt.from).to eql(:green)
295
+ expect(evt.to).to eql(:yellow)
296
+ expect(evt.name).to eql(:slow)
297
+ end
298
+
299
+ it "passes extra parameters to callbacks" do
300
+ expected = {name: :init, from: :none, to: :green, a: nil, b: nil, c: nil }
301
+
302
+ callback = Proc.new { |event, a, b, c|
303
+ expect(event.from).to eql(expected[:from])
304
+ expect(event.to).to eql(expected[:to])
305
+ expect(event.name).to eql(expected[:name])
306
+ expect(a).to eql(expected[:a])
307
+ expect(b).to eql(expected[:b])
308
+ expect(c).to eql(expected[:c])
309
+ }
310
+
311
+ fsm = FiniteMachine.define do
312
+ initial :green
313
+
314
+ events {
315
+ event :slow, :green => :yellow
316
+ event :stop, :yellow => :red
317
+ event :ready, :red => :yellow
318
+ event :go, :yellow => :green
319
+ }
320
+
321
+ callbacks {
322
+ # generic callbacks
323
+ on_enter &callback
324
+ on_transition &callback
325
+ on_exit &callback
326
+
327
+ # state callbacks
328
+ on_enter :green, &callback
329
+ on_enter :yellow, &callback
330
+ on_enter :red, &callback
331
+
332
+ on_transition :green , &callback
333
+ on_transition :yellow, &callback
334
+ on_transition :red , &callback
335
+
336
+ on_exit :green , &callback
337
+ on_exit :yellow, &callback
338
+ on_exit :red , &callback
339
+
340
+ # event callbacks
341
+ on_enter :slow , &callback
342
+ on_enter :stop , &callback
343
+ on_enter :ready, &callback
344
+ on_enter :go , &callback
345
+
346
+ on_transition :slow , &callback
347
+ on_transition :stop , &callback
348
+ on_transition :ready, &callback
349
+ on_transition :go , &callback
350
+
351
+ on_exit :slow , &callback
352
+ on_exit :stop , &callback
353
+ on_exit :ready, &callback
354
+ on_exit :go , &callback
355
+ }
356
+ end
357
+
358
+
359
+ expected = {name: :slow, from: :green, to: :yellow, a: 1, b: 2, c: 3}
360
+ fsm.slow(1, 2, 3)
361
+
362
+ expected = {name: :stop, from: :yellow, to: :red, a: 'foo', b: 'bar'}
363
+ fsm.stop('foo', 'bar')
364
+
365
+ expected = {name: :ready, from: :red, to: :yellow, a: :foo, b: :bar}
366
+ fsm.ready(:foo, :bar)
367
+
368
+ expected = {name: :go, from: :yellow, to: :green, a: nil, b: nil}
369
+ fsm.go(nil, nil)
370
+ end
371
+
372
+ it "raises an error with invalid callback name" do
373
+ expect {
374
+ FiniteMachine.define do
375
+ initial :green
376
+
377
+ events {
378
+ event :slow, :green => :yellow
379
+ }
380
+
381
+ callbacks {
382
+ on_enter(:magic) { |event| called << 'on_enter'}
383
+ }
384
+ end
385
+ }.to raise_error(FiniteMachine::InvalidCallbackNameError, /magic is not a valid callback name/)
386
+ end
387
+
388
+ xit "propagates exceptions raised inside callback"
389
+
390
+ xit "executes callbacks with multiple 'from' transitions"
391
+ end