Skip to content
This repository was archived by the owner on Jun 19, 2024. It is now read-only.

Repository files navigation

Jester

CI StatusVersionLicensePlatform

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

classShopKeepSelfDrivingCar{varnumberOfPassengers:Int=0varlocationRequested:String?=nilprivate(set)varmachine:StateMachine<State>!enumInput{staticletstartEngine=BaseInput<Void>(description:"startEngine")staticletdriveAround=BaseInput<Void>(description:"drive")staticletpark=BaseInput<Void>(description:"park")staticletpickUpPassengers=BaseInput<Void>(description:"pickUpPassengers")staticletpassengersHaveEntered=BaseInput<Int>(description:"passengersHaveEntered")staticletdriveToLocation=BaseInput<String>(description:"driveToLocation")staticletdropOffPassengers=BaseInput<Void>(description:"dropOffPassengers")staticletnoMapInput=BaseInput<Void>(description:"noMapInput")staticletnoFailInput=BaseInput<Void>(description:"noFailInput")}enumState{case unstarted
case drivingAround
case drivingToLocation
case parked
case pickingUpPassengers
case droppingOffPassengers
}init(){letmappings:[MappedStateTransition<State>]={[weak self]inletdropOffPassengers=wrap{self?.dropOffPassengers($0)}letpickedUpPassengers=wrap{self?.pickedUpPassengers($0, $1)}letdriveToLocation=wrap{self?.driveToLocation($0, $1)}letnoEffect=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
}privatefunc noEffect(_ machine:StateMachine<State>){}}

How To

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

Inputs

enumInput{staticletstartEngine=BaseInput<Void>(description:"startEngine")staticletdriveAround=BaseInput<Void>(description:"drive")staticletpark=BaseInput<Void>(description:"park")staticletpickUpPassengers=BaseInput<Void>(description:"pickUpPassengers")staticletpassengersHaveEntered=BaseInput<Int>(description:"passengersHaveEntered")staticletdriveToLocation=BaseInput<String>(description:"driveToLocation")staticletdropOffPassengers=BaseInput<Void>(description:"dropOffPassengers")staticletnoMapInput=BaseInput<Void>(description:"noMapInput")staticletnoFailInput=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

enumState{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

letdropOffPassengers=wrap{self?.dropOffPassengers($0)}letpickedUpPassengers=wrap{self?.pickedUpPassengers($0, $1)}letdriveToLocation=wrap{self?.driveToLocation($0, $1)}letnoEffect=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

letmappings:[MappedStateTransition<State>]={[weak self]in
// set up effects and mappings here
// example:
letdriveToLocation=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.

letmachine= 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 :

publicenumStateTransitionResult<State>{case Success(old:State, new:State, input:AnyInput)case Failure(error:StateTransitionError<State>)publicvardebugDescription:String{switchself{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 :

publicstructStateTransitionError<State>:Swift.Error,CustomDebugStringConvertible{publicletcurrent:Statepublicletinput:AnyInputpublicleterror:MappingErrorpublicvardebugDescription: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 inswitch result {case.Success(let oldState,let newState,let input):breakcase.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

Stars

9 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages