Skip to content
This repository has been archived by the owner on Jun 19, 2024. It is now read-only.
/ Jester Public archive

A pure Swift state machine with Rx streams and non-Rx callbacks

License

Notifications You must be signed in to change notification settings

shopkeep/Jester

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

17 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Jester

CI Status Version License Platform

Jester is a pure swift state machine with a Rx streams and non-Rx callbacks.

The DSL was inspired by RxAutomaton.

Table of Contents

Example

class ShopKeepSelfDrivingCar {

    var numberOfPassengers: Int = 0
    var locationRequested: String? = nil
    private(set) var machine: StateMachine<State>!

    enum Input {
        static let startEngine           = BaseInput<Void>(description: "startEngine")
        static let driveAround           = BaseInput<Void>(description: "drive")
        static let park                  = BaseInput<Void>(description: "park")
        static let pickUpPassengers      = BaseInput<Void>(description: "pickUpPassengers")
        static let passengersHaveEntered = BaseInput<Int>(description: "passengersHaveEntered")
        static let driveToLocation       = BaseInput<String>(description: "driveToLocation")
        static let dropOffPassengers     = BaseInput<Void>(description: "dropOffPassengers")

        static let noMapInput            = BaseInput<Void>(description: "noMapInput")
        static let noFailInput           = BaseInput<Void>(description: "noFailInput")
    }

    enum State {
        case unstarted
        case drivingAround
        case drivingToLocation
        case parked
        case pickingUpPassengers
        case droppingOffPassengers
    }

    init() {
        let mappings: [MappedStateTransition<State>] = { [weak self] in

        let dropOffPassengers  = wrap { self?.dropOffPassengers($0) }
        let pickedUpPassengers = wrap { self?.pickedUpPassengers($0, $1) }
        let driveToLocation    = wrap { self?.driveToLocation($0, $1) }
        let noEffect           = wrap { self?.noEffect($0) }

        return [
            //    Input                 |          From State    ->    To State            | Effect (callback)
            Input.startEngine           |             .unstarted => .parked                | noEffect,

            Input.driveAround           |                .parked => .drivingAround         | noEffect,
            Input.driveAround           | .droppingOffPassengers => .drivingAround         | noEffect,

            Input.park                  |         .drivingAround => .parked                | noEffect,
            Input.park                  |     .drivingToLocation => .parked                | noEffect,

            Input.pickUpPassengers      |                .parked => .pickingUpPassengers   | noEffect,
            Input.passengersHaveEntered |   .pickingUpPassengers => .pickingUpPassengers   | pickedUpPassengers,
            Input.driveToLocation       |   .pickingUpPassengers => .drivingToLocation     | driveToLocation,
            Input.dropOffPassengers     |                .parked => .droppingOffPassengers | dropOffPassengers,

            Input.noFailInput           |             .unstarted => .unstarted             | noEffect,
            Input.noFailInput           |         .drivingAround => .drivingAround         | noEffect,
            Input.noFailInput           |     .drivingToLocation => .drivingToLocation     | noEffect,
            Input.noFailInput           |                .parked => .parked                | noEffect,
            Input.noFailInput           |   .pickingUpPassengers => .pickingUpPassengers   | noEffect,
            Input.noFailInput           | .droppingOffPassengers => .droppingOffPassengers | noEffect,
            ]
        }()

        machine = StateMachine<State>.init(initialState: .unstarted, mappings: mappings)
    }

    func driveToLocation(_ location: String, _ machine: StateMachine<State>) {
        locationRequested = location
    }

    func dropOffPassengers(_ machine: StateMachine<State>) {
        numberOfPassengers = 0
    }

    func pickedUpPassengers(_ count: Int, _ machine: StateMachine<State>) {
        numberOfPassengers = count
    }

    private func noEffect(_ machine: StateMachine<State>) {}
}

How To

There are three components that are needed to set up your state machine:

Inputs

enum Input {
    static let startEngine           = BaseInput<Void>(description: "startEngine")
    static let driveAround           = BaseInput<Void>(description: "drive")
    static let park                  = BaseInput<Void>(description: "park")
    static let pickUpPassengers      = BaseInput<Void>(description: "pickUpPassengers")
    static let passengersHaveEntered = BaseInput<Int>(description: "passengersHaveEntered")
    static let driveToLocation       = BaseInput<String>(description: "driveToLocation")
    static let dropOffPassengers     = BaseInput<Void>(description: "dropOffPassengers")

    static let noMapInput            = BaseInput<Void>(description: "noMapInput")
    static let noFailInput           = BaseInput<Void>(description: "noFailInput")
}

To create an input, initialize a BaseInput. Every BaseInput has an id and description. The description is for the user's convenience, making it easier when debugging and reading code.

It is required that your BaseInput be static or belong to a singleton. The reason is that every instance of BaseInput generates a uuid, so in order for the inputs to match, they must be the input used during the creation of the state machine. The above uses an enum simply for name spacing but the same can be done using a struct or class.

