Skip to content

Commit e076260

Browse files
authored
Merge pull request #19 from treamology/async
Add asynchronous state transitions
2 parents 7258168 + 8df3a15 commit e076260

File tree

3 files changed

+220
-37
lines changed

3 files changed

+220
-37
lines changed

README.md

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ local fsm = machine.create({
4545
along with the following members:
4646

4747
* fsm.current - contains the current state
48+
* fsm.currentTransitioningEvent - contains the current event that is in a transition.
4849
* fsm:is(s) - return true if state `s` is the current state
4950
* fsm:can(e) - return true if event `e` can be fired in the current state
5051
* fsm:cannot(e) - return true if event `e` cannot be fired in the current state
@@ -99,7 +100,7 @@ You can affect the event in 3 ways:
99100

100101
* return `false` from an `onbeforeevent` handler to cancel the event.
101102
* return `false` from an `onleavestate` handler to cancel the event.
102-
* return `ASYNC` from an `onleavestate` handler to perform an asynchronous state transition (see next section)
103+
* return `ASYNC` from an `onleavestate` or `onenterstate` handler to perform an asynchronous state transition (see next section)
103104

104105
For convenience, the 2 most useful callbacks can be shortened:
105106

@@ -169,11 +170,19 @@ A good example of this is when you transition out of a `menu` state, perhaps you
169170
fade the menu away, or slide it off the screen and don't want to transition to your `game` state
170171
until after that animation has been performed.
171172

172-
You can now return `StateMachine.ASYNC` from your `onleavestate` handler and the state machine
173-
will be _'put on hold'_ until you are ready to trigger the transition using the new `transition()`
173+
You can now return `ASYNC` from your `onleavestate` and/or `onenterstate` handlers and the state machine
174+
will be _'put on hold'_ until you are ready to trigger the transition using the new `transition(eventName)`
174175
method.
175176

176-
For example, using jQuery effects:
177+
If another event is triggered during a state machine transition, the event will be triggered relative to the
178+
state the machine was transitioning to or from. Any calls to `transition` with the cancelled async event name
179+
will be invalidated.
180+
181+
During a state change, `asyncState` will transition from `NONE` to `[event]WaitingOnLeave` to `[event]WaitingOnEnter`,
182+
looping back to `NONE`. If the state machine is put on hold, `asyncState` will pause depending on which handler
183+
you returned `ASYNC` from.
184+
185+
Example of asynchronous transitions:
177186

178187
```lua
179188
local machine = require('statemachine')
@@ -193,24 +202,24 @@ local fsm = machine.create({
193202
onentermenu = function() manager.switch('menu') end,
194203
onentergame = function() manager.switch('game') end,
195204

196-
onleavemenu = function()
205+
onleavemenu = function(fsm, name, from, to)
197206
manager.fade('fast', function()
198-
fsm:transition()
207+
fsm:transition(name)
199208
end)
200-
return machine.ASYNC -- tell machine to defer next state until we call transition (in fadeOut callback above)
209+
return fsm.ASYNC -- tell machine to defer next state until we call transition (in fadeOut callback above)
201210
end,
202211

203-
onleavegame = function()
212+
onleavegame = function(fsm, name, from, to)
204213
manager.slide('slow', function()
205-
fsm:transition()
214+
fsm:transition(name)
206215
end)
207-
return machine.ASYNC -- tell machine to defer next state until we call transition (in slideDown callback above)
216+
return fsm.ASYNC -- tell machine to defer next state until we call transition (in slideDown callback above)
208217
end,
209218
}
210219
})
211220
```
212221

213-
>> _NOTE: If you decide to cancel the ASYNC event, you can call `fsm.transition.cancel()`
222+
If you decide to cancel the async event, you can call `fsm.cancelTransition(eventName)`
214223

215224
Initialization Options
216225
======================

spec/fsm_spec.lua

Lines changed: 140 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
require("busted")
22

33
local machine = require("statemachine")
4+
local _ = require("luassert.match")._
45

56
describe("Lua state machine framework", function()
67
describe("A stop light", function()
@@ -68,11 +69,13 @@ describe("Lua state machine framework", function()
6869

6970
fsm:warn()
7071

71-
assert.spy(fsm.onbeforewarn).was_called_with(fsm, 'warn', 'green', 'yellow')
72-
assert.spy(fsm.onleavegreen).was_called_with(fsm, 'warn', 'green', 'yellow')
73-
assert.spy(fsm.onenteryellow).was_called_with(fsm, 'warn', 'green', 'yellow')
74-
assert.spy(fsm.onafterwarn).was_called_with(fsm, 'warn', 'green', 'yellow')
75-
assert.spy(fsm.onstatechange).was_called_with(fsm, 'warn', 'green', 'yellow')
72+
assert.spy(fsm.onbeforewarn).was_called_with(_, 'warn', 'green', 'yellow')
73+
assert.spy(fsm.onleavegreen).was_called_with(_, 'warn', 'green', 'yellow')
74+
75+
assert.spy(fsm.onenteryellow).was_called_with(_, 'warn', 'green', 'yellow')
76+
assert.spy(fsm.onafterwarn).was_called_with(_, 'warn', 'green', 'yellow')
77+
assert.spy(fsm.onstatechange).was_called_with(_, 'warn', 'green', 'yellow')
78+
7679
assert.spy(fsm.onyellow).was_not_called()
7780
assert.spy(fsm.onwarn).was_not_called()
7881
end)
@@ -89,11 +92,12 @@ describe("Lua state machine framework", function()
8992

9093
fsm:warn()
9194

92-
assert.spy(fsm.onbeforewarn).was_called_with(fsm, 'warn', 'green', 'yellow')
93-
assert.spy(fsm.onleavegreen).was_called_with(fsm, 'warn', 'green', 'yellow')
94-
assert.spy(fsm.onenteryellow).was_called_with(fsm, 'warn', 'green', 'yellow')
95-
assert.spy(fsm.onafterwarn).was_called_with(fsm, 'warn', 'green', 'yellow')
96-
assert.spy(fsm.onstatechange).was_called_with(fsm, 'warn', 'green', 'yellow')
95+
assert.spy(fsm.onbeforewarn).was_called_with(_, 'warn', 'green', 'yellow')
96+
assert.spy(fsm.onleavegreen).was_called_with(_, 'warn', 'green', 'yellow')
97+
98+
assert.spy(fsm.onenteryellow).was_called_with(_, 'warn', 'green', 'yellow')
99+
assert.spy(fsm.onafterwarn).was_called_with(_, 'warn', 'green', 'yellow')
100+
assert.spy(fsm.onstatechange).was_called_with(_, 'warn', 'green', 'yellow')
97101

98102
assert.spy(fsm.onyellow).was_not_called()
99103
assert.spy(fsm.onwarn).was_not_called()
@@ -108,11 +112,12 @@ describe("Lua state machine framework", function()
108112

109113
fsm:warn('bar')
110114

111-
assert.spy(fsm.onbeforewarn).was_called_with(fsm, 'warn', 'green', 'yellow', 'bar')
112-
assert.spy(fsm.onleavegreen).was_called_with(fsm, 'warn', 'green', 'yellow', 'bar')
113-
assert.spy(fsm.onenteryellow).was_called_with(fsm, 'warn', 'green', 'yellow', 'bar')
114-
assert.spy(fsm.onafterwarn).was_called_with(fsm, 'warn', 'green', 'yellow', 'bar')
115-
assert.spy(fsm.onstatechange).was_called_with(fsm, 'warn', 'green', 'yellow', 'bar')
115+
assert.spy(fsm.onbeforewarn).was_called_with(_, 'warn', 'green', 'yellow', 'bar')
116+
assert.spy(fsm.onleavegreen).was_called_with(_, 'warn', 'green', 'yellow', 'bar')
117+
118+
assert.spy(fsm.onenteryellow).was_called_with(_, 'warn', 'green', 'yellow', 'bar')
119+
assert.spy(fsm.onafterwarn).was_called_with(_, 'warn', 'green', 'yellow', 'bar')
120+
assert.spy(fsm.onstatechange).was_called_with(_, 'warn', 'green', 'yellow', 'bar')
116121
end)
117122

118123
it("should fire short handlers as a fallback", function()
@@ -121,8 +126,8 @@ describe("Lua state machine framework", function()
121126

122127
fsm:warn()
123128

124-
assert.spy(fsm.onyellow).was_called_with(fsm, 'warn', 'green', 'yellow')
125-
assert.spy(fsm.onwarn).was_called_with(fsm, 'warn', 'green', 'yellow')
129+
assert.spy(fsm.onyellow).was_called_with(_, 'warn', 'green', 'yellow')
130+
assert.spy(fsm.onwarn).was_called_with(_, 'warn', 'green', 'yellow')
126131
end)
127132

128133
it("should cancel the warn event from onleavegreen", function()
@@ -147,6 +152,124 @@ describe("Lua state machine framework", function()
147152
assert.are_equal(fsm.current, 'green')
148153
end)
149154

155+
it("pauses when async is passed", function()
156+
fsm.onleavegreen = function(self, name, from, to)
157+
return fsm.ASYNC
158+
end
159+
fsm.onenteryellow = function(self, name, from, to)
160+
return fsm.ASYNC
161+
end
162+
163+
local result = fsm:warn()
164+
assert.is_true(result)
165+
assert.are_equal(fsm.current, 'green')
166+
assert.are_equal(fsm.currentTransitioningEvent, 'warn')
167+
assert.are_equal(fsm.asyncState, 'warnWaitingOnLeave')
168+
169+
result = fsm:transition(fsm.currentTransitioningEvent)
170+
assert.is_true(result)
171+
assert.are_equal(fsm.current, 'yellow')
172+
assert.are_equal(fsm.currentTransitioningEvent, 'warn')
173+
assert.are_equal(fsm.asyncState, 'warnWaitingOnEnter')
174+
175+
result = fsm:transition(fsm.currentTransitioningEvent)
176+
assert.is_true(result)
177+
assert.are_equal(fsm.current, 'yellow')
178+
assert.is_nil(fsm.currentTransitioningEvent)
179+
assert.are_equal(fsm.asyncState, fsm.NONE)
180+
end)
181+
182+
it("should accept additional arguments to async handlers", function()
183+
fsm.onbeforewarn = stub.new()
184+
fsm.onleavegreen = spy.new(function(self, name, from, to, arg)
185+
return fsm.ASYNC
186+
end)
187+
fsm.onenteryellow = spy.new(function(self, name, from, to, arg)
188+
return fsm.ASYNC
189+
end)
190+
fsm.onafterwarn = stub.new()
191+
fsm.onstatechange = stub.new()
192+
193+
fsm:warn('bar')
194+
assert.spy(fsm.onbeforewarn).was_called_with(_, 'warn', 'green', 'yellow', 'bar')
195+
assert.spy(fsm.onleavegreen).was_called_with(_, 'warn', 'green', 'yellow', 'bar')
196+
197+
fsm:transition(fsm.currentTransitioningEvent)
198+
assert.spy(fsm.onenteryellow).was_called_with(_, 'warn', 'green', 'yellow', 'bar')
199+
200+
fsm:transition(fsm.currentTransitioningEvent)
201+
assert.spy(fsm.onafterwarn).was_called_with(_, 'warn', 'green', 'yellow', 'bar')
202+
assert.spy(fsm.onstatechange).was_called_with(_, 'warn', 'green', 'yellow', 'bar')
203+
end)
204+
205+
it("should properly transition when another event happens during leave async", function()
206+
local tempStoplight = {}
207+
for _, event in ipairs(stoplight) do
208+
table.insert(tempStoplight, event)
209+
end
210+
table.insert(tempStoplight, { name = "panic", from = "green", to = "red" })
211+
212+
local fsm = machine.create({
213+
initial = 'green',
214+
events = tempStoplight
215+
})
216+
217+
fsm.onleavegreen = function(self, name, from, to)
218+
return fsm.ASYNC
219+
end
220+
221+
fsm:warn()
222+
223+
local result = fsm:panic()
224+
local transitionResult = fsm:transition(fsm.currentTransitioningEvent)
225+
226+
assert.is_true(result)
227+
assert.is_true(transitionResult)
228+
assert.is_nil(fsm.currentTransitioningEvent)
229+
assert.are_equal(fsm.asyncState, fsm.NONE)
230+
assert.are_equal(fsm.current, 'red')
231+
end)
232+
233+
it("should properly transition when another event happens during enter async", function()
234+
fsm.onenteryellow = function(self, name, from, to)
235+
return fsm.ASYNC
236+
end
237+
238+
fsm:warn()
239+
240+
local result = fsm:panic()
241+
242+
assert.is_true(result)
243+
assert.is_nil(fsm.currentTransitioningEvent)
244+
assert.are_equal(fsm.asyncState, fsm.NONE)
245+
assert.are_equal(fsm.current, 'red')
246+
end)
247+
248+
it("should properly cancel the transition if asked", function()
249+
fsm.onleavegreen = function(self, name, from, to)
250+
return fsm.ASYNC
251+
end
252+
253+
fsm:warn()
254+
fsm:cancelTransition(fsm.currentTransitioningEvent)
255+
256+
assert.is_nil(fsm.currentTransitioningEvent)
257+
assert.are_equal(fsm.asyncState, fsm.NONE)
258+
assert.are_equal(fsm.current, 'green')
259+
260+
fsm.onleavegreen = nil
261+
fsm.onenteryellow = function(self, name, from, to)
262+
return fsm.ASYNC
263+
end
264+
265+
fsm:warn()
266+
fsm:cancelTransition(fsm.currentTransitioningEvent)
267+
268+
assert.is_nil(fsm.currentTransitioningEvent)
269+
assert.are_equal(fsm.asyncState, fsm.NONE)
270+
assert.are_equal(fsm.current, 'yellow')
271+
end)
272+
150273
it("todot generates dot file (graphviz)", function()
151274
assert.has_no_error(function()
152275
fsm:todot('stoplight.dot')

statemachine.lua

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
local machine = {}
22
machine.__index = machine
33

4+
local NONE = "none"
5+
local ASYNC = "async"
46

57
local function call_handler(handler, params)
68
if handler then
@@ -9,29 +11,62 @@ local function call_handler(handler, params)
911
end
1012

1113
local function create_transition(name)
12-
return function(self, ...)
13-
local can, to = self:can(name)
14+
local can, to, from, params
1415

15-
if can then
16-
local from = self.current
17-
local params = { self, name, from, to, ... }
16+
local function transition(self, ...)
17+
if self.asyncState == NONE then
18+
can, to = self:can(name)
19+
from = self.current
20+
params = { self, name, from, to, ...}
1821

19-
if call_handler(self["onbefore" .. name], params) == false
20-
or call_handler(self["onleave" .. from], params) == false then
22+
if not can then return false end
23+
self.currentTransitioningEvent = name
24+
25+
local beforeReturn = call_handler(self["onbefore" .. name], params)
26+
local leaveReturn = call_handler(self["onleave" .. from], params)
27+
28+
if beforeReturn == false or leaveReturn == false then
2129
return false
2230
end
2331

32+
self.asyncState = name .. "WaitingOnLeave"
33+
34+
if leaveReturn ~= ASYNC then
35+
transition(self, ...)
36+
end
37+
38+
return true
39+
elseif self.asyncState == name .. "WaitingOnLeave" then
2440
self.current = to
2541

26-
call_handler(self["onenter" .. to] or self["on" .. to], params)
42+
local enterReturn = call_handler(self["onenter" .. to] or self["on" .. to], params)
43+
44+
self.asyncState = name .. "WaitingOnEnter"
45+
46+
if enterReturn ~= ASYNC then
47+
transition(self, ...)
48+
end
49+
50+
return true
51+
elseif self.asyncState == name .. "WaitingOnEnter" then
2752
call_handler(self["onafter" .. name] or self["on" .. name], params)
2853
call_handler(self["onstatechange"], params)
29-
54+
self.asyncState = NONE
55+
self.currentTransitioningEvent = nil
3056
return true
57+
else
58+
if string.find(self.asyncState, "WaitingOnLeave") or string.find(self.asyncState, "WaitingOnEnter") then
59+
self.asyncState = NONE
60+
transition(self, ...)
61+
return true
62+
end
3163
end
3264

65+
self.currentTransitioningEvent = nil
3366
return false
3467
end
68+
69+
return transition
3570
end
3671

3772
local function add_to_map(map, event)
@@ -52,6 +87,7 @@ function machine.create(options)
5287

5388
fsm.options = options
5489
fsm.current = options.initial or 'none'
90+
fsm.asyncState = NONE
5591
fsm.events = {}
5692

5793
for _, event in ipairs(options.events or {}) do
@@ -101,5 +137,20 @@ function machine:todot(filename)
101137
dotfile:close()
102138
end
103139

140+
function machine:transition(event)
141+
if self.currentTransitioningEvent == event then
142+
return self[self.currentTransitioningEvent](self)
143+
end
144+
end
145+
146+
function machine:cancelTransition(event)
147+
if self.currentTransitioningEvent == event then
148+
self.asyncState = NONE
149+
self.currentTransitioningEvent = nil
150+
end
151+
end
152+
153+
machine.NONE = NONE
154+
machine.ASYNC = ASYNC
104155

105156
return machine

0 commit comments

Comments
 (0)