├── .github ├── FUNDING.yml └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── assets ├── phone-graph.png └── stateless.svg ├── config.go ├── example_test.go ├── go.mod ├── go.sum ├── graph.go ├── graph_test.go ├── modes.go ├── statemachine.go ├── statemachine_test.go ├── states.go ├── states_test.go ├── testdata └── golden │ ├── emptyWithInitial.dot │ ├── phoneCall.dot │ ├── withGuards.dot │ ├── withInitialState.dot │ ├── withSubstate.dot │ └── withUnicodeNames.dot ├── triggers.go └── triggers_test.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: qmuntal 4 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Test 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | go-version: [1.21.x, 1.22.x] 8 | platform: [ubuntu-latest, macos-latest, windows-latest] 9 | runs-on: ${{ matrix.platform }} 10 | steps: 11 | - name: Install Go 12 | uses: actions/setup-go@v2 13 | with: 14 | go-version: ${{ matrix.go-version }} 15 | - name: Checkout code 16 | uses: actions/checkout@v2 17 | - name: Test 18 | run: go test -race -covermode atomic -coverprofile profile.cov ./... 19 | - name: Send coverage 20 | uses: shogo82148/actions-goveralls@v1 21 | with: 22 | path-to-profile: profile.cov 23 | flag-name: Go-${{ matrix.go-version }} 24 | parallel: true 25 | finish: 26 | needs: test 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: shogo82148/actions-goveralls@v1 30 | with: 31 | parallel-finished: true 32 | 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2019, Quim Muntal 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Stateless logo. Fire gopher designed by https://www.deviantart.com/quasilyte

2 | 3 |

4 | go.dev 5 | Build Status 6 | Code Coverage 7 | Go Report Card 8 | Licenses 9 | Mentioned in Awesome Go 10 |

