├── .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 |

2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
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 | 
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(``)
204 | sb.WriteString(html.EscapeString(t))
205 | sb.WriteString(` |
`)
206 | }
207 | if len(transitions.reentry) > 0 {
208 | sb.WriteString(`Reentry |
`)
209 | for _, t := range transitions.reentry {
210 | sb.WriteString(``)
211 | sb.WriteString(html.EscapeString(t))
212 | sb.WriteString(` |
`)
213 | }
214 | }
215 | if len(transitions.internal) > 0 {
216 | sb.WriteString(`Internal |
`)
217 | for _, t := range transitions.internal {
218 | sb.WriteString(``)
219 | sb.WriteString(html.EscapeString(t))
220 | sb.WriteString(` |
`)
221 | }
222 | }
223 | if len(transitions.ignored) > 0 {
224 | sb.WriteString(`Ignored |
`)
225 | for _, t := range transitions.ignored {
226 | sb.WriteString(``)
227 | sb.WriteString(html.EscapeString(t))
228 | sb.WriteString(` |
`)
229 | }
230 | }
231 | 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 |
--------------------------------------------------------------------------------