You'll also notice that BaseInput has a generic type. This specifies the type of argument that can be passed the BaseInput's derivative input, InputWithArgument.

States

enum State {
    case unstarted
    case drivingAround
    case drivingToLocation
    case parked
    case pickingUpPassengers
    case droppingOffPassengers
}

Your state machine State can be anything, as long as it is Equatable. Enums generally work well as states, but if the enum required associated values you will have to define equality.

Effects

let dropOffPassengers  = wrap { self?.dropOffPassengers($0) }
let pickedUpPassengers = wrap { self?.pickedUpPassengers($0, $1) }
let driveToLocation    = wrap { self?.driveToLocation($0, $1) }
let noEffect           = wrap { self?.noEffect($0) }

The global function wrap will allow you to generate an EffectWrapper, which really just holds a callback. Effect callback closures that don't require the handline of an input argument must be of type: (StateMachine<State>) -> Void While callbacks that are intended to be used for handling input arguments must be of type: (T, StateMachine<State>) -> Void where T is the type required.

Combining The Components

//    Input                 |     from State      ->      to State             | Effect (callback)
Input.driveToLocation       | .pickingUpPassengers => .drivingToLocation       | driveToLocation,

This can be read as: When I receive an input of driveToLocation And I am currently on state .pickingUpPassengers Then move to state .drivingToLocation And call the effect driveToLocation

Generate Mappings

let mappings: [MappedStateTransition<State>] = { [weak self] in
    // set up effects and mappings here
    // example:
    let driveToLocation    = wrap { self?.driveToLocation($0, $1) }

    Input.driveToLocation       | .pickingUpPassengers => .drivingToLocation       | driveToLocation,
}()

It is recommended to create your mappings using an immediately executed closure (similar to how lazy variables are created). This allows you to weakify self, preventing retain cycles between the state machine and the owner of the machine.

Then once you have your mappings you can initialize the state machine with the mappings.

let machine = StateMachine<State>.init(initialState: .unstarted, mappings: mappings)

Sending An Input

Sending an input to your state machine is straighforward:

machine.send(Input.startEngine)

Sending an input with an argument is just as simple:

machine.send(Input.passengersHaveEntered.inputWithArgument(4))

As you can see above, to generate an InputWithArgument, use the inputWithArgument function on your the corresponding BaseInput instance, supplying an argument that matches the type specified by the BaseInput.

Because the type is specified you are guaranteed to have compile time checking - preventing any runtime type mismatches. Additionally Jester was built to force the Effect to only accept the type specified by the BaseInput its' mapping row.

Observing with RxSwift

If you're using RxSwift, you can hook into your state machine's updates by using the currentState property on your state machine. You can also hook into all transition results through the transitionResults property on your state machine.

StateTransitionResult :

public enum StateTransitionResult<State> {
    case Success(old: State, new: State, input: AnyInput)
    case Failure(error: StateTransitionError<State>)
    public var debugDescription: String {
        switch self {
        case .Success(let old, let new, let input):
            return "STATE TRANSITION RESULT:\n    SUCCESS\n    transition: \(old) -> \(new)\n    input: \(input)\n\n"
        case .Failure(let error):
            return "STATE TRANSITION RESULT:\n    FAILURE\n    \(error.debugDescription)"
        }
    }
}

StateTransitionError :

public struct StateTransitionError<State>: Swift.Error, CustomDebugStringConvertible  {
    public let current: State
    public let input: AnyInput
    public let error: MappingError

    public var debugDescription: String {
        return "STATE TRANSITION ERROR:\n    state: \(current)\n    input: \(input)\n    error: \(error)\n\n"
    }
}

Observing with Callbacks

For those who opt to use callbacks instead of RxSwift, hooking into the updates and transition results is just as simple. Simply use the watcher() function on your state machine to generate a callback registry, StateMachineWatcher. Then add your callbacks through the onNext(_:) and onTransitionResult(_:) functions. Just be sure to retain your StateMachineWatcher.

Examples:

watcher = machine.watcher()

watcher.onNext({ state in
    currentState = state
})

watcher.onTransitionResult({ result in
    switch result {
    case .Success(let oldState, let newState, let input): break
    case .Failure(let err): error = err
    }
})

More Info

For more info, check out the Tests in the Example folder. There should be adequate sample code there and in the ShopKeepSelfDrivingCar code which is also in the Tests folder and up above in Example section.

Requirements

iOS 9+ | Swift 4

Installation

Jester is available through CocoaPods. To install it, simply add the following line to your Podfile:

pod 'Jester'

Author

ShopKeep

License

Jester is available under the MIT license. See the LICENSE file for more info.

About

A pure Swift state machine with Rx streams and non-Rx callbacks

Resources

License

Stars

Watchers

Forks

Packages

No packages published