11 | 12 | # Stateless 13 | 14 | **Create *state machines* and lightweight *state machine-based workflows* directly in Go code:** 15 | 16 | ```go 17 | phoneCall := stateless.NewStateMachine(stateOffHook) 18 | 19 | phoneCall.Configure(stateOffHook).Permit(triggerCallDialed, stateRinging) 20 | 21 | phoneCall.Configure(stateRinging). 22 | OnEntryFrom(triggerCallDialed, func(_ context.Context, args ...any) error { 23 | onDialed(args[0].(string)) 24 | return nil 25 | }). 26 | Permit(triggerCallConnected, stateConnected) 27 | 28 | phoneCall.Configure(stateConnected). 29 | OnEntry(func(_ context.Context, _ ...any) error { 30 | startCallTimer() 31 | return nil 32 | }). 33 | OnExit(func(_ context.Context, _ ...any) error { 34 | stopCallTimer() 35 | return nil 36 | }). 37 | Permit(triggerLeftMessage, stateOffHook). 38 | Permit(triggerPlacedOnHold, stateOnHold) 39 | 40 | // ... 41 | 42 | phoneCall.Fire(triggerCallDialed, "qmuntal") 43 | ``` 44 | 45 | This project, as well as the example above, is almost a direct, yet idiomatic, port of [dotnet-state-machine/stateless](https://github.com/dotnet-state-machine/stateless), which is written in C#. 46 | 47 | The state machine implemented in this library is based on the theory of [UML statechart](https://en.wikipedia.org/wiki/UML_state_machine). The concepts behind it are about organizing the way a device, computer program, or other (often technical) process works such that an entity or each of its sub-entities is always in exactly one of a number of possible states and where there are well-defined conditional transitions between these states. 48 | 49 | ## Features 50 | 51 | Most standard state machine constructs are supported: 52 | 53 | * Support for states and triggers of any comparable type (int, strings, boolean, structs, etc.) 54 | * Hierarchical states 55 | * Entry/exit events for states 56 | * Guard clauses to support conditional transitions 57 | * Introspection 58 | 59 | Some useful extensions are also provided: 60 | 61 | * Ability to store state externally (for example, in a property tracked by an ORM) 62 | * Parameterised triggers 63 | * Reentrant states 64 | * Thread-safe 65 | * Export to DOT graph 66 | 67 | ### Hierarchical States 68 | 69 | In the example below, the `OnHold` state is a substate of the `Connected` state. This means that an `OnHold` call is still connected. 70 | 71 | ```go 72 | phoneCall.Configure(stateOnHold). 73 | SubstateOf(stateConnected). 74 | Permit(triggerTakenOffHold, stateConnected). 75 | Permit(triggerPhoneHurledAgainstWall, statePhoneDestroyed) 76 | ``` 77 | 78 | In addition to the `StateMachine.State` property, which will report the precise current state, an `IsInState(State)` method is provided. `IsInState(State)` will take substates into account, so that if the example above was in the `OnHold` state, `IsInState(State.Connected)` would also evaluate to `true`. 79 | 80 | ### Entry/Exit Events 81 | 82 | In the example, the `StartCallTimer()` method will be executed when a call is connected. The `StopCallTimer()` will be executed when call completes (by either hanging up or hurling the phone against the wall.) 83 | 84 | The call can move between the `Connected` and `OnHold` states without the `StartCallTimer()` and `StopCallTimer()` methods being called repeatedly because the `OnHold` state is a substate of the `Connected` state. 85 | 86 | Entry/Exit event handlers can be supplied with a parameter of type `Transition` that describes the trigger, source and destination states. 87 | 88 | ### Initial state transitions 89 | 90 | A substate can be marked as initial state. When the state machine enters the super state it will also automatically enter the substate. This can be configured like this: 91 | 92 | ```go 93 | sm.Configure(State.B) 94 | .InitialTransition(State.C); 95 | 96 | sm.Configure(State.C) 97 | .SubstateOf(State.B); 98 | ``` 99 | 100 | ### External State Storage 101 | 102 | Stateless is designed to be embedded in various application models. For example, some ORMs place requirements upon where mapped data may be stored, and UI frameworks often require state to be stored in special "bindable" properties. To this end, the `StateMachine` constructor can accept function arguments that will be used to read and write the state values: 103 | 104 | ```go 105 | machine := stateless.NewStateMachineWithExternalStorage(func(_ context.Context) (stateless.State, error) { 106 | return myState.Value, nil 107 | }, func(_ context.Context, state stateless.State) error { 108 | myState.Value = state 109 | return nil 110 | }, stateless.FiringQueued) 111 | ``` 112 | 113 | In this example the state machine will use the `myState` object for state storage. 114 | 115 | This can further be extended to support more complex scenarios, such as when not only the current state is required but also the arguments which were supplied to that state. This can be useful when using error states that additional metadata can be stored or acted upon via callbacks. 116 | 117 | ```go 118 | machine := stateless.NewStateMachineWithExternalStorageAndArgs(func(_ context.Context) (stateless.State, []any, error) { 119 | return myState.Value, myState.Args, nil 120 | }, func(_ context.Context, state stateless.State, args ...any) error { 121 | myState.Value = state 122 | myState.Args = args 123 | return nil 124 | }, stateless.FiringQueued) 125 | ``` 126 | 127 | 128 | ### Activation / Deactivation 129 | 130 | It might be necessary to perform some code before storing the object state, and likewise when restoring the object state. Use `Deactivate` and `Activate` for this. Activation should only be called once before normal operation starts, and once before state storage. 131 | 132 | ### Introspection 133 | 134 | The state machine can provide a list of the triggers that can be successfully fired within the current state via the `StateMachine.PermittedTriggers` property. 135 | 136 | ### Guard Clauses 137 | 138 | The state machine will choose between multiple transitions based on guard clauses, e.g.: 139 | 140 | ```go 141 | phoneCall.Configure(stateOffHook). 142 | Permit(triggerCallDialled, stateRinging, func(_ context.Context, _ ...any) bool { 143 | return IsValidNumber() 144 | }). 145 | Permit(triggerCallDialled, stateBeeping, func(_ context.Context, _ ...any) bool { 146 | return !IsValidNumber() 147 | }) 148 | ``` 149 | 150 | Guard clauses within a state must be mutually exclusive (multiple guard clauses cannot be valid at the same time). Substates can override transitions by respecifying them, however substates cannot disallow transitions that are allowed by the superstate. 151 | 152 | The guard clauses will be evaluated whenever a trigger is fired. Guards should therefor be made side effect free. 153 | 154 | ### Parameterised Triggers 155 | 156 | Strongly-typed parameters can be assigned to triggers: 157 | 158 | ```go 159 | stateMachine.SetTriggerParameters(triggerCallDialed, reflect.TypeOf("")) 160 | 161 | stateMachine.Configure(stateRinging). 162 | OnEntryFrom(triggerCallDialed, func(_ context.Context, args ...any) error { 163 | fmt.Println(args[0].(string)) 164 | return nil 165 | }) 166 | 167 | stateMachine.Fire(triggerCallDialed, "qmuntal") 168 | ``` 169 | 170 | It is runtime safe to cast parameters to the ones specified in `SetTriggerParameters`. If the parameters passed in `Fire` do not match the ones specified it will panic. 171 | 172 | Trigger parameters can be used to dynamically select the destination state using the `PermitDynamic()` configuration method. 173 | 174 | ### Ignored Transitions and Reentrant States 175 | 176 | Firing a trigger that does not have an allowed transition associated with it will cause a panic to be thrown. 177 | 178 | To ignore triggers within certain states, use the `Ignore(Trigger)` directive: 179 | 180 | ```go 181 | phoneCall.Configure(stateConnected). 182 | Ignore(triggerCallDialled) 183 | ``` 184 | 185 | Alternatively, a state can be marked reentrant so its entry and exit events will fire even when transitioning from/to itself: 186 | 187 | ```go 188 | stateMachine.Configure(stateAssigned). 189 | PermitReentry(triggerAssigned). 190 | OnEntry(func(_ context.Context, _ ...any) error { 191 | startCallTimer() 192 | return nil 193 | }) 194 | ``` 195 | 196 | By default, triggers must be ignored explicitly. To override Stateless's default behaviour of throwing a panic when an unhandled trigger is fired, configure the state machine using the `OnUnhandledTrigger` method: 197 | 198 | ```go 199 | stateMachine.OnUnhandledTrigger( func (_ context.Context, state State, _ Trigger, _ []string) {}) 200 | ``` 201 | 202 | ### Export to DOT graph 203 | 204 | It can be useful to visualize state machines on runtime. With this approach the code is the authoritative source and state diagrams are by-products which are always up to date. 205 | 206 | ```go 207 | sm := stateMachine.Configure(stateOffHook). 208 | Permit(triggerCallDialed, stateRinging, isValidNumber) 209 | graph := sm.ToGraph() 210 | ``` 211 | 212 | The StateMachine.ToGraph() method returns a string representation of the state machine in the DOT graph language, e.g.: 213 | 214 | ```dot 215 | digraph { 216 | OffHook -> Ringing [label="CallDialled [isValidNumber]"]; 217 | } 218 | ``` 219 | 220 | This can then be rendered by tools that support the DOT graph language, such as the dot command line tool from graphviz.org or viz.js. See [webgraphviz.com](http://www.webgraphviz.com) for instant gratification. Command line example: dot -T pdf -o phoneCall.pdf phoneCall.dot to generate a PDF file. 221 | 222 | This is the complete Phone Call graph as builded in `example_test.go`. 223 | 224 | ![Phone Call graph](assets/phone-graph.png?raw=true "Phone Call complete DOT") 225 | 226 | ## Project Goals 227 | 228 | This page is an almost-complete description of Stateless, and its explicit aim is to remain minimal. 229 | 230 | Please use the issue tracker or the if you'd like to report problems or discuss features. 231 | 232 | (_Why the name? Stateless implements the set of rules regarding state transitions, but, at least when the delegate version of the constructor is used, doesn't maintain any internal state itself._) 233 | -------------------------------------------------------------------------------- /assets/phone-graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qmuntal/stateless/1e4ccb842168def338668bc628aa50001081ee74/assets/phone-graph.png -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package stateless 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | type transitionKey struct{} 9 | 10 | func withTransition(ctx context.Context, transition Transition) context.Context { 11 | return context.WithValue(ctx, transitionKey{}, transition) 12 | } 13 | 14 | // GetTransition returns the transition from the context. 15 | // If there is no transition the returned value is empty. 16 | func GetTransition(ctx context.Context) Transition { 17 | tr, _ := ctx.Value(transitionKey{}).(Transition) 18 | return tr 19 | } 20 | 21 | // ActionFunc describes a generic action function. 22 | // The context will always contain Transition information. 23 | type ActionFunc = func(ctx context.Context, args ...any) error 24 | 25 | // GuardFunc defines a generic guard function. 26 | type GuardFunc = func(ctx context.Context, args ...any) bool 27 | 28 | // DestinationSelectorFunc defines a functions that is called to select a dynamic destination. 29 | type DestinationSelectorFunc = func(ctx context.Context, args ...any) (State, error) 30 | 31 | // StateConfiguration is the configuration for a single state value. 32 | type StateConfiguration struct { 33 | sm *StateMachine 34 | sr *stateRepresentation 35 | lookup func(State) *stateRepresentation 36 | } 37 | 38 | // State is configured with this configuration. 39 | func (sc *StateConfiguration) State() State { 40 | return sc.sr.State 41 | } 42 | 43 | // Machine that is configured with this configuration. 44 | func (sc *StateConfiguration) Machine() *StateMachine { 45 | return sc.sm 46 | } 47 | 48 | // InitialTransition adds an initial transition to this state. 49 | // When entering the current state the state machine will look for an initial transition, 50 | // and enter the target state. 51 | func (sc *StateConfiguration) InitialTransition(targetState State) *StateConfiguration { 52 | if sc.sr.HasInitialState { 53 | panic(fmt.Sprintf("stateless: This state has already been configured with an initial transition (%v).", sc.sr.InitialTransitionTarget)) 54 | } 55 | if targetState == sc.State() { 56 | panic("stateless: Setting the current state as the target destination state is not allowed.") 57 | } 58 | sc.sr.SetInitialTransition(targetState) 59 | return sc 60 | } 61 | 62 | // Permit accept the specified trigger and transition to the destination state if the guard conditions are met (if any). 63 | func (sc *StateConfiguration) Permit(trigger Trigger, destinationState State, guards ...GuardFunc) *StateConfiguration { 64 | if destinationState == sc.sr.State { 65 | panic("stateless: Permit() require that the destination state is not equal to the source state. To accept a trigger without changing state, use either Ignore() or PermitReentry().") 66 | } 67 | sc.sr.AddTriggerBehaviour(&transitioningTriggerBehaviour{ 68 | baseTriggerBehaviour: baseTriggerBehaviour{Trigger: trigger, Guard: newtransitionGuard(guards...)}, 69 | Destination: destinationState, 70 | }) 71 | return sc 72 | } 73 | 74 | // InternalTransition add an internal transition to the state machine. 75 | // An internal action does not cause the Exit and Entry actions to be triggered, and does not change the state of the state machine. 76 | func (sc *StateConfiguration) InternalTransition(trigger Trigger, action ActionFunc, guards ...GuardFunc) *StateConfiguration { 77 | sc.sr.AddTriggerBehaviour(&internalTriggerBehaviour{ 78 | baseTriggerBehaviour: baseTriggerBehaviour{Trigger: trigger, Guard: newtransitionGuard(guards...)}, 79 | Action: action, 80 | }) 81 | return sc 82 | } 83 | 84 | // PermitReentry accept the specified trigger, execute exit actions and re-execute entry actions. 85 | // Reentry behaves as though the configured state transitions to an identical sibling state. 86 | // Applies to the current state only. Will not re-execute superstate actions, or 87 | // cause actions to execute transitioning between super- and sub-states. 88 | func (sc *StateConfiguration) PermitReentry(trigger Trigger, guards ...GuardFunc) *StateConfiguration { 89 | sc.sr.AddTriggerBehaviour(&reentryTriggerBehaviour{ 90 | baseTriggerBehaviour: baseTriggerBehaviour{Trigger: trigger, Guard: newtransitionGuard(guards...)}, 91 | Destination: sc.sr.State, 92 | }) 93 | return sc 94 | } 95 | 96 | // Ignore the specified trigger when in the configured state, if the guards return true. 97 | func (sc *StateConfiguration) Ignore(trigger Trigger, guards ...GuardFunc) *StateConfiguration { 98 | sc.sr.AddTriggerBehaviour(&ignoredTriggerBehaviour{ 99 | baseTriggerBehaviour: baseTriggerBehaviour{Trigger: trigger, Guard: newtransitionGuard(guards...)}, 100 | }) 101 | return sc 102 | } 103 | 104 | // PermitDynamic accept the specified trigger and transition to the destination state, calculated dynamically by the supplied function. 105 | func (sc *StateConfiguration) PermitDynamic(trigger Trigger, selector DestinationSelectorFunc, guards ...GuardFunc) *StateConfiguration { 106 | guardDescriptors := make([]invocationInfo, len(guards)) 107 | for i, guard := range guards { 108 | guardDescriptors[i] = newinvocationInfo(guard) 109 | } 110 | sc.sr.AddTriggerBehaviour(&dynamicTriggerBehaviour{ 111 | baseTriggerBehaviour: baseTriggerBehaviour{Trigger: trigger, Guard: newtransitionGuard(guards...)}, 112 | Destination: selector, 113 | }) 114 | return sc 115 | } 116 | 117 | // OnActive specify an action that will execute when activating the configured state. 118 | func (sc *StateConfiguration) OnActive(action func(context.Context) error) *StateConfiguration { 119 | sc.sr.ActivateActions = append(sc.sr.ActivateActions, actionBehaviourSteady{ 120 | Action: action, 121 | Description: newinvocationInfo(action), 122 | }) 123 | return sc 124 | } 125 | 126 | // OnDeactivate specify an action that will execute when deactivating the configured state. 127 | func (sc *StateConfiguration) OnDeactivate(action func(context.Context) error) *StateConfiguration { 128 | sc.sr.DeactivateActions = append(sc.sr.DeactivateActions, actionBehaviourSteady{ 129 | Action: action, 130 | Description: newinvocationInfo(action), 131 | }) 132 | return sc 133 | } 134 | 135 | // OnEntry specify an action that will execute when transitioning into the configured state. 136 | func (sc *StateConfiguration) OnEntry(action ActionFunc) *StateConfiguration { 137 | sc.sr.EntryActions = append(sc.sr.EntryActions, actionBehaviour{ 138 | Action: action, 139 | Description: newinvocationInfo(action), 140 | }) 141 | return sc 142 | } 143 | 144 | // OnEntryFrom Specify an action that will execute when transitioning into the configured state from a specific trigger. 145 | func (sc *StateConfiguration) OnEntryFrom(trigger Trigger, action ActionFunc) *StateConfiguration { 146 | sc.sr.EntryActions = append(sc.sr.EntryActions, actionBehaviour{ 147 | Action: action, 148 | Description: newinvocationInfo(action), 149 | Trigger: &trigger, 150 | }) 151 | return sc 152 | } 153 | 154 | // OnExit specify an action that will execute when transitioning from the configured state. 155 | func (sc *StateConfiguration) OnExit(action ActionFunc) *StateConfiguration { 156 | sc.sr.ExitActions = append(sc.sr.ExitActions, actionBehaviour{ 157 | Action: action, 158 | Description: newinvocationInfo(action), 159 | }) 160 | return sc 161 | } 162 | 163 | // OnExitWith specifies an action that will execute when transitioning from the configured state with a specific trigger. 164 | func (sc *StateConfiguration) OnExitWith(trigger Trigger, action ActionFunc) *StateConfiguration { 165 | sc.sr.ExitActions = append(sc.sr.ExitActions, actionBehaviour{ 166 | Action: action, 167 | Description: newinvocationInfo(action), 168 | Trigger: &trigger, 169 | }) 170 | return sc 171 | } 172 | 173 | // SubstateOf sets the superstate that the configured state is a substate of. 174 | // Substates inherit the allowed transitions of their superstate. 175 | // When entering directly into a substate from outside of the superstate, 176 | // entry actions for the superstate are executed. 177 | // Likewise when leaving from the substate to outside the supserstate, 178 | // exit actions for the superstate will execute. 179 | func (sc *StateConfiguration) SubstateOf(superstate State) *StateConfiguration { 180 | state := sc.sr.State 181 | // Check for accidental identical cyclic configuration 182 | if state == superstate { 183 | panic(fmt.Sprintf("stateless: Configuring %v as a substate of %v creates an illegal cyclic configuration.", state, superstate)) 184 | } 185 | 186 | // Check for accidental identical nested cyclic configuration 187 | var empty struct{} 188 | supersets := map[State]struct{}{state: empty} 189 | // Build list of super states and check for 190 | 191 | activeSc := sc.lookup(superstate) 192 | for activeSc.Superstate != nil { 193 | // Check if superstate is already added to hashset 194 | if _, ok := supersets[activeSc.Superstate.state()]; ok { 195 | panic(fmt.Sprintf("stateless: Configuring %v as a substate of %v creates an illegal nested cyclic configuration.", state, supersets)) 196 | } 197 | supersets[activeSc.Superstate.state()] = empty 198 | activeSc = sc.lookup(activeSc.Superstate.state()) 199 | } 200 | 201 | // The check was OK, we can add this 202 | superRepresentation := sc.lookup(superstate) 203 | sc.sr.Superstate = superRepresentation 204 | superRepresentation.Substates = append(superRepresentation.Substates, sc.sr) 205 | return sc 206 | } 207 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package stateless_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "reflect" 7 | 8 | "github.com/qmuntal/stateless" 9 | ) 10 | 11 | const ( 12 | triggerCallDialed = "CallDialed" 13 | triggerCallConnected = "CallConnected" 14 | triggerLeftMessage = "LeftMessage" 15 | triggerPlacedOnHold = "PlacedOnHold" 16 | triggerTakenOffHold = "TakenOffHold" 17 | triggerPhoneHurledAgainstWall = "PhoneHurledAgainstWall" 18 | triggerMuteMicrophone = "MuteMicrophone" 19 | triggerUnmuteMicrophone = "UnmuteMicrophone" 20 | triggerSetVolume = "SetVolume" 21 | ) 22 | 23 | const ( 24 | stateOffHook = "OffHook" 25 | stateRinging = "Ringing" 26 | stateConnected = "Connected" 27 | stateOnHold = "OnHold" 28 | statePhoneDestroyed = "PhoneDestroyed" 29 | ) 30 | 31 | func Example() { 32 | phoneCall := stateless.NewStateMachine(stateOffHook) 33 | phoneCall.SetTriggerParameters(triggerSetVolume, reflect.TypeOf(0)) 34 | phoneCall.SetTriggerParameters(triggerCallDialed, reflect.TypeOf("")) 35 | 36 | phoneCall.Configure(stateOffHook). 37 | Permit(triggerCallDialed, stateRinging) 38 | 39 | phoneCall.Configure(stateRinging). 40 | OnEntryFrom(triggerCallDialed, func(_ context.Context, args ...any) error { 41 | onDialed(args[0].(string)) 42 | return nil 43 | }). 44 | Permit(triggerCallConnected, stateConnected) 45 | 46 | phoneCall.Configure(stateConnected). 47 | OnEntry(startCallTimer). 48 | OnExit(func(_ context.Context, _ ...any) error { 49 | stopCallTimer() 50 | return nil 51 | }). 52 | InternalTransition(triggerMuteMicrophone, func(_ context.Context, _ ...any) error { 53 | onMute() 54 | return nil 55 | }). 56 | InternalTransition(triggerUnmuteMicrophone, func(_ context.Context, _ ...any) error { 57 | onUnmute() 58 | return nil 59 | }). 60 | InternalTransition(triggerSetVolume, func(_ context.Context, args ...any) error { 61 | onSetVolume(args[0].(int)) 62 | return nil 63 | }). 64 | Permit(triggerLeftMessage, stateOffHook). 65 | Permit(triggerPlacedOnHold, stateOnHold) 66 | 67 | phoneCall.Configure(stateOnHold). 68 | SubstateOf(stateConnected). 69 | OnExitWith(triggerPhoneHurledAgainstWall, func(ctx context.Context, args ...any) error { 70 | onWasted() 71 | return nil 72 | }). 73 | Permit(triggerTakenOffHold, stateConnected). 74 | Permit(triggerPhoneHurledAgainstWall, statePhoneDestroyed) 75 | 76 | phoneCall.ToGraph() 77 | 78 | phoneCall.Fire(triggerCallDialed, "qmuntal") 79 | phoneCall.Fire(triggerCallConnected) 80 | phoneCall.Fire(triggerSetVolume, 2) 81 | phoneCall.Fire(triggerPlacedOnHold) 82 | phoneCall.Fire(triggerMuteMicrophone) 83 | phoneCall.Fire(triggerUnmuteMicrophone) 84 | phoneCall.Fire(triggerTakenOffHold) 85 | phoneCall.Fire(triggerSetVolume, 11) 86 | phoneCall.Fire(triggerPlacedOnHold) 87 | phoneCall.Fire(triggerPhoneHurledAgainstWall) 88 | fmt.Printf("State is %v\n", phoneCall.MustState()) 89 | 90 | // Output: 91 | // [Phone Call] placed for : [qmuntal] 92 | // [Timer:] Call started at 11:00am 93 | // Volume set to 2! 94 | // Microphone muted! 95 | // Microphone unmuted! 96 | // Volume set to 11! 97 | // Wasted! 98 | // [Timer:] Call ended at 11:30am 99 | // State is PhoneDestroyed 100 | 101 | } 102 | 103 | func onSetVolume(volume int) { 104 | fmt.Printf("Volume set to %d!\n", volume) 105 | } 106 | 107 | func onUnmute() { 108 | fmt.Println("Microphone unmuted!") 109 | } 110 | 111 | func onMute() { 112 | fmt.Println("Microphone muted!") 113 | } 114 | 115 | func onDialed(callee string) { 116 | fmt.Printf("[Phone Call] placed for : [%s]\n", callee) 117 | } 118 | 119 | func onWasted() { 120 | fmt.Println("Wasted!") 121 | } 122 | 123 | func startCallTimer(_ context.Context, _ ...any) error { 124 | fmt.Println("[Timer:] Call started at 11:00am") 125 | return nil 126 | } 127 | 128 | func stopCallTimer() { 129 | fmt.Println("[Timer:] Call ended at 11:30am") 130 | } 131 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/qmuntal/stateless 2 | 3 | go 1.19 4 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qmuntal/stateless/1e4ccb842168def338668bc628aa50001081ee74/go.sum -------------------------------------------------------------------------------- /graph.go: -------------------------------------------------------------------------------- 1 | package stateless 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "html" 7 | "sort" 8 | "strings" 9 | "text/template" 10 | "unicode" 11 | ) 12 | 13 | type graph struct { 14 | } 15 | 16 | type transitionLabel struct { 17 | reentry []string 18 | internal []string 19 | transitioning []string 20 | ignored []string 21 | } 22 | 23 | func (g *graph) formatStateMachine(sm *StateMachine) string { 24 | var sb strings.Builder 25 | sb.WriteString("digraph {\n\tcompound=true;\n\tnode [shape=Mrecord];\n\trankdir=\"LR\";\n\n") 26 | 27 | stateList := make([]*stateRepresentation, 0, len(sm.stateConfig)) 28 | for _, st := range sm.stateConfig { 29 | stateList = append(stateList, st) 30 | } 31 | sort.Slice(stateList, func(i, j int) bool { 32 | return fmt.Sprint(stateList[i].State) < fmt.Sprint(stateList[j].State) 33 | }) 34 | 35 | for _, sr := range stateList { 36 | if sr.Superstate == nil { 37 | g.formatOneState(&sb, sr, 1) 38 | } 39 | } 40 | for _, sr := range stateList { 41 | if sr.HasInitialState { 42 | dest := sm.stateConfig[sr.InitialTransitionTarget] 43 | if dest != nil { 44 | src := clusterStr(sr.State, true, true) 45 | formatOneLine(&sb, src, str(dest.State, true), `""`) 46 | } 47 | } 48 | } 49 | for _, sr := range stateList { 50 | g.formatAllStateTransitions(&sb, sm, sr) 51 | } 52 | initialState, err := sm.State(context.Background()) 53 | if err == nil { 54 | sb.WriteString("\tinit [label=\"\", shape=point];\n") 55 | sb.WriteString(fmt.Sprintf("\tinit -> %s\n", str(initialState, true))) 56 | } 57 | sb.WriteString("}\n") 58 | return sb.String() 59 | } 60 | 61 | func (g *graph) formatActions(sr *stateRepresentation) string { 62 | es := make([]string, 0, len(sr.EntryActions)+len(sr.ExitActions)+len(sr.ActivateActions)+len(sr.DeactivateActions)) 63 | for _, act := range sr.ActivateActions { 64 | es = append(es, fmt.Sprintf("activated / %s", esc(act.Description.String(), false))) 65 | } 66 | for _, act := range sr.DeactivateActions { 67 | es = append(es, fmt.Sprintf("deactivated / %s", esc(act.Description.String(), false))) 68 | } 69 | for _, act := range sr.EntryActions { 70 | if act.Trigger == nil { 71 | es = append(es, fmt.Sprintf("entry / %s", esc(act.Description.String(), false))) 72 | } 73 | } 74 | for _, act := range sr.ExitActions { 75 | es = append(es, fmt.Sprintf("exit / %s", esc(act.Description.String(), false))) 76 | } 77 | return strings.Join(es, "\\n") 78 | } 79 | 80 | func (g *graph) formatOneState(sb *strings.Builder, sr *stateRepresentation, level int) { 81 | var indent string 82 | for i := 0; i < level; i++ { 83 | indent += "\t" 84 | } 85 | sb.WriteString(fmt.Sprintf("%s%s [label=\"%s", indent, str(sr.State, true), str(sr.State, false))) 86 | act := g.formatActions(sr) 87 | if act != "" { 88 | if len(sr.Substates) == 0 { 89 | sb.WriteString("|") 90 | } else { 91 | sb.WriteString("\\n----------\\n") 92 | } 93 | sb.WriteString(act) 94 | } 95 | sb.WriteString("\"];\n") 96 | if len(sr.Substates) != 0 { 97 | sb.WriteString(fmt.Sprintf("%ssubgraph %s {\n%s\tlabel=\"Substates of\\n%s\";\n", indent, clusterStr(sr.State, true, false), indent, str(sr.State, false))) 98 | sb.WriteString(fmt.Sprintf("%s\tstyle=\"dashed\";\n", indent)) 99 | if sr.HasInitialState { 100 | sb.WriteString(fmt.Sprintf("%s\t\"%s\" [label=\"\", shape=point];\n", indent, clusterStr(sr.State, false, true))) 101 | } 102 | for _, substate := range sr.Substates { 103 | g.formatOneState(sb, substate, level+1) 104 | } 105 | sb.WriteString(indent + "}\n") 106 | } 107 | } 108 | 109 | func (g *graph) getEntryActions(ab []actionBehaviour, t Trigger) []string { 110 | var actions []string 111 | for _, ea := range ab { 112 | if ea.Trigger != nil && *ea.Trigger == t { 113 | actions = append(actions, esc(ea.Description.String(), false)) 114 | } 115 | } 116 | return actions 117 | } 118 | 119 | func (g *graph) formatAllStateTransitions(sb *strings.Builder, sm *StateMachine, sr *stateRepresentation) { 120 | triggerList := make([]triggerBehaviour, 0, len(sr.TriggerBehaviours)) 121 | for _, triggers := range sr.TriggerBehaviours { 122 | triggerList = append(triggerList, triggers...) 123 | } 124 | sort.Slice(triggerList, func(i, j int) bool { 125 | ti := triggerList[i].GetTrigger() 126 | tj := triggerList[j].GetTrigger() 127 | return fmt.Sprint(ti) < fmt.Sprint(tj) 128 | }) 129 | 130 | type line struct { 131 | source State 132 | destination State 133 | } 134 | 135 | lines := make(map[line]transitionLabel, len(triggerList)) 136 | order := make([]line, 0, len(triggerList)) 137 | for _, trigger := range triggerList { 138 | switch t := trigger.(type) { 139 | case *ignoredTriggerBehaviour: 140 | ln := line{sr.State, sr.State} 141 | if _, ok := lines[ln]; !ok { 142 | order = append(order, ln) 143 | } 144 | transition := lines[ln] 145 | transition.ignored = append(transition.ignored, formatOneTransition(t.Trigger, nil, t.Guard)) 146 | lines[ln] = transition 147 | case *reentryTriggerBehaviour: 148 | actions := g.getEntryActions(sr.EntryActions, t.Trigger) 149 | ln := line{sr.State, t.Destination} 150 | if _, ok := lines[ln]; !ok { 151 | order = append(order, ln) 152 | } 153 | transition := lines[ln] 154 | transition.reentry = append(transition.reentry, formatOneTransition(t.Trigger, actions, t.Guard)) 155 | lines[ln] = transition 156 | case *internalTriggerBehaviour: 157 | actions := g.getEntryActions(sr.EntryActions, t.Trigger) 158 | ln := line{sr.State, sr.State} 159 | if _, ok := lines[ln]; !ok { 160 | order = append(order, ln) 161 | } 162 | transition := lines[ln] 163 | transition.internal = append(transition.internal, formatOneTransition(t.Trigger, actions, t.Guard)) 164 | lines[ln] = transition 165 | case *transitioningTriggerBehaviour: 166 | src := sm.stateConfig[sr.State] 167 | if src == nil { 168 | continue 169 | } 170 | dest := sm.stateConfig[t.Destination] 171 | var actions []string 172 | if dest != nil { 173 | actions = g.getEntryActions(dest.EntryActions, t.Trigger) 174 | } 175 | var destState State 176 | if dest == nil { 177 | destState = t.Destination 178 | } else { 179 | destState = dest.State 180 | } 181 | ln := line{sr.State, destState} 182 | if _, ok := lines[ln]; !ok { 183 | order = append(order, ln) 184 | } 185 | transition := lines[ln] 186 | transition.transitioning = append(transition.transitioning, formatOneTransition(t.Trigger, actions, t.Guard)) 187 | lines[ln] = transition 188 | case *dynamicTriggerBehaviour: 189 | // TODO: not supported yet 190 | } 191 | } 192 | 193 | for _, ln := range order { 194 | content := lines[ln] 195 | formatOneLine(sb, str(ln.source, true), str(ln.destination, true), toTransitionsLabel(content)) 196 | } 197 | } 198 | 199 | func toTransitionsLabel(transitions transitionLabel) string { 200 | var sb strings.Builder 201 | sb.WriteString(`<`) 202 | for _, t := range transitions.transitioning { 203 | sb.WriteString(``) 206 | } 207 | if len(transitions.reentry) > 0 { 208 | sb.WriteString(``) 209 | for _, t := range transitions.reentry { 210 | sb.WriteString(``) 213 | } 214 | } 215 | if len(transitions.internal) > 0 { 216 | sb.WriteString(``) 217 | for _, t := range transitions.internal { 218 | sb.WriteString(``) 221 | } 222 | } 223 | if len(transitions.ignored) > 0 { 224 | sb.WriteString(``) 225 | for _, t := range transitions.ignored { 226 | sb.WriteString(``) 229 | } 230 | } 231 | sb.WriteString(`
`) 204 | sb.WriteString(html.EscapeString(t)) 205 | sb.WriteString(`
Reentry
`) 211 | sb.WriteString(html.EscapeString(t)) 212 | sb.WriteString(`
Internal
`) 219 | sb.WriteString(html.EscapeString(t)) 220 | sb.WriteString(`
Ignored
`) 227 | sb.WriteString(html.EscapeString(t)) 228 | sb.WriteString(`
>`) 232 | return sb.String() 233 | } 234 | 235 | func formatOneTransition(trigger Trigger, actions []string, guards transitionGuard) string { 236 | var sb strings.Builder 237 | sb.WriteString(str(trigger, false)) 238 | if len(actions) > 0 { 239 | sb.WriteString(" / ") 240 | sb.WriteString(strings.Join(actions, ", ")) 241 | } 242 | for _, info := range guards.Guards { 243 | if sb.Len() > 0 { 244 | sb.WriteString(" ") 245 | } 246 | sb.WriteString(fmt.Sprintf("[%s]", esc(info.Description.String(), false))) 247 | } 248 | return sb.String() 249 | } 250 | 251 | func formatOneLine(sb *strings.Builder, fromNodeName, toNodeName, label string) { 252 | sb.WriteString(fmt.Sprintf("\t%s -> %s [label=%s", fromNodeName, toNodeName, label)) 253 | sb.WriteString("];\n") 254 | } 255 | 256 | func clusterStr(state any, quote, init bool) string { 257 | s := fmt.Sprint(state) 258 | if init { 259 | s += "-init" 260 | } 261 | return esc("cluster_"+s, quote) 262 | } 263 | 264 | func str(v any, quote bool) string { 265 | return esc(fmt.Sprint(v), quote) 266 | } 267 | 268 | func isHTML(s string) bool { 269 | if len(s) == 0 { 270 | return false 271 | } 272 | ss := strings.TrimSpace(s) 273 | if ss[0] != '<' { 274 | return false 275 | } 276 | var count int 277 | for _, c := range ss { 278 | if c == '<' { 279 | count++ 280 | } 281 | if c == '>' { 282 | count-- 283 | } 284 | } 285 | return count == 0 286 | } 287 | 288 | func isLetter(ch rune) bool { 289 | return 'a' <= ch && ch <= 'z' || 'A' <= ch && ch <= 'Z' || ch == '_' || 290 | ch >= 0x80 && unicode.IsLetter(ch) && ch != 'ε' 291 | } 292 | 293 | func isID(s string) bool { 294 | for _, c := range s { 295 | if !isLetter(c) { 296 | return false 297 | } 298 | if unicode.IsSpace(c) { 299 | return false 300 | } 301 | switch c { 302 | case '-', '/', '.', '@': 303 | return false 304 | } 305 | } 306 | return true 307 | } 308 | 309 | func isDigit(ch rune) bool { 310 | return '0' <= ch && ch <= '9' || ch >= 0x80 && unicode.IsDigit(ch) 311 | } 312 | 313 | func isNumber(s string) bool { 314 | var state int 315 | for _, c := range s { 316 | if state == 0 { 317 | if isDigit(c) || c == '.' { 318 | state = 2 319 | } else if c == '-' { 320 | state = 1 321 | } else { 322 | return false 323 | } 324 | } else if state == 1 { 325 | if isDigit(c) || c == '.' { 326 | state = 2 327 | } 328 | } else if c != '.' && !isDigit(c) { 329 | return false 330 | } 331 | } 332 | return (state == 2) 333 | } 334 | 335 | func isStringLit(s string) bool { 336 | if !strings.HasPrefix(s, `"`) || !strings.HasSuffix(s, `"`) { 337 | return false 338 | } 339 | var prev rune 340 | for _, r := range s[1 : len(s)-1] { 341 | if r == '"' && prev != '\\' { 342 | return false 343 | } 344 | prev = r 345 | } 346 | return true 347 | } 348 | 349 | func esc(s string, quote bool) string { 350 | if len(s) == 0 { 351 | return s 352 | } 353 | if isHTML(s) { 354 | return s 355 | } 356 | ss := strings.TrimSpace(s) 357 | if ss[0] == '<' { 358 | s := strings.Replace(s, "\"", "\\\"", -1) 359 | if quote { 360 | s = fmt.Sprintf("\"%s\"", s) 361 | } 362 | return s 363 | } 364 | if isID(s) { 365 | return s 366 | } 367 | if isNumber(s) { 368 | return s 369 | } 370 | if isStringLit(s) { 371 | return s 372 | } 373 | s = template.HTMLEscapeString(s) 374 | if quote { 375 | s = fmt.Sprintf("\"%s\"", s) 376 | } 377 | return s 378 | } 379 | -------------------------------------------------------------------------------- /graph_test.go: -------------------------------------------------------------------------------- 1 | package stateless_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "flag" 7 | "os" 8 | "reflect" 9 | "runtime" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/qmuntal/stateless" 14 | ) 15 | 16 | var update = flag.Bool("update", false, "update golden files on failure") 17 | 18 | func emptyWithInitial() *stateless.StateMachine { 19 | return stateless.NewStateMachine("A") 20 | } 21 | 22 | func withSubstate() *stateless.StateMachine { 23 | sm := stateless.NewStateMachine("B") 24 | sm.Configure("A").Permit("Z", "B") 25 | sm.Configure("B").SubstateOf("C").Permit("X", "A") 26 | sm.Configure("C").Permit("Y", "A").Ignore("X") 27 | return sm 28 | } 29 | 30 | func withInitialState() *stateless.StateMachine { 31 | sm := stateless.NewStateMachine("A") 32 | sm.Configure("A"). 33 | Permit("X", "B") 34 | sm.Configure("B"). 35 | InitialTransition("C") 36 | sm.Configure("C"). 37 | InitialTransition("D"). 38 | SubstateOf("B") 39 | sm.Configure("D"). 40 | SubstateOf("C") 41 | return sm 42 | } 43 | 44 | func withGuards() *stateless.StateMachine { 45 | sm := stateless.NewStateMachine("B") 46 | sm.SetTriggerParameters("X", reflect.TypeOf(0)) 47 | sm.Configure("A"). 48 | Permit("X", "D", func(_ context.Context, args ...any) bool { 49 | return args[0].(int) == 3 50 | }) 51 | 52 | sm.Configure("B"). 53 | SubstateOf("A"). 54 | Permit("X", "C", func(_ context.Context, args ...any) bool { 55 | return args[0].(int) == 2 56 | }) 57 | return sm 58 | } 59 | 60 | func œ(_ context.Context, args ...any) bool { 61 | return args[0].(int) == 2 62 | } 63 | 64 | func withUnicodeNames() *stateless.StateMachine { 65 | sm := stateless.NewStateMachine("Ĕ") 66 | sm.Configure("Ĕ"). 67 | Permit("◵", "ų", œ) 68 | sm.Configure("ų"). 69 | InitialTransition("ㇴ") 70 | sm.Configure("ㇴ"). 71 | InitialTransition("ꬠ"). 72 | SubstateOf("ų") 73 | sm.Configure("ꬠ"). 74 | SubstateOf("𒀄") 75 | sm.Configure("1"). 76 | SubstateOf("𒀄") 77 | sm.Configure("2"). 78 | SubstateOf("1") 79 | return sm 80 | } 81 | 82 | func phoneCall() *stateless.StateMachine { 83 | phoneCall := stateless.NewStateMachine(stateOffHook) 84 | phoneCall.SetTriggerParameters(triggerSetVolume, reflect.TypeOf(0)) 85 | phoneCall.SetTriggerParameters(triggerCallDialed, reflect.TypeOf("")) 86 | 87 | phoneCall.Configure(stateOffHook). 88 | Permit(triggerCallDialed, stateRinging) 89 | 90 | phoneCall.Configure(stateRinging). 91 | OnEntryFrom(triggerCallDialed, func(_ context.Context, args ...any) error { 92 | return nil 93 | }). 94 | Permit(triggerCallConnected, stateConnected) 95 | 96 | phoneCall.Configure(stateConnected). 97 | OnEntry(startCallTimer). 98 | OnExit(func(_ context.Context, _ ...any) error { 99 | return nil 100 | }). 101 | InternalTransition(triggerMuteMicrophone, func(_ context.Context, _ ...any) error { 102 | return nil 103 | }). 104 | InternalTransition(triggerUnmuteMicrophone, func(_ context.Context, _ ...any) error { 105 | return nil 106 | }). 107 | InternalTransition(triggerSetVolume, func(_ context.Context, args ...any) error { 108 | return nil 109 | }). 110 | Permit(triggerLeftMessage, stateOffHook). 111 | Permit(triggerPlacedOnHold, stateOnHold) 112 | 113 | phoneCall.Configure(stateOnHold). 114 | SubstateOf(stateConnected). 115 | OnExitWith(triggerPhoneHurledAgainstWall, func(ctx context.Context, args ...any) error { 116 | onWasted() 117 | return nil 118 | }). 119 | Permit(triggerTakenOffHold, stateConnected). 120 | Permit(triggerPhoneHurledAgainstWall, statePhoneDestroyed) 121 | 122 | return phoneCall 123 | } 124 | 125 | func TestStateMachine_ToGraph(t *testing.T) { 126 | tests := []func() *stateless.StateMachine{ 127 | emptyWithInitial, 128 | withSubstate, 129 | withInitialState, 130 | withGuards, 131 | withUnicodeNames, 132 | phoneCall, 133 | } 134 | for _, fn := range tests { 135 | name := runtime.FuncForPC(reflect.ValueOf(fn).Pointer()).Name() 136 | sp := strings.Split(name, ".") 137 | name = sp[len(sp)-1] 138 | t.Run(name, func(t *testing.T) { 139 | got := fn().ToGraph() 140 | name := "testdata/golden/" + name + ".dot" 141 | want, err := os.ReadFile(name) 142 | want = bytes.ReplaceAll(want, []byte("\r\n"), []byte("\n")) 143 | if *update { 144 | if !bytes.Equal([]byte(got), want) { 145 | os.WriteFile(name, []byte(got), 0666) 146 | } 147 | } else { 148 | if err != nil { 149 | t.Fatal(err) 150 | } 151 | if !bytes.Equal([]byte(got), want) { 152 | t.Fatalf("got:\n%swant:\n%s", got, want) 153 | } 154 | } 155 | }) 156 | } 157 | } 158 | 159 | func BenchmarkToGraph(b *testing.B) { 160 | sm := phoneCall() 161 | b.ResetTimer() 162 | for i := 0; i < b.N; i++ { 163 | _ = sm.ToGraph() 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /modes.go: -------------------------------------------------------------------------------- 1 | package stateless 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "sync/atomic" 7 | ) 8 | 9 | type fireMode interface { 10 | Fire(ctx context.Context, trigger Trigger, args ...any) error 11 | Firing() bool 12 | } 13 | 14 | type fireModeImmediate struct { 15 | ops atomic.Uint64 16 | sm *StateMachine 17 | } 18 | 19 | func (f *fireModeImmediate) Firing() bool { 20 | return f.ops.Load() > 0 21 | } 22 | 23 | func (f *fireModeImmediate) Fire(ctx context.Context, trigger Trigger, args ...any) error { 24 | f.ops.Add(1) 25 | defer f.ops.Add(^uint64(0)) 26 | return f.sm.internalFireOne(ctx, trigger, args...) 27 | } 28 | 29 | type queuedTrigger struct { 30 | Context context.Context 31 | Trigger Trigger 32 | Args []any 33 | } 34 | 35 | type fireModeQueued struct { 36 | firing atomic.Bool 37 | sm *StateMachine 38 | 39 | triggers []queuedTrigger 40 | mu sync.Mutex // guards triggers 41 | } 42 | 43 | func (f *fireModeQueued) Firing() bool { 44 | return f.firing.Load() 45 | } 46 | 47 | func (f *fireModeQueued) Fire(ctx context.Context, trigger Trigger, args ...any) error { 48 | f.enqueue(ctx, trigger, args...) 49 | for { 50 | et, ok := f.fetch() 51 | if !ok { 52 | break 53 | } 54 | err := f.execute(et) 55 | if err != nil { 56 | return err 57 | } 58 | } 59 | return nil 60 | } 61 | 62 | func (f *fireModeQueued) enqueue(ctx context.Context, trigger Trigger, args ...any) { 63 | f.mu.Lock() 64 | defer f.mu.Unlock() 65 | 66 | f.triggers = append(f.triggers, queuedTrigger{Context: ctx, Trigger: trigger, Args: args}) 67 | } 68 | 69 | func (f *fireModeQueued) fetch() (et queuedTrigger, ok bool) { 70 | f.mu.Lock() 71 | defer f.mu.Unlock() 72 | 73 | if len(f.triggers) == 0 { 74 | return queuedTrigger{}, false 75 | } 76 | 77 | if !f.firing.CompareAndSwap(false, true) { 78 | return queuedTrigger{}, false 79 | } 80 | 81 | et, f.triggers = f.triggers[0], f.triggers[1:] 82 | return et, true 83 | } 84 | 85 | func (f *fireModeQueued) execute(et queuedTrigger) error { 86 | defer f.firing.Swap(false) 87 | return f.sm.internalFireOne(et.Context, et.Trigger, et.Args...) 88 | } 89 | -------------------------------------------------------------------------------- /statemachine.go: -------------------------------------------------------------------------------- 1 | package stateless 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "reflect" 7 | "sync" 8 | ) 9 | 10 | // State is used to to represent the possible machine states. 11 | type State = any 12 | 13 | // Trigger is used to represent the triggers that cause state transitions. 14 | type Trigger = any 15 | 16 | // FiringMode enumerate the different modes used when Fire-ing a trigger. 17 | type FiringMode uint8 18 | 19 | const ( 20 | // FiringQueued mode shoud be used when run-to-completion is required. This is the recommended mode. 21 | FiringQueued FiringMode = iota 22 | // FiringImmediate should be used when the queing of trigger events are not needed. 23 | // Care must be taken when using this mode, as there is no run-to-completion guaranteed. 24 | FiringImmediate 25 | ) 26 | 27 | // Transition describes a state transition. 28 | type Transition struct { 29 | Source State 30 | Destination State 31 | Trigger Trigger 32 | 33 | isInitial bool 34 | } 35 | 36 | // IsReentry returns true if the transition is a re-entry, 37 | // i.e. the identity transition. 38 | func (t *Transition) IsReentry() bool { 39 | return t.Source == t.Destination 40 | } 41 | 42 | type TransitionFunc = func(context.Context, Transition) 43 | 44 | // UnhandledTriggerActionFunc defines a function that will be called when a trigger is not handled. 45 | type UnhandledTriggerActionFunc = func(ctx context.Context, state State, trigger Trigger, unmetGuards []string) error 46 | 47 | // DefaultUnhandledTriggerAction is the default unhandled trigger action. 48 | func DefaultUnhandledTriggerAction(_ context.Context, state State, trigger Trigger, unmetGuards []string) error { 49 | if len(unmetGuards) != 0 { 50 | return fmt.Errorf("stateless: Trigger '%v' is valid for transition from state '%v' but a guard conditions are not met. Guard descriptions: '%v", trigger, state, unmetGuards) 51 | } 52 | return fmt.Errorf("stateless: No valid leaving transitions are permitted from state '%v' for trigger '%v', consider ignoring the trigger", state, trigger) 53 | } 54 | 55 | func callEvents(events []TransitionFunc, ctx context.Context, transition Transition) { 56 | for _, e := range events { 57 | e(ctx, transition) 58 | } 59 | } 60 | 61 | // A StateMachine is an abstract machine that can be in exactly one of a finite number of states at any given time. 62 | // It is safe to use the StateMachine concurrently, but non of the callbacks (state manipulation, actions, events, ...) are guarded, 63 | // so it is up to the client to protect them against race conditions. 64 | type StateMachine struct { 65 | stateConfig map[State]*stateRepresentation 66 | triggerConfig map[Trigger]triggerWithParameters 67 | stateAccessor func(context.Context) (State, []any, error) 68 | stateMutator func(context.Context, State, ...any) error 69 | unhandledTriggerAction UnhandledTriggerActionFunc 70 | onTransitioningEvents []TransitionFunc 71 | onTransitionedEvents []TransitionFunc 72 | stateMutex sync.RWMutex 73 | mode fireMode 74 | } 75 | 76 | func newStateMachine(firingMode FiringMode) *StateMachine { 77 | sm := &StateMachine{ 78 | stateConfig: make(map[State]*stateRepresentation), 79 | triggerConfig: make(map[Trigger]triggerWithParameters), 80 | unhandledTriggerAction: UnhandledTriggerActionFunc(DefaultUnhandledTriggerAction), 81 | } 82 | if firingMode == FiringImmediate { 83 | sm.mode = &fireModeImmediate{sm: sm} 84 | } else { 85 | sm.mode = &fireModeQueued{sm: sm} 86 | } 87 | return sm 88 | } 89 | 90 | // NewStateMachine returns a queued state machine. 91 | func NewStateMachine(initialState State) *StateMachine { 92 | return NewStateMachineWithMode(initialState, FiringQueued) 93 | } 94 | 95 | // NewStateMachineWithMode returns a state machine with the desired firing mode 96 | func NewStateMachineWithMode(initialState State, firingMode FiringMode) *StateMachine { 97 | var stateMutex sync.Mutex 98 | sm := newStateMachine(firingMode) 99 | reference := &struct { 100 | State State 101 | Args []any 102 | }{State: initialState} 103 | sm.stateAccessor = func(_ context.Context) (State, []any, error) { 104 | stateMutex.Lock() 105 | defer stateMutex.Unlock() 106 | return reference.State, nil, nil 107 | } 108 | sm.stateMutator = func(_ context.Context, state State, args ...any) error { 109 | stateMutex.Lock() 110 | defer stateMutex.Unlock() 111 | reference.State = state 112 | reference.Args = args 113 | return nil 114 | } 115 | return sm 116 | } 117 | 118 | // NewStateMachineWithExternalStorage returns a state machine with external state storage. 119 | func NewStateMachineWithExternalStorage(stateAccessor func(context.Context) (State, error), stateMutator func(context.Context, State) error, firingMode FiringMode) *StateMachine { 120 | sm := newStateMachine(firingMode) 121 | sm.stateAccessor = func(ctx context.Context) (State, []any, error) { 122 | state, err := stateAccessor(ctx) 123 | return state, nil, err 124 | } 125 | sm.stateMutator = func(ctx context.Context, state State, a ...any) error { 126 | return stateMutator(ctx, state) 127 | } 128 | return sm 129 | } 130 | 131 | // NewStateMachineWithExternalStorageAndArgs returns a state machine with external state storage. This version allows for arguments which were passed to the state mutator to be retained. 132 | func NewStateMachineWithExternalStorageAndArgs(stateAccessor func(context.Context) (State, []any, error), stateMutator func(context.Context, State, ...any) error, firingMode FiringMode) *StateMachine { 133 | sm := newStateMachine(firingMode) 134 | sm.stateAccessor = stateAccessor 135 | sm.stateMutator = stateMutator 136 | return sm 137 | } 138 | 139 | // ToGraph returns the DOT representation of the state machine. 140 | // It is not guaranteed that the returned string will be the same in different executions. 141 | func (sm *StateMachine) ToGraph() string { 142 | return new(graph).formatStateMachine(sm) 143 | } 144 | 145 | // State returns the current state. 146 | func (sm *StateMachine) State(ctx context.Context) (State, error) { 147 | state, _, err := sm.stateAccessor(ctx) 148 | return state, err 149 | } 150 | 151 | // MustState returns the current state without the error. 152 | // It is safe to use this method when used together with NewStateMachine 153 | // or when using NewStateMachineWithExternalStorage / NewStateMachineWithExternalStorageAndArgs with a state accessor that 154 | // does not return an error. 155 | func (sm *StateMachine) MustState() State { 156 | st, err := sm.State(context.Background()) 157 | if err != nil { 158 | panic(err) 159 | } 160 | return st 161 | } 162 | 163 | // PermittedTriggers see PermittedTriggersCtx. 164 | func (sm *StateMachine) PermittedTriggers(args ...any) ([]Trigger, error) { 165 | return sm.PermittedTriggersCtx(context.Background(), args...) 166 | } 167 | 168 | // PermittedTriggersCtx returns the currently-permissible trigger values. 169 | func (sm *StateMachine) PermittedTriggersCtx(ctx context.Context, args ...any) ([]Trigger, error) { 170 | sr, err := sm.currentState(ctx) 171 | if err != nil { 172 | return nil, err 173 | } 174 | return sr.PermittedTriggers(ctx, args...), nil 175 | } 176 | 177 | // Activate see ActivateCtx. 178 | func (sm *StateMachine) Activate() error { 179 | return sm.ActivateCtx(context.Background()) 180 | } 181 | 182 | // ActivateCtx activates current state. Actions associated with activating the current state will be invoked. 183 | // The activation is idempotent and subsequent activation of the same current state 184 | // will not lead to re-execution of activation callbacks. 185 | func (sm *StateMachine) ActivateCtx(ctx context.Context) error { 186 | sr, err := sm.currentState(ctx) 187 | if err != nil { 188 | return err 189 | } 190 | return sr.Activate(ctx) 191 | } 192 | 193 | // Deactivate see DeactivateCtx. 194 | func (sm *StateMachine) Deactivate() error { 195 | return sm.DeactivateCtx(context.Background()) 196 | } 197 | 198 | // DeactivateCtx deactivates current state. Actions associated with deactivating the current state will be invoked. 199 | // The deactivation is idempotent and subsequent deactivation of the same current state 200 | // will not lead to re-execution of deactivation callbacks. 201 | func (sm *StateMachine) DeactivateCtx(ctx context.Context) error { 202 | sr, err := sm.currentState(ctx) 203 | if err != nil { 204 | return err 205 | } 206 | return sr.Deactivate(ctx) 207 | } 208 | 209 | // IsInState see IsInStateCtx. 210 | func (sm *StateMachine) IsInState(state State) (bool, error) { 211 | return sm.IsInStateCtx(context.Background(), state) 212 | } 213 | 214 | // IsInStateCtx determine if the state machine is in the supplied state. 215 | // Returns true if the current state is equal to, or a substate of, the supplied state. 216 | func (sm *StateMachine) IsInStateCtx(ctx context.Context, state State) (bool, error) { 217 | sr, err := sm.currentState(ctx) 218 | if err != nil { 219 | return false, err 220 | } 221 | return sr.IsIncludedInState(state), nil 222 | } 223 | 224 | // CanFire see CanFireCtx. 225 | func (sm *StateMachine) CanFire(trigger Trigger, args ...any) (bool, error) { 226 | return sm.CanFireCtx(context.Background(), trigger, args...) 227 | } 228 | 229 | // CanFireCtx returns true if the trigger can be fired in the current state. 230 | func (sm *StateMachine) CanFireCtx(ctx context.Context, trigger Trigger, args ...any) (bool, error) { 231 | sr, err := sm.currentState(ctx) 232 | if err != nil { 233 | return false, err 234 | } 235 | return sr.CanHandle(ctx, trigger, args...), nil 236 | } 237 | 238 | // SetTriggerParameters specify the arguments that must be supplied when a specific trigger is fired. 239 | func (sm *StateMachine) SetTriggerParameters(trigger Trigger, argumentTypes ...reflect.Type) { 240 | config := triggerWithParameters{Trigger: trigger, ArgumentTypes: argumentTypes} 241 | if _, ok := sm.triggerConfig[config.Trigger]; ok { 242 | panic(fmt.Sprintf("stateless: Parameters for the trigger '%v' have already been configured.", trigger)) 243 | } 244 | sm.triggerConfig[trigger] = config 245 | } 246 | 247 | // Fire see FireCtx 248 | func (sm *StateMachine) Fire(trigger Trigger, args ...any) error { 249 | return sm.FireCtx(context.Background(), trigger, args...) 250 | } 251 | 252 | // FireCtx transition from the current state via the specified trigger. 253 | // The target state is determined by the configuration of the current state. 254 | // Actions associated with leaving the current state and entering the new one will be invoked. 255 | // 256 | // An error is returned if any of the state machine actions or the state callbacks return an error 257 | // without wrapping. It can also return an error if the trigger is not mapped to any state change, 258 | // being this error the one returned by `OnUnhandledTrigger` func. 259 | // 260 | // There is no rollback mechanism in case there is an action error after the state has been changed. 261 | // Guard clauses or error states can be used gracefully handle this situations. 262 | // 263 | // The context is passed down to all actions and callbacks called within the scope of this method. 264 | // There is no context error checking, although it may be implemented in future releases. 265 | func (sm *StateMachine) FireCtx(ctx context.Context, trigger Trigger, args ...any) error { 266 | return sm.internalFire(ctx, trigger, args...) 267 | } 268 | 269 | // OnTransitioned registers a callback that will be invoked every time the state machine 270 | // successfully finishes a transitions from one state into another. 271 | func (sm *StateMachine) OnTransitioned(fn ...TransitionFunc) { 272 | sm.onTransitionedEvents = append(sm.onTransitionedEvents, fn...) 273 | } 274 | 275 | // OnTransitioning registers a callback that will be invoked every time the state machine 276 | // starts a transitions from one state into another. 277 | func (sm *StateMachine) OnTransitioning(fn ...TransitionFunc) { 278 | sm.onTransitioningEvents = append(sm.onTransitioningEvents, fn...) 279 | } 280 | 281 | // OnUnhandledTrigger override the default behaviour of returning an error when an unhandled trigger. 282 | func (sm *StateMachine) OnUnhandledTrigger(fn UnhandledTriggerActionFunc) { 283 | sm.unhandledTriggerAction = fn 284 | } 285 | 286 | // Configure begin configuration of the entry/exit actions and allowed transitions 287 | // when the state machine is in a particular state. 288 | func (sm *StateMachine) Configure(state State) *StateConfiguration { 289 | return &StateConfiguration{sm: sm, sr: sm.stateRepresentation(state), lookup: sm.stateRepresentation} 290 | } 291 | 292 | // Firing returns true when the state machine is processing a trigger. 293 | func (sm *StateMachine) Firing() bool { 294 | return sm.mode.Firing() 295 | } 296 | 297 | // String returns a human-readable representation of the state machine. 298 | // It is not guaranteed that the order of the PermittedTriggers is the same in consecutive executions. 299 | func (sm *StateMachine) String() string { 300 | state, err := sm.State(context.Background()) 301 | if err != nil { 302 | return "" 303 | } 304 | 305 | // PermittedTriggers only returns an error if state accessor returns one, and it has already been checked. 306 | triggers, _ := sm.PermittedTriggers() 307 | return fmt.Sprintf("StateMachine {{ State = %v, PermittedTriggers = %v }}", state, triggers) 308 | } 309 | 310 | func (sm *StateMachine) setState(ctx context.Context, state State, args ...any) error { 311 | return sm.stateMutator(ctx, state, args...) 312 | } 313 | 314 | func (sm *StateMachine) currentState(ctx context.Context) (*stateRepresentation, error) { 315 | state, err := sm.State(ctx) 316 | if err != nil { 317 | return nil, err 318 | } 319 | return sm.stateRepresentation(state), nil 320 | } 321 | 322 | func (sm *StateMachine) stateRepresentation(state State) *stateRepresentation { 323 | sm.stateMutex.RLock() 324 | sr, ok := sm.stateConfig[state] 325 | sm.stateMutex.RUnlock() 326 | if !ok { 327 | sm.stateMutex.Lock() 328 | defer sm.stateMutex.Unlock() 329 | // Check again, since another goroutine may have added it while we were waiting for the lock. 330 | if sr, ok = sm.stateConfig[state]; !ok { 331 | sr = newstateRepresentation(state) 332 | sm.stateConfig[state] = sr 333 | } 334 | } 335 | return sr 336 | } 337 | 338 | func (sm *StateMachine) internalFire(ctx context.Context, trigger Trigger, args ...any) error { 339 | return sm.mode.Fire(ctx, trigger, args...) 340 | } 341 | 342 | func (sm *StateMachine) internalFireOne(ctx context.Context, trigger Trigger, args ...any) error { 343 | var ( 344 | config triggerWithParameters 345 | ok bool 346 | ) 347 | if config, ok = sm.triggerConfig[trigger]; ok { 348 | config.validateParameters(args...) 349 | } 350 | source, err := sm.State(ctx) 351 | if err != nil { 352 | return err 353 | } 354 | representativeState := sm.stateRepresentation(source) 355 | var result triggerBehaviourResult 356 | if result, ok = representativeState.FindHandler(ctx, trigger, args...); !ok { 357 | return sm.unhandledTriggerAction(ctx, representativeState.State, trigger, result.UnmetGuardConditions) 358 | } 359 | switch t := result.Handler.(type) { 360 | case *ignoredTriggerBehaviour: 361 | // ignored 362 | case *reentryTriggerBehaviour: 363 | transition := Transition{Source: source, Destination: t.Destination, Trigger: trigger} 364 | err = sm.handleReentryTrigger(ctx, representativeState, transition, args...) 365 | case *dynamicTriggerBehaviour: 366 | var destination any 367 | destination, err = t.Destination(ctx, args...) 368 | if err == nil { 369 | transition := Transition{Source: source, Destination: destination, Trigger: trigger} 370 | err = sm.handleTransitioningTrigger(ctx, representativeState, transition, args...) 371 | } 372 | case *transitioningTriggerBehaviour: 373 | if source == t.Destination { 374 | // If a trigger was found on a superstate that would cause unintended reentry, don't trigger. 375 | break 376 | } 377 | transition := Transition{Source: source, Destination: t.Destination, Trigger: trigger} 378 | err = sm.handleTransitioningTrigger(ctx, representativeState, transition, args...) 379 | case *internalTriggerBehaviour: 380 | var sr *stateRepresentation 381 | sr, err = sm.currentState(ctx) 382 | if err == nil { 383 | transition := Transition{Source: source, Destination: source, Trigger: trigger} 384 | err = sr.InternalAction(ctx, transition, args...) 385 | } 386 | } 387 | return err 388 | } 389 | 390 | func (sm *StateMachine) handleReentryTrigger(ctx context.Context, sr *stateRepresentation, transition Transition, args ...any) error { 391 | if err := sr.Exit(ctx, transition, args...); err != nil { 392 | return err 393 | } 394 | newSr := sm.stateRepresentation(transition.Destination) 395 | if !transition.IsReentry() { 396 | transition = Transition{Source: transition.Destination, Destination: transition.Destination, Trigger: transition.Trigger} 397 | if err := newSr.Exit(ctx, transition, args...); err != nil { 398 | return err 399 | } 400 | } 401 | callEvents(sm.onTransitioningEvents, ctx, transition) 402 | rep, err := sm.enterState(ctx, newSr, transition, args...) 403 | if err != nil { 404 | return err 405 | } 406 | if err := sm.setState(ctx, rep.State, args...); err != nil { 407 | return err 408 | } 409 | callEvents(sm.onTransitionedEvents, ctx, transition) 410 | return nil 411 | } 412 | 413 | func (sm *StateMachine) handleTransitioningTrigger(ctx context.Context, sr *stateRepresentation, transition Transition, args ...any) error { 414 | if err := sr.Exit(ctx, transition, args...); err != nil { 415 | return err 416 | } 417 | callEvents(sm.onTransitioningEvents, ctx, transition) 418 | if err := sm.setState(ctx, transition.Destination, args...); err != nil { 419 | return err 420 | } 421 | newSr := sm.stateRepresentation(transition.Destination) 422 | rep, err := sm.enterState(ctx, newSr, transition, args...) 423 | if err != nil { 424 | return err 425 | } 426 | // Check if state has changed by entering new state (by firing triggers in OnEntry or such) 427 | if rep.State != newSr.State { 428 | if err := sm.setState(ctx, rep.State, args...); err != nil { 429 | return err 430 | } 431 | } 432 | callEvents(sm.onTransitionedEvents, ctx, Transition{transition.Source, rep.State, transition.Trigger, false}) 433 | return nil 434 | } 435 | 436 | func (sm *StateMachine) enterState(ctx context.Context, sr *stateRepresentation, transition Transition, args ...any) (*stateRepresentation, error) { 437 | // Enter the new state 438 | err := sr.Enter(ctx, transition, args...) 439 | if err != nil { 440 | return nil, err 441 | } 442 | // Recursively enter substates that have an initial transition 443 | if sr.HasInitialState { 444 | isValidForInitialState := false 445 | for _, substate := range sr.Substates { 446 | // Verify that the target state is a substate 447 | // Check if state has substate(s), and if an initial transition(s) has been set up. 448 | if substate.State == sr.InitialTransitionTarget { 449 | isValidForInitialState = true 450 | break 451 | } 452 | } 453 | if !isValidForInitialState { 454 | panic(fmt.Sprintf("stateless: The target (%v) for the initial transition is not a substate.", sr.InitialTransitionTarget)) 455 | } 456 | initialTranslation := Transition{Source: transition.Source, Destination: sr.InitialTransitionTarget, Trigger: transition.Trigger, isInitial: true} 457 | sr = sm.stateRepresentation(sr.InitialTransitionTarget) 458 | callEvents(sm.onTransitioningEvents, ctx, Transition{transition.Destination, initialTranslation.Destination, transition.Trigger, false}) 459 | sr, err = sm.enterState(ctx, sr, initialTranslation, args...) 460 | } 461 | return sr, err 462 | } 463 | -------------------------------------------------------------------------------- /statemachine_test.go: -------------------------------------------------------------------------------- 1 | package stateless 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "reflect" 8 | "sync" 9 | "testing" 10 | ) 11 | 12 | const ( 13 | stateA = "A" 14 | stateB = "B" 15 | stateC = "C" 16 | stateD = "D" 17 | 18 | triggerX = "X" 19 | triggerY = "Y" 20 | triggerZ = "Z" 21 | ) 22 | 23 | func TestTransition_IsReentry(t *testing.T) { 24 | tests := []struct { 25 | name string 26 | t *Transition 27 | want bool 28 | }{ 29 | {"TransitionIsNotChange", &Transition{"1", "1", "0", false}, true}, 30 | {"TransitionIsChange", &Transition{"1", "2", "0", false}, false}, 31 | } 32 | for _, tt := range tests { 33 | t.Run(tt.name, func(t *testing.T) { 34 | if got := tt.t.IsReentry(); got != tt.want { 35 | t.Errorf("Transition.IsReentry() = %v, want %v", got, tt.want) 36 | } 37 | }) 38 | } 39 | } 40 | 41 | func TestStateMachine_NewStateMachine(t *testing.T) { 42 | sm := NewStateMachine(stateA) 43 | if got := sm.MustState(); got != stateA { 44 | t.Errorf("MustState() = %v, want %v", got, stateA) 45 | } 46 | } 47 | 48 | func TestStateMachine_NewStateMachineWithExternalStorage(t *testing.T) { 49 | var state State = stateB 50 | sm := NewStateMachineWithExternalStorage(func(_ context.Context) (State, error) { 51 | return state, nil 52 | }, func(_ context.Context, s State) error { 53 | state = s 54 | return nil 55 | }, FiringImmediate) 56 | sm.Configure(stateB).Permit(triggerX, stateC) 57 | if got := sm.MustState(); got != stateB { 58 | t.Errorf("MustState() = %v, want %v", got, stateB) 59 | } 60 | if state != stateB { 61 | t.Errorf("expected state to be %v, got %v", stateB, state) 62 | } 63 | sm.Fire(triggerX) 64 | if got := sm.MustState(); got != stateC { 65 | t.Errorf("MustState() = %v, want %v", got, stateC) 66 | } 67 | if state != stateC { 68 | t.Errorf("expected state to be %v, got %v", stateC, state) 69 | } 70 | } 71 | 72 | func TestStateMachine_NewStateMachineWithExternalStorageAndArgs(t *testing.T) { 73 | var state State = stateB 74 | var args = []any{"test1", errors.New("test1")} 75 | sm := NewStateMachineWithExternalStorageAndArgs(func(_ context.Context) (State, []any, error) { 76 | return state, args, nil 77 | }, func(_ context.Context, s State, a ...any) error { 78 | state = s 79 | args = a 80 | return nil 81 | }, FiringImmediate) 82 | sm.Configure(stateB).Permit(triggerX, stateC) 83 | 84 | // test both existing calls and new calls since there's wrapping involved 85 | if got := sm.MustState(); got != stateB { 86 | t.Errorf("MustState() = %v, want %v", got, stateB) 87 | } 88 | if state != stateB { 89 | t.Errorf("expected state to be %v, got %v", stateB, state) 90 | } 91 | 92 | sm.Fire(triggerX, "test2", errors.New("test2")) 93 | if got := sm.MustState(); got != stateC { 94 | t.Errorf("MustState() = %v, want %v", got, stateC) 95 | } 96 | if state != stateC { 97 | t.Errorf("expected state to be %v, got %v", stateC, state) 98 | } 99 | 100 | // ensure that the state has been updated 101 | if got := args[0].(string); got != "test2" { 102 | t.Errorf("expected arg 0 to be %v, got %v", "test2", got) 103 | } 104 | if got := args[1].(error).Error(); got != "test2" { 105 | t.Errorf("expected arg 1 to be %v, got %v", "test2", got) 106 | } 107 | } 108 | 109 | func TestStateMachine_Configure_SubstateIsIncludedInCurrentState(t *testing.T) { 110 | sm := NewStateMachine(stateB) 111 | sm.Configure(stateB).SubstateOf(stateC) 112 | if ok, _ := sm.IsInState(stateC); !ok { 113 | t.Errorf("IsInState() = %v, want %v", ok, true) 114 | } 115 | 116 | if got := sm.MustState(); got != stateB { 117 | t.Errorf("MustState() = %v, want %v", got, stateB) 118 | } 119 | } 120 | 121 | func TestStateMachine_Configure_InSubstate_TriggerIgnoredInSuperstate_RemainsInSubstate(t *testing.T) { 122 | sm := NewStateMachine(stateB) 123 | sm.Configure(stateB).SubstateOf(stateC) 124 | sm.Configure(stateC).Ignore(triggerX) 125 | sm.Fire(triggerX) 126 | 127 | if got := sm.MustState(); got != stateB { 128 | t.Errorf("MustState() = %v, want %v", got, stateB) 129 | } 130 | } 131 | 132 | func TestStateMachine_CanFire(t *testing.T) { 133 | sm := NewStateMachine(stateB) 134 | sm.Configure(stateB).Permit(triggerX, stateA) 135 | if ok, _ := sm.CanFire(triggerX); !ok { 136 | t.Errorf("CanFire() = %v, want %v", ok, true) 137 | } 138 | if ok, _ := sm.CanFire(triggerY); ok { 139 | t.Errorf("CanFire() = %v, want %v", ok, false) 140 | } 141 | } 142 | 143 | func TestStateMachine_CanFire_StatusError(t *testing.T) { 144 | sm := NewStateMachineWithExternalStorage(func(_ context.Context) (State, error) { 145 | return nil, errors.New("status error") 146 | }, func(_ context.Context, s State) error { return nil }, FiringImmediate) 147 | 148 | sm.Configure(stateB).Permit(triggerX, stateA) 149 | 150 | ok, err := sm.CanFire(triggerX) 151 | if ok { 152 | t.Fail() 153 | } 154 | want := "status error" 155 | if err == nil || err.Error() != want { 156 | t.Errorf("CanFire() = %v, want %v", err, want) 157 | } 158 | } 159 | 160 | func TestStateMachine_IsInState_StatusError(t *testing.T) { 161 | sm := NewStateMachineWithExternalStorage(func(_ context.Context) (State, error) { 162 | return nil, errors.New("status error") 163 | }, func(_ context.Context, s State) error { return nil }, FiringImmediate) 164 | 165 | ok, err := sm.IsInState(stateA) 166 | if ok { 167 | t.Fail() 168 | } 169 | want := "status error" 170 | if err == nil || err.Error() != want { 171 | t.Errorf("IsInState() = %v, want %v", err, want) 172 | } 173 | } 174 | 175 | func TestStateMachine_Activate_StatusError(t *testing.T) { 176 | sm := NewStateMachineWithExternalStorage(func(_ context.Context) (State, error) { 177 | return nil, errors.New("status error") 178 | }, func(_ context.Context, s State) error { return nil }, FiringImmediate) 179 | 180 | want := "status error" 181 | if err := sm.Activate(); err == nil || err.Error() != want { 182 | t.Errorf("Activate() = %v, want %v", err, want) 183 | } 184 | if err := sm.Deactivate(); err == nil || err.Error() != want { 185 | t.Errorf("Deactivate() = %v, want %v", err, want) 186 | } 187 | } 188 | 189 | func TestStateMachine_PermittedTriggers_StatusError(t *testing.T) { 190 | sm := NewStateMachineWithExternalStorage(func(_ context.Context) (State, error) { 191 | return nil, errors.New("status error") 192 | }, func(_ context.Context, s State) error { return nil }, FiringImmediate) 193 | 194 | want := "status error" 195 | if _, err := sm.PermittedTriggers(); err == nil || err.Error() != want { 196 | t.Errorf("PermittedTriggers() = %v, want %v", err, want) 197 | } 198 | } 199 | 200 | func TestStateMachine_MustState_StatusError(t *testing.T) { 201 | sm := NewStateMachineWithExternalStorage(func(_ context.Context) (State, error) { 202 | return nil, errors.New("") 203 | }, func(_ context.Context, s State) error { return nil }, FiringImmediate) 204 | 205 | assertPanic(t, func() { sm.MustState() }) 206 | } 207 | 208 | func TestStateMachine_Fire_StatusError(t *testing.T) { 209 | sm := NewStateMachineWithExternalStorage(func(_ context.Context) (State, error) { 210 | return nil, errors.New("status error") 211 | }, func(_ context.Context, s State) error { return nil }, FiringImmediate) 212 | 213 | want := "status error" 214 | if err := sm.Fire(triggerX); err == nil || err.Error() != want { 215 | t.Errorf("Fire() = %v, want %v", err, want) 216 | } 217 | } 218 | 219 | func TestStateMachine_Configure_PermittedTriggersIncludeSuperstatePermittedTriggers(t *testing.T) { 220 | sm := NewStateMachine(stateB) 221 | sm.Configure(stateA).Permit(triggerZ, stateB) 222 | sm.Configure(stateB).SubstateOf(stateC).Permit(triggerX, stateA) 223 | sm.Configure(stateC).Permit(triggerY, stateA) 224 | 225 | permitted, _ := sm.PermittedTriggers(context.Background()) 226 | 227 | var hasX, hasY, hasZ bool 228 | for _, trigger := range permitted { 229 | if trigger == triggerX { 230 | hasX = true 231 | } 232 | if trigger == triggerY { 233 | hasY = true 234 | } 235 | if trigger == triggerZ { 236 | hasZ = true 237 | } 238 | } 239 | if !hasX { 240 | t.Errorf("expected permitted triggers to include %v", triggerX) 241 | } 242 | if !hasY { 243 | t.Errorf("expected permitted triggers to include %v", triggerY) 244 | } 245 | if hasZ { 246 | t.Errorf("expected permitted triggers to exclude %v", triggerZ) 247 | } 248 | } 249 | 250 | func TestStateMachine_PermittedTriggers_PermittedTriggersAreDistinctValues(t *testing.T) { 251 | sm := NewStateMachine(stateB) 252 | sm.Configure(stateB).SubstateOf(stateC).Permit(triggerX, stateA) 253 | sm.Configure(stateC).Permit(triggerX, stateB) 254 | 255 | permitted, _ := sm.PermittedTriggers(context.Background()) 256 | 257 | want := []any{triggerX} 258 | if !reflect.DeepEqual(permitted, want) { 259 | t.Errorf("PermittedTriggers() = %v, want %v", permitted, want) 260 | } 261 | } 262 | 263 | func TestStateMachine_PermittedTriggers_AcceptedTriggersRespectGuards(t *testing.T) { 264 | sm := NewStateMachine(stateB) 265 | sm.Configure(stateB).Permit(triggerX, stateA, func(_ context.Context, _ ...any) bool { 266 | return false 267 | }) 268 | 269 | permitted, _ := sm.PermittedTriggers(context.Background()) 270 | 271 | if got := len(permitted); got != 0 { 272 | t.Errorf("PermittedTriggers() = %v, want %v", got, 0) 273 | } 274 | } 275 | 276 | func TestStateMachine_PermittedTriggers_AcceptedTriggersRespectMultipleGuards(t *testing.T) { 277 | sm := NewStateMachine(stateB) 278 | sm.Configure(stateB).Permit(triggerX, stateA, func(_ context.Context, _ ...any) bool { 279 | return true 280 | }, func(_ context.Context, _ ...any) bool { 281 | return false 282 | }) 283 | 284 | permitted, _ := sm.PermittedTriggers(context.Background()) 285 | 286 | if got := len(permitted); got != 0 { 287 | t.Errorf("PermittedTriggers() = %v, want %v", got, 0) 288 | } 289 | } 290 | 291 | func TestStateMachine_Fire_DiscriminatedByGuard_ChoosesPermitedTransition(t *testing.T) { 292 | sm := NewStateMachine(stateB) 293 | sm.Configure(stateB). 294 | Permit(triggerX, stateA, func(_ context.Context, _ ...any) bool { 295 | return false 296 | }). 297 | Permit(triggerX, stateC, func(_ context.Context, _ ...any) bool { 298 | return true 299 | }) 300 | 301 | sm.Fire(triggerX) 302 | 303 | if got := sm.MustState(); got != stateC { 304 | t.Errorf("MustState() = %v, want %v", got, stateC) 305 | } 306 | } 307 | 308 | func TestStateMachine_Fire_SaveError(t *testing.T) { 309 | sm := NewStateMachineWithExternalStorage(func(_ context.Context) (State, error) { 310 | return stateB, nil 311 | }, func(_ context.Context, s State) error { return errors.New("status error") }, FiringImmediate) 312 | 313 | sm.Configure(stateB). 314 | Permit(triggerX, stateA) 315 | 316 | want := "status error" 317 | if err := sm.Fire(triggerX); err == nil || err.Error() != want { 318 | t.Errorf("Fire() = %v, want %v", err, want) 319 | } 320 | if sm.MustState() != stateB { 321 | t.Errorf("MustState() = %v, want %v", sm.MustState(), stateB) 322 | } 323 | } 324 | 325 | func TestStateMachine_Fire_TriggerIsIgnored_ActionsNotExecuted(t *testing.T) { 326 | fired := false 327 | sm := NewStateMachine(stateB) 328 | sm.Configure(stateB). 329 | OnEntry(func(_ context.Context, _ ...any) error { 330 | fired = true 331 | return nil 332 | }). 333 | Ignore(triggerX) 334 | 335 | sm.Fire(triggerX) 336 | 337 | if fired { 338 | t.Error("actions were executed") 339 | } 340 | } 341 | 342 | func TestStateMachine_Fire_SelfTransitionPermited_ActionsFire(t *testing.T) { 343 | fired := false 344 | sm := NewStateMachine(stateB) 345 | sm.Configure(stateB). 346 | OnEntry(func(_ context.Context, _ ...any) error { 347 | fired = true 348 | return nil 349 | }). 350 | PermitReentry(triggerX) 351 | 352 | sm.Fire(triggerX) 353 | if !fired { 354 | t.Error("actions did not fire") 355 | } 356 | } 357 | 358 | func TestStateMachine_Fire_ImplicitReentryIsDisallowed(t *testing.T) { 359 | sm := NewStateMachine(stateB) 360 | assertPanic(t, func() { 361 | sm.Configure(stateB). 362 | Permit(triggerX, stateB) 363 | }) 364 | } 365 | 366 | func TestStateMachine_Fire_ErrorForInvalidTransition(t *testing.T) { 367 | sm := NewStateMachine(stateA) 368 | if err := sm.Fire(triggerX); err == nil { 369 | t.Error("error expected") 370 | } 371 | } 372 | 373 | func TestStateMachine_Fire_ErrorForInvalidTransitionMentionsGuardDescriptionIfPresent(t *testing.T) { 374 | sm := NewStateMachine(stateA) 375 | sm.Configure(stateA).Permit(triggerX, stateB, func(_ context.Context, _ ...any) bool { 376 | return false 377 | }) 378 | if err := sm.Fire(triggerX); err == nil { 379 | t.Error("error expected") 380 | } 381 | } 382 | 383 | func TestStateMachine_Fire_ParametersSuppliedToFireArePassedToEntryAction(t *testing.T) { 384 | sm := NewStateMachine(stateB) 385 | sm.SetTriggerParameters(triggerX, reflect.TypeOf(""), reflect.TypeOf(0)) 386 | sm.Configure(stateB).Permit(triggerX, stateC) 387 | 388 | var ( 389 | entryArg1 string 390 | entryArg2 int 391 | ) 392 | sm.Configure(stateC).OnEntryFrom(triggerX, func(_ context.Context, args ...any) error { 393 | entryArg1 = args[0].(string) 394 | entryArg2 = args[1].(int) 395 | return nil 396 | }) 397 | suppliedArg1, suppliedArg2 := "something", 2 398 | sm.Fire(triggerX, suppliedArg1, suppliedArg2) 399 | 400 | if entryArg1 != suppliedArg1 { 401 | t.Errorf("entryArg1 = %v, want %v", entryArg1, suppliedArg1) 402 | } 403 | if entryArg2 != suppliedArg2 { 404 | t.Errorf("entryArg2 = %v, want %v", entryArg2, suppliedArg2) 405 | } 406 | } 407 | 408 | func TestStateMachine_Fire_ParametersSuppliedToFireArePassedToExitAction(t *testing.T) { 409 | sm := NewStateMachine(stateB) 410 | sm.SetTriggerParameters(triggerX, reflect.TypeOf(""), reflect.TypeOf(0)) 411 | sm.Configure(stateB).Permit(triggerX, stateC) 412 | 413 | var ( 414 | entryArg1 string 415 | entryArg2 int 416 | ) 417 | sm.Configure(stateB).OnExitWith(triggerX, func(_ context.Context, args ...any) error { 418 | entryArg1 = args[0].(string) 419 | entryArg2 = args[1].(int) 420 | return nil 421 | }) 422 | suppliedArg1, suppliedArg2 := "something", 2 423 | sm.Fire(triggerX, suppliedArg1, suppliedArg2) 424 | 425 | if entryArg1 != suppliedArg1 { 426 | t.Errorf("entryArg1 = %v, want %v", entryArg1, suppliedArg1) 427 | } 428 | if entryArg2 != suppliedArg2 { 429 | t.Errorf("entryArg2 = %v, want %v", entryArg2, suppliedArg2) 430 | } 431 | } 432 | 433 | func TestStateMachine_OnUnhandledTrigger_TheProvidedHandlerIsCalledWithStateAndTrigger(t *testing.T) { 434 | sm := NewStateMachine(stateB) 435 | var ( 436 | unhandledState State 437 | unhandledTrigger Trigger 438 | ) 439 | sm.OnUnhandledTrigger(func(_ context.Context, state State, trigger Trigger, unmetGuards []string) error { 440 | unhandledState = state 441 | unhandledTrigger = trigger 442 | return nil 443 | }) 444 | 445 | sm.Fire(triggerZ) 446 | 447 | if stateB != unhandledState { 448 | t.Errorf("unhandledState = %v, want %v", unhandledState, stateB) 449 | } 450 | if triggerZ != unhandledTrigger { 451 | t.Errorf("unhandledTrigger = %v, want %v", unhandledTrigger, triggerZ) 452 | } 453 | } 454 | 455 | func TestStateMachine_SetTriggerParameters_TriggerParametersAreImmutableOnceSet(t *testing.T) { 456 | sm := NewStateMachine(stateB) 457 | 458 | sm.SetTriggerParameters(triggerX, reflect.TypeOf(""), reflect.TypeOf(0)) 459 | 460 | assertPanic(t, func() { sm.SetTriggerParameters(triggerX, reflect.TypeOf(""), reflect.TypeOf(0)) }) 461 | } 462 | 463 | func TestStateMachine_SetTriggerParameters_Interfaces(t *testing.T) { 464 | sm := NewStateMachine(stateB) 465 | sm.SetTriggerParameters(triggerX, reflect.TypeOf((*error)(nil)).Elem()) 466 | 467 | sm.Configure(stateB).Permit(triggerX, stateA) 468 | defer func() { 469 | if r := recover(); r != nil { 470 | t.Error("panic not expected") 471 | } 472 | }() 473 | sm.Fire(triggerX, errors.New("failed")) 474 | } 475 | 476 | func TestStateMachine_SetTriggerParameters_Invalid(t *testing.T) { 477 | sm := NewStateMachine(stateB) 478 | 479 | sm.SetTriggerParameters(triggerX, reflect.TypeOf(""), reflect.TypeOf(0)) 480 | sm.Configure(stateB).Permit(triggerX, stateA) 481 | 482 | assertPanic(t, func() { sm.Fire(triggerX) }) 483 | assertPanic(t, func() { sm.Fire(triggerX, "1", "2", "3") }) 484 | assertPanic(t, func() { sm.Fire(triggerX, "1", "2") }) 485 | } 486 | 487 | func TestStateMachine_OnTransitioning_EventFires(t *testing.T) { 488 | sm := NewStateMachine(stateB) 489 | sm.Configure(stateB).Permit(triggerX, stateA) 490 | 491 | var transition Transition 492 | sm.OnTransitioning(func(_ context.Context, tr Transition) { 493 | transition = tr 494 | }) 495 | sm.Fire(triggerX) 496 | 497 | want := Transition{ 498 | Source: stateB, 499 | Destination: stateA, 500 | Trigger: triggerX, 501 | } 502 | if !reflect.DeepEqual(transition, want) { 503 | t.Errorf("transition = %v, want %v", transition, want) 504 | } 505 | } 506 | 507 | func TestStateMachine_OnTransitioned_EventFires(t *testing.T) { 508 | sm := NewStateMachine(stateB) 509 | sm.Configure(stateB).Permit(triggerX, stateA) 510 | 511 | var transition Transition 512 | sm.OnTransitioned(func(_ context.Context, tr Transition) { 513 | transition = tr 514 | }) 515 | sm.Fire(triggerX) 516 | 517 | want := Transition{ 518 | Source: stateB, 519 | Trigger: triggerX, 520 | Destination: stateA, 521 | } 522 | if !reflect.DeepEqual(transition, want) { 523 | t.Errorf("transition = %v, want %v", transition, want) 524 | } 525 | } 526 | 527 | func TestStateMachine_OnTransitioned_EventFiresBeforeTheOnEntryEvent(t *testing.T) { 528 | sm := NewStateMachine(stateB) 529 | expectedOrdering := []string{"OnExit", "OnTransitioning", "OnEntry", "OnTransitioned"} 530 | var actualOrdering []string 531 | 532 | sm.Configure(stateB).Permit(triggerX, stateA).OnExit(func(_ context.Context, args ...any) error { 533 | actualOrdering = append(actualOrdering, "OnExit") 534 | return nil 535 | }).Machine() 536 | 537 | var transition Transition 538 | sm.Configure(stateA).OnEntry(func(ctx context.Context, args ...any) error { 539 | actualOrdering = append(actualOrdering, "OnEntry") 540 | transition = GetTransition(ctx) 541 | return nil 542 | }) 543 | 544 | sm.OnTransitioning(func(_ context.Context, tr Transition) { 545 | actualOrdering = append(actualOrdering, "OnTransitioning") 546 | }) 547 | sm.OnTransitioned(func(_ context.Context, tr Transition) { 548 | actualOrdering = append(actualOrdering, "OnTransitioned") 549 | }) 550 | 551 | sm.Fire(triggerX) 552 | 553 | if !reflect.DeepEqual(actualOrdering, expectedOrdering) { 554 | t.Errorf("actualOrdering = %v, want %v", actualOrdering, expectedOrdering) 555 | } 556 | 557 | want := Transition{ 558 | Source: stateB, 559 | Destination: stateA, 560 | Trigger: triggerX, 561 | } 562 | if !reflect.DeepEqual(transition, want) { 563 | t.Errorf("transition = %v, want %v", transition, want) 564 | } 565 | } 566 | 567 | func TestStateMachine_SubstateOf_DirectCyclicConfigurationDetected(t *testing.T) { 568 | sm := NewStateMachine(stateA) 569 | assertPanic(t, func() { sm.Configure(stateA).SubstateOf(stateA) }) 570 | } 571 | 572 | func TestStateMachine_SubstateOf_NestedCyclicConfigurationDetected(t *testing.T) { 573 | sm := NewStateMachine(stateA) 574 | sm.Configure(stateB).SubstateOf(stateA) 575 | assertPanic(t, func() { sm.Configure(stateA).SubstateOf(stateB) }) 576 | } 577 | 578 | func TestStateMachine_SubstateOf_NestedTwoLevelsCyclicConfigurationDetected(t *testing.T) { 579 | sm := NewStateMachine(stateA) 580 | sm.Configure(stateB).SubstateOf(stateA) 581 | sm.Configure(stateC).SubstateOf(stateB) 582 | assertPanic(t, func() { sm.Configure(stateA).SubstateOf(stateC) }) 583 | } 584 | 585 | func TestStateMachine_SubstateOf_DelayedNestedCyclicConfigurationDetected(t *testing.T) { 586 | sm := NewStateMachine(stateA) 587 | sm.Configure(stateB).SubstateOf(stateA) 588 | sm.Configure(stateC) 589 | sm.Configure(stateA).SubstateOf(stateC) 590 | assertPanic(t, func() { sm.Configure(stateC).SubstateOf(stateB) }) 591 | } 592 | 593 | func TestStateMachine_Fire_IgnoreVsPermitReentry(t *testing.T) { 594 | sm := NewStateMachine(stateA) 595 | var calls int 596 | sm.Configure(stateA). 597 | OnEntry(func(_ context.Context, _ ...any) error { 598 | calls += 1 599 | return nil 600 | }). 601 | PermitReentry(triggerX). 602 | Ignore(triggerY) 603 | 604 | sm.Fire(triggerX) 605 | sm.Fire(triggerY) 606 | 607 | if calls != 1 { 608 | t.Errorf("calls = %d, want %d", calls, 1) 609 | } 610 | } 611 | 612 | func TestStateMachine_Fire_IgnoreVsPermitReentryFrom(t *testing.T) { 613 | sm := NewStateMachine(stateA) 614 | var calls int 615 | sm.Configure(stateA). 616 | OnEntryFrom(triggerX, func(_ context.Context, _ ...any) error { 617 | calls += 1 618 | return nil 619 | }). 620 | OnEntryFrom(triggerY, func(_ context.Context, _ ...any) error { 621 | calls += 1 622 | return nil 623 | }). 624 | PermitReentry(triggerX). 625 | Ignore(triggerY) 626 | 627 | sm.Fire(triggerX) 628 | sm.Fire(triggerY) 629 | 630 | if calls != 1 { 631 | t.Errorf("calls = %d, want %d", calls, 1) 632 | } 633 | } 634 | 635 | func TestStateMachine_Fire_IgnoreVsPermitReentryExitWith(t *testing.T) { 636 | sm := NewStateMachine(stateA) 637 | var calls int 638 | sm.Configure(stateA). 639 | OnExitWith(triggerX, func(_ context.Context, _ ...any) error { 640 | calls += 1 641 | return nil 642 | }). 643 | OnExitWith(triggerY, func(_ context.Context, _ ...any) error { 644 | calls += 1 645 | return nil 646 | }). 647 | PermitReentry(triggerX). 648 | Ignore(triggerY) 649 | 650 | sm.Fire(triggerX) 651 | sm.Fire(triggerY) 652 | 653 | if calls != 1 { 654 | t.Errorf("calls = %d, want %d", calls, 1) 655 | } 656 | } 657 | 658 | func TestStateMachine_Fire_IfSelfTransitionPermited_ActionsFire_InSubstate(t *testing.T) { 659 | sm := NewStateMachine(stateA) 660 | var onEntryStateBfired, onExitStateBfired, onExitStateAfired bool 661 | sm.Configure(stateB). 662 | OnEntry(func(_ context.Context, _ ...any) error { 663 | onEntryStateBfired = true 664 | return nil 665 | }). 666 | PermitReentry(triggerX). 667 | OnExit(func(_ context.Context, _ ...any) error { 668 | onExitStateBfired = true 669 | return nil 670 | }) 671 | 672 | sm.Configure(stateA). 673 | SubstateOf(stateB). 674 | OnExit(func(_ context.Context, _ ...any) error { 675 | onExitStateAfired = true 676 | return nil 677 | }) 678 | 679 | sm.Fire(triggerX) 680 | 681 | if got := sm.MustState(); got != stateB { 682 | t.Errorf("sm.MustState() = %v, want %v", got, stateB) 683 | } 684 | if !onEntryStateBfired { 685 | t.Error("OnEntryStateB was not fired") 686 | } 687 | if !onExitStateBfired { 688 | t.Error("OnExitStateB was not fired") 689 | } 690 | if !onExitStateAfired { 691 | t.Error("OnExitStateA was not fired") 692 | } 693 | } 694 | 695 | func TestStateMachine_Fire_TransitionWhenParameterizedGuardTrue(t *testing.T) { 696 | sm := NewStateMachine(stateA) 697 | sm.SetTriggerParameters(triggerX, reflect.TypeOf(0)) 698 | sm.Configure(stateA). 699 | Permit(triggerX, stateB, func(_ context.Context, args ...any) bool { 700 | return args[0].(int) == 2 701 | }) 702 | 703 | sm.Fire(triggerX, 2) 704 | 705 | if got := sm.MustState(); got != stateB { 706 | t.Errorf("sm.MustState() = %v, want %v", got, stateB) 707 | } 708 | } 709 | 710 | func TestStateMachine_Fire_ErrorWhenParameterizedGuardFalse(t *testing.T) { 711 | sm := NewStateMachine(stateA) 712 | sm.SetTriggerParameters(triggerX, reflect.TypeOf(0)) 713 | sm.Configure(stateA). 714 | Permit(triggerX, stateB, func(_ context.Context, args ...any) bool { 715 | return args[0].(int) == 3 716 | }) 717 | 718 | sm.Fire(triggerX, 2) 719 | if err := sm.Fire(triggerX, 2); err == nil { 720 | t.Error("error expected") 721 | } 722 | } 723 | 724 | func TestStateMachine_Fire_TransitionWhenBothParameterizedGuardClausesTrue(t *testing.T) { 725 | sm := NewStateMachine(stateA) 726 | sm.SetTriggerParameters(triggerX, reflect.TypeOf(0)) 727 | sm.Configure(stateA). 728 | Permit(triggerX, stateB, func(_ context.Context, args ...any) bool { 729 | return args[0].(int) == 2 730 | }, func(_ context.Context, args ...any) bool { 731 | return args[0].(int) != 3 732 | }) 733 | 734 | sm.Fire(triggerX, 2) 735 | 736 | if got := sm.MustState(); got != stateB { 737 | t.Errorf("sm.MustState() = %v, want %v", got, stateB) 738 | } 739 | } 740 | 741 | func TestStateMachine_Fire_TransitionWhenGuardReturnsTrueOnTriggerWithMultipleParameters(t *testing.T) { 742 | sm := NewStateMachine(stateA) 743 | sm.SetTriggerParameters(triggerX, reflect.TypeOf(""), reflect.TypeOf(0)) 744 | sm.Configure(stateA). 745 | Permit(triggerX, stateB, func(_ context.Context, args ...any) bool { 746 | return args[0].(string) == "3" && args[1].(int) == 2 747 | }) 748 | 749 | sm.Fire(triggerX, "3", 2) 750 | 751 | if got := sm.MustState(); got != stateB { 752 | t.Errorf("sm.MustState() = %v, want %v", got, stateB) 753 | } 754 | } 755 | 756 | func TestStateMachine_Fire_TransitionWhenPermitDyanmicIfHasMultipleExclusiveGuards(t *testing.T) { 757 | sm := NewStateMachine(stateA) 758 | sm.SetTriggerParameters(triggerX, reflect.TypeOf(0)) 759 | sm.Configure(stateA). 760 | PermitDynamic(triggerX, func(_ context.Context, args ...any) (State, error) { 761 | if args[0].(int) == 3 { 762 | return stateB, nil 763 | } 764 | return stateC, nil 765 | }, func(_ context.Context, args ...any) bool { return args[0].(int) == 3 || args[0].(int) == 5 }). 766 | PermitDynamic(triggerX, func(_ context.Context, args ...any) (State, error) { 767 | if args[0].(int) == 2 { 768 | return stateC, nil 769 | } 770 | return stateD, nil 771 | }, func(_ context.Context, args ...any) bool { return args[0].(int) == 2 || args[0].(int) == 4 }) 772 | 773 | sm.Fire(triggerX, 3) 774 | 775 | if got := sm.MustState(); got != stateB { 776 | t.Errorf("sm.MustState() = %v, want %v", got, stateB) 777 | } 778 | } 779 | 780 | func TestStateMachine_Fire_PermitDyanmic_Error(t *testing.T) { 781 | sm := NewStateMachine(stateA) 782 | sm.Configure(stateA). 783 | PermitDynamic(triggerX, func(_ context.Context, _ ...any) (State, error) { 784 | return nil, errors.New("") 785 | }) 786 | 787 | if err := sm.Fire(triggerX, ""); err == nil { 788 | t.Error("error expected") 789 | } 790 | if got := sm.MustState(); got != stateA { 791 | t.Errorf("sm.MustState() = %v, want %v", got, stateA) 792 | } 793 | } 794 | 795 | func TestStateMachine_Fire_PanicsWhenPermitDyanmicIfHasMultipleNonExclusiveGuards(t *testing.T) { 796 | sm := NewStateMachine(stateA) 797 | sm.SetTriggerParameters(triggerX, reflect.TypeOf(0)) 798 | sm.Configure(stateA). 799 | PermitDynamic(triggerX, func(_ context.Context, args ...any) (State, error) { 800 | if args[0].(int) == 4 { 801 | return stateB, nil 802 | } 803 | return stateC, nil 804 | }, func(_ context.Context, args ...any) bool { return args[0].(int)%2 == 0 }). 805 | PermitDynamic(triggerX, func(_ context.Context, args ...any) (State, error) { 806 | if args[0].(int) == 2 { 807 | return stateC, nil 808 | } 809 | return stateD, nil 810 | }, func(_ context.Context, args ...any) bool { return args[0].(int) == 2 }) 811 | 812 | assertPanic(t, func() { sm.Fire(triggerX, 2) }) 813 | } 814 | 815 | func TestStateMachine_Fire_TransitionWhenPermitIfHasMultipleExclusiveGuardsWithSuperStateTrue(t *testing.T) { 816 | sm := NewStateMachine(stateB) 817 | sm.SetTriggerParameters(triggerX, reflect.TypeOf(0)) 818 | sm.Configure(stateA). 819 | Permit(triggerX, stateD, func(_ context.Context, args ...any) bool { 820 | return args[0].(int) == 3 821 | }) 822 | 823 | sm.Configure(stateB). 824 | SubstateOf(stateA). 825 | Permit(triggerX, stateC, func(_ context.Context, args ...any) bool { 826 | return args[0].(int) == 2 827 | }) 828 | 829 | sm.Fire(triggerX, 3) 830 | 831 | if got := sm.MustState(); got != stateD { 832 | t.Errorf("sm.MustState() = %v, want %v", got, stateD) 833 | } 834 | } 835 | 836 | func TestStateMachine_Fire_TransitionWhenPermitIfHasMultipleExclusiveGuardsWithSuperStateFalse(t *testing.T) { 837 | sm := NewStateMachine(stateB) 838 | sm.SetTriggerParameters(triggerX, reflect.TypeOf(0)) 839 | sm.Configure(stateA). 840 | Permit(triggerX, stateD, func(_ context.Context, args ...any) bool { 841 | return args[0].(int) == 3 842 | }) 843 | 844 | sm.Configure(stateB). 845 | SubstateOf(stateA). 846 | Permit(triggerX, stateC, func(_ context.Context, args ...any) bool { 847 | return args[0].(int) == 2 848 | }) 849 | 850 | sm.Fire(triggerX, 2) 851 | 852 | if got := sm.MustState(); got != stateC { 853 | t.Errorf("sm.MustState() = %v, want %v", got, stateC) 854 | } 855 | } 856 | 857 | func TestStateMachine_Fire_TransitionToSuperstateDoesNotExitSuperstate(t *testing.T) { 858 | sm := NewStateMachine(stateB) 859 | var superExit, superEntry, subExit bool 860 | sm.Configure(stateA). 861 | OnEntry(func(_ context.Context, _ ...any) error { 862 | superEntry = true 863 | return nil 864 | }). 865 | OnExit(func(_ context.Context, _ ...any) error { 866 | superExit = true 867 | return nil 868 | }) 869 | 870 | sm.Configure(stateB). 871 | SubstateOf(stateA). 872 | Permit(triggerY, stateA). 873 | OnExit(func(_ context.Context, _ ...any) error { 874 | subExit = true 875 | return nil 876 | }) 877 | 878 | sm.Fire(triggerY) 879 | 880 | if !subExit { 881 | t.Error("substate should exit") 882 | } 883 | if superEntry { 884 | t.Error("superstate should not enter") 885 | } 886 | if superExit { 887 | t.Error("superstate should not exit") 888 | } 889 | } 890 | 891 | func TestStateMachine_Fire_OnExitFiresOnlyOnceReentrySubstate(t *testing.T) { 892 | sm := NewStateMachine(stateA) 893 | var exitB, exitA, entryB, entryA int 894 | sm.Configure(stateA). 895 | SubstateOf(stateB). 896 | OnEntry(func(_ context.Context, _ ...any) error { 897 | entryA += 1 898 | return nil 899 | }). 900 | PermitReentry(triggerX). 901 | OnExit(func(_ context.Context, _ ...any) error { 902 | exitA += 1 903 | return nil 904 | }) 905 | 906 | sm.Configure(stateB). 907 | OnEntry(func(_ context.Context, _ ...any) error { 908 | entryB += 1 909 | return nil 910 | }). 911 | OnExit(func(_ context.Context, _ ...any) error { 912 | exitB += 1 913 | return nil 914 | }) 915 | 916 | sm.Fire(triggerX) 917 | 918 | if entryB != 0 { 919 | t.Error("entryB should be 0") 920 | } 921 | if exitB != 0 { 922 | t.Error("exitB should be 0") 923 | } 924 | if entryA != 1 { 925 | t.Error("entryA should be 1") 926 | } 927 | if exitA != 1 { 928 | t.Error("exitA should be 1") 929 | } 930 | } 931 | 932 | func TestStateMachine_Activate(t *testing.T) { 933 | sm := NewStateMachine(stateA) 934 | 935 | expectedOrdering := []string{"ActivatedC", "ActivatedA"} 936 | var actualOrdering []string 937 | 938 | sm.Configure(stateA). 939 | SubstateOf(stateC). 940 | OnActive(func(_ context.Context) error { 941 | actualOrdering = append(actualOrdering, "ActivatedA") 942 | return nil 943 | }) 944 | 945 | sm.Configure(stateC). 946 | OnActive(func(_ context.Context) error { 947 | actualOrdering = append(actualOrdering, "ActivatedC") 948 | return nil 949 | }) 950 | 951 | // should not be called for activation 952 | sm.OnTransitioning(func(_ context.Context, _ Transition) { 953 | actualOrdering = append(actualOrdering, "OnTransitioning") 954 | }) 955 | sm.OnTransitioned(func(_ context.Context, _ Transition) { 956 | actualOrdering = append(actualOrdering, "OnTransitioned") 957 | }) 958 | 959 | sm.Activate() 960 | 961 | if !reflect.DeepEqual(expectedOrdering, actualOrdering) { 962 | t.Errorf("expectedOrdering = %v, actualOrdering = %v", expectedOrdering, actualOrdering) 963 | } 964 | } 965 | 966 | func TestStateMachine_Activate_Error(t *testing.T) { 967 | sm := NewStateMachine(stateA) 968 | 969 | var actualOrdering []string 970 | 971 | sm.Configure(stateA). 972 | SubstateOf(stateC). 973 | OnActive(func(_ context.Context) error { 974 | actualOrdering = append(actualOrdering, "ActivatedA") 975 | return errors.New("") 976 | }) 977 | 978 | sm.Configure(stateC). 979 | OnActive(func(_ context.Context) error { 980 | actualOrdering = append(actualOrdering, "ActivatedC") 981 | return nil 982 | }) 983 | 984 | if err := sm.Activate(); err == nil { 985 | t.Error("error expected") 986 | } 987 | } 988 | 989 | func TestStateMachine_Activate_Idempotent(t *testing.T) { 990 | sm := NewStateMachine(stateA) 991 | 992 | var actualOrdering []string 993 | 994 | sm.Configure(stateA). 995 | SubstateOf(stateC). 996 | OnActive(func(_ context.Context) error { 997 | actualOrdering = append(actualOrdering, "ActivatedA") 998 | return nil 999 | }) 1000 | 1001 | sm.Configure(stateC). 1002 | OnActive(func(_ context.Context) error { 1003 | actualOrdering = append(actualOrdering, "ActivatedC") 1004 | return nil 1005 | }) 1006 | 1007 | sm.Activate() 1008 | 1009 | if got := len(actualOrdering); got != 2 { 1010 | t.Errorf("expected 2, got %d", got) 1011 | } 1012 | } 1013 | 1014 | func TestStateMachine_Deactivate(t *testing.T) { 1015 | sm := NewStateMachine(stateA) 1016 | 1017 | expectedOrdering := []string{"DeactivatedA", "DeactivatedC"} 1018 | var actualOrdering []string 1019 | 1020 | sm.Configure(stateA). 1021 | SubstateOf(stateC). 1022 | OnDeactivate(func(_ context.Context) error { 1023 | actualOrdering = append(actualOrdering, "DeactivatedA") 1024 | return nil 1025 | }) 1026 | 1027 | sm.Configure(stateC). 1028 | OnDeactivate(func(_ context.Context) error { 1029 | actualOrdering = append(actualOrdering, "DeactivatedC") 1030 | return nil 1031 | }) 1032 | 1033 | // should not be called for activation 1034 | sm.OnTransitioning(func(_ context.Context, _ Transition) { 1035 | actualOrdering = append(actualOrdering, "OnTransitioning") 1036 | }) 1037 | sm.OnTransitioned(func(_ context.Context, _ Transition) { 1038 | actualOrdering = append(actualOrdering, "OnTransitioned") 1039 | }) 1040 | 1041 | sm.Activate() 1042 | sm.Deactivate() 1043 | 1044 | if !reflect.DeepEqual(expectedOrdering, actualOrdering) { 1045 | t.Errorf("expectedOrdering = %v, actualOrdering = %v", expectedOrdering, actualOrdering) 1046 | } 1047 | } 1048 | 1049 | func TestStateMachine_Deactivate_NoActivated(t *testing.T) { 1050 | sm := NewStateMachine(stateA) 1051 | 1052 | var actualOrdering []string 1053 | 1054 | sm.Configure(stateA). 1055 | SubstateOf(stateC). 1056 | OnDeactivate(func(_ context.Context) error { 1057 | actualOrdering = append(actualOrdering, "DeactivatedA") 1058 | return nil 1059 | }) 1060 | 1061 | sm.Configure(stateC). 1062 | OnDeactivate(func(_ context.Context) error { 1063 | actualOrdering = append(actualOrdering, "DeactivatedC") 1064 | return nil 1065 | }) 1066 | 1067 | sm.Deactivate() 1068 | 1069 | want := []string{"DeactivatedA", "DeactivatedC"} 1070 | if !reflect.DeepEqual(want, actualOrdering) { 1071 | t.Errorf("want = %v, actualOrdering = %v", want, actualOrdering) 1072 | } 1073 | } 1074 | 1075 | func TestStateMachine_Deactivate_Error(t *testing.T) { 1076 | sm := NewStateMachine(stateA) 1077 | 1078 | var actualOrdering []string 1079 | 1080 | sm.Configure(stateA). 1081 | SubstateOf(stateC). 1082 | OnDeactivate(func(_ context.Context) error { 1083 | actualOrdering = append(actualOrdering, "DeactivatedA") 1084 | return errors.New("") 1085 | }) 1086 | 1087 | sm.Configure(stateC). 1088 | OnDeactivate(func(_ context.Context) error { 1089 | actualOrdering = append(actualOrdering, "DeactivatedC") 1090 | return nil 1091 | }) 1092 | 1093 | sm.Activate() 1094 | if err := sm.Deactivate(); err == nil { 1095 | t.Error("error expected") 1096 | } 1097 | } 1098 | 1099 | func TestStateMachine_Deactivate_Idempotent(t *testing.T) { 1100 | sm := NewStateMachine(stateA) 1101 | 1102 | var actualOrdering []string 1103 | 1104 | sm.Configure(stateA). 1105 | SubstateOf(stateC). 1106 | OnDeactivate(func(_ context.Context) error { 1107 | actualOrdering = append(actualOrdering, "DeactivatedA") 1108 | return nil 1109 | }) 1110 | 1111 | sm.Configure(stateC). 1112 | OnDeactivate(func(_ context.Context) error { 1113 | actualOrdering = append(actualOrdering, "DeactivatedC") 1114 | return nil 1115 | }) 1116 | 1117 | sm.Activate() 1118 | sm.Deactivate() 1119 | actualOrdering = make([]string, 0) 1120 | sm.Activate() 1121 | 1122 | if got := len(actualOrdering); got != 0 { 1123 | t.Errorf("expected 0, got %d", got) 1124 | } 1125 | } 1126 | 1127 | func TestStateMachine_Activate_Transitioning(t *testing.T) { 1128 | sm := NewStateMachine(stateA) 1129 | 1130 | var actualOrdering []string 1131 | expectedOrdering := []string{"ActivatedA", "ExitedA", "OnTransitioning", "EnteredB", "OnTransitioned", 1132 | "ExitedB", "OnTransitioning", "EnteredA", "OnTransitioned"} 1133 | 1134 | sm.Configure(stateA). 1135 | OnActive(func(_ context.Context) error { 1136 | actualOrdering = append(actualOrdering, "ActivatedA") 1137 | return nil 1138 | }). 1139 | OnDeactivate(func(_ context.Context) error { 1140 | actualOrdering = append(actualOrdering, "DeactivatedA") 1141 | return nil 1142 | }). 1143 | OnEntry(func(_ context.Context, _ ...any) error { 1144 | actualOrdering = append(actualOrdering, "EnteredA") 1145 | return nil 1146 | }). 1147 | OnExit(func(_ context.Context, _ ...any) error { 1148 | actualOrdering = append(actualOrdering, "ExitedA") 1149 | return nil 1150 | }). 1151 | Permit(triggerX, stateB) 1152 | 1153 | sm.Configure(stateB). 1154 | OnActive(func(_ context.Context) error { 1155 | actualOrdering = append(actualOrdering, "ActivatedB") 1156 | return nil 1157 | }). 1158 | OnDeactivate(func(_ context.Context) error { 1159 | actualOrdering = append(actualOrdering, "DeactivatedB") 1160 | return nil 1161 | }). 1162 | OnEntry(func(_ context.Context, _ ...any) error { 1163 | actualOrdering = append(actualOrdering, "EnteredB") 1164 | return nil 1165 | }). 1166 | OnExit(func(_ context.Context, _ ...any) error { 1167 | actualOrdering = append(actualOrdering, "ExitedB") 1168 | return nil 1169 | }). 1170 | Permit(triggerY, stateA) 1171 | 1172 | sm.OnTransitioning(func(_ context.Context, _ Transition) { 1173 | actualOrdering = append(actualOrdering, "OnTransitioning") 1174 | }) 1175 | sm.OnTransitioned(func(_ context.Context, _ Transition) { 1176 | actualOrdering = append(actualOrdering, "OnTransitioned") 1177 | }) 1178 | 1179 | sm.Activate() 1180 | sm.Fire(triggerX) 1181 | sm.Fire(triggerY) 1182 | 1183 | if !reflect.DeepEqual(expectedOrdering, actualOrdering) { 1184 | t.Errorf("expectedOrdering = %v, actualOrdering = %v", expectedOrdering, actualOrdering) 1185 | } 1186 | } 1187 | 1188 | func TestStateMachine_Fire_ImmediateEntryAProcessedBeforeEnterB(t *testing.T) { 1189 | sm := NewStateMachineWithMode(stateA, FiringImmediate) 1190 | 1191 | var actualOrdering []string 1192 | expectedOrdering := []string{"ExitA", "ExitB", "EnterA", "EnterB"} 1193 | 1194 | sm.Configure(stateA). 1195 | OnEntry(func(_ context.Context, _ ...any) error { 1196 | actualOrdering = append(actualOrdering, "EnterA") 1197 | return nil 1198 | }). 1199 | OnExit(func(_ context.Context, _ ...any) error { 1200 | actualOrdering = append(actualOrdering, "ExitA") 1201 | return nil 1202 | }). 1203 | Permit(triggerX, stateB) 1204 | 1205 | sm.Configure(stateB). 1206 | OnEntry(func(_ context.Context, _ ...any) error { 1207 | sm.Fire(triggerY) 1208 | actualOrdering = append(actualOrdering, "EnterB") 1209 | return nil 1210 | }). 1211 | OnExit(func(_ context.Context, _ ...any) error { 1212 | actualOrdering = append(actualOrdering, "ExitB") 1213 | return nil 1214 | }). 1215 | Permit(triggerY, stateA) 1216 | 1217 | sm.Fire(triggerX) 1218 | 1219 | if !reflect.DeepEqual(expectedOrdering, actualOrdering) { 1220 | t.Errorf("expectedOrdering = %v, actualOrdering = %v", expectedOrdering, actualOrdering) 1221 | } 1222 | } 1223 | 1224 | func TestStateMachine_Fire_QueuedEntryAProcessedBeforeEnterB(t *testing.T) { 1225 | sm := NewStateMachineWithMode(stateA, FiringQueued) 1226 | 1227 | var actualOrdering []string 1228 | expectedOrdering := []string{"ExitA", "EnterB", "ExitB", "EnterA"} 1229 | 1230 | sm.Configure(stateA). 1231 | OnEntry(func(_ context.Context, _ ...any) error { 1232 | actualOrdering = append(actualOrdering, "EnterA") 1233 | return nil 1234 | }). 1235 | OnExit(func(_ context.Context, _ ...any) error { 1236 | actualOrdering = append(actualOrdering, "ExitA") 1237 | return nil 1238 | }). 1239 | Permit(triggerX, stateB) 1240 | 1241 | sm.Configure(stateB). 1242 | OnEntry(func(_ context.Context, _ ...any) error { 1243 | sm.Fire(triggerY) 1244 | actualOrdering = append(actualOrdering, "EnterB") 1245 | return nil 1246 | }). 1247 | OnExit(func(_ context.Context, _ ...any) error { 1248 | actualOrdering = append(actualOrdering, "ExitB") 1249 | return nil 1250 | }). 1251 | Permit(triggerY, stateA) 1252 | 1253 | sm.Fire(triggerX) 1254 | 1255 | if !reflect.DeepEqual(expectedOrdering, actualOrdering) { 1256 | t.Errorf("expectedOrdering = %v, actualOrdering = %v", expectedOrdering, actualOrdering) 1257 | } 1258 | } 1259 | 1260 | func TestStateMachine_Fire_QueuedEntryAsyncFire(t *testing.T) { 1261 | sm := NewStateMachineWithMode(stateA, FiringQueued) 1262 | 1263 | sm.Configure(stateA). 1264 | Permit(triggerX, stateB) 1265 | 1266 | sm.Configure(stateB). 1267 | OnEntry(func(_ context.Context, _ ...any) error { 1268 | go sm.Fire(triggerY) 1269 | go sm.Fire(triggerY) 1270 | return nil 1271 | }). 1272 | Permit(triggerY, stateA) 1273 | 1274 | sm.Fire(triggerX) 1275 | } 1276 | 1277 | func TestStateMachine_Fire_Race(t *testing.T) { 1278 | sm := NewStateMachineWithMode(stateA, FiringImmediate) 1279 | 1280 | var actualOrdering []string 1281 | var mu sync.Mutex 1282 | sm.Configure(stateA). 1283 | OnEntry(func(_ context.Context, _ ...any) error { 1284 | mu.Lock() 1285 | actualOrdering = append(actualOrdering, "EnterA") 1286 | mu.Unlock() 1287 | return nil 1288 | }). 1289 | OnExit(func(_ context.Context, _ ...any) error { 1290 | mu.Lock() 1291 | actualOrdering = append(actualOrdering, "ExitA") 1292 | mu.Unlock() 1293 | return nil 1294 | }). 1295 | Permit(triggerX, stateB) 1296 | 1297 | sm.Configure(stateB). 1298 | OnEntry(func(_ context.Context, _ ...any) error { 1299 | sm.Fire(triggerY) 1300 | mu.Lock() 1301 | actualOrdering = append(actualOrdering, "EnterB") 1302 | mu.Unlock() 1303 | return nil 1304 | }). 1305 | OnExit(func(_ context.Context, _ ...any) error { 1306 | mu.Lock() 1307 | actualOrdering = append(actualOrdering, "ExitB") 1308 | mu.Unlock() 1309 | return nil 1310 | }). 1311 | Permit(triggerY, stateA) 1312 | 1313 | var wg sync.WaitGroup 1314 | wg.Add(2) 1315 | go func() { 1316 | sm.Fire(triggerX) 1317 | wg.Done() 1318 | }() 1319 | go func() { 1320 | sm.Fire(triggerZ) 1321 | wg.Done() 1322 | }() 1323 | wg.Wait() 1324 | if got := len(actualOrdering); got != 4 { 1325 | t.Errorf("expected 4, got %d", got) 1326 | } 1327 | } 1328 | 1329 | func TestStateMachine_Fire_Queued_ErrorExit(t *testing.T) { 1330 | sm := NewStateMachineWithMode(stateA, FiringQueued) 1331 | 1332 | sm.Configure(stateA). 1333 | Permit(triggerX, stateB) 1334 | 1335 | sm.Configure(stateB). 1336 | OnEntry(func(_ context.Context, _ ...any) error { 1337 | sm.Fire(triggerY) 1338 | return nil 1339 | }). 1340 | OnExit(func(_ context.Context, _ ...any) error { 1341 | return errors.New("") 1342 | }). 1343 | Permit(triggerY, stateA) 1344 | 1345 | sm.Fire(triggerX) 1346 | 1347 | if err := sm.Fire(triggerX); err == nil { 1348 | t.Error("expected error") 1349 | } 1350 | } 1351 | 1352 | func TestStateMachine_Fire_Queued_ErrorEnter(t *testing.T) { 1353 | sm := NewStateMachineWithMode(stateA, FiringQueued) 1354 | 1355 | sm.Configure(stateA). 1356 | OnEntry(func(_ context.Context, _ ...any) error { 1357 | return errors.New("") 1358 | }). 1359 | Permit(triggerX, stateB) 1360 | 1361 | sm.Configure(stateB). 1362 | OnEntry(func(_ context.Context, _ ...any) error { 1363 | sm.Fire(triggerY) 1364 | return nil 1365 | }). 1366 | Permit(triggerY, stateA) 1367 | 1368 | sm.Fire(triggerX) 1369 | 1370 | if err := sm.Fire(triggerX); err == nil { 1371 | t.Error("expected error") 1372 | } 1373 | } 1374 | 1375 | func TestStateMachine_InternalTransition_StayInSameStateOneState(t *testing.T) { 1376 | sm := NewStateMachine(stateA) 1377 | sm.Configure(stateB). 1378 | InternalTransition(triggerX, func(_ context.Context, _ ...any) error { 1379 | return nil 1380 | }) 1381 | 1382 | sm.Fire(triggerX) 1383 | if got := sm.MustState(); got != stateA { 1384 | t.Errorf("expected %v, got %v", stateA, got) 1385 | } 1386 | } 1387 | 1388 | func TestStateMachine_InternalTransition_HandledOnlyOnceInSuper(t *testing.T) { 1389 | sm := NewStateMachine(stateA) 1390 | handledIn := stateC 1391 | sm.Configure(stateA). 1392 | InternalTransition(triggerX, func(_ context.Context, _ ...any) error { 1393 | handledIn = stateA 1394 | return nil 1395 | }) 1396 | 1397 | sm.Configure(stateB). 1398 | SubstateOf(stateA). 1399 | InternalTransition(triggerX, func(_ context.Context, _ ...any) error { 1400 | handledIn = stateB 1401 | return nil 1402 | }) 1403 | 1404 | sm.Fire(triggerX) 1405 | if stateA != handledIn { 1406 | t.Errorf("expected %v, got %v", stateA, handledIn) 1407 | } 1408 | } 1409 | 1410 | func TestStateMachine_InternalTransition_HandledOnlyOnceInSub(t *testing.T) { 1411 | sm := NewStateMachine(stateB) 1412 | handledIn := stateC 1413 | sm.Configure(stateA). 1414 | InternalTransition(triggerX, func(_ context.Context, _ ...any) error { 1415 | handledIn = stateA 1416 | return nil 1417 | }) 1418 | 1419 | sm.Configure(stateB). 1420 | SubstateOf(stateA). 1421 | InternalTransition(triggerX, func(_ context.Context, _ ...any) error { 1422 | handledIn = stateB 1423 | return nil 1424 | }) 1425 | 1426 | sm.Fire(triggerX) 1427 | if stateB != handledIn { 1428 | t.Errorf("expected %v, got %v", stateB, handledIn) 1429 | } 1430 | } 1431 | 1432 | func TestStateMachine_InitialTransition_EntersSubState(t *testing.T) { 1433 | sm := NewStateMachine(stateA) 1434 | 1435 | sm.Configure(stateA). 1436 | Permit(triggerX, stateB) 1437 | 1438 | sm.Configure(stateB). 1439 | InitialTransition(stateC) 1440 | 1441 | sm.Configure(stateC). 1442 | SubstateOf(stateB) 1443 | 1444 | sm.Fire(triggerX) 1445 | if got := sm.MustState(); got != stateC { 1446 | t.Errorf("MustState() = %v, want %v", got, stateC) 1447 | } 1448 | } 1449 | 1450 | func TestStateMachine_InitialTransition_EntersSubStateofSubstate(t *testing.T) { 1451 | sm := NewStateMachine(stateA) 1452 | 1453 | sm.Configure(stateA). 1454 | Permit(triggerX, stateB) 1455 | 1456 | sm.Configure(stateB). 1457 | InitialTransition(stateC) 1458 | 1459 | sm.Configure(stateC). 1460 | InitialTransition(stateD). 1461 | SubstateOf(stateB) 1462 | 1463 | sm.Configure(stateD). 1464 | SubstateOf(stateC) 1465 | 1466 | sm.Fire(triggerX) 1467 | if got := sm.MustState(); got != stateD { 1468 | t.Errorf("MustState() = %v, want %v", got, stateD) 1469 | } 1470 | } 1471 | 1472 | func TestStateMachine_InitialTransition_Ordering(t *testing.T) { 1473 | var actualOrdering []string 1474 | expectedOrdering := []string{"ExitA", "OnTransitioningAB", "EnterB", "OnTransitioningBC", "EnterC", "OnTransitionedAC"} 1475 | 1476 | sm := NewStateMachine(stateA) 1477 | 1478 | sm.Configure(stateA). 1479 | Permit(triggerX, stateB). 1480 | OnExit(func(c context.Context, i ...any) error { 1481 | actualOrdering = append(actualOrdering, "ExitA") 1482 | return nil 1483 | }) 1484 | 1485 | sm.Configure(stateB). 1486 | InitialTransition(stateC). 1487 | OnEntry(func(c context.Context, i ...any) error { 1488 | actualOrdering = append(actualOrdering, "EnterB") 1489 | return nil 1490 | }) 1491 | 1492 | sm.Configure(stateC). 1493 | SubstateOf(stateB). 1494 | OnEntry(func(c context.Context, i ...any) error { 1495 | actualOrdering = append(actualOrdering, "EnterC") 1496 | return nil 1497 | }) 1498 | 1499 | sm.OnTransitioning(func(_ context.Context, tr Transition) { 1500 | actualOrdering = append(actualOrdering, fmt.Sprintf("OnTransitioning%v%v", tr.Source, tr.Destination)) 1501 | }) 1502 | sm.OnTransitioned(func(_ context.Context, tr Transition) { 1503 | actualOrdering = append(actualOrdering, fmt.Sprintf("OnTransitioned%v%v", tr.Source, tr.Destination)) 1504 | }) 1505 | 1506 | sm.Fire(triggerX) 1507 | if got := sm.MustState(); got != stateC { 1508 | t.Errorf("MustState() = %v, want %v", got, stateC) 1509 | } 1510 | 1511 | if !reflect.DeepEqual(expectedOrdering, actualOrdering) { 1512 | t.Errorf("expected %v, got %v", expectedOrdering, actualOrdering) 1513 | } 1514 | } 1515 | 1516 | func TestStateMachine_InitialTransition_DoesNotEnterSubStateofSubstate(t *testing.T) { 1517 | sm := NewStateMachine(stateA) 1518 | 1519 | sm.Configure(stateA). 1520 | Permit(triggerX, stateB) 1521 | 1522 | sm.Configure(stateB). 1523 | sm.Configure(stateC). 1524 | InitialTransition(stateD). 1525 | SubstateOf(stateB) 1526 | 1527 | sm.Configure(stateD). 1528 | SubstateOf(stateC) 1529 | 1530 | sm.Fire(triggerX) 1531 | if got := sm.MustState(); got != stateB { 1532 | t.Errorf("MustState() = %v, want %v", got, stateB) 1533 | } 1534 | } 1535 | 1536 | func TestStateMachine_InitialTransition_DoNotAllowTransitionToSelf(t *testing.T) { 1537 | sm := NewStateMachine(stateA) 1538 | assertPanic(t, func() { 1539 | sm.Configure(stateA). 1540 | InitialTransition(stateA) 1541 | }) 1542 | } 1543 | 1544 | func TestStateMachine_InitialTransition_WithMultipleSubStates(t *testing.T) { 1545 | sm := NewStateMachine(stateA) 1546 | sm.Configure(stateA).Permit(triggerX, stateB) 1547 | sm.Configure(stateB).InitialTransition(stateC) 1548 | sm.Configure(stateC).SubstateOf(stateB) 1549 | sm.Configure(stateD).SubstateOf(stateB) 1550 | if err := sm.Fire(triggerX); err != nil { 1551 | t.Error(err) 1552 | } 1553 | } 1554 | 1555 | func TestStateMachine_InitialTransition_DoNotAllowTransitionToAnotherSuperstate(t *testing.T) { 1556 | sm := NewStateMachine(stateA) 1557 | 1558 | sm.Configure(stateA). 1559 | Permit(triggerX, stateB) 1560 | 1561 | sm.Configure(stateB). 1562 | InitialTransition(stateA) 1563 | 1564 | assertPanic(t, func() { sm.Fire(triggerX) }) 1565 | } 1566 | 1567 | func TestStateMachine_InitialTransition_DoNotAllowMoreThanOneInitialTransition(t *testing.T) { 1568 | sm := NewStateMachine(stateA) 1569 | 1570 | sm.Configure(stateA). 1571 | Permit(triggerX, stateB) 1572 | 1573 | sm.Configure(stateB). 1574 | InitialTransition(stateC) 1575 | 1576 | assertPanic(t, func() { sm.Configure(stateB).InitialTransition(stateA) }) 1577 | } 1578 | 1579 | func TestStateMachine_String(t *testing.T) { 1580 | tests := []struct { 1581 | name string 1582 | sm *StateMachine 1583 | want string 1584 | }{ 1585 | {"noTriggers", NewStateMachine(stateA), "StateMachine {{ State = A, PermittedTriggers = [] }}"}, 1586 | {"error state", NewStateMachineWithExternalStorage(func(_ context.Context) (State, error) { 1587 | return nil, errors.New("status error") 1588 | }, func(_ context.Context, s State) error { return nil }, FiringImmediate), ""}, 1589 | {"triggers", NewStateMachine(stateB).Configure(stateB).Permit(triggerX, stateA).Machine(), 1590 | "StateMachine {{ State = B, PermittedTriggers = [X] }}"}, 1591 | } 1592 | for _, tt := range tests { 1593 | t.Run(tt.name, func(t *testing.T) { 1594 | if got := tt.sm.String(); got != tt.want { 1595 | t.Errorf("StateMachine.String() = %v, want %v", got, tt.want) 1596 | } 1597 | }) 1598 | } 1599 | } 1600 | 1601 | func TestStateMachine_String_Concurrent(t *testing.T) { 1602 | // Test that race mode doesn't complain about concurrent access to the state machine. 1603 | sm := NewStateMachine(stateA) 1604 | const n = 10 1605 | var wg sync.WaitGroup 1606 | wg.Add(n) 1607 | for i := 0; i < n; i++ { 1608 | go func() { 1609 | defer wg.Done() 1610 | _ = sm.String() 1611 | }() 1612 | } 1613 | wg.Wait() 1614 | } 1615 | 1616 | func TestStateMachine_Firing_Queued(t *testing.T) { 1617 | sm := NewStateMachine(stateA) 1618 | 1619 | sm.Configure(stateA). 1620 | Permit(triggerX, stateB) 1621 | 1622 | sm.Configure(stateB). 1623 | OnEntry(func(ctx context.Context, i ...any) error { 1624 | if !sm.Firing() { 1625 | t.Error("expected firing to be true") 1626 | } 1627 | return nil 1628 | }) 1629 | if err := sm.Fire(triggerX); err != nil { 1630 | t.Error(err) 1631 | } 1632 | if sm.Firing() { 1633 | t.Error("expected firing to be false") 1634 | } 1635 | } 1636 | 1637 | func TestStateMachine_Firing_Immediate(t *testing.T) { 1638 | sm := NewStateMachineWithMode(stateA, FiringImmediate) 1639 | 1640 | sm.Configure(stateA). 1641 | Permit(triggerX, stateB) 1642 | 1643 | sm.Configure(stateB). 1644 | OnEntry(func(ctx context.Context, i ...any) error { 1645 | if !sm.Firing() { 1646 | t.Error("expected firing to be true") 1647 | } 1648 | return nil 1649 | }) 1650 | if err := sm.Fire(triggerX); err != nil { 1651 | t.Error(err) 1652 | } 1653 | if sm.Firing() { 1654 | t.Error("expected firing to be false") 1655 | } 1656 | } 1657 | 1658 | func TestStateMachine_Firing_Concurrent(t *testing.T) { 1659 | sm := NewStateMachine(stateA) 1660 | 1661 | sm.Configure(stateA). 1662 | PermitReentry(triggerX). 1663 | OnEntry(func(ctx context.Context, i ...any) error { 1664 | if !sm.Firing() { 1665 | t.Error("expected firing to be true") 1666 | } 1667 | return nil 1668 | }) 1669 | 1670 | var wg sync.WaitGroup 1671 | wg.Add(1000) 1672 | for i := 0; i < 1000; i++ { 1673 | go func() { 1674 | if err := sm.Fire(triggerX); err != nil { 1675 | t.Error(err) 1676 | } 1677 | wg.Done() 1678 | }() 1679 | } 1680 | wg.Wait() 1681 | if sm.Firing() { 1682 | t.Error("expected firing to be false") 1683 | } 1684 | } 1685 | 1686 | func TestGetTransition_ContextEmpty(t *testing.T) { 1687 | // It should not panic 1688 | GetTransition(context.Background()) 1689 | } 1690 | 1691 | func assertPanic(t *testing.T, f func()) { 1692 | t.Helper() 1693 | defer func() { 1694 | if r := recover(); r == nil { 1695 | t.Errorf("did not panic") 1696 | } 1697 | }() 1698 | f() 1699 | } 1700 | 1701 | func TestStateMachineWhenInSubstate_TriggerSuperStateTwiceToSameSubstate_DoesNotReenterSubstate(t *testing.T) { 1702 | sm := NewStateMachine(stateA) 1703 | var eCount = 0 1704 | 1705 | sm.Configure(stateB). 1706 | OnEntry(func(_ context.Context, _ ...any) error { 1707 | eCount++ 1708 | return nil 1709 | }). 1710 | SubstateOf(stateC) 1711 | 1712 | sm.Configure(stateA). 1713 | SubstateOf(stateC) 1714 | 1715 | sm.Configure(stateC). 1716 | Permit(triggerX, stateB) 1717 | 1718 | sm.Fire(triggerX) 1719 | sm.Fire(triggerX) 1720 | 1721 | if eCount != 1 { 1722 | t.Errorf("expected 1, got %d", eCount) 1723 | } 1724 | } 1725 | -------------------------------------------------------------------------------- /states.go: -------------------------------------------------------------------------------- 1 | package stateless 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | type actionBehaviour struct { 9 | Action ActionFunc 10 | Description invocationInfo 11 | Trigger *Trigger 12 | } 13 | 14 | func (a actionBehaviour) Execute(ctx context.Context, transition Transition, args ...any) (err error) { 15 | if a.Trigger == nil || *a.Trigger == transition.Trigger { 16 | ctx = withTransition(ctx, transition) 17 | err = a.Action(ctx, args...) 18 | } 19 | return 20 | } 21 | 22 | type actionBehaviourSteady struct { 23 | Action func(ctx context.Context) error 24 | Description invocationInfo 25 | } 26 | 27 | func (a actionBehaviourSteady) Execute(ctx context.Context) error { 28 | return a.Action(ctx) 29 | } 30 | 31 | type stateRepresentation struct { 32 | State State 33 | InitialTransitionTarget State 34 | Superstate *stateRepresentation 35 | EntryActions []actionBehaviour 36 | ExitActions []actionBehaviour 37 | ActivateActions []actionBehaviourSteady 38 | DeactivateActions []actionBehaviourSteady 39 | Substates []*stateRepresentation 40 | TriggerBehaviours map[Trigger][]triggerBehaviour 41 | HasInitialState bool 42 | } 43 | 44 | func newstateRepresentation(state State) *stateRepresentation { 45 | return &stateRepresentation{ 46 | State: state, 47 | TriggerBehaviours: make(map[Trigger][]triggerBehaviour), 48 | } 49 | } 50 | 51 | func (sr *stateRepresentation) SetInitialTransition(state State) { 52 | sr.InitialTransitionTarget = state 53 | sr.HasInitialState = true 54 | } 55 | 56 | func (sr *stateRepresentation) state() State { 57 | return sr.State 58 | } 59 | 60 | func (sr *stateRepresentation) CanHandle(ctx context.Context, trigger Trigger, args ...any) (ok bool) { 61 | _, ok = sr.FindHandler(ctx, trigger, args...) 62 | return 63 | } 64 | 65 | func (sr *stateRepresentation) FindHandler(ctx context.Context, trigger Trigger, args ...any) (handler triggerBehaviourResult, ok bool) { 66 | handler, ok = sr.findHandler(ctx, trigger, args...) 67 | if ok || sr.Superstate == nil { 68 | return 69 | } 70 | handler, ok = sr.Superstate.FindHandler(ctx, trigger, args...) 71 | return 72 | } 73 | 74 | func (sr *stateRepresentation) findHandler(ctx context.Context, trigger Trigger, args ...any) (result triggerBehaviourResult, ok bool) { 75 | possibleBehaviours, ok := sr.TriggerBehaviours[trigger] 76 | if !ok { 77 | return 78 | } 79 | var unmet []string 80 | for _, behaviour := range possibleBehaviours { 81 | unmet = behaviour.UnmetGuardConditions(ctx, unmet[:0], args...) 82 | if len(unmet) == 0 { 83 | if result.Handler != nil && len(result.UnmetGuardConditions) == 0 { 84 | panic(fmt.Sprintf("stateless: Multiple permitted exit transitions are configured from state '%v' for trigger '%v'. Guard clauses must be mutually exclusive.", sr.State, trigger)) 85 | } 86 | result.Handler = behaviour 87 | result.UnmetGuardConditions = nil 88 | } else if result.Handler == nil { 89 | result.Handler = behaviour 90 | result.UnmetGuardConditions = make([]string, len(unmet)) 91 | copy(result.UnmetGuardConditions, unmet) 92 | } 93 | } 94 | return result, result.Handler != nil && len(result.UnmetGuardConditions) == 0 95 | } 96 | 97 | func (sr *stateRepresentation) Activate(ctx context.Context) error { 98 | if sr.Superstate != nil { 99 | if err := sr.Superstate.Activate(ctx); err != nil { 100 | return err 101 | } 102 | } 103 | return sr.executeActivationActions(ctx) 104 | } 105 | 106 | func (sr *stateRepresentation) Deactivate(ctx context.Context) error { 107 | if err := sr.executeDeactivationActions(ctx); err != nil { 108 | return err 109 | } 110 | if sr.Superstate != nil { 111 | return sr.Superstate.Deactivate(ctx) 112 | } 113 | return nil 114 | } 115 | 116 | func (sr *stateRepresentation) Enter(ctx context.Context, transition Transition, args ...any) error { 117 | if transition.IsReentry() { 118 | return sr.executeEntryActions(ctx, transition, args...) 119 | } 120 | if sr.IncludeState(transition.Source) { 121 | return nil 122 | } 123 | if sr.Superstate != nil && !transition.isInitial { 124 | if err := sr.Superstate.Enter(ctx, transition, args...); err != nil { 125 | return err 126 | } 127 | } 128 | return sr.executeEntryActions(ctx, transition, args...) 129 | } 130 | 131 | func (sr *stateRepresentation) Exit(ctx context.Context, transition Transition, args ...any) (err error) { 132 | isReentry := transition.IsReentry() 133 | if !isReentry && sr.IncludeState(transition.Destination) { 134 | return 135 | } 136 | 137 | err = sr.executeExitActions(ctx, transition, args...) 138 | // Must check if there is a superstate, and if we are leaving that superstate 139 | if err == nil && !isReentry && sr.Superstate != nil { 140 | // Check if destination is within the state list 141 | if sr.IsIncludedInState(transition.Destination) { 142 | // Destination state is within the list, exit first superstate only if it is NOT the the first 143 | if sr.Superstate.state() != transition.Destination { 144 | err = sr.Superstate.Exit(ctx, transition, args...) 145 | } 146 | } else { 147 | // Exit the superstate as well 148 | err = sr.Superstate.Exit(ctx, transition, args...) 149 | } 150 | } 151 | return 152 | } 153 | 154 | func (sr *stateRepresentation) InternalAction(ctx context.Context, transition Transition, args ...any) error { 155 | var internalTransition *internalTriggerBehaviour 156 | var stateRep *stateRepresentation = sr 157 | for stateRep != nil { 158 | if result, ok := stateRep.findHandler(ctx, transition.Trigger, args...); ok { 159 | switch t := result.Handler.(type) { 160 | case *internalTriggerBehaviour: 161 | internalTransition = t 162 | } 163 | break 164 | } 165 | stateRep = stateRep.Superstate 166 | } 167 | if internalTransition == nil { 168 | panic("stateless: The configuration is incorrect, no action assigned to this internal transition.") 169 | } 170 | return internalTransition.Execute(ctx, transition, args...) 171 | } 172 | 173 | func (sr *stateRepresentation) IncludeState(state State) bool { 174 | if state == sr.State { 175 | return true 176 | } 177 | for _, substate := range sr.Substates { 178 | if substate.IncludeState(state) { 179 | return true 180 | } 181 | } 182 | return false 183 | } 184 | 185 | func (sr *stateRepresentation) IsIncludedInState(state State) bool { 186 | if state == sr.State { 187 | return true 188 | } 189 | if sr.Superstate != nil { 190 | return sr.Superstate.IsIncludedInState(state) 191 | } 192 | return false 193 | } 194 | 195 | func (sr *stateRepresentation) AddTriggerBehaviour(tb triggerBehaviour) { 196 | trigger := tb.GetTrigger() 197 | sr.TriggerBehaviours[trigger] = append(sr.TriggerBehaviours[trigger], tb) 198 | 199 | } 200 | 201 | func (sr *stateRepresentation) PermittedTriggers(ctx context.Context, args ...any) (triggers []Trigger) { 202 | var unmet []string 203 | for key, value := range sr.TriggerBehaviours { 204 | for _, tb := range value { 205 | if len(tb.UnmetGuardConditions(ctx, unmet[:0], args...)) == 0 { 206 | triggers = append(triggers, key) 207 | break 208 | } 209 | } 210 | } 211 | if sr.Superstate != nil { 212 | triggers = append(triggers, sr.Superstate.PermittedTriggers(ctx, args...)...) 213 | // remove duplicated 214 | seen := make(map[Trigger]struct{}, len(triggers)) 215 | j := 0 216 | for _, v := range triggers { 217 | if _, ok := seen[v]; ok { 218 | continue 219 | } 220 | seen[v] = struct{}{} 221 | triggers[j] = v 222 | j++ 223 | } 224 | triggers = triggers[:j] 225 | } 226 | return 227 | } 228 | 229 | func (sr *stateRepresentation) executeActivationActions(ctx context.Context) error { 230 | for _, a := range sr.ActivateActions { 231 | if err := a.Execute(ctx); err != nil { 232 | return err 233 | } 234 | } 235 | return nil 236 | } 237 | 238 | func (sr *stateRepresentation) executeDeactivationActions(ctx context.Context) error { 239 | for _, a := range sr.DeactivateActions { 240 | if err := a.Execute(ctx); err != nil { 241 | return err 242 | } 243 | } 244 | return nil 245 | } 246 | 247 | func (sr *stateRepresentation) executeEntryActions(ctx context.Context, transition Transition, args ...any) error { 248 | for _, a := range sr.EntryActions { 249 | if err := a.Execute(ctx, transition, args...); err != nil { 250 | return err 251 | } 252 | } 253 | return nil 254 | } 255 | 256 | func (sr *stateRepresentation) executeExitActions(ctx context.Context, transition Transition, args ...any) error { 257 | for _, a := range sr.ExitActions { 258 | if err := a.Execute(ctx, transition, args...); err != nil { 259 | return err 260 | } 261 | } 262 | return nil 263 | } 264 | -------------------------------------------------------------------------------- /states_test.go: -------------------------------------------------------------------------------- 1 | package stateless 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "reflect" 7 | "testing" 8 | ) 9 | 10 | func createSuperSubstatePair() (*stateRepresentation, *stateRepresentation) { 11 | super := newstateRepresentation(stateA) 12 | sub := newstateRepresentation(stateB) 13 | super.Substates = append(super.Substates, sub) 14 | sub.Superstate = super 15 | return super, sub 16 | } 17 | 18 | func Test_stateRepresentation_Includes_SameState(t *testing.T) { 19 | sr := newstateRepresentation(stateB) 20 | if !sr.IncludeState(stateB) { 21 | t.Fail() 22 | } 23 | } 24 | 25 | func Test_stateRepresentation_Includes_Substate(t *testing.T) { 26 | sr := newstateRepresentation(stateB) 27 | sr.Substates = append(sr.Substates, newstateRepresentation(stateC)) 28 | if !sr.IncludeState(stateC) { 29 | t.Fail() 30 | } 31 | } 32 | 33 | func Test_stateRepresentation_Includes_UnrelatedState(t *testing.T) { 34 | sr := newstateRepresentation(stateB) 35 | if sr.IncludeState(stateC) { 36 | t.Fail() 37 | } 38 | } 39 | 40 | func Test_stateRepresentation_Includes_Superstate(t *testing.T) { 41 | sr := newstateRepresentation(stateB) 42 | sr.Superstate = newstateRepresentation(stateC) 43 | if sr.IncludeState(stateC) { 44 | t.Fail() 45 | } 46 | } 47 | 48 | func Test_stateRepresentation_IsIncludedInState_SameState(t *testing.T) { 49 | sr := newstateRepresentation(stateB) 50 | if !sr.IsIncludedInState(stateB) { 51 | t.Fail() 52 | } 53 | } 54 | 55 | func Test_stateRepresentation_IsIncludedInState_Substate(t *testing.T) { 56 | sr := newstateRepresentation(stateB) 57 | sr.Substates = append(sr.Substates, newstateRepresentation(stateC)) 58 | if sr.IsIncludedInState(stateC) { 59 | t.Fail() 60 | } 61 | } 62 | 63 | func Test_stateRepresentation_IsIncludedInState_UnrelatedState(t *testing.T) { 64 | sr := newstateRepresentation(stateB) 65 | if sr.IsIncludedInState(stateC) { 66 | t.Fail() 67 | } 68 | } 69 | 70 | func Test_stateRepresentation_IsIncludedInState_Superstate(t *testing.T) { 71 | sr := newstateRepresentation(stateB) 72 | if sr.IsIncludedInState(stateC) { 73 | t.Fail() 74 | } 75 | } 76 | 77 | func Test_stateRepresentation_CanHandle_TransitionExists_TriggerCannotBeFired(t *testing.T) { 78 | sr := newstateRepresentation(stateB) 79 | if sr.CanHandle(context.Background(), triggerX) { 80 | t.Fail() 81 | } 82 | } 83 | 84 | func Test_stateRepresentation_CanHandle_TransitionDoesNotExist_TriggerCanBeFired(t *testing.T) { 85 | sr := newstateRepresentation(stateB) 86 | sr.AddTriggerBehaviour(&ignoredTriggerBehaviour{baseTriggerBehaviour: baseTriggerBehaviour{Trigger: triggerX}}) 87 | if !sr.CanHandle(context.Background(), triggerX) { 88 | t.Fail() 89 | } 90 | } 91 | 92 | func Test_stateRepresentation_CanHandle_TransitionExistsInSupersate_TriggerCanBeFired(t *testing.T) { 93 | super, sub := createSuperSubstatePair() 94 | super.AddTriggerBehaviour(&ignoredTriggerBehaviour{baseTriggerBehaviour: baseTriggerBehaviour{Trigger: triggerX}}) 95 | if !sub.CanHandle(context.Background(), triggerX) { 96 | t.Fail() 97 | } 98 | } 99 | 100 | func Test_stateRepresentation_CanHandle_TransitionUnmetGuardConditions_TriggerCannotBeFired(t *testing.T) { 101 | sr := newstateRepresentation(stateB) 102 | sr.AddTriggerBehaviour(&transitioningTriggerBehaviour{baseTriggerBehaviour: baseTriggerBehaviour{ 103 | Trigger: triggerX, 104 | Guard: newtransitionGuard(func(_ context.Context, _ ...any) bool { 105 | return true 106 | }, func(_ context.Context, _ ...any) bool { 107 | return false 108 | }), 109 | }, Destination: stateC}) 110 | if sr.CanHandle(context.Background(), triggerX) { 111 | t.Fail() 112 | } 113 | } 114 | 115 | func Test_stateRepresentation_CanHandle_TransitionGuardConditionsMet_TriggerCanBeFired(t *testing.T) { 116 | sr := newstateRepresentation(stateB) 117 | sr.AddTriggerBehaviour(&transitioningTriggerBehaviour{baseTriggerBehaviour: baseTriggerBehaviour{ 118 | Trigger: triggerX, 119 | Guard: newtransitionGuard(func(_ context.Context, _ ...any) bool { 120 | return true 121 | }, func(_ context.Context, _ ...any) bool { 122 | return true 123 | }), 124 | }, Destination: stateC}) 125 | if !sr.CanHandle(context.Background(), triggerX) { 126 | t.Fail() 127 | } 128 | } 129 | 130 | func Test_stateRepresentation_FindHandler_TransitionExistAndSuperstateUnmetGuardConditions_FireNotPossible(t *testing.T) { 131 | super, sub := createSuperSubstatePair() 132 | super.AddTriggerBehaviour(&transitioningTriggerBehaviour{baseTriggerBehaviour: baseTriggerBehaviour{ 133 | Trigger: triggerX, 134 | Guard: newtransitionGuard(func(_ context.Context, _ ...any) bool { 135 | return true 136 | }, func(_ context.Context, _ ...any) bool { 137 | return false 138 | }), 139 | }, Destination: stateC}) 140 | handler, ok := sub.FindHandler(context.Background(), triggerX) 141 | if ok { 142 | t.Fail() 143 | } 144 | if sub.CanHandle(context.Background(), triggerX) { 145 | t.Fail() 146 | } 147 | if super.CanHandle(context.Background(), triggerX) { 148 | t.Fail() 149 | } 150 | if handler.Handler.GuardConditionMet(context.Background()) { 151 | t.Fail() 152 | } 153 | } 154 | 155 | func Test_stateRepresentation_FindHandler_TransitionExistSuperstateMetGuardConditions_CanBeFired(t *testing.T) { 156 | super, sub := createSuperSubstatePair() 157 | super.AddTriggerBehaviour(&transitioningTriggerBehaviour{baseTriggerBehaviour: baseTriggerBehaviour{ 158 | Trigger: triggerX, 159 | Guard: newtransitionGuard(func(_ context.Context, _ ...any) bool { 160 | return true 161 | }, func(_ context.Context, _ ...any) bool { 162 | return true 163 | }), 164 | }, Destination: stateC}) 165 | handler, ok := sub.FindHandler(context.Background(), triggerX) 166 | if !ok { 167 | t.Fail() 168 | } 169 | if !sub.CanHandle(context.Background(), triggerX) { 170 | t.Fail() 171 | } 172 | if !super.CanHandle(context.Background(), triggerX) { 173 | t.Fail() 174 | } 175 | if !handler.Handler.GuardConditionMet(context.Background()) { 176 | t.Error("expected guard condition to be met") 177 | } 178 | if len(handler.UnmetGuardConditions) != 0 { 179 | t.Error("expected no unmet guard conditions") 180 | } 181 | } 182 | 183 | func Test_stateRepresentation_Enter_EnteringActionsExecuted(t *testing.T) { 184 | sr := newstateRepresentation(stateB) 185 | transition := Transition{Source: stateA, Destination: stateB, Trigger: triggerX} 186 | var actualTransition Transition 187 | sr.EntryActions = append(sr.EntryActions, actionBehaviour{ 188 | Action: func(_ context.Context, _ ...any) error { 189 | actualTransition = transition 190 | return nil 191 | }, 192 | }) 193 | if err := sr.Enter(context.Background(), transition); err != nil { 194 | t.Error(err) 195 | } 196 | if !reflect.DeepEqual(transition, actualTransition) { 197 | t.Error("expected transition to be passed to action") 198 | } 199 | } 200 | 201 | func Test_stateRepresentation_Enter_EnteringActionsExecuted_Error(t *testing.T) { 202 | sr := newstateRepresentation(stateB) 203 | transition := Transition{Source: stateA, Destination: stateB, Trigger: triggerX} 204 | var actualTransition Transition 205 | sr.EntryActions = append(sr.EntryActions, actionBehaviour{ 206 | Action: func(_ context.Context, _ ...any) error { 207 | return errors.New("") 208 | }, 209 | }) 210 | if err := sr.Enter(context.Background(), transition); err == nil { 211 | t.Error("error expected") 212 | } 213 | if reflect.DeepEqual(transition, actualTransition) { 214 | t.Error("transition should not be passed to action") 215 | } 216 | } 217 | 218 | func Test_stateRepresentation_Enter_LeavingActionsNotExecuted(t *testing.T) { 219 | sr := newstateRepresentation(stateA) 220 | transition := Transition{Source: stateA, Destination: stateB, Trigger: triggerX} 221 | var actualTransition Transition 222 | sr.ExitActions = append(sr.ExitActions, actionBehaviour{ 223 | Action: func(_ context.Context, _ ...any) error { 224 | actualTransition = transition 225 | return nil 226 | }, 227 | }) 228 | sr.Enter(context.Background(), transition) 229 | if actualTransition != (Transition{}) { 230 | t.Error("expected transition to not be passed to action") 231 | } 232 | } 233 | 234 | func Test_stateRepresentation_Enter_FromSubToSuperstate_SubstateEntryActionsExecuted(t *testing.T) { 235 | super, sub := createSuperSubstatePair() 236 | executed := false 237 | sub.EntryActions = append(sub.EntryActions, actionBehaviour{ 238 | Action: func(_ context.Context, _ ...any) error { 239 | executed = true 240 | return nil 241 | }, 242 | }) 243 | transition := Transition{Source: super.State, Destination: sub.State, Trigger: triggerX} 244 | sub.Enter(context.Background(), transition) 245 | if !executed { 246 | t.Error("expected substate entry actions to be executed") 247 | } 248 | } 249 | 250 | func Test_stateRepresentation_Enter_SuperFromSubstate_SuperEntryActionsNotExecuted(t *testing.T) { 251 | super, sub := createSuperSubstatePair() 252 | executed := false 253 | super.EntryActions = append(super.EntryActions, actionBehaviour{ 254 | Action: func(_ context.Context, _ ...any) error { 255 | executed = true 256 | return nil 257 | }, 258 | }) 259 | transition := Transition{Source: super.State, Destination: sub.State, Trigger: triggerX} 260 | sub.Enter(context.Background(), transition) 261 | if executed { 262 | t.Error("expected superstate entry actions not to be executed") 263 | } 264 | } 265 | 266 | func Test_stateRepresentation_Enter_Substate_SuperEntryActionsExecuted(t *testing.T) { 267 | super, sub := createSuperSubstatePair() 268 | executed := false 269 | super.EntryActions = append(super.EntryActions, actionBehaviour{ 270 | Action: func(_ context.Context, _ ...any) error { 271 | executed = true 272 | return nil 273 | }, 274 | }) 275 | transition := Transition{Source: stateC, Destination: sub.State, Trigger: triggerX} 276 | sub.Enter(context.Background(), transition) 277 | if !executed { 278 | t.Error("expected superstate entry actions to be executed") 279 | } 280 | } 281 | 282 | func Test_stateRepresentation_Enter_ActionsExecuteInOrder(t *testing.T) { 283 | var actual []int 284 | sr := newstateRepresentation(stateB) 285 | sr.EntryActions = append(sr.EntryActions, actionBehaviour{ 286 | Action: func(_ context.Context, _ ...any) error { 287 | actual = append(actual, 0) 288 | return nil 289 | }, 290 | }) 291 | sr.EntryActions = append(sr.EntryActions, actionBehaviour{ 292 | Action: func(_ context.Context, _ ...any) error { 293 | actual = append(actual, 1) 294 | return nil 295 | }, 296 | }) 297 | transition := Transition{Source: stateA, Destination: stateB, Trigger: triggerX} 298 | sr.Enter(context.Background(), transition) 299 | want := []int{0, 1} 300 | if !reflect.DeepEqual(actual, want) { 301 | t.Errorf("expected %v, got %v", want, actual) 302 | } 303 | } 304 | 305 | func Test_stateRepresentation_Enter_Substate_SuperstateEntryActionsExecuteBeforeSubstate(t *testing.T) { 306 | super, sub := createSuperSubstatePair() 307 | var order, subOrder, superOrder int 308 | super.EntryActions = append(super.EntryActions, actionBehaviour{ 309 | Action: func(_ context.Context, _ ...any) error { 310 | order += 1 311 | superOrder = order 312 | return nil 313 | }, 314 | }) 315 | sub.EntryActions = append(sub.EntryActions, actionBehaviour{ 316 | Action: func(_ context.Context, _ ...any) error { 317 | order += 1 318 | subOrder = order 319 | return nil 320 | }, 321 | }) 322 | transition := Transition{Source: stateC, Destination: sub.State, Trigger: triggerX} 323 | sub.Enter(context.Background(), transition) 324 | if superOrder >= subOrder { 325 | t.Error("expected superstate entry actions to execute before substate entry actions") 326 | } 327 | } 328 | 329 | func Test_stateRepresentation_Exit_EnteringActionsNotExecuted(t *testing.T) { 330 | sr := newstateRepresentation(stateB) 331 | transition := Transition{Source: stateA, Destination: stateB, Trigger: triggerX} 332 | var actualTransition Transition 333 | sr.EntryActions = append(sr.EntryActions, actionBehaviour{ 334 | Action: func(_ context.Context, _ ...any) error { 335 | actualTransition = transition 336 | return nil 337 | }, 338 | }) 339 | sr.Exit(context.Background(), transition) 340 | if actualTransition != (Transition{}) { 341 | t.Error("expected transition to not be passed to action") 342 | } 343 | } 344 | 345 | func Test_stateRepresentation_Exit_LeavingActionsExecuted(t *testing.T) { 346 | sr := newstateRepresentation(stateA) 347 | transition := Transition{Source: stateA, Destination: stateB, Trigger: triggerX} 348 | var actualTransition Transition 349 | sr.ExitActions = append(sr.ExitActions, actionBehaviour{ 350 | Action: func(_ context.Context, _ ...any) error { 351 | actualTransition = transition 352 | return nil 353 | }, 354 | }) 355 | if err := sr.Exit(context.Background(), transition); err != nil { 356 | t.Error(err) 357 | } 358 | if actualTransition != transition { 359 | t.Error("expected transition to be passed to leaving actions") 360 | } 361 | } 362 | 363 | func Test_stateRepresentation_Exit_LeavingActionsExecuted_Error(t *testing.T) { 364 | sr := newstateRepresentation(stateA) 365 | transition := Transition{Source: stateA, Destination: stateB, Trigger: triggerX} 366 | var actualTransition Transition 367 | sr.ExitActions = append(sr.ExitActions, actionBehaviour{ 368 | Action: func(_ context.Context, _ ...any) error { 369 | return errors.New("") 370 | }, 371 | }) 372 | if err := sr.Exit(context.Background(), transition); err == nil { 373 | t.Error("expected error") 374 | } 375 | if actualTransition == transition { 376 | t.Error("expected transition to not be passed to leaving actions") 377 | } 378 | } 379 | 380 | func Test_stateRepresentation_Exit_FromSubToSuperstate_SubstateExitActionsExecuted(t *testing.T) { 381 | super, sub := createSuperSubstatePair() 382 | executed := false 383 | sub.ExitActions = append(sub.ExitActions, actionBehaviour{ 384 | Action: func(_ context.Context, _ ...any) error { 385 | executed = true 386 | return nil 387 | }, 388 | }) 389 | transition := Transition{Source: sub.State, Destination: super.State, Trigger: triggerX} 390 | sub.Exit(context.Background(), transition) 391 | if !executed { 392 | t.Error("expected substate exit actions to be executed") 393 | } 394 | } 395 | 396 | func Test_stateRepresentation_Exit_FromSubToOther_SuperstateExitActionsExecuted(t *testing.T) { 397 | super, sub := createSuperSubstatePair() 398 | supersuper := newstateRepresentation(stateC) 399 | super.Superstate = supersuper 400 | supersuper.Superstate = newstateRepresentation(stateD) 401 | executed := false 402 | super.ExitActions = append(super.ExitActions, actionBehaviour{ 403 | Action: func(_ context.Context, _ ...any) error { 404 | executed = true 405 | return nil 406 | }, 407 | }) 408 | transition := Transition{Source: sub.State, Destination: stateD, Trigger: triggerX} 409 | sub.Exit(context.Background(), transition) 410 | if !executed { 411 | t.Error("expected superstate exit actions to be executed") 412 | } 413 | } 414 | 415 | func Test_stateRepresentation_Exit_FromSuperToSubstate_SuperExitActionsNotExecuted(t *testing.T) { 416 | super, sub := createSuperSubstatePair() 417 | executed := false 418 | super.ExitActions = append(super.ExitActions, actionBehaviour{ 419 | Action: func(_ context.Context, _ ...any) error { 420 | executed = true 421 | return nil 422 | }, 423 | }) 424 | transition := Transition{Source: super.State, Destination: sub.State, Trigger: triggerX} 425 | sub.Exit(context.Background(), transition) 426 | if executed { 427 | t.Error("expected superstate exit actions to not be executed") 428 | } 429 | } 430 | 431 | func Test_stateRepresentation_Exit_Substate_SuperExitActionsExecuted(t *testing.T) { 432 | super, sub := createSuperSubstatePair() 433 | executed := false 434 | super.ExitActions = append(super.ExitActions, actionBehaviour{ 435 | Action: func(_ context.Context, _ ...any) error { 436 | executed = true 437 | return nil 438 | }, 439 | }) 440 | transition := Transition{Source: sub.State, Destination: stateC, Trigger: triggerX} 441 | sub.Exit(context.Background(), transition) 442 | if !executed { 443 | t.Error("expected superstate exit actions to be executed") 444 | } 445 | } 446 | 447 | func Test_stateRepresentation_Exit_ActionsExecuteInOrder(t *testing.T) { 448 | var actual []int 449 | sr := newstateRepresentation(stateB) 450 | sr.ExitActions = append(sr.ExitActions, actionBehaviour{ 451 | Action: func(_ context.Context, _ ...any) error { 452 | actual = append(actual, 0) 453 | return nil 454 | }, 455 | }) 456 | sr.ExitActions = append(sr.ExitActions, actionBehaviour{ 457 | Action: func(_ context.Context, _ ...any) error { 458 | actual = append(actual, 1) 459 | return nil 460 | }, 461 | }) 462 | transition := Transition{Source: stateB, Destination: stateC, Trigger: triggerX} 463 | sr.Exit(context.Background(), transition) 464 | want := []int{0, 1} 465 | if !reflect.DeepEqual(actual, want) { 466 | t.Errorf("expected %v, got %v", want, actual) 467 | } 468 | } 469 | 470 | func Test_stateRepresentation_Exit_Substate_SubstateEntryActionsExecuteBeforeSuperstate(t *testing.T) { 471 | super, sub := createSuperSubstatePair() 472 | var order, subOrder, superOrder int 473 | super.ExitActions = append(super.ExitActions, actionBehaviour{ 474 | Action: func(_ context.Context, _ ...any) error { 475 | order += 1 476 | superOrder = order 477 | return nil 478 | }, 479 | }) 480 | sub.ExitActions = append(sub.ExitActions, actionBehaviour{ 481 | Action: func(_ context.Context, _ ...any) error { 482 | order += 1 483 | subOrder = order 484 | return nil 485 | }, 486 | }) 487 | transition := Transition{Source: sub.State, Destination: stateC, Trigger: triggerX} 488 | sub.Exit(context.Background(), transition) 489 | if subOrder >= superOrder { 490 | t.Error("expected substate exit actions to execute before superstate") 491 | } 492 | } 493 | -------------------------------------------------------------------------------- /testdata/golden/emptyWithInitial.dot: -------------------------------------------------------------------------------- 1 | digraph { 2 | compound=true; 3 | node [shape=Mrecord]; 4 | rankdir="LR"; 5 | 6 | init [label="", shape=point]; 7 | init -> A 8 | } 9 | -------------------------------------------------------------------------------- /testdata/golden/phoneCall.dot: -------------------------------------------------------------------------------- 1 | digraph { 2 | compound=true; 3 | node [shape=Mrecord]; 4 | rankdir="LR"; 5 | 6 | Connected [label="Connected\n----------\nentry / startCallTimer\nexit / func2"]; 7 | subgraph cluster_Connected { 8 | label="Substates of\nConnected"; 9 | style="dashed"; 10 | OnHold [label="OnHold|exit / func6"]; 11 | } 12 | OffHook [label="OffHook"]; 13 | Ringing [label="Ringing"]; 14 | Connected -> OffHook [label="LeftMessage"]; 15 | Connected -> Connected [label="MuteMicrophone\nSetVolume\nUnmuteMicrophone"]; 16 | Connected -> OnHold [label="PlacedOnHold"]; 17 | OffHook -> Ringing [label="CallDialed / func1"]; 18 | OnHold -> PhoneDestroyed [label="PhoneHurledAgainstWall"]; 19 | OnHold -> Connected [label="TakenOffHold"]; 20 | Ringing -> Connected [label="CallConnected"]; 21 | init [label="", shape=point]; 22 | init -> OffHook 23 | } 24 | -------------------------------------------------------------------------------- /testdata/golden/withGuards.dot: -------------------------------------------------------------------------------- 1 | digraph { 2 | compound=true; 3 | node [shape=Mrecord]; 4 | rankdir="LR"; 5 | 6 | A [label="A"]; 7 | subgraph cluster_A { 8 | label="Substates of\nA"; 9 | style="dashed"; 10 | B [label="B"]; 11 | } 12 | A -> D [label="X [func1]"]; 13 | B -> C [label="X [func2]"]; 14 | init [label="", shape=point]; 15 | init -> B 16 | } 17 | -------------------------------------------------------------------------------- /testdata/golden/withInitialState.dot: -------------------------------------------------------------------------------- 1 | digraph { 2 | compound=true; 3 | node [shape=Mrecord]; 4 | rankdir="LR"; 5 | 6 | A [label="A"]; 7 | B [label="B"]; 8 | subgraph cluster_B { 9 | label="Substates of\nB"; 10 | style="dashed"; 11 | "cluster_B-init" [label="", shape=point]; 12 | C [label="C"]; 13 | subgraph cluster_C { 14 | label="Substates of\nC"; 15 | style="dashed"; 16 | "cluster_C-init" [label="", shape=point]; 17 | D [label="D"]; 18 | } 19 | } 20 | "cluster_B-init" -> C [label=""]; 21 | "cluster_C-init" -> D [label=""]; 22 | A -> B [label="X"]; 23 | init [label="", shape=point]; 24 | init -> A 25 | } 26 | -------------------------------------------------------------------------------- /testdata/golden/withSubstate.dot: -------------------------------------------------------------------------------- 1 | digraph { 2 | compound=true; 3 | node [shape=Mrecord]; 4 | rankdir="LR"; 5 | 6 | A [label="A"]; 7 | C [label="C"]; 8 | subgraph cluster_C { 9 | label="Substates of\nC"; 10 | style="dashed"; 11 | B [label="B"]; 12 | } 13 | A -> B [label="Z"]; 14 | B -> A [label="X"]; 15 | C -> C [label="X"]; 16 | C -> A [label="Y"]; 17 | init [label="", shape=point]; 18 | init -> B 19 | } 20 | -------------------------------------------------------------------------------- /testdata/golden/withUnicodeNames.dot: -------------------------------------------------------------------------------- 1 | digraph { 2 | compound=true; 3 | node [shape=Mrecord]; 4 | rankdir="LR"; 5 | 6 | Ĕ [label="Ĕ"]; 7 | ų [label="ų"]; 8 | subgraph cluster_ų { 9 | label="Substates of\nų"; 10 | style="dashed"; 11 | "cluster_ų-init" [label="", shape=point]; 12 | ㇴ [label="ㇴ"]; 13 | } 14 | 𒀄 [label="𒀄"]; 15 | subgraph cluster_𒀄 { 16 | label="Substates of\n𒀄"; 17 | style="dashed"; 18 | ꬠ [label="ꬠ"]; 19 | 1 [label="1"]; 20 | subgraph "cluster_1" { 21 | label="Substates of\n1"; 22 | style="dashed"; 23 | 2 [label="2"]; 24 | } 25 | } 26 | "cluster_ų-init" -> ㇴ [label=""]; 27 | "cluster_ㇴ-init" -> ꬠ [label=""]; 28 | Ĕ -> ų [label="◵ [œ]"]; 29 | init [label="", shape=point]; 30 | init -> Ĕ 31 | } 32 | -------------------------------------------------------------------------------- /triggers.go: -------------------------------------------------------------------------------- 1 | package stateless 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "reflect" 7 | "runtime" 8 | "strings" 9 | ) 10 | 11 | type invocationInfo struct { 12 | Method string 13 | } 14 | 15 | func newinvocationInfo(method any) invocationInfo { 16 | funcName := runtime.FuncForPC(reflect.ValueOf(method).Pointer()).Name() 17 | nameParts := strings.Split(funcName, ".") 18 | var name string 19 | if len(nameParts) != 0 { 20 | name = nameParts[len(nameParts)-1] 21 | } 22 | return invocationInfo{ 23 | Method: name, 24 | } 25 | } 26 | 27 | func (inv invocationInfo) String() string { 28 | if inv.Method != "" { 29 | return inv.Method 30 | } 31 | return "" 32 | } 33 | 34 | type guardCondition struct { 35 | Guard GuardFunc 36 | Description invocationInfo 37 | } 38 | 39 | type transitionGuard struct { 40 | Guards []guardCondition 41 | } 42 | 43 | func newtransitionGuard(guards ...GuardFunc) transitionGuard { 44 | tg := transitionGuard{Guards: make([]guardCondition, len(guards))} 45 | for i, guard := range guards { 46 | tg.Guards[i] = guardCondition{ 47 | Guard: guard, 48 | Description: newinvocationInfo(guard), 49 | } 50 | } 51 | return tg 52 | } 53 | 54 | // GuardConditionsMet is true if all of the guard functions return true. 55 | func (t transitionGuard) GuardConditionMet(ctx context.Context, args ...any) bool { 56 | for _, guard := range t.Guards { 57 | if !guard.Guard(ctx, args...) { 58 | return false 59 | } 60 | } 61 | return true 62 | } 63 | 64 | func (t transitionGuard) UnmetGuardConditions(ctx context.Context, buf []string, args ...any) []string { 65 | if cap(buf) < len(t.Guards) { 66 | buf = make([]string, 0, len(t.Guards)) 67 | } 68 | buf = buf[:0] 69 | for _, guard := range t.Guards { 70 | if !guard.Guard(ctx, args...) { 71 | buf = append(buf, guard.Description.String()) 72 | } 73 | } 74 | return buf 75 | } 76 | 77 | type triggerBehaviour interface { 78 | GuardConditionMet(context.Context, ...any) bool 79 | UnmetGuardConditions(context.Context, []string, ...any) []string 80 | GetTrigger() Trigger 81 | } 82 | 83 | type baseTriggerBehaviour struct { 84 | Guard transitionGuard 85 | Trigger Trigger 86 | } 87 | 88 | func (t *baseTriggerBehaviour) GetTrigger() Trigger { 89 | return t.Trigger 90 | } 91 | 92 | func (t *baseTriggerBehaviour) GuardConditionMet(ctx context.Context, args ...any) bool { 93 | return t.Guard.GuardConditionMet(ctx, args...) 94 | } 95 | 96 | func (t *baseTriggerBehaviour) UnmetGuardConditions(ctx context.Context, buf []string, args ...any) []string { 97 | return t.Guard.UnmetGuardConditions(ctx, buf, args...) 98 | } 99 | 100 | type ignoredTriggerBehaviour struct { 101 | baseTriggerBehaviour 102 | } 103 | 104 | type reentryTriggerBehaviour struct { 105 | baseTriggerBehaviour 106 | Destination State 107 | } 108 | 109 | type transitioningTriggerBehaviour struct { 110 | baseTriggerBehaviour 111 | Destination State 112 | } 113 | 114 | type dynamicTriggerBehaviour struct { 115 | baseTriggerBehaviour 116 | Destination func(context.Context, ...any) (State, error) 117 | } 118 | 119 | type internalTriggerBehaviour struct { 120 | baseTriggerBehaviour 121 | Action ActionFunc 122 | } 123 | 124 | func (t *internalTriggerBehaviour) Execute(ctx context.Context, transition Transition, args ...any) error { 125 | ctx = withTransition(ctx, transition) 126 | return t.Action(ctx, args...) 127 | } 128 | 129 | type triggerBehaviourResult struct { 130 | Handler triggerBehaviour 131 | UnmetGuardConditions []string 132 | } 133 | 134 | // triggerWithParameters associates configured parameters with an underlying trigger value. 135 | type triggerWithParameters struct { 136 | Trigger Trigger 137 | ArgumentTypes []reflect.Type 138 | } 139 | 140 | func (t triggerWithParameters) validateParameters(args ...any) { 141 | if len(args) != len(t.ArgumentTypes) { 142 | panic(fmt.Sprintf("stateless: An unexpected amount of parameters have been supplied. Expecting '%d' but got '%d'.", len(t.ArgumentTypes), len(args))) 143 | } 144 | for i := range t.ArgumentTypes { 145 | tp := reflect.TypeOf(args[i]) 146 | want := t.ArgumentTypes[i] 147 | if !tp.ConvertibleTo(want) { 148 | panic(fmt.Sprintf("stateless: The argument in position '%d' is of type '%v' but must be convertible to '%v'.", i, tp, want)) 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /triggers_test.go: -------------------------------------------------------------------------------- 1 | package stateless 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func Test_invocationInfo_String(t *testing.T) { 8 | tests := []struct { 9 | name string 10 | inv invocationInfo 11 | want string 12 | }{ 13 | {"empty", invocationInfo{}, ""}, 14 | {"named", invocationInfo{Method: "aaa"}, "aaa"}, 15 | } 16 | for _, tt := range tests { 17 | t.Run(tt.name, func(t *testing.T) { 18 | if got := tt.inv.String(); got != tt.want { 19 | t.Errorf("invocationInfo.String() = %v, want %v", got, tt.want) 20 | } 21 | }) 22 | } 23 | } 24 | --------------------------------------------------------------------------------