Skip to content

Commit

Permalink
Merge pull request #19 from treamology/async
Browse files Browse the repository at this point in the history
Add asynchronous state transitions
  • Loading branch information
kyleconroy authored Jun 20, 2016
2 parents 7258168 + 8df3a15 commit e076260
Show file tree
Hide file tree
Showing 3 changed files with 220 additions and 37 deletions.
31 changes: 20 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ local fsm = machine.create({
along with the following members:

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

* return `false` from an `onbeforeevent` handler to cancel the event.
* return `false` from an `onleavestate` handler to cancel the event.
* return `ASYNC` from an `onleavestate` handler to perform an asynchronous state transition (see next section)
* return `ASYNC` from an `onleavestate` or `onenterstate` handler to perform an asynchronous state transition (see next section)

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

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

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

For example, using jQuery effects:
If another event is triggered during a state machine transition, the event will be triggered relative to the
state the machine was transitioning to or from. Any calls to `transition` with the cancelled async event name
will be invalidated.

During a state change, `asyncState` will transition from `NONE` to `[event]WaitingOnLeave` to `[event]WaitingOnEnter`,
looping back to `NONE`. If the state machine is put on hold, `asyncState` will pause depending on which handler
you returned `ASYNC` from.

Example of asynchronous transitions:

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

onleavemenu = function()
onleavemenu = function(fsm, name, from, to)
manager.fade('fast', function()
fsm:transition()
fsm:transition(name)
end)
return machine.ASYNC -- tell machine to defer next state until we call transition (in fadeOut callback above)
return fsm.ASYNC -- tell machine to defer next state until we call transition (in fadeOut callback above)
end,

onleavegame = function()
onleavegame = function(fsm, name, from, to)
manager.slide('slow', function()
fsm:transition()
fsm:transition(name)
end)
return machine.ASYNC -- tell machine to defer next state until we call transition (in slideDown callback above)
return fsm.ASYNC -- tell machine to defer next state until we call transition (in slideDown callback above)
end,
}
})
```

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

Initialization Options
======================
Expand Down
157 changes: 140 additions & 17 deletions spec/fsm_spec.lua
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
require("busted")

local machine = require("statemachine")
local _ = require("luassert.match")._

describe("Lua state machine framework", function()
describe("A stop light", function()
Expand Down Expand Up @@ -68,11 +69,13 @@ describe("Lua state machine framework", function()

fsm:warn()

assert.spy(fsm.onbeforewarn).was_called_with(fsm, 'warn', 'green', 'yellow')
assert.spy(fsm.onleavegreen).was_called_with(fsm, 'warn', 'green', 'yellow')
assert.spy(fsm.onenteryellow).was_called_with(fsm, 'warn', 'green', 'yellow')
assert.spy(fsm.onafterwarn).was_called_with(fsm, 'warn', 'green', 'yellow')
assert.spy(fsm.onstatechange).was_called_with(fsm, 'warn', 'green', 'yellow')
assert.spy(fsm.onbeforewarn).was_called_with(_, 'warn', 'green', 'yellow')
assert.spy(fsm.onleavegreen).was_called_with(_, 'warn', 'green', 'yellow')

assert.spy(fsm.onenteryellow).was_called_with(_, 'warn', 'green', 'yellow')
assert.spy(fsm.onafterwarn).was_called_with(_, 'warn', 'green', 'yellow')
assert.spy(fsm.onstatechange).was_called_with(_, 'warn', 'green', 'yellow')

assert.spy(fsm.onyellow).was_not_called()
assert.spy(fsm.onwarn).was_not_called()
end)
Expand All @@ -89,11 +92,12 @@ describe("Lua state machine framework", function()

fsm:warn()

assert.spy(fsm.onbeforewarn).was_called_with(fsm, 'warn', 'green', 'yellow')
assert.spy(fsm.onleavegreen).was_called_with(fsm, 'warn', 'green', 'yellow')
assert.spy(fsm.onenteryellow).was_called_with(fsm, 'warn', 'green', 'yellow')
assert.spy(fsm.onafterwarn).was_called_with(fsm, 'warn', 'green', 'yellow')
assert.spy(fsm.onstatechange).was_called_with(fsm, 'warn', 'green', 'yellow')
assert.spy(fsm.onbeforewarn).was_called_with(_, 'warn', 'green', 'yellow')
assert.spy(fsm.onleavegreen).was_called_with(_, 'warn', 'green', 'yellow')

assert.spy(fsm.onenteryellow).was_called_with(_, 'warn', 'green', 'yellow')
assert.spy(fsm.onafterwarn).was_called_with(_, 'warn', 'green', 'yellow')
assert.spy(fsm.onstatechange).was_called_with(_, 'warn', 'green', 'yellow')

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

fsm:warn('bar')

assert.spy(fsm.onbeforewarn).was_called_with(fsm, 'warn', 'green', 'yellow', 'bar')
assert.spy(fsm.onleavegreen).was_called_with(fsm, 'warn', 'green', 'yellow', 'bar')
assert.spy(fsm.onenteryellow).was_called_with(fsm, 'warn', 'green', 'yellow', 'bar')
assert.spy(fsm.onafterwarn).was_called_with(fsm, 'warn', 'green', 'yellow', 'bar')
assert.spy(fsm.onstatechange).was_called_with(fsm, 'warn', 'green', 'yellow', 'bar')
assert.spy(fsm.onbeforewarn).was_called_with(_, 'warn', 'green', 'yellow', 'bar')
assert.spy(fsm.onleavegreen).was_called_with(_, 'warn', 'green', 'yellow', 'bar')

assert.spy(fsm.onenteryellow).was_called_with(_, 'warn', 'green', 'yellow', 'bar')
assert.spy(fsm.onafterwarn).was_called_with(_, 'warn', 'green', 'yellow', 'bar')
assert.spy(fsm.onstatechange).was_called_with(_, 'warn', 'green', 'yellow', 'bar')
end)

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

fsm:warn()

assert.spy(fsm.onyellow).was_called_with(fsm, 'warn', 'green', 'yellow')
assert.spy(fsm.onwarn).was_called_with(fsm, 'warn', 'green', 'yellow')
assert.spy(fsm.onyellow).was_called_with(_, 'warn', 'green', 'yellow')
assert.spy(fsm.onwarn).was_called_with(_, 'warn', 'green', 'yellow')
end)

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

it("pauses when async is passed", function()
fsm.onleavegreen = function(self, name, from, to)
return fsm.ASYNC
end
fsm.onenteryellow = function(self, name, from, to)
return fsm.ASYNC
end

local result = fsm:warn()
assert.is_true(result)
assert.are_equal(fsm.current, 'green')
assert.are_equal(fsm.currentTransitioningEvent, 'warn')
assert.are_equal(fsm.asyncState, 'warnWaitingOnLeave')

result = fsm:transition(fsm.currentTransitioningEvent)
assert.is_true(result)
assert.are_equal(fsm.current, 'yellow')
assert.are_equal(fsm.currentTransitioningEvent, 'warn')
assert.are_equal(fsm.asyncState, 'warnWaitingOnEnter')

result = fsm:transition(fsm.currentTransitioningEvent)
assert.is_true(result)
assert.are_equal(fsm.current, 'yellow')
assert.is_nil(fsm.currentTransitioningEvent)
assert.are_equal(fsm.asyncState, fsm.NONE)
end)

it("should accept additional arguments to async handlers", function()
fsm.onbeforewarn = stub.new()
fsm.onleavegreen = spy.new(function(self, name, from, to, arg)
return fsm.ASYNC
end)
fsm.onenteryellow = spy.new(function(self, name, from, to, arg)
return fsm.ASYNC
end)
fsm.onafterwarn = stub.new()
fsm.onstatechange = stub.new()

fsm:warn('bar')
assert.spy(fsm.onbeforewarn).was_called_with(_, 'warn', 'green', 'yellow', 'bar')
assert.spy(fsm.onleavegreen).was_called_with(_, 'warn', 'green', 'yellow', 'bar')

fsm:transition(fsm.currentTransitioningEvent)
assert.spy(fsm.onenteryellow).was_called_with(_, 'warn', 'green', 'yellow', 'bar')

fsm:transition(fsm.currentTransitioningEvent)
assert.spy(fsm.onafterwarn).was_called_with(_, 'warn', 'green', 'yellow', 'bar')
assert.spy(fsm.onstatechange).was_called_with(_, 'warn', 'green', 'yellow', 'bar')
end)

it("should properly transition when another event happens during leave async", function()
local tempStoplight = {}
for _, event in ipairs(stoplight) do
table.insert(tempStoplight, event)
end
table.insert(tempStoplight, { name = "panic", from = "green", to = "red" })

local fsm = machine.create({
initial = 'green',
events = tempStoplight
})

fsm.onleavegreen = function(self, name, from, to)
return fsm.ASYNC
end

fsm:warn()

local result = fsm:panic()
local transitionResult = fsm:transition(fsm.currentTransitioningEvent)

assert.is_true(result)
assert.is_true(transitionResult)
assert.is_nil(fsm.currentTransitioningEvent)
assert.are_equal(fsm.asyncState, fsm.NONE)
assert.are_equal(fsm.current, 'red')
end)

it("should properly transition when another event happens during enter async", function()
fsm.onenteryellow = function(self, name, from, to)
return fsm.ASYNC
end

fsm:warn()

local result = fsm:panic()

assert.is_true(result)
assert.is_nil(fsm.currentTransitioningEvent)
assert.are_equal(fsm.asyncState, fsm.NONE)
assert.are_equal(fsm.current, 'red')
end)

it("should properly cancel the transition if asked", function()
fsm.onleavegreen = function(self, name, from, to)
return fsm.ASYNC
end

fsm:warn()
fsm:cancelTransition(fsm.currentTransitioningEvent)

assert.is_nil(fsm.currentTransitioningEvent)
assert.are_equal(fsm.asyncState, fsm.NONE)
assert.are_equal(fsm.current, 'green')

fsm.onleavegreen = nil
fsm.onenteryellow = function(self, name, from, to)
return fsm.ASYNC
end

fsm:warn()
fsm:cancelTransition(fsm.currentTransitioningEvent)

assert.is_nil(fsm.currentTransitioningEvent)
assert.are_equal(fsm.asyncState, fsm.NONE)
assert.are_equal(fsm.current, 'yellow')
end)

it("todot generates dot file (graphviz)", function()
assert.has_no_error(function()
fsm:todot('stoplight.dot')
Expand Down
69 changes: 60 additions & 9 deletions statemachine.lua
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
local machine = {}
machine.__index = machine

local NONE = "none"
local ASYNC = "async"

local function call_handler(handler, params)
if handler then
Expand All @@ -9,29 +11,62 @@ local function call_handler(handler, params)
end

local function create_transition(name)
return function(self, ...)
local can, to = self:can(name)
local can, to, from, params

if can then
local from = self.current
local params = { self, name, from, to, ... }
local function transition(self, ...)
if self.asyncState == NONE then
can, to = self:can(name)
from = self.current
params = { self, name, from, to, ...}

if call_handler(self["onbefore" .. name], params) == false
or call_handler(self["onleave" .. from], params) == false then
if not can then return false end
self.currentTransitioningEvent = name

local beforeReturn = call_handler(self["onbefore" .. name], params)
local leaveReturn = call_handler(self["onleave" .. from], params)

if beforeReturn == false or leaveReturn == false then
return false
end

self.asyncState = name .. "WaitingOnLeave"

if leaveReturn ~= ASYNC then
transition(self, ...)
end

return true
elseif self.asyncState == name .. "WaitingOnLeave" then
self.current = to

call_handler(self["onenter" .. to] or self["on" .. to], params)
local enterReturn = call_handler(self["onenter" .. to] or self["on" .. to], params)

self.asyncState = name .. "WaitingOnEnter"

if enterReturn ~= ASYNC then
transition(self, ...)
end

return true
elseif self.asyncState == name .. "WaitingOnEnter" then
call_handler(self["onafter" .. name] or self["on" .. name], params)
call_handler(self["onstatechange"], params)

self.asyncState = NONE
self.currentTransitioningEvent = nil
return true
else
if string.find(self.asyncState, "WaitingOnLeave") or string.find(self.asyncState, "WaitingOnEnter") then
self.asyncState = NONE
transition(self, ...)
return true
end
end

self.currentTransitioningEvent = nil
return false
end

return transition
end

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

fsm.options = options
fsm.current = options.initial or 'none'
fsm.asyncState = NONE
fsm.events = {}

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

function machine:transition(event)
if self.currentTransitioningEvent == event then
return self[self.currentTransitioningEvent](self)
end
end

function machine:cancelTransition(event)
if self.currentTransitioningEvent == event then
self.asyncState = NONE
self.currentTransitioningEvent = nil
end
end

machine.NONE = NONE
machine.ASYNC = ASYNC

return machine

0 comments on commit e076260

Please sign in to comment.