diff --git a/README.md b/README.md index ef631ca..654accf 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,63 @@ do { } ``` +### Wildcard transitions + +An event definition in Stately may define a single wildcard transition. A wildcard transition will transition from any from state to the specified to state. The following example adds a broken state to the above example: + +```swift +var stateClosed: State! +var stateOpened: State! +var stateBroken: State! +var eventOpen: Event! +var eventClose: Event! +var eventBroken: Event! +var stateMachine: StateMachine! + +do { + // Define the states that the state machine can be in. + stateClosed = try State(name: "Closed") { (object: AnyObject?) -> StateChange? in + // Log. + print("Closed") + + // Return, leaving state unchanged. + return nil + } + stateOpened = try State(name: "Opened") { (object: AnyObject?) -> StateChange? in + // Log. + print("Opened") + + // Return, leaving state unchanged. + return nil + } + stateBroken = try State(name: "Broken") { (object: AnyObject?) -> StateChange? in + // Log. + print("Broken") + + // Return, leaving state unchanged. + return nil + } + + // Define the events that can be sent to the state machine. + eventOpen = try Event(name: "Open", transitions: [(fromState: stateClosed, toState: stateOpened)]) + eventClose = try Event(name: "Close", transitions: [(fromState: stateOpened, toState: stateClosed)]) + eventBroken = try Event(name: "Broken", transitions: [(fromState: nil, toState: stateBroken)]) + + // Initialize the state machine. + stateMachine = try StateMachine(name: "Door", + defaultState: stateClosed, + states: [stateClosed, stateOpened], + events: [eventClose, eventOpen]) + + // Fire events to the state machine. + try stateMachine.fireEvent(event: eventOpen) + try stateMachine.fireEvent(event: eventClose) + try stateMachine.fireEvent(event: eventBroken) +} catch { + // Handle errors. +} +``` + ## Example Project The [StatelyExample](https://github.com/softwarenerd/StatelyExample) project provides a fairly complete example of using Stately to build a garage door simulator. Other examples are planned. diff --git a/Stately/Code/Event.swift b/Stately/Code/Event.swift index ba13500..a6e5d9a 100644 --- a/Stately/Code/Event.swift +++ b/Stately/Code/Event.swift @@ -9,13 +9,14 @@ import Foundation // Types alias for a transition tuple. -public typealias Transition = (fromState: State, toState: State) +public typealias Transition = (fromState: State?, toState: State) // Event error enumeration. public enum EventError: Error { case NameEmpty case NoTransitions case DuplicateTransition(fromState: State) + case MultipleWildcardTransitions } // Event class. @@ -26,11 +27,16 @@ public class Event : Hashable { // The set of transitions for the event. let transitions: [Transition] + // The wildcard transition. + let wildcardTransition: Transition? + /// Initializes a new instance of the Event class. /// /// - Parameters: /// - name: The name of the event. Each event must have a unique name. - /// - transitions: The the event transitions. Each event must define at least one transition. + /// - transitions: The transitions for the event. An event must define at least one transition. + /// A transition with a from state of nil is the whildcard transition and will match any from + /// state. Only one wildcard transition may defined for an event. public init(name nameIn: String, transitions transitionsIn: [Transition]) throws { // Validate the event name. if nameIn.isEmpty { @@ -42,21 +48,34 @@ public class Event : Hashable { throw EventError.NoTransitions } - // Ensure that there are no duplicate from state transitions defined. While this wouldn't strictly + // Ensure that there are no duplicate from state transitions defined. (While this wouldn't strictly // be a bad thing, the presence of duplicate from state transitions more than likely indicates that - // there is a bug in the definition of the state machine, so we don't allow it. + // there is a bug in the definition of the state machine, so we don't allow it.) Also, there can be + // only one wildcard transition (a transition with a nil from state) defined. + var wildcardTransitionTemp: Transition? = nil var fromStatesTemp = Set(minimumCapacity: transitionsIn.count) for transition in transitionsIn { - if fromStatesTemp.contains(transition.fromState) { - throw EventError.DuplicateTransition(fromState: transition.fromState) + // See if there's a from state. If there is, ensure it's not a duplicate. If there isn't, then + // ensure there is only one wildcard transition. + if let fromState = transition.fromState { + if fromStatesTemp.contains(fromState) { + throw EventError.DuplicateTransition(fromState: fromState) + } else { + fromStatesTemp.insert(fromState) + } } else { - fromStatesTemp.insert(transition.fromState) + if wildcardTransitionTemp != nil { + throw EventError.MultipleWildcardTransitions + } else { + wildcardTransitionTemp = transition + } } } // Initialize. name = nameIn transitions = transitionsIn + wildcardTransition = wildcardTransitionTemp } /// Returns the transition with the specified from state, if one is found; otherwise, nil. @@ -64,9 +83,9 @@ public class Event : Hashable { /// - Parameters: /// - fromState: The from state. func transition(fromState: State) -> State? { - // Find the transition. If it cannot be found, return nil. + // Find the transition. If it cannot be found, and there's a wildcard transition, return its to state. guard let transition = (transitions.first(where: { (transition: Transition) -> Bool in return transition.fromState === fromState })) else { - return nil; + return wildcardTransition?.toState } // Return the to state. diff --git a/Stately/Code/StateMachine.swift b/Stately/Code/StateMachine.swift index 58ebae8..ffca703 100644 --- a/Stately/Code/StateMachine.swift +++ b/Stately/Code/StateMachine.swift @@ -87,9 +87,12 @@ public class StateMachine { } else { // Validate the transitions for the event. for transition in event.transitions { - // Ensure that the from state is defined. - if !statesTemp.contains(transition.fromState) { - throw StateMachineError.TransitionFromStateNotDefined(fromState: transition.fromState) + + // Ensure that the from state is defined, or is nil, indicating "wildcard". + if let fromState = transition.fromState { + if !statesTemp.contains(fromState) { + throw StateMachineError.TransitionFromStateNotDefined(fromState: fromState) + } } // Ensure that the to state is defined. diff --git a/StatelyTests/StatelyTests.swift b/StatelyTests/StatelyTests.swift index d838b3f..8cb5e96 100644 --- a/StatelyTests/StatelyTests.swift +++ b/StatelyTests/StatelyTests.swift @@ -214,6 +214,66 @@ class StatelyTests: XCTestCase } } + // Tests multiple wildcard transitions. + func testMultipleWildcardTransition() { + do { + // Setup. + let stateA = try State(name: "A") { (object: AnyObject?) -> StateChange? in + return nil + } + let stateB = try State(name: "B") { (object: AnyObject?) -> StateChange? in + return nil + } + let stateC = try State(name: "C") { (object: AnyObject?) -> StateChange? in + return nil + } + let event1 = try Event(name: "1", transitions: [(fromState: nil, toState: stateA), + (fromState: nil, toState: stateC)]) + + // Test. + let stateMachine = try StateMachine(name: "StateMachine", defaultState: stateA, states: [stateA, stateB, stateC], events: [event1]) + try stateMachine.fireEvent(event: event1) + + // Assert. + XCTFail("An error should have been thrown") + } catch EventError.MultipleWildcardTransitions { + // Expect to arrive here. This is success. + } catch { + XCTFail("Unexpeced error thrown: \(error)") + } + } + + // Tests a wildcard transition. + func testWildcardTransition() { + do { + // Setup. + let stateA = try State(name: "A") { (object: AnyObject?) -> StateChange? in + return nil + } + let stateB = try State(name: "B") { (object: AnyObject?) -> StateChange? in + return nil + } + var stateCEntered = false + let stateC = try State(name: "C") { (object: AnyObject?) -> StateChange? in + stateCEntered = true + return nil + } + let event1 = try Event(name: "1", transitions: [(fromState: stateB, toState: stateA), + (fromState: nil, toState: stateC)]) + + // Test. + let stateMachine = try StateMachine(name: "StateMachine", defaultState: stateA, states: [stateA, stateB, stateC], events: [event1]) + try stateMachine.fireEvent(event: event1) + + // Assert. + XCTAssertTrue(stateCEntered) + } + catch { + // Assert. + XCTFail("Unexpeced error thrown: \(error)") + } + } + // Tests multiple threads. func testMultipleThreads() { do {